feat(mcp): add colibri-mcp crate — MCP bridge for editor integration (Sam & Claude)
- 7 Phase 1 tools: status, snapshot, list_tasks, list_skills, create_task, intake_task, set_cost_mode - Write tools gated behind COLIBRI_MCP_WRITE=1 (default read-only) - stdio JSON-RPC server for MCP protocol compliance - 10 integration tests with mock Unix socket server - Uses ColibriCommand/ColibriResponse (post-rename from PR #30) - Design doc: docs/CLAWDIE-STUDIO-PROPOSAL.md
This commit is contained in:
parent
773f7294c1
commit
21800a8775
8 changed files with 1212 additions and 4 deletions
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -38,6 +38,56 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
|
|
@ -187,6 +237,46 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clawdie"
|
||||
version = "0.0.1"
|
||||
|
|
@ -290,6 +380,21 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-mcp"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"colibri-client",
|
||||
"colibri-daemon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-runtime"
|
||||
version = "0.0.1"
|
||||
|
|
@ -322,6 +427,12 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.1"
|
||||
|
|
@ -1089,6 +1200,12 @@ version = "2.12.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
|
|
@ -1362,6 +1479,12 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/clawdie"]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/clawdie"]
|
||||
|
||||
[package]
|
||||
name = "colibri"
|
||||
|
|
|
|||
|
|
@ -4,17 +4,18 @@ The Clawdie control plane core — a small, cross-platform (FreeBSD + Linux) Rus
|
|||
daemon that unifies coordination (task board, agent registry, skills catalog)
|
||||
with cache-first cost discipline (byte-stable prompt prefixes, cache-hit metering).
|
||||
|
||||
**Status:** 8 crates; workspace gates are expected to be fmt/clippy/test/release green. Avoid fixed test-count status here — run the gate commands below for the current count. Phase 3 (coordination core) is in progress.
|
||||
**Status:** 11 crates; workspace gates are expected to be fmt/clippy/test/release green. Avoid fixed test-count status here — run the gate commands below for the current count. Phase 3 (coordination core) is in progress.
|
||||
|
||||
Next ISO integration plan: `docs/ISO-INTEGRATION-PLAN.md`.
|
||||
ISO acceptance runbook: `docs/ISO-ACCEPTANCE-RUNBOOK.md`.
|
||||
Clawdie Studio/Zed proposal: `docs/CLAWDIE-STUDIO-PROPOSAL.md`.
|
||||
|
||||
## Workspace — 10 crates
|
||||
## Workspace — 11 crates
|
||||
|
||||
| Crate | Role |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clawdie` | Simplified operator agent: glasspane + herdr + DeepSeek/Telegram in one small binary (build-flag configured). See `docs/CLAWDIE-AGENT-WIKI.md`. |
|
||||
| `colibri-mcp` | MCP bridge for editor integration (Zed, Claude Code) via stdio JSON-RPC |
|
||||
| `clawdie` | Simplified operator agent: glasspane + DeepSeek/Telegram in one small binary (build-flag configured). See `docs/CLAWDIE-AGENT-WIKI.md`. |
|
||||
| `colibri-contracts` | JSON schema contracts (golden tests) |
|
||||
| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering |
|
||||
| `colibri-runtime` | Host status ingestion, runtime inventory |
|
||||
|
|
|
|||
24
crates/colibri-mcp/Cargo.toml
Normal file
24
crates/colibri-mcp/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "colibri-mcp"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "MCP (Model Context Protocol) bridge wrapping colibri-client for editor integration"
|
||||
|
||||
[[bin]]
|
||||
name = "colibri-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
colibri-client = { path = "../colibri-client" }
|
||||
colibri-daemon = { path = "../colibri-daemon" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "time"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
435
crates/colibri-mcp/src/lib.rs
Normal file
435
crates/colibri-mcp/src/lib.rs
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
//! colibri-mcp — MCP (Model Context Protocol) bridge for Colibri.
|
||||
//!
|
||||
//! Wraps `colibri-client` so that MCP-capable editors (Zed, Cursor, Windsurf)
|
||||
//! can query and control the Colibri daemon through the standard MCP
|
||||
//! stdin/stdout JSON-RPC transport.
|
||||
//!
|
||||
//! ## Tools
|
||||
//!
|
||||
//! | Tool | Access | Description |
|
||||
//! |-----------------------|------------|-------------------------------------|
|
||||
//! | `colibri_status` | read-only | Daemon status (agents, sessions) |
|
||||
//! | `colibri_snapshot` | read-only | Glasspane snapshot (pane states) |
|
||||
//! | `colibri_list_tasks` | read-only | Tasks by status |
|
||||
//! | `colibri_list_skills` | read-only | Registered skills catalog |
|
||||
//! | `colibri_create_task` | write-gated| Create a task |
|
||||
//! | `colibri_intake_task` | write-gated| Submit intake task with capabilities|
|
||||
//! | `colibri_set_cost_mode` | write-gated | Switch cost mode (fast/smart/max) |
|
||||
//!
|
||||
//! Write tools require `COLIBRI_MCP_WRITE=1`.
|
||||
|
||||
mod protocol;
|
||||
|
||||
pub use protocol::{McpError, McpRequest, McpResponse, McpResult, McpValue, ToolHandler};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colibri_client::DaemonClient;
|
||||
use colibri_daemon::DaemonConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::protocol::{ToolSchema, ToolsResult};
|
||||
|
||||
/// Default socket resolution: `COLIBRI_MCP_SOCKET` → `COLIBRI_DAEMON_SOCKET`
|
||||
/// → `DaemonConfig::from_env().socket_path`.
|
||||
pub fn resolve_socket_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("COLIBRI_MCP_SOCKET") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
DaemonConfig::from_env().socket_path
|
||||
}
|
||||
|
||||
/// Configuration for the MCP server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServerConfig {
|
||||
pub socket_path: PathBuf,
|
||||
pub write_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for McpServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
socket_path: resolve_socket_path(),
|
||||
write_enabled: std::env::var("COLIBRI_MCP_WRITE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build and return the list of MCP tools exposed by this server.
|
||||
pub fn tool_list() -> Vec<Value> {
|
||||
vec![
|
||||
json_tool(
|
||||
"colibri_status",
|
||||
"Get Colibri daemon status: agents, sessions, host, cost mode, paths",
|
||||
None,
|
||||
),
|
||||
json_tool(
|
||||
"colibri_snapshot",
|
||||
"Get Glasspane snapshot — all pane states, agent states, stall flags",
|
||||
None,
|
||||
),
|
||||
json_tool(
|
||||
"colibri_list_tasks",
|
||||
"List coordination tasks, optionally filtered by status",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Filter by status: queued, claimed, done, failed",
|
||||
"enum": ["queued", "claimed", "done", "failed"]
|
||||
}
|
||||
}
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_list_skills",
|
||||
"List registered skills in the Colibri catalog",
|
||||
None,
|
||||
),
|
||||
json_tool(
|
||||
"colibri_create_task",
|
||||
"Create a task on the coordination board (requires COLIBRI_MCP_WRITE=1)",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string", "description": "Task title" },
|
||||
"description": { "type": "string", "description": "Optional task description" }
|
||||
},
|
||||
"required": ["title"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_intake_task",
|
||||
"Submit an intake task with capability requirements (requires COLIBRI_MCP_WRITE=1)",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string", "description": "Task title" },
|
||||
"description": { "type": "string", "description": "Optional task description" },
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Required capabilities (e.g. [\"freebsd\", \"sqlite\"])"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_set_cost_mode",
|
||||
"Switch daemon cost mode: fast, smart, or max (requires COLIBRI_MCP_WRITE=1)",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["fast", "smart", "max"],
|
||||
"description": "Cost mode to activate"
|
||||
}
|
||||
},
|
||||
"required": ["mode"]
|
||||
})),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn json_tool(name: &str, description: &str, input_schema: Option<Value>) -> Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("name".to_string(), Value::String(name.to_string()));
|
||||
obj.insert(
|
||||
"description".to_string(),
|
||||
Value::String(description.to_string()),
|
||||
);
|
||||
obj.insert(
|
||||
"inputSchema".to_string(),
|
||||
input_schema.unwrap_or_else(|| serde_json::json!({ "type": "object", "properties": {} })),
|
||||
);
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Dispatch a tool call to the daemon via `colibri-client`.
|
||||
pub async fn dispatch_tool(
|
||||
client: &DaemonClient,
|
||||
config: &McpServerConfig,
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
) -> McpResult {
|
||||
debug!(tool = name, "dispatching tool");
|
||||
match name {
|
||||
"colibri_status" => {
|
||||
let data = client.status().await.map_err(map_client_error)?;
|
||||
Ok(tool_text(data))
|
||||
}
|
||||
"colibri_snapshot" => {
|
||||
let data = client
|
||||
.glasspane_snapshot()
|
||||
.await
|
||||
.map_err(map_client_error)?;
|
||||
Ok(tool_text(data))
|
||||
}
|
||||
"colibri_list_tasks" => {
|
||||
let status = arguments
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let data = client.list_tasks(status).await.map_err(map_client_error)?;
|
||||
Ok(tool_text(data))
|
||||
}
|
||||
"colibri_list_skills" => {
|
||||
let data = client.list_skills().await.map_err(map_client_error)?;
|
||||
Ok(tool_text(data))
|
||||
}
|
||||
"colibri_create_task" => {
|
||||
require_write(config)?;
|
||||
let title = require_string(arguments, "title")?;
|
||||
let description = optional_string(arguments, "description");
|
||||
let data = client
|
||||
.create_task(title, description)
|
||||
.await
|
||||
.map_err(map_client_error)?;
|
||||
Ok(tool_text(data))
|
||||
}
|
||||
"colibri_intake_task" => {
|
||||
require_write(config)?;
|
||||
let title = require_string(arguments, "title")?;
|
||||
let description = optional_string(arguments, "description");
|
||||
let capabilities = arguments
|
||||
.get("capabilities")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let data = client
|
||||
.intake_task(title, description, capabilities)
|
||||
.await
|
||||
.map_err(map_client_error)?;
|
||||
Ok(tool_text(data))
|
||||
}
|
||||
"colibri_set_cost_mode" => {
|
||||
require_write(config)?;
|
||||
let mode = require_string(arguments, "mode")?;
|
||||
validate_cost_mode(&mode)?;
|
||||
let response = client
|
||||
.send(&colibri_daemon::ColibriCommand::SetCostMode { mode: mode.clone() })
|
||||
.await
|
||||
.map_err(map_client_error)?;
|
||||
if !response.ok {
|
||||
return Err(McpError::internal(format!(
|
||||
"daemon rejected cost mode change: {}",
|
||||
response.error.unwrap_or_else(|| "unknown".to_string())
|
||||
)));
|
||||
}
|
||||
Ok(tool_text(serde_json::json!({
|
||||
"mode": mode,
|
||||
"acknowledged": true,
|
||||
"note": "Cost mode change is runtime-only/status-intent until live config mutation exists."
|
||||
})))
|
||||
}
|
||||
other => Err(McpError::not_found(format!("unknown tool: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn require_write(config: &McpServerConfig) -> Result<(), McpError> {
|
||||
if config.write_enabled {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(McpError::permission(
|
||||
"write tools require COLIBRI_MCP_WRITE=1",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn require_string(args: &Value, key: &str) -> Result<String, McpError> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| McpError::invalid_params(format!("missing required parameter: {key}")))
|
||||
}
|
||||
|
||||
fn optional_string(args: &Value, key: &str) -> Option<String> {
|
||||
args.get(key).and_then(|v| v.as_str()).map(String::from)
|
||||
}
|
||||
|
||||
fn validate_cost_mode(mode: &str) -> Result<(), McpError> {
|
||||
match mode {
|
||||
"fast" | "smart" | "max" => Ok(()),
|
||||
_ => Err(McpError::invalid_params(format!(
|
||||
"invalid cost mode '{mode}': must be fast, smart, or max"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_client_error(e: colibri_client::ClientError) -> McpError {
|
||||
McpError::internal(e.to_string())
|
||||
}
|
||||
|
||||
fn tool_text(data: impl Serialize) -> McpValue {
|
||||
let text = serde_json::to_string_pretty(&data).unwrap_or_else(|_| "{}".to_string());
|
||||
ToolSchema::text(text).into()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the MCP server loop over stdin/stdout.
|
||||
///
|
||||
/// Reads newline-delimited JSON-RPC requests from stdin, dispatches tool
|
||||
/// calls, and writes responses to stdout. Logging goes to stderr only.
|
||||
pub async fn serve(config: McpServerConfig) -> std::io::Result<()> {
|
||||
info!(
|
||||
socket = %config.socket_path.display(),
|
||||
write_enabled = config.write_enabled,
|
||||
"colibri-mcp server starting"
|
||||
);
|
||||
|
||||
let client = DaemonClient::new(&config.socket_path);
|
||||
let mut handler = StdioHandler::new();
|
||||
|
||||
loop {
|
||||
let line = match handler.read_line().await {
|
||||
Ok(Some(line)) => line,
|
||||
Ok(None) => {
|
||||
debug!("stdin closed, shutting down");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "error reading stdin");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let response = handle_request(&client, &config, &line).await;
|
||||
handler.write_response(&response).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
client: &DaemonClient,
|
||||
config: &McpServerConfig,
|
||||
line: &str,
|
||||
) -> McpResponse {
|
||||
let request: McpRequest = match serde_json::from_str(line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return McpResponse::error(
|
||||
None,
|
||||
McpError::invalid_request(format!("invalid JSON-RPC: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let id = request.id.clone();
|
||||
let result = handle_method(client, config, &request).await;
|
||||
|
||||
McpResponse::from_result(id, result)
|
||||
}
|
||||
|
||||
async fn handle_method(
|
||||
client: &DaemonClient,
|
||||
config: &McpServerConfig,
|
||||
request: &McpRequest,
|
||||
) -> McpResult {
|
||||
match request.method.as_str() {
|
||||
"initialize" => Ok(serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "colibri-mcp",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
})
|
||||
.into()),
|
||||
"initialized" | "notifications/initialized" => {
|
||||
debug!("client initialized");
|
||||
Ok(serde_json::Value::Null.into())
|
||||
}
|
||||
"tools/list" => Ok(ToolsResult::new(tool_list()).into()),
|
||||
"tools/call" => {
|
||||
let params = request
|
||||
.params
|
||||
.clone()
|
||||
.ok_or_else(|| McpError::invalid_params("missing tool call params".to_string()))?;
|
||||
|
||||
let call: ToolCallParams = serde_json::from_value(params)
|
||||
.map_err(|e| McpError::invalid_params(e.to_string()))?;
|
||||
|
||||
let result = dispatch_tool(client, config, &call.name, &call.arguments).await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
method if method.starts_with("resources/") || method.starts_with("prompts/") => Err(
|
||||
McpError::not_found(format!("method not supported: {method}")),
|
||||
),
|
||||
other => Err(McpError::not_found(format!("unknown method: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolCallParams {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
}
|
||||
|
||||
/// Read/write JSON-RPC lines over stdin/stdout.
|
||||
struct StdioHandler {
|
||||
stdin: tokio::io::BufReader<tokio::io::Stdin>,
|
||||
stdout: tokio::io::Stdout,
|
||||
}
|
||||
|
||||
impl StdioHandler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stdin: tokio::io::BufReader::new(tokio::io::stdin()),
|
||||
stdout: tokio::io::stdout(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_line(&mut self) -> std::io::Result<Option<String>> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut buf = String::new();
|
||||
let n = self.stdin.read_line(&mut buf).await?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let trimmed = buf.trim_end().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(trimmed))
|
||||
}
|
||||
|
||||
async fn write_response(&mut self, response: &McpResponse) -> std::io::Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut json = serde_json::to_string(response)?;
|
||||
json.push('\n');
|
||||
self.stdout.write_all(json.as_bytes()).await?;
|
||||
self.stdout.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
86
crates/colibri-mcp/src/main.rs
Normal file
86
crates/colibri-mcp/src/main.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! colibri-mcp — MCP server binary entry point.
|
||||
//!
|
||||
//! Run as a subprocess by MCP-capable editors (Zed, Cursor, Windsurf).
|
||||
//! Communicates via stdin/stdout JSON-RPC.
|
||||
//!
|
||||
//! Configuration:
|
||||
//! COLIBRI_MCP_SOCKET — override daemon socket path
|
||||
//! COLIBRI_DAEMON_SOCKET — fallback daemon socket path
|
||||
//! COLIBRI_MCP_WRITE=1 — enable write tools (create/intake/set-cost-mode)
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use colibri_mcp::{serve, McpServerConfig};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "colibri-mcp")]
|
||||
#[command(about = "MCP bridge for the Colibri control plane")]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
/// Path to the colibri-daemon Unix socket
|
||||
#[arg(long)]
|
||||
socket: Option<String>,
|
||||
|
||||
/// Enable write tools (create-task, intake-task, set-cost-mode)
|
||||
#[arg(long)]
|
||||
write: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<SubCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SubCmd {
|
||||
/// List available MCP tools (for debugging)
|
||||
Tools,
|
||||
/// Print the resolved socket path and exit
|
||||
SocketPath,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let mut config = McpServerConfig::default();
|
||||
if let Some(ref s) = cli.socket {
|
||||
config.socket_path = std::path::PathBuf::from(s);
|
||||
}
|
||||
if cli.write {
|
||||
config.write_enabled = true;
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Some(SubCmd::Tools) => {
|
||||
for tool in colibri_mcp::tool_list() {
|
||||
if let Some(name) = tool.get("name").and_then(|v| v.as_str()) {
|
||||
let desc = tool
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
println!(" {name:30} {desc}");
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some(SubCmd::SocketPath) => {
|
||||
println!("{}", config.socket_path.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => match serve(config).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("colibri-mcp: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
288
crates/colibri-mcp/src/protocol.rs
Normal file
288
crates/colibri-mcp/src/protocol.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//! Minimal MCP (Model Context Protocol) JSON-RPC types.
|
||||
//!
|
||||
//! Implements the subset of MCP needed for a tools-only server:
|
||||
//! - `initialize` / `initialized` handshake
|
||||
//! - `tools/list` — enumerate available tools
|
||||
//! - `tools/call` — invoke a tool
|
||||
//!
|
||||
//! Transport: newline-delimited JSON over stdin/stdout.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A JSON-RPC 2.0 error.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// Standard JSON-RPC error codes.
|
||||
impl McpError {
|
||||
pub const PARSE_ERROR: i32 = -32700;
|
||||
pub const INVALID_REQUEST: i32 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i32 = -32601;
|
||||
pub const INVALID_PARAMS: i32 = -32602;
|
||||
pub const INTERNAL_ERROR: i32 = -32603;
|
||||
|
||||
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_error(msg: impl Into<String>) -> Self {
|
||||
Self::new(Self::PARSE_ERROR, msg)
|
||||
}
|
||||
|
||||
pub fn invalid_request(msg: impl Into<String>) -> Self {
|
||||
Self::new(Self::INVALID_REQUEST, msg)
|
||||
}
|
||||
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
Self::new(Self::METHOD_NOT_FOUND, msg)
|
||||
}
|
||||
|
||||
pub fn invalid_params(msg: impl Into<String>) -> Self {
|
||||
Self::new(Self::INVALID_PARAMS, msg)
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::new(Self::INTERNAL_ERROR, msg)
|
||||
}
|
||||
|
||||
pub fn permission(msg: impl Into<String>) -> Self {
|
||||
Self::new(-32603, msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request id — can be a number, string, or null per JSON-RPC spec.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
Number(i64),
|
||||
String(String),
|
||||
#[default]
|
||||
Null,
|
||||
}
|
||||
|
||||
/// A JSON-RPC request/notification.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct McpRequest {
|
||||
pub jsonrpc: String,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub id: RequestId,
|
||||
}
|
||||
|
||||
/// A JSON-RPC response — either success with `result` or error.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpResponse {
|
||||
pub jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<RequestId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<McpError>,
|
||||
}
|
||||
|
||||
impl McpResponse {
|
||||
pub fn success(id: RequestId, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".into(),
|
||||
id: Some(id),
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: Option<RequestId>, error: McpError) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_result(id: RequestId, result: McpResult) -> Self {
|
||||
match result {
|
||||
Ok(value) => Self::success(id, value.0),
|
||||
Err(e) => Self::error(Some(id), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type for MCP operations.
|
||||
pub type McpResult = Result<McpValue, McpError>;
|
||||
|
||||
/// A wrapper around `serde_json::Value` for MCP result payloads.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct McpValue(Value);
|
||||
|
||||
impl McpValue {
|
||||
pub fn into_inner(self) -> Value {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Value> for McpValue {
|
||||
fn from(v: Value) -> Self {
|
||||
Self(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<McpValue> for Value {
|
||||
fn from(v: McpValue) -> Self {
|
||||
v.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a `tools/list` call.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolsResult {
|
||||
pub tools: Vec<Value>,
|
||||
}
|
||||
|
||||
impl ToolsResult {
|
||||
pub fn new(tools: Vec<Value>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolsResult> for McpValue {
|
||||
fn from(r: ToolsResult) -> Self {
|
||||
serde_json::to_value(r)
|
||||
.map(McpValue)
|
||||
.unwrap_or(McpValue(Value::Null))
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a `tools/call` — content blocks.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolSchema {
|
||||
pub content: Vec<ContentBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
impl ToolSchema {
|
||||
pub fn text(text: String) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::text(text)],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(text: String) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::text(text)],
|
||||
is_error: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolSchema> for McpValue {
|
||||
fn from(t: ToolSchema) -> Self {
|
||||
serde_json::to_value(t)
|
||||
.map(McpValue)
|
||||
.unwrap_or(McpValue(Value::Null))
|
||||
}
|
||||
}
|
||||
|
||||
/// A content block in a tool result.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
impl ContentBlock {
|
||||
pub fn text(text: String) -> Self {
|
||||
Self {
|
||||
block_type: "text".into(),
|
||||
text: Some(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for tool handler functions/async blocks.
|
||||
pub type ToolHandler = Box<
|
||||
dyn Fn(
|
||||
&serde_json::Value,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpResult> + Send + '_>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialize_success_response() {
|
||||
let resp = McpResponse::success(RequestId::Number(1), serde_json::json!({"ok": true}));
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("\"result\""));
|
||||
assert!(json.contains("\"id\":1"));
|
||||
assert!(!json.contains("\"error\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_error_response() {
|
||||
let resp = McpResponse::error(
|
||||
Some(RequestId::String("abc".into())),
|
||||
McpError::not_found("no such tool"),
|
||||
);
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("\"error\""));
|
||||
assert!(json.contains("\"id\":\"abc\""));
|
||||
assert!(json.contains("-32601"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_request() {
|
||||
let raw = r#"{"jsonrpc":"2.0","method":"tools/list","id":42}"#;
|
||||
let req: McpRequest = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(req.method, "tools/list");
|
||||
assert_eq!(req.id, RequestId::Number(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_tool_call() {
|
||||
let raw = r#"{"jsonrpc":"2.0","method":"tools/call","id":7,"params":{"name":"colibri_status","arguments":{}}}"#;
|
||||
let req: McpRequest = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(req.method, "tools/call");
|
||||
assert!(req.params.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_text_schema_serializes() {
|
||||
let schema = ToolSchema::text("hello world".into());
|
||||
let json = serde_json::to_string(&schema).unwrap();
|
||||
assert!(json.contains("\"type\":\"text\""));
|
||||
assert!(json.contains("hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_id_variants() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<RequestId>("42").unwrap(),
|
||||
RequestId::Number(42)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<RequestId>("\"abc\"").unwrap(),
|
||||
RequestId::String("abc".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
251
crates/colibri-mcp/tests/tool_dispatch.rs
Normal file
251
crates/colibri-mcp/tests/tool_dispatch.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
//! Integration tests for colibri-mcp tool dispatch.
|
||||
//!
|
||||
//! Each test spins up a one-shot Unix socket mock server that responds to a
|
||||
//! single ColibriCommand, then verifies that the MCP tool dispatch returns the
|
||||
//! expected content.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colibri_client::DaemonClient;
|
||||
use colibri_mcp::{dispatch_tool, McpServerConfig};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
||||
async fn mock_daemon(response: serde_json::Value) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("colibri-mcp-test-{}.sock", uuid::Uuid::new_v4()));
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let server_path = path.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
handle_one_request(stream, response).await;
|
||||
let _ = tokio::fs::remove_file(server_path).await;
|
||||
});
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
async fn handle_one_request(stream: UnixStream, response: serde_json::Value) {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
let json = format!("{}\n", serde_json::to_string(&response).unwrap());
|
||||
writer.write_all(json.as_bytes()).await.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
}
|
||||
|
||||
fn config(socket: PathBuf, write_enabled: bool) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
socket_path: socket,
|
||||
write_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_status_returns_daemon_data() {
|
||||
let socket = mock_daemon(json!({
|
||||
"ok": true,
|
||||
"data": { "host": "osa", "agents": 2 }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, false);
|
||||
|
||||
let result = dispatch_tool(&client, &cfg, "colibri_status", &json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result_val: serde_json::Value = serde_json::to_value(&result).unwrap();
|
||||
let text = result_val["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("osa"));
|
||||
assert!(text.contains("\"agents\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_list_tasks_passes_status_filter() {
|
||||
let socket = mock_daemon(json!({
|
||||
"ok": true,
|
||||
"data": [{ "id": "t1", "status": "queued", "title": "test" }]
|
||||
}))
|
||||
.await;
|
||||
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, false);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_list_tasks",
|
||||
&json!({ "status": "queued" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result_val: serde_json::Value = serde_json::to_value(&result).unwrap();
|
||||
let text = result_val["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("t1"));
|
||||
assert!(text.contains("queued"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_create_task_blocked_without_write() {
|
||||
let socket = mock_daemon(json!({ "ok": true, "data": {} })).await;
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, false);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_create_task",
|
||||
&json!({ "title": "test task" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().message;
|
||||
assert!(err_msg.contains("COLIBRI_MCP_WRITE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_create_task_works_with_write() {
|
||||
let socket = mock_daemon(json!({
|
||||
"ok": true,
|
||||
"data": { "id": "t99", "status": "queued" }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, true);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_create_task",
|
||||
&json!({ "title": "test task", "description": "from mcp" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result_val: serde_json::Value = serde_json::to_value(&result).unwrap();
|
||||
let text = result_val["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("t99"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_intake_task_passes_capabilities() {
|
||||
let socket = mock_daemon(json!({
|
||||
"ok": true,
|
||||
"data": { "id": "t100", "status": "intake" }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, true);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_intake_task",
|
||||
&json!({
|
||||
"title": "freebsd task",
|
||||
"capabilities": ["freebsd", "sqlite"]
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result_val: serde_json::Value = serde_json::to_value(&result).unwrap();
|
||||
let text = result_val["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("t100"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_set_cost_mode_validates_mode() {
|
||||
let socket = mock_daemon(json!({ "ok": true, "data": {} })).await;
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, true);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_set_cost_mode",
|
||||
&json!({ "mode": "invalid_mode" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().message;
|
||||
assert!(err_msg.contains("invalid cost mode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_set_cost_mode_blocked_without_write() {
|
||||
let socket = mock_daemon(json!({ "ok": true, "data": {} })).await;
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, false);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_set_cost_mode",
|
||||
&json!({ "mode": "fast" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().message.contains("COLIBRI_MCP_WRITE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_set_cost_mode_dispatches_to_daemon() {
|
||||
let socket = mock_daemon(json!({ "ok": true, "data": null })).await;
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, true);
|
||||
|
||||
let result = dispatch_tool(
|
||||
&client,
|
||||
&cfg,
|
||||
"colibri_set_cost_mode",
|
||||
&json!({ "mode": "fast" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result_val: serde_json::Value = serde_json::to_value(&result).unwrap();
|
||||
let text = result_val["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("fast"));
|
||||
assert!(text.contains("acknowledged"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_unknown_returns_error() {
|
||||
let socket = mock_daemon(json!({ "ok": true, "data": {} })).await;
|
||||
let client = DaemonClient::new(&socket);
|
||||
let cfg = config(socket, false);
|
||||
|
||||
let result = dispatch_tool(&client, &cfg, "bogus_tool", &json!({})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().message.contains("unknown tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_list_has_all_phase1_tools() {
|
||||
let tools = colibri_mcp::tool_list();
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.filter_map(|t| t.get("name").and_then(|v| v.as_str()))
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&"colibri_status"));
|
||||
assert!(names.contains(&"colibri_snapshot"));
|
||||
assert!(names.contains(&"colibri_list_tasks"));
|
||||
assert!(names.contains(&"colibri_list_skills"));
|
||||
assert!(names.contains(&"colibri_create_task"));
|
||||
assert!(names.contains(&"colibri_intake_task"));
|
||||
assert!(names.contains(&"colibri_set_cost_mode"));
|
||||
assert_eq!(names.len(), 7);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue