From 21800a87752296409236dbe6fd08853a1548ef00 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 13 Jun 2026 12:46:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20add=20colibri-mcp=20crate=20?= =?UTF-8?q?=E2=80=94=20MCP=20bridge=20for=20editor=20integration=20(Sam=20?= =?UTF-8?q?&=20Claude)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 123 ++++++ Cargo.toml | 2 +- README.md | 7 +- crates/colibri-mcp/Cargo.toml | 24 ++ crates/colibri-mcp/src/lib.rs | 435 ++++++++++++++++++++++ crates/colibri-mcp/src/main.rs | 86 +++++ crates/colibri-mcp/src/protocol.rs | 288 ++++++++++++++ crates/colibri-mcp/tests/tool_dispatch.rs | 251 +++++++++++++ 8 files changed, 1212 insertions(+), 4 deletions(-) create mode 100644 crates/colibri-mcp/Cargo.toml create mode 100644 crates/colibri-mcp/src/lib.rs create mode 100644 crates/colibri-mcp/src/main.rs create mode 100644 crates/colibri-mcp/src/protocol.rs create mode 100644 crates/colibri-mcp/tests/tool_dispatch.rs diff --git a/Cargo.lock b/Cargo.lock index ab8aea1..768a9f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b90f536..8c69e7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index a0d9fa7..79354df 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/colibri-mcp/Cargo.toml b/crates/colibri-mcp/Cargo.toml new file mode 100644 index 0000000..710b3f6 --- /dev/null +++ b/crates/colibri-mcp/Cargo.toml @@ -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"] } diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs new file mode 100644 index 0000000..b860d66 --- /dev/null +++ b/crates/colibri-mcp/src/lib.rs @@ -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 { + 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 { + 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 { + 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 { + 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, + 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> { + 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(()) + } +} diff --git a/crates/colibri-mcp/src/main.rs b/crates/colibri-mcp/src/main.rs new file mode 100644 index 0000000..69f603a --- /dev/null +++ b/crates/colibri-mcp/src/main.rs @@ -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, + + /// Enable write tools (create-task, intake-task, set-cost-mode) + #[arg(long)] + write: bool, + + #[command(subcommand)] + command: Option, +} + +#[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 + } + }, + } +} diff --git a/crates/colibri-mcp/src/protocol.rs b/crates/colibri-mcp/src/protocol.rs new file mode 100644 index 0000000..c6854a2 --- /dev/null +++ b/crates/colibri-mcp/src/protocol.rs @@ -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, +} + +/// 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) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn parse_error(msg: impl Into) -> Self { + Self::new(Self::PARSE_ERROR, msg) + } + + pub fn invalid_request(msg: impl Into) -> Self { + Self::new(Self::INVALID_REQUEST, msg) + } + + pub fn not_found(msg: impl Into) -> Self { + Self::new(Self::METHOD_NOT_FOUND, msg) + } + + pub fn invalid_params(msg: impl Into) -> Self { + Self::new(Self::INVALID_PARAMS, msg) + } + + pub fn internal(msg: impl Into) -> Self { + Self::new(Self::INTERNAL_ERROR, msg) + } + + pub fn permission(msg: impl Into) -> 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, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +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, 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; + +/// 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 for McpValue { + fn from(v: Value) -> Self { + Self(v) + } +} + +impl From 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, +} + +impl ToolsResult { + pub fn new(tools: Vec) -> Self { + Self { tools } + } +} + +impl From 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +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 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, +} + +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 + 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::("42").unwrap(), + RequestId::Number(42) + ); + assert_eq!( + serde_json::from_str::("\"abc\"").unwrap(), + RequestId::String("abc".into()) + ); + } +} diff --git a/crates/colibri-mcp/tests/tool_dispatch.rs b/crates/colibri-mcp/tests/tool_dispatch.rs new file mode 100644 index 0000000..065832c --- /dev/null +++ b/crates/colibri-mcp/tests/tool_dispatch.rs @@ -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); +}