Files
todo-mcp/src/mcp.rs

275 lines
10 KiB
Rust

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<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Serialize)]
struct Response {
jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RpcError>,
}
#[derive(Serialize)]
struct RpcError {
code: i32,
message: String,
}
impl Response {
fn ok(id: Option<Value>, result: Value) -> Self {
Self { jsonrpc: "2.0", id, result: Some(result), error: None }
}
fn err(id: Option<Value>, 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<String> {
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::<Priority>()
.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::<Priority>().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::<Priority>().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(())
}