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",
);
}