| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
} |