diff --git a/AGENTS.md b/AGENTS.md index 378de88..e231d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ Full diagram and concurrency model: [docs/dev/architecture.md](docs/dev/architec | Cluster operator guide (deploy/manage clusters, approvals, recovery, serving) | [docs/user/clusters/index.md](docs/user/clusters/index.md) | | Cedar policy actions, scopes, CLI | [docs/user/operations/policy.md](docs/user/operations/policy.md) | | HTTP server endpoints, auth, error model, body limits | [docs/user/operations/server.md](docs/user/operations/server.md) | +| MCP surface — tools/resources for MCP agents (`POST /graphs/{id}/mcp`) | [docs/user/operations/mcp.md](docs/user/operations/mcp.md) | | CLI quick-start | [docs/user/cli/index.md](docs/user/cli/index.md) | | CLI command surface and config schema (`~/.omnigraph/config.yaml`) | [docs/user/cli/reference.md](docs/user/cli/reference.md) | | Audit / actor tracking | [docs/user/operations/audit.md](docs/user/operations/audit.md) | @@ -265,7 +266,7 @@ omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act | Three-way row-level merge | — | `OrderedTableCursor` + `StagedTableWriter`, structured `MergeConflictKind` | | Change feeds | — | `diff_between` / `diff_commits` with manifest fast path + ID streaming | | Cedar policy | — | Per-graph actions plus server-scoped actions (see [docs/user/operations/policy.md](docs/user/operations/policy.md) for the current list), branch / target_branch / protected scopes, validate/test/explain CLI. **Engine-wide enforcement** (MR-722): every `_as` writer (`apply_schema_as`, `mutate_as`, `load_as` — the deprecated `ingest_as` shims route through it — `branch_create_as` / `branch_create_from_as`, `branch_delete_as`, `branch_merge_as`) calls `Omnigraph::enforce(action, scope, actor)` — HTTP, CLI, embedded SDK all hit the same gate. | -| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **cluster-only boot (RFC-011): always `--cluster `, serving N graphs (N ≥ 1) under multi-graph routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs via `cluster apply` and restart.** | +| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **cluster-only boot (RFC-011): always `--cluster `, serving N graphs (N ≥ 1) under multi-graph routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs via `cluster apply` and restart.** Each served graph also exposes a per-graph **MCP surface** (`POST /graphs/{id}/mcp`, RFC-003): stateless Streamable-HTTP transport projecting built-ins + stored queries as tools and schema/branches as resources, same Cedar gate, `tools/list` a relaxation of the per-call gate. | | CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`), scope addressing (`--store`/`--server`/`--cluster`/`--profile`/defaults, RFC-011), aliases, multi-format output (json/jsonl/csv/kv/table) | | Audit / actor tracking | — | `_as` write APIs + actor map in commit graph | | Local S3 testing | — | run RustFS/MinIO + the `AWS_*` env; see [docs/user/deployment.md](docs/user/deployment.md) → *Testing against S3 locally* | diff --git a/docs/dev/rfc-005-server-cluster-boot.md b/docs/dev/rfc-005-server-cluster-boot.md index 85df875..d4fddfc 100644 --- a/docs/dev/rfc-005-server-cluster-boot.md +++ b/docs/dev/rfc-005-server-cluster-boot.md @@ -88,9 +88,24 @@ Boot is fail-fast, matching the server's existing stance (bad policy YAML refuse | stored query fails type-check against the live schema | boot error (existing `validate_and_attach` behavior) | | state lock held | **not** an error — boot takes no lock; it reads a point-in-time snapshot of an immutable-once-written state file (the CAS discipline means a concurrent apply produces a *new* file atomically; the server reads whichever was current at open) | -### D5. The `mcp.expose` bridge in cluster mode +### D5. MCP presentation (`@mcp(expose, tool_name)`) in cluster mode -The cluster query registry has no `expose` flag by design (axiom 14: exposure is a policy decision — Phase 6). Until Phase 6 ships, cluster-mode servers list **all** stored queries in `GET /queries`. This is the documented bridge: *cluster mode = everything exposed; omnigraph.yaml mode = `mcp.expose` honored as today*. Its named sunset is Phase 6's policy-filtered catalog (Compatibility Stance #9). Invocation remains gated by the existing coarse `invoke_query` Cedar action in both modes. +**Superseded (v0.8.0).** The old "bridge" — cluster mode force-lists every stored +query because the cluster registry had no `expose` flag — is gone. Per-query MCP +presentation is now carried in the `.gq` **source** via the `@mcp(expose: …, +tool_name: …)` annotation (re-parsed at boot from the content-addressed query +blob), so cluster mode honors it the same as any other deployment; the boot path +no longer hardcodes `expose: true`. Default with no `@mcp`: exposed, tool name = +query name. + +Crucially this splits two axes the original bridge conflated: **`expose` is +presentation** (does the query appear on the agent tool surface — `tools/list` / +`GET /queries`), carried in source; **authorization** (who may invoke a query) +stays the coarse `invoke_query` Cedar action, with a per-query refinement the +durable future direction. An `expose: false` query is still HTTP/service-callable +by name for any caller holding `invoke_query`. See +[../user/queries/index.md](../user/queries/index.md#annotations) and +[../user/operations/mcp.md](../user/operations/mcp.md). ### D6. Migration path (exit criterion 7) diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 8d6a305..8cb683e 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -9,7 +9,8 @@ This file is the always-on map of the test surface. **Consult it before every ta | `omnigraph` (engine) | `crates/omnigraph/tests/` | Integration tests (28 files), fixture-driven, share `tests/helpers/mod.rs` | | `omnigraph-cli` | `crates/omnigraph-cli/tests/` | Per-area suites (post-modularization): `cli_cluster.rs` (cluster command surface + operator-actor cascade), `cli_cluster_e2e.rs` (spawned-binary lifecycle compositions — lost-state re-import recovery, out-of-band drift, graph-root destruction, multi-graph mixed-disposition convergence), `cli_data.rs` (load/read/change/branch/commit/export/snapshot/policy/embed/maintenance + operator format cascade), `cli_schema_config.rs` (init/config, schema plan/apply), `cli_queries.rs`, `parity_matrix.rs` (RFC-009 Phase 1: the embedded-vs-remote referee — every forked verb run against both arms with matched Cedar policy and the same actor, scrubbed-JSON + exit-code equality; divergences are pinned in its `KNOWN_DIVERGENCES` ledger, never silently repaired), `system_local.rs` (full-cycle cluster lifecycle with a spawned `--cluster` server, applied-policy enforcement over HTTP, keyed-credential auth, operator aliases), `system_remote.rs`; share `tests/support/mod.rs` (hermetic `OMNIGRAPH_HOME` by default) | | `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests`; `tests/failpoints.rs` (feature-gated); `tests/s3_cluster.rs` (bucket-gated full lifecycle on object storage) | Cluster config parser, local JSON state diff, state CAS/lock handling/recovery, read-only validate/plan/status plus explicit refresh/import graph observations, config-only apply (content-addressed payload publish, disposition gating, composite-digest convergence, idempotent re-apply), catalog payload verification (status read-only, refresh drift + self-heal), failpoint crash-mid-apply / CAS-race coverage, Stage 4A graph creation (create executor, recovery sidecars + sweep rows, create crash windows), Stage 4B schema apply (migration previews in plan, schema executor, schema-apply sweep classification, schema crash windows), Stage 4C gated deletes (digest-bound approvals, delete executor + tombstones, delete sweep rows, delete crash windows), and 5A policy binding metadata (applies_to in the applied revision, binding-change diffing + convergence, pre-5A backfill), and the 5B serving-snapshot read API (converged read, refusal rows) | -| `omnigraph-server` | `crates/omnigraph-server/tests/` | Per-area suites (post-modularization): `auth_policy.rs`, `data_routes.rs`, `schema_routes.rs`, `stored_queries.rs`, `multi_graph.rs` (cluster-mode boot — converged serving, policy binding wiring, boot refusals — + the concurrent branch-ops matrix), `boot_settings.rs` (mode inference, PolicySource), `s3.rs` (bucket-gated: single-graph serving + config-free `--cluster s3://` boot), `openapi.rs` (OpenAPI drift / regeneration); share `tests/support/mod.rs` | +| `omnigraph-server` | `crates/omnigraph-server/tests/` | Per-area suites (post-modularization): `auth_policy.rs`, `data_routes.rs`, `schema_routes.rs`, `stored_queries.rs`, `mcp.rs` (the `/graphs/{id}/mcp` surface — protocol conformance, Cedar-filtered listing + the argument-scoped relaxation, stored-tool projection modes + `expose` reachability, structured output), `multi_graph.rs` (cluster-mode boot — converged serving, policy binding wiring, boot refusals — + the concurrent branch-ops matrix), `boot_settings.rs` (mode inference, PolicySource), `s3.rs` (bucket-gated: single-graph serving + config-free `--cluster s3://` boot), `openapi.rs` (OpenAPI drift / regeneration); share `tests/support/mod.rs` | +| `omnigraph-mcp` | `crates/omnigraph-mcp/tests/standalone.rs` | Crate-level MCP transport conformance with a trivial backend (no engine dep): `initialize`/`tools/list`, `405` for `GET`/`DELETE`, fail-closed Origin, the full loopback `Host` set (incl. `::1`), and the `MCP-Protocol-Version` contract (non-init → 400, init exempt). Also the **rmcp surface guard** — first smoke check on an rmcp bump. | | `omnigraph-compiler` | mostly in-source `#[cfg(test)] mod tests` | Parser, type-checker, IR lowering, lint | The engine's `tests/` is the principal coverage surface; most graph-shaped behavior is exercised there. diff --git a/docs/releases/v0.8.0.md b/docs/releases/v0.8.0.md new file mode 100644 index 0000000..283ba08 --- /dev/null +++ b/docs/releases/v0.8.0.md @@ -0,0 +1,89 @@ +# Omnigraph v0.8.0 + +v0.8.0 makes every served graph an **MCP (Model Context Protocol) server**. An +MCP-capable agent — Claude Code/Desktop, Cursor, the OpenAI Responses `mcp` tool, +and others — can connect to a graph and operate it directly: run reads and +mutations, load data, manage branches, browse commits, read the schema, and +invoke the graph's curated stored queries. The surface adds no new capability and +no new business logic; every tool delegates to the same engine/handler path the +REST routes use and is gated by the same Cedar policy. + +## Highlights + +### MCP surface (`POST /graphs/{id}/mcp`) + +- **One MCP endpoint per served graph**, mounted automatically by the cluster + server — no separate flag. It is a stateless Streamable-HTTP transport: a + single `application/json` JSON-RPC response per call, no SSE, no session id. +- **Built-in tools** cover the operational surface: `graph_query`, + `graph_mutate`, `graph_load`, `graph_snapshot`, `schema_get`, `branch_list`, + `branch_create` / `branch_delete` / `branch_merge`, `commit_list` / + `commit_get`, `schema_apply` (disabled with a `409` under cluster-backed + serving — evolve via `cluster apply` and restart), and a `graph_health` + liveness probe. +- **Stored queries as tools.** A graph's stored-query registry is projected as + tools, in one of two modes chosen automatically from the exposed-query count: + `per_query` (each exposed query is its own typed tool) below a threshold, or a + `stored_query_list` + `stored_query_run` discovery/execute pair at or above it, + so a client's tool count stays bounded. +- **Resources.** The graph schema (`omnigraph://schema`) and branch list + (`omnigraph://branches`) are exposed as MCP resources. +- **Structured output.** Tool results carry `structuredContent` (the same typed + result envelopes as the REST routes) plus a text mirror. + +### Authorization parity with REST + +- Every tool and resource resolves the actor from the bearer token and passes the + same Cedar gate as the equivalent REST route; the call-time gate is + authoritative. +- **`tools/list` is a relaxation of the per-call gate**: a tool the actor could + invoke on *some* branch is listed, so listing never hides a tool you can call, + while an actor with no grant for an action still does not see its tools. Under + the common "protect `main`, write feature branches" policy, `graph_mutate` is + listed for an actor who can write unprotected branches. +- Stored queries sit behind the coarse `invoke_query` gate (a stored mutation is + additionally `change`-gated); for a caller without `invoke_query`, a stored + tool masks as an unknown tool so the catalog can't be probed. An + `expose: false` query is unreachable on the MCP surface entirely (not listed, + not runnable by name) while remaining HTTP/service callable. + +### Authoring stored queries as MCP tools + +`.gq` gains the controls to shape how a stored query appears as an MCP tool, all +carried in the query source: + +- **`@instruction("…")` reaches agents.** The query's `@instruction` annotation + is folded into the MCP tool description (after `@description`), so the + how/when-to-use guidance shows up in `tools/list` — previously it surfaced only + in the REST catalog. +- **Per-parameter docs.** A leading `@description("…")` on a parameter + (`@description("the user's slug") $slug: String`) is surfaced into the + parameter's JSON-Schema `description` in both the MCP tool input schema and the + `GET /queries` catalog. +- **`@mcp(tool_name: "…", expose: )`.** A dedicated MCP-presentation + annotation: `tool_name` overrides the tool id (unique-checked at boot, can't + shadow a built-in); `expose: false` hides the query from the agent tool surface + (`tools/list` / `stored_query_list` / `stored_query_run`) while keeping it + HTTP/service-callable by name. `expose` is presentation only — Cedar + `invoke_query` remains the authority for who may call a query. + +### Transport hardening + +- **Fail-closed Host / Origin posture**, derived from the bind address at + startup. A loopback bind accepts the full loopback `Host` set + (`127.0.0.1`, `::1`, `localhost`) regardless of which IP stack it bound; a + non-loopback bind rejects an unexpected browser `Origin` and restricts `Host` + to the configured public hosts. +- The `MCP-Protocol-Version` header is validated on follow-up requests (an + unsupported version is a `400`); `initialize` negotiates the version in its + body and is exempt by design. + +## Upgrade notes + +- **No breaking changes.** 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 + recipe, the tool catalog, projection modes, and the Host/Origin and + protocol-version contracts. Design and rationale: RFC-003. diff --git a/docs/user/index.md b/docs/user/index.md index cabd98a..263a94d 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -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) | diff --git a/docs/user/operations/mcp.md b/docs/user/operations/mcp.md new file mode 100644 index 0000000..be02b86 --- /dev/null +++ b/docs/user/operations/mcp.md @@ -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. diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index bd14e1e..1430c7c 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -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: ; 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` diff --git a/docs/user/queries/index.md b/docs/user/queries/index.md index c8a70c5..2a6d65e 100644 --- a/docs/user/queries/index.md +++ b/docs/user/queries/index.md @@ -3,8 +3,8 @@ ## Query declarations ``` -query ($p1: T1, $p2: T2?, …) - @description("…") @instruction("…") { +query (@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: ""` — 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: ` — 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: , … }` diff --git a/skills/omnigraph/SKILL.md b/skills/omnigraph/SKILL.md index 7bf044a..be87369 100644 --- a/skills/omnigraph/SKILL.md +++ b/skills/omnigraph/SKILL.md @@ -1,11 +1,11 @@ --- name: omnigraph -description: Store, retrieve, and query knowledge, memory, and relationships in an Omnigraph graph, and operate a local or remote Omnigraph deployment. Use when the user wants to capture or recall facts, notes, or entities, build or query a knowledge graph or agent memory, or run Omnigraph — and whenever you see Omnigraph CLI commands (omnigraph init/query/mutate/load/schema/lint/embed/branch/commit/login/profile/cluster), .pg schema or .gq query files, s3:// graph URIs, bearer-authed graph endpoints, 504 errors, or a cluster.yaml / omnigraph.yaml / ~/.omnigraph/config.yaml. Covers cluster-mode deployments (cluster.yaml plan/apply, omnigraph-server --cluster), the two config surfaces (cluster.yaml + ~/.omnigraph/config.yaml), schema evolution, query linting, data writes (mutate; load needs --mode/--from), branches, embeddings, Cedar policy, and remote ops. Especially important before schema apply (plan first), any load (--mode required), any .gq/.pg edit (lint after), or any remote write (verify via commit list). +description: Store, retrieve, and query knowledge, memory, and relationships in an Omnigraph graph, and operate a local or remote Omnigraph deployment. Use when the user wants to capture or recall facts, notes, or entities, build or query a knowledge graph or agent memory, or run Omnigraph — and whenever you see Omnigraph CLI commands (omnigraph init/query/mutate/load/schema/lint/embed/branch/commit/login/profile/cluster), .pg schema or .gq query files, s3:// graph URIs, bearer-authed graph endpoints, an MCP endpoint or MCP client config for a graph (POST /graphs/{id}/mcp), 504 errors, or a cluster.yaml / omnigraph.yaml / ~/.omnigraph/config.yaml. Covers cluster-mode deployments (cluster.yaml plan/apply, omnigraph-server --cluster), the MCP surface (graphs as MCP servers), the two config surfaces (cluster.yaml + ~/.omnigraph/config.yaml), schema evolution, query linting, data writes (mutate; load needs --mode/--from), branches, embeddings, Cedar policy, and remote ops. Especially important before schema apply (plan first), any load (--mode required), any .gq/.pg edit (lint after), or any remote write (verify via commit list). license: MIT (see LICENSE at repo root) -compatibility: Requires omnigraph CLI >= 0.7.0 — the unified `load`, the two config surfaces (cluster.yaml + ~/.omnigraph/config.yaml), and cluster apply/serve all require 0.7.0. +compatibility: Requires omnigraph CLI >= 0.7.0 — the unified `load`, the two config surfaces (cluster.yaml + ~/.omnigraph/config.yaml), and cluster apply/serve all require 0.7.0. The MCP surface (POST /graphs/{id}/mcp) requires omnigraph-server >= 0.8.0. metadata: author: ModernRelay - version: "0.7.0" + version: "0.8.0" repository: https://github.com/ModernRelay/omnigraph --- @@ -409,6 +409,6 @@ 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 | +| [`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/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/server-policy.md b/skills/omnigraph/references/server-policy.md index 225c708..1ed9776 100644 --- a/skills/omnigraph/references/server-policy.md +++ b/skills/omnigraph/references/server-policy.md @@ -7,6 +7,7 @@ - Setup operations bypass the server - Cedar policy - Multi-graph mode +- MCP surface - Server + policy together - Cluster-booted servers @@ -37,6 +38,7 @@ All per-graph routes are nested under `/graphs/{id}/...` (`{id}` = a graph id fr | `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) | +| `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 | | `GET /graphs/{id}/commits?branch=` · `…/commits/{commit_id}` | history | @@ -210,6 +212,21 @@ Policy attaches at two levels via `cluster.yaml` `applies_to`: There is no runtime add/remove of graphs — edit `cluster.yaml`, `cluster apply`, restart. +## 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: + +- **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`. + ## Server + Policy Together When the server is running with a policy file: diff --git a/skills/omnigraph/references/stored-queries.md b/skills/omnigraph/references/stored-queries.md index 02aaf75..b03ff05 100644 --- a/skills/omnigraph/references/stored-queries.md +++ b/skills/omnigraph/references/stored-queries.md @@ -15,7 +15,7 @@ graphs: queries: queries/ # discover every `query ` in queries/*.gq ``` -`queries` also accepts an explicit file list (`[a.gq, b.gq]`) or a fine-grained `name: { file: … }` map; an unparseable `.gq` or a duplicate query name across files fails `cluster validate`. `cluster apply` publishes them to the content-addressed catalog, and the `--cluster` server type-checks and serves every applied query. Every applied query is listed (per-query `mcp:`/expose flags are a planned phase). +`queries` also accepts an explicit file list (`[a.gq, b.gq]`) or a fine-grained `name: { file: … }` map; an unparseable `.gq` or a duplicate query name across files fails `cluster validate`. `cluster apply` publishes them to the content-addressed catalog, and the `--cluster` server type-checks and serves every applied query. Per-query MCP presentation (`expose`, `tool_name`) is set in the `.gq` source via the `@mcp(...)` annotation (see *MCP exposure* below), so it travels content-addressed with the query — no `cluster.yaml` per-query block needed. ## CLI @@ -46,7 +46,23 @@ omnigraph queries list # print the addressed graph's registry: query nam ## MCP exposure -Every applied query is listed in `GET /graphs/{id}/queries` as a typed MCP tool. Per-query exposure controls (`mcp.expose`, `tool_name`) are a planned phase — there is no per-query `mcp:` flag in cluster mode today. +Stored queries are surfaced two ways: the REST catalog `GET /graphs/{id}/queries` (typed entries for a client to register), and — since v0.8.0 — **live MCP tools** at `POST /graphs/{id}/mcp` (per-query tools below an exposed-count threshold, or a `stored_query_list` + `stored_query_run` pair above it). See [`server-policy.md`](server-policy.md#mcp-surface) and `docs/user/operations/mcp.md`. + +Per-query MCP presentation is set in the `.gq` source via `@mcp(...)`: + +```gq +query find_user(@description("the user's slug") $slug: String) + @description("Look up a user by slug.") + @instruction("Use for exact slugs; for fuzzy names use search_users.") + @mcp(tool_name: "lookup_user", expose: true) +{ match { $u: User { slug: $slug } } return { $u.name } } +``` + +- `@mcp(tool_name: "…")` — the MCP tool id (default: the query name). Must be unique across exposed queries and must not shadow a built-in, or the server refuses to boot. +- `@mcp(expose: false)` — hide from `tools/list`, `stored_query_list`, and `stored_query_run` (by name). Presentation only: the query stays HTTP/service-callable via `POST /queries/{name}` for any caller with `invoke_query` (which is the *authorization* gate — Cedar, not `expose`). +- The MCP tool description folds `@instruction` after `@description`; per-param `@description` documents each argument in the tool input schema. + +Defaults (no `@mcp`): exposed, tool name = query name. There is no `cluster.yaml` per-query block — the source annotation is the single home. ## Note on per-query authorization