mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
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.
154 lines
6.8 KiB
Markdown
154 lines
6.8 KiB
Markdown
# 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.
|