//! Minimal Model Context Protocol server exposing Magnotia'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 — Magnotia'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 = "magnotia-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 id = request.id.clone()?; 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 Magnotia'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 Magnotia'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 Magnotia'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 Magnotia'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, } // The `arguments` field in CallParams defaults to `Value::Null` // when a client omits it entirely. `serde_json::from_value` does // not accept Null as an empty object, so we short-circuit that // case before deserialising — a missing `arguments` still falls // back to defaults (the common case for list_transcripts), while // a genuinely malformed payload returns -32602 per the Invalid // arguments contract the other handlers use. let args: Args = if args.is_null() { Args::default() } else { serde_json::from_value(args) .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))? }; let limit = args.limit.unwrap_or(20).clamp(1, 200); let rows = magnotia_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 = magnotia_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 = magnotia_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 = magnotia_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)), } } /// Build a JSON-RPC 2.0 Parse Error response (code -32700, id null), /// for use by the stdio transport when a raw line fails to parse as /// JSON at all. `handle_message` covers the shape-mismatch case; this /// helper covers the `serde_json::from_str` failure in `main.rs` so /// clients receive a well-formed JSON-RPC reply instead of silence /// (2026-04-22 review MAJOR). pub fn parse_error_response(detail: &str) -> JsonRpcResponse { error_response(Value::Null, -32700, format!("Parse error: {detail}")) } #[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" ], ); } #[test] fn parse_error_response_has_jsonrpc_2_0_shape() { let resp = parse_error_response("expected value at line 1 column 1"); assert_eq!(resp.jsonrpc, "2.0"); assert_eq!(resp.id, Value::Null); assert!(resp.result.is_none()); let err = resp .error .expect("parse_error_response must carry an error"); assert_eq!(err.code, -32700); assert!(err.message.contains("Parse error")); assert!(err.message.contains("expected value")); } #[tokio::test] async fn list_transcripts_accepts_omitted_arguments() { // Regression for the review-of-review: tools/call requests // that omit `arguments` arrive with `Value::Null`. The // malformed-params fix must not reject those — it is the // common shape for an empty call, equivalent to defaults. let request = json!({ "jsonrpc": "2.0", "id": 98, "method": "tools/call", "params": { "name": "list_transcripts", // `arguments` omitted }, }); let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); magnotia_storage::migrations::run_migrations(&pool) .await .unwrap(); let response = handle_message(&pool, request).await.expect("has response"); assert!( response.error.is_none(), "omitted arguments must not error, got: {:?}", response.error ); assert!(response.result.is_some()); } #[tokio::test] async fn list_transcripts_rejects_malformed_params_with_invalid_arguments() { // Regression for the 2026-04-22 review MAJOR: previously the // handler did `from_value(args).unwrap_or_default()`, so // `{"limit": "not-a-number"}` silently became `limit = 20`. // Every other handler returns -32602 on shape mismatch; this // one must now do the same. let request = json!({ "jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": { "name": "list_transcripts", "arguments": { "limit": "twenty" }, }, }); let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let response = handle_message(&pool, request).await.expect("has response"); assert!(response.result.is_none()); let err = response.error.expect("expected error"); assert_eq!(err.code, -32602, "invalid arguments must surface as -32602"); assert!(err.message.contains("Invalid arguments")); } #[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"); } }