Chio/Docs
LOGIN · JOIN

PlatformProcess Model

Node

Remote MCP Edge

Streamable HTTP with replay, three bearer modes with sender constraints, an optional local authorization server, and one kernel per session.

This page owns the process, the next one owns the session

Session Lifecycle owns what happens to one session once it exists: the ledger, the lifecycle states, idle expiry and drain, tombstones, and the integrity gate a resumed session passes on restart. This page owns everything around that: how the process boots, how a request is admitted, how a token is bound to a sender, and what runs per session inside one OS process. Read Node Overview first for the rung itself.

The fullest single-process node

chio mcp serve-http puts more into one process than any other Chio entry point: an HTTP and SSE edge for the MCP Streamable HTTP transport, three bearer authentication modes, an optional OAuth authorization server of its own, an admin API, and one kernel per session. Point it at --receipt-db and every governed action a remote client takes lands a signed receipt on this node’s disk. Omit both that flag and --control-url and the kernel refuses to dispatch at all unless policy sets allow_ephemeral_receipt_log, in which case the log is in memory and gone at restart. The crate is chio-mcp-remote, marked public_entrypoint = true in its Cargo.toml, and exposed as a single blocking call: serve_http(RemoteServeHttpConfig). The release runbook covers it as a supported self-hosted service alongside chio trust serve.

It is shipped and it is bounded. The bounded operational profile grades hosted authorization with cnf and dedicated sessions as local-only: bounded request-time authorization and protected-resource admission, with no cross-node auth-code failover and no restart-safe replay guarantee. It grades static bearer, non-cnf tokens, and shared_hosted_owner compatibility-only, which is the posture the rest of this page keeps returning to.


What the process holds

RemoteAppState is cloned into every handler and holds eight things. Five of them are Option, and the None case is a configuration, not a fault.

FieldWhat it isAbsent when
factoryOne RemoteSessionFactory per process: the config, the shared durable-admission runtime, the shared-upstream cache, and the lifecycle policy.Never.
sessionsThe RemoteSessionLedger: active sessions, terminal tombstones, and the SQLite file both are persisted to.Never, though its database path is optional.
auth_modeExactly one of StaticBearer, JwtBearer, IntrospectionBearer.Never. A config naming none of them fails to boot.
admin_tokenThe bearer every /admin/* route compares in constant time.Never in practice: boot fails without --admin-token, unless --auth-token is set and silently reused.
protected_resource_metadataThe resource indicator, the advertised authorization servers, the scopes, and Chio’s authorization profile.Static bearer mode. /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp then answer 404, and every 401 carries a bare Bearer challenge with no resource_metadata.
authorization_server_metadataA discovery document plus the one path it may be served from. The handler 404s any other path, including the /.well-known/oauth-authorization-server/{*rest} wildcard, unless it matches the path derived from the issuer.Static bearer mode; an external issuer that is not same-origin with the public base URL; or an external issuer whose issuer, authorization endpoint, and token endpoint did not all resolve.
local_auth_serverThe self-issued LocalAuthorizationServer.No --auth-server-seed-file. All three /oauth/* routes then answer 404.
enterprise_provider_registryIssuer-to-provider records used to attribute a bearer principal to an enterprise identity.No --enterprise-providers-file. Invalid records are logged at load and stay unavailable for admission.

One process, many kernels

rendering
Dedicated-per-session hosting, the default. Each session gets its own kernel, its own wrapped subprocess, and its own receipt-store handle; the local durable-admission runtime is claimed once per process and cloned into every kernel.
sourcecrates/protocol/chio-mcp-remote/src/remote_mcp/session_core/factory.rs:153-301at fe56570

spawn_session builds a whole kernel per session: it loads the policy again, opens the receipt store, configures the capability authority, issues that session’s default capabilities, registers the wrapped server as a tool server, and starts a dedicated OS thread running edge.serve_message_channels. The HTTP handler never touches that kernel directly; it sends JSON into an mpsc channel and reads events back off a 256-slot broadcast channel. That channel is the one lossy component on the page. A reader more than 256 events behind gets RecvError::Lagged, and every handler answers it the same way: log a warning with the skipped count and keep going. The skipped events are not recovered from the retained window, so a slow SSE consumer loses them outright.

Stores behave differently under that fan-out, and the difference matters. With durable admission on and no --control-url, budget, revocation, and admission-operation state come from one DurableAdmissionRuntime opened once at factory construction against <session-db>.admission, with its lock root at <...>.admission.locks and its kernel seed at <...>.admission.kernel.seed; those handles are cloned into every session kernel, so the serving-owner fence described in Node State on Disk applies once, to the process. Two configurations leave that path. --control-url swaps it for DurableAdmissionRuntime::open_remote, which provisions no local sidecar and still requires --session-db for its kernel seed. A policy with durable_admission_mode: off leaves no runtime at all, and then configure_revocation_store and configure_budget_store run per session, one handle each on the same file.

The receipt store never shares. configure_receipt_store runs per session and opens its own SqliteReceiptStore against the same --receipt-db path, so a hundred sessions are a hundred reader pools and a hundred group-commit writers on one file, serialized by SQLite’s own locking rather than by a single writer actor. Each open also blocks for up to 30 seconds in wait_for_writer_ready while its actor seeds a verified head, so session creation pays that cost per session, not once.


Boot, in order

serve_http_async is the whole sequence, and its order is load-bearing.

  1. Bind first. The TCP listener binds before anything else, because the resolved local_addr is the default for the public base URL, which is in turn the default for the issuer, the resource indicator, and every metadata URL.
  2. Discover the identity provider. A discovery URL, or a provider profile paired with an issuer, pulls the OIDC document. Its JWKS is resolved only when nothing else supplies keys: no static JWT key, no local seed file, no introspection URL. Both fetches go through the typed HttpEgressContract, and without a contract they fail closed rather than proceeding. The CLI builds that contract from the configured auth URLs alone: the scheme and authority allowlists hold exactly those URLs, loopback, link-local, and IPv6 unique-local targets are denied unless a configured URL is itself one of those, redirects cap at three, and the response caps at 1 MiB. When no auth URL is configured the CLI passes no contract at all, which is safe only because nothing then fetches. Node Egress Contract documents that contract in full, including the DNS re-check and the per-hop redirect validation this fetch runs under.
  3. Resolve the auth mode. build_remote_auth_mode rejects four combinations before it picks one: static bearer with any OAuth flag, a local seed file with a static JWT key or external discovery or introspection, an introspection client id and secret not supplied together, and introspection alongside --auth-jwt-public-key. That last rule is narrower than it looks. Discovery plus introspection is accepted, and introspection wins: the discovery document is still fetched and still shapes the issuer and the metadata, no JWKS is resolved, and every token is validated by a call to the introspection endpoint.
  4. Build and cross-validate metadata. When both documents exist, validate_chio_oauth_discovery_metadata_pair requires them to carry an identical chio_authorization_profile, with the expected schema and id, capability-subject binding, the Chio DPoP proof type advertised, and every remaining contract field (portable claim catalog, portable identity binding, governed authorization binding, request-time contract, resource binding, artifact boundary) equal to this binary’s own default profile. It also requires the protected resource to list the authorization server’s own issuer.
  5. Claim the disk and restore. Constructing the factory provisions and takes the serving lock on the admission sidecar, unless --control-url sent that state to a control plane instead. Local durable admission with no --session-db is not a degraded mode, it is a boot failure: "durable admission mode requires a database so operations and tool outcomes survive restart". Then persisted session rows are loaded, restored one by one, and deleted when they fail restore.
  6. Wrap and serve. The rate-limit layer is applied to the /mcp routes, then admin, discovery, and OAuth routes are added on top of that router, then shared hygiene wraps everything.

One hygiene default is deliberately turned off, and the code says why in its own words:

crates/protocol/chio-mcp-remote/src/remote_mcp/http_service.rsrust
// The generic per-request timeout is left off here. The edge's GET and POST
// routes return Server-Sent Event streams that stay open indefinitely while a
// session waits for notifications, so a blanket request timeout would close a
// healthy idle stream with 408 and force resumable clients into reconnect
// churn. Request bodies are already bounded by `read_limited_mcp_post_body`,
// per-IP request rate is capped by the rate limiter, and the drain deadline
// bounds any stream still open at shutdown.

Everything else in ServeHygieneConfig::default() stands: a 25 second drain, 1024 concurrent requests shedding to 503, and 2048 simultaneously accepted connections, all covered by Node Lifecycle. One of those defaults is a hole worth naming: max_body_bytes defaults to None, so no global body cap is layered on. The 8 MiB ceiling below belongs to the /mcp POST handler alone; admin and OAuth form posts carry only whatever limit their axum extractor applies. Draining is the durability story here, because the receipt commit actor acknowledges an append only after the batch reaches WAL: once a request finishes, the receipt it wrote is already durable, and each session kernel lives behind a channel with no in-process handle left to flush.


One request, in order

A POST to /mcp runs every gate below in this order, and each returns before the next is attempted. GET skips the body and content-type gates; DELETE runs only origin, bearer, session id, and auth continuity, so a Ready check never stands between a valid token and a session teardown.

GateRuleRefusal
Rate limitA per-IP window, applied to these routes only. The counts, the key cap, and what the limiter does not cover are in Backpressure & Limits.429 with Retry-After
OriginAn Origin header is allowed only for localhost, 127.0.0.1, or ::1. No header at all passes.403
BearerAn Authorization: Bearer value, verified under the configured mode, including any sender constraint the token carries. exp and nbf are optional claims: a token that omits exp is never expired by this edge.401 with WWW-Authenticate
Content negotiationPOST must accept both application/json and text/event-stream and send JSON. GET must accept the event stream. Both checks are literal substring tests on the header, so a client sending only */* is refused.406, or 415 on content type
Body size8 MiB, checked against Content-Length first and enforced again while streaming.413 as a JSON-RPC error
SessionMCP-Session-Id must be present, non-empty, and free of leading or trailing whitespace, except on initialize, which must not carry one at all.400, or 404 for an unknown session
Protocol versionOnce a session has negotiated a version, an MCP-Protocol-Version header must equal it. A request that omits the header passes.400
Auth continuityThe request’s authorization context must equal the session’s: transport, origin, and every field of the method. The OAuth token fingerprint is compared only when the session stored one.403
LifecycleOnly a Ready session is served.409 while initializing or draining, 410 once deleted, expired, or closed

Two details in that list are easy to misread. The rate-limit key comes from the accepted socket, not from any header, which mcp_rate_limit_key_ignores_unverified_client_headers pins directly: behind a reverse proxy every client shares one key. And the auth-continuity check runs against a terminal session too, so a client holding the wrong token learns 403 rather than which terminal state the session reached.


Binding a token to its sender

Sender constraints live entirely in the token’s cnf claim. There are three, and a token may carry any combination:

crates/protocol/chio-mcp-remote/src/remote_mcp/session_core.rsrust
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ChioSenderConstraintClaims {
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "chioSenderKey")]
    chio_sender_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "x5t#S256")]
    mtls_thumbprint_sha256: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "chioAttestationSha256")]
    chio_attestation_sha256: Option<String>,
}

validate_sender_constraint_runtime enforces each one that is present. A token with no cnf passes the whole function untouched, which is precisely why non-cnf tokens are compatibility-only: nothing about them is bound to the caller.

DPoP

chioSenderKey is a hex Ed25519 public key, and the request must carry a DPoP header holding a base64url-encoded JSON DpopProof. The proof reuses the kernel’s mediated-call DPoP body rather than defining an HTTP-specific one, so its fields are repurposed and compared exactly:

crates/protocol/chio-mcp-remote/src/remote_mcp/http_service_auth.rsrust
let expected_action_hash = sha256_hex(HTTP_DPOP_ACTION_HASH_EMPTY);
if proof.body.capability_id != expected_binding_id
    || proof.body.tool_server != expected_target
    || proof.body.tool_name != expected_method
    || proof.body.action_hash != expected_action_hash
{
    return Err(
        "DPoP proof did not match the expected binding id, target, method, or action hash"
            .to_string(),
    );
}

The three expected values depend on where the proof is checked. On a /mcp request expected_binding_id is the token’s jti, expected_target is the protected resource indicator, and expected_method is that request’s HTTP method; a DPoP-bound token with no jti is refused for want of a binding identifier. At the local token endpoint the binding id is the authorization code itself, the target is {issuer}/token, and the method is always POST. The action hash is the SHA-256 of the empty string in both cases, because the HTTP proof binds no arguments. The proof must also declare schema chio.dpop_proof.v1, carry an agent_key equal to the bound chioSenderKey, carry a non-empty nonce, be signed by that key over the canonical JSON of its own body, sit inside the default 300-second TTL with at most 30 seconds of future skew, and win a single-use insert into a 65,536-entry nonce store keyed by nonce and binding id. A replayed nonce is rejected.

mTLS and runtime attestation

The other two constraints are header comparisons: x-chio-mtls-thumbprint-sha256 against x5t#S256, and x-chio-runtime-attestation-sha256 against chioAttestationSha256. Both are string equality, and a missing header is a refusal rather than a pass.

The edge does not terminate TLS

chio mcp serve-http takes no certificate arguments and binds plain TCP; it prints its own address as http://. It therefore never observes a client certificate. The mTLS thumbprint it checks is whatever the fronting proxy asserted in a header, and the edge does no stripping of its own, so that constraint is only as strong as the proxy’s willingness to overwrite a client-supplied copy of x-chio-mtls-thumbprint-sha256. The same caveat applies to x-chio-runtime-attestation-sha256.

Streams and replay

Every event a session emits gets an id of {session_id}-{seq} from one monotonic counter, and is classified once, at emission: a message with a method and no id is a notification, anything else is request-correlated. Notifications, and only notifications, are pushed into a retained deque bounded at DEFAULT_NOTIFICATION_REPLAY_WINDOW = 64 entries.

A GET without Last-Event-ID attaches a live notification stream. A GET with one replays first. The cursor is parsed strictly, and every failure is a 409: an id that does not split on its final hyphen, an id whose session prefix belongs to another session, a non-numeric sequence, an empty retained window, or a sequence below one less than the oldest retained event or above the newest. Replay is best-effort-free: the edge refuses a cursor it can no longer prove rather than silently resuming from the oldest event it happens to hold. Nothing is persisted, so the window is per process: a restart drops it, and a restored session starts its counter at zero again.

Exactly one GET notification stream may be attached per session. The second gets 409, and the flag is released by a Drop guard when the stream ends. That single flag drives delivery routing: should_emit_post_stream_event emits notifications on a POST response only while no GET stream is attached, so a client that never opens the GET stream still receives notifications and a client that does opens no duplicate path. On a POST that carries a client notification rather than a request, the handler drains events until 100 milliseconds pass with none, so a session emitting steadily can hold that response open indefinitely; on a POST that carries a request, notifications go out inline until the terminal response. The POST request stream is serialized by an owned mutex, and the POST-side responses open with a priming event carrying a 1000 millisecond reconnect hint; the GET streams send no such hint. Every response is tagged with x-chio-mcp-response-mode, whose values name the exact branch taken: initialize_sse, post_request_sse, post_notification_sse, post_notification_accepted, get_sse_live, get_sse_replay.


The self-issued authorization server

Pointing --auth-server-seed-file at a seed turns the edge into its own OAuth 2.0 authorization server: GET and POST /oauth/authorize, POST /oauth/token, GET /oauth/jwks.json, and a discovery document advertising exactly two grants, authorization_code and urn:ietf:params:oauth:grant-type:token-exchange, with S256 as the only code challenge method and none as the only client authentication method.

The authorization request is validated tightly. Response type must be code, client id must be non-empty, the redirect URI must be https or a loopback http, the resource parameter is mandatory and must equal the advertised protected resource, and PKCE is not optional. Scope is the exception: with no --auth-scope configured, resolve_requested_scopes has nothing to check against and returns whatever the client asked for. Chio’s own request-time parameters are validated at the same point: authorization_details must contain at least one chio_governed_tool detail with non-empty locations and actions, any detail type outside the three Chio types is refused, no single detail may carry a sidecar belonging to another type (a governed-tool detail with commerce or metered-billing fields is rejected, and so is a commerce detail carrying metered billing), and a chio_transaction_context must carry a non-empty intent id and intent hash, with approval, runtime assurance, and call-chain fields each pulling in their own required companions. Attestation-bound senders are refused unless the evidence digest matches the transaction context and a DPoP key or mTLS thumbprint is also present.

The token endpoint enforces one-shot codes by removing the grant before checking anything, then checks expiry, client and redirect equality, S256, the PKCE verifier, and resource equality, and finally runs the same sender-constraint check with the code as the binding id. Codes live --auth-code-ttl-secs seconds (default 300) and the access tokens it signs live --auth-access-token-ttl-secs seconds (default 600). Token exchange accepts only an access token as the subject token type, verifies it was signed by this server’s own EdDSA key, requires the issuer to match, and refuses to widen scope beyond the subject token’s.

The consent page authenticates nobody

approve_authorization issues a code for self.subject, the fixed --auth-subject value that defaults to operator. There is no login, no session cookie, no client registration and no client allowlist: any client id is accepted, and anyone who can reach /oauth/authorize can press Approve and receive a token for that one subject. Nothing narrows who can reach it either. The localhost-only origin gate runs on /mcp and /admin/* and not on /oauth/*, and the per-IP limiter does not reach them either (Backpressure & Limits). Outstanding codes live in an in-memory map and are gone on restart. This is a development and single-operator convenience, not an identity provider. For anything with real users, run an external issuer and point --auth-jwt-discovery-url at it.

Guarantees and limits

StatusClaimEvidence
ShippedThree bearer modes, mutually exclusive at config time, verifying EdDSA, RS256/384/512, PS256/384/512, ES256, and ES384. A statically configured verification key accepts EdDSA only; the rest arrive through JWKS.build_remote_auth_mode, JwtSignatureAlgorithm::from_header, JwtResolvedJwkPublicKey::supports_alg
ShippedA JWKS key is selected by kid, and an untrusted kid is refused. A token with no kid is accepted only when exactly one configured key supports its algorithm.JwtJwksKeySet::resolve
ShippedEvery outbound identity-provider fetch, discovery, JWKS, and introspection, runs through a typed egress contract and fails closed without one.fetch_identity_provider_json, IntrospectionBearerVerifier::authenticate_token, remote_mcp_auth_egress_contract
LimitIntrospection trusts the endpoint more than JWT mode trusts a token. The issuer check runs only when the introspection response carries an iss claim, so a response that omits it is admitted against a configured issuer, and audience, scope, and cnf are whatever that endpoint returned.session_auth_context_from_introspection
Proved by testLoopback and link-local OIDC discovery and JWKS targets are denied before any connection is attempted. The in-crate test drives fetch_identity_provider_json itself; the two conformance tests call enforce_oidc_egress_contract, a thin exported wrapper over the same enforce_url_with_dns gate.oidc_fetch_rejects_special_use_address_contract; oidc_discovery_loopback_target_is_denied and jwks_link_local_target_is_denied in chio-conformance/tests/ssrf_oidc_jwks_loopback.rs
Proved by testA second factory cannot claim a sidecar the first still holds, and reclaims it once the first is dropped; the sidecar path may not alias any other configured database. Both tests run two factories inside one process, so cross-process fencing rests on the lock inode, not on these.remote_session_factory_holds_one_durable_admission_sidecar, remote_session_factory_rejects_admission_sidecar_aliases
BoundedHosted authorization with cnf and dedicated sessions is graded local-only: bounded request-time authorization and protected-resource admission, with no cross-node auth-code failover and no restart-safe replay guarantee.docs/standards/CHIO_BOUNDED_OPERATIONAL_PROFILE.md
LimitWith --auth-token and no --admin-token, the admin bearer is silently the session bearer, so every MCP client holds admin authority. JWT and introspection modes have no such fallback and refuse to boot without an explicit admin token.build_remote_auth_state; jwt_remote_auth_requires_separate_admin_token
Compatibility-onlyStatic bearer. It produces no protected-resource metadata, no authorization-server metadata, and a bare Bearer challenge; it carries no cnf, so no sender constraint can apply; and its session principal is a truncated hash of the shared token, identical for every client holding it.build_protected_resource_metadata, build_static_bearer_session_auth_context
Compatibility-onlyA random per-session subject. Without --identity-federation-seed-file and an authenticated OAuth principal, each session’s agent keypair is freshly generated, so capabilities bind to a key that exists only for that session and cannot be re-derived at restore.derive_session_agent_keypair, expected_resume_agent_id
Compatibility-onlyshared_hosted_owner. One wrapped subprocess is multiplexed across sessions; a polling thread drains its raw notifications every 25 ms and copies each batch into every live session tap; the ownership snapshot reports weak_shared_hosted_owner_compatibility; and the stored OAuth token fingerprint is dropped, which weakens per-request auth continuity to claim equality alone.RemoteHostedIsolationMode::snapshot_auth_context and identity_profile, fan_out_shared_upstream_notifications
UnsupportedTLS termination and client-certificate handling anywhere; on the local authorization server, refresh tokens, dynamic client registration, and revocation or introspection endpoints of its own.No certificate arguments on ServeHttp; the local discovery document lists two grants and no registration endpoint
LimitThe authorization-server document the edge synthesizes for an external issuer is not read from that issuer. It hardcodes authorization_code and refresh_token, none client auth, and S256, whatever the issuer actually supports, and copies only the endpoints and registration URI it was given or discovered.build_authorization_server_metadata, external-issuer branch
UnsupportedTwo edge processes serving one session database. On the local path, and durable admission is on by default (SideEffecting), the second process fails to construct its factory rather than sharing the file. That fence lives on the admission sidecar, not on the session database itself, so a pair started with --control-url, or under durable_admission_mode: off, gets no such refusal. Do not run two.DurableAdmissionRuntime::open through SqliteAuthorityStore::open_serving; DurableAdmissionMode default in chio-kernel/src/admission_operation/identity.rs

The runbook posture, in full

The release runbook states the recommended bounded hosted profile without hedging: prefer dedicated-per-session hosting, and require explicit sender-constrained access tokens with cnf where the hosted authorization path is part of the security boundary. It names four things as compatibility-only: --auth-token, non-cnf JWT and introspection tokens, random per-session subject fallback, and shared_hosted_owner. Shipping on any of them is a decision to run outside the recommended profile.

Next Steps

  • Session Lifecycle · the ledger, the lifecycle states, tombstones, and what a restored session must re-prove
  • Node Overview · the rung this page sits on, and the five obligations a node hands a cluster
  • Node State on Disk · the serving owner the admission sidecar claims, and the single-writer rule around it
  • Backpressure & Limits · the per-IP limiter in full, and the other bounds this listener inherits
  • Node Lifecycle · the drain deadline that bounds an open stream, and the hygiene defaults this edge keeps
  • Health & Readiness · what a process answers about itself, next to what /admin/health reports here
  • Core & Shell · what the per-session kernel refuses to do on its own
Remote MCP Edge · Chio Docs