test(mcp): harden symptomatic MCP fixes to construction-enforced

Four guards/refactors that convert previously convention-enforced MCP
invariants into ones a future edit can't silently break:

- H1 (loopback Host set): standalone.rs surface-guard asserts our loopback
  allow-list is a superset of rmcp's own default set, so an rmcp bump that
  adds a loopback form turns red instead of 403'ing that client. Pins the
  ::1 regression by construction rather than by a hand-kept literal list.

- H2 (one stored-query gate): extract a single invoke_query_request() in
  handlers.rs used by GET /queries, POST /queries/{name}, and (imported) the
  MCP tools/list + tools/call stored paths. REST and MCP can no longer drift
  on which Cedar action governs the catalog. Deletes mcp.rs's local copy.

- H3 (expose chokepoint): tests/mcp.rs source-walk guard bans .lookup( and
  registry.iter( in mcp.rs, so the agent surface can only reach stored
  queries through exposed()/exposed_by_name() — an @mcp(expose:false) query
  cannot leak back into tools/list via the expose-ignoring registry methods.

- H4 (list-gate relaxation lower-bound): a permit-all actor must see every
  built-in tool, pinning the other end of the list-gate fix (no callable
  tool may be hidden from tools/list).

cargo test -p omnigraph-mcp -p omnigraph-server green (the lone schema_routes
flake was disk-pressure during the clean build; passes in isolation).
This commit is contained in:
Ragnor Comerford 2026-06-20 17:09:27 +02:00
parent fbf455a250
commit 8dab7e2e61
No known key found for this signature in database
4 changed files with 92 additions and 24 deletions

View file

@ -693,6 +693,58 @@ async fn stored_query_run_cannot_reach_unexposed_query() {
assert_eq!(v["result"]["isError"], json!(true), "unexposed query must not run via stored_query_run: {v}");
}
#[test]
fn mcp_backend_resolves_only_through_the_exposed_chokepoint() {
// Construction guard for the stored-query expose class: the MCP backend must
// reach stored queries ONLY through exposed()/exposed_by_name(), never the
// expose-ignoring QueryRegistry::lookup/iter (those would leak an
// `@mcp(expose: false)` query to the agent surface). HTTP/service callers may
// use lookup/iter; the agent surface may not. A source-walk so a future edit
// that reintroduces the bug fails loudly here.
const SRC: &str = include_str!("../src/mcp.rs");
assert!(
!SRC.contains(".lookup("),
"mcp.rs must not call registry.lookup — use exposed_by_name()"
);
assert!(
!SRC.contains("registry.iter("),
"mcp.rs must not call registry.iter — use exposed()"
);
}
#[tokio::test]
async fn permit_all_actor_sees_every_builtin_tool() {
// Relaxation lower-bound for `list_gate`: when the policy permits every
// action, NO built-in may be hidden from tools/list (a hidden-but-callable
// tool is the class the list-gate fix closed). Pairs with
// `list_gate_matches_call_for_fixed_branchless_reads`, which pins the
// fixed-branchless reads' faithful gate at the other end.
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (_s, list) =
json_response(&app, mcp_request(Some("tok"), rpc(1, "tools/list", json!({})))).await;
let names = tool_names(&list);
for builtin in [
"graph_health",
"graph_query",
"graph_snapshot",
"schema_get",
"branch_list",
"commit_list",
"commit_get",
"graph_mutate",
"graph_load",
"branch_create",
"branch_delete",
"branch_merge",
"schema_apply",
] {
assert!(
names.contains(&builtin.to_string()),
"a permit-all actor must see built-in '{builtin}' (list_gate must not hide a callable tool): {names:?}"
);
}
}
#[test]
fn stored_query_shadowing_a_builtin_is_a_load_error() {
// A stored query whose tool name collides with a built-in must fail loudly