//! `spectra serve` end to end (spec §8.5, §15.1): the real binary, a client //! signing requests over the Unix socket, both MCP revisions, and every //! refusal the spec promises. Hermetic: temporary homes, data and port 0. mod common; use common::{add_agent, run, setup, text, Serving}; use serde_json::json; use spectra_core::keys::KeyPair; use spectra_mcp::client::{rpc_result, Client}; fn call(id: u64, name: &str, args: serde_json::Value) -> serde_json::Value { json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }) } #[tokio::test] async fn an_agent_discovers_lists_and_calls_tools_over_the_socket() { let dir = setup(); let d = dir.path(); let agent = add_agent(d, "claude-oxygen", &[]); let srv = Serving::start(d); let c = Client::new(&srv.socket, Some(agent.clone())); let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {} })).await.unwrap(); assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); let v = r.json().unwrap(); assert_eq!(v["result"]["serverInfo"]["name"], "spectra"); assert_eq!(v["result"]["resultType"], "complete"); assert!(v["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2025-11-25"))); let v = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })).await.unwrap().json().unwrap(); assert!(v["result"]["tools"].as_array().unwrap().iter().any(|t| t["name"] == "agents")); assert_eq!(v["result"]["ttlMs"], 3_600_000); assert!(v["result"]["_meta"]["spectra/session"].is_string(), "{v}"); let r = c.mcp("2026-07-28", &call(3, "agents", json!({}))).await.unwrap(); let v = rpc_result(&r).unwrap(); assert_eq!(v["result"]["isError"], false, "{v}"); assert_eq!(v["result"]["structuredContent"]["agents"][0]["owner"], "Andy McCall"); assert!(v["result"]["content"][0]["text"].as_str().unwrap().contains("claude-oxygen · Andy McCall's agent")); // The legacy revisions begin with initialize. 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(); assert_eq!(v["result"]["protocolVersion"], "2025-11-25"); assert!(v["result"]["instructions"].as_str().unwrap().contains("Call `brief` first")); assert_eq!(c.mcp("2025-11-25", &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await.unwrap().status, 202); srv.stop(); } #[tokio::test] async fn every_refusal_the_spec_promises() { let dir = setup(); let d = dir.path(); let agent = add_agent(d, "claude-oxygen", &[]); let uid = unsafe { libc::getuid() }; let other_uid = (uid + 1).to_string(); let bound = add_agent(d, "bound", &["--uid", &other_uid]); let srv = Serving::start(d); let list = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); let unsigned = Client::new(&srv.socket, None).mcp("2026-07-28", &list).await.unwrap(); assert_eq!(unsigned.status, 401); let stranger = Client::new(&srv.socket, Some(KeyPair::generate("stranger", ""))).mcp("2026-07-28", &list).await.unwrap(); assert_eq!(stranger.status, 401); assert!(String::from_utf8_lossy(&stranger.body).contains("unknown key")); let c = Client::new(&srv.socket, Some(agent)); let r = c .send( "POST", "/mcp", &[("mcp-protocol-version", "2026-07-28".into()), ("mcp-method", "tools/call".into())], serde_json::to_vec(&list).unwrap(), ) .await .unwrap(); assert_eq!((r.status, r.json().unwrap()["error"]["code"].as_i64()), (400, Some(-32020)), "the Mcp-Method header must match the body"); let r = c.send("POST", "/mcp", &[("mcp-protocol-version", "1999-01-01".into())], serde_json::to_vec(&list).unwrap()).await.unwrap(); assert_eq!((r.status, r.json().unwrap()["error"]["code"].as_i64()), (400, Some(-32022))); let r = c .send( "POST", "/mcp", &[("origin", "http://evil.example".into()), ("mcp-protocol-version", "2026-07-28".into()), ("mcp-method", "tools/list".into())], serde_json::to_vec(&list).unwrap(), ) .await .unwrap(); assert_eq!(r.status, 403, "a page from another origin"); let r = c.send("GET", "/mcp", &[], vec![]).await.unwrap(); assert_eq!(r.status, 405); let wrong = Client::new(&srv.socket, Some(bound)).mcp("2026-07-28", &list).await.unwrap(); assert_eq!(wrong.status, 401); assert!(String::from_utf8_lossy(&wrong.body).contains(&format!("bound to uid {other_uid}"))); srv.stop(); let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); assert_eq!(v["uncovered"], 0, "the stop wrote a checkpoint"); let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); let logged: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE kind = 'guard.impersonation'").fetch_one(&pool).await.unwrap(); assert_eq!(logged, 1, "the wrong-uid attempt is in the ledger"); } #[tokio::test] async fn owner_commands_go_through_the_running_daemon_and_learn_uid_binds() { let dir = setup(); let d = dir.path(); let srv = Serving::start(d); // While serve holds the ledger, the CLI's writes go through it. let learner = add_agent(d, "learner", &["--learn-uid"]); std::fs::create_dir_all(d.join("hub")).unwrap(); let o = run(d, &["project", "add", d.join("hub").to_str().unwrap()]); assert!(o.status.success(), "{}", text(&o)); let o = run(d, &["agent", "list", "--json"]); assert!(text(&o).contains("learner"), "{}", text(&o)); let list = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }); assert_eq!(Client::new(&srv.socket, Some(learner)).mcp("2025-11-25", &list).await.unwrap().status, 200); srv.stop(); let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); let uid: Option = sqlx::query_scalar("SELECT uid FROM agents WHERE name = 'learner'").fetch_one(&pool).await.unwrap(); assert_eq!(uid, Some(unsafe { libc::getuid() } as i64), "bound to the uid of its first signed connection"); let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); }