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