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

nordisk/nornir

tests/mcp_client_mode_law.rs

//! **The MCP client-mode law** — every MCP tool that reaches the suite warehouse
//! must have a `NORNIR_SERVER` (gRPC) branch.
//!
//! ## Why this gate exists
//!
//! `src/mcp_stdio.rs` is deliberately DUAL-MODE, and that is the correct design,
//! not a gap:
//!
//!   * **embedded** (`NORNIR_SERVER` unset) — open the local warehouse in-process.
//!     This is the "fat client" premise: the MCP surface must work with no server
//!     running at all, because an agent SPAWNS `nornir-server mcp` over stdio and
//!     there may be no daemon anywhere.
//!   * **client** (`NORNIR_SERVER` set) — the server owns the warehouse's redb
//!     WRITE lock, so the MCP may not open it. Every warehouse-touching tool must
//!     route to the daemon over gRPC instead.
//!
//! The `mcp` feature already pulls `wire-client` (`Cargo.toml`), and the vast
//! majority of tools honour the split. What has no gate is the split ITSELF: a
//! new tool that opens the warehouse and forgets the `server_target()` branch
//! compiles, passes every functional test (which run embedded), and then answers
//! a DIFFERENT question than the gRPC surface answers for the same input — a
//! LAW 5 (one writer, one answer) violation at the seam. In the worst case it
//! does not even fail loudly: against a REMOTE server the local warehouse is
//! empty, so the tool returns a confident "nothing here" instead of the data the
//! daemon holds.
//!
//! That is exactly what happened to `arch_svg` / `arch_graph` / `arch_trace`:
//! three tools calling the exclusive `IcebergWarehouse::open` with no client
//! branch at all.
//!
//! ## What is asserted
//!
//! For every `#[tool]`-annotated fn in `src/mcp_stdio.rs`: if its body reaches
//! the warehouse — directly (`IcebergWarehouse::open*`) or through one of the
//! LOCAL-ONLY helpers (`lens_local`, `lens_local_cached`, `knowledge_query_json`,
//! `dwarf_load_json`) — then its body must also mention `server_target()`.
//!
//! Deliberately a SOURCE gate and not a behavioural one: proving it by driving
//! the binary needs a live `nornir-server` plus a seeded warehouse, which is
//! exactly the setup no CI leg has — so the seam would stay ungated in practice.
//! The three sibling law gates in this repo (`rayon_free_law.rs`,
//! `law4_no_opengl_backend.rs`, `no_dwarves_edge.rs`) are source gates for the
//! same reason.
//!
//!   cargo test --test mcp_client_mode_law

use std::path::PathBuf;

/// Helpers that ONLY ever touch the local warehouse. A tool reaching one of
/// these is reaching the warehouse just as surely as if it opened it itself.
const LOCAL_ONLY_HELPERS: &[&str] = &[
    "self.lens_local(",
    "self.lens_local_cached(",
    "self.knowledge_query_json(",
    "self.dwarf_load_json(",
];

/// Direct warehouse opens.
const DIRECT_OPENS: &[&str] = &["IcebergWarehouse::open"];

/// The marker that a fn has a client-mode (gRPC) branch.
const CLIENT_BRANCH: &str = "server_target()";

fn mcp_stdio_path() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/mcp_stdio.rs")
}

/// One `#[tool]` fn: its name, its 1-based declaration line, and its body.
struct ToolFn {
    name: String,
    line: usize,
    body: String,
}

/// Split `src/mcp_stdio.rs` into its `#[tool]`-annotated fns.
///
/// The file's `impl NornirServer` block puts every tool at a 4-space indent, so a
/// fn body runs from its `async fn` line to the first line that is exactly
/// `    }`. Brace counting is NOT used on purpose: the `#[tool(description = …)]`
/// strings are full of `{published, max_version}`-style prose braces that no
/// balanced-brace scanner survives.
fn parse_tool_fns(src: &str) -> Vec<ToolFn> {
    let lines: Vec<&str> = src.lines().collect();
    let mut out = Vec::new();
    let mut i = 0usize;
    while i < lines.len() {
        if !lines[i].trim_start().starts_with("#[tool(") {
            i += 1;
            continue;
        }
        // Walk to the fn signature that this attribute decorates. Match on the
        // START of the trimmed line, never on "contains(\"fn \")" — several tool
        // DESCRIPTIONS contain the words "core fn", and matching those pulled the
        // parser into the middle of a doc string.
        let is_sig = |l: &str| {
            let t = l.trim_start();
            t.starts_with("fn ") || t.starts_with("async fn ") || t.starts_with("pub ")
        };
        let mut j = i + 1;
        while j < lines.len() && !is_sig(lines[j]) {
            j += 1;
        }
        if j >= lines.len() {
            break;
        }
        let Some(name) = lines[j]
            .split("fn ")
            .nth(1)
            .and_then(|rest| rest.split(['(', '<', ' ']).next())
            .map(str::to_string)
        else {
            i = j + 1;
            continue;
        };
        // Body: from the signature line to the first `    }` (fn-level close).
        let mut k = j;
        let mut body = String::new();
        while k < lines.len() {
            body.push_str(lines[k]);
            body.push('\n');
            if lines[k] == "    }" {
                break;
            }
            k += 1;
        }
        out.push(ToolFn { name, line: j + 1, body });
        i = k + 1;
    }
    out
}

fn reaches_warehouse(body: &str) -> Option<&'static str> {
    DIRECT_OPENS
        .iter()
        .chain(LOCAL_ONLY_HELPERS.iter())
        .find(|needle| body.contains(**needle))
        .copied()
}

#[test]
fn every_warehouse_touching_mcp_tool_has_a_grpc_branch() {
    let path = mcp_stdio_path();
    let src = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let tools = parse_tool_fns(&src);

    // Sanity: the parser must actually find the surface. If a refactor renames
    // the attribute or reflows the impl, a silently-empty scan would turn this
    // gate into a no-op that always passes — the worst kind of guard.
    assert!(
        tools.len() >= 40,
        "the #[tool] parser found only {} tools in {} — the gate has gone blind, \
         fix the parser before trusting a green run",
        tools.len(),
        path.display(),
    );

    let mut offenders: Vec<String> = Vec::new();
    for t in &tools {
        let Some(via) = reaches_warehouse(&t.body) else {
            continue;
        };
        if t.body.contains(CLIENT_BRANCH) {
            continue;
        }
        offenders.push(format!(
            "  src/mcp_stdio.rs:{} `{}` reaches the warehouse via `{}` with NO `{}` branch",
            t.line, t.name, via, CLIENT_BRANCH,
        ));
    }

    assert!(
        offenders.is_empty(),
        "{} MCP tool(s) answer from the LOCAL warehouse even when NORNIR_SERVER is set — \
         the MCP and gRPC surfaces then answer the same question differently (LAW 5), and \
         against a running server the exclusive open fails on redb's single-writer lock:\n{}",
        offenders.len(),
        offenders.join("\n"),
    );
}

/// The counterpart claim: the EMBEDDED half is deliberate and must not be
/// "fixed" away. If someone deletes the local fallbacks to make the gate above
/// trivially green, the MCP stops working with no server — which is the whole
/// point of a stdio-spawned agent surface. Assert the dual path is still there.
#[test]
fn the_embedded_fallback_is_still_present() {
    let src = std::fs::read_to_string(mcp_stdio_path()).unwrap();
    let opens = src.matches("IcebergWarehouse::open").count();
    assert!(
        opens >= 10,
        "only {opens} local warehouse opens left in src/mcp_stdio.rs — the EMBEDDED \
         (no-server) mode is a deliberate design (an agent spawns `nornir-server mcp` \
         over stdio with no daemon anywhere), not a gap to be routed away",
    );
}