Document the per-graph MCP surface (POST /graphs/{id}/mcp, shipped in the
preceding commits and landing under v0.8.0) and the `.gq` authoring controls
that shape stored-query tools.
- New docs/user/operations/mcp.md: the client-facing guide — transport, tool
catalog (built-ins + stored queries), projection modes, structured output,
authorization (call-authoritative + list-relaxation), Host/Origin policy, the
protocol-version contract.
- docs/user/operations/server.md: the /mcp endpoint + an "MCP surface" section;
docs/user/index.md: a "Connect an MCP agent" pointer.
- docs/user/queries/index.md: an Annotations section — query @description /
@instruction / @mcp(expose, tool_name) and per-parameter @description.
- AGENTS.md: topic-table row + MCP note on the HTTP-server capability row.
- docs/dev/testing.md: the omnigraph-mcp crate + server tests/mcp.rs.
- docs/dev/rfc-005 §D5: retire the "cluster = everything exposed" bridge —
cluster mode honors source `@mcp(expose: …)`; presentation vs authorization
split made explicit.
- skills/omnigraph: server-policy.md MCP section; stored-queries.md corrected
(per-query controls now ship via @mcp, not "planned"); SKILL.md MCP triggers,
Deep Dives row, version → 0.8.0.
- docs/releases/v0.8.0.md: the MCP surface + authoring-controls release notes.
Crate version manifests are deliberately NOT bumped — that is the v0.8.0
release-cut step; this lands on the feature branch.
6.8 KiB
MCP Surface (POST /graphs/{id}/mcp)
Every graph a cluster server serves is also exposed as a Model Context
Protocol server, so an MCP-capable agent
(Claude Code/Desktop, Cursor, the OpenAI Responses mcp tool, …) can operate the
graph directly — no bespoke client, no .gq source on the wire. The MCP surface
adds no new capability or business logic: every tool delegates to the same
engine/handler path the REST routes use and is gated by the same Cedar policy.
Available since v0.8.0. It is served automatically by omnigraph-server --cluster … — there is no separate flag to enable it.
Transport
One endpoint per served graph:
POST /graphs/{id}/mcp
It is a stateless Streamable-HTTP MCP transport: each call returns a single
application/json JSON-RPC response — no SSE, no session id. GET/DELETE
return 405 (Allow: POST).
curl -sS https://graph.example.com/graphs/sales/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":"my-agent","version":"0"}}}'
A typical MCP client is configured with just the URL and the bearer token; it
then drives initialize → tools/list / resources/list → tools/call /
resources/read itself.
Every tool operates on the single graph in the URL path, so the graph id never appears in tool arguments or output.
Tools
Built-in tools
| Tool | Action | Notes |
|---|---|---|
graph_health |
— | liveness/identity probe; always visible |
graph_query |
read |
run an ad-hoc read-only GQ query (mutations rejected) |
graph_snapshot |
read |
manifest version + per-table metadata of a branch |
schema_get |
read |
the graph's .pg source |
branch_list |
read |
branch names |
commit_list / commit_get |
read |
commit history |
graph_mutate |
change |
ad-hoc GQ insert/update/delete against a branch |
graph_load |
change (+ branch_create with from) |
bulk NDJSON load |
branch_create / branch_delete / branch_merge |
branch_create / branch_delete / branch_merge |
branch ops |
schema_apply |
schema_apply |
disabled (409) under cluster-backed serving — evolve via cluster apply + restart |
Tool names are domain-qualified snake_case. Read tools are annotated
readOnly; writers are annotated destructive so clients can prompt for
confirmation (annotations are advisory hints — Cedar is the enforcement
boundary).
Stored-query tools
The graph's stored-query registry (declared in cluster.yaml, see
stored queries) is projected as
tools too, in one of two modes chosen automatically from the count of exposed
queries:
per_query(fewer than 24 exposed queries) — each exposed query is its own tool, named by its@mcp(tool_name: …)(default: the query name), with a typed input schema. Query parameters are nested under aparamsobject;branch(and, for reads,snapshot) sit alongside it.meta(24 or more) — the per-query tools collapse to a discovery + execute pair,stored_query_list(filter/inspect the catalog) andstored_query_run(name, params, branch?, snapshot?), so a client's tool count stays bounded.
A stored-query tool's metadata comes from the .gq source (see
query annotations):
- description =
@description, with@instructionfolded in after a blank line (so the agent sees both intools/list). - tool name =
@mcp(tool_name: …), else the query name. - parameter docs = each parameter's
@description, surfaced into the input schema's per-propertydescription.
Only exposed queries are reachable on the MCP surface in either mode. Set
@mcp(expose: false) to hide a query from tools/list, from
stored_query_list, and from stored_query_run (by name). This is presentation
only — the query stays HTTP/service-callable via POST /queries/{name} for any
caller with the invoke_query grant.
Resources
| URI | Gate | Contents |
|---|---|---|
omnigraph://schema |
read |
the graph's .pg schema source |
omnigraph://branches |
read |
branch names as JSON |
Structured output
Tool results carry structured output: structuredContent (the typed result
DTO — the same shape as the REST ReadOutput / ChangeOutput envelopes) plus a
text mirror for clients that don't parse it.
Authorization
Authorization is identical to the REST routes — the bearer token resolves to a server-side actor, and every tool/resource hits the same Cedar gate:
- Calls are authoritative. A built-in tool runs only if the actor's Cedar
grant permits the action on the actual branch argument; a denial comes back
as a tool error (
isError), not a silent success. - Listing is a relaxation.
tools/listshows a tool if the actor could invoke it on some branch, so a tool you can call is never hidden. Under the canonical "protectmain, write feature branches" policy,graph_mutateis listed for an actor who can change unprotected branches even though a write tomainwould be denied. An actor with no write grant at all does not see the write tools. - Stored queries sit behind one coarse
invoke_querygate (a stored mutation is additionallychange-gated). For a caller lackinginvoke_query, a stored tool masks as an unknown tool, so the catalog can't be probed.
Host & Origin policy
The transport derives a fail-closed DNS-rebinding / browser posture from the bind address at startup:
- Loopback bind (
127.0.0.1or::1) — theHostallow-list is the full loopback set127.0.0.1,::1,localhost(so either IP stack works regardless of which one the server bound), andOriginis unchecked (local-dev convenience). - Non-loopback bind — the
Hostallow-list is the configured public host(s) (or unrestricted if none are configured — rely on the bearer token there), and any present browserOriginis rejected (403) unless it is in the configured browser-origins list. A non-browser MCP client sends noOriginand passes.
A disallowed Host is 403.
Protocol version
The MCP-Protocol-Version header is validated on follow-up requests (an
unsupported version → 400). The initialize request is exempt by design — it
negotiates the version in its JSON-RPC body (protocolVersion), so the header is
not checked there.
Not supported
MCP prompts, elicitation, sampling, tasks, and tools/list_changed /
resources/list_changed subscriptions are not implemented — the surface is
initialize + tools + resources over the stateless POST transport.