diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 0000000..f66286d --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "kon-mcp" +version = "0.1.0" +edition = "2021" +description = "Read-only MCP stdio server exposing Kon transcripts and tasks to external agents" + +[[bin]] +name = "kon-mcp" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +kon-storage = { path = "../storage" } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt", "io-std", "io-util"] } +anyhow = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 0000000..6e561ab --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,432 @@ +//! Minimal Model Context Protocol server exposing Kon's local SQLite store. +//! +//! Scope: **read-only** tools. An external agent (Claude desktop, Cline, any +//! MCP-capable client) can list / search / fetch transcripts and list tasks. +//! No writes — Kon's Tauri app remains the only writer. +//! +//! Transport: newline-delimited JSON-RPC 2.0 over stdio, per the stdio +//! transport spec. Server spec version: 2024-11-05. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::SqlitePool; + +pub const PROTOCOL_VERSION: &str = "2024-11-05"; +pub const SERVER_NAME: &str = "kon-mcp"; +pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + #[serde(default, rename = "jsonrpc")] + pub jsonrpc: Option, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: &'static str, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// Dispatch a single JSON-RPC message. Returns `None` when the message is a +/// notification (no `id`) — MCP clients send `notifications/initialized` +/// after the initialize handshake, which we ignore. +pub async fn handle_message(pool: &SqlitePool, raw: Value) -> Option { + let request: JsonRpcRequest = match serde_json::from_value(raw) { + Ok(req) => req, + Err(err) => { + return Some(error_response( + Value::Null, + -32700, + format!("Parse error: {err}"), + )); + } + }; + + // Notifications: no id, no response. + let Some(id) = request.id.clone() else { + return None; + }; + + let outcome = match request.method.as_str() { + "initialize" => Ok(initialize_result()), + "tools/list" => Ok(tools_list_result()), + "tools/call" => call_tool(pool, request.params).await, + // Clients sometimes ping — respond trivially rather than erroring. + "ping" => Ok(json!({})), + other => Err(error(-32601, format!("Method not found: {other}"))), + }; + + Some(match outcome { + Ok(result) => JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + }, + Err(err) => JsonRpcResponse { + jsonrpc: "2.0", + id, + result: None, + error: Some(err), + }, + }) +} + +fn initialize_result() -> Value { + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + }, + "instructions": + "Read-only access to Kon's local transcript history and task list. \ + All data stays on the user's machine.", + }) +} + +fn tools_list_result() -> Value { + json!({ + "tools": [ + { + "name": "list_transcripts", + "description": "List recent transcripts from Kon's local history, most recent first. \ + Returns summaries (id, title, created_at, duration, preview).", + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Max transcripts to return (1–200, default 20).", + "minimum": 1, + "maximum": 200, + }, + }, + }, + }, + { + "name": "get_transcript", + "description": "Fetch the full text and metadata of a single transcript by id.", + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Transcript id (UUID) from list_transcripts / search_transcripts.", + }, + }, + }, + }, + { + "name": "search_transcripts", + "description": "Full-text search across Kon's transcripts. Returns matching summaries.", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "Search query (FTS5 syntax supported).", + }, + "limit": { + "type": "integer", + "description": "Max matches to return (1–100, default 20).", + "minimum": 1, + "maximum": 100, + }, + }, + }, + }, + { + "name": "list_tasks", + "description": "List tasks from Kon's task store. Returns both open and completed.", + "inputSchema": { + "type": "object", + "properties": {}, + }, + }, + ], + }) +} + +async fn call_tool(pool: &SqlitePool, params: Value) -> Result { + #[derive(Deserialize)] + struct CallParams { + name: String, + #[serde(default)] + arguments: Value, + } + + let call: CallParams = serde_json::from_value(params) + .map_err(|e| error(-32602, format!("Invalid params: {e}")))?; + + match call.name.as_str() { + "list_transcripts" => list_transcripts_tool(pool, call.arguments).await, + "get_transcript" => get_transcript_tool(pool, call.arguments).await, + "search_transcripts" => search_transcripts_tool(pool, call.arguments).await, + "list_tasks" => list_tasks_tool(pool).await, + other => Err(error(-32602, format!("Unknown tool: {other}"))), + } +} + +async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result { + #[derive(Deserialize, Default)] + struct Args { + #[serde(default)] + limit: Option, + } + let args: Args = serde_json::from_value(args).unwrap_or_default(); + let limit = args.limit.unwrap_or(20).clamp(1, 200); + + let rows = kon_storage::list_transcripts(pool, limit) + .await + .map_err(|e| error(-32603, format!("DB error: {e}")))?; + + let summaries: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.id, + "title": r.title, + "createdAt": r.created_at, + "source": r.source, + "duration": r.duration, + "starred": r.starred, + "language": r.language, + "preview": preview(&r.text, 240), + }) + }) + .collect(); + + Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap())) +} + +async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result { + #[derive(Deserialize)] + struct Args { + id: String, + } + let args: Args = serde_json::from_value(args) + .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?; + + let row = kon_storage::get_transcript(pool, &args.id) + .await + .map_err(|e| error(-32603, format!("DB error: {e}")))? + .ok_or_else(|| error(-32000, format!("Transcript {} not found", args.id)))?; + + let value = json!({ + "id": row.id, + "title": row.title, + "text": row.text, + "createdAt": row.created_at, + "source": row.source, + "duration": row.duration, + "engine": row.engine, + "modelId": row.model_id, + "language": row.language, + "starred": row.starred, + "manualTags": row.manual_tags, + "template": row.template, + }); + + Ok(text_content(serde_json::to_string_pretty(&value).unwrap())) +} + +async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result { + #[derive(Deserialize)] + struct Args { + query: String, + #[serde(default)] + limit: Option, + } + let args: Args = serde_json::from_value(args) + .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?; + let limit = args.limit.unwrap_or(20).clamp(1, 100); + + let rows = kon_storage::search_transcripts(pool, &args.query, limit) + .await + .map_err(|e| error(-32603, format!("DB error: {e}")))?; + + let summaries: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.id, + "title": r.title, + "createdAt": r.created_at, + "preview": preview(&r.text, 240), + "source": r.source, + }) + }) + .collect(); + + Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap())) +} + +async fn list_tasks_tool(pool: &SqlitePool) -> Result { + let rows = kon_storage::list_tasks(pool) + .await + .map_err(|e| error(-32603, format!("DB error: {e}")))?; + + let summaries: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.id, + "text": r.text, + "bucket": r.bucket, + "done": r.done, + "doneAt": r.done_at, + "createdAt": r.created_at, + "parentTaskId": r.parent_task_id, + }) + }) + .collect(); + + Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap())) +} + +fn text_content(text: String) -> Value { + json!({ + "content": [{ "type": "text", "text": text }], + }) +} + +fn preview(text: &str, limit: usize) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= limit { + return trimmed.to_string(); + } + let mut out: String = trimmed.chars().take(limit).collect(); + out.push('…'); + out +} + +fn error(code: i32, message: String) -> JsonRpcError { + JsonRpcError { + code, + message, + data: None, + } +} + +fn error_response(id: Value, code: i32, message: String) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: None, + error: Some(error(code, message)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn initialize_returns_server_info() { + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {}, + }); + + // No pool needed — initialize doesn't hit the DB. + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let response = handle_message(&pool, request).await.expect("has response"); + + let result = response.result.expect("ok"); + assert_eq!(result["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(result["serverInfo"]["name"], SERVER_NAME); + } + + #[tokio::test] + async fn notification_without_id_produces_no_response() { + let request = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }); + + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let response = handle_message(&pool, request).await; + + assert!(response.is_none()); + } + + #[tokio::test] + async fn tools_list_advertises_four_tools() { + let request = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + }); + + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let response = handle_message(&pool, request).await.expect("has response"); + + let tools = response.result.expect("ok")["tools"].as_array().unwrap().clone(); + let names: Vec = tools + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + names, + vec![ + "list_transcripts", + "get_transcript", + "search_transcripts", + "list_tasks" + ], + ); + } + + #[tokio::test] + async fn unknown_method_returns_method_not_found_error() { + let request = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "not_a_real_method", + }); + + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let response = handle_message(&pool, request).await.expect("has response"); + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32601); + } + + #[test] + fn preview_truncates_at_boundary() { + let long: String = "abcdefghij".repeat(30); + let result = preview(&long, 20); + let char_count = result.chars().count(); + assert_eq!(char_count, 21); // 20 + ellipsis + assert!(result.ends_with('…')); + } + + #[test] + fn preview_keeps_short_text_intact() { + assert_eq!(preview("hello", 20), "hello"); + assert_eq!(preview(" padded ", 20), "padded"); + } +} diff --git a/crates/mcp/src/main.rs b/crates/mcp/src/main.rs new file mode 100644 index 0000000..e1b68dd --- /dev/null +++ b/crates/mcp/src/main.rs @@ -0,0 +1,45 @@ +//! Stdio entry point for kon-mcp. Reads newline-delimited JSON-RPC messages +//! from stdin, dispatches via `kon_mcp::handle_message`, writes responses to +//! stdout. Logs land on stderr so they don't collide with the JSON-RPC stream. + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let db_path = kon_storage::database_path(); + eprintln!( + "[kon-mcp] opening Kon database at {}", + db_path.display() + ); + let pool = kon_storage::init(&db_path).await?; + eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin"); + + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut stdout = tokio::io::stdout(); + + while let Some(line) = lines.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let raw: serde_json::Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(err) => { + eprintln!("[kon-mcp] ignoring malformed line: {err}"); + continue; + } + }; + + let Some(response) = kon_mcp::handle_message(&pool, raw).await else { + continue; // notification — no reply + }; + + let payload = serde_json::to_string(&response)?; + stdout.write_all(payload.as_bytes()).await?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + } + + Ok(()) +}