OXYGENHUB
# 87f601ccce6d spectra / tests / cli_mcp.rs
5.0 KiB · Rust · last changed The stdio shim: spectra mcp --agent NAME signs with the agent's key and forwards to the daemon (#9) 2 days ago source blame history raw
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
}