OXYGENHUB
◆ main spectra / tests / cli_mcp.rs
5.1 KiB · Rust · last changed Fix the shim test's unused field that the gate caught after #9 was pushed 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
    /// Kept so the shim is killed when the test drops it (`kill_on_drop`).
18
    _child: tokio::process::Child,
19
    stdin: ChildStdin,
20
    stdout: Lines<BufReader<ChildStdout>>,
21
}
22
23
impl Shim {
24
    fn start(d: &std::path::Path, agent: &str, extra: &[&str]) -> Shim {
25
        let mut cmd = tokio::process::Command::from(hermetic(d));
26
        cmd.args(["mcp", "--agent", agent])
27
            .args(extra)
28
            .stdin(Stdio::piped())
29
            .stdout(Stdio::piped())
30
            .stderr(Stdio::piped())
31
            .kill_on_drop(true);
32
        let mut child = cmd.spawn().unwrap();
33
        let stdin = child.stdin.take().unwrap();
34
        let stdout = BufReader::new(child.stdout.take().unwrap()).lines();
35
        Shim { _child: child, stdin, stdout }
36
    }
37
    async fn send(&mut self, v: Value) {
38
        self.stdin.write_all(format!("{v}\n").as_bytes()).await.unwrap();
39
        self.stdin.flush().await.unwrap();
40
    }
41
    async fn recv(&mut self) -> Value {
42
        let line = tokio::time::timeout(Duration::from_secs(20), self.stdout.next_line())
43
            .await
44
            .expect("an answer within 20 s")
45
            .unwrap()
46
            .expect("a line");
47
        serde_json::from_str(&line).unwrap_or_else(|e| panic!("stdout carries only JSON-RPC, got {line:?}: {e}"))
48
    }
49
}
50
51
fn call(id: u64, name: &str, args: Value) -> Value {
52
    json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args } })
53
}
54
55
#[tokio::test]
56
async fn a_client_speaks_to_spectra_through_the_shim() {
57
    let dir = setup();
58
    let d = dir.path();
59
    add_agent(d, "claude-oxygen", &[]);
60
    let kimi = add_agent(d, "kimi-oxygen", &[]);
61
    let srv = Serving::start(d);
62
    let mut s = Shim::start(d, "claude-oxygen", &[]);
63
64
    s.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1" } } })).await;
65
    let init = s.recv().await;
66
    assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
67
    s.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await;
68
    s.send(json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" })).await;
69
    let list = s.recv().await;
70
    assert_eq!(list["id"], 2, "a notification gets no answer");
71
    assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 11);
72
    s.send(call(3, "brief", json!({}))).await;
73
    let brief = s.recv().await;
74
    assert!(brief["result"]["content"][0]["text"].as_str().unwrap().contains("You are claude-oxygen · Andy McCall's agent"));
75
76
    // A wait in flight, then another request: the second is answered first.
77
    s.send(call(10, "wait", json!({ "timeout_s": 15 }))).await;
78
    tokio::time::sleep(Duration::from_millis(200)).await;
79
    s.send(json!({ "jsonrpc": "2.0", "id": 11, "method": "ping" })).await;
80
    assert_eq!(s.recv().await["id"], 11, "ping is not held up by the wait");
81
    let k = Client::new(&srv.socket, Some(kimi));
82
    let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@claude-oxygen ready when you are", "channel": "general" } } });
83
    assert_eq!(k.mcp("2025-11-25", &say).await.unwrap().status, 200);
84
    let woke = s.recv().await;
85
    assert_eq!(woke["id"], 10);
86
    assert!(woke["result"]["content"][0]["text"].as_str().unwrap().contains("ready when you are"), "{woke}");
87
88
    drop(s);
89
    srv.stop();
90
}
91
92
#[tokio::test]
93
async fn the_shim_says_plainly_what_is_wrong() {
94
    let dir = setup();
95
    let d = dir.path();
96
    add_agent(d, "claude-oxygen", &[]);
97
    add_agent(d, "kimi-oxygen", &[]);
98
99
    // No daemon: an answer, not silence.
100
    let mut s = Shim::start(d, "claude-oxygen", &[]);
101
    s.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25" } })).await;
102
    let r = s.recv().await;
103
    assert!(r["error"]["message"].as_str().unwrap().contains("spectra serve"), "{r}");
104
    drop(s);
105
106
    let out = hermetic(d).args(["mcp", "--agent", "nobody"]).stdin(Stdio::null()).output().unwrap();
107
    assert!(!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("no agent named nobody"));
108
    assert!(out.stdout.is_empty(), "errors never go to stdout");
109
    let other = d.join("agents/kimi-oxygen");
110
    let out =
111
        hermetic(d).args(["mcp", "--agent", "claude-oxygen", "--home", other.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
112
    assert!(
113
        !out.status.success() && String::from_utf8_lossy(&out.stderr).contains("holds kimi-oxygen's key"),
114
        "{}",
115
        String::from_utf8_lossy(&out.stderr)
116
    );
117
}