tests/mcp_matrix.rs
//! MCP server test matrix — spawns the real `nornir-mcp` binary over stdio and
//! drives the MCP JSON-RPC handshake (initialize → tools/list → tools/call),
//! asserting the whole nornir tool surface is exposed and callable.
//!
//! The server opens a redb warehouse at startup; the *live* warehouses are held
//! by the running `nornir-server` (redb is single-process), so the test points
//! the MCP server at an **isolated temp warehouse** with a config declaring the
//! real `nordisk/*` repo names — so `repos_list` returns live repo names while
//! the protocol + tool surface get exercised end-to-end.
//!
//! This is ALSO the matrix-wired home of the exhaustive, coverage-gated
//! functional test (`mcp_every_tool_functionally_asserted`): it calls every one
//! of the ~56 tools with REAL args and asserts VALID, NON-EMPTY structured
//! OUTPUT, and goes RED if a tool is registered but unexercised. The harness
//! body is shared with `tests/mcp_tools.rs` via `tests/support/mcp_harness.rs`,
//! so the two `--test` targets can never drift. Runs under `nornir test`
//! (EPIC L) as a plain `#[test]`.
//!
//! cargo test --features mcp,viz --test mcp_matrix -- --nocapture
#![cfg(feature = "mcp")]
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};
use serde_json::{Value, json};
/// The exhaustive functional harness (shared with `tests/mcp_tools.rs`).
#[path = "support/mcp_harness.rs"]
mod harness;
/// EPIC H1 — every MCP tool driven with REAL arguments, its structured output
/// asserted against seeded data, with a coverage gate that fails if any
/// registered tool is unexercised. This is the matrix entry point the task asks
/// for: `cargo test --features mcp --test mcp_matrix mcp_every_tool_functionally_asserted`.
/// (Same body as `mcp_tools::mcp_every_tool_exercised_and_asserted`.)
#[test]
fn mcp_every_tool_functionally_asserted() {
harness::run_full_coverage();
}
/// A minimal config: the real repo names (so `repos_list` is live-ish) + a
/// `local_path` so the warehouse lands inside the tempdir (no server lock).
const CONFIG_TOML: &str = r#"
[repo.holger]
remote = "https://git.gunnar.rs/nordisk/holger"
[repo.nornir]
remote = "https://git.gunnar.rs/nordisk/nornir"
[repo.znippy]
remote = "https://git.gunnar.rs/nordisk/znippy"
[storage]
local_path = ".nornir"
"#;
fn send(stdin: &mut impl Write, v: Value) {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
}
/// Read newline-delimited JSON-RPC until the response with `id` arrives.
fn read_resp<R: BufRead + ?Sized>(reader: &mut R, id: i64) -> Value {
for _ in 0..400 {
let mut line = String::new();
if reader.read_line(&mut line).unwrap() == 0 {
panic!("nornir-mcp closed stdout before id={id}");
}
let line = line.trim();
if line.is_empty() || !line.starts_with('{') {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v["id"].as_i64() == Some(id) {
return v;
}
}
panic!("no response for id={id}");
}
/// Spawn `nornir-mcp` against an isolated temp config; returns the child, its
/// stdin, a reader on its stdout, and the tempdir (kept alive by the caller).
fn spawn_mcp() -> (Child, impl Write, impl BufRead, tempfile::TempDir) {
// load_explicit derives workspace_root = config.parent().parent().parent()
// (the canonical `<root>/workspace_X/release/nornir.toml` layout), so nest
// the temp config 3 levels deep → warehouse lands in the tempdir.
let dir = tempfile::tempdir().expect("tempdir");
let cfgdir = dir.path().join("workspace_test").join("release");
std::fs::create_dir_all(&cfgdir).unwrap();
let cfg = cfgdir.join("nornir.toml");
std::fs::write(&cfg, CONFIG_TOML).unwrap();
// A HOME with no ssh dir in it — see the isolation note below.
let scratch_home = dir.path().join("home");
std::fs::create_dir_all(&scratch_home).unwrap();
// ISOLATION (2026-09-10). This fixture used to spawn the server with the
// operator's real $HOME and a config carrying real
// remotes of ours on the old forge. It was safe only by
// COINCIDENCE — every verb it calls happens not to fetch — while
// `clone_or_fetch` coerces `https://codeberg.org/...` to
// `git@codeberg.org:...` and `deploykey`'s ladder would have answered
// with `~/.nornir/ssh/id_ed25519`. One added fetching verb and this test
// would have authenticated to codeberg with a real key, which is exactly
// what `tests/security_isolated_live.rs` was caught doing.
//
// So: a scratch HOME (empties the ladder's $HOME rungs), the CONFINE
// (removes the absolute /home/dwarves/.ssh and /home/nornir/.ssh rungs,
// which no env var can otherwise reach), and a local clone base.
let mut child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
.arg("mcp") // MCP moved into the server binary 2026-08-03; same stdio protocol
.env("NORNIR_CONFIG", &cfg)
.env("HOME", &scratch_home)
.env("DWARVES_SSH_CONFINE", "1")
.env_remove("DWARVES_SSH_DIR")
.env_remove("NORNIR_SSH_DIR")
.env("NORNIR_DEP_CLONE_BASE", format!("file://{}", scratch_home.display()))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn nornir-server mcp");
let stdin = child.stdin.take().unwrap();
let out = BufReader::new(child.stdout.take().unwrap());
(child, stdin, out, dir)
}
fn initialize(stdin: &mut impl Write, out: &mut impl BufRead) {
send(
stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"mcp_matrix","version":"1"}}}),
);
let init = read_resp(out, 1);
assert!(
init["result"]["serverInfo"].is_object() || init["result"]["protocolVersion"].is_string(),
"initialize failed: {init}"
);
send(
stdin,
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
);
}
#[test]
fn mcp_initialize_lists_tools_and_calls_repos_list() {
let (mut child, mut stdin, mut out, _dir) = spawn_mcp();
initialize(&mut stdin, &mut out);
// tools/list — the whole nornir API surface as MCP tools.
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
);
let tools = read_resp(&mut out, 2);
let list = tools["result"]["tools"]
.as_array()
.expect("tools/list array");
let names: Vec<&str> = list.iter().filter_map(|t| t["name"].as_str()).collect();
eprintln!("MCP exposes {} tools: {names:?}", names.len());
assert!(
list.len() >= 5,
"expected the nornir tool surface, got {}",
list.len()
);
assert!(
names.contains(&"repos_list"),
"repos_list tool missing: {names:?}"
);
// tools/call repos_list — exercises a real tool + the telemetry sink.
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"repos_list","arguments":{}}}),
);
let res = read_resp(&mut out, 3);
let content = res["result"]["content"]
.as_array()
.expect("repos_list content");
let text = content
.iter()
.filter_map(|c| c["text"].as_str())
.collect::<String>();
eprintln!("repos_list → {text}");
assert!(
text.contains("holger") || text.contains("nornir"),
"repos_list lost the repos: {text}"
);
drop(stdin);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn mcp_every_tool_has_name_description_and_schema() {
let (mut child, mut stdin, mut out, _dir) = spawn_mcp();
initialize(&mut stdin, &mut out);
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
);
let tools = read_resp(&mut out, 2);
let list = tools["result"]["tools"].as_array().expect("tools array");
assert!(!list.is_empty(), "no tools exposed");
for t in list {
let name = t["name"].as_str().unwrap_or("");
assert!(!name.is_empty(), "tool with empty name: {t}");
assert!(
t["description"]
.as_str()
.map(|d| !d.is_empty())
.unwrap_or(false),
"tool `{name}` has no description (agents need it)"
);
assert!(
t["inputSchema"].is_object(),
"tool `{name}` has no inputSchema"
);
}
drop(stdin);
let _ = child.kill();
let _ = child.wait();
}
/// Multi-workspace switch (server/proxy mode) — gated on NORNIR_TEST_SERVER +
/// NORNIR_TEST_TOKEN. Spawns nornir-mcp in proxy mode, lists workspaces, switches
/// between two, and asserts repos_list reflects the active workspace.
#[test]
fn mcp_multi_workspace_switch() {
let (server, token) = match (
std::env::var("NORNIR_TEST_SERVER"),
std::env::var("NORNIR_TEST_TOKEN"),
) {
(Ok(s), Ok(t)) if !s.is_empty() && !t.is_empty() => (s, t),
_ => {
eprintln!("[skip] NORNIR_TEST_SERVER/TOKEN unset");
return;
}
};
let dir = tempfile::tempdir().expect("tempdir");
let cfgdir = dir.path().join("workspace_test").join("release");
std::fs::create_dir_all(&cfgdir).unwrap();
std::fs::write(cfgdir.join("nornir.toml"), CONFIG_TOML).unwrap();
// A HOME with no ssh dir in it — see the isolation note below.
let scratch_home = dir.path().join("home");
std::fs::create_dir_all(&scratch_home).unwrap();
// ISOLATION (2026-09-10). This fixture used to spawn the server with the
// operator's real $HOME and a config carrying real
// remotes of ours on the old forge. It was safe only by
// COINCIDENCE — every verb it calls happens not to fetch — while
// `clone_or_fetch` coerces `https://codeberg.org/...` to
// `git@codeberg.org:...` and `deploykey`'s ladder would have answered
// with `~/.nornir/ssh/id_ed25519`. One added fetching verb and this test
// would have authenticated to codeberg with a real key, which is exactly
// what `tests/security_isolated_live.rs` was caught doing.
//
// So: a scratch HOME (empties the ladder's $HOME rungs), the CONFINE
// (removes the absolute /home/dwarves/.ssh and /home/nornir/.ssh rungs,
// which no env var can otherwise reach), and a local clone base.
let mut child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
.arg("mcp") // MCP moved into the server binary 2026-08-03; same stdio protocol
.env("NORNIR_CONFIG", cfgdir.join("nornir.toml"))
.env("NORNIR_SERVER", &server)
.env("NORNIR_SERVER_TOKEN", &token)
.env("HOME", &scratch_home)
.env("DWARVES_SSH_CONFINE", "1")
.env_remove("DWARVES_SSH_DIR")
.env_remove("NORNIR_SSH_DIR")
.env("NORNIR_DEP_CLONE_BASE", format!("file://{}", scratch_home.display()))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn nornir-server mcp");
let mut stdin = child.stdin.take().unwrap();
let mut out = BufReader::new(child.stdout.take().unwrap());
initialize(&mut stdin, &mut out);
let call = |stdin: &mut std::process::ChildStdin,
out: &mut dyn BufRead,
id: i64,
name: &str,
args: Value|
-> String {
send(
stdin,
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}}),
);
let r = read_resp(out, id);
r["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string()
};
// list ≥ 2 workspaces
let list = call(&mut stdin, &mut out, 2, "workspaces_list", json!({}));
let v: Value = serde_json::from_str(&list).unwrap();
let wss: Vec<String> = v["workspaces"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
assert!(
wss.len() >= 2,
"need ≥2 served workspaces to test switching, got {wss:?}"
);
// switch to each of the first two; repos_list must reflect the active one
let (a, b) = (&wss[0], &wss[1]);
call(&mut stdin, &mut out, 3, "workspace_use", json!({"name": a}));
let repos_a = call(&mut stdin, &mut out, 4, "repos_list", json!({}));
call(&mut stdin, &mut out, 5, "workspace_use", json!({"name": b}));
let repos_b = call(&mut stdin, &mut out, 6, "repos_list", json!({}));
eprintln!("switch: {a} → {repos_a} ; {b} → {repos_b}");
assert_ne!(
repos_a, repos_b,
"repos_list must differ between workspaces `{a}` and `{b}`"
);
drop(stdin);
let _ = child.kill();
let _ = child.wait();
}