//! `wai mcp` as a coding agent meets it: the binary started as an MCP server, and a client in //! this test speaking the protocol to it over standard input and output (newline-delimited //! JSON-RPC 2.0, MCP 2025-06-18): the handshake, the tool list, and every tool through //! `tools/call`. //! //! - The stateless revision, MCP 2026-07-28, with no handshake: `server/discover` states every //! revision the server speaks, its capabilities and its caching hints; `tools/list` and //! `tools/call` are served from each request's `_meta` alone, every result with `resultType` //! and the server's identity; a revision it does not serve per request is refused `-32022` with //! the ones it does, which never hold the one asked for, and those `initialize` negotiates //! apart; a request without the client's capabilities `-32602`, and `ping`, which that //! revision removed, `-32601`. The same process still answers `initialize` after them. //! - Every tool is an operation of the toolchain's catalogue, named by it: the tool list is //! exactly the operations below, each tool's name its operation's with `_` for the dot, each //! operation of the catalogue (`wai::catalogue`). `core` is offered by default, //! `dev` when it is asked for, and a toolset that does not exist is refused. //! - `receipt_verify` agrees with the committed corpora vector by vector: every group and object //! vector of jwp-conformance (kind delivery), every claim vector whose context is one the tool //! takes (none, the previous claim, the object, an OER receipt, a gap's object; kind claim), //! every room of live-conformance/rooms (kind room_session) and every session of //! live-conformance/audit (kind audit_bundle); a receipt changed by one character is refused. //! - `envelope_wrap` writes an envelope inside the project directory only, never over a file //! without `overwrite`; `envelope_inspect` reads it back, and the directory as a package. //! - `conformance_list` lists the independent verifiers' table, each with its command line, and //! `conformance_run` runs one by its directory or its name and reports it passed; a directory //! the claim verifier also runs over runs its corpus's own verifier. //! - The `dev` tools refuse a service that is not on a loopback address, before any //! connection, and any target but `local`; with no room they say how to start one. (The room //! itself, joined, published to and verified through these tools, is joule-moq-room's //! tests/mcp_dev_room.rs.) //! - `--read-only` offers no tool that writes, serves or asks for a grant. #![cfg(feature = "mcp")] use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; // A program a test runs is written by a process of its own (ETXTBSY: support/program.rs). #[path = "support/program.rs"] mod program; /// The `wai/` directory: the specification and the corpora. fn wai_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("..").canonicalize().unwrap() } /// An MCP client of a `wai mcp` process. struct Client { child: Child, stdin: ChildStdin, out: BufReader, next: i64, } impl Client { /// A client that has opened a session with `initialize` (MCP 2025-06-18). fn start(project: &Path, extra: &[&str]) -> Client { let mut c = Client::spawn(project, extra); let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_mcp test", "version": "1"}})); assert_eq!(init["result"]["protocolVersion"], "2025-06-18", "{init}"); c.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); c } /// A server process, and nothing sent to it yet. fn spawn(project: &Path, extra: &[&str]) -> Client { let mut child = Command::new(env!("CARGO_BIN_EXE_wai")) .arg("mcp") .arg("--spec-root") .arg(wai_dir()) .arg("--project") .arg(project) .args(extra) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .expect("wai mcp starts"); let stdin = child.stdin.take().unwrap(); let out = BufReader::new(child.stdout.take().unwrap()); Client { child, stdin, out, next: 1 } } fn send(&mut self, v: &Value) { writeln!(self.stdin, "{v}").unwrap(); self.stdin.flush().unwrap(); } fn line(&mut self) -> Value { let mut l = String::new(); assert!(self.out.read_line(&mut l).unwrap() > 0, "the server closed its output"); serde_json::from_str(&l).unwrap_or_else(|e| panic!("a line that is not JSON on the protocol channel: {e}: {l:?}")) } fn request(&mut self, method: &str, params: Value) -> Value { let id = self.next; self.next += 1; self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); let v = self.line(); assert_eq!(v["id"], json!(id), "answers come in order: {v}"); v } /// A tool's structured result, or its error text. fn call(&mut self, name: &str, args: Value) -> Result { let v = self.request("tools/call", json!({"name": name, "arguments": args})); let r = &v["result"]; assert!(r.is_object(), "{name}: {v}"); let text = r["content"][0]["text"].as_str().unwrap_or("").to_string(); if r["isError"] == json!(true) { return Err(text); } let s = r["structuredContent"].clone(); assert!(s.is_object(), "{name}: a structured result: {v}"); assert!(serde_json::from_str::(&text).is_ok_and(|t| t.is_object()), "{name}: the text block is the result's JSON"); Ok(s) } fn ok(&mut self, name: &str, args: Value) -> Value { self.call(name, args.clone()).unwrap_or_else(|e| panic!("{name} {args}: {e}")) } } impl Drop for Client { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } fn scratch(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("wai-cli-mcp-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d.canonicalize().unwrap() } fn corpus(rel: &str) -> Value { serde_json::from_str(&std::fs::read_to_string(wai_dir().join(rel)).unwrap()).unwrap() } /// The operations of each toolset, in the order the server lists them. A tool is its /// operation's name with `_` for the dot. const CORE: &[&str] = &["envelope.inspect", "media.inspect", "registry.list", "registry.show", "receipt.verify", "envelope.wrap", "conformance.list", "conformance.run", "project.check", "project.lock", "project.build", "docs.search", "ops.search", "ops.show", "toolchain.doctor"]; const DEV: &[&str] = &["dev.start", "dev.stop", "room.grant", "room.join", "room.leave", "session.show", "room.test", "room.load"]; fn tool_names(c: &mut Client) -> Vec { let list = c.request("tools/list", json!({})); list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect() } fn names_of(ops: &[&[&str]]) -> Vec { ops.iter().flat_map(|s| s.iter()).map(|op| op.replace('.', "_")).collect() } #[test] fn the_handshake_and_the_tool_list() { let d = scratch("handshake"); // core by default. let mut c = Client::start(&d, &[]); assert_eq!(tool_names(&mut c), names_of(&[CORE])); // core and dev when asked: every tool an operation named by the rule. let mut c = Client::start(&d, &["--toolset", "core,dev"]); let list = c.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); let names: Vec = tools.iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); assert_eq!(names, names_of(&[CORE, DEV])); for t in tools { let op = t["_meta"]["wai/operation"].as_str().unwrap(); assert!(wai::catalogue::op(op).is_some_and(|o| o.mcp), "{t}: an operation of the catalogue that wai mcp serves"); assert_eq!(t["name"].as_str().unwrap(), op.replace('.', "_"), "{t}: the tool is its operation, named by it"); let set = t["_meta"]["wai/toolset"].as_str().unwrap(); assert_eq!(set, if DEV.contains(&op) { "dev" } else { "core" }, "{t}"); let d = t["description"].as_str().unwrap(); assert!(!d.is_empty() && !d.contains('\n'), "{t}: a one-line description"); assert_eq!(t["inputSchema"]["type"], "object", "{t}"); assert!(t["annotations"]["readOnlyHint"].is_boolean(), "{t}"); assert_eq!(d.starts_with("DEVELOPMENT ONLY."), set == "dev", "{t}: a development tool is marked so, and only one"); } let mut only_dev = Client::start(&d, &["--toolset", "dev"]); assert_eq!(tool_names(&mut only_dev), names_of(&[DEV])); // A toolset this server does not have is refused before it serves. let out = Command::new(env!("CARGO_BIN_EXE_wai")).args(["mcp", "--toolset", "core,nonesuch"]).stdin(Stdio::null()).output().unwrap(); assert_eq!(out.status.code(), Some(2)); assert!(String::from_utf8_lossy(&out.stderr).contains("nonesuch"), "{out:?}"); // The protocol's own answers. assert_eq!(c.request("ping", json!({}))["result"], json!({})); assert_eq!(c.request("nonsense/method", json!({}))["error"]["code"], -32601); assert_eq!(c.request("tools/call", json!({"name": "no_such_tool", "arguments": {}}))["error"]["code"], -32602); c.send(&json!([{"jsonrpc": "2.0", "id": 99, "method": "ping"}])); assert_eq!(c.line()["error"]["code"], -32600, "a batch is refused"); writeln!(c.stdin, "{{not json").unwrap(); c.stdin.flush().unwrap(); assert_eq!(c.line()["error"]["code"], -32700); // Each revision initialize negotiates is answered in it, from 2025-11-25, the last with a // handshake, down; one it does not know, in the newest of them. for (asked, answered) in [("2025-11-25", "2025-11-25"), ("2025-06-18", "2025-06-18"), ("2025-03-26", "2025-03-26"), ("2024-11-05", "2024-11-05"), ("1999-01-01", "2025-11-25")] { let mut c = Client::spawn(&d, &[]); let init = c.request("initialize", json!({"protocolVersion": asked, "capabilities": {}})); assert_eq!(init["result"]["protocolVersion"], answered, "{init}"); assert_eq!(tool_names(&mut c), names_of(&[CORE]), "{asked}: the same tools"); } } /// A request of the stateless revision: `params` with the `_meta` MCP 2026-07-28 requires on /// every request, naming `version`. fn stateless(version: &str, mut params: Value) -> Value { params["_meta"] = json!({ "io.modelcontextprotocol/protocolVersion": version, "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": {"name": "cli_mcp test", "version": "1"}, }); params } #[test] fn the_stateless_revision_is_served_with_no_handshake() { let d = scratch("stateless"); let mut c = Client::spawn(&d, &[]); let every = json!(["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]); let complete = |v: &Value| { let r = &v["result"]; assert_eq!(r["resultType"], "complete", "{v}"); assert_eq!(r["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "wai", "{v}"); }; let cacheable = |v: &Value| { assert!(v["result"]["ttlMs"].as_u64().is_some(), "{v}: ttlMs, a whole number of milliseconds"); assert_eq!(v["result"]["cacheScope"], "public", "{v}"); }; // The probe a client of both eras sends first, before anything else. let d0 = c.request("server/discover", stateless("2026-07-28", json!({}))); complete(&d0); cacheable(&d0); assert_eq!(d0["result"]["supportedVersions"], every, "{d0}"); assert!(d0["result"]["capabilities"]["tools"].is_object(), "{d0}"); assert!(d0["result"]["instructions"].as_str().is_some_and(|i| i.contains("WAI")), "{d0}"); // The tools, listed and called from what each request carries alone. let list = c.request("tools/list", stateless("2026-07-28", json!({}))); complete(&list); cacheable(&list); let names: Vec = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); assert_eq!(names, names_of(&[CORE])); let call = c.request("tools/call", stateless("2026-07-28", json!({"name": "registry_show", "arguments": {"capability": "wai.image.png"}}))); complete(&call); assert_eq!(call["result"]["isError"], false, "{call}"); assert_eq!(call["result"]["structuredContent"]["registry"]["capability"], "wai.image.png", "{call}"); let bad = c.request("tools/call", stateless("2026-07-28", json!({"name": "registry_show", "arguments": {}}))); complete(&bad); assert_eq!(bad["result"]["isError"], true, "a tool's own error is a result: {bad}"); assert_eq!(c.request("tools/call", stateless("2026-07-28", json!({"name": "no_such_tool", "arguments": {}})))["error"]["code"], -32602); // A revision it does not serve per request: refused with the ones it does, the client's // retry rule, which never holds the one asked for, so a retry with one of them is served. A // legacy one is negotiated by initialize, not named per request, and those are named apart. for v in ["1900-01-01", "2025-11-25", "2025-06-18", "2024-11-05"] { let e = c.request("tools/list", stateless(v, json!({}))); assert_eq!(e["error"]["code"], -32022, "{e}"); let data = &e["error"]["data"]; assert_eq!((data["supported"].clone(), data["requested"].clone()), (json!(["2026-07-28"]), json!(v)), "{e}"); assert_eq!(data["initialize"], json!(every.as_array().unwrap()[1..]), "{e}"); let supported = data["supported"].as_array().unwrap(); assert!(!supported.contains(&json!(v)), "{e}: the revision asked for is not one to retry with"); for retry in supported { complete(&c.request("tools/list", stateless(retry.as_str().unwrap(), json!({})))); } } assert!(c.request("tools/list", stateless("2025-06-18", json!({})))["error"]["message"].as_str().unwrap().contains("initialize")); // The client's capabilities are required on every request. let mut no_caps = stateless("2026-07-28", json!({})); no_caps["_meta"].as_object_mut().unwrap().remove("io.modelcontextprotocol/clientCapabilities"); assert_eq!(c.request("tools/list", no_caps)["error"]["code"], -32602); // ping is not a method of this revision; server/discover is not one of the legacy ones. assert_eq!(c.request("ping", stateless("2026-07-28", json!({})))["error"]["code"], -32601); assert_eq!(c.request("server/discover", json!({}))["error"]["code"], -32602); // The same process answers a legacy client too, and serves it as before. let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}})); assert_eq!(init["result"]["protocolVersion"], "2025-06-18", "{init}"); let old = c.request("tools/list", json!({})); assert!(old["result"]["tools"].is_array() && old["result"].get("resultType").is_none(), "{old}"); // And a stateless request after it is still served statelessly. complete(&c.request("tools/list", stateless("2026-07-28", json!({})))); } #[test] fn read_only_offers_no_tool_that_writes_serves_or_mints() { let d = scratch("readonly"); let mut c = Client::start(&d, &["--toolset", "core,dev", "--read-only"]); let names = tool_names(&mut c); for gone in ["envelope_wrap", "project_lock", "project_build", "dev_start", "dev_stop", "room_grant", "room_join", "room_leave", "session_show", "room_test", "room_load"] { assert!(!names.iter().any(|n| n == gone), "{gone} is offered read-only"); } assert!(names.iter().any(|n| n == "receipt_verify") && names.iter().any(|n| n == "project_check")); let r = c.request("tools/call", json!({"name": "envelope_wrap", "arguments": {"input": "x", "capability": "wai.text.zstd"}})); assert_eq!(r["error"]["code"], -32602, "{r}"); } #[test] fn the_registry_and_the_specification() { let d = scratch("registry"); let mut c = Client::start(&d, &[]); let all = c.ok("registry_list", json!({})); let n = all["count"].as_u64().unwrap(); assert!(n > 50 && all["capabilities"].as_array().unwrap().len() as u64 == n); let rf = c.ok("registry_list", json!({"licence": "royalty-free", "media": "image"})); assert!(rf["count"].as_u64().unwrap() > 0); for e in rf["capabilities"].as_array().unwrap() { assert_eq!((e["licence"].as_str(), e["media"].as_str()), (Some("royalty-free"), Some("image")), "{e}"); } let png = c.ok("registry_show", json!({"capability": "wai.image.png"})); assert_eq!(png["registry"]["capability"], "wai.image.png"); assert_eq!(png["this_build"]["licence"], png["registry"]["licence"]); assert!(!png["spec_lines"].as_array().unwrap().is_empty(), "{png}"); let e = c.call("registry_show", json!({"capability": "wai.image.nothing"})).unwrap_err(); assert!(e.contains("not in the registry") && e.contains("registry_list"), "{e}"); let s = c.ok("docs_search", json!({"query": "capability fallback", "limit": 5})); let hits = s["hits"].as_array().unwrap(); assert_eq!(hits.len(), 5); for h in hits { let t = h["text"].as_str().unwrap().to_ascii_lowercase(); assert!(t.contains("capability") && t.contains("fallback"), "{h}"); assert!(h["line"].as_u64().unwrap() > 0 && h["file"].as_str().unwrap().ends_with(".md")); } assert_eq!(hits[0]["file"], "SPEC.md", "SPEC.md is searched first"); assert!(c.call("docs_search", json!({"query": " "})).is_err()); } /// The catalogue through its two tools: a task finds its operation, an id gives its entry as /// `wai ops show` does, and a task of no word or an id the catalogue does not hold is refused. #[test] fn ops_search_and_ops_show_are_the_catalogues() { let d = scratch("ops"); let mut c = Client::start(&d, &[]); let s = c.ok("ops_search", json!({"task": "verify the receipts under a directory"})); let ops = s["operations"].as_array().unwrap(); assert!(!ops.is_empty() && ops.len() <= 5 && s["count"] == ops.len(), "{s}"); assert_eq!((ops[0]["id"].as_str(), ops[0]["mcp"].as_str()), (Some("receipt.verify"), Some("receipt_verify")), "{s}"); assert_eq!(c.ok("ops_search", json!({"task": "search operations", "limit": 1}))["operations"].as_array().unwrap().len(), 1); let w = c.ok("ops_show", json!({"id": "envelope.wrap"})); let want = wai::catalogue::op("envelope.wrap").unwrap().to_json(); for k in ["id", "cli", "effect", "placement", "input", "output", "names"] { assert_eq!(w[k], want[k], "{k}: {w}"); } assert_eq!(w["available"], true); let e = c.call("ops_show", json!({"id": "envelope.wrapp"})).unwrap_err(); assert!(e.contains("no operation envelope.wrapp") && e.contains("envelope.wrap"), "{e}"); assert!(c.call("ops_search", json!({"task": " "})).is_err()); assert!(c.call("ops_search", json!({})).is_err()); } /// The machine's report, as `wai doctor --json` gives it, the keystore named as on its command line. #[test] fn toolchain_doctor_reports_this_machine() { let d = scratch("doctor"); let mut c = Client::start(&d, &["--read-only"]); let ks = d.join("no-keystore-here"); let r = c.ok("toolchain_doctor", json!({"keystore": ks.to_str().unwrap()})); assert!(["passed", "failed", "passed-with-not-checked"].contains(&r["result"].as_str().unwrap()), "{r}"); let checks = r["checks"].as_array().unwrap(); let of = |id: &str| checks.iter().find(|k| k["id"] == id).unwrap_or_else(|| panic!("no {id}: {r}")); assert_eq!(of("install.version")["facts"]["version"], env!("CARGO_PKG_VERSION")); assert!(of("keystore.directory")["message"].as_str().unwrap().contains("no keystore yet"), "{r}"); assert!(checks.iter().any(|k| k["id"] == "port.8080"), "{r}"); } #[test] fn packaging_and_inspecting_stay_inside_the_project() { let d = scratch("package"); let mut c = Client::start(&d, &[]); let input = d.join("hello.txt"); std::fs::write(&input, b"hello, world").unwrap(); let p = c.ok("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})); assert_eq!((p["format"].as_str(), p["components"][0]["capability"].as_str()), (Some("WAI1"), Some("wai.text.zstd")), "{p}"); let out = d.join("hello.txt.wai"); assert!(out.is_file()); assert!(c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})).unwrap_err().contains("exists")); c.ok("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve", "overwrite": true})); let outside = std::env::temp_dir().join(format!("wai-cli-mcp-outside-{}.wai", std::process::id())); let e = c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "output": outside})).unwrap_err(); assert!(e.contains("outside the project directory") && !outside.exists(), "{e}"); assert!(c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "output": "../escape.wai"})).is_err()); assert!(c.call("envelope_wrap", json!({"input": input, "capability": "not.a.capability"})).is_err()); #[cfg(unix)] { std::os::unix::fs::symlink(&outside, d.join("link.wai")).unwrap(); assert!(c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "output": "link.wai", "overwrite": true})).unwrap_err().contains("a link")); assert!(!outside.exists(), "nothing written through a link"); std::fs::remove_file(d.join("link.wai")).unwrap(); } let i = c.ok("envelope_inspect", json!({"path": out})); assert_eq!((i["kind"].as_str(), i["format"].as_str(), i["intent"].as_str()), (Some("envelope"), Some("WAI1"), Some("improve")), "{i}"); let comp = &i["components"][0]; assert_eq!(comp["capability"], "wai.text.zstd"); assert_eq!(comp["bytes"], 12); assert!(comp["licence"].is_string() && comp["tier"].is_string(), "{comp}"); assert_eq!(i["checks"]["component_roles"], "ok"); let pkg = c.ok("envelope_inspect", json!({"path": d})); assert_eq!(pkg["kind"], "package"); assert_eq!(pkg["envelopes"].as_array().unwrap().len(), 1); assert_eq!(pkg["listing_sha256"].as_str().unwrap().len(), 64); assert!(pkg["capabilities"]["wai.text.zstd"].is_object(), "{pkg}"); assert!(c.call("envelope_inspect", json!({"path": input})).unwrap_err().contains("not a WAI envelope")); let _ = std::fs::remove_dir_all(&d); } /// `reject step=N reason=X` or `accept` as the corpus states it. fn expected_receipt(e: &Value) -> (String, Option, Option) { (e["outcome"].as_str().unwrap().replace("reject", "refuse"), e["step"].as_u64(), e["reason"].as_str().map(String::from)) } #[test] fn receipts_agree_with_the_jwp_corpus_and_a_changed_one_is_refused() { let d = scratch("receipts"); let mut c = Client::start(&d, &[]); let doc = corpus("jwp-conformance/vectors.json"); let mut n = 0; for v in doc["vectors"].as_array().unwrap() { let i = &v["input"]; let args = match v["procedure"].as_str() { Some("group") => { let mut a = json!({"receipt": i["group"]}); if let Some(p) = i.get("previous") { a["previous"] = p.clone(); } a } Some("object") => json!({"receipt": i["object"], "group": i["group"], "envelope_hex": i["envelope"]}), _ => continue, }; let got = c.ok("receipt_verify", args); assert_eq!(got["kind"], "delivery", "{}: the kind read from the arguments", v["name"]); let (want, step, reason) = expected_receipt(&v["expected"]); assert_eq!(got["verdict"].as_str(), Some(want.as_str()), "{}: {got}", v["name"]); if want == "refuse" { assert_eq!((got["step"].as_u64(), got["reason"].as_str().map(String::from)), (step, reason), "{}: {got}", v["name"]); } else if let Some(h) = v["expected"]["link_hash"].as_str() { assert_eq!(got["link_hash"], h, "{}", v["name"]); } n += 1; } assert_eq!(n, 54, "every group and object vector"); // A group receipt that verifies, its figure changed: refused. let good = doc["vectors"].as_array().unwrap().iter().find(|v| v["procedure"] == "group" && v["expected"]["outcome"] == "accept" && v["input"].get("previous").is_none()).unwrap(); let mut bad = good["input"]["group"].clone(); bad["joules_total"] = json!(bad["joules_total"].as_u64().unwrap() + 1); let r = c.ok("receipt_verify", json!({"receipt": bad})); assert_eq!((r["verdict"].as_str(), r["reason"].as_str()), (Some("refuse"), Some("signature_verify_failed")), "{r}"); let r = c.ok("receipt_verify", json!({"kind": "delivery", "receipt": good["input"]["group"].to_string()})); assert_eq!((r["kind"].as_str(), r["receipt"].as_str(), r["verdict"].as_str()), (Some("delivery"), Some("group"), Some("accept")), "a receipt given as its text: {r}"); assert!(c.call("receipt_verify", json!({"receipt": {"kind": "elsewhere"}})).is_err()); // A kind the tool does not take, arguments naming none, and arguments naming two. assert!(c.call("receipt_verify", json!({"kind": "letter", "receipt": bad})).unwrap_err().contains("kind")); assert!(c.call("receipt_verify", json!({})).is_err()); assert!(c.call("receipt_verify", json!({"receipt": bad, "claim": "x"})).unwrap_err().contains("give kind")); } #[test] fn claims_agree_with_the_claim_corpus() { let d = scratch("claims"); let mut c = Client::start(&d, &[]); let doc = corpus("claim-conformance/vectors.json"); let mut n = 0; let taken = ["previous", "object", "oer", "gap_object"]; for v in doc["vectors"].as_array().unwrap() { let ctx = v.get("context").cloned().unwrap_or(json!({})); if v["procedure"] != "claim" || ctx.as_object().unwrap().keys().any(|k| !taken.contains(&k.as_str())) { continue; } let mut args = ctx.clone(); args["claim"] = v["receipt"].clone(); let got = c.ok("receipt_verify", args); assert_eq!(got["kind"], "claim", "{}", v["name"]); let e = &v["expected"]; if e["outcome"] == "accept" { assert_eq!(got["verdict"], "accept", "{}: {got}", v["name"]); if let Some(h) = e["link_hash"].as_str() { assert_eq!(got["link_hash"], h, "{}", v["name"]); } } else { assert_eq!((got["verdict"].as_str(), got["step"].as_u64(), got["reason"].as_str()), (Some("refuse"), e["step"].as_u64(), e["reason"].as_str()), "{}: {got}", v["name"]); } n += 1; } assert!(n > 340, "{n} claim vectors, alone or with the context the tool takes"); } #[test] fn rooms_agree_with_the_live_rooms_corpus() { let d = scratch("rooms"); let mut c = Client::start(&d, &[]); let doc = corpus("live-conformance/rooms/vectors.json"); for v in doc["vectors"].as_array().unwrap() { let got = c.ok("receipt_verify", json!({"kind": "room_session", "descriptor": v["descriptor"], "grants": v["grants"], "key_log_head": v["key_log_head"], "chains": v["chains"]})); assert_eq!(got["kind"], "room_session"); assert_eq!(got["outcome"], v["outcome"], "{}: {got}", v["name"]); match v["outcome"].as_str().unwrap() { "refuse" => assert_eq!((&got["reason"], &got["at"]), (&v["reason"], &v["at"]), "{}", v["name"]), "gap" => assert_eq!((&got["gaps"], &got["room"]), (&v["gaps"], &v["room"]), "{}", v["name"]), _ => assert_eq!(got["room"], v["room"], "{}", v["name"]), } } } #[test] fn audit_bundles_agree_with_the_stream_audit_corpus() { let d = scratch("audit"); let mut c = Client::start(&d, &[]); let doc = corpus("live-conformance/audit/vectors.json"); for s in doc["sessions"].as_array().unwrap() { let got = c.ok("receipt_verify", json!({"bundle": s["bundle"], "owner": doc["owner"], "channels": doc["channels"]})); assert_eq!(got["kind"], "audit_bundle"); assert_eq!(got["outcome"], s["outcome"]["outcome"], "{}: {got}", s["name"]); if s["outcome"]["outcome"] == "refuse" { assert_eq!(got["reason"], s["outcome"]["reason"], "{}", s["name"]); } } } #[test] fn a_corpus_runs_through_its_independent_verifier() { let d = scratch("conformance"); let mut c = Client::start(&d, &[]); let l = c.ok("conformance_list", json!({})); let pairs: Vec<(String, String)> = l["corpora"].as_array().unwrap().iter().map(|p| (p["corpus"].as_str().unwrap().into(), p["verifier"].as_str().unwrap().into())).collect(); assert!(pairs.contains(&("live-conformance/rooms".into(), "live-conformance/live_rooms_verify.py".into())), "{pairs:?}"); assert!(pairs.contains(&("claim-conformance".into(), "claim-conformance/claim_verify.py".into())), "{pairs:?}"); let rooms = l["corpora"].as_array().unwrap().iter().find(|p| p["name"] == "live-rooms").unwrap_or_else(|| panic!("{l}")); assert_eq!(rooms["command"], "python3 -I live-conformance/live_rooms_verify.py live-conformance/rooms", "{rooms}"); let r = c.ok("conformance_run", json!({"corpus": "live-conformance/rooms", "timeout_s": 600})); assert_eq!(r["passed"], true, "{r}"); assert_eq!(r["exit_code"], 0); // By its name, with the table's command line: the pin corpus's verifier, run as its README does. let r = c.ok("conformance_run", json!({"corpus": "pin", "timeout_s": 600})); assert_eq!((r["passed"].as_bool(), r["command"].as_str()), (Some(true), Some("python3 pin-conformance/pin_verify.py pin-conformance")), "{r}"); // A directory several entries share names them, when more than one is the corpus's own. assert!(c.call("conformance_run", json!({"corpus": "oer-conformance"})).unwrap_err().contains("oer-firmware")); // A directory the claim verifier also runs over (its claims, an entry of its own name) runs // the corpus's own verifier, as before the table had the claims runs. for (dir, script) in [("live-conformance/bearers", "live-conformance/bearer_verify.py"), ("live-conformance/usage", "live-conformance/ai_usage_verify.py")] { let r = c.ok("conformance_run", json!({"corpus": dir, "timeout_s": 600})); assert_eq!((r["verifier"].as_str(), r["command"].as_str()), (Some(script), Some(format!("python3 -I {script} {dir}").as_str())), "{r}"); } assert!(c.call("conformance_run", json!({"corpus": "no-such-conformance"})).unwrap_err().contains("no verifier")); let e = c.call("conformance_run", json!({"corpus": "live-conformance/rooms", "verifier": "../scripts/check-spec-refs.py"})).unwrap_err(); assert!(e.contains("outside") || e.contains("_verify.py"), "{e}"); } #[test] fn development_tools_refuse_any_service_off_this_machine() { let d = scratch("dev"); let mut c = Client::start(&d, &["--toolset", "core,dev"]); for remote in ["http://10.0.0.5:9682", "https://127.0.0.1:9682", "http://room.example/room-api", "http://127.0.0.1.example:80"] { let e = c.call("room_grant", json!({"principal": "alice", "role": "publisher", "service": remote})).unwrap_err(); assert!(e.starts_with("DEVELOPMENT ONLY"), "{remote}: {e}"); } // A target other than local is refused, whatever the service. for t in ["room_grant", "room_join", "room_leave", "session_show", "room_test", "room_load"] { let e = c.call(t, json!({"target": "cloud", "principal": "alice", "role": "publisher"})).unwrap_err(); assert!(e.contains("target local alone"), "{t}: {e}"); } // A loopback service is asked: with nothing listening, the connection is what fails. let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); let e = c.call("room_grant", json!({"target": "local", "principal": "alice", "role": "publisher", "service": format!("http://127.0.0.1:{port}")})).unwrap_err(); assert!(!e.starts_with("DEVELOPMENT ONLY") && e.contains(&port.to_string()), "{e}"); // With no room running, each tool says how to start one. for t in ["room_join", "room_leave"] { assert!(c.call(t, json!({"principal": "alice"})).is_err()); } assert!(c.call("session_show", json!({})).unwrap_err().contains("dev_start")); for t in ["room_test", "room_load"] { assert!(c.call(t, json!({})).unwrap_err().contains("dev_start"), "{t}"); } assert!(c.call("dev_stop", json!({})).is_err()); assert!(c.call("room_grant", json!({"principal": "alice", "role": "publisher"})).unwrap_err().contains("dev_start")); let e = c.call("dev_start", json!({"room_bin": d.join("no-such-program")})).unwrap_err(); assert!(e.contains("no-such-program"), "{e}"); } /// The tools of wai-quantum's server the quantum toolset reaches, by its operations, and one it /// does not. #[cfg(unix)] const QUANTUM: &[(&str, &str)] = &[("quantum_run", "run_quantum_circuit"), ("quantum_inspect", "plan_quantum_circuit"), ("quantum_check", "lint_qasm"), ("quantum_verify", "verify_quantum_result"), ("quantum_list", "list_methods")]; /// A stand-in of `wai-quantum mcp`: it lists the tools of [`QUANTUM`] and `estimate_resources`, /// each with a schema that names it, and answers a call with a result that names the tool called. #[cfg(unix)] fn quantum_stand_in(path: &Path) { let tools: Vec = QUANTUM .iter() .map(|(_, t)| *t) .chain(["estimate_resources"]) .map(|t| json!({"name": t, "title": format!("the title of {t}"), "description": format!("{t}, as wai-quantum describes it"), "inputSchema": {"type": "object", "properties": {"qasm": {"type": "string"}}, "x-tool": t}, "outputSchema": {"type": "object", "x-tool": t}}).to_string()) .collect(); let script = format!( r#"#!/bin/sh [ "$1" = mcp ] || exit 2 while IFS= read -r line; do id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') case "$line" in *'"method":"initialize"'*) printf '{{"jsonrpc":"2.0","id":%s,"result":{{"protocolVersion":"2025-06-18","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"stand-in","version":"0"}}}}}}\n' "$id" ;; *'"method":"tools/list"'*) printf '{{"jsonrpc":"2.0","id":%s,"result":{{"tools":[%s]}}}}\n' "$id" '{}' ;; *'"method":"tools/call"'*) name=$(printf '%s' "$line" | sed -n 's/.*"name":"\([a-z_]*\)".*/\1/p'); printf '{{"jsonrpc":"2.0","id":%s,"result":{{"content":[{{"type":"text","text":"%s answered"}}],"structuredContent":{{"tool":"%s"}},"isError":false}}}}\n' "$id" "$name" "$name" ;; esac done "#, tools.join(",") ); program::write_program(path, &script); } /// The quantum toolset is wai-quantum's own server's: each tool listed with the schemas that server /// states for the tool its operation is, each call answered with that server's own answer, /// unchanged; no tool of that server the toolset does not list is reached, under any name; and a /// server whose wai-quantum is not there does not start (exit 5). Against wai-quantum itself: /// wai/wai-quantum-cli's tests/wai_fold.rs. #[cfg(unix)] #[test] fn the_quantum_toolset_is_answered_by_wai_quantums_server() { let d = scratch("quantum"); let stub = d.join("wai-quantum"); quantum_stand_in(&stub); let mut c = Client::start(&d, &["--toolset", "quantum", "--quantum-bin", stub.to_str().unwrap()]); let list = c.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); assert_eq!(tools.iter().map(|t| t["name"].as_str().unwrap()).collect::>(), QUANTUM.iter().map(|(m, _)| *m).collect::>(), "{list}"); for (t, (ours, theirs)) in tools.iter().zip(QUANTUM) { assert_eq!((t["inputSchema"]["x-tool"].as_str(), t["outputSchema"]["x-tool"].as_str()), (Some(*theirs), Some(*theirs)), "{ours}: {t}"); assert_eq!(t["description"], format!("{theirs}, as wai-quantum describes it"), "{t}"); assert_eq!((t["_meta"]["wai/operation"].as_str(), t["_meta"]["wai/toolset"].as_str()), (Some(ours.replacen('_', ".", 1).as_str()), Some("quantum")), "{t}"); let r = c.request("tools/call", json!({"name": ours, "arguments": {"qasm": "OPENQASM 3.0;"}})); assert_eq!(r["result"], json!({"content": [{"type": "text", "text": format!("{theirs} answered")}], "structuredContent": {"tool": theirs}, "isError": false}), "{ours}: the server's own answer: {r}"); } // Neither a tool the toolset does not list nor a tool's own name reaches the server. for name in ["estimate_resources", "lint_qasm", "quantum_estimate"] { assert_eq!(c.request("tools/call", json!({"name": name, "arguments": {}}))["error"]["code"], -32602, "{name}"); } // Beside core, the quantum tools come after core's. let mut both = Client::start(&d, &["--toolset", "core,quantum", "--quantum-bin", stub.to_str().unwrap()]); let mut want = names_of(&[CORE]); want.extend(QUANTUM.iter().map(|(m, _)| m.to_string())); assert_eq!(tool_names(&mut both), want); // No wai-quantum: the server does not start. let out = Command::new(env!("CARGO_BIN_EXE_wai")).args(["mcp", "--toolset", "quantum", "--quantum-bin"]).arg(d.join("gone")).stdin(Stdio::null()).output().unwrap(); assert_eq!(out.status.code(), Some(5), "{}", String::from_utf8_lossy(&out.stderr)); // --dry-run names the tools and starts nothing. let out = Command::new(env!("CARGO_BIN_EXE_wai")).args(["mcp", "--toolset", "quantum", "--dry-run"]).stdin(Stdio::null()).output().unwrap(); assert!(out.status.success() && String::from_utf8_lossy(&out.stdout).contains("quantum_run"), "{out:?}"); }