OXYGENHUB

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