feat: add mcp server
This commit is contained in:
@@ -34,6 +34,9 @@ pub enum Command {
|
||||
|
||||
/// remove all completed todos
|
||||
Purge,
|
||||
|
||||
/// start mcp server over stdio
|
||||
McpServe,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod cli;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod display;
|
||||
mod mcp;
|
||||
mod model;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
274
src/mcp.rs
Normal file
274
src/mcp.rs
Normal file
@@ -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<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(())
|
||||
}
|
||||
Reference in New Issue
Block a user