Files
Lumotia/crates/mcp/src/lib.rs
Jake 9b0067b4c0
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
Land release blocker fixes and workspace cleanup
2026-04-23 00:16:09 +01:00

532 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
pub id: Option<Value>,
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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
/// 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<JsonRpcResponse> {
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 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 (1200, 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 (1100, 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<Value, JsonRpcError> {
#[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<Value, JsonRpcError> {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
limit: Option<i64>,
}
// 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 = kon_storage::list_transcripts(pool, limit)
.await
.map_err(|e| error(-32603, format!("DB error: {e}")))?;
let summaries: Vec<Value> = 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<Value, JsonRpcError> {
#[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<Value, JsonRpcError> {
#[derive(Deserialize)]
struct Args {
query: String,
#[serde(default)]
limit: Option<i64>,
}
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<Value> = 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<Value, JsonRpcError> {
let rows = kon_storage::list_tasks(pool)
.await
.map_err(|e| error(-32603, format!("DB error: {e}")))?;
let summaries: Vec<Value> = 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<String> = 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();
kon_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");
}
}