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:
Sam & Claude 2026-06-13 12:46:54 +02:00
parent 773f7294c1
commit aeecfca454
7 changed files with 1208 additions and 1 deletions

123
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View 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"] }

View 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(())
}
}

View 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
}
},
}
}

View 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())
);
}
}

View 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);
}