mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
feat: inline query strings in CLI and HTTP server (#110)
* feat(MR-656): inline query strings in CLI and HTTP server
CLI:
- Add -e / --query-string <STRING> to omnigraph read and omnigraph change
- Exactly one of --query, --query-string, --alias is required (3-way XOR)
- Empty --query-string is rejected with a clear error
HTTP:
- New POST /query (read-only, clean field names: query/name/params/branch/snapshot)
- Mutations on /query are rejected with 400 -- use POST /change instead
- ChangeRequest fields polished: query (alias query_source), name (alias query_name)
- POST /read and POST /change remain byte-compatible for existing clients
Tests:
- cli.rs: -e happy-path on read/change, mutex error vs --query, empty -e rejected
- system_local.rs: inline -e read and -e change exercise the local flow
- system_remote.rs: inline -e read/change over HTTP plus direct /query 200/400
- server.rs: /query 200, /query 400 on mutation, /change legacy field alias
- openapi.rs: new /query path, QueryRequest schema, ChangeRequest field-name polish
Docs: cli.md (-e examples), cli-reference.md (read/change rows), server.md (/query)
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* feat(MR-656): rename read/change to query/mutate with deprecation signals
HTTP server:
- Add POST /mutate as canonical write endpoint (pairs with POST /query).
- Mark POST /read and POST /change as deprecated. Three-channel signal:
* OpenAPI: `deprecated: true` on the operation (every codegen flags
the generated SDK method).
* RFC 9745: response `Deprecation: true` header on every response.
* RFC 8288: response `Link: </successor>; rel="successor-version"`
pointing at /query and /mutate respectively.
- Share business logic across /mutate and /change via run_mutate(); the
/change wrapper is the only place that adds the deprecation headers.
- ChangeRequest field aliases (query_source/query_name) preserved.
- AliasCommand serde now accepts `query`/`mutate` alongside `read`/`change`.
CLI:
- Promote `omnigraph query` / `omnigraph mutate` to top-level canonical
subcommands (clap visible_alias keeps `omnigraph read` / `omnigraph
change` working forever).
- Promote `omnigraph lint` / `omnigraph check` to top-level (was nested
under `omnigraph query lint`, which is now a deprecated argv shim that
rewrites to the canonical form).
- Argv-level preprocessing prints a one-line deprecation warning to
stderr when any legacy spelling is used. Canonical names are silent.
Tests:
- Server: /mutate works, /change emits Deprecation+Link headers, /read
emits Deprecation+Link headers, /query carries no deprecation signal.
- OpenAPI: /read and /change flagged deprecated; /query and /mutate not.
- CLI: canonical `lint` matches deprecated `query lint` / `query check`
output; `read` / `change` print deprecation warnings.
Docs:
- cli.md: new canonical examples; "Deprecated names" migration table.
- cli-reference.md: top-level table updated; aliases.<name>.command
accepts both legacy and canonical spellings.
- server.md: endpoint inventory shows /query and /mutate as canonical
and /read and /change as deprecated; dedicated section explains the
three-channel deprecation signal.
- og-cheet-sheet.md: use new `omnigraph lint` / `omnigraph check`.
- openapi.json regenerated.
Migration is purely cosmetic — every deprecated form continues to work
indefinitely; only the spelling changes.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* fix(MR-656): address Devin Review findings on /query and /change
Two issues raised by Devin Review on PR #110:
1. `POST /query` mutation-rejection error pointed at the deprecated
`/change` endpoint instead of the canonical `/mutate`. Fixed in
three places: the runtime error message in `server_query`, the
utoipa 400-response description, and the handler doc comment. The
`QueryRequest` schema docstrings in `api.rs` got the same update so
the openapi.json bodies match. Server and openapi tests updated.
2. `execute_change_remote` serialized `ChangeRequest` directly, which
emits the new canonical field names `query` / `name` on the wire.
`#[serde(alias = "query_source")]` only affects deserialization, so
a newer CLI talking to an older server would have its `/change`
POST body fail with "missing field: query_source". Fixed by
extracting a `legacy_change_request_body` helper that hand-rolls
the JSON with the legacy keys (`query_source` / `query_name`), the
same byte-stable contract `execute_read_remote` already uses
against `/read`. Added two unit tests on the helper to lock the
wire shape in.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* docs(dev): RFC 001 — inline + stored queries, envelope, MCP
Tracked artifact consolidating the design across MR-656 (this branch),
MR-976 (Phase 1 envelope hardening parent, with MR-977/978/979/980
sub-issues), and MR-969 (stored queries + MCP).
Sections:
* Two paths, one engine — inline `/query` + `/mutate` (this PR) coexist
with stored `/queries/{name}` (MR-969). Same `run_query` / `run_mutate`
backend (the fold-in landed in the previous commit).
* Request envelope ("before") — Idempotency-Key, If-Match, X-Deadline,
X-Trace-Id, expect, dry_run, fields. Phase 1 ships the load-bearing
subset on `/mutate`.
* Response envelope ("after") — audit_id, snapshot_id, commit_id, stats,
warnings. Closes the provenance loop today's `ChangeOutput` leaves
open.
* `.gq` pragmas — `@description`, `@returns`, `@mcp`. Source-of-truth
for the stored-query agent contract; no separate YAML registry.
* Multi-graph MCP — per-graph `/graphs/{id}/mcp/tools` + `/mcp/invoke`.
Token binds to one graph by default; cross-graph agents loop.
* Cedar split — `read`/`change` for inline, `invoke_query` for stored.
Operators deny ad-hoc for agent groups while keeping curated tool
list open.
* Rejected alternatives — per-env override files, compiled bundles,
tool-name prefixing across graphs, body-field graph dispatch.
Index entry added under "Active Implementation Plans" so future agents
land on the RFC before touching queries / mutations / envelope code.
`scripts/check-agents-md.sh` clean (35 links, 34 docs).
* docs(server): clarify why run_query lacks AppState parameter
run_mutate takes state for workload admission; run_query doesn't because
reads aren't admission-gated today. Mark the asymmetry as intentional and
flag the two future events that would grow the signature: Phase 1's
`expect: { max_rows_scanned: N }` budget (MR-976) or per-actor admission
extending to stored-read invocations (MR-969). Prevents the natural
"make these symmetrical" follow-up.
* refactor(server): run_query / run_mutate take &ResolvedActor
Replace `Option<Extension<ResolvedActor>>` in the helpers with
`Option<&ResolvedActor>`. Saves MR-969's stored-query handler from
wrapping a bare actor in axum's `Extension(...)` before calling.
Handler signatures (`server_query`, `server_read`, `server_mutate`,
`server_change`) keep `Option<Extension<ResolvedActor>>` because that
is what axum injects, and unwrap at the call site with
`actor.as_ref().map(|Extension(actor)| actor)`.
Net: -13/+10 LOC, 89/0 server tests pass.
* docs(releases): v0.6.0 — describe inline + canonical-named queries (MR-656)
Extend the v0.6.0 release notes to cover the third piece of work landing
alongside the graph terminology rename and multi-graph server mode:
canonical-named `POST /query` and `POST /mutate` endpoints, the CLI's
new `-e/--query-string` flag, the top-level promotion of `lint` /
`check`, and the three-channel deprecation signal on `/read` and
`/change` (OpenAPI `deprecated: true` + RFC 9745 + RFC 8288).
Additions:
* Top blurb: "Two pieces" -> "Three pieces" with a bullet describing
the rename + inline flow.
* Breaking Changes: new "Query / mutation rename" subsection covering
the `ChangeRequest` field rename (with the back-compat serde aliases
and the CLI's `legacy_change_request_body` byte-stable wire helper)
and the `omnigraph query lint` -> `omnigraph lint` move.
* New: 5 bullets — the two endpoints, the CLI subcommands, the `-e`
flag, the deprecation signal channels, the widened `aliases.<name>.command`
vocabulary.
* User Impact: one bullet making explicit that the rename is cosmetic
on the client side and migration is voluntary.
* Documentation: pointers to the updated `server.md` / `cli.md` /
`cli-reference.md` and the new `docs/dev/rfc-001-queries-envelope-mcp.md`.
+15/-1 lines. `./scripts/check-agents-md.sh` clean.
* refactor(cli): demote `check` from visible_alias to deprecation shim
`omnigraph check` was a clap `visible_alias` on `lint`, advertised in
`--help` as an equivalent canonical name. Per MR-981 §6 (long-form
flags as canonical, short forms as visible aliases), visible aliases
on subcommand names hurt agent CX: agents emit either spelling
depending on training-data drift, and there's no length signal
pointing at the canonical name.
Changes:
* Remove `#[command(visible_alias = "check")]` from the `Lint` variant.
`omnigraph --help` now shows only `lint`.
* Add bare `check` to `rewrite_deprecated_argv` so `omnigraph check
<args>` still works — it rewrites to `omnigraph lint <args>` and
emits a one-line stderr deprecation warning, matching the existing
pattern for `read` / `change` / `query lint` / `query check`.
* Fix the nested `query check` shim to substitute `check` -> `lint` in
the rewritten argv (previously it relied on `check` being a
visible_alias to reach the `Lint` variant).
* New test `deprecated_check_top_level_rewrites_to_lint` covers: bare
`check` produces identical stdout to `lint`, emits the deprecation
warning, and `check` does NOT appear as an alias in `omnigraph
--help`.
* Release notes updated to reflect the deprecation-shim treatment and
cross-reference MR-981 §6 reasoning.
Cargo / Go users typing `check` still work indefinitely; one stderr
nudge per invocation teaches the canonical name. Agents see only
`lint` in `--help --json` so they emit one canonical form.
67/0 omnigraph-cli tests pass; 39 workspace test suites green.
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ragnor Comerford <ragnor.comerford@gmail.com>
Co-authored-by: Ragnor Comerford <hello@ragnor.co>
This commit is contained in:
parent
e0f13b32c5
commit
1a4d2cee97
19 changed files with 2088 additions and 264 deletions
|
|
@ -58,6 +58,7 @@ Working documents for in-flight feature work. Removed when the work lands.
|
|||
| Area | Read |
|
||||
|---|---|
|
||||
| Schema-lint chassis v1 (MR-694) — `--allow-data-loss`, soft/hard drops | [schema-lint-v1-plan.md](schema-lint-v1-plan.md) |
|
||||
| Inline + stored queries, request/response envelope, MCP (MR-656 / MR-976 / MR-969) | [rfc-001-queries-envelope-mcp.md](rfc-001-queries-envelope-mcp.md) |
|
||||
|
||||
## Boundary
|
||||
|
||||
|
|
|
|||
351
docs/dev/rfc-001-queries-envelope-mcp.md
Normal file
351
docs/dev/rfc-001-queries-envelope-mcp.md
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
# RFC: Inline + Stored Queries, Request/Response Envelope, MCP
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-05-28
|
||||
**Tickets:** MR-656 (inline `-e` + URL rename), MR-668 (multi-graph, shipped), MR-976 (Phase 1 envelope parent: MR-977 / MR-978 / MR-979 / MR-980), MR-969 (stored queries + MCP)
|
||||
**Target release:** v0.6.x patch series (MR-656 + Phase 1) → v0.7.0 (MR-969 PRs 1-3)
|
||||
|
||||
## Summary
|
||||
|
||||
OmniGraph today exposes `POST /read` and `POST /change` with a weakly-contracted body (counts only on writes) and no per-query authorization. This RFC consolidates the work landing across three Linear tickets into one coherent design:
|
||||
|
||||
1. **MR-656**: rename `/read` → `/query` and `/change` → `/mutate`, add inline `-e` CLI flag, ship three-channel deprecation on the legacy URLs. **In flight, PR #110.**
|
||||
2. **Envelope hardening** (this RFC adds it as a Phase 1 before MR-969): make today's mutation surface agent-grade with idempotency keys, preconditions, deadlines, and a structured response envelope carrying `audit_id`, `commit_id`, `snapshot_id`, and cost stats.
|
||||
3. **MR-969**: add a stored-query registry, `POST /queries/{name}`, a new `InvokeQuery` Cedar action with per-query scope, inline pragmas in `.gq` (`@description`, `@returns`, `@mcp`), and MCP transport over the same routing primitive.
|
||||
|
||||
The bet: inline and stored queries serve different stages of the same lifecycle, run through the same engine code, and are gated by different Cedar actions. HelixDB collapsed to stored-only. Postgres has neither stored-query Cedar nor MCP. The window for an OSS, declarative, agent-grade graph query surface is open.
|
||||
|
||||
## Motivation
|
||||
|
||||
Three problems today:
|
||||
|
||||
- **Mutation responses are too thin.** `ChangeOutput { node_count, edge_count }` is the entire memory the API has of what just happened. No `commit_id`, no `audit_id`, no `snapshot_id`. Agents reporting results have nothing to cite. Humans can't reproduce a read.
|
||||
- **No agent-safe surface.** Cedar gates `read` and `change` at the action level. A token either runs *any* query or *no* query of that kind. There is no way to express "this agent can invoke `find_user` and nothing else."
|
||||
- **No discovery primitive.** Agents need a tool list. SDKs need a stable contract per operation. Both are absent.
|
||||
|
||||
The MR-656 rename solves the cosmetic asymmetry (`/read` was a poor pair for the future `/queries/{name}`). The envelope work and MR-969 solve the substantive gaps.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Compiled query bundles (HelixDB's `queries.json` shape). `.gq` files are already declarative; the file *is* the artifact.
|
||||
- Hot reload of the registry. Restart-only matches the multi-graph operational model from MR-668.
|
||||
- Per-query rate limits in v1. Existing `WorkloadController` covers the bulk of the risk. Punt to a future ticket.
|
||||
- Cross-graph tool listing in MCP. Agents loop over per-graph endpoints when they need multi-graph access. Avoid namespacing in the contract.
|
||||
- Web dashboard / control-plane management of the registry. Operators edit `.gq` + `policy.yaml` and restart.
|
||||
- Schema introspection through MCP. Schema is an operator concern; agents see types through declared return shapes on the queries they're allowed to invoke.
|
||||
- Per-environment override files. Environment-specific differences live in `policy.yaml`, which already has per-env variants.
|
||||
|
||||
## Background
|
||||
|
||||
OmniGraph runs on Lance 6.x with a property graph layered on top: typed nodes/edges in per-type Lance datasets, atomic multi-table commits via a `__manifest` table, branchable and time-travelable through Lance versioning. The HTTP server (`omnigraph-server`) is Axum + utoipa with bearer-token auth and Cedar policy enforcement at every `_as` writer.
|
||||
|
||||
MR-668 shipped multi-graph mode in v0.6.0. One server process can host 1-10 graphs, with per-graph endpoints under `/graphs/{id}/...`. Cedar policy resolves against `Server::"root"` (for management actions) and `Graph::"prod"` (for per-graph actions).
|
||||
|
||||
MR-656 is currently in PR #110 (CONFLICTING / DIRTY against main; rebase planned). It renames the URL surface, adds inline source support, and ships three-channel deprecation (OpenAPI `deprecated: true`, RFC 9745 `Deprecation: true` header, RFC 8288 successor `Link`).
|
||||
|
||||
## Design
|
||||
|
||||
### Two paths, one engine
|
||||
|
||||
| Dimension | Inline (`/query`, `/mutate`) | Stored (`/queries/{name}`) |
|
||||
|---|---|---|
|
||||
| Source location | Request body | `queries/*.gq` on disk |
|
||||
| Parse + typecheck | Per request | Once at server boot |
|
||||
| Cedar action | `read` / `change` | `invoke_query` (per-name scope) |
|
||||
| MCP-exposed | No (not enumerable) | Yes (when `@mcp(expose=true)`) |
|
||||
| Output schema | Inferred | Declared via `@returns`, asserted at boot |
|
||||
| Audit log shape | Records query hash | Records query name |
|
||||
| Failure visibility | Runtime 400 | Boot-time refusal |
|
||||
|
||||
Both paths converge in the engine:
|
||||
|
||||
```
|
||||
POST /query ─parse→─┐
|
||||
POST /mutate ─parse→─┤
|
||||
├─→ run_query / run_mutate(ast, params, branch) ─→ envelope
|
||||
POST /queries/{name} ───────┤
|
||||
POST /mcp/invoke ───────────┘ (MCP adapter on top of the same call)
|
||||
```
|
||||
|
||||
The MR-656 rebase widens `run_query` / `run_mutate` to accept a parsed AST or source string. Inline parses on each call. Stored looks up the pre-parsed AST in the registry. Same execution path beyond that point.
|
||||
|
||||
### Cedar split (the LLM-safe wedge)
|
||||
|
||||
Inline and stored coexist safely because they're gated by different actions:
|
||||
|
||||
```yaml
|
||||
# Production policy — agents locked to a curated stored-query set
|
||||
- deny:
|
||||
actors: { group: agents }
|
||||
actions: [read, change] # blocks /query, /mutate, /read, /change
|
||||
|
||||
- allow:
|
||||
actors: { group: agents }
|
||||
actions: [invoke_query]
|
||||
resource: Graph::"prod"
|
||||
query_scope: { names: [find_user, list_orders, search_docs] }
|
||||
```
|
||||
|
||||
The agent's effective surface: three stored queries by name. Cannot compose inline. Cannot enumerate schema. Cannot read arbitrary entities. A developer in the same deployment with `dev-engineers` group membership might have `[read, change, invoke_query]` allowed — full access to both paths.
|
||||
|
||||
Same server, same data, two completely different API surfaces depending on token. This is the posture MR-969 calls "LLM-safe API surface."
|
||||
|
||||
### `.gq` pragmas
|
||||
|
||||
Stored queries self-describe at the top of the source file:
|
||||
|
||||
```gq
|
||||
@description("Look up a user by ID. Returns name, email, last_login.")
|
||||
@returns({ name: String, email: String, last_login: DateTime? })
|
||||
@mcp(expose=true)
|
||||
|
||||
query find_user($id: String) {
|
||||
match { $u: User { id: $id } }
|
||||
return { $u.name, $u.email, $u.last_login }
|
||||
}
|
||||
```
|
||||
|
||||
Three pragmas in v1:
|
||||
|
||||
- `@description("...")` — string surfaced in `omnigraph queries explain` and MCP tool descriptions.
|
||||
- `@returns({...})` — optional output type assertion. Compiler verifies the inferred type matches; mismatch fails server startup.
|
||||
- `@mcp(expose=true|false, tool_name="alt_name"?)` — controls MCP visibility. Default is `expose=false` (callable via HTTP, hidden from MCP). `tool_name` defaults to the query name.
|
||||
|
||||
Pragmas live in source, not in a separate YAML registry. Drop a file in `queries/`, restart, the registry picks it up. The full agent contract is reviewable in one diff.
|
||||
|
||||
### Request envelope ("before")
|
||||
|
||||
Today's request carries auth + body. The envelope adds five fields, all optional:
|
||||
|
||||
```http
|
||||
POST /graphs/prod/queries/find_user
|
||||
Authorization: Bearer <token>
|
||||
Idempotency-Key: 01HXYZ... # mutations only
|
||||
If-Match: 01HABC... # optimistic concurrency
|
||||
X-Deadline: 2026-05-28T19:30:00Z # or X-Timeout-Ms: 5000
|
||||
X-Trace-Id: 01HDEF...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"params": { "id": "u-42" },
|
||||
"branch": "main",
|
||||
"expect": "read_only", # scope assertion
|
||||
"dry_run": false, # mutations only
|
||||
"fields": ["name", "email"] # result projection
|
||||
}
|
||||
```
|
||||
|
||||
Field semantics:
|
||||
|
||||
| Field | Applies to | Purpose |
|
||||
|---|---|---|
|
||||
| `Idempotency-Key` | Mutations | Server caches `(token, key)` → response for 10 minutes. Replays return cached response with `Idempotency-Replay: true` header. Prevents double-write on retry. |
|
||||
| `If-Match` | Mutations | Run only if branch HEAD matches the given commit ID. 412 Precondition Failed otherwise. Enables read-then-write without races. |
|
||||
| `X-Deadline` / `X-Timeout-Ms` | All | Server respects; returns 504-typed error past the deadline. Bounds execution for context-budget-constrained callers. |
|
||||
| `X-Trace-Id` | All | Caller-supplied; server echoes back. Lets agents correlate multi-call sequences. |
|
||||
| `expect` | All | Caller asserts shape: `"read_only"`, `{"max_rows_scanned": 10000}`. Server validates against parsed AST or planner estimate; rejects before running. |
|
||||
| `dry_run` | Mutations | Returns what *would* happen without committing. Implemented via scratch branch + diff + discard. |
|
||||
| `fields` | Reads | Server returns only listed columns. Saves bandwidth + agent context window. |
|
||||
|
||||
All five fields are optional; today's call shape continues working.
|
||||
|
||||
### Response envelope ("after")
|
||||
|
||||
The response envelope replaces today's bare-result shape with a structured wrapper. Every endpoint (inline, stored, MCP) returns the same envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": { "name": "Alice", "email": "alice@..." },
|
||||
"audit_id": "01HGHI...",
|
||||
"snapshot_id": "01HJKL...",
|
||||
"commit_id": null,
|
||||
"stats": {
|
||||
"rows_scanned": 1,
|
||||
"ms_elapsed": 4,
|
||||
"bytes_read": 128
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Response headers:
|
||||
|
||||
| Header | When | Purpose |
|
||||
|---|---|---|
|
||||
| `Idempotency-Replay: true\|false` | Mutations | Was this response served from the idempotency cache? |
|
||||
| `X-Trace-Id` | All | Echo of the request's trace ID, or server-minted if absent. |
|
||||
| `Deprecation: true` | `/read`, `/change` only | RFC 9745 signal from MR-656. |
|
||||
| `Link: </query>; rel="successor-version"` | `/read`, `/change` only | RFC 8288 successor pointer from MR-656. |
|
||||
|
||||
Body envelope fields:
|
||||
|
||||
| Field | When | Purpose |
|
||||
|---|---|---|
|
||||
| `result` | All | The actual response payload. Shape determined by the query's return type. |
|
||||
| `audit_id` | All | ULID for the audit log entry. Lets the caller cite exactly what ran. |
|
||||
| `snapshot_id` | All | Manifest snapshot the query observed. Reproducibility — replay with `?snapshot=<id>`. |
|
||||
| `commit_id` | Mutations | ULID of the new commit. Null for reads. Lets the caller cite what changed. |
|
||||
| `stats` | All | `{rows_scanned, ms_elapsed, bytes_read}`. Lets agents learn what's expensive. |
|
||||
| `warnings` | All | Non-fatal observations: deprecated property access, full-scan despite available index, scan exceeded soft row limit. Empty array when none. |
|
||||
|
||||
The envelope is the API's *memory of what happened*. Without `audit_id` + `commit_id` + `snapshot_id`, agent reports are hearsay and reads are not reproducible. With them, provenance is a first-class property of every response.
|
||||
|
||||
### MCP integration with multi-graph
|
||||
|
||||
MCP routes are per-graph, matching the rest of MR-668's hierarchy:
|
||||
|
||||
```
|
||||
GET /graphs/{id}/mcp/tools # tool list for this graph, this token
|
||||
POST /graphs/{id}/mcp/invoke # invoke a tool on this graph
|
||||
```
|
||||
|
||||
Single-mode collapses to `/mcp/tools` and `/mcp/invoke` at the root (same shape, no `/graphs/{id}` prefix). Both modes route through identical handler code.
|
||||
|
||||
Tool list response:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "find_user",
|
||||
"description": "Look up a user by ID.",
|
||||
"inputSchema": { "id": { "type": "string", "required": true } },
|
||||
"outputSchema": { "name": "string", "email": "string", "last_login": "datetime?" },
|
||||
"read_only": true
|
||||
}
|
||||
],
|
||||
"graph_id": "prod",
|
||||
"snapshot_id": "01HJKL..."
|
||||
}
|
||||
```
|
||||
|
||||
The tool list is the subset of registered queries where (a) `@mcp(expose=true)` in source and (b) Cedar permits `invoke_query` for this token on this name on this graph. Computed per request — cheap because it's just iterating the registry + one Cedar evaluation per name.
|
||||
|
||||
**Token scoping.** Most tokens carry one graph claim. Cross-graph access requires multiple Cedar rules (one per graph) and is uncommon. Agents that genuinely operate across graphs loop over `/graphs/{id}/mcp/tools` themselves. The contract stays clean; graph renames don't break tool names.
|
||||
|
||||
**Discovery.** Agents are told their MCP URL at provisioning: `https://omnigraph.example.com/graphs/prod/mcp`. Token authorizes; URL identifies. Same model as every OAuth-style API.
|
||||
|
||||
**`/mcp/invoke` is a protocol adapter.** Unwrap MCP protocol envelope, call the same code path as `/queries/{name}`, wrap the response in MCP shape. No new execution semantics.
|
||||
|
||||
### CLI surface
|
||||
|
||||
The CLI mirrors the HTTP routes. Post-MR-656 and post-MR-969:
|
||||
|
||||
```bash
|
||||
# Inline (MR-656)
|
||||
omnigraph query -e 'query test() { ... }' # /query
|
||||
omnigraph mutate -e 'query bump() { update ... }' # /mutate
|
||||
|
||||
# Stored (MR-969)
|
||||
omnigraph queries list # GET /queries (future)
|
||||
omnigraph queries explain find_user # show params + return shape + source
|
||||
omnigraph queries invoke find_user --param id=u-42 # POST /queries/find_user
|
||||
|
||||
# Pragma + registry validation
|
||||
omnigraph lint queries/find_user.gq # parses + verifies pragmas
|
||||
omnigraph queries lint # validates the whole registry
|
||||
```
|
||||
|
||||
`omnigraph queries invoke` reads bearer + URL from `omnigraph.yaml` like the other remote commands. Local invocations work the same way the existing `omnigraph query`/`mutate` do.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
The promotion path from inline to stored is the load-bearing DX story:
|
||||
|
||||
```
|
||||
1. EXPLORE omnigraph query -e 'query find_user($id: String) { ... }' --params '{"id": "u-42"}'
|
||||
└─ POST /query, iterate freely
|
||||
|
||||
2. STABILIZE write queries/find_user.gq with @description, @returns, @mcp pragmas
|
||||
└─ git diff shows the full agent contract in one file
|
||||
|
||||
3. AUTHORIZE add Cedar rule allowing invoke_query for the appropriate actor group
|
||||
└─ scope_names: [find_user]
|
||||
|
||||
4. DEPLOY restart server
|
||||
└─ /queries/find_user goes live
|
||||
└─ /mcp/tools auto-lists it for any token with invoke_query[find_user]
|
||||
|
||||
5. RETIRE deny: read change for the agent group
|
||||
└─ inline access closed; stored remains
|
||||
└─ MR-969's "LLM-safe API surface" reached
|
||||
```
|
||||
|
||||
Same `.gq` source through all five steps. No rewrite. No language shift. The pragmas are the only added syntax between exploration and production.
|
||||
|
||||
## Migration
|
||||
|
||||
Existing callers see no breakage:
|
||||
|
||||
- `POST /read` and `POST /change` keep working, now with `Deprecation: true` headers (MR-656).
|
||||
- `ChangeRequest` field names `query_source` / `query_name` accepted as serde aliases (MR-656).
|
||||
- `aliases:` block in `omnigraph.yaml` unchanged; both `read`/`change` and `query`/`mutate` accepted as `command:` values (MR-656).
|
||||
- New envelope fields are additive; old clients ignoring them keep working.
|
||||
- `Idempotency-Key`, `If-Match`, `X-Deadline` are opt-in headers; absence is the current behavior.
|
||||
|
||||
Callers move at their own pace. The envelope upgrades + URL rename ship in v0.6.x (small PRs). Stored queries + MCP ship in v0.7.0.
|
||||
|
||||
## Sequencing
|
||||
|
||||
**Phase 1: envelope (v0.6.x, before MR-969).** Four small PRs, ~100-200 LOC each.
|
||||
|
||||
1. Wrap responses in the structured envelope. Add `audit_id`, `snapshot_id`, `commit_id`, `stats`, `warnings`. Backward-compatible if we keep today's top-level fields and add new ones alongside; cleaner break if we move to nested `result.*`. Pick one and live with it.
|
||||
2. Honor `Idempotency-Key` on `/mutate` (and the deprecated `/change`). Server-side cache keyed by `(token, key)`.
|
||||
3. Honor `If-Match` on `/mutate`. Wire through to the publisher CAS layer.
|
||||
4. Honor `X-Deadline` / `X-Timeout-Ms` on every endpoint. Return 504-typed error past deadline.
|
||||
|
||||
**Phase 2: MR-969 PR 1 (registry).** The stored-query registry, `/queries/{name}` route, `InvokeQuery` Cedar action with per-name scope, `.gq` pragma parsing (`@description`, `@returns`, `@mcp`), read-vs-mutate classification at registry load. Inline keeps working unchanged.
|
||||
|
||||
**Phase 3: MR-969 PR 2 (MCP).** `/graphs/{id}/mcp/tools` and `/graphs/{id}/mcp/invoke`. Tool schemas projected from declared return types and parameter declarations. Single-graph-scoped tokens.
|
||||
|
||||
**Phase 4: MR-969 PR 3 (Cedar deny-on-ad-hoc sugar).** Small Cedar-language addition so operators can lock down `/read` / `/query` while keeping `/queries/*` open. Independent of PRs 1-2.
|
||||
|
||||
**Phase 5: deferred.**
|
||||
- Cross-graph MCP namespacing (wait for usage signal).
|
||||
- Per-query rate limits (extend `WorkloadController`).
|
||||
- Schema introspection as a separate Cedar action (3-line PR).
|
||||
- CLI verb consolidation (`omnigraph call <name>`).
|
||||
- Cache warming (HelixDB-style; not load-bearing).
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
**Per-environment override files (`_overrides.yaml`).** Initial design had a sparse YAML file for per-env tweaks: MCP exposure, row caps, kill-switch, param locks. Rejected because every override candidate either belongs in source (`@mcp` flag), Cedar policy (per-actor visibility, per-env), or `omnigraph.yaml` (operator config). Splitting query metadata across files makes it harder to review what an agent can see. Keep source authoritative; let Cedar express the per-env differences.
|
||||
|
||||
**Compiled query bundle (HelixDB's `queries.json`).** HelixDB compiles their Rust-DSL queries to JSON. Rejected because `.gq` files are already declarative. The file is the artifact. Reviewers diff source, not bytecode.
|
||||
|
||||
**Stored-queries-only (HelixDB's posture).** Rejected because the personal-graph / dev-iteration use case dies without inline. Inline `-e` is the REPL for human exploration; stored is the contract for production agents. Both first-class.
|
||||
|
||||
**Cross-graph tool-name prefixing (`prod.find_user`).** Rejected because graph renames would break agent contracts. Per-graph URLs let graph identity live in the URL, not in tool names.
|
||||
|
||||
**Body-field graph dispatch (`{tool, graph, params}`).** Rejected because it doubles the contract surface (every tool is identified by two fields). Per-graph URLs are simpler.
|
||||
|
||||
**Pragmas in YAML instead of source.** Rejected because two-file definitions (source + metadata YAML) make diffs harder to review and create drift opportunities. Source is the source of truth.
|
||||
|
||||
**Pragmas as in-source comments (`#[mcp]` HelixDB-style).** Considered; chose `@mcp(...)` because comment-flavored pragmas conflate documentation and machine-readable metadata. The `@` prefix makes the pragma's role explicit.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Envelope breakage vs additive.** Phase 1.1 wraps responses in a structured envelope. Do we keep today's top-level fields *and* add new ones (additive, ugly), or move result to `result.*` (clean break, requires SDK updates)? Lean toward additive — let the new envelope coexist with the old shape until v0.7.0, then collapse.
|
||||
|
||||
2. **`@returns` strictness.** Should mismatched declared-vs-inferred return type be a boot-time error or a warning? Lean toward error — silent drift defeats the assertion's purpose. Operators who want flexibility omit `@returns`.
|
||||
|
||||
3. **MCP protocol transport.** Streamable HTTP (the new MCP standard) vs stdio (Anthropic's original). Both have Rust crates. Lean toward streamable HTTP since we're already an HTTP server.
|
||||
|
||||
4. **Stored mutation routing.** A `.gq` file that contains both reads and writes — does the registry reject it at load (parse-time D2 rule from MR-656), or accept and classify as "mixed"? Lean toward reject. Mixed queries are a footgun; force operators to split.
|
||||
|
||||
5. **`expect` field strictness.** `expect: "read_only"` against a parsed mutating query is an obvious 400. But `expect: {max_rows_scanned: 10000}` requires planner estimates that don't exist today. Either ship `expect` with only the "read_only" assertion in v1 and grow it, or wait for the planner. Lean toward shipping the partial form.
|
||||
|
||||
6. **CLI `queries invoke` shape.** Today's `omnigraph query` takes a file or alias. `omnigraph queries invoke find_user` takes a stored query name. Should `omnigraph query --name find_user` also work (auto-detect)? Cleaner to keep them separate verbs — the stored vs inline distinction is part of the contract.
|
||||
|
||||
## References
|
||||
|
||||
- MR-656: [Support inline query strings in CLI and HTTP server](https://linear.app/modernrelay/issue/MR-656)
|
||||
- MR-668: [Multi-graph server mode](https://linear.app/modernrelay/issue/MR-668) (shipped, PR #119)
|
||||
- MR-969: [Stored queries with MCP exposure and per-query Cedar authorization](https://linear.app/modernrelay/issue/MR-969)
|
||||
- PR #110: [feat: inline query strings in CLI and HTTP server](https://github.com/ModernRelay/omnigraph/pull/110)
|
||||
- HelixDB docs: [docs.helix-db.com/llms-full.txt](https://docs.helix-db.com/llms-full.txt) — `#[mcp]` macro, scoped API keys, stored query model
|
||||
- RFC 9745 (`Deprecation` header)
|
||||
- RFC 8288 (`Link` relations, `successor-version`)
|
||||
- MCP spec: [modelcontextprotocol.io](https://modelcontextprotocol.io)
|
||||
- [invariants.md](./invariants.md) — substrate boundaries this work respects
|
||||
- [../user/server.md](../user/server.md) — current HTTP surface (post-MR-656 picks up the `/query`+`/mutate` rename and deprecation)
|
||||
Loading…
Add table
Add a link
Reference in a new issue