fix(server): align stored-query MCP discovery gates

This commit is contained in:
Ragnor Comerford 2026-06-17 20:16:56 +02:00
parent c06343362a
commit 916dc46c0e
No known key found for this signature in database
13 changed files with 392 additions and 80 deletions

View file

@ -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 |

View file

@ -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/<id>/mcp \
--header "Authorization: Bearer $TOKEN"
```
```bash
# Raw probe
curl -sS https://graph.example.com/graphs/<id>/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`.

View file

@ -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

View file

@ -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.