Repository navigation
feat(cli): relay an agent's stdio MCP to an existing server - #1118
Conversation
`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.
# 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
What this change does
The bridge opens no storage and starts no server. A direct target and a managed target refuse with 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. VerdictOne 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
Optional
What holds
A non-success RFC 0003 still describes a future
ValidationReviewed commit: GQT cannot express this contract. The behavior is a process, stdio, HTTP, a credential file, and cancellation. The owner is Command (both runs):
On the PR head, with one temporary test added ( A local fix stored a 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
LiabilityThe 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. 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. 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:
|
# 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.
|
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 ( 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: 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:
Local runs: CLI lib 30 passed; integration 67 passed ( |
# Conflicts: # crates/omnigraph-cli/src/operator_routing.rs
# Conflicts: # docs/user/operations/mcp.md
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
/mcpwithout 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
GraphHttpClient): the captured credential scope and transport settings, the--asassertion and a fresh discovery probe. A newGraphHttpClient::send_mcpshares that path withsend, minus the response contract-header check, which/mcpanswers do not carry.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 whoseauthisprovider: anonymoussends none.target_kind_unsupportedfrom target validation (a newRequirement::ServedGraph;scope::resolvenow 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.--graph, elsedefault_graph. A call that omitsgraphgets it; a call oromnigraph://graphs/{g}/…URI naming another graph is refused with JSON-RPC-32602;graphs,omnigraph://graphsandresources/listare narrowed; the binding is appended toinitialize's (andserver/discover's) instructions.--mcp-allow-writes); without--writeonly tools annotatedreadOnlyHint: trueare listed and admitted, and othertools/callrequests are refused before relaying.--assendsOmnigraph-If-Actoron every relayed request (now accepted bymcpin the flag matrix).ErrorOutputbody indata.errorwithcodeanddisposition, as the server's own MCP refusals do:{"status", "error": <server body>};WWW-Authenticatechallenge, verbatim, aswww_authenticate(several fields joined with,; no field when the header is absent or not UTF-8);service_unavailable/api_contract_mismatch,effects: none;outcome_unknown(effects: unknown,reconcile) for a tool not annotated read-only; otherwiseservice_unavailable,deadline_exceededorresult_too_largewitheffects: none.notifications/cancelledis 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.--write, answersoutcome_unknownwitheffects: unknown,action: reconcile, as a lost answer does; any other answers a plain error.Interpretations and points left open
credentials set --target NAME --stdin --replaceand a restart for a stored token (its replacement is a new credential generation, which a running bridge refuses), a restart forenv, a rewrite of the file forfile, and the helper run again forexec. The bridge does not exit on it.service_unavailable(effects: none,retry); the bridge keeps running, so a restarted server is reachable again. No code was added; the discontinuedmcp_target_lostis gone.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.target_incompatiblecarryinggraph_required), "as other served graph commands do".graphrequired; theinitializenote tells the agent it may omit it. Resource templates are relayed unchanged.HEAD /healthzbefore each relayed POST, as every served command does./mcphas no contract check, so this is what keepsOmnigraph-If-Actorfrom reaching a server that would ignore it.commands/mcpnamesomnigraph_server,omnigraph_mcporrmcp, so the move in refactor(mcp): extract protocol implementation into a separate crate #1106 cannot affect it.Reused from
feat/cli-mcp(55ce580)wire.rsnearly verbatim: line framing, classification, cancellation, SEP-2243 routing headers, SSE parsing. Addedrefusal_data.binding.rs: graph fill-in and refusal, listing narrowing, theinitializenote. Dropped connection generations; added the--writeconsent andmay_write.mod.rsrelay loop: concurrent relay, ordered handshake, cancellation semantics, stdin thread, signals, drain. Dropped the re-resolve-and-resend loop andmcp_target_lost.upstream.rsrewritten: server targets only, throughGraphHttpClient. Theserve --reusechild, local-server records, loopback client and theomnigraph_server::{MCP_CALLS_MAX, DEFAULT_SHUTDOWN_GRACE}dependencies are gone.Bridgeharness 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(--assentence),docs/dev/testing.md(test owner), skillSKILL.md(condensed reference, agent paths) andreferences/commands.md(index row, MCP for Agents),changelog.d/cli-mcp-stdio-bridge.added.md.Local verification
Run with
RUST_MIN_STACK=16777216, aftercargo 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_mcp6,system_remote20,cli_schema_config29)cargo test --locked -p omnigraph-cli --test integration -- --test-threads=6 cli_data::: 98 passedcargo test --locked -p omnigraph-cli --lib: 231 passed, 9 ignored (includescommands::mcp::12,skill::6,planes::4,scope::7)cargo test -p omnigraph-skill --locked: 1 passedcargo clippy -p omnigraph-cli -p omnigraph-api-types -p omnigraph-server --all-targets --locked -- -D warnings -W clippy::dbg_macro: cleancargo fmt --all --check,python3 scripts/check-docs.py(365 files OK),scripts/check-agents-md.shOK,typoson changed files: cleanpython3 scripts/release_notes.py preview --working-treerenders the new noteAfter 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_mcp7,cli_queries26,cli_schema_config33,cli_data::access1)cargo test --locked -p omnigraph-cli --lib -- targets:: operator_routing:: credential_sources:: managed::: 132 passed, 6 ignoredcargo test --locked -p omnigraph-skill: 1 passed; clippy (same command as above),cargo fmt --all --check,check-docs.py(384 files OK) andtyposon changed files: clean