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:
23
crates/mcp/Cargo.toml
Normal file
23
crates/mcp/Cargo.toml
Normal 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
432
crates/mcp/src/lib.rs
Normal 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 (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>,
|
||||
}
|
||||
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
45
crates/mcp/src/main.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user