| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
mod common; |
| 6 |
|
| 7 |
use common::{add_agent, run, setup, text, Serving}; |
| 8 |
use serde_json::json; |
| 9 |
use spectra_core::keys::KeyPair; |
| 10 |
use spectra_mcp::client::{rpc_result, Client}; |
| 11 |
|
| 12 |
fn call(id: u64, name: &str, args: serde_json::Value) -> serde_json::Value { |
| 13 |
json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args, |
| 14 |
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }) |
| 15 |
} |
| 16 |
|
| 17 |
#[tokio::test] |
| 18 |
async fn an_agent_discovers_lists_and_calls_tools_over_the_socket() { |
| 19 |
let dir = setup(); |
| 20 |
let d = dir.path(); |
| 21 |
let agent = add_agent(d, "claude-oxygen", &[]); |
| 22 |
let srv = Serving::start(d); |
| 23 |
let c = Client::new(&srv.socket, Some(agent.clone())); |
| 24 |
|
| 25 |
let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {} })).await.unwrap(); |
| 26 |
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); |
| 27 |
let v = r.json().unwrap(); |
| 28 |
assert_eq!(v["result"]["serverInfo"]["name"], "spectra"); |
| 29 |
assert_eq!(v["result"]["resultType"], "complete"); |
| 30 |
assert!(v["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2025-11-25"))); |
| 31 |
|
| 32 |
let v = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })).await.unwrap().json().unwrap(); |
| 33 |
assert!(v["result"]["tools"].as_array().unwrap().iter().any(|t| t["name"] == "agents")); |
| 34 |
assert_eq!(v["result"]["ttlMs"], 3_600_000); |
| 35 |
assert!(v["result"]["_meta"]["spectra/session"].is_string(), "{v}"); |
| 36 |
|
| 37 |
let r = c.mcp("2026-07-28", &call(3, "agents", json!({}))).await.unwrap(); |
| 38 |
let v = rpc_result(&r).unwrap(); |
| 39 |
assert_eq!(v["result"]["isError"], false, "{v}"); |
| 40 |
assert_eq!(v["result"]["structuredContent"]["agents"][0]["owner"], "Andy McCall"); |
| 41 |
assert!(v["result"]["content"][0]["text"].as_str().unwrap().contains("claude-oxygen · Andy McCall's agent")); |
| 42 |
|
| 43 |
|
| 44 |
let v = c.mcp("2025-11-25", &json!({ "jsonrpc": "2.0", "id": 4, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "t", "version": "1" } } })).await.unwrap().json().unwrap(); |
| 45 |
assert_eq!(v["result"]["protocolVersion"], "2025-11-25"); |
| 46 |
assert!(v["result"]["instructions"].as_str().unwrap().contains("Call `brief` first")); |
| 47 |
assert_eq!(c.mcp("2025-11-25", &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await.unwrap().status, 202); |
| 48 |
srv.stop(); |
| 49 |
} |
| 50 |
|
| 51 |
#[tokio::test] |
| 52 |
async fn every_refusal_the_spec_promises() { |
| 53 |
let dir = setup(); |
| 54 |
let d = dir.path(); |
| 55 |
let agent = add_agent(d, "claude-oxygen", &[]); |
| 56 |
let uid = unsafe { libc::getuid() }; |
| 57 |
let other_uid = (uid + 1).to_string(); |
| 58 |
let bound = add_agent(d, "bound", &["--uid", &other_uid]); |
| 59 |
let srv = Serving::start(d); |
| 60 |
let list = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); |
| 61 |
|
| 62 |
let unsigned = Client::new(&srv.socket, None).mcp("2026-07-28", &list).await.unwrap(); |
| 63 |
assert_eq!(unsigned.status, 401); |
| 64 |
let stranger = Client::new(&srv.socket, Some(KeyPair::generate("stranger", ""))).mcp("2026-07-28", &list).await.unwrap(); |
| 65 |
assert_eq!(stranger.status, 401); |
| 66 |
assert!(String::from_utf8_lossy(&stranger.body).contains("unknown key")); |
| 67 |
|
| 68 |
let c = Client::new(&srv.socket, Some(agent)); |
| 69 |
let r = c |
| 70 |
.send( |
| 71 |
"POST", |
| 72 |
"/mcp", |
| 73 |
&[("mcp-protocol-version", "2026-07-28".into()), ("mcp-method", "tools/call".into())], |
| 74 |
serde_json::to_vec(&list).unwrap(), |
| 75 |
) |
| 76 |
.await |
| 77 |
.unwrap(); |
| 78 |
assert_eq!((r.status, r.json().unwrap()["error"]["code"].as_i64()), (400, Some(-32020)), "the Mcp-Method header must match the body"); |
| 79 |
let r = c.send("POST", "/mcp", &[("mcp-protocol-version", "1999-01-01".into())], serde_json::to_vec(&list).unwrap()).await.unwrap(); |
| 80 |
assert_eq!((r.status, r.json().unwrap()["error"]["code"].as_i64()), (400, Some(-32022))); |
| 81 |
let r = c |
| 82 |
.send( |
| 83 |
"POST", |
| 84 |
"/mcp", |
| 85 |
&[("origin", "http://evil.example".into()), ("mcp-protocol-version", "2026-07-28".into()), ("mcp-method", "tools/list".into())], |
| 86 |
serde_json::to_vec(&list).unwrap(), |
| 87 |
) |
| 88 |
.await |
| 89 |
.unwrap(); |
| 90 |
assert_eq!(r.status, 403, "a page from another origin"); |
| 91 |
let r = c.send("GET", "/mcp", &[], vec![]).await.unwrap(); |
| 92 |
assert_eq!(r.status, 405); |
| 93 |
|
| 94 |
let wrong = Client::new(&srv.socket, Some(bound)).mcp("2026-07-28", &list).await.unwrap(); |
| 95 |
assert_eq!(wrong.status, 401); |
| 96 |
assert!(String::from_utf8_lossy(&wrong.body).contains(&format!("bound to uid {other_uid}"))); |
| 97 |
srv.stop(); |
| 98 |
|
| 99 |
let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); |
| 100 |
assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); |
| 101 |
assert_eq!(v["uncovered"], 0, "the stop wrote a checkpoint"); |
| 102 |
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
| 103 |
let logged: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE kind = 'guard.impersonation'").fetch_one(&pool).await.unwrap(); |
| 104 |
assert_eq!(logged, 1, "the wrong-uid attempt is in the ledger"); |
| 105 |
} |
| 106 |
|
| 107 |
#[tokio::test] |
| 108 |
async fn owner_commands_go_through_the_running_daemon_and_learn_uid_binds() { |
| 109 |
let dir = setup(); |
| 110 |
let d = dir.path(); |
| 111 |
let srv = Serving::start(d); |
| 112 |
|
| 113 |
let learner = add_agent(d, "learner", &["--learn-uid"]); |
| 114 |
std::fs::create_dir_all(d.join("hub")).unwrap(); |
| 115 |
let o = run(d, &["project", "add", d.join("hub").to_str().unwrap()]); |
| 116 |
assert!(o.status.success(), "{}", text(&o)); |
| 117 |
let o = run(d, &["agent", "list", "--json"]); |
| 118 |
assert!(text(&o).contains("learner"), "{}", text(&o)); |
| 119 |
|
| 120 |
let list = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }); |
| 121 |
assert_eq!(Client::new(&srv.socket, Some(learner)).mcp("2025-11-25", &list).await.unwrap().status, 200); |
| 122 |
srv.stop(); |
| 123 |
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
| 124 |
let uid: Option<i64> = sqlx::query_scalar("SELECT uid FROM agents WHERE name = 'learner'").fetch_one(&pool).await.unwrap(); |
| 125 |
assert_eq!(uid, Some(unsafe { libc::getuid() } as i64), "bound to the uid of its first signed connection"); |
| 126 |
let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); |
| 127 |
assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); |
| 128 |
} |