feat(mcp): add kon-mcp — read-only MCP stdio server over transcripts and tasks

New workspace binary crates/mcp exposes Kon's SQLite store to external
agents (Claude desktop, Cline, any MCP client) without running the Tauri
app. Newline-delimited JSON-RPC 2.0 on stdio, MCP protocol 2024-11-05.

Tools shipped (all read-only):
- list_transcripts — recent transcript summaries, limit 1..200 default 20
- get_transcript   — full text + metadata by id
- search_transcripts — FTS5-backed query, limit 1..100 default 20
- list_tasks       — all tasks (open + done)

No writes. The Tauri app remains the only writer; kon-mcp just opens the
same SQLite file (via kon_storage::init) and reads. Logs land on stderr to
keep stdout clean for the JSON-RPC stream. Smoke-tested end-to-end with
initialize + tools/list over a pipe.

Wire into an MCP client with:
  { "mcpServers": { "kon": { "command": "/path/to/kon-mcp" } } }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 07:52:12 +01:00
parent b8baa65bd2
commit 63e00c15b1
3 changed files with 500 additions and 0 deletions

23
crates/mcp/Cargo.toml Normal file
View File

@@ -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"

432
crates/mcp/src/lib.rs Normal file
View File

@@ -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<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 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 (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>,
}
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<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)),
}
}
#[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"
],
);
}
#[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");
}
}

45
crates/mcp/src/main.rs Normal file
View File

@@ -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(())
}