Repository navigation
Conversation
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.
|
Recommendation: change the cancellation behavior before merge. I reviewed only the commits after #1083 ( WhyAn 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 What
HowOne task writes stdout. Up to 32 requests run at the same time, and each answer is written when that request finishes. ImpactThis 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
The test I reproduced the cap case at this head with
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 Optional
The bridge allows 32 requests in flight (
Draft RFC #1017 at The skill row names What I checked and acceptStdout discipline holds. Graph binding matches the server parser. Both take the segment after
Credentials hold for the cases I ran. The token is not an argument. The loopback client uses Handshake order holds for messages read after A 401 or a refused connection re-resolves once per generation, under one mutex, and resends. A second failure ends the bridge with Graceful stop holds. Stdin close, OverlapThis branch contains #1083 ( TradeoffCorrectness 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. ValidationHead Local,
The first run of The cancel repro used the rebuilt GQT does not apply. The contract is process stdio, HTTP, and signals. The owner is Not run: |
| 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) => {} | ||
| } |
There was a problem hiding this comment.
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.
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.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.--asis sent as theomnigraph-expect-actorassertion.graphargument is filled in. A call or resource naming another graph is refused, and the graph listings show only the bound graph.readOnlyHint: trueare listed or admitted.mcp --writewaits, asserve --writedoes, 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).notifications/cancelleddrops the named request's HTTP exchange. On stdin EOF, requests in flight are answered before shutdown.-32000and exits 1 with the new codemcp_target_lost.How
The command is on the Session plane like
serve. It lives incrates/omnigraph-cli/src/commands/mcp/:binding.rsresolves the target,upstream.rsrelays, andwire.rshandles 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/, themcpwiring incli.rs,planes.rsandapp.rs,cli_mcp.rs, the "Agents over stdio" section ofdocs/user/operations/mcp.md, the skill rows and the changelog note. Merge after #1083.Tests
cli_mcpprocess tests:serveis reused. Four requests lost together on its restart resolve once.--asgets412 actor_mismatch.mcp_target_lost.wire.rsandbinding.rs, and the planes flag matrix.cargo test -p omnigraph-cli -- --test-threads=6passes: 262 lib and 351 integration tests. Clippy, fmt,check-docs,typosand the OpenAPI drift test also pass.Backing issue / RFC