Chio/Docs
LOGIN · JOIN

Remote MCP Edge

Serve a stdio MCP server to remote agents over HTTP, with a kernel per session and a signed receipt for each governed call.

Guide chio@5f63e6e

On this page

chio mcp serve-http lets remote agents call a stdio MCP server over HTTP, and the kernel admits each tool call before the server runs it. Every client session gets its own kernel, which signs a receipt for each governed call before the result returns.

Clients connect over MCP Streamable HTTP, so Claude Code, Codex, and other remote MCP clients connect as ordinary clients, with no plugin. The edge also serves an admin API, validates bearer tokens in one of several modes, and can act as its own authorization server.

Start

The kernel admits each call behind the HTTP door

A POST to /mcp passes the edge’s transport gates first: rate limit, origin, bearer, content negotiation, body size, session, protocol version, auth continuity, and lifecycle. The edge then resolves the tool against the session’s grant and hands the call to the kernel, which runs the wrapped server and signs the receipt.

Remote MCP edgeChoose an outcome.

κcapabilityσscopegguardsBbudget

Every check passes, so the kernel invokes the server

Rate limit, origin, bearer, session, and protocol-version gates run at /mcp before the edge accepts the call. The edge resolves the tool against the session’s grant and hands the request to the kernel, which invokes the tool server and signs the receipt before the result returns. The Mediated Call.

One MCP call over the remote edge’s /mcp route to the kernel and a tool server. The transport gates before the edge, session hosting, and output checks after dispatch are left out.

A transport gate answers with an HTTP status before the kernel sees the request, so those refusals leave no kernel receipt. tools/list returns the tools the session’s grant covers, and a listed tool carries no authority of its own: each tools/call is admitted on its own.

1. Start the edge with durable state

Point the edge at a policy, name the wrapped server, and give it an admission database and a receipt database. The command after -- must speak stdio MCP, and the policy’s scopes must name the same server id and the server’s tools.

Serve a filesystem server over HTTPtranscript
$ chio --session-db ./edge-session.db --receipt-db ./edge-receipts.db \
  mcp serve-http --policy ./fs-readonly-policy.yaml \
  --server-id srv-files --listen 127.0.0.1:8931 --auth-token demo-token \
  -- npx -y @modelcontextprotocol/server-filesystem ./workspace
remote MCP edge listening on http://127.0.0.1:8931/mcp
exit 0

You should see the edge listening on /mcp. Local durable admission owns budget, revocation, and operation state, so configure no separate budget or revocation database beside it. To keep that state in a trust control plane, pass --control-url instead; it excludes a local --receipt-db. The admin API example checks health on this same edge.

Durable admission is the default, and the edge refuses to start without its database:

Starting without --session-dbtranscript
$ chio mcp serve-http --policy ./fs-readonly-policy.yaml \
  --server-id srv-files --listen 127.0.0.1:8931 \
  --auth-token demo-token \
  -- npx -y @modelcontextprotocol/server-filesystem ./workspace
error[CHIO-CLI-ADMISSION-STORE-REQUIRED]: durable admission needs a session database so operations and tool outcomes survive a restart
  help: pass --session-db <path>
  code: urn:chio:error:cli:admission-store-required
  docs: https://chio.computer/docs/reference/errors#error-cli-admission-store-required
exit 1

2. Choose how clients authenticate

Select exactly one bearer mode. The edge refuses to boot with none, and with an incompatible pair.

ModeConfigureSender bindingMetadata served
JWT--auth-jwt-discovery-url or --auth-jwt-public-keycnf in the tokenProtected resource
Introspection--auth-introspection-urlcnf from the endpointProtected resource
Local server--auth-server-seed-fileDPoP or mTLS at issueResource and authorization server
Static bearer--auth-token or CHIO_AUTH_TOKENNoneNone; bare Bearer challenge

The recommended profile is sender-constrained tokens with dedicated sessions. A token’s cnf claim binds it to a Chio sender key proved with a DPoP header, an mTLS certificate thumbprint, or a runtime attestation digest, and the edge enforces each constraint present. Static bearer, tokens without cnf, a random per-session subject, and a shared wrapped server are compatibility modes outside that profile.

Give the admin API its own token in CHIO_ADMIN_TOKEN. JWT and introspection modes refuse to boot without one; static bearer mode reuses the client token, which gives every MCP client admin authority.

3. Put TLS in front of the listener

The listener serves plain HTTP. Bind it to loopback or a private interface, terminate TLS at a trusted proxy, and set the public URLs to what clients see.

Terminalbash
export CHIO_ADMIN_TOKEN="$(openssl rand -hex 32)"
chio --session-db ./edge-session.db --receipt-db ./edge-receipts.db \
  mcp serve-http --policy ./policy.yaml --server-id pricing \
  --listen 127.0.0.1:8931 \
  --auth-jwt-discovery-url https://id.larkfield.example/.well-known/openid-configuration \
  --auth-jwt-audience https://pricing.larkfield.example/mcp \
  --public-base-url https://pricing.larkfield.example \
  -- python mcp_server.py
Output
remote MCP edge listening on http://127.0.0.1:8931/mcp

You should see the same listening line; clients reach the edge at https://pricing.larkfield.example/mcp through the proxy. The proxy authenticates any certificate or attestation it asserts, overwrites client-supplied copies of x-chio-mtls-thumbprint-sha256 and x-chio-runtime-attestation-sha256, and is the only route to the listener.

The rate-limit key comes from the accepted socket, so behind a proxy every client shares one key. Size the limit for the proxy’s aggregate traffic, using Backpressure & Limits.

4. Call a tool through the edge

Point a client at the edge instead of the server, with CHIO_AUTH_TOKEN set to the bearer the edge accepts, and ask the pricing server for a quote on sku-118 at 10 percent.

client.tsclient.py
import { ChioClient } from "@chio-protocol/sdk";

const token = process.env.CHIO_AUTH_TOKEN!;
const client = ChioClient.withStaticBearer("http://127.0.0.1:8931", token);
const session = await client.initialize();
try {
  console.log(await session.callTool("quote", { sku: "sku-118", percent: 10 }));
} finally {
  await session.close();
}
Output
{
  content: [ { type: "text", text: '{"sku":"sku-118","percent":10,"price":21.6,"currency":"USD"}' } ],
  isError: false,
  _meta: { chioReceipt: { receiptId: "rcpt-01a8…9c04", requestId: "mcp-edge-req-…" } }
}

You should see the tool result with _meta.chioReceipt, which names the receipt the kernel signed for this call. A client without an SDK opens the session with an initialize POST and sends each later request with the returned MCP-Session-Id. A refusal at a transport gate returns its status, such as 401 for a bad bearer or 406 for a client that accepts only */*; the gates are listed in the reference below.

5. Read the receipt

The receipt is in the edge’s receipt database. List it with the same database path.

Terminalbash
chio --receipt-db ./edge-receipts.db receipt list --admin-all \
  | jq -c '{id, tool: .tool_name, server: .tool_server, verdict: .decision.verdict}'
Output
{"id":"rcpt-01a8…9c04","tool":"quote","server":"pricing","verdict":"allow"}

You should see the call’s receipt id, the tool, the server id, and the verdict. A refused call appears beside it with deny and the check that stopped it.

Each session runs its own kernel over shared admission state

Dedicated hosting is the default: every session owns a kernel worker and a wrapped tool subprocess, and the session ledger routes each request to its session’s worker.

Session hostingChoose a hosting mode.
serve-http processPOST /mcpstdioown handleshared stateaMCP clientℓsession ledgerKAsession AKBsession BBadmission runtimedAserver AdBserver Bρreceipt storeserve-http processPOST /mcpstdioaMCP clientℓsession ledgerKAsession AKBsession BBadmission runtimedAserver AdBserver Bρreceipt store

Each session runs its own kernel and its own server

Dedicated hosting is the default. The session ledger routes each request by its MCP-Session-Id to that session’s kernel worker, which owns one wrapped tool subprocess. Budget, revocation, and operation state come from one admission runtime the process opens once, so every session kernel reads the same state. Session Lifecycle.

One serving process with two sessions; the session count is illustrative. The broadcast channel between each kernel and its HTTP handlers, the notification window, and the per-session policy load are left out.

With local durable admission, one process-owned runtime supplies the budget, revocation, and operation handles every session kernel uses, so a hold or revocation recorded in one session is seen by all of them. Each session opens its own receipt-store handle on the configured file, so connection and writer costs grow with session count.

Responses route without loss; notifications replay from a window

A POST carries its request’s messages over SSE until the terminal response. A GET attaches a live notification stream, one per session, and a GET with Last-Event-ID replays from the last 64 retained notifications before continuing live. After a restart, a restored session keeps its subscriptions.

Responses reach their requests without loss. A notification consumer that falls more than 256 events behind logs the skipped count and continues, so it never hangs; a cursor the edge can no longer prove, including one from before a restart, is refused, and the client starts a fresh stream.

Remote edge reference

Startup order

serve_http_async runs these steps in order.

  1. Bind. The TCP listener binds first, because its resolved local_addr is the default public base URL, which defaults the issuer, the resource indicator, and every metadata URL.
  2. Discover the identity provider. A discovery URL, or a provider profile with an issuer, pulls the OIDC document. Its JWKS is resolved only when no static JWT key, local seed file, or introspection URL supplies keys. Both fetches go through the typed HttpEgressContract and fail closed without one. The CLI builds that contract from the configured auth URLs: the scheme and authority allowlists hold exactly those URLs; loopback, link-local, and IPv6 unique-local targets are denied unless a configured URL is one; redirects cap at three and responses at 1 MiB. With no auth URL configured, nothing is fetched and no contract is passed. Node Egress Contract documents the DNS re-check and per-hop redirect validation.
  3. Resolve the auth mode. build_remote_auth_mode rejects four combinations: static bearer with any OAuth flag; a local seed file with a static JWT key, external discovery, or introspection; an introspection client id and secret not supplied together; and introspection with --auth-jwt-public-key. Discovery plus introspection is accepted and introspection wins: the discovery document still shapes the issuer and metadata, no JWKS is resolved, and each token is validated by the introspection endpoint.
  4. Cross-validate metadata. When both documents exist, validate_chio_oauth_discovery_metadata_pair requires an identical chio_authorization_profile: the expected schema and id, capability-subject binding, the Chio DPoP proof type, and every remaining contract field (portable claim catalog, portable identity binding, governed authorization binding, request-time contract, resource binding, artifact boundary) equal to the binary’s default profile. The protected resource must 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 sends that state to a control plane. Local durable admission without --session-db fails boot with "durable admission mode requires a database so operations and tool outcomes survive restart". Persisted session rows are then loaded and restored one by one, and a row that fails restore is deleted.
  6. Wrap and serve. The rate-limit layer wraps the /mcp routes, admin, discovery, and OAuth routes are added on top, and shared hygiene wraps everything.
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.

The rest of ServeHygieneConfig::default() stands: a 25 second drain, 1024 concurrent requests shedding to 503, and 2048 simultaneously accepted connections, covered in Node Lifecycle. max_body_bytes defaults to None, so the 8 MiB ceiling belongs to the /mcp POST handler, and admin and OAuth form posts carry their axum extractor’s limit. The receipt commit actor acknowledges an append once its batch reaches the WAL, so a finished request’s receipt is already durable when draining begins.

Process state

RemoteAppState is cloned into every handler. Five of its eight fields are optional, and an absent one is a configuration.

  • factory: one RemoteSessionFactory per process, holding the config, the shared durable-admission runtime, the shared-upstream cache, and the lifecycle policy. Always present.
  • sessions: the RemoteSessionLedger of active sessions and terminal tombstones, persisted to SQLite. Always present; its database path is optional.
  • auth_mode: exactly one of StaticBearer, JwtBearer, IntrospectionBearer. A config naming none fails to boot.
  • admin_token: the bearer every /admin/* route compares in constant time. Boot fails without --admin-token unless --auth-token is set and reused.
  • protected_resource_metadata: the resource indicator, advertised authorization servers, scopes, and Chio’s authorization profile. Absent in static bearer mode, where /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp answer 404 and every 401 carries a bare Bearer challenge with no resource_metadata.
  • authorization_server_metadata: a discovery document and the one path, derived from the issuer, it is served from; any other path 404s, including the /.well-known/oauth-authorization-server/{*rest} wildcard. Absent in static bearer mode, for an external issuer that is not same-origin with the public base URL, and for an external issuer whose issuer, authorization endpoint, and token endpoint did not all resolve.
  • local_auth_server: the self-issued LocalAuthorizationServer. Absent without --auth-server-seed-file, when the three /oauth/* routes answer 404.
  • enterprise_provider_registry: issuer-to-provider records that attribute a bearer principal to an enterprise identity, from --enterprise-providers-file. Invalid records are logged at load and stay unavailable for admission.

Session workers and stores

spawn_session builds a whole kernel per session: it loads the policy, opens the receipt store, configures the capability authority, issues the 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 sends JSON into an mpsc channel and reads events from a 256-slot broadcast channel; a reader that lags gets RecvError::Lagged, logs the skipped count, and continues.

With durable admission and no --control-url, budget, revocation, and admission-operation state come from one DurableAdmissionRuntime opened 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 in Node State on Disk applies once, to the process. --control-url swaps in DurableAdmissionRuntime::open_remote, which provisions no local sidecar and still needs --session-db for its kernel seed. Under durable_admission_mode: off there is no runtime, and configure_revocation_store and configure_budget_store run per session, one handle each on the same file.

configure_receipt_store runs per session and opens its own SqliteReceiptStore on the same --receipt-db path, so a hundred sessions are a hundred reader pools and group-commit writers on one file, serialized by SQLite’s locking. Each open 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.

Request gates

A POST to /mcp runs these gates in order, and each returns before the next. 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 limit
Per-IP window on these routes
429 with Retry-After
Origin
localhost, 127.0.0.1, ::1, or no header
403
Bearer
Valid under the mode, sender constraints met
401 with WWW-Authenticate
Content negotiation
POST accepts JSON and event stream, sends JSON
406, or 415 on content type
Body size
8 MiB
413 as a JSON-RPC error
Session
MCP-Session-Id present and clean
400, or 404 when unknown
Protocol version
Matches the negotiated version
400
Auth continuity
Same authorization context as the session
403
Lifecycle
Session is Ready
409 initializing or draining; 410 deleted, expired, or closed

The limiter’s counts, key cap, and coverage are in Backpressure & Limits; mcp_rate_limit_key_ignores_unverified_client_headers pins the socket-derived key. exp and nbf are optional claims, so a token that omits exp is never expired by the edge. Both negotiation checks are literal substring tests, so a client sending only */* is refused. The body limit is checked against Content-Length and again while streaming.

MCP-Session-Id must be non-empty without leading or trailing whitespace, except on initialize, which must carry none. A request that omits MCP-Protocol-Version passes. Auth continuity compares transport, origin, and every field of the method, and the OAuth token fingerprint when the session stored one; it runs against terminal sessions too, so a client with the wrong token learns 403 rather than the session’s terminal state.

Sender constraints and DPoP

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 constraint present; a token with no cnf passes it untouched, which is why such tokens are compatibility-only. chioSenderKey is a hex Ed25519 public key, and the request carries a DPoP header holding a base64url-encoded JSON DpopProof. The proof reuses the kernel’s mediated-call DPoP body, with its fields 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(),
    );
}

On /mcp, expected_binding_id is the token’s jti, expected_target the protected resource indicator, and expected_method the request’s HTTP method; a DPoP-bound token with no jti is refused. At the local token endpoint the binding id is the authorization code, the target {issuer}/token, and the method POST. The action hash is the SHA-256 of the empty string in both, because the HTTP proof binds no request arguments.

The proof declares schema chio.dpop_proof.v1, carries an agent_key equal to the bound chioSenderKey and a non-empty nonce, is signed by that key over the canonical JSON of its body, sits inside the default 300-second TTL with at most 30 seconds of future skew, and wins a single-use insert into a 65,536-entry nonce store keyed by nonce and binding id. A replayed nonce is refused. The other two constraints are string equality: x-chio-mtls-thumbprint-sha256 against x5t#S256, x-chio-runtime-attestation-sha256 against chioAttestationSha256, with a missing header refused.

Event ids, cursors, and response modes

Client operationBehaviorBound
POST a request
SSE until the terminal response
POST requests serialized per session
GET
Live notification stream
One per session; a second gets 409
GET with Last-Event-ID
Replay, then live
64 retained notifications
Reconnect after restart
Subscriptions restored
Pre-restart cursors refused with 409

Each event gets the id {session_id}-{seq} from one monotonic counter and is classified once: a message with a method and no id is a notification, anything else is request-correlated. Only notifications enter the retained deque, bounded at DEFAULT_NOTIFICATION_REPLAY_WINDOW = 64.

The cursor is parsed strictly, and every failure is a 409: an id that does not split on its final hyphen, a session prefix belonging 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. A second GET stream gets 409, and a Drop guard releases the flag when a stream ends. should_emit_post_stream_event emits notifications on a POST response only while no GET stream is attached, so a client that never opens one still receives them, without a duplicate path.

A POST carrying a client notification drains events until 100 milliseconds pass with none, so a steadily emitting session can hold it open; a POST carrying a request sends notifications inline until the terminal response. An owned mutex serializes the POST request stream. POST responses open with a priming event carrying a 1000 millisecond reconnect hint; GET streams send none. x-chio-mcp-response-mode names the branch: initialize_sse, post_request_sse, post_notification_sse, post_notification_accepted, get_sse_live, get_sse_replay.

Local authorization server

--auth-server-seed-file makes the edge its own OAuth 2.0 authorization server: GET and POST /oauth/authorize, POST /oauth/token, GET /oauth/jwks.json, and a discovery document advertising 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. It issues no refresh tokens and has no client registration, revocation, or introspection endpoint.

An authorization request needs response type code, a non-empty client id, an https or loopback http redirect URI, a resource equal to the advertised protected resource, and PKCE. With no --auth-scope configured, resolve_requested_scopes returns the requested scopes. authorization_details must hold at least one chio_governed_tool detail with non-empty locations and actions; detail types outside the three Chio types are refused, and so is a detail carrying another type’s sidecar (a governed-tool detail with commerce or metered-billing fields, or a commerce detail with metered billing). A chio_transaction_context needs a non-empty intent id and intent hash, and its approval, runtime assurance, and call-chain fields each require their companions. An attestation-bound sender needs an evidence digest matching the transaction context plus a DPoP key or mTLS thumbprint.

The token endpoint removes the grant before checking anything, so codes are one-shot, then checks expiry, client and redirect equality, S256, the PKCE verifier, and resource equality, and runs the sender-constraint check with the code as the binding id. Codes live --auth-code-ttl-secs seconds (default 300) and access tokens --auth-access-token-ttl-secs (default 600). Token exchange accepts only an access token as subject, signed by this server’s EdDSA key with a matching issuer, and never widens scope beyond it.

approve_authorization issues a code for the fixed --auth-subject, default operator, to any client id, and the approval page authenticates no one. The origin gate and the per-IP limiter cover /mcp and /admin/*, and leave /oauth/* open to anyone who can reach it. Outstanding codes and replay state live in process memory, serve one node, and are gone on restart. Run the local server for development or a single-operator deployment, and point --auth-jwt-discovery-url at an external issuer for user identities.

Token verification

  • The three bearer modes are mutually exclusive at config time and verify EdDSA, RS256/384/512, PS256/384/512, ES256, and ES384. A statically configured key accepts EdDSA; the rest arrive through JWKS (JwtSignatureAlgorithm::from_header, JwtResolvedJwkPublicKey::supports_alg).
  • A JWKS key is selected by kid, and an untrusted kid is refused. A token without kid is accepted only when exactly one configured key supports its algorithm (JwtJwksKeySet::resolve).
  • Discovery, JWKS, and introspection fetches all run through a typed egress contract and fail closed without one (fetch_identity_provider_json, IntrospectionBearerVerifier::authenticate_token, remote_mcp_auth_egress_contract).
  • Introspection trusts its endpoint: the issuer check runs only when the response carries iss, and audience, scope, and cnf are what the endpoint returned (session_auth_context_from_introspection).
  • For an external issuer, the edge synthesizes the authorization-server document: it lists authorization_code and refresh_token, none client authentication, and S256, and copies only the endpoints and registration URI it was given or discovered (build_authorization_server_metadata).

Compatibility modes and the serving fence

  • Static bearer produces no protected-resource or authorization-server metadata and a bare Bearer challenge, carries no cnf, and derives the session principal from a truncated hash of the shared token, identical for every holder (build_protected_resource_metadata, build_static_bearer_session_auth_context).
  • Without --identity-federation-seed-file and an authenticated OAuth principal, each session’s agent keypair is freshly generated, so its capabilities bind to a key that cannot be re-derived at restore (derive_session_agent_keypair, expected_resume_agent_id).
  • --shared-hosted-owner (shared_hosted_owner) multiplexes one wrapped subprocess across sessions; a polling thread drains its notifications every 25 ms into every live session tap, the ownership snapshot reports weak_shared_hosted_owner_compatibility, and auth continuity drops the stored token fingerprint (RemoteHostedIsolationMode::snapshot_auth_context, identity_profile, fan_out_shared_upstream_notifications).
  • Run one edge process per session database. On the local path, where durable admission is on by default (SideEffecting), a second process fails to construct its factory. The fence lives on the admission sidecar (DurableAdmissionRuntime::open through SqliteAuthorityStore::open_serving), so a pair started with --control-url or under durable_admission_mode: off meets no such refusal.

Egress, fence, and admin-token tests

  • oidc_fetch_rejects_special_use_address_contract drives fetch_identity_provider_json; oidc_discovery_loopback_target_is_denied and jwks_link_local_target_is_denied in chio-conformance/tests/ssrf_oidc_jwks_loopback.rs call enforce_oidc_egress_contract, a wrapper over the same enforce_url_with_dns gate. Loopback and link-local targets are denied before any connection.
  • remote_session_factory_holds_one_durable_admission_sidecar and remote_session_factory_rejects_admission_sidecar_aliases: a second factory cannot claim a held sidecar and reclaims it once the first is dropped, and the sidecar path may not alias another configured database. Both run two factories in one process; cross-process fencing rests on the lock inode.
  • jwt_remote_auth_requires_separate_admin_token: JWT mode refuses to boot without an explicit admin token (build_remote_auth_state).

Next: operate the edge