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

@ -14,3 +14,7 @@ omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.0" }
serde = { workspace = true }
serde_json = { workspace = true }
utoipa = { workspace = true }
[dev-dependencies]
# Faithful `pattern` enforcement in the schema/coercer equivalence test.
regex = { workspace = true }

View file

@ -11,7 +11,7 @@ use omnigraph_compiler::query::ast::Param;
use omnigraph_compiler::result::QueryResult;
use omnigraph_compiler::types::{PropType, ScalarType};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{Value, json};
use utoipa::{IntoParams, ToSchema};
/// Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.
@ -459,6 +459,68 @@ pub fn param_descriptor(param: &Param) -> ParamDescriptor {
}
}
/// JSON Schema (2020-12) for a scalar param kind. **Superset of the engine
/// coercer** (`omnigraph_compiler::coerce_param_typed`, Standard mode): a
/// too-narrow schema would make a strict client reject inputs the engine
/// accepts; a too-wide one reaches the coercer and surfaces as an `isError`
/// tool result for model self-correction (SEP-1303). Locked to the coercer by
/// `tests/schema_equivalence.rs`. Exhaustive + wildcard-free: adding a
/// `ParamKind` is a compile error until its arm (and corpus row) exist.
fn scalar_schema(kind: ParamKind) -> Value {
match kind {
ParamKind::String => json!({ "type": "string" }),
ParamKind::Bool => json!({ "type": "boolean" }),
// Standard-mode integer coercion accepts a JSON number OR a numeric
// string (i64/u64 lose precision past 2^53 as a JSON number), so the
// schema accepts both; range/sign are the coercer's to enforce.
ParamKind::Int | ParamKind::BigInt => json!({
"anyOf": [ { "type": "integer" }, { "type": "string", "pattern": r"^-?\d+$" } ]
}),
ParamKind::Float => json!({ "type": "number" }),
// Date/DateTime/Blob coerce from any string; `format` is an advisory
// annotation (non-asserting in 2020-12), so the schema accepts exactly
// what the coercer does while still hinting the shape to clients.
ParamKind::Date => json!({ "type": "string", "format": "date" }),
ParamKind::DateTime => json!({ "type": "string", "format": "date-time" }),
ParamKind::Blob => json!({ "type": "string", "format": "uri" }),
ParamKind::Vector | ParamKind::List => {
unreachable!("composite kinds are handled in param_json_schema")
}
}
}
/// The JSON Schema (2020-12) for a stored-query parameter — the single mapping
/// both the OpenAPI catalog and the MCP tool projection consume, applying the
/// nullable rule uniformly. See [`scalar_schema`] for the superset contract.
pub fn param_json_schema(p: &ParamDescriptor) -> Value {
let base = match p.kind {
ParamKind::Vector => {
let mut schema = json!({ "type": "array", "items": { "type": "number" } });
if let Some(dim) = p.vector_dim {
schema["minItems"] = json!(dim);
schema["maxItems"] = json!(dim);
}
schema
}
ParamKind::List => {
let item = p
.item_kind
.map(scalar_schema)
.unwrap_or_else(|| json!({ "type": "string" }));
json!({ "type": "array", "items": item })
}
scalar => scalar_schema(scalar),
};
// The coercer accepts explicit `null` for a nullable param (and its
// omission); a strict client would reject `null` against the bare scalar.
// Allow null at the schema level for nullable params.
if p.nullable {
json!({ "anyOf": [ base, { "type": "null" } ] })
} else {
base
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
pub struct SchemaApplyRequest {

View file

@ -0,0 +1,149 @@
//! The lock that makes `param_json_schema` correct *by construction*: for a
//! fixed corpus, the generated JSON Schema must accept **at least** every value
//! the engine coercer (`coerce_param_typed`, Standard mode — the mode the
//! stored-query invoke path uses) accepts. A schema narrower than the coercer
//! would make a strict client reject inputs the engine would have taken; a
//! wider one is fine (it reaches the coercer and surfaces as `isError`,
//! SEP-1303). Drift in either direction-of-acceptance turns this red.
//!
//! Keyed by **engine type-name string** (`"I32"`, `"U64"`, `"Vector(3)"`,
//! `"[I32]"`), not `ParamKind`, because the coercer keys on the type-name and
//! `ParamKind` is a lossy bucket (`I32`/`U32` → `Int`). The descriptor is built
//! through the real projection (`param_descriptor`).
use omnigraph_api_types::{param_descriptor, param_json_schema};
use omnigraph_compiler::query::ast::Param;
use omnigraph_compiler::{JsonParamMode, coerce_param_typed};
use serde_json::{Value, json};
/// A faithful validator for the closed schema vocabulary `param_json_schema`
/// emits: `type` (string/integer/number/boolean/array/null), `anyOf`, `items`,
/// `minItems`/`maxItems`, and `pattern` (enforced via `regex`). It interprets
/// the *emitted* schema JSON — not a hardcoded copy — so it tracks generator
/// changes. A new construct the generator emits would need a new arm here.
fn schema_accepts(schema: &Value, value: &Value) -> bool {
if let Some(any_of) = schema.get("anyOf").and_then(Value::as_array) {
return any_of.iter().any(|s| schema_accepts(s, value));
}
match schema.get("type").and_then(Value::as_str) {
Some("string") => {
let Some(s) = value.as_str() else { return false };
match schema.get("pattern").and_then(Value::as_str) {
Some(pat) => regex::Regex::new(pat).unwrap().is_match(s),
None => true,
}
}
Some("integer") => value.as_i64().is_some() || value.as_u64().is_some(),
Some("number") => value.is_number(),
Some("boolean") => value.is_boolean(),
Some("null") => value.is_null(),
Some("array") => {
let Some(arr) = value.as_array() else { return false };
if let Some(min) = schema.get("minItems").and_then(Value::as_u64) {
if (arr.len() as u64) < min {
return false;
}
}
if let Some(max) = schema.get("maxItems").and_then(Value::as_u64) {
if (arr.len() as u64) > max {
return false;
}
}
match schema.get("items") {
Some(items) => arr.iter().all(|el| schema_accepts(items, el)),
None => true,
}
}
other => panic!("schema_accepts: unhandled schema {other:?} in {schema}"),
}
}
fn descriptor(type_name: &str, nullable: bool) -> omnigraph_api_types::ParamDescriptor {
param_descriptor(&Param {
name: "p".to_string(),
type_name: type_name.to_string(),
nullable,
})
}
/// Every engine scalar/composite type-name a stored-query param can declare.
const TYPE_NAMES: &[&str] = &[
"String", "Bool", "I32", "I64", "U32", "U64", "F32", "F64", "Date", "DateTime", "Blob",
"Vector(3)", "[I32]", "[String]",
];
/// A broad value bag thrown at every type-name; the coercer decides which it
/// accepts, and the schema must accept at least those.
fn corpus() -> Vec<Value> {
vec![
json!("hello"),
json!("AAEC"), // base64-looking → still just a string (Blob)
json!("2024-01-02"), // date string
json!("2024-01-02T03:04:05Z"), // datetime string
json!("og://blob/abc"), // blob URI
json!(5),
json!(-5),
json!(0),
json!(5_000_000_000i64), // fits i64/u64, exceeds i32
json!(99_999_999_999u64),
json!("5"),
json!("-5"),
json!("9999999999999999999999"), // exceeds i64 even as string
json!(1.5),
json!(true),
json!([1.0, 2.0, 3.0]),
json!([1, 2, 3]),
json!([1, 2]),
json!(["a", "b"]),
json!({ "k": 1 }),
]
}
#[test]
fn schema_is_a_superset_of_the_coercer() {
for &type_name in TYPE_NAMES {
let schema = param_json_schema(&descriptor(type_name, false));
for value in corpus() {
// The coercer is the authority; null is handled by the parent
// (`json_params_to_param_map`), not `coerce_param_typed`, so skip it
// here — the null rule is pinned separately below.
if value.is_null() {
continue;
}
if coerce_param_typed("p", &value, type_name, JsonParamMode::Standard).is_ok() {
assert!(
schema_accepts(&schema, &value),
"type {type_name}: coercer accepts {value} but schema {schema} rejects it"
);
}
}
}
}
#[test]
fn nullable_rule_matches_the_parent_coercer() {
// The parent coercer accepts explicit `null` iff the param is nullable.
// `param_json_schema` must mirror that at the schema level.
for &type_name in TYPE_NAMES {
let nullable = param_json_schema(&descriptor(type_name, true));
let non_nullable = param_json_schema(&descriptor(type_name, false));
assert!(
schema_accepts(&nullable, &Value::Null),
"type {type_name}: nullable schema must accept null ({nullable})"
);
assert!(
!schema_accepts(&non_nullable, &Value::Null),
"type {type_name}: non-nullable schema must reject null ({non_nullable})"
);
}
}
#[test]
fn vector_dim_bounds_are_present_or_omitted() {
let with_dim = param_json_schema(&descriptor("Vector(4)", false));
assert_eq!(with_dim["minItems"], json!(4));
assert_eq!(with_dim["maxItems"], json!(4));
// A four-element array validates; three or five do not.
assert!(schema_accepts(&with_dim, &json!([1.0, 2.0, 3.0, 4.0])));
assert!(!schema_accepts(&with_dim, &json!([1.0, 2.0, 3.0])));
}

View file

@ -27,7 +27,7 @@ pub use query::lint::{
lint_query_file,
};
pub use query_input::{
JsonParamMode, RunInputError, RunInputResult, ToParam, find_named_query,
JsonParamMode, RunInputError, RunInputResult, ToParam, coerce_param_typed, find_named_query,
json_params_to_param_map,
};
pub use result::{MutationExecResult, MutationResult, QueryResult, RunResult};

View file

@ -322,6 +322,23 @@ pub fn json_params_to_param_map(
Ok(map)
}
/// Coerce one JSON value to a typed [`Literal`] by the engine's input
/// contract — the single authority for what a param accepts. Exposed so the
/// shared `param_json_schema` projection (in `omnigraph-api-types`) can be
/// locked to this coercer by an equivalence test: the JSON Schema a client
/// validates against must accept at least what this accepts, or a strict
/// client would reject inputs the engine would have taken. Does **not** apply
/// the nullable rule — explicit `null` is handled by [`json_params_to_param_map`],
/// not here.
pub fn coerce_param_typed(
key: &str,
value: &Value,
type_name: &str,
mode: JsonParamMode,
) -> RunInputResult<Literal> {
json_value_to_literal_typed(key, value, type_name, mode)
}
fn json_value_to_literal_typed(
key: &str,
value: &Value,

View file

@ -0,0 +1,28 @@
[package]
name = "omnigraph-mcp"
version = "0.7.0"
edition = "2024"
description = "MCP (Model Context Protocol) Streamable-HTTP transport and backend seam for Omnigraph. Contains the rmcp dependency and defines the McpBackend trait the server implements; names no omnigraph engine/server type, so the dependency edge is server → mcp."
license = "MIT"
repository = "https://github.com/ModernRelay/omnigraph"
homepage = "https://github.com/ModernRelay/omnigraph"
documentation = "https://docs.rs/omnigraph-mcp"
[dependencies]
# rmcp is contained to this crate. `server` + `transport-streamable-http-server`
# give the StreamableHttpService tower wiring. Do NOT enable rmcp's `local`
# feature — it cfg's the tower wiring out (transport mod is gated on
# `not(feature = "local")`).
rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"] }
axum = { workspace = true }
http = "1"
# `limit` adds RequestBodyLimitLayer; features are additive with the workspace's
# `trace`. rmcp reads the body directly (no axum extractor), so axum's
# DefaultBodyLimit does not bound /mcp — this layer is the real bound.
tower-http = { workspace = true, features = ["limit"] }
tokio = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tower = { workspace = true }

View file

@ -0,0 +1,73 @@
//! MCP (Model Context Protocol) server surface for Omnigraph, served over
//! **stateless Streamable HTTP**.
//!
//! This crate owns the `rmcp` dependency and the transport wiring. It defines a
//! single seam — the [`McpBackend`] trait — that the server crate implements.
//! The crate **never names an omnigraph type**: the backend reads its own types
//! (resolved actor, graph handle, …) out of `parts.extensions`, so the
//! dependency edge is `server → mcp` (never the reverse — a `mcp → server` edge
//! would cycle the binary at `server-bin → omnigraph-mcp → server-lib`).
//!
//! The transport is **stateless JSON over a single `/mcp` POST**: no SSE stream,
//! no `Mcp-Session-Id`, every request independent. See [`transport`].
use async_trait::async_trait;
mod service;
pub mod transport;
pub use transport::{McpHostPolicy, OriginPolicy, mcp_router};
// rmcp model types re-exported so the server speaks rmcp via `omnigraph_mcp::…`
// and carries no direct rmcp dependency.
pub use rmcp::ErrorData as McpError; // JSON-RPC error: invalid_params=-32602, internal_error=-32603
pub use rmcp::model::{
CallToolResult, Content, Extensions, Implementation, RawResource, ReadResourceResult, Resource,
ResourceContents, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
};
/// A JSON object — the shape of tool arguments and JSON Schema documents.
/// Identical to `rmcp::model::JsonObject` (`serde_json::Map<String, Value>`).
pub type JsonObject = serde_json::Map<String, serde_json::Value>;
/// The seam the server fills. One implementor (`OmnigraphMcpBackend`); the boxed
/// future from `#[async_trait]` is negligible at MCP QPS.
///
/// **The list seam is non-paginated by contract.** `list_tools`/`list_resources`
/// return the *full* set, so the service always emits `nextCursor: null`. The
/// catalog is bounded by construction (a fixed set of built-ins; large
/// stored-query catalogs collapse to a discovery + execute meta-tool pair rather
/// than leaning on `tools/list` paging). The `Vec<T>` return type *is* that
/// contract; a future paging need is a signature change, not a doc promise.
///
/// Each method receives the request's [`http::request::Parts`]; the backend reads
/// its own injected extensions (`parts.extensions.get::<T>()`) — the decoupling
/// mechanism that keeps this crate free of omnigraph types and auth-method
/// agnostic.
#[async_trait]
pub trait McpBackend: Clone + Send + Sync + 'static {
/// Server identity + advertised capabilities (`initialize` response).
fn server_info(&self) -> ServerInfo;
/// The full, Cedar-filtered tool set for this request's actor + graph.
async fn list_tools(&self, parts: &http::request::Parts) -> Result<Vec<Tool>, McpError>;
/// Dispatch a tool call. The authoritative authorization gate.
async fn call_tool(
&self,
parts: &http::request::Parts,
name: &str,
args: JsonObject,
) -> Result<CallToolResult, McpError>;
/// The full, Cedar-filtered resource set for this request's actor + graph.
async fn list_resources(&self, parts: &http::request::Parts)
-> Result<Vec<Resource>, McpError>;
/// Read one resource by URI.
async fn read_resource(
&self,
parts: &http::request::Parts,
uri: &str,
) -> Result<ReadResourceResult, McpError>;
}

View file

@ -0,0 +1,80 @@
//! `McpService<B>` — the rmcp `ServerHandler` adapter. Pulls the request's
//! `http::request::Parts` out of the context once and delegates each method to
//! the [`McpBackend`]. Maps the backend's non-paginated `Vec<T>` returns to
//! rmcp's `List*Result` with `next_cursor: None`.
use rmcp::ServerHandler;
use rmcp::ErrorData as McpError;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ListResourcesResult, ListToolsResult,
PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerInfo,
};
use rmcp::service::{RequestContext, RoleServer};
use crate::McpBackend;
#[derive(Clone)]
pub(crate) struct McpService<B: McpBackend> {
backend: B,
}
impl<B: McpBackend> McpService<B> {
pub(crate) fn new(backend: B) -> Self {
Self { backend }
}
/// The HTTP `Parts` injected by `StreamableHttpService` into the request
/// context extensions (`tower.rs` does `request.into_parts()` then
/// `req.request.extensions_mut().insert(part)`). Absent only on an internal
/// wiring error, not a client-reachable path.
fn parts(ctx: &RequestContext<RoleServer>) -> Result<&http::request::Parts, McpError> {
ctx.extensions
.get::<http::request::Parts>()
.ok_or_else(|| McpError::internal_error("request parts missing from MCP context", None))
}
}
impl<B: McpBackend> ServerHandler for McpService<B> {
fn get_info(&self) -> ServerInfo {
self.backend.server_info()
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let parts = Self::parts(&context)?;
let tools = self.backend.list_tools(parts).await?;
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let parts = Self::parts(&context)?;
let args = request.arguments.unwrap_or_default();
self.backend.call_tool(parts, &request.name, args).await
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let parts = Self::parts(&context)?;
let resources = self.backend.list_resources(parts).await?;
Ok(ListResourcesResult::with_all_items(resources))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let parts = Self::parts(&context)?;
self.backend.read_resource(parts, &request.uri).await
}
}

View file

@ -0,0 +1,158 @@
//! Stateless Streamable-HTTP transport for the MCP surface.
//!
//! One endpoint: `POST /mcp` returns a single `application/json` object; no SSE,
//! no session id (`NeverSessionManager` + `stateful_mode = false`). rmcp gives,
//! for free in stateless mode: `GET`/`DELETE → 405 + Allow: POST`, a disallowed
//! `Host → 403`, and an unsupported `MCP-Protocol-Version → 400` on
//! **non-`initialize`** requests. `initialize` is exempt by design — it
//! negotiates the version in its JSON-RPC body (`protocolVersion`), not the HTTP
//! header, so a bogus header there is ignored (absent ⇒ rmcp's default version).
//!
//! The one thing rmcp does **not** give is fail-closed Origin: it validates
//! `Origin` only when `allowed_origins` is non-empty (an empty list is
//! *fail-open*). [`origin_guard`] closes that — a present, disallowed `Origin`
//! is `403` regardless — and [`McpHostPolicy`] has no "absent ⇒ skip" state, so a
//! remote deployment cannot accidentally run fail-open.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::middleware::{Next, from_fn_with_state};
use axum::response::{IntoResponse, Response};
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::never::NeverSessionManager,
};
use crate::McpBackend;
use crate::service::McpService;
/// Browser-`Origin` posture as a **total** choice — there is no `None ⇒ skip`
/// state to leak into a fail-open default. Every deployment lands in exactly one
/// arm, chosen once by [`McpHostPolicy::from_bind`].
#[derive(Debug, Clone)]
pub enum OriginPolicy {
/// Browser clients from these origins; any OTHER present `Origin` → `403`.
Allow(Vec<String>),
/// No browser clients expected; ANY present `Origin` → `403`. Non-browser
/// MCP clients (the launch tier) send no `Origin` and pass. The remote
/// default.
DenyBrowsers,
/// Explicit opt-out (loopback dev / trusted network) — never the remote
/// default.
Unchecked,
}
/// Host + Origin posture, derived together from the deployment. The struct has
/// no skip-by-absence state, and [`from_bind`](Self::from_bind) is the only
/// constructor, so a fail-open policy is unrepresentable.
#[derive(Debug, Clone)]
pub struct McpHostPolicy {
/// `None` ⇒ accept any `Host` (DNS-rebinding defense relaxed for a known
/// public bind; bearer is the real control there).
pub allowed_hosts: Option<Vec<String>>,
/// Total — no `Option`.
pub origin: OriginPolicy,
}
impl McpHostPolicy {
/// The only constructor. Host and Origin posture are derived together from
/// the bind + config, **fail-closed**: a remote bind with no configured
/// origins is `DenyBrowsers` (a present `Origin` is rejected), NOT "skip".
pub fn from_bind(bind: &SocketAddr, public_hosts: &[String], browser_origins: &[String]) -> Self {
let loopback = bind.ip().is_loopback();
Self {
allowed_hosts: if loopback {
// A loopback bind accepts every loopback Host form, not just the
// stack it bound: the Host header is independent of the socket
// (in-process tests, reverse proxies, dual-stack `localhost`
// resolution), so a `127.0.0.1`-bound server must still accept a
// `[::1]` Host and vice-versa. This mirrors rmcp's own default
// loopback set; deriving the list from `bind.ip()` alone dropped
// the sibling-stack literal and 403'd legitimate loopback clients.
Some(vec!["127.0.0.1".into(), "::1".into(), "localhost".into()])
} else if public_hosts.is_empty() {
None
} else {
Some(public_hosts.to_vec())
},
origin: if !browser_origins.is_empty() {
OriginPolicy::Allow(browser_origins.to_vec())
} else if loopback {
OriginPolicy::Unchecked
} else {
OriginPolicy::DenyBrowsers
},
}
}
}
/// Fail-closed Origin enforcement, run BEFORE rmcp so it is independent of
/// rmcp's empty-`allowed_origins` fail-open semantics. A *present* `Origin` that
/// the policy disallows → `403`; an *absent* `Origin` always passes (non-browser
/// MCP clients send none); `Unchecked` is a no-op.
async fn origin_guard(State(origin): State<OriginPolicy>, request: Request, next: Next) -> Response {
let header = request
.headers()
.get(http::header::ORIGIN)
.and_then(|v| v.to_str().ok());
let allowed = match header {
None => true,
Some(o) => match &origin {
OriginPolicy::Unchecked => true,
OriginPolicy::Allow(list) => list.iter().any(|a| a == o),
OriginPolicy::DenyBrowsers => false,
},
};
if allowed {
next.run(request).await
} else {
(http::StatusCode::FORBIDDEN, "Forbidden: Origin not allowed").into_response()
}
}
/// Build the `/mcp` router for a backend. The returned router carries its own
/// Origin guard and body-limit layer; merge (not `.route`) it into the
/// per-graph group so the body limit does not leak onto sibling routes.
///
/// Generic over the router state `S`: the `/mcp` route is a `route_service`
/// with no state-bearing extractors, so it composes with any caller's state
/// type (e.g. the server merges it into a `Router<AppState>` before
/// `.with_state`). A standalone caller pins `S = ()` via the return-type
/// annotation.
pub fn mcp_router<B, S>(backend: B, body_limit: usize, hosts: McpHostPolicy) -> axum::Router<S>
where
B: McpBackend,
S: Clone + Send + Sync + 'static,
{
// `StreamableHttpServerConfig` is `#[non_exhaustive]`; its Default is
// stateful_mode=true, json_response=false, allowed_hosts=loopback. Build
// from Default and flip via the with_* setters for a remote stateless JSON
// server.
let mut config = StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_json_response(true);
config = match &hosts.allowed_hosts {
Some(list) => config.with_allowed_hosts(list.clone()),
None => config.disable_allowed_hosts(),
};
// `Allow` also configures rmcp as defense-in-depth; `DenyBrowsers` cannot be
// expressed to rmcp (empty list ⇒ rmcp skips), so `origin_guard` is the
// fail-closed authority.
if let OriginPolicy::Allow(origins) = &hosts.origin {
config = config.with_allowed_origins(origins.clone());
}
// service_factory returns Result<S, io::Error>; NeverSessionManager pairs
// with stateless mode (rejects every session op).
let svc = StreamableHttpService::new(
move || Ok(McpService::new(backend.clone())),
Arc::new(NeverSessionManager::default()),
config,
);
axum::Router::<S>::new()
.route_service("/mcp", svc)
.layer(from_fn_with_state(hosts.origin, origin_guard))
.layer(tower_http::limit::RequestBodyLimitLayer::new(body_limit))
}

View file

@ -0,0 +1,247 @@
//! The `omnigraph-mcp` crate stands alone: a trivial `McpBackend` drives the
//! real transport with no omnigraph dependency. Also the **rmcp surface guard**
//! — the first smoke check on any rmcp version bump (mirrors the engine's
//! `lance_surface_guards.rs`).
use std::sync::Arc;
use async_trait::async_trait;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use omnigraph_mcp::{
CallToolResult, Content, Implementation, McpBackend, McpError, McpHostPolicy, OriginPolicy,
ReadResourceResult, Resource, ServerCapabilities, ServerInfo, Tool, mcp_router,
};
use serde_json::{Value, json};
use tower::ServiceExt;
#[derive(Clone)]
struct Dummy;
#[async_trait]
impl McpBackend for Dummy {
fn server_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
.with_server_info(Implementation::new("omnigraph-test", "0.0.0"))
}
async fn list_tools(&self, _parts: &http::request::Parts) -> Result<Vec<Tool>, McpError> {
let schema = json!({ "type": "object", "properties": {}, "additionalProperties": false });
let schema = schema.as_object().unwrap().clone();
Ok(vec![Tool::new(
"graph_health",
"Liveness probe.",
Arc::new(schema),
)])
}
async fn call_tool(
&self,
_parts: &http::request::Parts,
name: &str,
_args: omnigraph_mcp::JsonObject,
) -> Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::text(format!(
"called {name}"
))]))
}
async fn list_resources(
&self,
_parts: &http::request::Parts,
) -> Result<Vec<Resource>, McpError> {
Ok(vec![])
}
async fn read_resource(
&self,
_parts: &http::request::Parts,
_uri: &str,
) -> Result<ReadResourceResult, McpError> {
Err(McpError::invalid_params("no resources", None))
}
}
fn loopback_router() -> axum::Router {
let policy = McpHostPolicy::from_bind(&"127.0.0.1:0".parse().unwrap(), &[], &[]);
mcp_router(Dummy, 1 << 20, policy)
}
fn mcp_post(body: Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/mcp")
.header(header::HOST, "localhost")
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json, text/event-stream")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
async fn json_body(resp: axum::response::Response) -> Value {
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn initialize_advertises_tools_and_resources() {
let resp = loopback_router()
.oneshot(mcp_post(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "0" }
}
})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = json_body(resp).await;
assert_eq!(v["result"]["serverInfo"]["name"], "omnigraph-test");
assert!(v["result"]["capabilities"]["tools"].is_object());
assert!(v["result"]["capabilities"]["resources"].is_object());
}
#[tokio::test]
async fn tools_list_returns_full_set_with_no_next_cursor() {
let resp = loopback_router()
.oneshot(mcp_post(json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}
})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = json_body(resp).await;
let tools = v["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"], "graph_health");
// Non-paginated by contract: nextCursor is absent (None ⇒ omitted).
assert!(v["result"]["nextCursor"].is_null());
}
#[tokio::test]
async fn get_is_method_not_allowed() {
let resp = loopback_router()
.oneshot(
Request::builder()
.method("GET")
.uri("/mcp")
.header(header::HOST, "localhost")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(resp.headers()[header::ALLOW], "POST");
}
#[tokio::test]
async fn deny_browsers_rejects_present_origin_but_allows_absent() {
// A remote-shaped policy: any present Origin is forbidden; absent passes.
let policy = McpHostPolicy {
allowed_hosts: None,
origin: OriginPolicy::DenyBrowsers,
};
let router: axum::Router = mcp_router(Dummy, 1 << 20, policy);
let init = json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": { "name": "t", "version": "0" } }
});
// Present, disallowed Origin → 403 (origin_guard, not rmcp's empty-list path).
let mut with_origin = mcp_post(init.clone());
with_origin
.headers_mut()
.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
let resp = router.clone().oneshot(with_origin).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// Absent Origin → 200 (non-browser MCP clients send none).
let resp = router.oneshot(mcp_post(init)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn loopback_bind_allows_all_loopback_host_forms() {
// A loopback bind must accept *every* loopback Host form — 127.0.0.1, [::1],
// and localhost — regardless of which stack it bound. The Host header is
// independent of the socket (in-process, reverse proxies, dual-stack
// `localhost`), so a `127.0.0.1`-bound server must still accept a `[::1]`
// Host. (Matches rmcp's default loopback set; deriving the list from the
// bound IP alone dropped the sibling-stack literal and 403'd the client.)
for bind in ["127.0.0.1:8080", "[::1]:8080"] {
let policy = McpHostPolicy::from_bind(&bind.parse().unwrap(), &[], &[]);
let hosts = policy.allowed_hosts.clone().unwrap();
for expected in ["127.0.0.1", "::1", "localhost"] {
assert!(
hosts.iter().any(|h| h == expected),
"bind {bind}: loopback allowlist missing {expected}: {hosts:?}"
);
}
// e2e: the IPv6 loopback Host is accepted even on the IPv4 bind.
let router: axum::Router = mcp_router(Dummy, 1 << 20, policy);
let mut req = mcp_post(json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": { "name": "t", "version": "0" } }
}));
req.headers_mut().insert(header::HOST, "[::1]:8080".parse().unwrap());
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "bind {bind}: IPv6 loopback Host rejected");
}
}
#[tokio::test]
async fn unsupported_protocol_version_header_is_400_except_on_initialize() {
// rmcp validates `MCP-Protocol-Version` on non-`initialize` requests only:
// `initialize` negotiates the version in its JSON-RPC body, so a bogus header
// there is ignored (200), while the same header on a follow-up request is a
// 400. Pins the real contract (the transport doc-comment notes this).
// `initialize` + bogus version header → 200 (header not validated on init).
let mut init = mcp_post(json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": { "name": "t", "version": "0" } }
}));
init.headers_mut()
.insert("mcp-protocol-version", "1900-01-01".parse().unwrap());
let resp = loopback_router().oneshot(init).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "initialize must not validate the version header");
// A follow-up request (`tools/list`) + bogus version header → 400.
let mut list = mcp_post(json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }));
list.headers_mut()
.insert("mcp-protocol-version", "1900-01-01".parse().unwrap());
let resp = loopback_router().oneshot(list).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "non-init bogus version must be 400");
}
/// rmcp surface guard — pins the API shapes the transport relies on. Turns red
/// (compile error) on an rmcp bump that renames/moves any of these. Compile-only.
#[allow(dead_code)]
fn _rmcp_surface_guard() {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, session::never::NeverSessionManager,
};
let _config = StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_json_response(true)
.disable_allowed_hosts()
.with_allowed_origins(["https://app.example".to_string()]);
let _session = NeverSessionManager::default();
// The Parts passthrough: rmcp's RequestContext extensions hold the HTTP parts.
fn _reads_parts(ctx: &rmcp::service::RequestContext<rmcp::service::RoleServer>) {
let _parts: Option<&http::request::Parts> = ctx.extensions.get::<http::request::Parts>();
}
}

View file

@ -6,7 +6,7 @@ use std::str::FromStr;
use cedar_policy::{
Authorizer, Context, Decision, Entities, Entity, EntityId, EntityTypeName, EntityUid, Policy,
PolicyId, PolicySet, Request, Schema, ValidationMode, Validator,
PolicyId, PolicySet, Request, Response, Schema, ValidationMode, Validator,
};
use clap::ValueEnum;
use color_eyre::eyre::{Result, bail, eyre};
@ -506,6 +506,80 @@ impl PolicyEngine {
/// the "server-authoritative actor identity" invariant — clients
/// supplying a `PolicyRequest` cannot smuggle identity through the
/// same struct that carries the requested action.
/// Evaluate one Cedar request for `actor_id` performing `action` in the
/// given branch `context`, returning the raw decision response. The single
/// Cedar entry point shared by [`Self::authorize`] (per-call gate) and
/// [`Self::permits_on_any_branch`] (list-time capability probe) — there is
/// no second matching implementation that could drift from the policy set.
fn evaluate(&self, actor_id: &str, action: PolicyAction, context: serde_json::Value) -> Result<Response> {
let principal = entity_uid("Actor", actor_id)?;
let action_uid = entity_uid("Action", action.as_str())?;
// Pick the resource entity based on the action's `resource_kind`.
// Server-scoped actions (`graph_list`) bind to
// `Omnigraph::Server::"root"`; per-graph actions bind to
// `Omnigraph::Graph::"<graph_label>"`.
let resource = match action.resource_kind() {
PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?,
PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?,
};
let context = Context::from_json_value(context, Some((&self.schema, &action_uid)))?;
let cedar_request = Request::new(principal, action_uid, resource, context, Some(&self.schema))?;
let response =
Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
let errors = response
.diagnostics()
.errors()
.map(|err| err.to_string())
.collect::<Vec<_>>();
if !errors.is_empty() {
bail!("policy evaluation failed:\n{}", errors.join("\n"));
}
Ok(response)
}
/// List-time capability probe: could `action` be permitted for `actor_id` on
/// **any** branch the per-call gate might be invoked with? Enumerates the
/// branch-shape space (omitted / protected / unprotected, on whichever of
/// `branch`/`target_branch` the action scopes on) through [`Self::evaluate`]
/// and returns true if any is allowed.
///
/// This makes `tools/list` a faithful *relaxation* of the per-call gate: it
/// never hides a tool the caller could invoke on some branch (over-showing
/// is safe — the per-call gate is authoritative), while still hiding a tool
/// the actor has no grant for. It deliberately does not fabricate a single
/// branch name (which under a "write unprotected branches" policy answers
/// the wrong question — a `change` request with no branch, or on protected
/// `main`, is denied, yet the actor can write feature branches).
pub fn permits_on_any_branch(&self, actor_id: &str, action: PolicyAction) -> Result<bool> {
if !self.known_actors.contains(actor_id) {
return Ok(false);
}
// The compiled branch/target scope conditions depend only on
// (`has_*`, `*_is_protected`), so these shapes span every distinguishable
// request. `branch`/`target_branch` string values are unread by any rule.
let shapes: Vec<serde_json::Value> = if action.uses_branch_scope() {
vec![
branch_context(false, "", false, false, "", false),
branch_context(true, "p", true, false, "", false),
branch_context(true, "u", false, false, "", false),
]
} else if action.uses_target_branch_scope() {
vec![
branch_context(false, "", false, true, "p", true),
branch_context(false, "", false, true, "u", false),
]
} else {
// Graph-scoped action (no branch dimension): one evaluation suffices.
vec![branch_context(false, "", false, false, "", false)]
};
for context in shapes {
if matches!(self.evaluate(actor_id, action, context)?.decision(), Decision::Allow) {
return Ok(true);
}
}
Ok(false)
}
pub fn authorize(&self, actor_id: &str, request: &PolicyRequest) -> Result<PolicyDecision> {
if !self.known_actors.contains(actor_id) {
return Ok(self.deny(
@ -517,36 +591,15 @@ impl PolicyEngine {
));
}
let principal = entity_uid("Actor", actor_id)?;
let action = entity_uid("Action", request.action.as_str())?;
// Pick the resource entity based on the action's `resource_kind`.
// Server-scoped actions (`graph_list`) bind to
// `Omnigraph::Server::"root"`; per-graph actions bind to
// `Omnigraph::Graph::"<graph_label>"`.
let resource = match request.action.resource_kind() {
PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?,
PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?,
};
let context_value = json!({
"has_branch": request.branch.is_some(),
"branch": request.branch.clone().unwrap_or_default(),
"has_target_branch": request.target_branch.is_some(),
"target_branch": request.target_branch.clone().unwrap_or_default(),
"branch_is_protected": request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
"target_branch_is_protected": request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
});
let context = Context::from_json_value(context_value, Some((&self.schema, &action)))?;
let cedar_request = Request::new(principal, action, resource, context, Some(&self.schema))?;
let response =
Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
let errors = response
.diagnostics()
.errors()
.map(|err| err.to_string())
.collect::<Vec<_>>();
if !errors.is_empty() {
bail!("policy evaluation failed:\n{}", errors.join("\n"));
}
let context = branch_context(
request.branch.is_some(),
request.branch.as_deref().unwrap_or_default(),
request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
request.target_branch.is_some(),
request.target_branch.as_deref().unwrap_or_default(),
request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
);
let response = self.evaluate(actor_id, request.action, context)?;
let matched_rule_id = response
.diagnostics()
@ -790,6 +843,28 @@ fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, graph_id: &st
)
}
/// Build the Cedar request context from the branch/target dimensions. The
/// single shape used by both the per-call gate ([`PolicyEngine::authorize`])
/// and the list-time capability probe ([`PolicyEngine::permits_on_any_branch`]),
/// so both feed the policy set an identically-structured context.
fn branch_context(
has_branch: bool,
branch: &str,
branch_is_protected: bool,
has_target_branch: bool,
target_branch: &str,
target_branch_is_protected: bool,
) -> serde_json::Value {
json!({
"has_branch": has_branch,
"branch": branch,
"has_target_branch": has_target_branch,
"target_branch": target_branch,
"branch_is_protected": branch_is_protected,
"target_branch_is_protected": target_branch_is_protected,
})
}
fn branch_scope_condition(scope: PolicyBranchScope) -> String {
match scope {
PolicyBranchScope::Any => "true".to_string(),
@ -1200,6 +1275,65 @@ rules:
assert_eq!(admin.matched_rule_id.as_deref(), Some("admins-promote"));
}
#[test]
fn permits_on_any_branch_reports_capability_not_a_fabricated_branch() {
// The canonical "protected main, write unprotected branches" policy.
let policy: PolicyConfig = serde_yaml::from_str(
r#"
version: 1
groups:
writers: [act-writer]
readers: [act-reader]
protected_branches: [main]
rules:
- 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
"#,
)
.unwrap();
let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
// The writer can `change` *some* branch (unprotected), so the list-time
// capability probe is true — even though the per-call gate denies on
// protected `main` and on a branchless request. This is exactly the
// divergence a fabricated-`main` probe got wrong.
assert!(engine.permits_on_any_branch("act-writer", PolicyAction::Change).unwrap());
assert!(
!engine
.authorize(
"act-writer",
&PolicyRequest { action: PolicyAction::Change, branch: Some("main".to_string()), target_branch: None },
)
.unwrap()
.allowed
);
assert!(
engine
.authorize(
"act-writer",
&PolicyRequest { action: PolicyAction::Change, branch: Some("feature".to_string()), target_branch: None },
)
.unwrap()
.allowed
);
// A reader has no `change` grant on any branch → capability is false,
// so the relaxation still hides write tools from read-only actors.
assert!(!engine.permits_on_any_branch("act-reader", PolicyAction::Change).unwrap());
assert!(engine.permits_on_any_branch("act-reader", PolicyAction::Read).unwrap());
// Unknown actor → false (never panics, never allows).
assert!(!engine.permits_on_any_branch("act-ghost", PolicyAction::Read).unwrap());
}
#[test]
fn policy_tests_enforce_expected_outcomes() {
let policy: PolicyConfig = serde_yaml::from_str(

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