docs(mcp): document the MCP surface, authoring controls, and skill (v0.8.0)

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.
This commit is contained in:
Ragnor Comerford 2026-06-17 16:04:29 +02:00
parent c8e91c11f0
commit c06343362a
No known key found for this signature in database
11 changed files with 349 additions and 13 deletions

View file

@ -45,6 +45,7 @@ start with install, then follow the section that matches your task.
|---|---|
| Deploy the binary or container | [deployment.md](deployment.md) |
| Use HTTP endpoints | [operations/server.md](operations/server.md) |
| Connect an MCP agent (Claude, Cursor, …) | [operations/mcp.md](operations/mcp.md) |
| Compact, repair, and clean old versions | [operations/maintenance.md](operations/maintenance.md) |
| Configure Cedar authorization | [operations/policy.md](operations/policy.md) |
| Track actors and audit behavior | [operations/audit.md](operations/audit.md) |

154
docs/user/operations/mcp.md Normal file
View file

@ -0,0 +1,154 @@
# MCP Surface (`POST /graphs/{id}/mcp`)
Every graph a cluster server serves is also exposed as a [Model Context
Protocol](https://modelcontextprotocol.io) 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`).
```bash
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](server.md#stored-query-catalog-get-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 a `params` object; `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) and
`stored_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](../queries/index.md#annotations)):
- **description** = `@description`, with `@instruction` folded in after a blank
line (so the agent sees both in `tools/list`).
- **tool name** = `@mcp(tool_name: …)`, else the query name.
- **parameter docs** = each parameter's `@description`, surfaced into the input
schema's per-property `description`.
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/list` shows a tool if the actor could
invoke it on *some* branch, so a tool you can call is never hidden. Under the
canonical "protect `main`, write feature branches" policy, `graph_mutate` is
listed for an actor who can change unprotected branches even though a write to
`main` would be denied. An actor with no write grant at all does not see the
write tools.
- **Stored queries** sit behind one coarse `invoke_query` gate (a stored
*mutation* is additionally `change`-gated). For a caller lacking `invoke_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.1` or `::1`) — the `Host` allow-list is the full
loopback set `127.0.0.1`, `::1`, `localhost` (so either IP stack works
regardless of which one the server bound), and `Origin` is unchecked (local-dev
convenience).
- **Non-loopback bind** — the `Host` allow-list is the configured public host(s)
(or unrestricted if none are configured — rely on the bearer token there), and
any *present* browser `Origin` is rejected (`403`) unless it is in the
configured browser-origins list. A non-browser MCP client sends no `Origin` and
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.

View file

@ -46,6 +46,7 @@ graph id from the cluster's applied revision:
| POST | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: <mutate>; rel="successor-version"`) |
| GET | `/graphs/{id}/queries` | bearer + `read` | list the `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 |
| POST | `/graphs/{id}/schema/apply` | bearer + `schema_apply` (target=`main`) | disabled for cluster-backed serving; returns 409 and points operators at `omnigraph cluster apply` + restart |
| POST | `/graphs/{id}/load` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | bulk load (canonical); branch creation is opt-in via `from` — without it a missing `branch` is a 404, never an implicit fork (32 MB body limit) |
@ -65,7 +66,7 @@ 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 (e.g. an MCP server) to register each as a tool without fetching `.gq` source. 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 **`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.
- **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.
@ -80,6 +81,22 @@ Invoke a curated, server-side stored query by **name** — the source comes from
- **Requires an explicit policy grant when auth is on.** In default-deny mode (bearer tokens but no `policy.file`), only `read` is permitted, so *every* `/queries/{name}` call returns `404` until an `invoke_query` rule is configured.
- A stored mutation cannot target a `snapshot` (`400`); a parameter type error is a structured `400` naming the parameter.
## MCP surface (`POST /graphs/{id}/mcp`)
Each served graph is also an MCP (Model Context Protocol) server at
`POST /graphs/{id}/mcp` — a stateless Streamable-HTTP transport that projects the
built-in operations and the graph's stored-query registry as MCP **tools**, and
the schema / branch list as MCP **resources**. It 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 (resolved from the same bearer token). `tools/list` is a
*relaxation* of the per-call gate — a tool callable on some branch is never
hidden, while the per-call gate stays authoritative. Served automatically by the
cluster server; no separate flag.
Full client guide — connecting, the tool catalog, projection modes, structured
output, Host/Origin policy, and the protocol-version contract — is in
[mcp.md](mcp.md).
## Adding and removing graphs
Runtime add/remove via API is **not** exposed — neither `POST /graphs`

View file

@ -3,8 +3,8 @@
## Query declarations
```
query <name>($p1: T1, $p2: T2?, …)
@description("…") @instruction("…") {
query <name>(@description("…") $p1: T1, $p2: T2?, …)
@description("…") @instruction("…") @mcp(tool_name: "…", expose: true) {
}
```
@ -19,6 +19,31 @@ Multi-modal search functions (`nearest`, `bm25`, `rrf`, …) used inside `match`
Param types reuse all schema scalars; trailing `?` makes a param optional. The compiler reserves `$__nanograph_now` for `now()`.
### Annotations
Annotations after the param list are optional and order-independent:
- `@description("…")` — human-readable summary of the query (shown in the
stored-query catalog and as the MCP tool description).
- `@instruction("…")` — agent-facing *how/when to use* guidance. It is folded
into the [MCP](../operations/mcp.md) tool description (appended after
`@description`), so an agent reading `tools/list` sees it.
- `@mcp(...)`**MCP-presentation** controls for when the query is served as an
agent tool (see [mcp.md](../operations/mcp.md)). Both keys are optional:
- `tool_name: "<name>"` — the tool id to expose the query under (default: the
query name). Must be unique across exposed queries and must not shadow a
built-in tool, or the server refuses to boot.
- `expose: <bool>` — whether the query appears on the agent tool surface
(default `true`). `expose: false` keeps the query HTTP/service-callable by
name but hides it from `tools/list` and the catalog. This is **presentation
only** — not an authorization control (Cedar `invoke_query` governs who may
call it).
A **per-parameter** `@description("…")` (written before the variable, e.g.
`@description("the user's slug") $slug: String`) documents that argument; it is
surfaced into the parameter's JSON-Schema `description` in the catalog and the
MCP tool input schema.
## MATCH clauses
- **Binding**: `$x: NodeType { prop: <literal | $param | now()>, … }`