//! The stdio shim, `spectra mcp --agent NAME` (spec §15.1), driven over //! pipes as a client would: the handshake, tools, concurrency (a `wait` in //! flight does not hold up other requests), nothing but protocol on stdout, //! and plain answers when the daemon is not running. mod common; use common::{add_agent, hermetic, setup, Serving}; use serde_json::{json, Value}; use spectra_mcp::client::Client; use std::process::Stdio; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; use tokio::process::{ChildStdin, ChildStdout}; struct Shim { child: tokio::process::Child, stdin: ChildStdin, stdout: Lines>, } impl Shim { fn start(d: &std::path::Path, agent: &str, extra: &[&str]) -> Shim { let mut cmd = tokio::process::Command::from(hermetic(d)); cmd.args(["mcp", "--agent", agent]) .args(extra) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); let mut child = cmd.spawn().unwrap(); let stdin = child.stdin.take().unwrap(); let stdout = BufReader::new(child.stdout.take().unwrap()).lines(); Shim { child, stdin, stdout } } async fn send(&mut self, v: Value) { self.stdin.write_all(format!("{v}\n").as_bytes()).await.unwrap(); self.stdin.flush().await.unwrap(); } async fn recv(&mut self) -> Value { let line = tokio::time::timeout(Duration::from_secs(20), self.stdout.next_line()) .await .expect("an answer within 20 s") .unwrap() .expect("a line"); serde_json::from_str(&line).unwrap_or_else(|e| panic!("stdout carries only JSON-RPC, got {line:?}: {e}")) } } fn call(id: u64, name: &str, args: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args } }) } #[tokio::test] async fn a_client_speaks_to_spectra_through_the_shim() { let dir = setup(); let d = dir.path(); add_agent(d, "claude-oxygen", &[]); let kimi = add_agent(d, "kimi-oxygen", &[]); let srv = Serving::start(d); let mut s = Shim::start(d, "claude-oxygen", &[]); s.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1" } } })).await; let init = s.recv().await; assert_eq!(init["result"]["protocolVersion"], "2025-06-18"); s.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await; s.send(json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" })).await; let list = s.recv().await; assert_eq!(list["id"], 2, "a notification gets no answer"); assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 11); s.send(call(3, "brief", json!({}))).await; let brief = s.recv().await; assert!(brief["result"]["content"][0]["text"].as_str().unwrap().contains("You are claude-oxygen · Andy McCall's agent")); // A wait in flight, then another request: the second is answered first. s.send(call(10, "wait", json!({ "timeout_s": 15 }))).await; tokio::time::sleep(Duration::from_millis(200)).await; s.send(json!({ "jsonrpc": "2.0", "id": 11, "method": "ping" })).await; assert_eq!(s.recv().await["id"], 11, "ping is not held up by the wait"); let k = Client::new(&srv.socket, Some(kimi)); let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@claude-oxygen ready when you are", "channel": "general" } } }); assert_eq!(k.mcp("2025-11-25", &say).await.unwrap().status, 200); let woke = s.recv().await; assert_eq!(woke["id"], 10); assert!(woke["result"]["content"][0]["text"].as_str().unwrap().contains("ready when you are"), "{woke}"); drop(s); srv.stop(); } #[tokio::test] async fn the_shim_says_plainly_what_is_wrong() { let dir = setup(); let d = dir.path(); add_agent(d, "claude-oxygen", &[]); add_agent(d, "kimi-oxygen", &[]); // No daemon: an answer, not silence. let mut s = Shim::start(d, "claude-oxygen", &[]); s.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25" } })).await; let r = s.recv().await; assert!(r["error"]["message"].as_str().unwrap().contains("spectra serve"), "{r}"); drop(s); let out = hermetic(d).args(["mcp", "--agent", "nobody"]).stdin(Stdio::null()).output().unwrap(); assert!(!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("no agent named nobody")); assert!(out.stdout.is_empty(), "errors never go to stdout"); let other = d.join("agents/kimi-oxygen"); let out = hermetic(d).args(["mcp", "--agent", "claude-oxygen", "--home", other.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); assert!( !out.status.success() && String::from_utf8_lossy(&out.stderr).contains("holds kimi-oxygen's key"), "{}", String::from_utf8_lossy(&out.stderr) ); }