Skip to content

feat(mcp): expose every non-streaming graph operation as a tool - #1139

Open
ragnorc wants to merge 3 commits into
ModernRelay:mainfrom
ragnorc:feat/mcp-every-graph-operation
Open

ragnorc wants to merge 3 commits into
ModernRelay:mainfrom
ragnorc:feat/mcp-every-graph-operation

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

What and why

The accepted graph access RFC (#1017, section "One set of operations") requires every graph operation to have one name and one JSON contract on the CLI, HTTP and MCP, with byte streams as the declared exception. On main, MCP lacked the graph diff, branch diff, merge base, Blob stat and clear, and commit publish and drop tools, refused stored mutations, filtered stored mutations out of queries, and listed only branch history in commits_list. This PR closes those MCP rows.

Every new tool follows the existing pattern: it resolves its graph through the registry and calls the handler (or the handler's body function) its HTTP route calls, under the same actor and Cedar action. Policy, admission, write ownership, the Omnigraph-If-Actor check, error bodies with disposition, the 1 MiB result bound and the 30 s waits are the routes' own. No HTTP route, body or status changes, and openapi.json does not drift.

Tools

Tool Route Annotation Gate
diff GET /graphs/{id}/diff readOnlyHint always
branches_diff GET /graphs/{id}/branches/diff readOnlyHint always
branches_merge_base GET /graphs/{id}/branches/merge-base readOnlyHint always
blobs_stat HEAD /graphs/{id}/blob, its headers as blob stat JSON readOnlyHint always
commits_list (adds listing) GET /graphs/{id}/commits readOnlyHint always
queries (whole catalog) GET /graphs/{id}/queries readOnlyHint always
blobs_clear DELETE /graphs/{id}/blob destructive, idempotent --mcp-allow-writes
commits_publish POST /graphs/{id}/commits/{commit_id}/publish destructive, idempotent --mcp-allow-writes
commits_drop DELETE /graphs/{id}/commits/{commit_id} destructive, idempotent --mcp-allow-writes
mutate with name and no query POST /graphs/{id}/queries/{name}, or /queries/{name}/if-graph-commit with if_graph_commit destructive (unchanged) --mcp-allow-writes

Arguments are the route's request fields plus graph. The paging tools (diff, branches_diff) take the route's page_token and limit; blobs_clear takes the route's If-Match value as if_match. Two tools answer for a route without a JSON body: blobs_stat returns the HEAD /blob headers as the BlobStatOutput that omnigraph blob stat --json prints, and commits_drop returns {} for the route's 204. Both are recorded as restricted bindings in the operation inventory.

Stored mutations

Admitted. mutate given a stored mutation's name and no query runs it, as omnigraph mutate NAME does, through server_invoke_query (with expect_mutation: true) or, when if_graph_commit is present, through server_invoke_query_if_graph_commit. A value for if_graph_commit always selects the conditional route that requires it, so a stale value is the route's 412, and an empty or entity-tag value is the route's 400. None of them falls through to an unconditional write. The plain route never receives the header. query stays read-only and still refuses stored mutations.

This does not contradict RFC 0003's deferral. That deferral was about dynamic per-query tools, which would list every stored query to every actor before per-query invoke_query scope exists, and it is unchanged. Admitting stored mutations adds no capability: a caller allowed change can already run any mutation through mutate, and the stored route is gated more narrowly (invoke_query, then change). RFC 0003 is amended in place with a decision-log entry, as phase 3.

Declared, not added

The change-feed baseline (POST /changes/baseline) stays off MCP. The RFC's gap table listed it as a tool, but the route answers an NDJSON entity snapshot, which is an export stream, followed by a terminal cursor that is valid only once that snapshot is installed. A bounded tool result can carry neither the stream nor a cursor without its snapshot, so the baseline falls under the RFC's own byte-stream exception. The inventory, the RFC body and its decision log record this; reviewers may want to confirm that reading. Blob payloads (GET/PUT /blob) and export remain byte-stream exceptions as before.

Tests

  • cargo test --locked -p omnigraph-mcp -p omnigraph-server -p omnigraph-api-types --lib: 16 + 7 + 218 passed (2 ignored, as on main). New: every tool parses its schema-required arguments and refuses an undeclared one, and write tools are annotated as writes and absent without allow_writes (MCP crate); the HEAD-to-blob stat mapping for managed, external and malformed answers (server).
  • cargo test --locked -p omnigraph-server --test integration mcp::: 11 passed (10 on the main baseline). Extended owners: the tool list per boot gate (compared against the operation inventory), read parity of diff, branches_diff, branches_merge_base and commits_list with listing (including continuation pages and the routes' refusal bodies), write tools absent without --mcp-allow-writes and their annotations, and queries equal to its route. New mcp_blob_commit_control_and_stored_mutation_tools_equal_their_routes: blobs_stat equals the HEAD headers, refusals equal GET's; blobs_clear 412, receipt and no-op equal DELETE /blob; commits_publish and commits_drop equal their routes, including republish, the dropped 404 and the published 409; a stored mutation through mutate matches the stored route, and stale, empty and entity-tag preconditions refuse as the conditional route does with the manifest unchanged; policy denials of every new tool equal the route's status and body.
  • cargo test --locked -p omnigraph-server --test integration -- openapi:: data_routes::blob: 112 passed, including openapi::openapi_spec_is_up_to_date (no drift) and the Blob routes after the DELETE /blob body extraction.
  • cargo test --locked -p omnigraph-skill: 1 passed. cargo test --locked -p omnigraph-cli --lib skill::: 7 passed. cargo test --locked -p omnigraph-cli --test integration cli_mcp::: 7 passed; its write-tool list now names the three new write tools, which the bridge lists only with --write against a server with --mcp-allow-writes.
  • cargo clippy --locked -p omnigraph-mcp -p omnigraph-server --all-targets -- -D warnings -W clippy::dbg_macro: clean.
  • cargo fmt --all --check, python3 scripts/check-docs.py (391 files) and typos on the changed files: clean.

Docs: the MCP guide's tool tables and write contract, RFC 0003's catalog (amendment and decision log), the graph access RFC's operation tables (decision log), the stored-queries skill reference, the server's --mcp-allow-writes help, and changelog.d/mcp-every-graph-operation.added.md.

Note for the operation inventory

The inventory from #1132 records discover graphs (GET /graphs/discovery) with no MCP tool. In fact the MCP graphs tool calls the discovery handler for an identity credential and answers list graphs in the discovery shape. This PR leaves those two rows as they are, so the existing inventory tests keep their meaning. A follow-up can bind graphs to both rows, each with a note on which shape it answers.

Close the MCP rows of the graph access RFC's one set of operations.

New read tools diff, branches_diff, branches_merge_base and blobs_stat,
and listing on commits_list. New write tools blobs_clear,
commits_publish and commits_drop, listed only with --mcp-allow-writes.
mutate with a stored mutation's name and no query runs the stored-query
route, or its conditional route when if_graph_commit is given; queries
returns the route's whole catalog. Each tool calls its route's handler
under the same actor, Cedar action, admission and error contract.

The change-feed baseline stays a declared byte-stream exception: its
route answers an NDJSON snapshot whose cursor is valid only once the
snapshot is installed.

ragnorc commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: merge. There are no blocking findings. Two optional findings are below. Correct the discover-graphs inventory sentence in this pull request if you can. Runtime behavior already matches the user guide.

Why

An MCP client must use the same graph operations that HTTP already serves. One handler per operation keeps one authority for the actor, the Cedar action, admission, and the error body.

What

This change exposes every non-streaming graph operation as an MCP tool. The tool calls that operation's HTTP handler.

New read tools are diff, branches_diff, branches_merge_base, and blobs_stat. commits_list accepts listing.

New write tools are blobs_clear, commits_publish, and commits_drop. They run only when --mcp-allow-writes is set.

mutate with name and no query runs a stored mutation. queries returns the route catalog, and that catalog includes mutations.

The change-feed baseline stays off MCP. The route returns an NDJSON entity snapshot, and the cursor is valid only after that snapshot is installed. Blob GET, blob PUT, and export stay byte-stream exceptions.

How

Each tool resolves the graph and calls the route handler under the same actor.

blobs_stat maps the HEAD headers into BlobStatOutput, the constructor the CLI uses. The engine etag is a strong quoted SHA-256 tag from blob_etag. The tool sends an empty header map. Conditional 304 and 412 answers stay on HTTP, where the client sends those headers.

commits_drop returns {} for the route's 204.

A stored if_graph_commit selects the conditional invoke route. An empty value and an entity tag stay on that route. The unconditional route receives no precondition header.

queries returns the handler body with mutations included. A stored mutation still requires invoke_query, then change. The query tool still sends expect_mutation: false.

Impact

An agent can diff, stat a blob, clear a blob, publish a commit, drop a commit, and run a stored mutation under the same policy as HTTP. Write tools stay closed until the operator sets --mcp-allow-writes.

The change removes the MCP-only filter that dropped mutations after the handler. The remaining cost is a hand-written tool schema beside each route.

Optional findings

  1. The "discover graphs" inventory reason is false. The note is on docs/user/operations/mcp.md.
  2. The mutate JSON schema requires graph alone. The note is on crates/omnigraph-mcp/src/catalog.rs.

Tradeoff and liability

One handler per operation is the source of truth. Correctness of that handler comes first. A generated schema would remove description drift, and it can wait. Five more tools stay cheap while each tool calls the handler and the inventory checks the name.

The part that rots is an absence reason. The inventory test accepts any non-empty reason. This pull request already carries one false sentence. Bind graphs on both rows. Later tools then follow a true pair of rows.

This change stays on serve_blob_head. A later Lance release changes the stat result only when that head response changes. There is no new storage-cost row. The user guide declares the two projections: header JSON for blobs_stat, and {} for commits_drop.

Other open pull requests

#1130 adds read-output fields in crates/omnigraph-api-types/src/lib.rs. MCP read and query return the route JSON, so those fields appear with no MCP edit. The file sets are disjoint.

#1137 adds a CLI-only studio row at the end of CLI_ONLY_OPERATIONS. The hunks are disjoint. Studio stays off the HTTP inventory.

#1012 edits mcp.rs and handlers.rs. Expect a textual conflict. Keep both sides. branches_list returns the route body, so the new branch fields flow through.

#892 edits skills/omnigraph/references/stored-queries.md. That overlap is documentation.

Validation

Reviewed head: bf31888ef9bdf2ff1ec58386186ab48a4ef202f5.

Local checkout /tmp/pr-1139, CARGO_TARGET_DIR=/workspace/target:

  • cargo test --locked -p omnigraph-mcp --lib — 7 passed.
  • cargo test --locked -p omnigraph-server --lib mcp:: — 5 passed, including blob_stat_reads_a_head_answer_as_the_cli_does.
  • cargo test --locked -p omnigraph-api-types --lib operation_inventory — operation_inventory_has_unique_names_routes_and_renderings passed.
  • RUST_MIN_STACK=16777216 cargo test --locked -p omnigraph-server --test integration mcp:: — 11 passed, including mcp_blob_commit_control_and_stored_mutation_tools_equal_their_routes and mcp_read_parity_tools_equal_their_routes.

The same integration command with the default thread stack aborted. mcp_read_parity_tools_equal_their_routes overflowed that stack. CI sets RUST_MIN_STACK=16777216. The abort is the local stack size.

CI on this commit:

  • Workflow run 39804993812: Test Workspace passed. Lint (clippy) passed. Format passed.
  • Workflow run 39804993681: GQT (ordinary) passed. GQT (served) passed. GQT (dst) job 119915786932 was cancelled during "Install system dependencies". The GQ Logic Tests check then failed in "Require all GQT qualification jobs". That log shows no assertion failure.
  • Workflow run 39804993461: DST pinned suite passed.

Clippy evidence is the CI Lint job. The GQT corpus result is the CI result above.

Regression proof

This change is a new MCP contract. It has no query-visible bug fix. A .gqt case cannot express a tool catalog, a header-to-JSON mapping, or equality with an HTTP route. The owner is crates/omnigraph-server/tests/integration/mcp.rs, together with the omnigraph-mcp unit tests and blob_stat_reads_a_head_answer_as_the_cli_does. No temporary .gqt case was added.

Limitations

The integration loop covers a managed blob stat. The external 302 path is covered by the unit test on a synthetic response. The loop covers history and listing=unpublished. listing=unattached is the CommitListing serde name and a schema enum, so an unknown string fails closed. Performance of the 1 MiB result bound is unverified here. The user guide already states that bound as an MCP limit.

Inline: docs/user/operations/mcp.md line 287 (RIGHT)

The sentence on this line matches the code. ServerHost::discover in crates/omnigraph-server/src/mcp.rs calls server_graphs_discovery when actor.is_identity(). Any other credential uses server_graphs_list, and the result keeps ids and names.

The inventory row "discover graphs" in crates/omnigraph-api-types/src/operations.rs is the opposite sentence:

Surface::absent(
    "MCP graphs uses the ordinary authorized listing, not identity-only discovery.",
),

That reason is false. operation_inventory_has_unique_names_routes_and_renderings accepts any non-empty absence reason. mcp_tool_list_follows_the_adhoc_setting_and_describes_every_tool compares tool names only.

Bind graphs on the list row and on the discovery row. State which credential each row serves. RFC 0003 in this change already describes the identity path.

Optional. The running tool already follows this guide.

Inline: crates/omnigraph-mcp/src/catalog.rs line 560 (RIGHT)

arguments inserts graph into required. This call passes an empty extra list, so the published schema requires graph alone.

The dispatch in crates/omnigraph-server/src/mcp.rs returns 400 when query and name are both absent: mutate needs query(GQ source) orname (a stored mutation). The property descriptions already state that rule.

Add a oneOf with two branches. One branch requires query. The other requires name. A schema-driven client can then refuse the empty call before it is sent. The server test already covers the 400.

Optional.

The MCP `graphs` tool answers an identity credential from the discovery
handler and every other credential from the ordinary listing, but the
inventory recorded the discovery row with no MCP tool and a reason that
said the opposite. Both rows now bind `graphs`, each naming the
credential it serves.
@ragnorc

ragnorc commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks. Answers to the two optional findings:

  1. Discover-graphs inventory reason. Fixed in 8897bd6. Both inventory rows, list graphs and discover graphs, now bind the graphs tool, and each row's reason names the credential that gets that shape. No absence reason is left to rot.
  2. mutate schema requires graph alone. Kept as is. A oneOf/anyOf that requires query or name has to sit at the top level of the input schema, and the Claude API refuses top-level combinators in a tool's input schema. Sending query and name together is also a valid call: name then selects which mutation inside query runs, as on the HTTP mutate route. A call with neither is refused as a bad request ("mutate needs query (GQ source) or name (a stored mutation)").

Main is merged in (6e527c1). Locally, clippy is clean on the four touched packages, and the omnigraph-mcp, omnigraph-api-types, server mcp:: and CLI suites pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant