From c006f8236ccac2d3e3ce9a51cb2728e72291add8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sun, 1 Mar 2026 00:06:32 +0100 Subject: [PATCH] feat: add mcp server --- src/cli.rs | 3 + src/commands.rs | 1 + src/main.rs | 1 + src/mcp.rs | 274 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 src/mcp.rs diff --git a/src/cli.rs b/src/cli.rs index 085e221..9962ec3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -34,6 +34,9 @@ pub enum Command { /// remove all completed todos Purge, + + /// start mcp server over stdio + McpServe, } #[derive(Args)] diff --git a/src/commands.rs b/src/commands.rs index 669b06a..18906ac 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -18,6 +18,7 @@ pub async fn run(cmd: Command, ctx: &Ctx<'_>) -> Result<()> { Command::Remove(args) => remove(ctx, args).await, Command::Tags => tags(ctx).await, Command::Purge => purge(ctx).await, + Command::McpServe => crate::mcp::serve(ctx.pool).await, } } diff --git a/src/main.rs b/src/main.rs index 7b312fe..18f5b60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod commands; mod db; mod display; +mod mcp; mod model; use clap::Parser; diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..f69e5d5 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,274 @@ +use std::io::{self, BufRead, Write}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::db::{self, ListFilters, Pool}; +use crate::model::Priority; + +#[derive(Deserialize)] +struct Request { + id: Option, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Serialize)] +struct Response { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct RpcError { + code: i32, + message: String, +} + +impl Response { + fn ok(id: Option, result: Value) -> Self { + Self { jsonrpc: "2.0", id, result: Some(result), error: None } + } + + fn err(id: Option, code: i32, message: String) -> Self { + Self { jsonrpc: "2.0", id, result: None, error: Some(RpcError { code, message }) } + } +} + +fn tool_schemas() -> Value { + json!({ + "tools": [ + { + "name": "todo_add", + "description": "add a new todo", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "todo text" }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "tags": { "type": "array", "items": { "type": "string" } }, + "no_project": { "type": "boolean" } + }, + "required": ["text"] + } + }, + { + "name": "todo_list", + "description": "list todos", + "inputSchema": { + "type": "object", + "properties": { + "tags": { "type": "array", "items": { "type": "string" } }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "search": { "type": "string" }, + "here": { "type": "boolean", "description": "only show todos for current repo" }, + "all": { "type": "boolean", "description": "include completed todos" } + } + } + }, + { + "name": "todo_done", + "description": "mark a todo as done", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer" } + }, + "required": ["id"] + } + }, + { + "name": "todo_edit", + "description": "edit a todo", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "text": { "type": "string" }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "tags": { "type": "array", "items": { "type": "string" } }, + "untags": { "type": "array", "items": { "type": "string" } } + }, + "required": ["id"] + } + }, + { + "name": "todo_remove", + "description": "remove a todo permanently", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer" } + }, + "required": ["id"] + } + }, + { + "name": "todo_tags", + "description": "list all tags in use", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "todo_purge", + "description": "remove all completed todos", + "inputSchema": { "type": "object", "properties": {} } + } + ] + }) +} + +// helper to build a tool result +fn tool_result(text: String) -> Value { + json!({ "content": [{ "type": "text", "text": text }], "isError": false }) +} + +fn tool_error(msg: String) -> Value { + json!({ "content": [{ "type": "text", "text": msg }], "isError": true }) +} + +fn str_array(v: &Value, key: &str) -> Vec { + v.get(key) + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()) + .unwrap_or_default() +} + +async fn dispatch(pool: &Pool, name: &str, args: &Value) -> Value { + let result = match name { + "todo_add" => { + let text = match args.get("text").and_then(|v| v.as_str()) { + Some(t) => t, + None => return tool_error("missing required arg: text".into()), + }; + let priority = args + .get("priority") + .and_then(|v| v.as_str()) + .unwrap_or("medium") + .parse::() + .unwrap_or_default(); + let tags = str_array(args, "tags"); + let no_project = args.get("no_project").and_then(|v| v.as_bool()).unwrap_or(false); + let project = if no_project { None } else { db::detect_project() }; + db::insert_todo(pool, text, &priority, project.as_deref(), &tags) + .await + .map(|t| serde_json::to_string(&t).unwrap()) + } + "todo_list" => { + let tags = str_array(args, "tags"); + let priority_str = args.get("priority").and_then(|v| v.as_str()).map(String::from); + let priority = priority_str.as_deref().and_then(|s| s.parse::().ok()); + let search = args.get("search").and_then(|v| v.as_str()).map(String::from); + let here = args.get("here").and_then(|v| v.as_bool()).unwrap_or(false); + let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false); + let project = if here { db::detect_project() } else { None }; + db::list_todos( + pool, + ListFilters { + tags: &tags, + priority: priority.as_ref(), + search: search.as_deref(), + project: project.as_deref(), + include_done: all, + }, + ) + .await + .map(|t| serde_json::to_string(&t).unwrap()) + } + "todo_done" => { + let id = match args.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => return tool_error("missing required arg: id".into()), + }; + db::complete_todo(pool, id).await.map(|t| serde_json::to_string(&t).unwrap()) + } + "todo_edit" => { + let id = match args.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => return tool_error("missing required arg: id".into()), + }; + let text = args.get("text").and_then(|v| v.as_str()).map(String::from); + let priority_str = args.get("priority").and_then(|v| v.as_str()).map(String::from); + let priority = priority_str.as_deref().and_then(|s| s.parse::().ok()); + let tags = str_array(args, "tags"); + let untags = str_array(args, "untags"); + db::update_todo(pool, id, text.as_deref(), priority.as_ref(), &tags, &untags) + .await + .map(|t| serde_json::to_string(&t).unwrap()) + } + "todo_remove" => { + let id = match args.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => return tool_error("missing required arg: id".into()), + }; + db::delete_todo(pool, id).await.map(|t| serde_json::to_string(&t).unwrap()) + } + "todo_tags" => db::list_tags(pool).await.map(|t| serde_json::to_string(&t).unwrap()), + "todo_purge" => db::purge_completed(pool) + .await + .map(|n| format!("purged {n} completed todo(s)")), + _ => return tool_error(format!("unknown tool: {name}")), + }; + + match result { + Ok(text) => tool_result(text), + Err(e) => tool_error(e.to_string()), + } +} + +pub async fn serve(pool: &Pool) -> Result<()> { + let stdin = io::stdin().lock(); + let mut stdout = io::stdout().lock(); + + for line in stdin.lines() { + let line = line?; + if line.is_empty() { + continue; + } + + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::err(None, -32700, format!("parse error: {e}")); + writeln!(stdout, "{}", serde_json::to_string(&resp)?)?; + stdout.flush()?; + continue; + } + }; + + // notifications have no id and expect no response + if req.id.is_none() { + continue; + } + + let resp = match req.method.as_str() { + "initialize" => Response::ok( + req.id, + json!({ + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "todo", "version": env!("CARGO_PKG_VERSION") } + }), + ), + "tools/list" => Response::ok(req.id, tool_schemas()), + "tools/call" => { + let name = req.params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = req.params.get("arguments").cloned().unwrap_or(json!({})); + let result = dispatch(pool, name, &args).await; + Response::ok(req.id, result) + } + _ => Response::err(req.id, -32601, format!("method not found: {}", req.method)), + }; + + writeln!(stdout, "{}", serde_json::to_string(&resp)?)?; + stdout.flush()?; + } + + Ok(()) +}