Skip to content

feat(cli): bridge an agent's MCP over stdio to a target - #1084

Closed
ragnorc wants to merge 90 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-mcp
Closed

ragnorc wants to merge 90 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-mcp

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Disposition — 2026-10-20

Closed without merging. This branch includes the discontinued second serving
model (#1075, #1077 and #1083) and will not be reworked or merged as a stack.

Useful MCP adapter logic may be selectively cherry-picked later into a separate,
focused PR for an existing server. Candidates include JSON-RPC framing, graph
binding, tool filtering and transport handling. Any reuse needs fresh review and
tests against the current targeting, credential and server contracts; the old
branch's results do not qualify a future extraction. No local hosting, discovery,
election, reuse or server-record implementation should be carried over.

No cherry-pick is being performed as part of this closure. The branch is retained
for reference. Its final head is 24be23f4eba4950f1b05266a84ff13519ae8351a;
the original MCP feature commit is 97132dcc823bcb5a302f8c6585c50d0317b70164.
That commit also contains local-hosting logic, so it is a source for selective
extraction, not a ready-to-merge cherry-pick.

Historical proposal

Why

This is RFC #1017 phase 3, MCP for agents. An agent that starts its tools as child processes should reach a target's graph over MCP with a configuration that names only the target:

{"command": "omnigraph", "args": ["mcp", "--target", "local"]}

The agent never handles a URL or a token.

What

omnigraph mcp --target NAME [--graph ID] [--as ACTOR] relays MCP over stdio to the target's /mcp. Each stdin line is one JSON-RPC message. Each answer is one whole, flushed stdout line, and stdout carries nothing else.

  • Direct target: the bridge runs serve --target NAME --reuse --json. It reuses a live local server or hosts one, recorded like any other, and stops a server it hosts with SIGTERM when stdin closes.
  • Server target: the bridge relays with the credential owner's stored token. --as is sent as the omnigraph-expect-actor assertion.
  • Bound to one graph: a missing graph argument is filled in. A call or resource naming another graph is refused, and the graph listings show only the bound graph.
  • Read tools only: only tools the server annotates readOnlyHint: true are listed or admitted. mcp --write waits, as serve --write does, until a write whose outcome is unknown no longer stops the server (bug: one storage write with an unknown outcome stops the server until a manual unlock #1067).
  • Concurrency: requests are relayed concurrently, at most 32 at a time, and answered as they complete. The handshake and notifications stay ordered. notifications/cancelled drops the named request's HTTP exchange. On stdin EOF, requests in flight are answered before shutdown.
  • A lost target: a 401 or a refused connection re-resolves the target once, as a single flight for all pending requests. A second failure answers every pending request with -32000 and exits 1 with the new code mcp_target_lost.

How

The command is on the Session plane like serve. It lives in crates/omnigraph-cli/src/commands/mcp/: binding.rs resolves the target, upstream.rs relays, and wire.rs handles framing and filters. A single loop owns stdout and polls the relays, which are abortable futures sharing the bridge's state. The bridge adds no tool. Admitted calls keep the server's handlers and refusals.

rmcp's stateless Streamable HTTP answers every notification with 202 and ignores it. It cancels a stateless request only when its connection drops, so the bridge cancels by dropping the exchange and does not forward the notification.

Stack

This branch contains #1083, and through it the rest of the RFC #1017 stack. The delta reviewed here is commands/mcp/, the mcp wiring in cli.rs, planes.rs and app.rs, cli_mcp.rs, the "Agents over stdio" section of docs/user/operations/mcp.md, the skill rows and the changelog note. Merge after #1083.

Tests

  • cli_mcp process tests:
    • Hosting: the bridge hosts a local server, with graph binding and read-only consent, a record while it runs and none after.
    • Reuse: a live serve is reused. Four requests lost together on its restart resolve once.
    • Server target: a spawned server is reached with the owner's token, its write tools are filtered, and --as gets 412 actor_mismatch.
    • Lost credential: a wrong token is retried once, then the bridge exits with mcp_target_lost.
    • Refusals before relaying.
    • Stand-in endpoint: answers arrive in completion order; a cancelled request is dropped at the server and never answered; a request in flight at stdin EOF is still answered.
  • Unit tests in wire.rs and binding.rs, and the planes flag matrix.
  • cargo test -p omnigraph-cli -- --test-threads=6 passes: 262 lib and 351 integration tests. Clippy, fmt, check-docs, typos and the OpenAPI drift test also pass.

Backing issue / RFC

A request carrying omnigraph-expect-actor is refused with 412
actor_mismatch, after authentication and the contract check and before
graph lookup, body work or effects, when its credential resolves to
another actor. A match changes nothing, so the header only narrows a
request and never supplies an actor. Every protected router and /mcp
check it.

The CLI sends it for --as on served writes and cluster --server
commands, which no longer refuse --as. A mismatch is a not-started
refusal and exits 4, like a failed graph-commit precondition.
AppState::local opens a standalone graph read-only or read-write behind a
generated bearer token and a Cedar policy generated for one actor, which
it installs on the engine as well as at the HTTP layer. Every policy
action has a local-server class, so an action added later is never
granted until it is given one. A read-only local server refuses every
write-class route in ingress before reading its body. No local server
mounts the cluster deployment routes; each enables MCP ad-hoc reads and
lists write tools only when it writes. A root inside a cluster is
refused.

serve_local runs the deployment's serving lifecycle, now one private
type both entry points share, on a loopback listener the caller bound.
It writes nothing to stdout, disarms the shutdown watchdog when it
returns, and reports an uncertain outcome instead of exiting.
install_log_subscriber puts the server's restricted log filter on
another writer.
LocalScope::Cluster serves a cluster's storage root from its applied
state for one actor, with the cluster's own policies, stored queries,
embedding-provider bindings and external-Blob rules. A read-only one
captures one ledger revision without admission, opens every graph
read-only and validates it against that revision, and forbids every
write action in each policy it loads, so policy answers report the
refusal. A read-write one takes the cluster's serving admission and
refuses while another owner holds it; serve_local releases it after a
clean drain through the release the deployment runs, and keeps it when
a completion is uncertain.

PolicyEngine::forbidding adds a Cedar forbid for a set of actions, and a
prepared graph open now names its access: writable within a storage
scope, or read-only against a captured ledger revision.
Every error body carries an outcome object (execution, effects, action)
that the server computes for the request it answered: not_started with
no effects when it refused before submitting a write, or refused the
write's one publication with a typed reason, which an engine gives only
before it publishes; unknown otherwise, including a typed refusal after
a load forked its branch. The next step a caller takes is retry,
refresh, recover or reconcile. A write whose completion is uncertain
answers outcome_unknown instead of internal. Errors that carried a
reason only in their details gain registry codes: precondition_failed,
recovery_required, external_blob_source and change_feed_gap.

CLI data-write failures print the same object as outcome, in place of
command_outcome, and take the server's when its request was the
command's only effectful one.
The exit follows from the failure's outcome: a command that did not
start exits 1, one whose effects are unknown 2, one refused for a
condition the caller set 4, and one refused before starting with retry
75. A refused command line exits 64 (EX_USAGE) instead of Clap's 2.

Read commands report the same failure envelope as data writes, always
not_started with no effects, so a failed read prints its outcome too.

An embedded engine write's own error now carries the engine's
completion evidence, as a server's does: a typed refusal before any
request, outside a load that forks its branch, did not start, so
embedded load conflicts and failed conditions exit as served ones
instead of claiming unknown effects. Any later error, such as one
rendering a committed receipt, stays unknown. Embedded writes refuse a
noncanonical branch name and read their data file before opening
storage.

Managed commands keep their own exits until the managed relay lands.
GET /graphs/{graph_id}/access and the MCP access tool return the
caller's resolved actor, the actions its credential allows on the whole
graph, and, for a named branch and target branch, the actions allowed
there. Each action is evaluated through authorize with the branch and
target branch shaped as the route that performs it shapes them, so the
answer matches those routes' decisions. The route needs no action of
its own, reads no graph data and judges branches by name.
A target is one complete binding in ~/.omnigraph/config.yaml: a direct
graph or cluster root, or a server with its credential owner or
anonymous access. --target NAME, else defaults.target, addresses a
command; it refuses every other address and --profile beside it, and
resolves through the scope resolver profiles use. A server target's
owner keys the existing token chain and is bound to the target's
endpoint, so its token goes only to URLs under it; the file refuses an
owner at two endpoints, two owners for one endpoint and an anonymous
target beside a keyed one.

target list|show|add|remove lists the targets without secrets or
network, with a binding digest, and edits one entry at a time: an edit
holds config.yaml.lock, changes only that entry's lines, proves by
parsing that every other key is unchanged, and replaces the file by
rename. login/logout --target store under the target's owner, and
credential get --target hands a server target's token to a pipe, never
a terminal. Refusals carry registry codes (target_unknown,
target_invalid, target_exists, target_is_default, config_not_editable,
target_kind_unsupported, credential_terminal, credential_anonymous,
credential_missing). version --json reports protocols.target and
protocols.credential. Existing servers, profiles and their flags keep
working.
# Conflicts:
#	crates/omnigraph-server/tests/integration/auth_policy.rs
omnigraph access names the caller's actor and the actions the graph's
policy grants it: graph-wide, on --branch, and from it to
--target-branch for branch_create and branch_merge. A server target
asks the server's access route with the target's token. For direct
storage the CLI decides for the actor --as or operator.actor names: a
cluster graph by its applied policy, which allows nothing without an
actor, and a standalone graph, which has no policy, allows every
action, as direct writes do.

The decision shape moves into api-types (AccessOutput::evaluate and the
AccessAction to PolicyAction mapping), so the route and the CLI decide
alike. version --json reports protocols.access.
# Conflicts:
#	crates/omnigraph-server/tests/integration/auth_policy.rs
The shutdown listener registered its SIGTERM and SIGINT handlers only
when its spawned task first ran, and `serve_local` installed the
lifecycle only after the caller had announced readiness. A signal in
either window met the default disposition and ended the process without
the drain.

The lifecycle now registers both handlers before it returns, and
`start_local` installs it so a caller announces between `start_local`
and `LocalServing::serve`. `serve_local` remains the two calls in one.
…server

# Conflicts:
#	crates/omnigraph-server/src/lib.rs
#	crates/omnigraph-server/tests/boot_settings.rs
#	docs/dev/control-plane.md
`omnigraph serve` serves one direct target, a standalone graph or a
cluster root, on 127.0.0.1 to clients of the same account. It is
read-only unless `--write`, acts as the `--as` or `operator.actor`
actor, and answers behind a token generated for the run.

It keeps an owner-only record a client attaches by reading, held by a
lock its process owns, so a killed server's record is ignored and
removed by the next reader. Starts of one destination are elected: one
serves and the others refuse with `local_server_exists`, or print the
live server's ready line with `--reuse` when its access, actor and HTTP
contract fit.

A guard outside every server layer admits only requests whose Host
names this server's loopback address, refuses any Origin, strips CORS
headers and marks every response same-origin. The ready line follows
`start_local`, so a signal after it drains the server.

`target list` and `target show` name each target's live local servers,
and `version --json` reports `protocols.serve` and
`protocols.local_server_record`.
…e records

A reuse from another target of the same root now reports that target's
graph. A platform without Unix permissions refuses to keep a record,
since it holds a bearer token, and a target listing reads records
without creating their directory or failing on it. The listener binds
before a read-write cluster open takes the cluster's admission.
…es it

A server built before `omnigraph-expect-actor` speaks the same HTTP
contract and ignores the header, so `--as` on a served write would run
as the credential's actor. Every response now lists the assertions its
server enforces in `omnigraph-http-api-assertions`, and the CLI refuses
before sending when discovery does not list `expect-actor`.

A served `cluster plan` or `cluster apply` refused for another actor
now exits 4 as a data write does: its requests are observed, and a
refused assertion with nothing effectful sent before it is a condition,
with or without `--json`.
`main` returned an undelivered failure as an error, so the process
exited 1, the status that says nothing changed, after a write whose
receipt met a closed stdout. It now reports the error as before and
exits with the code the outcome chose.

`alias` is a read, as `query` is, so its failures report an outcome
and exit by it. Command classification is now an exhaustive match, so
a new command declares how its failure reads.
…s everywhere

Credential lookup now reads the same validated bindings as the
ownership checks, so a server target that fails validation can no
longer win a request's credential and send another owner's token to
its endpoint.

`queries` and `policy` resolve `--target` and `defaults.target` like
every other command: a target is a complete address that excludes
`--cluster` and `--profile`, only a cluster-root target names a
cluster, and its default graph applies.

`login` and `logout` check after parsing that exactly one of a server
name, `--api` and `--target` names the credential, so a global
`--target` before the subcommand works and a conflict refuses.

A configuration refusal keeps its code through the invocation's
captured configuration, and a target edit is a local write: a failure
after its rename no longer reports that nothing changed.
# Conflicts:
#	crates/omnigraph-cli/src/command_outcome.rs
# Conflicts:
#	crates/omnigraph-cli/src/command_outcome.rs
A quarantined graph's policy bundle is withheld from the serving
snapshot and an unknown graph has none, so `access` read either as a
graph without a policy and reported every action allowed. It now
checks the snapshot first: a graph that is not applied refuses with
`not_found`, naming the applied graphs, and a quarantined one with
`graph_unavailable` and the cluster's reason, as a server refuses them.
…tests off the account's records

A records directory that does not exist means no local server has run,
so a direct target lists none; one that cannot be read safely leaves
`local_servers` absent. CLI tests point the records directory at a path
that does not exist, so no test reads the account's own records. `serve`
reports its own failures and takes no outcome kind.
The cluster skill page still said `cluster apply --server` refuses
`--as`; it now names the assertion `cluster plan` and `cluster apply`
send and its exit 4. The release note moves to `breaking`, since a
matching `--as` now runs a write the CLI used to refuse. The server
guide says `/mcp` has no contract check, an empty assertion joins the
malformed-header test, and stale comments name the current behaviour.
…ne's policy

- A relative or URL-special local root now names its cluster.
  `LocalBinding::from_root` makes a local root absolute, and the cluster
  storage URI is built with `Url::from_file_path`, so `./cluster` no longer
  becomes `file://./cluster`, which the cluster store refused in both modes.
- A ledger revision that moves between a read-only open's capture and its
  validation refuses the whole server. `GraphOpenFailure` carries the
  cluster's refusal from `validate_opened`, and the read-only open turns it
  into `LocalOpenError::Cluster`. A graph whose schema drifted from its
  achieved contract is still blocked alone, as normal serving blocks it.
- A user rule can share an id with the generated forbid. Cedar policy ids
  are namespaced `permit:` and `forbid:`, so the two can no longer collide
  and fail `PolicySet::add`. A matched rule still reports the bare rule id.
- `LocalServing::release` stops a local server that never served. It closes
  request admission, drains the logical owners, releases a read-write
  cluster's serving admission as `serve` does after a clean drain, and
  disarms the watchdog. Before this, a caller that failed after
  `start_local` could only drop it, which keeps the admission lock.
- `/readyz` and `/graphs` report the ledger revision served. Both access
  modes install the boot witness from the applied snapshot instead of the
  default revision 0.
- A local cluster server refuses when no graph opens, as a deployment's
  startup does. Read-only now applies the same check as `finish_graph_startup`,
  and both modes honour `OMNIGRAPH_REQUIRE_ALL_GRAPHS` through the config's
  `require_all_graphs`, not a hard-coded `false`.
- A graph the cluster binds no policy to gets `read` alone for the actor,
  which is what a deployment's routes grant without a bundle. The grant is
  installed on the engine too, so an embedder holding the handle can no
  longer write through an engine with no policy.
- Read-write refusals before any graph opens release the admission, as a
  refused settings preflight does. The catalogue policy now loads before
  graph preparation. A failed startup after a graph open keeps the admission
  and names its lock in `LocalOpenError::AdmissionRetained`.
- The read-only catalogue policy passes through the same write forbid as
  every other policy it loads.
The access report's branch slots were lists in the evaluator, so a new
policy action compiled and stayed out of the report. One exhaustive
match on `PolicyAction` now gives each action its slot (source, target,
transition, graph-wide or unreported), shared by the access route and
`omnigraph access`.
A write whose outcome is unknown still stops the server that ran it, so
`AppState::local` refuses `LocalAccess::ReadWrite` with
`LocalOpenError::ReadWriteUnavailable` until it no longer does. The
read-write path stays and stays tested: the `test-util` feature's
`with_local_read_write` scope opens it for tests alone, enabled from
dev-dependencies only (this crate's own included) and checked by the
test-util guard, and a build with the feature still refuses outside the
scope.

`LocalBinding::local(path)` replaces the string constructor. It takes a
root on this machine's file system, made absolute, and refuses a URI: a
remote root's endpoint and credentials would come from the process
environment when it opens, which a captured binding must not depend on.

A read-only local server's engine refuses a direct write, now tested on
the handle. The developer guide says what the read-write gate is, that
an embedder a browser can reach adds the loopback guard in `extend`, and
that Tokio keeps the signal handlers for the rest of the process. The
invariants say the library serves one standalone graph on loopback
while the binary stays cluster-only, and the release notes no longer
claim read-write or a wider signal window than the code covers.
Brings in main through ModernRelay#1081: `access` answering a store from the policy
it opens, the access protocol constant, and `schema plan` refusing a
root target. `version --json` lists `protocols.access` from its own
constant beside `serve` and `local_server_record`, and the skill's
`--as` line names both `serve` and `access`.
Brings in ModernRelay#1075's fix through ModernRelay#1077: the read-only refusal names no
--write, and a local binding folds `..` before it is stored.
Brings in the review fixes below the bridge: the serve and access
findings, `schema plan` refusing a root, and the local server's refusal
text. The command index lists `mcp` after `serve`'s new wording.
The engine re-exports the storage crate's lexical absolutizer for the
local server's binding; the split-crate re-export guard lists every such
item by name, so it now names this one.
The key-conflict description said the refused rows wrote nothing; the
OpenAPI vocabulary guard keeps graph vocabulary in the published
description, so it now says the refused write changed nothing.
Brings in the engine's pinned re-export of absolutize_lexically.
Brings in the key-conflict description in graph vocabulary.
Brings in the key-conflict description in graph vocabulary.
Brings in the key-conflict description in graph vocabulary.
Brings in the key-conflict description in graph vocabulary.
Brings in the engine's pinned re-export of absolutize_lexically.
Brings in the key-conflict wording and the pinned absolutize_lexically re-export.
@cursor

cursor Bot commented Oct 19, 2026

Copy link
Copy Markdown

Recommendation: change the cancellation behavior before merge. I reviewed only the commits after #1083 (6ee0c3d1), at head 24be23f4eba4950f1b05266a84ff13519ae8351a. Merge this after #1083, and after draft RFC #1017 (e15d2f5b) is accepted, as the pull request says.

Why

An agent that starts its tools as a child process speaks MCP on stdin and stdout. That agent must not hold the server token. The connection must stay on one graph, and it must stay on read-only tools until mcp --write exists.

What

omnigraph mcp reads one JSON-RPC message per line and sends it as one POST to the target's /mcp. A direct target runs serve --target NAME --reuse --json and keeps that server's token inside the bridge. A server target uses the credential owner's stored token. The bridge fills a missing graph argument, refuses another graph, and lists and admits only tools with readOnlyHint: true.

How

One task writes stdout. Up to 32 requests run at the same time, and each answer is written when that request finishes. initialize, notifications, and client responses stay in order: the bridge does not read the next line until that message is delivered. notifications/cancelled is not forwarded. The bridge drops the matching exchange instead. Logs and the child server's stderr stay on stderr. A refusal before relay is error[<code>] on stderr, and the process exits 1. finish writes no ErrorOutput for mcp, because command_outcome returns no machine body for this command.

Impact

This is the right layer. The server remains the only MCP implementation and the only publication door. The bridge is a filter and a relay. The new long-term cost is the stdio state machine: framing, the hosted child, the tool cache, and cancellation. The next similar change should extend this filter. It should not add a second protocol handler.

Blocking

  1. A cancellation that is already buffered can still get an answer.

crates/omnigraph-cli/src/commands/mcp/mod.rs selects with biased. A finished in-flight request is written before the next stdin line is read (lines 167-187). The read is also disabled while 32 requests are in flight (line 204). The stdin channel holds one line. At the cap, notifications/cancelled sits in that channel until one request finishes. The loop then writes that request's answer, removes it from pending, and only then reads the cancellation. The comment on line 171 ("gets no answer") holds only when the cancel is applied before the answer is selected.

docs/user/operations/mcp.md lines 130-132 state the contract: the named request gets no answer. The module docs at the top of mod.rs say the same. changelog.d/cli-mcp-bridge.added.md says the notification drops the named request.

The test a_bridge_answers_requests_as_they_complete_and_drops_cancelled_ones holds the call for about 3 seconds. The cancel is read while the call is still running, so the abort wins. It does not cover a cancel that is already in the channel when the answer is ready.

I reproduced the cap case at this head with target/debug/omnigraph and a local stand-in. The stand-in answered tools/call id 1 only after it had received all 32 calls. Stdin already contained notifications/cancelled for requestId 1. Stdout then contained:

{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "ANSWERED"}]}}

The MCP spec (2025-11-25) says a receiver should not send a response, and it may ignore a cancel that arrives after the request has already completed. This case is different. The cancel line was already in the bridge. The loop looked at the finished request first because the cap turns the read off.

Before write_lines of an in-flight answer, take a notifications/cancelled that is already buffered, including at the cap. If it names that id, drop the answer. Keep the rule that a response which completed before the cancel reached this process may still be sent, and say that in the guide. Extend the existing cancel test so the buffered-at-cap case gets no line for that id.

Optional

  1. A hard kill of the bridge leaves the hosted server running, and the next attach keeps it.

attach_local sets the child's stdin to null, sets kill_on_drop(true), and does not set a parent-death signal (upstream.rs around lines 496-500). stop sends SIGTERM and then waits (upstream.rs lines 598-617). That path is tested: stdin close and the host test remove the record. SIGKILL of the bridge skips destructors, so kill_on_drop does not run, and the child does not see a closed stdin. The next omnigraph mcp finds the live record, attaches with no hosted child, and a later clean exit does not stop that server. On Windows, stop uses start_kill (TerminateProcess) rather than a shutdown signal, so record removal there also depends on a hard kill. A parent-death signal, or an equivalent, would make the hosted server die with the bridge. The graceful path can stay as it is.

  1. Read-only facts survive a new connection.

Binding::learn_tools only inserts (binding.rs lines 146-159). reresolve replaces the connection and keeps the binding. A tool learned with readOnlyHint: true stays admitted if the server that comes back labels that same name as a write, until some later tools/list replaces the entry. A name the bridge has never seen is listed again, which fails closed. Clear the map when the generation changes, so the next call lists the tools of the server it will actually call.

  1. The guide's concurrency cap and the server's slot cap differ.

The bridge allows 32 requests in flight (IN_FLIGHT_MAX in mod.rs). /mcp admits 16 tool calls at a time (Semaphore::new(16) in crates/omnigraph-server/src/mcp.rs). Calls past that return a tool error capacity_exceeded. The new guide says "at most 32" and does not say that a burst of tool calls can fail at 16. Say that in "Framing and failures", or cap the bridge at 16 tool calls.

  1. Three RFC texts and the skill do not yet describe this bridge the same way.

Draft RFC #1017 at e15d2f5b, section "MCP for agents", matches the code: one graph, readOnlyHint, token stays in the bridge, 401 retry once, direct versus server targets. The later CLI bullet, "The MCP bridge relays JSON-RPC unchanged except for filtering tools/list and refusing tools/call...", is narrower than the code. The code also fills graph, refuses another graph and another resource URI, narrows graphs and resources/list, appends initialize instructions, and does not forward notifications/cancelled. Update that bullet in #1017 so the two sections agree.

The skill row names mcp. It does not state the three-row rule in "Which path an agent uses" (terminal CLI, terminal MCP, Studio gateway). RFC 0003 still says a Rust stdio bridge is deferred until local desktop clients need it (docs/rfcs/0003-mcp-server-surface.md around line 419). This delta does not add the disposition note #1017 asks for. docs/dev/testing.md does not name cli_mcp as the owner. These are documentation debts of this stack. They are not a reason to change the relay.

What I checked and accept

Stdout discipline holds. write_lines is the only stdout writer. Each line is one JSON value plus a newline, then a flush. Child stdout is a pipe. Refusals go to stderr. A pretty resource body is escaped inside the JSON string, so it stays one line. The host test checks that the local token is absent from stdout and stderr.

Graph binding matches the server parser. Both take the segment after omnigraph://graphs/ and before the next /. A call that omits graph, or sends null, is filled when the tool schema has properties.graph. Another string is JSON-RPC -32602 and is not sent. resources/list, the graphs tool, and omnigraph://graphs are narrowed to the bound graph. resources/templates/list is not narrowed. Its templates use {graph}, and a read of another graph is still refused.

readOnlyHint must be the JSON boolean true. A missing or false hint is refused, and the message says omnigraph mcp --write is not available yet. That matches phase 3 of the RFC. Phase 4 adds the flag. A server can mark a write tool readOnlyHint: true, and this bridge will admit it. The RFC chooses that trust. OmniGraph's own server sets the hint from the tool effect, and it also hides write tools unless writes are allowed. The hint is not a second policy check. A remote server that lies is admitted. I accept that residual.

Credentials hold for the cases I ran. The token is not an argument. The loopback client uses no_proxy and does not follow redirects. The server-target client is GraphHttpClient, which also does not follow redirects. The bearer is set on the POST to {endpoint}/mcp. --as on a server target is the header omnigraph-expect-actor on that POST. I confirmed the header on a stand-in, and a_bridge_relays_to_a_server_target_with_the_owners_token passes against a server built at this head: a mismatched actor is HTTP 412 actor_mismatch. --as on a direct target is passed to the serve child, as the guide says. The server-target client follows the ambient proxy, as the CLI's other served clients do. An http:// endpoint through an HTTP proxy would show the bearer to that proxy. An https:// CONNECT tunnel does not.

Handshake order holds for messages read after initialize. Those lines wait until initialize is delivered. A request that was already in flight can still finish first. That matches "answered as they complete".

A 401 or a refused connection re-resolves once per generation, under one mutex, and resends. A second failure ends the bridge with mcp_target_lost, answers pending ids with -32000, and exits 1. Resend is safe for those two failures: 401 is before the handler, and a refused connection means the POST was not sent. Other failures are not retried.

Graceful stop holds. Stdin close, SIGTERM, and Ctrl-C stop a hosted server, and the host test checks the record. A reused server is left running.

Overlap

This branch contains #1083 (feat/cli-serve at 6ee0c3d1) and the earlier stack. I did not review that code. #1073 is merged. Open siblings that share files with this delta, and can conflict at merge, are #1059 (planes.rs, --as scope), #1012 (openapi.json, api-types, skill commands), #1072 and #1086 (skill files). #1059 replaces the --as matrix with consumes_actor. This delta adds Command::Mcp to the Local arm of that matrix (planes.rs lines 243-246). A rebase onto #1059 needs the mcp row to stay an actor consumer. #675 and #1017 are RFC text. #1075 and #1077 do not touch this delta. I left their findings with them.

Tradeoff

Correctness of the stated stdio contract comes before a smaller loop. The biased select prefers a finished answer, which keeps a fast call from waiting behind stdin. That choice breaks the cancel sentence in the guide. Restore the sentence in the loop. Leave the network race to the MCP spec.

The design deprioritizes a private cancel protocol. The server already cancels a stateless call when the HTTP exchange is dropped, and only until the first message. After the bridge has the answer bytes, dropping them is a local choice. Do that when the cancel is already buffered. Do not add a second cancellation channel.

Liability removed: agents no longer store the token or choose the graph on each call. Liability added: one tool cache and one select order that must match the guide. Clearing the cache on re-resolve, and applying a buffered cancel before the write, keeps that liability in this module.

Validation

Head 24be23f4eba4950f1b05266a84ff13519ae8351a. Base of this delta: 6ee0c3d1. RFC compared: #1017 head e15d2f5b85a759b053dda73082a5c9e7fff8e121.

Local, --locked, RUST_MIN_STACK=16777216:

  • cargo test -p omnigraph-cli --lib commands::mcp:: — 8 passed
  • cargo test -p omnigraph-cli --lib planes::tests:: — 4 passed
  • cargo test -p omnigraph-cli --lib skill::tests:: — 6 passed
  • cargo test -p omnigraph-cli --test integration cli_mcp:: — 5 passed
  • cargo test -p omnigraph-cli --test integration cli_schema_config::skill_export_writes_the_built_in_skill_byte_for_byte — passed
  • cargo test -p omnigraph-server --test integration openapi::error_code_schema_has_expected_variants — passed
  • cargo test -p omnigraph-server --test integration openapi::openapi_spec_is_up_to_date — passed
  • cargo fmt --all --check — clean

The first run of cli_mcp::a_bridge_relays_to_a_server_target_with_the_owners_token failed. target/debug/omnigraph-server was from 18:43 UTC, and this checkout's server sources are from 19:20 UTC. The binary did not contain the expect-actor refusal string. After cargo build -p omnigraph-server --locked --bin omnigraph-server, the same test passed in the 5-test run (10.20s). That first failure is an old binary, not this head.

The cancel repro used the rebuilt target/debug/omnigraph against a local stand-in. It is not in the repository.

GQT does not apply. The contract is process stdio, HTTP, and signals. The owner is crates/omnigraph-cli/tests/integration/cli_mcp.rs, with unit tests in binding.rs and wire.rs. This pull request adds behavior. It does not fix a query defect, so there is no before-commit GQT pair. I did not change the tree.

Not run: cargo clippy, the full workspace suite, DST, and CI on this head. I did not treat an author's CI claim as a result. I have no performance measurement. The 32 and 45 second bounds are from the source, not from a load test.

Comment on lines +167 to +187
tokio::select! {
biased;
() = shutdown.received() => break Ended::Stopped,
Some((seq, done)) = in_flight.next(), if !in_flight.is_empty() => {
// A cancelled request is no longer pending, and gets no answer.
let Some(Pending { id, .. }) = pending.remove(&seq) else {
continue;
};
match done {
Ok(Ok(answer)) => {
if let Err(ended) = write_lines(stdout, &answer) {
break ended;
}
}
Ok(Err(Ended::Lost(cause))) => {
unanswered.push(id);
break Ended::Lost(cause);
}
Ok(Err(ended)) => break ended,
Err(_aborted) => {}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This biased select writes a finished in-flight answer before it reads stdin. At 32 in flight the read is disabled, so a notifications/cancelled that is already in the channel (capacity 1) stays unread until some request finishes. If that request is the cancelled one, its answer is written and removed from pending before the cancel is applied.

The guide (docs/user/operations/mcp.md, "Framing and failures") says that request gets no answer. The slow cancel test holds the call for about 3 seconds, so the abort wins. It does not cover this order.

I reproduced it at this head: 32 tools/calls, then notifications/cancelled for id 1, with the stand-in answering id 1 only after all 32 had arrived. Stdout contained the result for id 1.

Before write_lines, take a buffered cancel for that id, including at the cap, and drop the answer. A response that completed before the cancel reached this process may still be sent. Say that in the guide, and extend the cancel test for the buffered-at-cap case.

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.

2 participants