Skip to content

feat(cli): relay an agent's stdio MCP to an existing server - #1118

Merged
ragnorc merged 8 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-mcp-bridge
Oct 22, 2026
Merged

ragnorc merged 8 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-mcp-bridge

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

omnigraph mcp --target NAME [--graph G] [--write]: a stdio MCP bridge for agents that start their tools as child processes (Claude Code or Codex in a terminal). It relays each JSON-RPC line on stdin to an existing server target's /mcp (one POST per message) and writes each answer as one line on stdout; diagnostics go to stderr.

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

Backing RFC: Graph access and cross-process writes, phase 3 ("Existing-server clients"), sections MCP for agents, Which path an agent uses, One server model, Identity and Dispositions and refusals.

Why

Desktop agents that only start commands need a stdio path to the server's /mcp without copying a bearer token into their configuration. The RFC decides that this path is a client of an existing server only: no local serving, no process registry, no auto-hosting.

Behavior

  • Server targets only. Requests go through the transport every served command uses (GraphHttpClient): the captured credential scope and transport settings, the --as assertion and a fresh discovery probe. A new GraphHttpClient::send_mcp shares that path with send, minus the response contract-header check, which /mcp answers do not carry.
  • Credential. The token is the one every served command acquires from the target's auth (resolve_server_token_for), and never reaches the agent's configuration, stdout or stderr. After a 401 it is read from that source again (reread_server_token_for, Authentication::reacquire), leaving the invocation's own credential unchanged. A target whose auth is provider: anonymous sends none.
  • Refusals before any I/O. Direct graph, root and catalogue targets, and managed targets (deferred to A3), refuse with target_kind_unsupported from target validation (a new Requirement::ServedGraph; scope::resolve now keeps a typed refusal's own code). A missing graph refuses as other served graph commands do; a token its source cannot supply refuses before any request, as for every served command.
  • One graph. --graph, else default_graph. A call that omits graph gets it; a call or omnigraph://graphs/{g}/… URI naming another graph is refused with JSON-RPC -32602; graphs, omnigraph://graphs and resources/list are narrowed; the binding is appended to initialize's (and server/discover's) instructions.
  • Two consents. The server is the ceiling (--mcp-allow-writes); without --write only tools annotated readOnlyHint: true are listed and admitted, and other tools/call requests are refused before relaying.
  • --as sends Omnigraph-If-Actor on every relayed request (now accepted by mcp in the flag matrix).
  • Failures, never resent. Each bridge error carries an ErrorOutput body in data.error with code and disposition, as the server's own MCP refusals do:
    • HTTP refusal (401, 412, 429, …): {"status", "error": <server body>};
    • a 401 also carries the server's WWW-Authenticate challenge, verbatim, as www_authenticate (several fields joined with , ; no field when the header is absent or not UTF-8);
    • nothing sent (unreachable, or another HTTP contract): service_unavailable / api_contract_mismatch, effects: none;
    • lost answer: outcome_unknown (effects: unknown, reconcile) for a tool not annotated read-only; otherwise service_unavailable, deadline_exceeded or result_too_large with effects: none.
  • Cancellation is applied by the bridge (notifications/cancelled is not forwarded): the exchange is dropped and the request gets no answer unless it was already written. Up to 16 requests in flight, the server's documented limit.
  • Stopping (SIGINT, SIGTERM, or stdin closing past the drain wait) answers every request still pending. A call that may write, a tool the server does not annotate read-only on a bridge started with --write, answers outcome_unknown with effects: unknown, action: reconcile, as a lost answer does; any other answers a plain error.

Interpretations and points left open

  1. 401 handling. The RFC grants one credential re-read "for subsequent requests". The refused request is therefore answered with the server's 401 body and never sent again, although a 401 precedes any effect. Each refused read of the credential permits one re-read (concurrent 401s on the same read share it); a token that is still refused fails each later request the same way, with advice for its source: credentials set --target NAME --stdin --replace and a restart for a stored token (its replacement is a new credential generation, which a running bridge refuses), a restart for env, a rewrite of the file for file, and the helper run again for exec. The bridge does not exit on it.
  2. Refused connection. Reported per request with the registry code service_unavailable (effects: none, retry); the bridge keeps running, so a restarted server is reachable again. No code was added; the discontinued mcp_target_lost is gone.
  3. Anonymous only by selection. Anonymous access is the target's own auth: {provider: anonymous}, and every remote address is a registered target, so the bridge has no anonymous check of its own; an inline server address refuses as for every command.
  4. Missing graph uses the existing served-graph refusal (target_incompatible carrying graph_required), "as other served graph commands do".
  5. Tool schemas are relayed unchanged (the RFC's "relays JSON-RPC unchanged except where…"), so listed schemas still mark graph required; the initialize note tells the agent it may omit it. Resource templates are relayed unchanged.
  6. Discovery per request. The bridge probes HEAD /healthz before each relayed POST, as every served command does. /mcp has no contract check, so this is what keeps Omnigraph-If-Actor from reaching a server that would ignore it.
  7. Startup refusal exit status is the CLI's existing target-refusal path (exit 2, message on stderr); the RFC's exit table is separate work.
  8. RFC 0003 amendment (the RFC's "Docs and skill" row) is not in this PR, since this PR edits no RFC file.
  9. The bridge depends only on the wire protocol. An in-source test fails if commands/mcp names omnigraph_server, omnigraph_mcp or rmcp, so the move in refactor(mcp): extract protocol implementation into a separate crate #1106 cannot affect it.

Reused from feat/cli-mcp (55ce580)

  • wire.rs nearly verbatim: line framing, classification, cancellation, SEP-2243 routing headers, SSE parsing. Added refusal_data.
  • binding.rs: graph fill-in and refusal, listing narrowing, the initialize note. Dropped connection generations; added the --write consent and may_write.
  • mod.rs relay loop: concurrent relay, ordered handshake, cancellation semantics, stdin thread, signals, drain. Dropped the re-resolve-and-resend loop and mcp_target_lost.
  • upstream.rs rewritten: server targets only, through GraphHttpClient. The serve --reuse child, local-server records, loopback client and the omnigraph_server::{MCP_CALLS_MAX, DEFAULT_SHUTDOWN_GRACE} dependencies are gone.
  • Tests: the Bridge harness and the stand-in endpoint (extended with discovery, bearer recording, 401 and hang-up tools) and the timing/cancellation test. Server-target assertions now run against a real two-graph served cluster.

Docs

docs/user/operations/mcp.md (new Connect a stdio agent section), docs/user/cli/reference.md (one row, page kept at 349 lines), docs/user/operations/server.md (--as sentence), docs/dev/testing.md (test owner), skill SKILL.md (condensed reference, agent paths) and references/commands.md (index row, MCP for Agents), changelog.d/cli-mcp-stdio-bridge.added.md.

Local verification

Run with RUST_MIN_STACK=16777216, after cargo build --locked -p omnigraph-server -p omnigraph-cli:

  • cargo test --locked -p omnigraph-cli --test integration -- --test-threads=6 cli_mcp:: system_remote:: cli_schema_config::: 55 passed (cli_mcp 6, system_remote 20, cli_schema_config 29)
  • cargo test --locked -p omnigraph-cli --test integration -- --test-threads=6 cli_data::: 98 passed
  • cargo test --locked -p omnigraph-cli --lib: 231 passed, 9 ignored (includes commands::mcp:: 12, skill:: 6, planes:: 4, scope:: 7)
  • cargo test -p omnigraph-skill --locked: 1 passed
  • cargo clippy -p omnigraph-cli -p omnigraph-api-types -p omnigraph-server --all-targets --locked -- -D warnings -W clippy::dbg_macro: clean
  • cargo fmt --all --check, python3 scripts/check-docs.py (365 files OK), scripts/check-agents-md.sh OK, typos on changed files: clean
  • python3 scripts/release_notes.py preview --working-tree renders the new note

After merging main's owner-scoped operator configuration and adding the challenge relay (c017e09):

  • cargo test --locked -p omnigraph-cli --lib --test integration -- commands::mcp command_outcome:: skill:: planes:: scope:: cli_mcp:: cli_queries:: cli_schema_config:: cli_data::access --test-threads=6: lib 30 passed, integration 67 passed (cli_mcp 7, cli_queries 26, cli_schema_config 33, cli_data::access 1)
  • cargo test --locked -p omnigraph-cli --lib -- targets:: operator_routing:: credential_sources:: managed::: 132 passed, 6 ignored
  • cargo test --locked -p omnigraph-skill: 1 passed; clippy (same command as above), cargo fmt --all --check, check-docs.py (384 files OK) and typos on changed files: clean

`omnigraph mcp --target NAME [--graph G] [--write]` speaks MCP over stdio
for agents that start their tools as child processes and relays every
message to a server target's `/mcp`, through the transport every served
command uses: the captured credential scope, TCP or a Unix socket, the
`--as` assertion and a fresh discovery probe. The target's stored token
is resolved as every served command resolves it and never reaches the
agent's configuration, stdout or stderr.

The bridge is bound to one graph (`--graph`, else `default_graph`): it
fills `graph` where a call omits it, refuses a call or resource URI that
names another graph, narrows the graph listing, and appends the binding
to `initialize`'s instructions. Without `--write` it lists and admits
only tools the server annotates read-only. It applies
`notifications/cancelled` itself and never resends a request: a 401 has
the credential read again once for later requests, a refused connection
answers `service_unavailable`, and a lost answer to a call that may
write answers `outcome_unknown`, as does one still pending when the
bridge stops.

Direct and managed targets refuse with `target_kind_unsupported` before
any I/O, an address no target declares does not fall back to anonymous
access, and the bridge never opens storage or starts a server. It names
no server or MCP implementation crate, which an in-source test checks.
@ragnorc
ragnorc requested a review from aaltshuler as a code owner October 21, 2026 16:18
# Conflicts:
#	crates/omnigraph-cli/src/cli.rs
#	crates/omnigraph-cli/src/planes.rs
#	docs/user/cli/reference.md
#	skills/omnigraph/SKILL.md
#	skills/omnigraph/references/commands.md

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

What this change does

omnigraph mcp --target NAME [--graph G] [--write] is a stdio MCP client for an agent that starts tools as a child process. Each JSON-RPC line on stdin becomes one POST to an existing server target's /mcp. Answers come back as one line on stdout. Diagnostics stay on stderr. The token stays in this process.

The bridge opens no storage and starts no server. A direct target and a managed target refuse with target_kind_unsupported before any I/O. The call is bound to one graph. Write tools need both the server ceiling (--mcp-allow-writes) and --write. A request is never sent again. A lost answer for a call that may write is outcome_unknown. Cancellation drops that call's HTTP exchange and writes no answer.

This matches phase 3 of the graph-access RFC: an existing-server client, not a second server. Graph publication, snapshots, and Lance are unchanged. The new liability is local to this process: the effect class of a call that is already in flight.

Verdict

One blocking finding. The relay, the credential rule, the graph binding, and the refusal-before-I/O path hold. Fix the effect class before merge. Two optional notes follow.

Blocking

  1. The effect class of a call already sent is read again from the shared tool cache when the answer is lost, and again when the bridge stops. The first lost answer calls forget(), which clears that cache. A second in-flight read-only call is then answered outcome_unknown. Store the class on the in-flight call when the POST is sent. See the comment on mod.rs.

Optional

  1. A failed credential re-read still consumes the one re-read. A later 401 does not try again until the process restarts. See upstream.rs.
  2. A cancelled write gets no answer. That silence means the write may have landed. The skill sentence and the stdio section do not say so. The server initialize text does, when writes are enabled, and the bridge keeps that text.

What holds

  • Server targets only. Requirement::ServedGraph refuses other kinds in target validation, before storage and before TCP. scope::resolve keeps a TargetError code.
  • Anonymous access only when the target declares auth: anonymous. An inline address with no target is credential_missing.
  • One graph: fill when graph is omitted, refuse another graph on a tool call and on a resource URI, narrow graphs, omnigraph://graphs, and resources/list.
  • --write filters on readOnlyHint: true. The server still decides the call.
  • A 401 is answered and not sent again. One re-read is shared by concurrent 401s on the same credential read.
  • notifications/cancelled is not forwarded. The exchange is dropped.
  • GraphHttpClient::send still requires the contract header on the response. send_mcp does not, which matches /mcp. Discovery still runs, so Omnigraph-If-Actor is not sent to a server that failed the probe.
  • Redirects stay off. The bridge modules do not name the server crate or an MCP implementation crate.
  • Stop answers every still-pending request. A read at stop is a plain -32000 with no data. That is deliberate, and stopped_with_calls_in_flight locks it in.

A non-success HEAD /healthz is api_contract_mismatch (action: fix), because discovery uses that one code. The same probe already precedes every served command. Per message, it adds a round trip and can fail a call when /mcp itself would answer. That trade is the one named in the PR (keep Omnigraph-If-Actor off a server that would ignore it).

RFC 0003 still describes a future omnigraph mcp --server <url>. This PR leaves that amendment out on purpose. The user guide and the skill are the operator contract for the flag that shipped. A later docs change should point 0003 at omnigraph mcp --target.

tools/list stops after 32 pages and returns success while nextCursor remains. A tool not yet seen is refused and not sent. This server returns one page. The failure is closed. A server that pages past 32 tools would hide the rest until restart.

Validation

Reviewed commit: bd54c9dad6a08de31222b5bc4ad3dd6f71250561. The worktree was /tmp/pr1118, detached at that commit. The temporary test and the local fix were restored. git status is clean. Nothing was pushed.

GQT cannot express this contract. The behavior is a process, stdio, HTTP, a credential file, and cancellation. The owner is crates/omnigraph-cli/tests/integration/cli_mcp.rs.

Command (both runs):

RUST_MIN_STACK=16777216 cargo test --locked -p omnigraph-cli --test integration cli_mcp:: -- --test-threads=8

On the PR head, with one temporary test added (mcp_keeps_the_sent_effect_class_when_another_answer_is_lost): 6 passed, 1 failed, 11.82s. The 6 are the PR tests. The failure is the new test. Two concurrent drop_read calls after tools/list, with --write. Both were admitted while the tool was annotated read-only. The first lost answer was service_unavailable with effects: none. The second was outcome_unknown with effects: unknown. Three repeats of that binary gave the same split. A one-line trace, removed with the patch, printed may_write=false for the first loss and may_write=true for the second, after forget().

A local fix stored a bool on the in-flight call and set it immediately before post, after admit. The same command then reported 7 passed, 11.75s. That run includes the new test, the existing lost-answer test, and the SIGTERM test. The fix and the test were then removed with git checkout. They are not on the branch. Please add the regression on the branch with the fix. Snapshot the class after admit and before the POST. A tool name saved at dispatch, and resolved again at completion, keeps this bug. list_tools runs inside the request future, before the POST.

I did not re-run clippy, rustfmt, or GQT locally. GitHub Actions run 39636292646 for this commit is green: CI Gate, Test Workspace, clippy, rustfmt, and GQT (ordinary, dst, served). I did not measure the extra HEAD /healthz on each message. There is no storage-cost change.

origin/main is 13ecdc8b (perf(load): fan out a csv overwrite). The merge-base with this head is 092411dc. I did not review that main delta. It does not touch this bridge.

Liability

The observable contract is: a call that was read-only when it was sent has no effects if the answer is lost; a call that may write has an unknown outcome, and the agent reconciles before it replays. The tool list is a cache for the next admission. It is not a stable record for a call already sent. forget() exists so the next call lists the tools again after a lost exchange. Using that same cache to classify the call that was already sent breaks the contract.

The executed failure is fail-safe: a lost read is reported as a possible write, so the agent reconciles instead of retrying. The same read can fail open. learn_tools overwrites a name in place. If a later tools/list marks that name read-only while a write is in flight, stop and the lost-answer path report no effects. The agent can replay the write. This server does not flip readOnlyHint on a name. The bridge claims to follow the wire, and its own forget comment says the tool list can change after a restart. The class has to travel with the call.

Five more clients that classify effects from a shared mutable cache at completion will each grow the same race. One bit, set when the call is sent, keeps admission and effect-class apart.

Inline: crates/omnigraph-cli/src/commands/mcp/mod.rs:632

The effect class is taken from the live tool cache here, and the cache is then cleared.

I sent two drop_read calls together, after tools/list, with --write. Both were admitted while drop_read was annotated read-only. The first lost answer is service_unavailable with effects: none. forget() clears the map. The second lost answer is outcome_unknown with effects: unknown, because a missing name counts as a write (Binding::may_write). The action changes from retry to reconcile for a call that was read-only when it was sent.

Stop does the same read at lines 233–241. The comment there says a call the bridge admitted was listed by then. That stops being true after a sibling loss has called forget().

The other direction is the same read. learn_tools overwrites a name. A later tools/list that sets readOnlyHint: true on a write that is already in flight makes this path, and the stop path, report no effects. The agent can replay the write. This server does not flip that annotation. The bridge follows the wire, and forget exists because the tool list can change, so the class is not stable for the whole flight.

Store the class on the in-flight call when the POST is sent, after admit, and use that bit here and at stop. Keep forget and learn_tools for the next admission only. A tool name saved at dispatch and resolved again at completion keeps the bug: list_tools runs inside the request future, before the POST.

A local change stored the bit in Rc<Cell<bool>>, set it immediately before post, and shared it with Pending. cargo test --locked -p omnigraph-cli --test integration cli_mcp:: then passed 7 tests, including a temporary mcp_keeps_the_sent_effect_class_when_another_answer_is_lost and the existing lost-answer and SIGTERM tests. I restored the files. The regression should land with the fix.

Inline: crates/omnigraph-cli/src/commands/mcp/upstream.rs:267

reads increases before resolve_server_token_for returns. A failed re-read still consumes the one re-read. The next 401 takes Reread::Already and keeps the old token until the process restarts. The refused request is correctly not sent again. Later requests are the ones that stay stuck.

Increase reads only when the new read returns, or allow another read after Reread::Failed. A successful read that returns the same token can still count as the one re-read (Reread::Unchanged).

Inline: skills/omnigraph/references/commands.md:390

A cancelled call gets no answer. For a tool that may write, that silence means the same thing as outcome_unknown: the write may have landed, and the agent reconciles before it replays. This sentence only names outcome_unknown. An agent that retries a cancelled mutate can apply it twice.

The server initialize text already says this when --mcp-allow-writes is on, and the bridge keeps that text. The stdio section (docs/user/operations/mcp.md, the cancellation paragraph) says the cancelled request gets no answer and does not say that a write may have landed. The write-tools section later on that page does. One sentence in this skill paragraph, and the same sentence next to the stdio cancellation rule, closes the gap for an agent that follows the skill.

@cursor cursor Bot mentioned this pull request Oct 21, 2026
6 of 7 tasks
# Conflicts:
#	crates/omnigraph-cli/src/app.rs
#	crates/omnigraph-cli/src/cli.rs
The bridge read a call's effect class from the tools it last listed, once
when the answer was lost and again when it stopped. The first lost answer
clears that list, so a second read-only call in flight was then answered
`outcome_unknown`. Each call now records its class when its POST is
sent, and a lost answer and the stop both read that record; a call not
yet sent had no effects.

The guide and the skill also say a cancelled write gets no answer and
may still have landed.
…rofile

The operator configuration now selects a server target's credential through
its auth profile: the invocation acquires it once, and anonymous access is the
selected profile's own choice. The bridge sends that token and, after a 401,
reads it from its source again: a rotated file or a helper's new token reaches
later requests, while an environment variable stays fixed and a stored token
replaced with `credentials set` starts a new credential generation, which
refuses until the bridge restarts. A token that stays refused is reported with
how to replace it at its source, replacing the retired `login --target`
advice.

The bridge's own anonymous check is gone: every remote address is a
registered target, and it refused an anonymous profile selected with
`--auth`. The tests write version 1 operator documents, reach the stand-in
endpoint through the TLS gateway, rotate a token file, and show a Unix-socket
endpoint refused before anything connects.
A 401 the bridge answers now carries the server's `WWW-Authenticate`
challenge, verbatim, at `data.www_authenticate` beside `status` and `error`,
so an agent learns how the server asks to be authenticated, including an
OAuth server's protected-resource metadata. Several header fields are joined
with ", "; without the header, or with one that is not UTF-8, there is no
field. The bridge neither parses nor rewrites the challenge.
@ragnorc

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at c017e09, after merging main (c59428b).

Blocking: the effect class is kept on the call (6f72b38). Each relayed call records its effect class when its POST is sent, and a lost answer and the stop both read that record. Neither re-reads the tool cache, which a lost answer clears. A call not yet sent had no effects. The lost-answer test now loses two concurrent read-only calls (drop_read twice) and expects both to answer service_unavailable with effects: none. The stop test loses an answer while a write and a read are in flight, then stops: the write answers outcome_unknown and the read a plain error.

Optional 1: the credential re-read. Each request records the credential's read count when sent, and a failed re-read still advances it. A request sent after the failure that is refused therefore re-reads, and nothing is consumed for good. The test already relied on this: a token that stays refused is re-read and reported on every later 401. The point mattered more after #1115: resolve_server_token_for now returns the invocation's cached token, so a re-read found nothing new. In 11495ff the bridge reads the source again through Authentication::reacquire, with the same owner-route and stored-generation checks, and leaves the invocation's own credential unchanged. A rotated file or a helper's new token reaches later requests. An environment variable stays fixed. A stored token replaced with credentials set is a new generation and refuses until the bridge restarts. The login --target advice is replaced by per-source instructions.

Optional 2. The stdio section and the skill now say that a cancelled write gets no answer and may still have landed.

Also in this head:

  • On a 401 the bridge relays the server's WWW-Authenticate value verbatim at data.www_authenticate (c017e09). This lets an agent behind the bridge tell a missing token from a refused one and find the OAuth metadata fix(server): send WWW-Authenticate on every 401 #1122 advertises. With no header there is no field.
  • Version 1 operator documents refuse unix:// endpoints, so the Unix-socket claim is gone from the docs and the PR body.

Local runs: CLI lib 30 passed; integration 67 passed (cli_mcp 7); credential units 132 passed. Clippy, fmt, docs and typos are clean.

# Conflicts:
#	crates/omnigraph-cli/src/operator_routing.rs
# Conflicts:
#	docs/user/operations/mcp.md
@ragnorc
ragnorc added this pull request to the merge queue Oct 22, 2026
Merged via the queue into ModernRelay:main with commit 228a455 Oct 22, 2026
32 checks passed
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