Some checks failed
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 21s
wai-fleet service / does this change touch it (push) Successful in 17s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 26s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 28s
wai-app and Create / wai-app · does this change touch it (push) Successful in 22s
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 15s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 50s
wai-quantum editors / does this change touch it (push) Successful in 31s
wai-quantum for JavaScript / does this change touch it (push) Successful in 29s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 40s
wai-audit collector and player integration / does this change touch it (push) Successful in 1m23s
wai-quantum studio and desktop / does this change touch it (push) Successful in 30s
wai-fleet service / service · macos (push) Successful in 59s
wai-quantum for Python / does this change touch it (push) Successful in 31s
wai-quantum docs, course and locales / does this change touch it (push) Successful in 1m27s
wai-quantum views in agent hosts / does this change touch it (push) Successful in 21s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 2m4s
wai-key-proxy / does this change touch it (push) Successful in 1m55s
WAI operation catalogue / does this change touch it (push) Successful in 1m56s
wai-quantum command line / does this change touch it (push) Successful in 1m40s
wai-ledger service / does this change touch it (push) Successful in 1m50s
wai-quantum contract / does this change touch it (push) Successful in 1m41s
WAI Studio / does this change touch it (push) Successful in 12s
wai-sink-swift native sink package / does this change touch it (push) Successful in 25s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m43s
wai-sink-android native sink library / does this change touch it (push) Successful in 42s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Successful in 2m45s
wai-quantum editors / editors · macos (push) Successful in 2m2s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 2m21s
wai-fleet service / service · linux (push) Successful in 3m36s
wai-quantum docs, course and locales / the Studio's words in every locale · linux (push) Successful in 12s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 52s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m37s
wai-quantum for Python / python · macos (push) Successful in 2m57s
wai-ledger service / service · macos (push) Successful in 1m16s
wai-quantum editors / editors · linux (push) Successful in 3m46s
wai-audit collector and player integration / collector · linux (push) Successful in 3m39s
wai-quantum docs, course and locales / the course and the command line's languages · linux (push) Successful in 3m23s
wai-quantum for Python / python · linux (push) Successful in 4m26s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 6m38s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 5m7s
wai-quantum command line / command line · macos (push) Successful in 2m33s
wai-quantum for JavaScript / javascript · linux (push) Successful in 5m35s
wai-key-proxy / proxy · linux (push) Successful in 1m12s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 7m3s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 7m40s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 7m39s
wai-ledger service / service · linux (push) Successful in 2m5s
wai-quantum contract / the policy is linked, and the deprecations page reads (push) Successful in 7s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 8m36s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 8m36s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m10s
wai-quantum views in agent hosts / views · a scripted host (push) Successful in 4m26s
wai-quantum views in agent hosts / views · the page and its engine (push) Successful in 6m33s
wai-quantum contract / API compatibility · linux (push) Successful in 4m44s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 13m4s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 6m46s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 14m11s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 14m12s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 13m18s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 10m49s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 11m36s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 12m10s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 16m36s
wai-quantum contract / schemas and the committed tool lists · linux (push) Successful in 9m58s
wai-quantum studio and desktop / studio · browser (push) Successful in 16m35s
WAI Studio / Studio in WebKit · linux (push) Successful in 12m22s
wai-quantum docs, course and locales / every command and snippet in the docs · linux (push) Successful in 22m0s
wai release binaries build on Windows / the seven release binaries · windows (push) Successful in 24m31s
joule-code conformance gate / joule-moq-ingest, every target (push) Successful in 28m51s
joule-code conformance gate / joule-moq-relay, every target (push) Successful in 28m59s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 29m41s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 11m58s
wai-quantum command line / command line · linux (push) Successful in 48m17s
Folds, each the program it folds run as itself, so what it prints and its exit status are the program's for the same arguments: wai audit report|check|verify (audit.create, audit.check, audit.verify) is wai-audit, --owner its WAI_AUDIT_OWNER; wai serve <role> [--config F] [--check] (service.start) starts wai-<role>: relay, room, ingest and key-release from a file, audit, fleet, ledger and quantum-service from their environment; wai quantum run|plan|lint|verify|methods (quantum.run, .inspect, .check, .verify, .list) is wai-quantum with that verb, and no other verb of it is reached. A program is its flag, WAI_<NAME>_BIN, beside wai, or on PATH; one not there is exit 5, program-not-found. report is not an N3 verb, so wai audit report is the operation audit.create. --check on the daemons: wai-relay, wai-room (its keys too) and wai-ingest --config F --check, and wai-key-release serve F --check, read the configuration as each starts from it and start nothing, exit 1 with the reason when it does not read. The proxy is installed as wai-key-release; wai-key-proxy is the same program under its earlier name. wai cmcd encode|decode|verify is wai_telemetry's tool compiled in (cmcd.rs, one module behind both; telemetry joins full). wai receipt create <file>... --from <input>... [--key] signs the operation receipt a toolchain write leaves, for files made elsewhere. wai verify reads every record by its kind: by name, and a JSON record by what it holds (a delivery receipt or claim as receipt_verify answers it, a room session, an audit bundle as wai-audit verify does with --owner/--channel, a sealed playlist by its mr/<blake3>.json as check-playlist does); a file of no kind is exit 2, unknown-record. wai mcp --toolset quantum starts wai-quantum mcp and answers each quantum_* tool with that server's own answer and schemas. wai media score build|render alias wai score. The quantum operations' SDK names are registered labels. Tests, each fold against what it folds: cli_folds (cmcd over the whole corpus, receipt create against wrap's receipt, verify on the claim, JWP, room and audit corpora and a sealed playlist, audit|serve|quantum against stand-ins); wai-audit tests/fold.rs, wai-quantum-cli tests/wai_fold.rs and joule-moq-room tests/cli_serve.rs against the real programs; cli_mcp's quantum toolset; the daemons' --check (config_check.rs, check.rs). ops.json, mcp.json, the registry export and Studio's actions regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
650 lines
37 KiB
Rust
650 lines
37 KiB
Rust
//! `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<ChildStdout>,
|
|
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<Value, String> {
|
|
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::<Value>(&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<String> {
|
|
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<String> {
|
|
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<String> = 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<String> = 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<u64>, Option<String>) {
|
|
(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.
|
|
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<String> = 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::<Vec<_>>(), QUANTUM.iter().map(|(m, _)| *m).collect::<Vec<_>>(), "{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:?}");
|
|
}
|