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

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