feat(mcp): MCP server surface — Streamable-HTTP transport + tool/resource projection (RFC-003)

Add the `omnigraph-mcp` crate (stateless Streamable-HTTP transport, `McpBackend`
seam, fail-closed Host/Origin policy) and the server backend projecting built-in
operations and the per-graph stored-query registry as MCP tools + resources over
`POST /graphs/{id}/mcp`. Every tool delegates to the same engine/handler
functions the REST routes use and is gated by the same Cedar `authorize` path;
reads/writes carry structured output.

Includes three correctness fixes from review + live testing:

- tools/list is a faithful relaxation of the per-call gate: a built-in whose
  authorization depends on a caller-chosen branch is shown iff the actor could
  invoke it on some branch, via PolicyEngine::permits_on_any_branch (capability
  probe through the same Cedar authorizer). A fabricated-`main` probe wrongly
  hid graph_mutate under the canonical "protect main, write unprotected" policy.
- The stored-query surface honors mode + `expose` on call as well as on list:
  resolve_stored_tool is the single membership test, so the meta pair
  (stored_query_list/stored_query_run) is callable only in `meta` mode and
  stored_query_run resolves exposed-only. An `expose:false` query is unreachable
  by name on the agent surface (it stays HTTP/service-callable).
- The loopback Host allow-list is the full set [127.0.0.1, ::1, localhost]
  (matches rmcp's default), so an IPv6 loopback `Host: [::1]` is accepted
  regardless of which stack the server bound.

The protocol-version contract is documented (initialize negotiates the version
in its body, so the MCP-Protocol-Version header is validated on non-init
requests only) and pinned by a test.

Tests: omnigraph-mcp/tests/standalone.rs, omnigraph-server/tests/mcp.rs,
omnigraph-policy permits_on_any_branch unit test, omnigraph-api-types schema
projection. Full workspace gate green.
This commit is contained in:
Ragnor Comerford 2026-06-17 14:00:52 +02:00
parent c43b81d318
commit bcd0d9c867
No known key found for this signature in database
20 changed files with 2968 additions and 43 deletions

View file

@ -24,7 +24,11 @@ omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.0" }
omnigraph-policy = { path = "../omnigraph-policy", version = "0.7.0" }
omnigraph-api-types = { path = "../omnigraph-api-types", version = "0.7.0" }
omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.7.0" }
# The MCP surface. rmcp is contained to omnigraph-mcp — the server carries NO
# direct rmcp dependency (verify: `cargo tree -p omnigraph-server -e normal | grep rmcp`).
omnigraph-mcp = { path = "../omnigraph-mcp", version = "0.7.0" }
axum = { workspace = true }
http = "1"
clap = { workspace = true }
color-eyre = { workspace = true }
serde = { workspace = true }

View file

@ -426,6 +426,37 @@ pub(crate) fn authorize_request(
}
}
/// List-time capability probe: could `action` be permitted on *some* branch?
/// Mirrors [`authorize`]'s no-policy handling (open mode allows per-graph
/// actions; default-deny allows only `Read`; server-scoped actions are closed),
/// and otherwise delegates to [`PolicyEngine::permits_on_any_branch`]. Used to
/// filter argument-scoped tools in `tools/list` as a relaxation of the per-call
/// gate — so a tool callable on some branch is never hidden, while one the
/// actor has no grant for stays hidden.
pub(crate) fn authorize_any_branch(
actor: Option<&ResolvedActor>,
policy: Option<&PolicyEngine>,
action: PolicyAction,
) -> std::result::Result<bool, ApiError> {
let Some(engine) = policy else {
if action.resource_kind() == PolicyResourceKind::Server {
return Ok(false);
}
// Default-deny mode (tokens configured, no policy): only Read; Open mode
// (no tokens): all per-graph actions. Matches `authorize` exactly.
if actor.is_some() && action != PolicyAction::Read {
return Ok(false);
}
return Ok(true);
};
let Some(actor) = actor else {
return Err(ApiError::unauthorized("missing bearer token"));
};
engine
.permits_on_any_branch(actor.actor_id.as_ref(), action)
.map_err(|err| ApiError::internal(format!("policy: {err}")))
}
#[utoipa::path(
get,
path = "/snapshot",
@ -1216,7 +1247,7 @@ pub(crate) async fn server_schema_apply(
/// Shared body for `POST /load` (canonical) and `POST /ingest` (deprecated):
/// branch-exists / fork-if-`from` check, Cedar authorization, admission, the
/// bulk `load_as`, and the `IngestOutput` mapping.
async fn run_ingest(
pub(crate) async fn run_ingest(
state: AppState,
handle: Arc<GraphHandle>,
actor: Option<&ResolvedActor>,

View file

@ -1,5 +1,6 @@
pub mod api;
mod handlers;
mod mcp;
mod settings;
pub use settings::{load_server_settings, classify_server_runtime_state, ServerRuntimeState};
use settings::*;
@ -257,6 +258,18 @@ pub struct AppState {
/// resource. Loaded from the cluster-scoped policy binding when
/// configured. Per-graph policies live on each `GraphHandle.policy`.
server_policy: Option<Arc<PolicyEngine>>,
/// MCP host/Origin policy inputs. Default (`None` bind + empty lists)
/// yields a loopback-safe `Unchecked` policy — correct for in-process
/// tests that never bind a socket. `serve()` overrides `mcp_bind` from
/// `listener.local_addr()` so a public bind is fail-closed
/// (`DenyBrowsers`), not silently `Unchecked` (the silent-fail-open
/// guard — see `omnigraph_mcp::McpHostPolicy::from_bind`). `public_hosts`
/// / `browser_origins` are reserved for future cluster/CLI config (empty
/// today: a public bind disables Host-allowlisting and rejects browser
/// Origins until configured).
mcp_bind: Option<std::net::SocketAddr>,
mcp_public_hosts: Vec<String>,
mcp_browser_origins: Vec<String>,
}
struct ExportStreamWriter {
@ -531,6 +544,9 @@ impl AppState {
workload,
bearer_tokens,
server_policy: None,
mcp_bind: None,
mcp_public_hosts: Vec::new(),
mcp_browser_origins: Vec::new(),
}
}
@ -557,6 +573,9 @@ impl AppState {
workload: Arc::new(workload),
bearer_tokens,
server_policy: server_policy.map(Arc::new),
mcp_bind: None,
mcp_public_hosts: Vec::new(),
mcp_browser_origins: Vec::new(),
})
}
@ -567,6 +586,34 @@ impl AppState {
&self.routing
}
/// Install the MCP host/Origin policy inputs from the bound socket.
/// `serve()` calls this after `TcpListener::bind` (reading
/// `local_addr()` — the authoritative bound address, which resolves
/// `0.0.0.0`/hostname binds) and before `build_app`, so the derived
/// policy is fail-closed on a public bind. Tests that build an app
/// without a socket skip this and get the loopback-safe default.
pub fn with_mcp_host_inputs(
mut self,
bind: std::net::SocketAddr,
public_hosts: Vec<String>,
browser_origins: Vec<String>,
) -> Self {
self.mcp_bind = Some(bind);
self.mcp_public_hosts = public_hosts;
self.mcp_browser_origins = browser_origins;
self
}
/// Derive the MCP host/Origin policy from the stored inputs through the
/// single fail-closed constructor. A `None` bind defaults to loopback
/// (`Unchecked`), correct for in-process tests.
pub(crate) fn mcp_host_policy(&self) -> omnigraph_mcp::McpHostPolicy {
let bind = self
.mcp_bind
.unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
omnigraph_mcp::McpHostPolicy::from_bind(&bind, &self.mcp_public_hosts, &self.mcp_browser_origins)
}
fn requires_bearer_auth(&self) -> bool {
if !self.bearer_tokens.is_empty() {
return true;
@ -605,6 +652,20 @@ fn hash_bearer_tokens(bearer_tokens: Vec<(String, String)>) -> Arc<[(BearerToken
}
impl ApiError {
/// HTTP status this error maps to — identical to what `IntoResponse`
/// emits (`self.status`). Used by the MCP `classify` mapper to split
/// semantic 4xx (→ `isError` tool result) from operational 5xx
/// (→ JSON-RPC protocol error).
pub(crate) fn status_code(&self) -> StatusCode {
self.status
}
/// The human-readable message — identical to the `error` field
/// `IntoResponse` puts in the body (`self.message`).
pub(crate) fn message_str(&self) -> &str {
&self.message
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
@ -926,6 +987,12 @@ pub fn build_app(state: AppState) -> Router {
.route("/branches/merge", post(server_branch_merge))
.route("/commits", get(server_commit_list))
.route("/commits/{commit_id}", get(server_commit_show))
// The MCP surface → POST /graphs/{graph_id}/mcp. Merged (not `.route`)
// so its own tower-http body-limit + Origin-guard layers stay scoped to
// /mcp and don't leak onto the REST routes. The two route_layers below
// (bearer + handle) wrap it, so rmcp sees a request whose extensions
// already carry ResolvedActor + Arc<GraphHandle>.
.merge(mcp::mcp_router(state.clone()))
.route_layer(middleware::from_fn_with_state(
state.clone(),
resolve_graph_handle,
@ -1018,6 +1085,15 @@ pub async fn serve(config: ServerConfig) -> Result<()> {
};
let listener = TcpListener::bind(&bind).await?;
// Derive the MCP host/Origin policy from the ACTUAL bound address (not the
// configured `bind` string — `0.0.0.0`/hostname binds resolve only after
// bind). A public bind ⇒ fail-closed `DenyBrowsers`; loopback ⇒ `Unchecked`.
// `public_hosts`/`browser_origins` are empty until cluster/CLI config wires
// them (a public bind then disables Host-allowlisting, with bearer the
// control). Missing this reorder would silently leave a public bind on the
// loopback default — the fail-open class `McpHostPolicy` exists to close.
let local_addr = listener.local_addr()?;
let state = state.with_mcp_host_inputs(local_addr, Vec::new(), Vec::new());
axum::serve(listener, build_app(state))
.with_graceful_shutdown(shutdown_signal())
.await?;

File diff suppressed because it is too large Load diff

View file

@ -95,6 +95,11 @@ impl std::fmt::Display for LoadError {
}
}
/// Sentinel "winner" used to seed the collision check with built-in tool
/// names. Not a valid query symbol (`<`/`>` are not identifier characters), so
/// it can never collide with a real query name.
const BUILTIN_OWNER: &str = "<built-in>";
impl QueryRegistry {
/// Build a registry from in-memory specs: parse each source, select
/// the declaration whose symbol equals the manifest key, and assert
@ -147,14 +152,24 @@ impl QueryRegistry {
// before it is moved into `Self`.
{
let mut claimed: BTreeMap<&str, &str> = BTreeMap::new();
// Built-in MCP tool names are reserved graph-wide. A stored query
// that shadows one would silently never be served (built-ins win at
// dispatch) — the deny-list forbids silent drops, so seed them here
// and fail loudly at load instead.
for builtin in crate::mcp::BUILTIN_TOOL_NAMES {
claimed.insert(builtin, BUILTIN_OWNER);
}
for query in by_name.values().filter(|q| q.expose) {
let tool = query.effective_tool_name();
if let Some(winner) = claimed.insert(tool, &query.name) {
let message = if winner == BUILTIN_OWNER {
format!("MCP tool name '{tool}' is reserved by a built-in tool")
} else {
format!("MCP tool name '{tool}' already claimed by exposed query '{winner}'")
};
errors.push(LoadError {
query: Some(query.name.clone()),
message: format!(
"MCP tool name '{tool}' already claimed by exposed query '{winner}'"
),
message,
});
}
}
@ -167,14 +182,35 @@ impl QueryRegistry {
}
}
/// Resolve by symbol name, **ignoring `expose`**. The raw catalog accessor
/// for HTTP/service callers (`expose:false` queries are deliberately
/// HTTP-callable; see [`StoredQuery::expose`]). The MCP backend must NOT use
/// this — it resolves through [`Self::exposed_by_name`] so the agent surface
/// can never reach a query hidden from the tool list.
pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
self.by_name.get(name)
}
/// Iterate the full catalog, **ignoring `expose`** (HTTP/service surface).
pub fn iter(&self) -> impl Iterator<Item = &StoredQuery> {
self.by_name.values()
}
/// The MCP-reachable catalog: exactly the exposed queries. The single
/// `expose` chokepoint for the agent surface — `tools/list`, the
/// `stored_query_list` tool, and per-query tool dispatch all funnel through
/// it, so they cannot drift on which queries an agent may see or run.
pub fn exposed(&self) -> impl Iterator<Item = &StoredQuery> {
self.by_name.values().filter(|q| q.expose)
}
/// Resolve by symbol name, **exposed-only** — the MCP `stored_query_run`
/// resolver. An unexposed query is unreachable by name through this path
/// even to a caller that knows the name (the agent surface honors `expose`).
pub fn exposed_by_name(&self, name: &str) -> Option<&StoredQuery> {
self.by_name.get(name).filter(|q| q.expose)
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}

View file

@ -0,0 +1,618 @@
//! Black-box tests for the MCP surface (`POST /graphs/{id}/mcp`), driven over
//! `build_app` with in-process tower `oneshot`. Phase 2 covers the read tools,
//! resources, protocol conformance, Cedar-filtered listing, and the server-side
//! Origin fail-closed wiring. (Crate-level transport conformance — 405, the
//! rmcp surface guard — lives in `omnigraph-mcp/tests/standalone.rs`.)
mod support;
use axum::Router;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use omnigraph_server::queries::{QueryRegistry, RegistrySpec};
use omnigraph_server::{AppState, build_app};
use serde_json::{Value, json};
use support::{
FIND_PERSON_GQ, INVOKE_POLICY_YAML, app_for_loaded_graph_with_auth_tokens,
app_for_loaded_graph_with_auth_tokens_and_policy, app_with_stored_queries, g, graph_path,
init_loaded_graph, json_response,
};
/// Build a JSON-RPC POST to `/graphs/default/mcp`. Sets the `Accept` (both
/// JSON + SSE, as rmcp requires) and `Host` (loopback policy allows it) headers,
/// and an optional bearer token.
fn mcp_request(token: Option<&str>, body: Value) -> Request<Body> {
let mut builder = Request::builder()
.uri(g("/mcp"))
.method(Method::POST)
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
builder
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn rpc(id: i64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
#[tokio::test]
async fn initialize_advertises_tools_and_resources() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
1,
"initialize",
json!({
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "0" }
}),
),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["result"]["serverInfo"]["name"], "omnigraph");
assert!(v["result"]["capabilities"]["tools"].is_object());
assert!(v["result"]["capabilities"]["resources"].is_object());
}
fn tool_names(list_result: &Value) -> Vec<String> {
list_result["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn tools_list_returns_builtins_with_no_cursor() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) =
json_response(&app, mcp_request(Some("tok"), rpc(2, "tools/list", json!({})))).await;
assert_eq!(status, StatusCode::OK);
let names = tool_names(&v);
for expected in [
"graph_health",
"graph_query",
"graph_snapshot",
"schema_get",
"branch_list",
"commit_list",
"commit_get",
] {
assert!(names.contains(&expected.to_string()), "missing tool {expected} in {names:?}");
}
// Non-paginated by contract.
assert!(v["result"]["nextCursor"].is_null());
}
#[tokio::test]
async fn graph_health_returns_ok() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(Some("tok"), rpc(3, "tools/call", json!({ "name": "graph_health", "arguments": {} }))),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(v["result"]["isError"], json!(true));
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"status\":\"ok\""), "health payload: {text}");
}
#[tokio::test]
async fn graph_query_runs_a_read() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
4,
"tools/call",
json!({
"name": "graph_query",
"arguments": { "query": "query all() { match { $p: Person } return { $p.name } }" }
}),
),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(v["result"]["isError"], json!(true), "unexpected isError: {v}");
// ReadOutput carries a row_count; the text mirror is the serialized DTO.
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("row_count"), "read output: {text}");
}
#[tokio::test]
async fn malformed_query_is_iserror_not_protocol_error() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
5,
"tools/call",
json!({ "name": "graph_query", "arguments": { "query": "this is not gq" } }),
),
),
)
.await;
assert_eq!(status, StatusCode::OK);
// A bad query is a semantic (4xx) failure → isError tool result, not a
// JSON-RPC protocol error (SEP-1303).
assert_eq!(v["result"]["isError"], json!(true), "expected isError, got {v}");
assert!(v["error"].is_null());
}
#[tokio::test]
async fn unknown_tool_is_invalid_params() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(Some("tok"), rpc(6, "tools/call", json!({ "name": "no_such_tool", "arguments": {} }))),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["error"]["code"], json!(-32602));
}
const READER_ONLY_POLICY: &str = r#"
version: 1
groups:
readers: [act-reader]
protected_branches: [main]
rules:
- id: readers-read
allow:
actors: { group: readers }
actions: [read]
branch_scope: any
"#;
#[tokio::test]
async fn cedar_filters_listing_and_gates_calls() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
&[("act-reader", "tok-r"), ("act-none", "tok-n")],
READER_ONLY_POLICY,
)
.await;
// The reader sees the Read-gated tools.
let (_s, reader) =
json_response(&app, mcp_request(Some("tok-r"), rpc(1, "tools/list", json!({})))).await;
let reader_names = tool_names(&reader);
assert!(reader_names.contains(&"graph_query".to_string()));
assert!(reader_names.contains(&"schema_get".to_string()));
// act-none has no rules → Read denied → only the ungated graph_health shows.
let (_s, none) =
json_response(&app, mcp_request(Some("tok-n"), rpc(2, "tools/list", json!({})))).await;
let none_names = tool_names(&none);
assert_eq!(none_names, vec!["graph_health".to_string()], "denied actor saw {none_names:?}");
// And a denied call surfaces isError (the read gate inside the delegate).
let (status, v) = json_response(
&app,
mcp_request(Some("tok-n"), rpc(3, "tools/call", json!({ "name": "schema_get", "arguments": {} }))),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["result"]["isError"], json!(true), "expected denied schema_get to isError: {v}");
}
#[tokio::test]
async fn resource_read_returns_schema() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(1, "resources/read", json!({ "uri": "omnigraph://schema" })),
),
)
.await;
assert_eq!(status, StatusCode::OK);
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
assert!(text.contains("node Person"), "schema resource: {text}");
}
/// Server-side wiring of the fail-closed Origin policy: a non-loopback bind
/// yields `DenyBrowsers`, so a present `Origin` is `403` while an absent one
/// passes. (The policy logic itself is unit-tested in omnigraph-mcp.)
async fn app_with_public_bind() -> (tempfile::TempDir, Router) {
let temp = init_loaded_graph().await;
let graph = graph_path(temp.path());
let state = AppState::open(graph.to_string_lossy().to_string())
.await
.unwrap()
.with_mcp_host_inputs("203.0.113.1:8080".parse().unwrap(), Vec::new(), Vec::new());
(temp, build_app(state))
}
#[tokio::test]
async fn public_bind_rejects_present_origin() {
let (_t, app) = app_with_public_bind().await;
let init = rpc(
1,
"initialize",
json!({ "protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": { "name": "t", "version": "0" } }),
);
// Present, forged Origin → 403 (origin_guard).
let mut with_origin = mcp_request(None, init.clone());
with_origin
.headers_mut()
.insert("origin", "https://evil.example".parse().unwrap());
// A non-loopback bind also disables Host-allowlisting (allowed_hosts None),
// so the Host header is irrelevant here.
let resp = {
use tower::ServiceExt;
app.clone().oneshot(with_origin).await.unwrap()
};
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// Absent Origin → request proceeds (200).
let (status, _v) = json_response(&app, mcp_request(None, init)).await;
assert_eq!(status, StatusCode::OK);
}
// ===== Phase 3: write tools, stored queries, structured output =====
#[tokio::test]
async fn graph_query_emits_structured_content() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (_s, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
1,
"tools/call",
json!({
"name": "graph_query",
"arguments": { "query": "query all() { match { $p: Person } return { $p.name } }" }
}),
),
),
)
.await;
// Structured output: structuredContent present (never null) + text mirror.
assert!(v["result"]["structuredContent"].is_object(), "no structuredContent: {v}");
assert!(v["result"]["structuredContent"]["row_count"].is_number());
assert!(v["result"]["content"][0]["text"].is_string());
}
#[tokio::test]
async fn graph_mutate_writes_end_to_end() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
1,
"tools/call",
json!({
"name": "graph_mutate",
"arguments": {
"query": "query ins($name: String, $age: I32) { insert Person { name: $name, age: $age } }",
"params": { "name": "McpWrite", "age": 41 },
"branch": "main"
}
}),
),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(v["result"]["isError"], json!(true), "mutate failed: {v}");
assert!(
v["result"]["structuredContent"]["affected_nodes"].as_u64().unwrap_or(0) >= 1,
"expected an inserted node: {v}"
);
}
#[tokio::test]
async fn graph_load_missing_branch_then_fork() {
let (_t, app) = app_for_loaded_graph_with_auth_tokens(&[("act", "tok")]).await;
let line = r#"{"type":"Person","data":{"name":"McpLoaded","age":7}}"#;
// Missing branch + no `from` → 404 → isError (never an implicit fork).
let (_s, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(1, "tools/call", json!({ "name": "graph_load", "arguments": { "data": line, "branch": "nope" } })),
),
)
.await;
assert_eq!(v["result"]["isError"], json!(true), "expected 404 isError: {v}");
// With `from` → forks the branch and loads.
let (_s, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
2,
"tools/call",
json!({ "name": "graph_load", "arguments": { "data": line, "branch": "feature", "from": "main" } }),
),
),
)
.await;
assert_ne!(v["result"]["isError"], json!(true), "fork-and-load failed: {v}");
}
#[tokio::test]
async fn stored_query_projects_as_a_tool_and_runs() {
// 1 exposed query → per_query mode → it appears as its own tool.
let (_t, app) = app_with_stored_queries(
&[("find_person", FIND_PERSON_GQ, true)],
&[("act-invoke", "tok")],
INVOKE_POLICY_YAML,
)
.await;
let (_s, list) =
json_response(&app, mcp_request(Some("tok"), rpc(1, "tools/list", json!({})))).await;
assert!(
tool_names(&list).contains(&"find_person".to_string()),
"stored query not projected: {:?}",
tool_names(&list)
);
// And it runs (params nested under `params`).
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(
2,
"tools/call",
json!({ "name": "find_person", "arguments": { "params": { "name": "Nobody" } } }),
),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(v["result"]["isError"], json!(true), "stored query failed: {v}");
assert!(v["result"]["structuredContent"]["row_count"].is_number());
}
#[tokio::test]
async fn stored_query_invoke_denied_masks_as_unknown_tool() {
// act-noinvoke has `read` but not `invoke_query` → the outer gate denies and
// the stored tool masks byte-identically to an unknown tool.
let (_t, app) = app_with_stored_queries(
&[("find_person", FIND_PERSON_GQ, true)],
&[("act-noinvoke", "tok")],
INVOKE_POLICY_YAML,
)
.await;
let (_s, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(1, "tools/call", json!({ "name": "find_person", "arguments": { "params": {} } })),
),
)
.await;
assert_eq!(v["error"]["code"], json!(-32602));
assert_eq!(
v["error"]["message"].as_str().unwrap(),
"unknown tool: find_person",
"denied stored query must mask as unknown"
);
}
#[tokio::test]
async fn large_catalog_uses_meta_projection() {
// At/above the auto threshold (24 exposed queries) the projection collapses
// to the discovery + execute meta pair instead of N typed tools.
let sources: Vec<(String, String)> = (0..25)
.map(|i| {
let name = format!("q{i}");
let src = format!("query {name}() {{ match {{ $p: Person }} return {{ $p.name }} }}");
(name, src)
})
.collect();
let specs: Vec<(&str, &str, bool)> = sources
.iter()
.map(|(n, s)| (n.as_str(), s.as_str(), true))
.collect();
let (_t, app) =
app_with_stored_queries(&specs, &[("act-invoke", "tok")], INVOKE_POLICY_YAML).await;
let (_s, list) =
json_response(&app, mcp_request(Some("tok"), rpc(1, "tools/list", json!({})))).await;
let names = tool_names(&list);
assert!(names.contains(&"stored_query_list".to_string()), "{names:?}");
assert!(names.contains(&"stored_query_run".to_string()), "{names:?}");
assert!(!names.contains(&"q5".to_string()), "meta mode must not list per-query tools: {names:?}");
// stored_query_run executes one by name.
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(2, "tools/call", json!({ "name": "stored_query_run", "arguments": { "name": "q5" } })),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(v["result"]["isError"], json!(true), "stored_query_run failed: {v}");
}
const PROTECTED_MAIN_WRITE_BRANCHES_POLICY: &str = r#"
version: 1
groups:
writers: [act-writer]
readers: [act-reader]
protected_branches: [main]
rules:
- id: writers-read
allow:
actors: { group: writers }
actions: [read]
branch_scope: any
- id: writers-change-unprotected
allow:
actors: { group: writers }
actions: [change]
branch_scope: unprotected
- id: readers-read
allow:
actors: { group: readers }
actions: [read]
branch_scope: any
"#;
#[tokio::test]
async fn write_tool_listed_when_only_unprotected_writes_allowed() {
// The canonical workflow policy: protected `main`, writable feature branches.
// `graph_mutate`/`graph_load` must be advertised to an actor who can change
// unprotected branches — the per-call gate is authoritative and would allow
// graph_mutate(branch="feature"). Listing probes the action capability on
// *any* branch, not a fabricated `main` (which is protected → denied). A
// read-only actor must still NOT see the write tools.
let (_t, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
&[("act-writer", "tok-w"), ("act-reader", "tok-r")],
PROTECTED_MAIN_WRITE_BRANCHES_POLICY,
)
.await;
let (_s, w) =
json_response(&app, mcp_request(Some("tok-w"), rpc(1, "tools/list", json!({})))).await;
let w_names = tool_names(&w);
assert!(
w_names.contains(&"graph_mutate".to_string()),
"graph_mutate hidden from an unprotected-branch writer (under-show): {w_names:?}"
);
assert!(w_names.contains(&"graph_load".to_string()), "graph_load hidden: {w_names:?}");
let (_s, r) =
json_response(&app, mcp_request(Some("tok-r"), rpc(2, "tools/list", json!({})))).await;
let r_names = tool_names(&r);
assert!(
!r_names.contains(&"graph_mutate".to_string()),
"graph_mutate shown to a read-only actor (over-show regression): {r_names:?}"
);
assert!(
r_names.contains(&"graph_query".to_string()),
"reader should still see read tools: {r_names:?}"
);
}
#[tokio::test]
async fn per_query_mode_does_not_expose_meta_tools() {
// Below the auto threshold the projection is per-query, so the discovery +
// execute meta pair was never advertised. It must not be callable either —
// `call_tool` resolves a stored tool through the same projection `tools/list`
// renders, so list and call cannot diverge.
let (_t, app) = app_with_stored_queries(
&[("find_person", FIND_PERSON_GQ, true)],
&[("act-invoke", "tok")],
INVOKE_POLICY_YAML,
)
.await;
for tool in ["stored_query_run", "stored_query_list"] {
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(1, "tools/call", json!({ "name": tool, "arguments": { "name": "find_person" } })),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["error"]["code"], json!(-32602), "{tool} must be unknown in per_query mode: {v}");
assert_eq!(
v["error"]["message"].as_str().unwrap(),
format!("unknown tool: {tool}"),
"{tool} must mask as unknown when the projection didn't advertise it"
);
}
}
#[tokio::test]
async fn stored_query_run_cannot_reach_unexposed_query() {
// Meta projection (24 exposed) plus one unexposed `hidden`. `stored_query_run`
// must not resolve the unexposed query even to a caller that knows its name —
// the agent surface honors `expose`, like every other stored-query path.
// (`expose:false` stays HTTP/service-callable; this is the MCP boundary only.)
let exposed: Vec<(String, String)> = (0..24)
.map(|i| {
let name = format!("q{i}");
let src = format!("query {name}() {{ match {{ $p: Person }} return {{ $p.name }} }}");
(name, src)
})
.collect();
let hidden_src = "query hidden() { match { $p: Person } return { $p.name } }";
let mut specs: Vec<(&str, &str, bool)> =
exposed.iter().map(|(n, s)| (n.as_str(), s.as_str(), true)).collect();
specs.push(("hidden", hidden_src, false));
let (_t, app) =
app_with_stored_queries(&specs, &[("act-invoke", "tok")], INVOKE_POLICY_YAML).await;
// Confirm the meta projection is in force (so stored_query_run exists), and
// that the unexposed query is not discoverable via stored_query_list.
let (_s, list) =
json_response(&app, mcp_request(Some("tok"), rpc(1, "tools/list", json!({})))).await;
assert!(tool_names(&list).contains(&"stored_query_run".to_string()), "{:?}", tool_names(&list));
let (_s, listed) = json_response(
&app,
mcp_request(Some("tok"), rpc(2, "tools/call", json!({ "name": "stored_query_list", "arguments": {} }))),
)
.await;
let catalog = listed["result"]["structuredContent"]["queries"].as_array().unwrap();
assert!(
catalog.iter().all(|q| q["name"] != json!("hidden")),
"unexposed query leaked into stored_query_list: {listed}"
);
// Running the unexposed query by name → not found (isError), never executed.
let (status, v) = json_response(
&app,
mcp_request(
Some("tok"),
rpc(3, "tools/call", json!({ "name": "stored_query_run", "arguments": { "name": "hidden" } })),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["result"]["isError"], json!(true), "unexposed query must not run via stored_query_run: {v}");
}
#[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
// at registry load, never be silently un-served.
let result = QueryRegistry::from_specs(vec![RegistrySpec {
name: "graph_query".to_string(),
source: "query graph_query() { match { $p: Person } return { $p.name } }".to_string(),
expose: true,
tool_name: None,
}]);
let errors = result.expect_err("expected a collision error");
assert!(
errors.iter().any(|e| e.message.contains("reserved by a built-in")),
"expected built-in reservation error, got {errors:?}"
);
}