diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 59e09fe..64f259a 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -1065,13 +1065,14 @@ pub(crate) async fn server_invoke_query( )] /// List the graph's exposed stored queries as a typed tool catalog. /// -/// Returns the `mcp.expose == true` subset of the `queries:` registry, each -/// with its MCP tool name, read/mutate flag, description/instruction, and +/// Returns the exposed (`@mcp(expose: true)`) subset of the `queries:` registry, +/// each with its MCP tool name, read/mutate flag, description/instruction, and /// typed parameters — enough for a client to register them as tools without -/// fetching `.gq` source. Read-gated; the catalog is graph-wide (branch -/// independent — `read` is authorized against `main`). **Not** Cedar-filtered -/// per query yet, so it can list a query whose `invoke_query` the caller -/// lacks (a known gap until per-query authorization lands). +/// fetching `.gq` source. **`invoke_query`-gated** (graph-scoped), so catalog +/// discovery uses the same authority as invocation and matches the MCP +/// `tools/list` surface: a caller that can list can invoke (subject to the inner +/// `read`/`change` gate on the query body). Requires an explicit `invoke_query` +/// grant — in default-deny mode (tokens, no policy) it returns 403. pub(crate) async fn server_list_queries( Extension(handle): Extension>, actor: Option>, @@ -1080,8 +1081,8 @@ pub(crate) async fn server_list_queries( actor.as_ref().map(|Extension(actor)| actor), handle.policy.as_deref(), PolicyRequest { - action: PolicyAction::Read, - branch: Some("main".to_string()), + action: PolicyAction::InvokeQuery, + branch: None, target_branch: None, }, )?; diff --git a/crates/omnigraph-server/src/mcp.rs b/crates/omnigraph-server/src/mcp.rs index 4af8828..a35b762 100644 --- a/crates/omnigraph-server/src/mcp.rs +++ b/crates/omnigraph-server/src/mcp.rs @@ -60,6 +60,52 @@ pub(crate) const BUILTIN_TOOL_NAMES: &[&str] = &[ "schema_apply", ]; +/// Max MCP tool-name length. Matches the constraint major MCP clients enforce +/// (Anthropic/OpenAI cap tool names at 64 chars over `[A-Za-z0-9_-]`). +const MAX_TOOL_NAME_LEN: usize = 64; + +/// Every tool name the MCP surface itself generates and therefore reserves +/// graph-wide: the built-ins **plus** the `meta`-mode discovery/execute pair. +/// A stored query whose effective tool name claims one of these is refused at +/// load (`QueryRegistry::from_specs`) — otherwise it would be silently shadowed +/// (a built-in always wins at dispatch; the meta pair takes over the name once +/// the catalog crosses [`STORED_QUERY_AUTO_THRESHOLD`], a silent +/// threshold-crossing meaning change). Single source of "names the surface +/// emits", so the reservation can't drift from what's generated. +pub(crate) fn reserved_tool_names() -> impl Iterator { + BUILTIN_TOOL_NAMES + .iter() + .copied() + .chain([STORED_QUERY_LIST_TOOL, STORED_QUERY_RUN_TOOL]) +} + +/// The MCP tool-name contract every published name must satisfy: non-empty, +/// `[A-Za-z0-9_-]`, at most [`MAX_TOOL_NAME_LEN`] — the intersection of what +/// MCP clients accept. The `.gq` grammar already constrains query *names* to a +/// subset of this; this guards the free-form `@mcp(tool_name: …)` override so a +/// malformed name fails loudly at load instead of producing a tool a client +/// silently rejects at call time. Returns the operator-facing reason on failure. +pub(crate) fn validate_mcp_tool_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("MCP tool name must not be empty".to_string()); + } + if name.len() > MAX_TOOL_NAME_LEN { + return Err(format!( + "MCP tool name '{name}' exceeds {MAX_TOOL_NAME_LEN} characters" + )); + } + if let Some(bad) = name + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-')) + { + return Err(format!( + "MCP tool name '{name}' contains an unsupported character '{bad}' \ + (allowed: ASCII letters, digits, '_', '-')" + )); + } + Ok(()) +} + /// The server's thin wrapper over `omnigraph_mcp::mcp_router`: derives the /// fail-closed host policy from the bound socket and passes the 32 MiB body /// limit (`/load` parity). Merged into `per_graph_protected` in `build_app`. @@ -119,14 +165,16 @@ impl McpBackend for OmnigraphMcpBackend { let (actor, handle) = self.ctx(parts)?; let mut tools = Vec::new(); for builtin in Builtin::ALL { - // Visibility is a *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 (`authorize_any_branch`), - // never hidden because a fabricated branch happened to be denied. The + // Visibility is derived from what the tool's `call` authorizes + // (`list_gate`): a fixed-scope tool is gated on that exact request + // (faithful, and consistent with its resource twin); a + // caller-chosen-branch tool is shown iff the actor could invoke it on + // *some* branch (a relaxation — never hide a callable tool). The // per-call gate inside `call` stays authoritative. - let visible = match builtin.list_action() { - None => true, - Some(action) => { + let visible = match builtin.list_gate() { + ListGate::Always => true, + ListGate::Exact(request) => allowed(actor, handle, request)?, + ListGate::AnyBranch(action) => { authorize_any_branch(actor, handle.policy.as_deref(), action).map_err(api_to_mcp)? } }; @@ -633,6 +681,20 @@ fn stored_query_list(registry: &QueryRegistry, args: &JsonObject) -> Result Option { + /// How `tools/list` decides this tool's visibility — **derived from what the + /// tool's `call` actually authorizes**, so listing never diverges from + /// callability: + /// - `Always` — ungated (`graph_health` liveness). + /// - `Exact(req)` — the call authorizes a *fixed* request regardless of + /// arguments (`schema_get`/`branch_list`/`commit_get` read branchlessly; + /// `schema_apply` always targets `main`). Listed iff that exact request is + /// allowed — faithful, no over-show, and consistent with the resource + /// twins (`omnigraph://schema` etc.) which use the same branchless read. + /// - `AnyBranch(action)` — the call authorizes against a *caller-chosen* + /// branch. Listed iff the actor could perform `action` on *some* branch (a + /// relaxation: never hide a tool the caller could invoke — e.g. + /// `graph_mutate` shows for an unprotected-branch writer even when `main` + /// is protected). The authoritative per-call gate runs inside `call`. + fn list_gate(self) -> ListGate { match self { - Builtin::GraphHealth => None, - Builtin::GraphQuery - | Builtin::GraphSnapshot - | Builtin::SchemaGet - | Builtin::BranchList - | Builtin::CommitList - | Builtin::CommitGet => Some(PolicyAction::Read), - Builtin::GraphMutate | Builtin::GraphLoad => Some(PolicyAction::Change), - Builtin::BranchCreate => Some(PolicyAction::BranchCreate), - Builtin::BranchDelete => Some(PolicyAction::BranchDelete), - Builtin::BranchMerge => Some(PolicyAction::BranchMerge), - Builtin::SchemaApply => Some(PolicyAction::SchemaApply), + Builtin::GraphHealth => ListGate::Always, + // Fixed branchless read — same gate as the schema/branches resources. + Builtin::SchemaGet | Builtin::BranchList | Builtin::CommitGet => { + ListGate::Exact(read_request(None)) + } + // Always targets `main`. + Builtin::SchemaApply => ListGate::Exact(PolicyRequest { + action: PolicyAction::SchemaApply, + branch: None, + target_branch: Some("main".to_string()), + }), + // Caller-chosen branch → relaxation. + Builtin::GraphQuery | Builtin::GraphSnapshot | Builtin::CommitList => { + ListGate::AnyBranch(PolicyAction::Read) + } + Builtin::GraphMutate | Builtin::GraphLoad => ListGate::AnyBranch(PolicyAction::Change), + Builtin::BranchCreate => ListGate::AnyBranch(PolicyAction::BranchCreate), + Builtin::BranchDelete => ListGate::AnyBranch(PolicyAction::BranchDelete), + Builtin::BranchMerge => ListGate::AnyBranch(PolicyAction::BranchMerge), } } diff --git a/crates/omnigraph-server/src/queries.rs b/crates/omnigraph-server/src/queries.rs index 842b4e1..08594e1 100644 --- a/crates/omnigraph-server/src/queries.rs +++ b/crates/omnigraph-server/src/queries.rs @@ -152,18 +152,27 @@ 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); + // Every name the MCP surface generates is reserved graph-wide: the + // built-ins AND the meta-mode pair. A stored query claiming one would + // be silently shadowed (a built-in wins at dispatch; the meta pair + // takes over the name once the catalog crosses the auto threshold) — + // the deny-list forbids silent drops, so seed them all and fail + // loudly at load. Single source via `reserved_tool_names()`. + for reserved in crate::mcp::reserved_tool_names() { + claimed.insert(reserved, BUILTIN_OWNER); } for query in by_name.values().filter(|q| q.is_exposed()) { let tool = query.effective_tool_name(); + // Well-formedness: the `@mcp(tool_name: …)` override is free + // text; reject a name no MCP client will accept at load rather + // than emitting a tool that fails at call time. + if let Err(message) = crate::mcp::validate_mcp_tool_name(tool) { + errors.push(LoadError { query: Some(query.name.clone()), message }); + continue; + } 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") + format!("MCP tool name '{tool}' is reserved by a built-in or meta tool") } else { format!("MCP tool name '{tool}' already claimed by exposed query '{winner}'") }; @@ -447,6 +456,52 @@ mod tests { assert_eq!(reg.len(), 2); } + #[test] + fn malformed_tool_name_override_is_a_load_error() { + // The `@mcp(tool_name: …)` override is free text; a name no MCP client + // accepts must fail loudly at load, not surface at call time. + for (bad, hint) in [ + ("has space", "unsupported character"), + ("comma,name", "unsupported character"), + ("", "must not be empty"), + ( + // 65 chars > MAX_TOOL_NAME_LEN + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "exceeds", + ), + ] { + let errors = QueryRegistry::from_specs(vec![spec_tool( + "q", + "query q() { match { $u: User } return { $u.name } }", + true, + bad, + )]) + .unwrap_err(); + let msg = errors[0].to_string(); + assert!(msg.contains(hint), "tool_name {bad:?} should fail with {hint:?}: {msg}"); + } + } + + #[test] + fn meta_tool_name_is_reserved() { + // The generated meta-mode tools are reserved graph-wide, like the + // built-ins — a stored query claiming one would be silently shadowed + // once the catalog crosses the auto threshold. + for reserved in ["stored_query_list", "stored_query_run"] { + let errors = QueryRegistry::from_specs(vec![spec( + reserved, + &format!("query {reserved}() {{ match {{ $u: User }} return {{ $u.name }} }}"), + true, + )]) + .unwrap_err(); + assert!( + errors[0].to_string().contains("reserved"), + "'{reserved}' must be reserved: {}", + errors[0] + ); + } + } + #[test] fn parse_error_surfaces_per_entry() { let errors = diff --git a/crates/omnigraph-server/tests/mcp.rs b/crates/omnigraph-server/tests/mcp.rs index eb597d6..6d4801e 100644 --- a/crates/omnigraph-server/tests/mcp.rs +++ b/crates/omnigraph-server/tests/mcp.rs @@ -13,9 +13,9 @@ 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, + FIND_PERSON_GQ, INVOKE_POLICY_YAML, POLICY_PROTECTED_READ_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 @@ -577,6 +577,40 @@ async fn stored_query_tool_folds_docs_and_honors_mcp_annotation() { assert_ne!(v["result"]["isError"], json!(true), "renamed tool not callable: {v}"); } +#[tokio::test] +async fn list_gate_matches_call_for_fixed_branchless_reads() { + // A protected-only reader. Branch-arg reads (graph_query) relax and show + // (callable on a protected branch). Fixed branchless reads (schema_get) use + // the faithful read(None) gate — which a protected-scope rule denies — so + // schema_get is hidden, matching the omnigraph://schema resource (same + // branchless read). Tool and resource agree by construction. + let (_t, app) = app_for_loaded_graph_with_auth_tokens_and_policy( + &[("act-bruno", "tok")], + POLICY_PROTECTED_READ_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(&"graph_query".to_string()), + "branch-arg read relaxes and shows under protected-only read: {names:?}" + ); + assert!( + !names.contains(&"schema_get".to_string()), + "fixed branchless read uses read(None), denied under protected-only → hidden: {names:?}" + ); + + // resources/list uses the same read(None) gate → empty, matching schema_get. + let (_s, res) = + json_response(&app, mcp_request(Some("tok"), rpc(2, "resources/list", json!({})))).await; + assert!( + res["result"]["resources"].as_array().unwrap().is_empty(), + "resources hidden under protected-only read, consistent with schema_get: {res}" + ); +} + #[tokio::test] async fn per_query_mode_does_not_expose_meta_tools() { // Below the auto threshold the projection is per-query, so the discovery + diff --git a/crates/omnigraph-server/tests/stored_queries.rs b/crates/omnigraph-server/tests/stored_queries.rs index 02553a7..1cf8e02 100644 --- a/crates/omnigraph-server/tests/stored_queries.rs +++ b/crates/omnigraph-server/tests/stored_queries.rs @@ -345,28 +345,29 @@ async fn list_queries_returns_only_exposed_with_typed_params() { } #[tokio::test(flavor = "multi_thread")] -async fn list_queries_is_read_gated_so_a_non_invoker_can_list() { - // The catalog is read-gated (not invoke_query-gated), so a reader who - // lacks invoke_query still enumerates the exposed queries — the - // documented probe-oracle gap until per-query Cedar filtering lands. +async fn list_queries_requires_invoke_query() { + // The catalog is invoke_query-gated (same authority as invocation and the + // MCP `tools/list` surface): a reader who lacks invoke_query is denied + // listing, while an invoke_query holder lists it. let (_temp, app) = app_with_stored_queries( &[("find_person", FIND_PERSON_GQ, true)], - &[("act-noinvoke", "t-noinvoke")], + &[("act-noinvoke", "t-noinvoke"), ("act-invoke", "t-invoke")], INVOKE_POLICY_YAML, ) .await; - let (status, body) = json_response(&app, get_request(&g("/queries"), "t-noinvoke")).await; - assert_eq!(status, StatusCode::OK, "read-gated catalog; body: {body}"); + // read-only, no invoke_query → 403. + let (status, _body) = json_response(&app, get_request(&g("/queries"), "t-noinvoke")).await; + assert_eq!(status, StatusCode::FORBIDDEN, "catalog listing requires invoke_query"); + // invoke_query holder → 200 with the exposed query. + let (status, body) = json_response(&app, get_request(&g("/queries"), "t-invoke")).await; + assert_eq!(status, StatusCode::OK, "invoker lists the catalog; body: {body}"); let names: Vec<&str> = body["queries"] .as_array() .unwrap() .iter() .map(|q| q["name"].as_str().unwrap()) .collect(); - assert!( - names.contains(&"find_person"), - "a reader lists the catalog despite lacking invoke_query: {names:?}" - ); + assert!(names.contains(&"find_person"), "invoker sees the exposed query: {names:?}"); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/omnigraph-server/tests/support/mod.rs b/crates/omnigraph-server/tests/support/mod.rs index a481e87..54efb80 100644 --- a/crates/omnigraph-server/tests/support/mod.rs +++ b/crates/omnigraph-server/tests/support/mod.rs @@ -362,6 +362,10 @@ rules: actors: {{ group: permitted }} actions: [schema_apply, branch_create, branch_delete, branch_merge] target_branch_scope: any + - id: permit-invoke + allow: + actors: {{ group: permitted }} + actions: [invoke_query] "# ) } diff --git a/docs/releases/v0.8.0.md b/docs/releases/v0.8.0.md index 283ba08..0153bc1 100644 --- a/docs/releases/v0.8.0.md +++ b/docs/releases/v0.8.0.md @@ -80,8 +80,15 @@ carried in the query source: ## Upgrade notes -- **No breaking changes.** The REST surface, CLI, cluster config, and on-disk - format are unchanged. The MCP endpoint is additive. +- **`GET /graphs/{id}/queries` is now `invoke_query`-gated (was `read`).** The + stored-query catalog uses the same authority as invocation and the MCP + `tools/list` surface, so discovery and invocation agree ("see the menu iff you + can order from it"). A caller with only `read` (and no `invoke_query`) now gets + `403` instead of a listing; in default-deny mode the endpoint returns `403` + until an `invoke_query` rule is configured. This is the one observable REST + behavior change in this release. +- Otherwise no breaking changes: the rest of the REST surface, CLI, cluster + config, and on-disk format are unchanged. The MCP endpoint is additive. - **Pointing an agent at a graph:** configure your MCP client with the URL `https:///graphs//mcp` and the same bearer token you use for REST. See [docs/user/operations/mcp.md](../user/operations/mcp.md) for the connect diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 1430c7c..705c853 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -44,7 +44,7 @@ graph id from the cluster's applied revision: | POST | `/graphs/{id}/export` | bearer + `export` | NDJSON stream | | POST | `/graphs/{id}/mutate` | bearer + `change` | mutation (canonical; `query`/`name`; accepts legacy `query_source`/`query_name` as serde aliases) | | POST | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: ; rel="successor-version"`) | -| GET | `/graphs/{id}/queries` | bearer + `read` | list the `mcp.expose` stored queries as a typed tool catalog | +| GET | `/graphs/{id}/queries` | bearer + `invoke_query` | list the exposed (`@mcp(expose)`) stored queries as a typed tool catalog | | POST | `/graphs/{id}/queries/{name}` | bearer + `invoke_query` (+ `change` for a stored mutation) | invoke a named query from the `queries:` registry; deny == 404 | | POST | `/graphs/{id}/mcp` | bearer + same per-tool Cedar gate | MCP (Model Context Protocol) surface — built-ins + stored queries as tools, schema/branches as resources (see [mcp.md](mcp.md)) | | GET | `/graphs/{id}/schema` | bearer + `read` | get current `.pg` source | @@ -66,11 +66,10 @@ Server-level management endpoints: ### Stored-query catalog (`GET /queries`) -List the graph's **`mcp.expose`** stored queries as a typed tool catalog — enough for a client to register each as a tool without fetching `.gq` source. (The server also projects these queries as live MCP tools at `POST /graphs/{id}/mcp` — see [mcp.md](mcp.md); this catalog endpoint is the REST view of the same registry.) Each entry: `{ name, tool_name, description, instruction, mutation, params }`, where each param is `{ name, kind, item_kind?, vector_dim?, nullable }`. `kind` is one of `string | bool | int | bigint | float | date | datetime | blob | vector | list` (decomposed so a consumer maps it with a closed `switch`, never re-parsing GQ type spelling). `bigint` (I64/U64), `date`, `datetime`, and `blob` are carried as JSON **strings** — a 64-bit integer loses precision as a JSON number, dates are ISO strings, and a blob is a URI string. +List the graph's **exposed** (`@mcp(expose: true)`) stored queries as a typed tool catalog — enough for a client to register each as a tool without fetching `.gq` source. (The server also projects these queries as live MCP tools at `POST /graphs/{id}/mcp` — see [mcp.md](mcp.md); this catalog endpoint is the REST view of the same registry.) Each entry: `{ name, tool_name, description, instruction, mutation, params }`, where each param is `{ name, kind, item_kind?, vector_dim?, nullable, description? }`. `kind` is one of `string | bool | int | bigint | float | date | datetime | blob | vector | list` (decomposed so a consumer maps it with a closed `switch`, never re-parsing GQ type spelling). `bigint` (I64/U64), `date`, `datetime`, and `blob` are carried as JSON **strings** — a 64-bit integer loses precision as a JSON number, dates are ISO strings, and a blob is a URI string. -- **Read-gated** (works in default-deny mode). The catalog is **graph-wide** (branch-independent; `read` is authorized against `main`). -- **`mcp.expose` defaults to `true`** — declaring a query in `queries:` lists it; set `mcp: { expose: false }` to keep it HTTP/service-callable but hidden from the catalog. -- **Not Cedar-filtered per query (yet).** A caller with `read` but not `invoke_query` can *list* a query they can't *invoke* (which would 404). Closing that gap is future per-query authorization; for now the catalog is a discovery surface and `invoke_query` remains the invocation gate. +- **`invoke_query`-gated** (graph-scoped) — the same authority as invocation (`POST /queries/{name}`) and the MCP `tools/list` surface, so a caller that can list the catalog can invoke from it. Requires an explicit `invoke_query` grant; in default-deny mode (tokens, no policy) it returns 403. +- **Exposure is set per-query in the `.gq` source** via `@mcp(expose: false)` (default exposed; see [queries/index.md](../queries/index.md#annotations) and [mcp.md](mcp.md)). An unexposed query stays HTTP/service-callable by name but is absent from this catalog and the MCP tool surface. ### Stored-query invocation (`POST /queries/{name}`) diff --git a/openapi.json b/openapi.json index b3c1be9..692e0d9 100644 --- a/openapi.json +++ b/openapi.json @@ -1006,7 +1006,7 @@ "queries" ], "summary": "List the graph's exposed stored queries as a typed tool catalog.", - "description": "Returns the `mcp.expose == true` subset of the `queries:` registry, each\nwith its MCP tool name, read/mutate flag, description/instruction, and\ntyped parameters — enough for a client to register them as tools without\nfetching `.gq` source. Read-gated; the catalog is graph-wide (branch\nindependent — `read` is authorized against `main`). **Not** Cedar-filtered\nper query yet, so it can list a query whose `invoke_query` the caller\nlacks (a known gap until per-query authorization lands).", + "description": "Returns the exposed (`@mcp(expose: true)`) subset of the `queries:` registry,\neach with its MCP tool name, read/mutate flag, description/instruction, and\ntyped parameters — enough for a client to register them as tools without\nfetching `.gq` source. **`invoke_query`-gated** (graph-scoped), so catalog\ndiscovery uses the same authority as invocation and matches the MCP\n`tools/list` surface: a caller that can list can invoke (subject to the inner\n`read`/`change` gate on the query body). Requires an explicit `invoke_query`\ngrant — in default-deny mode (tokens, no policy) it returns 403.", "operationId": "cluster_list_queries", "parameters": [ { diff --git a/skills/omnigraph/SKILL.md b/skills/omnigraph/SKILL.md index be87369..558977d 100644 --- a/skills/omnigraph/SKILL.md +++ b/skills/omnigraph/SKILL.md @@ -409,6 +409,7 @@ For anything beyond the basics, load the relevant reference file. Each is self-c | [`references/search.md`](references/search.md) | Embeddings, `@embed`, vector/text ranking, scope-then-rank pattern | | [`references/aliases.md`](references/aliases.md) | Defining aliases for agents, structured output, JSON args | | [`references/stored-queries.md`](references/stored-queries.md) | Server-side stored-query registry: declared in `cluster.yaml`, `omnigraph queries validate/list`, `GET /graphs/{id}/queries` + `POST /graphs/{id}/queries/{name}`, `invoke_query` Cedar gating | -| [`references/server-policy.md`](references/server-policy.md) | Starting the HTTP server, routes, bearer auth, Cedar policy gating, multi-graph mode, the MCP surface (`POST /graphs/{id}/mcp` — connecting agents, tool catalog, list-vs-call gating) | +| [`references/server-policy.md`](references/server-policy.md) | Starting the HTTP server, routes, bearer auth, Cedar policy gating, multi-graph mode (MCP surface → `references/mcp.md`) | +| [`references/mcp.md`](references/mcp.md) | Serving a graph as an MCP server (`POST /graphs/{id}/mcp`): connecting an agent, the tool catalog + projection modes, `@mcp(...)`/`@description`/`@instruction` authoring of stored-query tools, `expose` vs `invoke_query`, Host/Origin + protocol-version contracts | | [`references/commands.md`](references/commands.md) | `snapshot`, `export`, `commit list/show`, addressing & resolution | | [`references/migrations.md`](references/migrations.md) | Migrating a pre-0.7.0 setup, or you hit an old config/command/flag/route/error and need its current form | diff --git a/skills/omnigraph/references/mcp.md b/skills/omnigraph/references/mcp.md new file mode 100644 index 0000000..26b4099 --- /dev/null +++ b/skills/omnigraph/references/mcp.md @@ -0,0 +1,144 @@ +# MCP Surface (`POST /graphs/{id}/mcp`) + +Since **v0.8.0** every graph a `--cluster` server serves is also an +[MCP](https://modelcontextprotocol.io) server, so an MCP agent (Claude +Code/Desktop, Cursor, the OpenAI Responses `mcp` tool) can operate the graph +directly — no bespoke client, no `.gq` source on the wire. It is **served +automatically** by `omnigraph-server --cluster …`; there is no flag to enable +it. The surface adds **no new capability**: every tool delegates to the same +engine/handler path the REST routes use and is gated by the same Cedar policy. + +## Transport + +One endpoint per served graph: `POST /graphs/{id}/mcp`. Stateless +Streamable-HTTP — each call returns a single `application/json` JSON-RPC +response (no SSE, no session id). `GET`/`DELETE` → `405`. Every tool operates on +the single graph in the URL path, so the graph id never appears in tool +arguments. + +## Connecting an agent + +Configure the client with the URL and the **same bearer token** you use for +REST: + +```bash +# Claude Code +claude mcp add og --transport http \ + https://graph.example.com/graphs//mcp \ + --header "Authorization: Bearer $TOKEN" +``` + +```bash +# Raw probe +curl -sS https://graph.example.com/graphs//mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2025-11-25","capabilities":{}, + "clientInfo":{"name":"probe","version":"0"}}}' +``` + +The client then drives `initialize` → `tools/list` / `resources/list` → +`tools/call` / `resources/read`. + +## Tools + +**Built-ins** (one per operation, delegating to the REST handlers): `graph_query`, +`graph_mutate`, `graph_load`, `graph_snapshot`, `schema_get`, `branch_list`, +`branch_create` / `branch_delete` / `branch_merge`, `commit_list` / `commit_get`, +`schema_apply` (returns 409 under cluster serving — evolve via `cluster apply` +and restart), and a `graph_health` liveness probe. + +**Stored-query tools** — the graph's registry, projected in one of two modes +chosen automatically from the exposed-query count: + +- **`per_query`** (fewer than 24 exposed) — each exposed query is its own tool, + named by `@mcp(tool_name: …)` (default: the query name), with a typed input + schema (params nested under `params`; `branch`, and for reads `snapshot`, + alongside). +- **`meta`** (24+) — the per-query tools collapse to a `stored_query_list` + + `stored_query_run(name, params, branch?, snapshot?)` pair, so the client's + tool count stays bounded. + +## Authoring stored-query tools (`.gq`) + +A stored-query tool's metadata comes entirely from the `.gq` source (see +[`queries.md`](queries.md) and [`stored-queries.md`](stored-queries.md)): + +```gq +query find_user(@description("the user's slug") $slug: String) + @description("Look up a user by slug.") + @instruction("Use for an exact slug; for fuzzy names use search_users.") + @mcp(tool_name: "lookup_user", expose: true) +{ match { $u: User { slug: $slug } } return { $u.name, $u.email } } +``` + +- **description** = `@description`, with `@instruction` folded in after a blank + line (so the agent reads the how/when-to-use guidance in `tools/list`). +- **tool name** = `@mcp(tool_name: …)`, else the query name. Validated at load: + `[A-Za-z0-9_-]`, ≤64 chars, unique, and may not shadow a built-in **or** the + `stored_query_list`/`stored_query_run` meta names — a violation **fails the + server boot** loudly, never a silently-broken catalog. +- **parameter docs** = each parameter's leading `@description`, surfaced into the + input-schema property `description`. +- **visibility** = `@mcp(expose: false)` hides a query from `tools/list`, + `stored_query_list`, and `stored_query_run` (by name); default is exposed. + +## Resources + +| URI | Contents | +|-----|----------| +| `omnigraph://schema` | the graph's `.pg` schema source | +| `omnigraph://branches` | branch names (JSON) | + +Tool results carry **structured output** (`structuredContent`, the same typed +envelopes as the REST routes) plus a text mirror. + +## Authorization — two axes, do not conflate + +Auth is identical to the REST routes — the bearer resolves to a server-side +actor and every tool/resource hits the same Cedar gate. + +- **Calls are authoritative.** A tool runs only if Cedar permits the action on + the *actual* branch argument; a denial is a tool error (`isError`), not a + silent success. +- **Listing is a relaxation of the call gate.** `tools/list` shows a tool if the + actor could invoke it on *some* branch, so a callable tool is never hidden + (under "protect `main`, write feature branches", `graph_mutate` is listed for a + branch-writer even though writing `main` is denied). Fixed-scope tools whose + call is branchless (`schema_get`/`branch_list`/`commit_get`, and the schema and + branches resources) are gated on that exact branchless read, so a tool and its + resource twin are consistent. +- **Stored-query discovery + invocation share the `invoke_query` gate** — the + same authority as REST `GET /queries` and `POST /queries/{name}`. A caller + without `invoke_query` gets a stored tool masked as an **unknown tool** (the + catalog can't be probed). Stored mutations are additionally `change`-gated. + +**`expose` is presentation, not authorization.** `@mcp(expose: false)` only keeps +a query off the agent tool surface; it stays HTTP/service-callable by name for +any caller with `invoke_query`. Who *may* call a query is governed by Cedar +(`invoke_query` + the inner `read`/`change`), never by `expose`. + +## Host / Origin and protocol version + +Fail-closed posture derived from the server bind at startup: + +- **Loopback bind** (`127.0.0.1` or `::1`) — `Host` allow-list is the full + loopback set (`127.0.0.1`, `::1`, `localhost`), Origin unchecked (local dev). +- **Non-loopback bind** — `Host` restricted to the configured public host(s) (or + unrestricted if none — rely on the bearer); any *present* browser `Origin` is + rejected unless allow-listed. Non-browser MCP clients send no `Origin` and + pass. + +A disallowed `Host` → `403`. The `MCP-Protocol-Version` header is validated on +follow-up requests (unsupported → `400`); `initialize` is exempt (it negotiates +the version in its body). + +## Not supported + +MCP prompts, elicitation, sampling, tasks, and `*_list_changed` subscriptions — +the surface is `initialize` + `tools` + `resources` over the stateless POST +transport. + +Full user-facing reference: `docs/user/operations/mcp.md`. diff --git a/skills/omnigraph/references/server-policy.md b/skills/omnigraph/references/server-policy.md index 1ed9776..e84b09f 100644 --- a/skills/omnigraph/references/server-policy.md +++ b/skills/omnigraph/references/server-policy.md @@ -37,7 +37,7 @@ All per-graph routes are nested under `/graphs/{id}/...` (`{id}` = a graph id fr | `POST /graphs/{id}/mutate` | mutation (`/change` = deprecated alias) | | `POST /graphs/{id}/load` | bulk JSONL load, 32 MB; branch creation opt-in via `from` (`/ingest` = deprecated alias) | | `POST /graphs/{id}/export` | NDJSON stream of a branch | -| `GET /graphs/{id}/queries` · `POST /graphs/{id}/queries/{name}` | stored-query catalog (`read`) + invocation (`invoke_query`, +`change` for a stored mutation; deny == 404) | +| `GET /graphs/{id}/queries` · `POST /graphs/{id}/queries/{name}` | stored-query catalog + invocation, both `invoke_query`-gated (+`change` for a stored mutation; invocation deny == 404) | | `POST /graphs/{id}/mcp` | MCP surface — built-ins + stored queries as tools, schema/branches as resources (same per-tool Cedar gate; see *MCP surface* below) | | `GET /graphs/{id}/schema` · `POST /graphs/{id}/schema/apply` | read `.pg` · migrate (`schema_apply`) | | `GET/POST /graphs/{id}/branches` · `DELETE …/branches/{b}` · `POST …/branches/merge` | branch ops | @@ -214,18 +214,9 @@ There is no runtime add/remove of graphs — edit `cluster.yaml`, `cluster apply ## MCP surface -Since **v0.8.0**, every served graph is also an MCP (Model Context Protocol) server at `POST /graphs/{id}/mcp` — mounted automatically by the `--cluster` server, no extra flag. An MCP agent (Claude, Cursor, OpenAI Responses `mcp` tool) connects with just the URL and the graph's bearer token, and operates the graph through tools: +Since **v0.8.0**, every served graph is also an MCP server at `POST /graphs/{id}/mcp`, mounted automatically by the `--cluster` server (no extra flag): built-ins + stored queries as tools, schema/branches as resources, same bearer + Cedar gate as the REST routes. Two things to know here: `tools/list` is a *relaxation* of the per-call gate (a tool callable on some branch is never hidden; the per-call gate stays authoritative), and stored-query discovery/invocation share the `invoke_query` gate (a non-holder gets an unknown-tool mask). -- **Built-in tools** — `graph_query`, `graph_mutate`, `graph_load`, `graph_snapshot`, `schema_get`, `branch_list`, `branch_create`/`delete`/`merge`, `commit_list`/`get`, `schema_apply` (returns 409 under cluster serving — evolve via `cluster apply`), and `graph_health`. -- **Stored-query tools** — the graph's registry, projected per-query below a threshold (default 24 exposed) or as a `stored_query_list` + `stored_query_run` pair above it. Honors `expose`/`tool_name` (see [`stored-queries.md`](stored-queries.md#mcp-exposure)). -- **Resources** — `omnigraph://schema` and `omnigraph://branches`. - -It adds **no new capability**: every tool delegates to the same engine/handler path as the REST routes and passes the **same Cedar gate** (resolved from the same bearer token). Two MCP-specific behaviors to know: - -- **`tools/list` is a relaxation of the per-call gate.** A tool is listed if the actor could invoke it on *some* branch, so a callable tool is never hidden — under "protect `main`, write unprotected branches", `graph_mutate` is listed for a branch-writer even though writing `main` is denied. The per-call gate stays authoritative (a denied call returns a tool error). An actor with no write grant at all sees no write tools. -- **Stored-query denials mask as unknown tools.** Behind the coarse `invoke_query` gate (a stored mutation is additionally `change`-gated), so the catalog can't be probed by a caller lacking the grant. - -Host/Origin posture is fail-closed and derived from the bind: a loopback bind accepts the full loopback `Host` set (`127.0.0.1`/`::1`/`localhost`); a remote bind rejects unexpected browser `Origin`s. Full client guide: `docs/user/operations/mcp.md`. +Full guide — connecting an agent, the tool catalog + projection modes, `@mcp(...)` authoring, the presentation-vs-authorization split, and the Host/Origin + protocol-version contracts — is in [`mcp.md`](mcp.md). ## Server + Policy Together diff --git a/skills/omnigraph/references/stored-queries.md b/skills/omnigraph/references/stored-queries.md index b03ff05..ac96468 100644 --- a/skills/omnigraph/references/stored-queries.md +++ b/skills/omnigraph/references/stored-queries.md @@ -32,7 +32,7 @@ omnigraph queries list # print the addressed graph's registry: query nam | Route | Gate | Purpose | |-------|------|---------| -| `GET /graphs/{id}/queries` | `read` | Typed tool catalog of the served queries. Graph-wide (branch-independent; `read` authorized against `main`). | +| `GET /graphs/{id}/queries` | `invoke_query` | Typed tool catalog of the exposed queries. Graph-scoped (branch-independent) — same authority as invocation and the MCP `tools/list` surface, so listing and invoking agree. | | `POST /graphs/{id}/queries/{name}` | `invoke_query` (+ `change` for a stored mutation) | Invoke a named query. Body carries params only — **never** `.gq` source. A stored mutation cannot target a `snapshot` (`400`); a param type error is a structured `400` naming the param. | `?branch=` / `?snapshot=` query params apply to `POST /graphs/{id}/queries/{name}` reads; branch/snapshot access stays enforced by the inner `read`/`change` gate (`invoke_query` itself is graph-scoped, not branch-scoped). @@ -66,5 +66,5 @@ Defaults (no `@mcp`): exposed, tool name = query name. There is no `cluster.yaml ## Note on per-query authorization -The catalog is **not** Cedar-filtered per query yet: a caller with `read` but not `invoke_query` can *list* a query it cannot *invoke* (invocation would 404). Per-query authorization is future work; for now the catalog is a discovery surface and `invoke_query` is the invocation gate. +Discovery and invocation share one gate: `invoke_query` lists the catalog *and* governs invocation (so a caller that can list can invoke, subject to the inner `read`/`change` gate on the query body). The gate is still **coarse** — `invoke_query` is graph-wide, not per query, so a holder sees/can-invoke every exposed query. Per-query Cedar scoping (distinguishing individual queries) is future work.