The stdio shim: spectra mcp --agent NAME signs with the agent's key and forwards to the daemon (#9) eb675dfa5e18
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key a0c2628bc758
- When
- · 2026-11-02 22:54 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
eb675dfa5e182402855864209047460e5ac235d8f0c306024a2df3703d27eee7- Depends on
- 037c45d8093c 258bb3824813 2fc88a7b3a5c 36537112fcac 571572f5fb2c 5d695d869fbf
- Depended on by
- 862f0db224aa 8d803d858c8c 01320eefd505 eca5af92279b da84e98f104e 39f5c913f617 62d35f1dd866
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 10 atoms, 2 tree operations, 6 files
Browse the tree at this change
added src/cli/mcp.rs +93 −0
| 1 | + | //! `spectra mcp --agent NAME`: the stdio shim (spec §15.1). A client starts |
|
| 2 | + | //! it as an ordinary stdio MCP server; it signs every request with the |
|
| 3 | + | //! agent's key from the agent's own home and forwards it to the daemon over |
|
| 4 | + | //! the Unix socket, so a client's configuration is one line with nothing |
|
| 5 | + | //! secret in it. Nothing is written to stdout but protocol. |
|
| 6 | + | ||
| 7 | + | use super::Ctx; |
|
| 8 | + | use anyhow::{bail, Context, Result}; |
|
| 9 | + | use serde_json::{json, Value as Json}; |
|
| 10 | + | use spectra_core::keys::KeyPair; |
|
| 11 | + | use spectra_core::ledger::Ledger; |
|
| 12 | + | use spectra_mcp::client::{rpc_result, Client}; |
|
| 13 | + | use std::path::PathBuf; |
|
| 14 | + | use std::sync::Arc; |
|
| 15 | + | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; |
|
| 16 | + | use tokio::sync::Mutex; |
|
| 17 | + | ||
| 18 | + | /// The revisions the shim speaks to its client (spec §15.1); 2026-07-28 |
|
| 19 | + | /// requests are passed through as they are. |
|
| 20 | + | pub const STDIO_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"]; |
|
| 21 | + | ||
| 22 | + | pub async fn run(ctx: &Ctx, agent: &str, home: Option<PathBuf>) -> Result<i32> { |
|
| 23 | + | let home = match home { |
|
| 24 | + | Some(h) => h, |
|
| 25 | + | None => { |
|
| 26 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 27 | + | let h: Option<String> = |
|
| 28 | + | sqlx::query_scalar("SELECT home FROM agents WHERE name = ?").bind(agent).fetch_optional(ledger.pool()).await?; |
|
| 29 | + | PathBuf::from(h.with_context(|| format!("no agent named {agent} (spectra agent list)"))?) |
|
| 30 | + | } |
|
| 31 | + | }; |
|
| 32 | + | let key = KeyPair::load_home(&home).with_context(|| format!("reading {agent}'s key in {}", home.display()))?; |
|
| 33 | + | if key.identity.name != agent { |
|
| 34 | + | bail!("{} holds {}'s key, not {agent}'s", home.display(), key.identity.name); |
|
| 35 | + | } |
|
| 36 | + | let client = Arc::new(Client::new(&ctx.socket(), Some(key))); |
|
| 37 | + | let version = Arc::new(Mutex::new(STDIO_VERSIONS[0].to_string())); |
|
| 38 | + | let out = Arc::new(Mutex::new(tokio::io::stdout())); |
|
| 39 | + | let mut lines = BufReader::new(tokio::io::stdin()).lines(); |
|
| 40 | + | let mut tasks = tokio::task::JoinSet::new(); |
|
| 41 | + | while let Some(line) = lines.next_line().await? { |
|
| 42 | + | if line.trim().is_empty() { |
|
| 43 | + | continue; |
|
| 44 | + | } |
|
| 45 | + | let (client, version, out) = (client.clone(), version.clone(), out.clone()); |
|
| 46 | + | // Each request on its own: a `wait` must not hold up the others. |
|
| 47 | + | tasks.spawn(async move { |
|
| 48 | + | if let Some(reply) = forward(&client, &version, &line).await { |
|
| 49 | + | let mut o = out.lock().await; |
|
| 50 | + | let _ = o.write_all(format!("{reply}\n").as_bytes()).await; |
|
| 51 | + | let _ = o.flush().await; |
|
| 52 | + | } |
|
| 53 | + | }); |
|
| 54 | + | } |
|
| 55 | + | while tasks.join_next().await.is_some() {} |
|
| 56 | + | Ok(0) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | /// Forward one message; the reply to write, if it was a request. |
|
| 60 | + | async fn forward(client: &Client, version: &Mutex<String>, line: &str) -> Option<Json> { |
|
| 61 | + | let req: Json = match serde_json::from_str(line) { |
|
| 62 | + | Ok(v) => v, |
|
| 63 | + | Err(e) => return Some(json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("not JSON: {e}") } })), |
|
| 64 | + | }; |
|
| 65 | + | let id = req.get("id").cloned(); |
|
| 66 | + | let method = req.get("method").and_then(Json::as_str).unwrap_or(""); |
|
| 67 | + | let asked_2026 = method == "server/discover" |
|
| 68 | + | || req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str) == Some("2026-07-28"); |
|
| 69 | + | let v = if asked_2026 { |
|
| 70 | + | "2026-07-28".to_string() |
|
| 71 | + | } else if method == "initialize" { |
|
| 72 | + | let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or(STDIO_VERSIONS[2]); |
|
| 73 | + | let chosen = if STDIO_VERSIONS.contains(&asked) { asked } else { STDIO_VERSIONS[0] }; |
|
| 74 | + | *version.lock().await = chosen.to_string(); |
|
| 75 | + | chosen.to_string() |
|
| 76 | + | } else { |
|
| 77 | + | version.lock().await.clone() |
|
| 78 | + | }; |
|
| 79 | + | let r = match client.mcp(&v, &req).await { |
|
| 80 | + | Ok(r) => r, |
|
| 81 | + | Err(e) => return id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32000, "message": format!("Spectra is not reachable: {e:#}. Is `spectra serve` running?") } })), |
|
| 82 | + | }; |
|
| 83 | + | let id = id?; |
|
| 84 | + | if r.status == 202 { |
|
| 85 | + | return None; |
|
| 86 | + | } |
|
| 87 | + | match rpc_result(&r) { |
|
| 88 | + | Ok(v) => Some(v), |
|
| 89 | + | Err(e) => Some( |
|
| 90 | + | json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32000, "message": format!("Spectra answered {}: {e:#}", r.status) } }), |
|
| 91 | + | ), |
|
| 92 | + | } |
|
| 93 | + | } |
added tests/cli_mcp.rs +116 −0
| 1 | + | //! The stdio shim, `spectra mcp --agent NAME` (spec §15.1), driven over |
|
| 2 | + | //! pipes as a client would: the handshake, tools, concurrency (a `wait` in |
|
| 3 | + | //! flight does not hold up other requests), nothing but protocol on stdout, |
|
| 4 | + | //! and plain answers when the daemon is not running. |
|
| 5 | + | ||
| 6 | + | mod common; |
|
| 7 | + | ||
| 8 | + | use common::{add_agent, hermetic, setup, Serving}; |
|
| 9 | + | use serde_json::{json, Value}; |
|
| 10 | + | use spectra_mcp::client::Client; |
|
| 11 | + | use std::process::Stdio; |
|
| 12 | + | use std::time::Duration; |
|
| 13 | + | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; |
|
| 14 | + | use tokio::process::{ChildStdin, ChildStdout}; |
|
| 15 | + | ||
| 16 | + | struct Shim { |
|
| 17 | + | child: tokio::process::Child, |
|
| 18 | + | stdin: ChildStdin, |
|
| 19 | + | stdout: Lines<BufReader<ChildStdout>>, |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | impl Shim { |
|
| 23 | + | fn start(d: &std::path::Path, agent: &str, extra: &[&str]) -> Shim { |
|
| 24 | + | let mut cmd = tokio::process::Command::from(hermetic(d)); |
|
| 25 | + | cmd.args(["mcp", "--agent", agent]) |
|
| 26 | + | .args(extra) |
|
| 27 | + | .stdin(Stdio::piped()) |
|
| 28 | + | .stdout(Stdio::piped()) |
|
| 29 | + | .stderr(Stdio::piped()) |
|
| 30 | + | .kill_on_drop(true); |
|
| 31 | + | let mut child = cmd.spawn().unwrap(); |
|
| 32 | + | let stdin = child.stdin.take().unwrap(); |
|
| 33 | + | let stdout = BufReader::new(child.stdout.take().unwrap()).lines(); |
|
| 34 | + | Shim { child, stdin, stdout } |
|
| 35 | + | } |
|
| 36 | + | async fn send(&mut self, v: Value) { |
|
| 37 | + | self.stdin.write_all(format!("{v}\n").as_bytes()).await.unwrap(); |
|
| 38 | + | self.stdin.flush().await.unwrap(); |
|
| 39 | + | } |
|
| 40 | + | async fn recv(&mut self) -> Value { |
|
| 41 | + | let line = tokio::time::timeout(Duration::from_secs(20), self.stdout.next_line()) |
|
| 42 | + | .await |
|
| 43 | + | .expect("an answer within 20 s") |
|
| 44 | + | .unwrap() |
|
| 45 | + | .expect("a line"); |
|
| 46 | + | serde_json::from_str(&line).unwrap_or_else(|e| panic!("stdout carries only JSON-RPC, got {line:?}: {e}")) |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | fn call(id: u64, name: &str, args: Value) -> Value { |
|
| 51 | + | json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args } }) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | #[tokio::test] |
|
| 55 | + | async fn a_client_speaks_to_spectra_through_the_shim() { |
|
| 56 | + | let dir = setup(); |
|
| 57 | + | let d = dir.path(); |
|
| 58 | + | add_agent(d, "claude-oxygen", &[]); |
|
| 59 | + | let kimi = add_agent(d, "kimi-oxygen", &[]); |
|
| 60 | + | let srv = Serving::start(d); |
|
| 61 | + | let mut s = Shim::start(d, "claude-oxygen", &[]); |
|
| 62 | + | ||
| 63 | + | s.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1" } } })).await; |
|
| 64 | + | let init = s.recv().await; |
|
| 65 | + | assert_eq!(init["result"]["protocolVersion"], "2025-06-18"); |
|
| 66 | + | s.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await; |
|
| 67 | + | s.send(json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" })).await; |
|
| 68 | + | let list = s.recv().await; |
|
| 69 | + | assert_eq!(list["id"], 2, "a notification gets no answer"); |
|
| 70 | + | assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 11); |
|
| 71 | + | s.send(call(3, "brief", json!({}))).await; |
|
| 72 | + | let brief = s.recv().await; |
|
| 73 | + | assert!(brief["result"]["content"][0]["text"].as_str().unwrap().contains("You are claude-oxygen · Andy McCall's agent")); |
|
| 74 | + | ||
| 75 | + | // A wait in flight, then another request: the second is answered first. |
|
| 76 | + | s.send(call(10, "wait", json!({ "timeout_s": 15 }))).await; |
|
| 77 | + | tokio::time::sleep(Duration::from_millis(200)).await; |
|
| 78 | + | s.send(json!({ "jsonrpc": "2.0", "id": 11, "method": "ping" })).await; |
|
| 79 | + | assert_eq!(s.recv().await["id"], 11, "ping is not held up by the wait"); |
|
| 80 | + | let k = Client::new(&srv.socket, Some(kimi)); |
|
| 81 | + | let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@claude-oxygen ready when you are", "channel": "general" } } }); |
|
| 82 | + | assert_eq!(k.mcp("2025-11-25", &say).await.unwrap().status, 200); |
|
| 83 | + | let woke = s.recv().await; |
|
| 84 | + | assert_eq!(woke["id"], 10); |
|
| 85 | + | assert!(woke["result"]["content"][0]["text"].as_str().unwrap().contains("ready when you are"), "{woke}"); |
|
| 86 | + | ||
| 87 | + | drop(s); |
|
| 88 | + | srv.stop(); |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | #[tokio::test] |
|
| 92 | + | async fn the_shim_says_plainly_what_is_wrong() { |
|
| 93 | + | let dir = setup(); |
|
| 94 | + | let d = dir.path(); |
|
| 95 | + | add_agent(d, "claude-oxygen", &[]); |
|
| 96 | + | add_agent(d, "kimi-oxygen", &[]); |
|
| 97 | + | ||
| 98 | + | // No daemon: an answer, not silence. |
|
| 99 | + | let mut s = Shim::start(d, "claude-oxygen", &[]); |
|
| 100 | + | s.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25" } })).await; |
|
| 101 | + | let r = s.recv().await; |
|
| 102 | + | assert!(r["error"]["message"].as_str().unwrap().contains("spectra serve"), "{r}"); |
|
| 103 | + | drop(s); |
|
| 104 | + | ||
| 105 | + | let out = hermetic(d).args(["mcp", "--agent", "nobody"]).stdin(Stdio::null()).output().unwrap(); |
|
| 106 | + | assert!(!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("no agent named nobody")); |
|
| 107 | + | assert!(out.stdout.is_empty(), "errors never go to stdout"); |
|
| 108 | + | let other = d.join("agents/kimi-oxygen"); |
|
| 109 | + | let out = |
|
| 110 | + | hermetic(d).args(["mcp", "--agent", "claude-oxygen", "--home", other.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); |
|
| 111 | + | assert!( |
|
| 112 | + | !out.status.success() && String::from_utf8_lossy(&out.stderr).contains("holds kimi-oxygen's key"), |
|
| 113 | + | "{}", |
|
| 114 | + | String::from_utf8_lossy(&out.stderr) |
|
| 115 | + | ); |
|
| 116 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: the stdio shim, `spectra mcp --agent NAME` (#9).** Any MCP client that starts stdio servers connects with one line and nothing secret in it: the shim signs every request with the agent's key from its own home and forwards it to the daemon. It speaks 2025-11-25, 2025-06-18 and 2025-03-26 (and passes 2026-07-28 through), answers requests concurrently so a `wait` holds nothing up, writes only protocol to stdout, and says plainly when the daemon is not running. |
|
| 5 | 6 | - **Added: the 0.1 tools (#8).** `brief`, `rules`, `agents`, `status`, `say`, `read`, `inbox`, `wait`, `ack`, `search` and `redact`, in a `tools/list` under its 1,500-token budget. Every call is recorded as `tool.called` with its arguments and result kept in full, content addressed. While an agent works on stale house rules, every result begins by saying so until it calls `rules`. Messages come back delimited with their author. `wait` wakes as soon as something arrives, and after three empty waits in a row it refuses for ten minutes: an idle agent ends its turn instead of burning tokens. `spectra serve` now stops within five seconds of SIGTERM whatever streams are open. |
|
| 6 | 7 | - **Added: `spectra serve` and Tuner, MCP over the Unix socket and loopback (#7).** The daemon holds the ledger and serves `POST /mcp` on `spectra.sock` (reading the caller's uid) and on `127.0.0.1:7350`. Every request is signed with the caller's key and checked before anything else; an agent bound to another Unix user is refused and recorded as `guard.impersonation`, and `--learn-uid` binds an agent on its first connection. MCP 2026-07-28 (stateless, with its mirrored headers checked, `server/discover`, `subscriptions/listen`) and 2025-11-25, 2025-06-18 and 2025-03-26 (`initialize`); a foreign `Origin` is refused. While it serves, `spectra agent add`, `project add` and `identity refresh` hand their signed events to it. It writes a checkpoint when it stops. |
|
| 7 | 8 | - **Added: channels, threads, messages, the inbox and search (#6).** `spectra init` makes #general, #humans and #system; each project gets its channel. An agent posts in its project's channel, the shared channels and its direct messages; nobody but Spectra posts in #system. Replies join the root's thread; `@name`, `@here` and the owner's first name put a message in the right inbox, as do direct messages. Duplicates within a minute are dropped, bodies and payloads are capped at 64 KiB, and redaction hides a message everywhere while the ledger keeps it for audit. Full-text search (FTS5, stemmed) covers what the reader may see. |
|
| … | 6 unchanged lines |
modified Cargo.toml +1 −1
| … | 65 unchanged lines |
||
| 66 | 66 | anyhow.workspace = true |
|
| 67 | 67 | clap.workspace = true |
|
| 68 | 68 | serde_json.workspace = true |
|
| 69 | - | tokio.workspace = true |
|
| 69 | + | tokio = { workspace = true, features = ["io-std"] } |
|
| 70 | 70 | hex.workspace = true |
|
| 71 | 71 | toml.workspace = true |
|
| 72 | 72 | axum.workspace = true |
|
| … | 17 unchanged lines |
modified docs/plan.md +1 −1
| … | 21 unchanged lines |
||
| 22 | 22 | - [x] #6 Channels, threads, messages, inbox and search — done 2026-11-02: spectra-core::conversation (post, read, inbox, ack, redact, search, direct channels), shared by tools, API and CLI. |
|
| 23 | 23 | - [x] #7 Tuner: MCP over Streamable HTTP — done 2026-11-02: spectra serve, socket + loopback, signed requests, both revisions, refusals, owner events through the daemon; end-to-end tests drive the real binary. |
|
| 24 | 24 | - [x] #8 The 0.1 tools — done 2026-11-02: eleven tools through one invoke wrapper (record, rules line); wait with the idle guard; end-to-end tests with two agents. Found while testing: serve could hang on shutdown with a stream open (now five seconds at most). |
|
| 25 | - | - [ ] #9 The stdio shim |
|
| 25 | + | - [x] #9 The stdio shim — done 2026-11-02: spectra mcp --agent NAME, driven over pipes in tests/cli_mcp.rs. Real Claude Code, Antigravity and Kimi connections are checked in #13 with Andy, since they need his MCP configuration. |
|
| 26 | 26 | - [ ] #10 Radar |
|
| 27 | 27 | - [ ] #11 JSON API and spectra-api types |
|
| 28 | 28 | - [ ] #12 Human CLI |
|
| … | 8 unchanged lines |
modified src/cli/mod.rs +11 −0
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod identity; |
|
| 9 | 9 | pub mod json; |
|
| 10 | 10 | pub mod marks; |
|
| 11 | + | pub mod mcp; |
|
| 11 | 12 | pub mod project; |
|
| 12 | 13 | pub mod serve; |
|
| 13 | 14 | pub mod ui; |
|
| … | 47 unchanged lines |
||
| 61 | 62 | #[arg(long)] |
|
| 62 | 63 | insecure: bool, |
|
| 63 | 64 | }, |
|
| 65 | + | /// The stdio MCP server for one agent: signs with its key and forwards to the daemon |
|
| 66 | + | Mcp { |
|
| 67 | + | /// The agent it speaks for |
|
| 68 | + | #[arg(long)] |
|
| 69 | + | agent: String, |
|
| 70 | + | /// The agent's home (default: the one recorded when it was added) |
|
| 71 | + | #[arg(long)] |
|
| 72 | + | home: Option<PathBuf>, |
|
| 73 | + | }, |
|
| 64 | 74 | /// The checkouts agents work in: add, list |
|
| 65 | 75 | Project { |
|
| 66 | 76 | #[command(subcommand)] |
|
| … | 221 unchanged lines |
||
| 288 | 298 | init_logging(); |
|
| 289 | 299 | runtime()?.block_on(serve::serve(&ctx, &bind, insecure)) |
|
| 290 | 300 | } |
|
| 301 | + | Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)), |
|
| 291 | 302 | Command::Project { command } => runtime()?.block_on(async { |
|
| 292 | 303 | match command { |
|
| 293 | 304 | ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await, |
|
| … | 38 unchanged lines |