mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-12 01:45:14 +02:00
feat(server): MCP stored-query tools + resources (RFC-003 §5.1/§5.3/§5.5)
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 <noreply@anthropic.com>
This commit is contained in:
parent
66c37d289a
commit
307ad7c8fd
3 changed files with 577 additions and 38 deletions
|
|
@ -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<bool, McpError> {
|
||||
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<bool, McpError> {
|
||||
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<bool, McpError> {
|
||||
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<Tool> {
|
||||
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<CallToolResult, McpError> {
|
||||
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<Value> = 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<u32>) -> 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<Vec<Resource>, 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<ReadResourceResult, McpError> {
|
||||
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<T: Serialize>(value: &T) -> Result<String, McpError> {
|
||||
serde_json::to_string_pretty(value)
|
||||
.map_err(|err| McpError::internal_error(format!("serialize resource: {err}"), None))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RoleServer>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, McpError> {
|
||||
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<RoleServer>,
|
||||
) -> Result<ReadResourceResult, McpError> {
|
||||
let cx = builtins::resolve_cx(&self.state, &context)?;
|
||||
builtins::read_resource(&cx, &request.uri).await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue