Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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();
}