From 307ad7c8fdb4e8f911fc2dbabe695f109618c7bb Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 9 Jun 2026 15:08:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20MCP=20stored-query=20tools=20+?= =?UTF-8?q?=20resources=20(RFC-003=20=C2=A75.1/=C2=A75.3/=C2=A75.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stored-query tools: project one MCP tool per `mcp.expose` registry entry from the same `query_catalog_entry` the GET /queries catalog uses, with params mapped to JSON Schema (§5.3: bigint/date/blob as strings, vector as a dim-bounded number array). Listed as a group under the coarse InvokeQuery gate and double-gated on call (outer InvokeQuery, then the inner Read/Change in run_query/run_mutate) — the same contract as POST /queries/{name}. A name that collides with a built-in is skipped (built-ins win). Resources: omnigraph://schema, omnigraph://branches, and (multi-graph) omnigraph://graphs, each gated by the same Cedar action as its tool/route (Read, Read, GraphList). list_resources is Cedar-filtered; read_resource masks a denied or unknown URI identically. The resources capability is now advertised (both handlers are backed). Tests (tests/server.rs, +6): stored query listed + callable, hidden without invoke_query, double-gated (invoke_query holder lacking read -> isError); resources list + read (schema/branches), and a denied read masked as unknown. Co-Authored-By: Claude --- crates/omnigraph-server/src/mcp/builtins.rs | 330 +++++++++++++++++++- crates/omnigraph-server/src/mcp/mod.rs | 83 +++-- crates/omnigraph-server/tests/server.rs | 202 +++++++++++- 3 files changed, 577 insertions(+), 38 deletions(-) diff --git a/crates/omnigraph-server/src/mcp/builtins.rs b/crates/omnigraph-server/src/mcp/builtins.rs index 22f9354..4d1cb40 100644 --- a/crates/omnigraph-server/src/mcp/builtins.rs +++ b/crates/omnigraph-server/src/mcp/builtins.rs @@ -9,10 +9,13 @@ use std::sync::Arc; use rmcp::ErrorData as McpError; use rmcp::RoleServer; -use rmcp::model::{CallToolResult, Content, JsonObject, Tool, ToolAnnotations}; +use rmcp::model::{ + Annotated, CallToolResult, Content, JsonObject, RawResource, ReadResourceResult, Resource, + ResourceContents, Tool, ToolAnnotations, +}; use rmcp::service::RequestContext; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use crate::{ AppState, Authz, GraphHandle, PolicyAction, PolicyRequest, ResolvedActor, api, authorize, @@ -444,10 +447,17 @@ impl Builtin { /// `list_tools` (visibility) and `call_tool` (deny ≡ unknown masking). `Err` is /// an operational failure (propagates as a JSON-RPC error); `Ok(false)` hides. pub(crate) fn is_visible(tool: Builtin, cx: &ToolCx) -> Result { - let (policy, action) = match tool.gate() { + gate_allowed(&tool.gate(), cx) +} + +/// Evaluate a gate against the actor's Cedar policy (the right policy source per +/// scope). Uses `branch: None`; the actual `do_*`/`run_*` call re-authorizes +/// with the real branch. Shared by tool visibility and resource visibility. +fn gate_allowed(gate: &Gate, cx: &ToolCx) -> Result { + let (policy, action) = match gate { Gate::None => return Ok(true), - Gate::Graph(action) => (cx.handle.as_ref().and_then(|h| h.policy.as_deref()), action), - Gate::Server(action) => (cx.state.server_policy.as_deref(), action), + Gate::Graph(action) => (cx.handle.as_ref().and_then(|h| h.policy.as_deref()), *action), + Gate::Server(action) => (cx.state.server_policy.as_deref(), *action), }; let request = PolicyRequest { action, @@ -521,3 +531,313 @@ fn api_operational_error(err: crate::ApiError) -> McpError { _ => McpError::internal_error(err.message_str().to_string(), None), } } + +// ---- stored-query tools (RFC-003 §5.1 / §5.3) ------------------------------ +// +// One MCP tool per `mcp.expose` entry in the graph's stored-query registry, +// projected from the same `query_catalog_entry` the `GET /queries` catalog +// uses. Double-gated like `POST /queries/{name}`: the coarse outer +// `InvokeQuery` action gates reaching the tool (all exposed queries on the +// graph, or none — per-query scope is deferred), then the inner `Read`/`Change` +// in `run_query`/`run_mutate` gates the body. + +/// Is the outer `InvokeQuery` gate open for this actor? `Err` is operational +/// (propagates); `Ok(false)` hides every stored-query tool. +pub(crate) fn stored_invoke_visible(cx: &ToolCx) -> Result { + let policy = cx.handle.as_ref().and_then(|h| h.policy.as_deref()); + let request = PolicyRequest { + action: PolicyAction::InvokeQuery, + branch: None, + target_branch: None, + }; + match authorize(cx.actor_ref(), policy, request) { + Ok(Authz::Allowed) => Ok(true), + Ok(Authz::Denied(_)) => Ok(false), + Err(err) => Err(api_operational_error(err)), + } +} + +/// Descriptors for the graph's exposed stored queries. A name that collides +/// with a built-in is skipped (built-ins win — avoids a duplicate tool name in +/// the catalog). +pub(crate) fn stored_descriptors(cx: &ToolCx) -> Vec { + let Some(registry) = cx.handle.as_ref().and_then(|h| h.queries.as_ref()) else { + return Vec::new(); + }; + registry + .iter() + .filter(|q| q.expose) + .filter_map(|q| { + let entry = api::query_catalog_entry(q); + if Builtin::from_name(&entry.tool_name).is_some() { + return None; + } + Some(stored_descriptor(&entry)) + }) + .collect() +} + +/// Does this graph expose a stored-query tool named `name` (not shadowed by a +/// built-in)? +pub(crate) fn is_stored_tool(cx: &ToolCx, name: &str) -> bool { + if Builtin::from_name(name).is_some() { + return false; + } + cx.handle + .as_ref() + .and_then(|h| h.queries.as_ref()) + .is_some_and(|reg| reg.iter().any(|q| q.expose && q.effective_tool_name() == name)) +} + +/// Dispatch an exposed stored-query tool. The caller has already enforced the +/// outer `InvokeQuery` gate (deny ≡ unknown); this runs the registry source +/// through `run_query` / `run_mutate`, whose inner `Read` / `Change` gate the +/// body. +pub(crate) async fn call_stored_tool( + cx: &ToolCx, + name: &str, + args: &JsonObject, +) -> Result { + let handle = cx.graph()?; + let unknown = || McpError::invalid_params(format!("unknown tool: {name}"), None); + let registry = handle.queries.as_ref().ok_or_else(unknown)?; + let stored = registry + .iter() + .find(|q| q.expose && q.effective_tool_name() == name) + .ok_or_else(unknown)?; + let source = Arc::clone(&stored.source); + let query_name = stored.name.clone(); + let mutation = stored.is_mutation(); + + // The query parameters are top-level tool args; `branch`/`snapshot` are + // invocation knobs. Peel the knobs off and pass the rest as the params + // object `run_query` / `run_mutate` expect. + let mut params = args.clone(); + let branch = params + .remove("branch") + .and_then(|v| v.as_str().map(str::to_string)); + let snapshot = params + .remove("snapshot") + .and_then(|v| v.as_str().map(str::to_string)); + let params = Value::Object(params); + + if mutation { + let branch = branch.unwrap_or_else(|| "main".to_string()); + to_tool( + run_mutate( + cx.state.clone(), + Arc::clone(handle), + cx.actor_ref(), + &source, + Some(&query_name), + Some(¶ms), + branch, + ) + .await, + ) + } else { + match run_query( + Arc::clone(handle), + cx.actor_ref(), + &source, + Some(&query_name), + Some(¶ms), + branch, + snapshot, + true, + ) + .await + { + Ok((selected, target, qr)) => ok_json(&api::read_output(selected, &target, qr)), + Err(err) => Ok(api_error_to_tool(err)), + } + } +} + +fn stored_descriptor(entry: &api::QueryCatalogEntry) -> Tool { + let mut props = Map::new(); + let mut required: Vec = Vec::new(); + for p in &entry.params { + props.insert(p.name.clone(), param_schema(p)); + if !p.nullable { + required.push(Value::String(p.name.clone())); + } + } + props.insert( + "branch".to_string(), + json!({"type": "string", "description": "Branch to target (default `main`)."}), + ); + if !entry.mutation { + props.insert( + "snapshot".to_string(), + json!({"type": "string", "description": "Snapshot id to read (mutually exclusive with `branch`)."}), + ); + } + let mut schema = Map::new(); + schema.insert("type".to_string(), json!("object")); + schema.insert("properties".to_string(), Value::Object(props)); + if !required.is_empty() { + schema.insert("required".to_string(), Value::Array(required)); + } + schema.insert("additionalProperties".to_string(), json!(false)); + + let mut description = entry + .description + .clone() + .unwrap_or_else(|| format!("Stored query `{}`.", entry.name)); + if let Some(instruction) = &entry.instruction { + description.push_str("\n\n"); + description.push_str(instruction); + } + let annotations = if entry.mutation { + ToolAnnotations::new() + .read_only(false) + .destructive(true) + .open_world(false) + } else { + ToolAnnotations::new().read_only(true).open_world(false) + }; + Tool::new(entry.tool_name.clone(), description, Arc::new(schema)).with_annotations(annotations) +} + +/// Map a stored-query parameter to its JSON Schema (RFC-003 §5.3). +fn param_schema(p: &api::ParamDescriptor) -> Value { + match p.kind { + api::ParamKind::List => { + let item = p + .item_kind + .map(scalar_schema) + .unwrap_or_else(|| json!({"type": "string"})); + json!({"type": "array", "items": item}) + } + other => kind_schema(other, p.vector_dim), + } +} + +fn scalar_schema(kind: api::ParamKind) -> Value { + kind_schema(kind, None) +} + +fn kind_schema(kind: api::ParamKind, vector_dim: Option) -> Value { + use api::ParamKind::*; + match kind { + String => json!({"type": "string"}), + Bool => json!({"type": "boolean"}), + Int => json!({"type": "integer"}), + // JSON numbers lose precision past 2^53, so i64/u64 ride as strings. + BigInt => json!({"type": "string", "pattern": "^-?\\d+$"}), + Float => json!({"type": "number"}), + Date => json!({"type": "string", "format": "date"}), + DateTime => json!({"type": "string", "format": "date-time"}), + Blob => json!({"type": "string", "contentEncoding": "base64"}), + Vector => { + let dim = vector_dim.unwrap_or(0); + json!({"type": "array", "items": {"type": "number"}, "minItems": dim, "maxItems": dim}) + } + // The grammar forbids lists/vectors of lists, so a bare List here is + // unreachable; fall back to an untyped array. + List => json!({"type": "array"}), + } +} + +// ---- resources (RFC-003 §5.5) ---------------------------------------------- +// +// Three read-only resources, each gated by the same action as its tool/route: +// the schema source, the branch list, and (multi-graph) the graph registry. A +// locked-down agent denied the gate never sees the resource (list-filtered) and +// a read is masked as "unknown resource" (deny ≡ missing). + +const RESOURCE_SCHEMA: &str = "omnigraph://schema"; +const RESOURCE_BRANCHES: &str = "omnigraph://branches"; +const RESOURCE_GRAPHS: &str = "omnigraph://graphs"; + +struct ResourceDef { + uri: &'static str, + name: &'static str, + description: &'static str, + mime: &'static str, + gate: Gate, +} + +fn resource_defs() -> [ResourceDef; 3] { + [ + ResourceDef { + uri: RESOURCE_SCHEMA, + name: "schema", + description: "The graph's `.pg` schema source.", + mime: "text/plain", + gate: Gate::Graph(PolicyAction::Read), + }, + ResourceDef { + uri: RESOURCE_BRANCHES, + name: "branches", + description: "The graph's branch names, as JSON.", + mime: "application/json", + gate: Gate::Graph(PolicyAction::Read), + }, + ResourceDef { + uri: RESOURCE_GRAPHS, + name: "graphs", + description: "The graphs registered with this server, as JSON (multi-graph mode).", + mime: "application/json", + gate: Gate::Server(PolicyAction::GraphList), + }, + ] +} + +/// The resources this actor may read (Cedar-filtered, same gate as the matching +/// tool). +pub(crate) fn list_resources(cx: &ToolCx) -> Result, McpError> { + let mut out = Vec::new(); + for def in resource_defs() { + if gate_allowed(&def.gate, cx)? { + let mut raw = RawResource::new(def.uri, def.name); + raw.description = Some(def.description.to_string()); + raw.mime_type = Some(def.mime.to_string()); + out.push(Annotated::new(raw, None)); + } + } + Ok(out) +} + +/// Read a resource by URI: enforce the gate (deny ≡ unknown resource), then +/// return the schema source / branch list / graph registry as text. +pub(crate) async fn read_resource(cx: &ToolCx, uri: &str) -> Result { + let unknown = || McpError::invalid_params(format!("unknown resource: {uri}"), None); + let def = resource_defs() + .into_iter() + .find(|d| d.uri == uri) + .ok_or_else(unknown)?; + if !gate_allowed(&def.gate, cx)? { + return Err(unknown()); + } + let text = match uri { + RESOURCE_SCHEMA => { + do_schema_get(cx.graph()?, cx.actor_ref()) + .await + .map_err(api_operational_error)? + .schema_source + } + RESOURCE_BRANCHES => { + let out = do_branches_list(cx.graph()?, cx.actor_ref()) + .await + .map_err(api_operational_error)?; + json_text(&out)? + } + RESOURCE_GRAPHS => { + let out = do_graphs_list(&cx.state, cx.actor_ref()) + .await + .map_err(api_operational_error)?; + json_text(&out)? + } + _ => return Err(unknown()), + }; + Ok(ReadResourceResult::new(vec![ResourceContents::text( + text, uri, + )])) +} + +fn json_text(value: &T) -> Result { + serde_json::to_string_pretty(value) + .map_err(|err| McpError::internal_error(format!("serialize resource: {err}"), None)) +} diff --git a/crates/omnigraph-server/src/mcp/mod.rs b/crates/omnigraph-server/src/mcp/mod.rs index 93f409e..809bd6e 100644 --- a/crates/omnigraph-server/src/mcp/mod.rs +++ b/crates/omnigraph-server/src/mcp/mod.rs @@ -20,8 +20,9 @@ use axum::Router; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, - ServerCapabilities, ServerInfo, + CallToolRequestParams, CallToolResult, ListResourcesResult, ListToolsResult, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, + ServerInfo, }, service::RequestContext, transport::streamable_http_server::{ @@ -66,11 +67,12 @@ impl ServerHandler for OmnigraphMcpHandler { // `resources` with neither `listChanged` nor `subscribe` — stateless, // no server push. let mut info = ServerInfo::default(); - // Advertise only `tools` for now. The resources phase adds - // `list_resources`/`read_resource`; advertising a `resources` - // capability whose `resources/read` returns method-not-found would be a - // dishonest contract, so `.enable_resources()` lands with that phase. - info.capabilities = ServerCapabilities::builder().enable_tools().build(); + // Advertise `tools` and `resources` (no `listChanged`/`subscribe` — + // stateless, no server push). Both are backed by real handlers below. + info.capabilities = ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(); info.server_info.name = "omnigraph-server".to_string(); info.server_info.version = env!("CARGO_PKG_VERSION").to_string(); info.instructions = Some(MCP_INSTRUCTIONS.to_string()); @@ -91,7 +93,11 @@ impl ServerHandler for OmnigraphMcpHandler { tools.push(tool.descriptor()); } } - // Phase 4 appends the dynamic stored-query tools here. + // Stored-query tools: gated as a group by the coarse `InvokeQuery` + // action (all exposed queries, or none). + if builtins::stored_invoke_visible(&cx)? { + tools.extend(builtins::stored_descriptors(&cx)); + } let mut result = ListToolsResult::default(); result.tools = tools; Ok(result) @@ -103,26 +109,49 @@ impl ServerHandler for OmnigraphMcpHandler { context: RequestContext, ) -> Result { let cx = builtins::resolve_cx(&self.state, &context)?; - let Some(tool) = Builtin::from_name(&request.name) else { - // Unknown tool → JSON-RPC error (a dispatch failure, not a - // tool-execution error). - return Err(McpError::invalid_params( - format!("unknown tool: {}", request.name), - None, - )); - }; - // Enforce the visibility gate at call-time too, and mask a denial as - // "unknown tool" so the catalog isn't probeable without the grant (the - // same deny ≡ missing principle as `POST /queries/{name}`). The inner - // `do_*` / `run_*` re-authorizes against the real branch. - if !builtins::is_visible(tool, &cx)? { - return Err(McpError::invalid_params( - format!("unknown tool: {}", request.name), - None, - )); - } + let name = request.name.to_string(); let args = request.arguments.unwrap_or_default(); - builtins::dispatch(tool, &cx, &args).await + // Deny ≡ unknown: a denied tool and an unknown one return the identical + // error so the catalog isn't probeable without the grant (the same + // principle as `POST /queries/{name}`). The inner `do_*` / `run_*` + // re-authorizes against the real branch. + let unknown = || McpError::invalid_params(format!("unknown tool: {name}"), None); + + if let Some(tool) = Builtin::from_name(&name) { + if !builtins::is_visible(tool, &cx)? { + return Err(unknown()); + } + return builtins::dispatch(tool, &cx, &args).await; + } + if builtins::is_stored_tool(&cx, &name) { + // Outer InvokeQuery gate (coarse); the inner Read/Change runs in + // run_query/run_mutate — the double-gate of POST /queries/{name}. + if !builtins::stored_invoke_visible(&cx)? { + return Err(unknown()); + } + return builtins::call_stored_tool(&cx, &name, &args).await; + } + Err(unknown()) + } + + async fn list_resources( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + let cx = builtins::resolve_cx(&self.state, &context)?; + let mut result = ListResourcesResult::default(); + result.resources = builtins::list_resources(&cx)?; + Ok(result) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + context: RequestContext, + ) -> Result { + let cx = builtins::resolve_cx(&self.state, &context)?; + builtins::read_resource(&cx, &request.uri).await } } diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index 1331d9f..ba72c52 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -879,7 +879,7 @@ fn mcp_initialize_body() -> Value { } #[tokio::test] -async fn mcp_initialize_advertises_tools_capability() { +async fn mcp_initialize_advertises_tools_and_resources() { let (_temp, app) = app_for_loaded_graph().await; let (status, body) = json_response(&app, mcp_post(mcp_initialize_body())).await; assert_eq!(status, StatusCode::OK, "initialize should 200"); @@ -889,12 +889,9 @@ async fn mcp_initialize_advertises_tools_capability() { body["result"]["capabilities"]["tools"].is_object(), "advertises the tools capability: {body}" ); - // Resources are NOT advertised until the resources phase implements - // `list_resources`/`read_resource`; advertising a capability whose - // `resources/read` 404s would be a dishonest contract. assert!( - body["result"]["capabilities"]["resources"].is_null(), - "does not advertise resources until implemented: {body}" + body["result"]["capabilities"]["resources"].is_object(), + "advertises the resources capability: {body}" ); assert_eq!(body["result"]["serverInfo"]["name"], "omnigraph-server"); } @@ -1225,6 +1222,199 @@ async fn mcp_tool_annotations_match_read_write() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn mcp_stored_query_listed_and_callable() { + let (_temp, app) = app_with_stored_queries( + &[("find_person", FIND_PERSON_GQ, true)], + &[("act-invoke", "t-invoke")], + INVOKE_POLICY_YAML, + ) + .await; + let names = mcp_tool_names(&app, "t-invoke").await; + assert!( + names.contains(&"find_person".to_string()), + "stored query is listed as a tool: {names:?}" + ); + + let (status, body) = json_response( + &app, + mcp_post_auth( + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "find_person", "arguments": { "name": "Alice" } } + }), + "t-invoke", + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_ne!(body["result"]["isError"], json!(true), "stored read failed: {body}"); + let text = body["result"]["content"][0]["text"] + .as_str() + .expect("read envelope text"); + let read: Value = serde_json::from_str(text).expect("read json"); + assert_eq!(read["query_name"], "find_person"); + assert_eq!(read["row_count"], 1, "Alice is in the fixture: {read}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mcp_stored_query_hidden_without_invoke_query() { + let (_temp, app) = app_with_stored_queries( + &[("find_person", FIND_PERSON_GQ, true)], + &[("act-noinvoke", "t-noinvoke")], + INVOKE_POLICY_YAML, + ) + .await; + let names = mcp_tool_names(&app, "t-noinvoke").await; + assert!( + !names.contains(&"find_person".to_string()), + "no invoke_query → stored tool hidden: {names:?}" + ); + let (status, body) = json_response( + &app, + mcp_post_auth( + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "find_person", "arguments": { "name": "Alice" } } + }), + "t-noinvoke", + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["error"]["code"], json!(-32602)); + assert_eq!(body["error"]["message"], json!("unknown tool: find_person")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mcp_stored_query_double_gated_inner_read_denies() { + // act-invokeonly clears the outer invoke_query gate (tool is listed and + // reachable) but lacks `read`, so the inner gate inside run_query denies — + // surfacing as an isError tool result, the double-gate of POST /queries/{name}. + let (_temp, app) = app_with_stored_queries( + &[("find_person", FIND_PERSON_GQ, true)], + &[("act-invokeonly", "t-invokeonly")], + INVOKE_POLICY_YAML, + ) + .await; + let names = mcp_tool_names(&app, "t-invokeonly").await; + assert!( + names.contains(&"find_person".to_string()), + "invoke_query holder sees the tool: {names:?}" + ); + let (status, body) = json_response( + &app, + mcp_post_auth( + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "find_person", "arguments": { "name": "Alice" } } + }), + "t-invokeonly", + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["result"]["isError"], + json!(true), + "inner read denial surfaces as isError: {body}" + ); +} + +async fn mcp_resource_uris(app: &Router, token: &str) -> Vec { + let (status, body) = json_response( + app, + mcp_post_auth( + json!({ "jsonrpc": "2.0", "id": 1, "method": "resources/list" }), + token, + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + body["result"]["resources"] + .as_array() + .expect("resources array") + .iter() + .filter_map(|r| r["uri"].as_str().map(String::from)) + .collect() +} + +#[tokio::test] +async fn mcp_resources_list_and_read() { + let (_temp, app) = app_for_loaded_graph().await; + let (status, body) = json_response( + &app, + mcp_post(json!({ "jsonrpc": "2.0", "id": 1, "method": "resources/list" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let uris: Vec<&str> = body["result"]["resources"] + .as_array() + .expect("resources array") + .iter() + .filter_map(|r| r["uri"].as_str()) + .collect(); + assert!(uris.contains(&"omnigraph://schema"), "{uris:?}"); + assert!(uris.contains(&"omnigraph://branches"), "{uris:?}"); + // graphs is server-scoped; single mode hides it. + assert!(!uris.contains(&"omnigraph://graphs"), "{uris:?}"); + + let (_s, schema) = json_response( + &app, + mcp_post(json!({ + "jsonrpc": "2.0", "id": 2, "method": "resources/read", + "params": { "uri": "omnigraph://schema" } + })), + ) + .await; + let text = schema["result"]["contents"][0]["text"] + .as_str() + .expect("schema text"); + assert!(text.contains("Person"), "schema source: {text}"); + + let (_s, branches) = json_response( + &app, + mcp_post(json!({ + "jsonrpc": "2.0", "id": 3, "method": "resources/read", + "params": { "uri": "omnigraph://branches" } + })), + ) + .await; + let text = branches["result"]["contents"][0]["text"] + .as_str() + .expect("branches text"); + assert!(text.contains("main"), "branches json: {text}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mcp_resource_read_denied_is_masked() { + // act-noread holds a token but matches no allow rule → Cedar denies read, so + // omnigraph://schema is not listed and a read is masked as unknown. + let (_temp, app) = + app_with_stored_queries(&[], &[("act-noread", "t-noread")], MCP_FILTER_POLICY_YAML).await; + let uris = mcp_resource_uris(&app, "t-noread").await; + assert!( + !uris.contains(&"omnigraph://schema".to_string()), + "no read → schema resource hidden: {uris:?}" + ); + let (status, body) = json_response( + &app, + mcp_post_auth( + json!({ + "jsonrpc": "2.0", "id": 1, "method": "resources/read", + "params": { "uri": "omnigraph://schema" } + }), + "t-noread", + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["error"]["message"], + json!("unknown resource: omnigraph://schema") + ); +} + #[tokio::test] async fn schema_apply_route_updates_graph_for_authorized_admin() { let (temp, app) = app_for_graph_with_auth_tokens_and_policy(