532 lines
17 KiB
Rust
532 lines
17 KiB
Rust
//! 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 (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<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");
|
||
}
|
||
}
|