Skip to content

MCP ingress: authenticated channel adapter #4 ​

The core is channel-agnostic: a channel is only a transport that turns an inbound request into an IncomingMessage, calls the core dispatch(), and provides a ChannelIO to reply through. Slack (#1), the CLI (#2), and HTTP ingress (#3) already prove this; MCP is adapter #4 — an MCP client drives Switchboard exactly like the HTTP ingress does, so the same request reaches the same agents under the same permissions. The HTTP ingress came first; MCP reuses its shape.

The maintained official MCP SDK handles modern and legacy Streamable HTTP. Authentication and body limits run before protocol handling. Clients declaring form elicitation receive confirmation inside their MCP host. Clients without form support use URL elicitation when advertised, or a saved browser link when the authenticated browser approval route is configured. Without a usable display path they cannot mint fresh consent. Browser connection setup remains separate. Personal credentials live in the existing digest store. No parallel session or approval store is introduced.

  • Code: src/core/dispatch/admission.ts, src/core/dispatch/originalOrganizationInput.ts, src/load/candidateDoorSmoke.ts, src/core/dispatch/requestOnlyReview.ts, src/core/organization/admissionTrace.ts, src/core/personalToken.ts, src/core/confirmationConsent.ts (shared recorded-consent predicate); src/channels/organizationAuthentication.ts, src/channels/organizationNativeSources.ts, src/channels/browserHomeWiring.ts; src/channels/mcpApproval.ts (credential-bound continuation), src/channels/mcpApprovalView.ts (authenticated browser consent), src/channels/mcp.ts (handleMcpRequest transport gating + JSON-RPC routing, McpIO single-shot ChannelIO, createMcpHandler node:http wrapper; auth reused from src/channels/http.ts — authenticate, readBody, MAX_BODY_BYTES, IngressConfig, IngressIdentity, DispatchFn); src/channels/singleShotDispatch.ts (shared HTTP/MCP job lifecycle); src/channels/personalMcpSetup.ts (browser approval); src/mcp/personalTokens.ts and src/core/personalToken.ts (digest store); src/setup/hostedClient.ts (local connector and stdio proxy); src/index.ts (wires both routes); deploy/cloudflare-memory/worker.ts (durable credentials).
  • Tests: src/core/dispatch/originalOrganizationInput.test.ts, src/core/organization/contract.test.ts, src/load/candidateDoorSmoke.test.ts, src/core/dispatch/requestOnlyReview.test.ts, src/channels/browserHomeInstallation.test.ts, src/channels/mcpApprovalView.test.ts, src/channels/mcpAdmission.test.ts, src/channels/organizationAuthentication.test.ts, src/channels/mcpRealm.test.ts, src/channels/mcpSso.test.ts, src/channels/mcpApprovals.test.ts, src/channels/mcp.test.ts, src/channels/personalMcpSetup.test.ts, src/mcp/personalTokens.test.ts, src/setup/hostedClient.test.ts, deploy/cloudflare-memory/config.test.ts.
  • Docs: AGENTS.md invariants 1, 2, 3, 4, HTTP ingress, Use an existing deployment.

Behavior ​

Hosted Access device approval may pin the Access issuer, app audience, fixed organization and exact subject as version 1 public metadata from server configuration. No JWT or grant snapshot is stored. Normalization does not fill missing realm proof; an occupied digest cannot acquire or change it, so old or changed-realm devices use the visible revoke/reissue path.

The composed request thread key must fit the ledger's 256-character claim key after the five-character #host suffix. A longer channel/thread pair is refused before dispatch.

  1. Pure transport, same core. A tools/call for the dispatch tool becomes an IncomingMessage and is handed to the unchanged dispatch(deps, msg, io) — no dispatcher, runner, or permission change. For machine credentials, the tool requires the configured dispatch grant (an unlisted credential holds nothing): a registry-only token (e.g. ["runs:read"]) gets JSON-RPC -32001 with data.code:"unauthorized" and dispatch() is never called, while its registry tools keep working. Identities are platform-namespaced (invariant 4), mirroring HTTP but with the mcp: prefix: userId = mcp:<subject>, channelId = mcp:<channel|default>, threadKey = mcp:<channel>:<thread|default>, messageId = a fresh mcp:-prefixed UUID (40 characters) per admitted dispatch. The message id is retained by the in-flight async dispatch and reaches the original-unit recovery caller with the user and thread identities; it is not derived from mutable text/thread or the reusable JSON-RPC id. A repeated call, even with identical arguments and JSON-RPC id after a lost response, receives a new message id: ingress does not deduplicate or replay the first recovery action. Confirm the first run's outcome before any intentional new attempt; this identity prerequisite alone does not make lost-response retry safe. Before either key is composed, every supplied channel and thread is validated as its own component: it must be a string, non-empty, at most 128 characters, and contain neither : nor #; the identity-pinned channel is checked by the same rule, and the call's channel is still checked before a valid pin overrides it. A refusal is JSON-RPC -32602 and nothing is dispatched, so two component pairs cannot collapse to one key and a caller cannot forge the reserved #host suffix.

  2. SDK methods. The maintained MCP server SDK owns framing and negotiation. Legacy requests use its stateless JSON transport; current requests use its per-request handler. initialize returns the protocol version, capabilities.tools, and serverInfo. tools/list advertises the hand-written dispatch tool — description "send a request to Switchboard", inputSchema { text: string (required), thread?: string, channel?: string, async?: boolean } — FIRST, followed by every command registry command exposed to MCP (runs_list, runs_get, runs_events, runs_friction, runs_stop, steer_run, friction_report, friction_propose, repo_list; name = <group>_<verb>, inputSchema derived from the command's zod input, annotations the definition's blast radius in MCP's own ToolAnnotations names — command-registry.md item 29: readOnlyHint when the effect is read, destructiveHint when blastRadius(def) is destructive (false for a read and for an exec-class write such as repo_test, whatever they declare), idempotentHint and openWorldHint as the definition declares them, each false unless it says otherwise, because MCP presumes a non-read-only tool destructive when the hint is absent; the shape is the bot's own client type McpToolInfo.annotations, and the hand-written dispatch tool carries none). The channel also advertises approval_resume and approval_cancel, which return a saved offer to the shared core click path; neither accepts changed arguments or a confirmation boolean. tools/call for dispatch runs the request and returns { content: [{ type: "text", text: <reply> }] }. A JSON-RPC notification (no id, e.g. notifications/initialized) is accepted and never answered (HTTP 202, empty body). Registry tools. tools/call on a registry name invokes the command with caller { kind:"mcp", id:"mcp:<subject>", actor: <the service Actor whose grants come from config>, channel?: "mcp:<pinned channel>" } — the raw arguments go to invoke unchanged; authorization (the policy table: an unlinked dispatch-only credential holds no registry command; a server-verified Slack or Access account with dispatch may cancel only its own run after exact destructive consent, while foreign targets and read-only credentials remain refused) and input validation live in the registry, not here. Success → { content: [{ type:"text", text: "<group>.<verb>: ok\n" + JSON.stringify(output) }] } (the JSON is byte-identical to the HTTP and CLI surfaces); failure → a JSON-RPC error with data.code ∈ unauthorized|invalid_input|not_found|conflict|internal (codes -32001, -32602, -32002, -32003, -32603). No output ever contains a run's capability token.

  3. Single-shot ChannelIO. McpIO.reply() collects text and the collected text is the tool result content; status() is an honest no-op handle (no live surface to edit in one shot); openThread() opens a stable job thread for a coordinator unit; history() is [] (a tool call carries no prior turns — conversation state, if any, rides on threadKey).

  4. Bearer auth, fail-closed. authenticate(headers, config) requires Authorization: Bearer <token>; an operator token maps through SWITCHBOARD_INGRESS_TOKENS. If it does not match, a 64-hex personal bearer is hashed and looked up in the personal store before the body is read. Unknown or revoked tokens return 401; a store failure returns 503. With no token path or SSO verifier configured the endpoint returns 503. The mapped subject becomes the credential id, so a token can only act as its assigned identity and existing gates apply unchanged. Operator tokens may pin a channel. No bearer is logged or stored in the personal store.

  5. JSON-RPC framing. Malformed JSON → -32700 (id null); a non-object / array / method-less message → -32600; an unknown method → -32601; an unknown tool or a missing/blank/non-string text argument → -32602. Post-auth JSON-RPC responses carry HTTP 200; transport gating uses 405 (non-POST) / 401 / 403 (cross-site SSO) / 503.

  6. Input hardening. The body is size-capped at read time (~1 MB via http.ts's readBody, 413 before it is fully buffered, request destroyed). Malformed env token config is treated as "no tokens" (disabled), never as open (proven by http.ts's parseIngressTokens tests, reused unchanged).

  7. Wiring keeps health + HTTP ingress working. The PORT server routes /mcp to the MCP handler, /ingress to the HTTP handler, and retains the existing health probe alongside the other HTTP routes. Static SWITCHBOARD_INGRESS_TOKENS credentials work on both routes; with none configured the startup log says static ingress tokens absent, and HTTP ingress returns 503. The log separately says personal MCP approval is available with grant when Access is configured (otherwise unavailable without Access). An already approved personal bearer can still authenticate on /mcp with its grant when static ingress tokens are absent; HTTP ingress never accepts personal bearers.

  8. Verified requester and bounded authority. A server-verified personal connection names one immutable user, selected at browser approval: the exact authenticated Access account. A matching email cannot select a Slack identity. The mcp: credential remains authenticatedAs. The same policy table must admit both the user and the delegated credential. This preserves native conditional personal rights without lending a read-only connection write authority. Code access, repo-memory ownership, private visibility and author bindings retain their separate checks. A configured email link that cannot be verified is refused; it never falls back to broader machine rights. Unlinked machine credentials retain their explicit grants. The personal: subject family authenticates only through the durable digest store, not a static token claiming that prefix.

  9. Job-shaped dispatch. HTTP and MCP call the same dispatchSingleShot lifecycle. dispatch with async: true validates the same text, identity and grants, starts the normal core run and returns an MCP tool result with structuredContent: { runId, runUrl, threadKey } as soon as runStarted fires. The tool result's text points to runs_get; runs_events reads progress. A request with no run returns its ordinary answer. Without async, the reply remains synchronous. Both adapters give Ship a job thread capability: a coordinator unit supplies its stable instance/unit key to openThread, which derives the same child thread key across retries and bot restarts. Unrelated children get distinct keys. Replies after the one-shot request are recorded on the run; a person with dashboard access can use the run page. To observe through MCP, grant the credential runs:read and its machine channel in addition to dispatch; to steer by run id, grant steer:write (the named run's owner rule and live agent allowlist still apply); restricted agents and repos need their own grants. Ship's coordinator and run history must be enabled independently of ingress.

  10. Personal setup. switchboard connect <host> assumes HTTPS; connect --url <origin> remains valid. It creates a 256-bit bearer locally and opens /settings/connect?challenge=<sha256>; the URL contains no bearer. The dashboard gate verifies the signed-in person and email. The page shows a short code derived from the digest; a same-origin POST approves the digest in the ConfigDO (or a mode-600 file for a local installation). A single mcp:personal:* grant supplies the team's policy without adding each person to config or the static 1Password map. The CLI polls initialize, calls runs_list as a real permission check, then writes the bearer to its mode-600 client profile and adds a stdio MCP proxy to Codex config. The stored user id is the exact authenticated Access subject, never client supplied or selected by email. Repeated approval preserves the original account, creation time and metadata. A digest already bound to another account or an explicit legacy Slack identity cannot be rewritten; a new connection is required. Protected legacy records without a user id normalize only to the Access account named by their immutable personal subject. Explicit Slack or mismatched Access records cannot authenticate; they remain visible for owner revocation with a reissue notice. The state Worker stores the user id and checks digest collisions atomically. Revocation deletes the digest immediately. The stdio proxy reads the private profile and forwards JSON-RPC envelopes; Codex needs no bearer environment variable.

  11. Same SSO as the browser. The MCP handler receives the existing DashboardVerifier. After static and personal bearer checks, the access strategy verifies the edge's signed assertion; the token strategy verifies its configured credential. A named session resolves through the same callerFor and resolveAccessActor as the browser: access:<sub> grants, verified Slack link and channel membership. No mcp:personal:* grant or new token store is needed for SSO. SSO dispatch reaches the core agent and repository gates just like browser chat; only machine credentials need the transport-level dispatch action. MCP uses one case-insensitive, whitespace-normalizing bearer parser for personal-token classification, digest lookup and saved-confirmation connection binding. A stored personal bearer keeps its own identity and delegated limits even beside an SSO credential; revocation and store failure cannot borrow an Access assertion. A 64-hex bearer absent from the personal store can authenticate only if the token strategy proves that exact configured dashboard credential. Missing, forged, expired, wrong-issuer or wrong-audience assertions return 401; verifier faults return 503 and log their cause with presented credentials and known secret formats redacted. Service assertions and none do not authenticate an MCP browser session. The existing browser origin guard rejects cross-site session requests with 403 before body read. Static and personal bearers retain their existing policy. Saved SSO forms bind to the OAuth bearer, or the verifier's signed assertion header when no bearer exists; resume re-verifies the credential before consuming the saved action. Cloudflare Managed OAuth on the existing browser Access application handles discovery, browser sign-in and refresh outside Switchboard. Other SSO providers use the same verifier interface.

Approval flow ​

Both prose/typed dispatch and direct command tools reach the registry's confirmation policy. MCP annotations are client hints, not permission or consent. A model-supplied confirm: true or a bare elicitation acceptance cannot approve an operation. A schema-valid host form response is accepted only for a server-minted form challenge bound to the exact saved action and originating credential. The host owns its human interaction; form acceptance is not independent proof of a physical human click.

On a current client declaring form elicitation, the official SDK returns an input-required result with an opaque saved-offer id, exact action and risk, and an approve/cancel choice. The host returns the user's choice outside tool arguments. The server validates the response schema, requester, credential and original tool/argument hash, then records transport-neutral consent in the existing row. Atomic consumption executes the stored action once through the shared click path. Decline or cancel removes the offer without execution.

approval_resume can request the same form for a pending saved action; calling it alone cannot create consent. Already-approved exact rows resume directly without another form or browser, including legacy browser rows. A client without form support receives URL elicitation when advertised, otherwise an ordinary result containing the saved browser link, offer id and expiry. Both require the configured authenticated browser route, a verified requester and an available confirmation store. Opening the URL or accepting a URL prompt is not consent. The browser POST records consent only; the original MCP connection resumes the stored action. The saved browser/form mode cannot change when client capabilities change. Without a usable display path or verified requester, the request is refused before minting an offer. An SSO browser can approve only a browser-bound row whose saved credential and authenticated actor equal its verified access:<sub> and whose person matches the saved requester. Browser authentication does not attest the original OAuth credential: the original MCP resume re-verifies its own credential and exact connection digest before execution. Static and personal browser approvals retain their credential lookup and revocation checks. Expiry, cancellation, requester/connection mismatch, changed request arguments, lost permission and duplicate confirmation cannot execute the action. The store survives restart. After an uncertain result, the id is not replayable: inspect the original run history before requesting another action.

The State Worker must expose the confirmation get and atomic approve routes before the new bot accepts offers. These extend the existing table and execution path. Source, CI/review, deployment and live acceptance remain separate stages.

Validation criteria ​

CriterionEvidence
The production wrapper passes the native admission trace through unchanged. Unavailable and exception responses retain the observed inner stage and exclude private error text; observer failure preserves the original result. The adapter never retries the admission.[unit] src/channels/mcp.test.ts::native admission through the production process wrapper::preserves the original %s result and diagnostic without replay
SSO uses the browser's identity and grants, refuses invalid assertions and does not require a separate MCP credential; the existing non-Cloudflare verifier uses the same seam[unit] src/channels/mcpSso.test.ts::MCP SSO::*
SSO authentication precedes request body reads, and the existing browser origin guard refuses cross-site session requests[unit] src/channels/mcp.test.ts::createMcpHandler (node:http wrapper)::authenticates SSO before body read and dispatches with the browser account, src/channels/mcpSso.test.ts::MCP SSO::uses the browser origin guard to refuse cross-site SSO requests
Personal bearer authority, revocation and store failure cannot borrow an Access assertion; ordinary browser agent grants can reach core dispatch[unit] src/channels/mcpSso.test.ts::MCP SSO::keeps a personal bearer…, ::dispatches an ordinary browser account through its actual agent baseline
Bearer spelling changes retain personal limits, read identity, revocation and store-failure refusal on both SSO paths, and preserve the same SSO confirmation connection[unit] src/channels/mcpSso.test.ts::MCP SSO::keeps a personal bearer…, src/channels/mcpApprovals.test.ts::MCP approval parity::SSO confirmation normalizes the same credential across header spelling…
A configured 64-hex dashboard bearer works through its token verifier; a stored personal identity takes precedence and another bearer cannot borrow an Access assertion[unit] src/channels/mcpSso.test.ts::MCP SSO::accepts a configured 64-hex dashboard token while preserving stored personal limits
The Node adapter preserves personal identity and refuses revoked bearers before body reads; personal saved actions retain their stored requester across header spellings[unit] src/channels/mcp.test.ts::createMcpHandler (node:http wrapper)::keeps personal identity and pre-body refusal across bearer spellings beside SSO, src/channels/mcpApprovals.test.ts::MCP approval parity::personal confirmation retains stored identity across scheme…
A verifier fault returns 503 and logs a bounded cause without bearer or signed-assertion contents[unit] src/channels/mcpSso.test.ts::MCP SSO::logs a verifier fault with its cause while redacting credentials
An SSO confirmation is bound to its authenticated connection, rechecks its credential and executes only once[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::SSO form confirmation stays bound to its authenticated connection and executes once
Native MCP SSO works through the existing browser Access application without ingress-token setup[agent] Deploy the bot change; add /mcp to the browser Access application with the same AUD and policies; enable Managed OAuth with the client's required callbacks; connect a remote HTTP MCP client to /mcp, complete browser sign-in, then read a permitted registry tool and confirm the same account's run visibility. An unauthenticated origin request must be refused.
URL-capable clients use the existing signed-in browser approval; URL acceptance alone cannot consent; unsupported clients receive the same saved link until approval[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::URL elicitation reuses browser consent and resumes the saved action once, ::a client without elicitation receives the same saved browser link until consent, ::a browser-bound offer cannot switch to form consent or change its saved arguments
SSO browser approval records consent for the same subject and person; original credential revocation and current permissions remain mandatory on native resume[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::SSO browser consent…, ::personal browser consent retains token revocation and refuses an unusable browser URL
Ordinary personal MCP confirmation uses a client form without a browser; accepted exact saved action executes once and replay refuses[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::client form confirmation executes the saved action once without a browser
Current SDK form elicitation validates the saved action and choice; direct HTTP, retained browser rows, own settings, revocation, cancellation, expiry, connection mismatch, restart and duplicate execution use the same core[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::*
Browser approval preserves its own form POST origin while refusing foreign, opaque, malformed or missing origins and changed saved-action arguments; approval still executes only through one saved-id continuation.[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::the rendered approval form preserves its origin and approves the saved action once, ::browser approval refuses a foreign, opaque, malformed or missing origin…, ::browser approval cannot add arguments to its saved action…
A linked MCP credential is bounded by the verified person and connection grants; destructive direct commands require recorded consent through the same core[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::*
A composed request thread key exceeding the hosted-run ledger limit is refused before dispatch[unit] src/channels/mcp.test.ts::handleMcpRequest — tools/call::rejects channel and thread pairs whose hosted run key exceeds the ledger limit
Item 8: a bound token's dispatch is the person's request with authenticatedAs the mcp: credential; an unfound declared person is refused[unit] ::handleMcpRequest — tools/call::a token bound to a verified person sends as that person; an unfound declared person is refused
initialize returns protocol version, capabilities.tools, and serverInfo[unit] src/channels/mcp.test.ts::handleMcpRequest — initialize::returns the protocol version, tools capability, and server info
tools/list with no registry wired advertises dispatch and approval controls with a text/thread/channel/async input schema (text required)[unit] ::handleMcpRequest — tools/list::advertises dispatch and approval controls with strict schemas
steer_run is a registry tool for a steer:write credential; a verified email link lets the owner rule recognize the person while the MCP credential keeps its own grants and rides the durable fold[unit] src/channels/mcp.test.ts::handleMcpRequest — registry commands as tools::steer_run is exposed and passes the bound person's credential to the shared sender, src/channels/mcp.test.ts::toCaller — the Caller a tool call runs as carries the mcp: Actor::a bound person is linked for the steer owner rule without lending the credential their grants, src/core/dispatch/admission.test.ts::createSteerSender — the wired sender behind…::*
With a registry wired, tools/list = dispatch first + every MCP-exposed command with a zod-derived inputSchema; mcp:false commands hidden; tools/call on a registry name returns header + exact invoke JSON (no token), maps errors to data.code, refuses unlinked dispatch-only tokens, records the originating mcp:<subject> credential and MCP surface as the stop actor, honors the pinned channel; dispatch unchanged[unit] src/channels/mcp.test.ts::handleMcpRequest — registry commands as tools::*; the cross-surface contract in src/channels/commandContract.test.ts::adapter contract — mcp (see command-registry.md)
Item 2, the hints: every registry tool on tools/list carries the four ToolAnnotations — a read readOnlyHint, an exec-class write and a read destructiveHint false, a reversible write destructiveHint false (idempotentHint when the definition says so), a destructive write destructiveHint true (openWorldHint when it reaches outside); the hand-written dispatch tool carries none[unit] src/channels/mcp.test.ts::handleMcpRequest — registry commands as tools::tools/list carries each command's blast radius in MCP's own hint names…
tools/call builds the mcp:-namespaced IncomingMessage and returns the reply as { content: [{type:"text", text}] }[unit] ::handleMcpRequest — tools/call::builds the mcp:-namespaced IncomingMessage and returns the reply as tool content
An async dispatch forwards a bounded, platform-namespaced message id with the user and thread to the original-unit recovery caller; without an explicit retry key, repeating identical text, thread and JSON-RPC id after a lost response makes a distinct call, not a replay of that action[unit] src/channels/mcp.test.ts::handleMcpRequest — tools/call::passes a bounded unique message identity to the recovery caller across async retries
Item 9: async Ship dispatch returns the run handle before completion; a no-run answer retains its normal result; a non-boolean flag is refused; MCP and HTTP job handles pass Ship's shared preflight and both wire adapters hand an authenticated request to the runner; coordinator unit keys survive a rebuilt handle without aliasing another unit[unit] src/channels/mcp.test.ts::handleMcpRequest — tools/call::acknowledges a Ship run by id while dispatch continues, then exposes its final receipt, ::an async no-run answer returns the normal tool result, ::rejects a non-boolean async flag before dispatch; src/core/dispatcher.test.ts::agent:ship (the hand-off to the plan runner)::HTTP and MCP job handles admit the same Ship hand-off as chat, ::the HTTP and MCP wire adapters both hand an authenticated Ship request to the runner; src/core/nullChannelIo.test.ts::nullChannelIO::opens a stable, separate job thread for each coordinator unit across rebuilt handles; src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::unit-start opens a plan unit's thread…
Namespacing: channel/thread defaults; a token-pinned channel overrides the call argument only after the argument component and the pin independently pass the component rule (string, non-empty, at most 128 characters, no : or #); invalid components are -32602 before dispatch, distinct pairs cannot collapse to one key, a thread cannot forge #host, and history is [][unit] src/channels/mcp.test.ts::handleMcpRequest — tools/call::defaults channel/thread when the arguments omit them, ::a token-pinned channel overrides the arguments' channel, ::rejects bad channel/thread types, empty values, delimiters, and overlong values at the MCP boundary, ::rejects ambiguous pairs from distinct pinned credentials and cannot forge a #host thread, ::rejects empty and overlong identity-pinned channels before composing a thread key, ::history is empty for a single-shot MCP tool call
A credential without the dispatch grant cannot call dispatch (-32001, data.code:"unauthorized", dispatch never called) but still calls the registry tools it is scoped for[unit] ::handleMcpRequest — registry commands as tools::a token without the dispatch scope (runs:read only) cannot call dispatch…
Unknown tool name / missing-blank-nonstring text → -32602, dispatch never called[unit] ::handleMcpRequest — tools/call::rejects an unknown tool name with -32602, dispatch never called, ::rejects missing/blank/non-string text with -32602, dispatch never called
Fail-closed: no tokens configured → 503 disabled, dispatch never called[unit] ::handleMcpRequest — auth (fail-closed)::no tokens configured → 503 disabled, dispatch never called
Personal bearers authenticate by digest before body read and stop on revocation; the shared grant does not widen operator tokens[unit] src/channels/mcp.test.ts::handleMcpRequest — auth (fail-closed)::a dashboard-approved personal bearer authenticates by digest and revocation takes effect immediately, ::createMcpHandler (node:http wrapper)::authenticates a personal bearer before reading the request body, src/core/authz/grants.test.ts::parseGrantsConfig — the native \grants` block::mcp:personal:* grants browser-approved credentials without widening operator-created MCP tokens`
Browser approval stores only digest metadata and the exact authenticated Access subject; email is display data. Same-origin form approval rejects absent or literal null Origin, preserves immutable owner/approval metadata, and offers revoke/reissue for legacy email-linked devices[unit] src/channels/personalMcpSetup.test.ts::personal MCP setup::*, src/mcp/personalTokens.test.ts::personal MCP token stores::*, deploy/cloudflare-memory/config.test.ts::ConfigDO personal MCP tokens::*
The hosted connector verifies a real read, writes a private local profile and Codex stdio config, replaces Switchboard's plain, quoted-parent, child, inline/dotted, and document-root inline TOML forms (including old credentials) while preserving unrelated Codex servers and valid TOML[unit] src/setup/hostedClient.test.ts::hosted client setup::preserves unrelated Codex config and replaces only the Switchboard MCP section, ::removes old Switchboard child tables even across unrelated Codex servers, ::replaces quoted-parent Switchboard tables and children without touching other servers, ::replaces an inline Switchboard server under mcp_servers while preserving siblings, ::replaces a document-root inline server without retaining credentials or freezing sibling tables, ::opens an empty root inline parent before adding the Codex server, ::replaces quoted and dotted server assignments without removing similarly named servers, ::waits for browser approval, verifies a real read, and stores the bearer only in a private local file
Missing/unknown token → 401, dispatch never called[unit] ::handleMcpRequest — auth (fail-closed)::missing token → 401, dispatch never called, ::unknown token → 401, dispatch never called
Unknown method → -32601; malformed JSON → -32700 (null id); non-object/array/method-less → -32600; non-POST → 405; notification → 202 no body[unit] ::handleMcpRequest — JSON-RPC framing errors::*
Strict JSON-RPC: missing/non-"2.0" jsonrpc → -32600; a malformed id (object/boolean) → -32600 (not coerced)[unit] ::handleMcpRequest — JSON-RPC framing errors::a missing or non-"2.0" jsonrpc field → -32600, ::a malformed id (object/boolean) → -32600, not silently coerced to null
Auth precedes body-buffering (shared authorizeRequest with HTTP): an unauthorized request is rejected from headers without reading the body[unit] ::createMcpHandler (node:http wrapper)::rejects an unauthorized request without reading the body (pre-auth)
McpIO: reply collection/join, no-op status, empty history[unit] ::McpIO (single-shot ChannelIO)::collects replies and joins them; status is a no-op; history is empty
node:http wrapper reads body, routes tools/call, writes 200 JSON-RPC; writes empty 202 for a notification; answers 413 + destroys request over the size cap[unit] ::createMcpHandler (node:http wrapper)::*
Live: an authed POST /mcp initialize + tools/call reaches an agent end-to-end; unauthenticated is refused[agent] Requires the bot deployed with SWITCHBOARD_INGRESS_TOKENS set and PORT exposed: POST /mcp with a configured bearer — initialize → 200 with serverInfo, then tools/call dispatch → 200 with the reply as tool content; the same request without the header → 401.
An MCP client discovers and completes standards-based OAuth directly against a deployment's chosen identity provider[gap] The browser-approved bearer flow needs the Switchboard connector; /mcp has no OAuth discovery or direct client authorization flow.
CriterionProof
Approved MCP stop retains its originating surface and credential; legacy personal connections without Slack retain verified Access identity and revocation[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::an approved MCP stop retains the original credential and surface in its stored actor, ::a legacy personal connection without Slack still reads as its verified Access user

| SQLite records only a matching form challenge and preserves one-use consumption and old browser rows | [unit] deploy/cloudflare-memory/config.test.ts::ConfigDO confirmations (docs/reference/specs/routing-and-config.md item 25)::client form consent is tied to its declared challenge and consumed once in SQLite |

| Initial, continued and invalid-answer forms display the same frozen implicit repository that execution uses | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::regenerated forms retain the frozen implicit repository and execute that repository | | Verified requesters cancel their own runs through exact MCP form consent without a broad run-write grant; foreign, unlinked, spoofed and read-only callers refuse | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::a verified requester stops only their own review through MCP form consent without runs:write, ::MCP self cancellation refuses unlinked, read-only and spoofed callers and rechecks consent authority, ::MCP owner stop preserves confirmation, monotonic repeats and terminal conflict | | A personal connection without Slack can stop its own Access-owned run through form consent; a forged Access-like bearer subject cannot | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::a verified Access-only personal MCP account cancels its own run without accepting forged account prefixes |

Current-source integration seam ​

The organization authentication resolver rechecks the exact original MCP credential. Static and personal keys use the existing token stores and installation MCP realm. SSO uses a separate MCP realm bound to the configured dashboard strategy, issuer and app audience. The native-source adapter retains at most 1,000 exact principal/connection/address verifier closures in memory; no credential bytes or grant snapshot enter the durable envelope. It rechecks the same credential before original-owner execution, reply and retry. Missing proof after restart or eviction is unavailable; invalid or expired proof is fenced. Another key or SSO principal cannot replace the original source.

SSO dispatch credits the actual Access account. An Access assertion establishes the existing human membership; a shared dashboard token remains a service with only its current configured grants and no human private stream. Email lookup does not add a durable requester alias. Ordinary registry commands retain their existing caller and one-use confirmation path. Static and personal credentials keep precedence over SSO.

CriterionProof
Node MCP SSO dispatch reaches the production installation under the actual Access account for signed Access, OAuth plus Access, dashboard token and 64-hex dashboard token; exact retry keeps its original owner and changed payload conflicts[unit] src/channels/browserHomeInstallation.test.ts::browser installation::admits the actual…
An uncertain Node response preserves the original synchronous or async admission receipt and executes one original owner[unit] src/channels/browserHomeInstallation.test.ts::browser installation::retains the original SSO receipt after an uncertain Node response…
Current SSO source outage stays unavailable, recovery proves the same owner, expired proof refuses, and static/personal credentials retain their own original realm beside SSO[unit] src/channels/browserHomeInstallation.test.ts::browser installation::holds the original SSO owner on current proof outage and refuses expired proof without another actor, ::keeps static and personal MCP credentials authoritative beside valid Access SSO
The separate SSO MCP realm preserves browser-source readability; restart without the exact ephemeral verifier holds the saved source[unit] src/channels/browserHomeInstallation.test.ts::browser installation::keeps browser history readable and holds an SSO original after adapter restart
With production admission installed, direct registry reads and ordinary one-use form consent retain the actual SSO caller; a native saved choice keeps its distinct Access-owned continuation and replays the consumed receipt without another effect[unit] src/channels/browserHomeInstallation.test.ts::browser installation::preserves actual SSO registry reads without giving the durable owner an email alias, ::keeps ordinary SSO form consent on its original connection with production admission installed, ::keeps an SSO native saved choice on the actual Access owner and replays its consumed receipt
Personal MCP resolution uses the exact credential and current metadata without borrowing another key's authority[unit] src/channels/organizationAuthentication.test.ts::current organization authentication::revalidates the exact personal MCP metadata without lending another key authority
Protected personal credential getter normalizes only an absent user id from the immutable Access subject; explicit Slack or mismatched accounts cannot authenticate, remain owner-revocable, and cannot be overwritten under the same digest[unit] src/mcp/personalTokens.test.ts::personal MCP token stores::keeps exact Access approval across restart and refuses email links and raw digest collisions, ::worker authentication rejects legacy Slack metadata while owner listing keeps it revocable
Current Worker stores the exact user id; concurrent foreign-account digest claims admit one owner and return a conflict to the other; exact retry preserves original creation time and metadata[worker] deploy/cloudflare-memory/config.test.ts::ConfigDO personal MCP tokens::atomically keeps one approved account under concurrent digest claims and preserves exact retry metadata, ::keeps a legacy email-linked device revocable without rebinding its digest to Access
An existing SQLite personal-token table gains nullable approval identity storage without changing an existing approved subject, creation time or metadata; same-account retry is a no-op and foreign replacement refuses[worker] deploy/cloudflare-memory/config.test.ts::ConfigDO personal MCP tokens::adds approval identity storage to an old table without changing its approved account
Fresh hosted personal-device approval pins version 1 Access issuer, app audience, installation organization and exact subject from trusted host configuration; form fields cannot change them. Changed realm or missing legacy proof requires revoke/reissue under a new digest[unit] src/channels/personalMcpSetup.test.ts::personal MCP setup::pins only the host Access realm and organization at approval and requires a new code after a realm change
Optional pinned approval is closed metadata, matches the protected subject and exact Access user id, survives file restart, and cannot be removed or replaced under an occupied digest. Getter normalization never invents missing realm proof; nested memory reads cannot mutate approval; invalid raw occupied metadata cannot be overwritten[unit] src/mcp/personalTokens.test.ts::personal MCP token stores::preserves pinned approval across file restart and rejects malformed or changed proof instead of replacing it, ::keeps pinned memory approval immutable and does not replace a raw malformed occupied digest
Real SQLite stores and lists pinned Access approval without losing it; realm changes and upgrades of old digests conflict, unknown approval fields refuse, and legacy owner revocation remains available[worker] deploy/cloudflare-memory/config.test.ts::ConfigDO personal MCP tokens::persists pinned Access approval and refuses realm changes or legacy upgrades under the same digest

Current personal-device realm ​

A trusted host may supply a current personal approval pin getter. When supplied, personal credentials require immutable approval metadata matching its current issuer, app audience and organization before body handling or tool effects. Missing pin, changed realm, legacy unqualified metadata or credential revocation holds access. The absent seam preserves the unactivated adapter, and static non-personal tokens remain unchanged. This read check adds no grants or account aliases.

CriterionProof
Typed tools require the current approved realm and both current device and person grants[unit] src/channels/mcpRealm.test.ts::personal MCP current realm::executes a typed tool only for the current approved realm and both current principals
Exact credential revalidation checks the latest pin after lookup; revoked and legacy records are held while an absent seam preserves the prior adapter[unit] src/channels/mcpRealm.test.ts::personal MCP current realm::rechecks the current pin after credential lookup and refuses revoked or legacy personal metadata
The node handler refuses a changed personal realm before body reads while preserving static ingress[unit] src/channels/mcp.test.ts::createMcpHandler (node:http wrapper)::fences a changed personal realm before reading the body while preserving static ingress
A personal client form rechecks realm after recording consent and preserves the exact unconsumed saved ID until current proof returns[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::holds a personal form choice when its realm changes during consent recording and resumes the exact id after current proof

Organization saved choices ​

For protected organization offers, maintained client form consent is recorded before a distinct native choice is admitted. The callback supplies original-owner handling; it does not execute the asking owner again. Cancellation uses the same callback with a separate cancel key. Consumed or lost-acknowledgement retries consult native replay before a used-row refusal. Exact credential, realm, requester and current command rights are rechecked before admission; this support slice registers no production callback. Ordinary unmarked confirmations retain their existing path.

CriterionProof
A native callback sees recorded form consent, exact requester and service provenance; a consumed choice returns its original receipt after restart[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::records form consent before native choice admission and replays a lost acknowledgement after restart
Cancellation owns its distinct native choice; unsupported consent and current grant loss leave work unstarted and saved choices unconsumed[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::routes native cancellation through its distinct choice and refuses unsupported fresh consent and current grant loss
Exact device realm loss after client consent leaves the saved id unconsumed and admits no native effect[unit] src/channels/mcpApprovals.test.ts::MCP approval parity::keeps a native saved id unconsumed when the exact device realm is lost after form consent

| A service-only offer stays with its exact credential; an unstamped delegated human is not promoted into that credential | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::resumes a preapproved service-only native offer with its exact credential and no human alias; ::refuses an unstamped delegated human row instead of treating its requester as the service credential |

Durable hosted admission ​

When organization admission is configured, dispatch returns a durable admission receipt. It does not report model completion. An explicit idempotencyKey and the unchanged input replay the original receipt and owner; a changed input conflicts. Without a key, each call remains distinct. Authenticated source facts come from the current credential, never from email or argument fields. The unactivated adapter retains its existing reply behavior.

An asynchronous caller reads its result from that exact original run through authenticated runs_get. The saved output and physical reply delivery are separate facts. Release acceptance records a bounded native-output readback only after the receipt, source identity, candidate, profile, seal, persistence, complete events and scenario output pass. Legacy channel responses still require their reply-delivery receipt.

CriterionProof
An exact keyed retry returns the original admitted owner; changed text conflicts and email cannot become sender proof[unit] src/channels/mcpAdmission.test.ts::MCP organization admission::retries one exact input under its original receipt and owner without borrowing an email identity
Malformed keys and lost current policy refuse admission; a valid key returns a pending receipt[unit] src/channels/mcpAdmission.test.ts::MCP organization admission::refuses malformed retry keys and lost current policy while a valid key admits one receipt
Unkeyed submissions remain distinct; the unactivated adapter keeps its reply behavior[unit] src/channels/mcpAdmission.test.ts::MCP organization admission::keeps unkeyed submissions distinct and preserves the unactivated reply path

McpApprovalViewOptions.resolveApprovalSource is an optional trusted host callback local to the browser approval view. Protected organization offers require this current proof; they cannot use email or legacy source reconstruction. The proof retains the original actual credential and both permission ceilings. It is rechecked after the form body together with the exact connection and action permission. Changed or revoked sources cannot approve or cancel the saved offer. A temporary proof lookup outage returns 503 and leaves that offer with its original owner. A direct Access-session registry offer instead proves the same current browser account and rebuilds its original caller and requester link. This path restores no model source and supplies no legacy fallback. Browser consent records permission to continue; execution remains with the original MCP or dispatcher owner.

CriterionProof
A proved subject needs no email correlation; consent marks the exact original offer and does not consume its execution owner. Foreign subjects, protected rows without proof, and another actual source actor refuse.[unit] src/channels/mcpApprovalView.test.ts::browser approval current source::uses the exact signed-in subject and current delegated credential without email, recording consent but not consuming its owner, src/channels/mcpApprovalView.test.ts::browser approval current source::does not use legacy identity fallback for protected offers or borrow a different source actor
Direct Access-session registry offers retain the same verified browser subject, actual Access actor, current grants and currently verified requester link. Production wiring passes the verified browser identity; current native browser proof is rechecked before and after the form body. A matching browser GET does not execute. Browser consent leaves the original MCP connection to resume the unchanged saved command once; duplicate resume adds no effect. Foreign subjects/callers, Person views, changed requester/source, expired or revoked proof and lost current command grants remain refused. Protected organization, personal-device and derived offers keep their existing source checks; no legacy fallback or stored-offer rewrite is added.[unit] src/channels/browserHomeWiring.test.ts::browser production wiring::shows a direct Access MCP offer through the production browser source resolver without executing it; ::holds a direct Access offer with %s without changing it or executing; ::rechecks Access %s after the approval body
Source changes during approval or cancellation body reads retain the untouched saved offer. Temporary lookup outage returns 503 and can recover on that same offer.[unit] src/channels/mcpApprovalView.test.ts::browser approval current source::holds the original offer when its source changes while %s body is read, src/channels/mcpApprovalView.test.ts::browser approval current source::retains the original offer through a temporary current-source lookup outage
Exact credential revocation or loss of either current action ceiling after the body refuses consent; source proof does not waive the same-origin form requirement.[unit] src/channels/mcpApprovalView.test.ts::browser approval current source::rechecks the exact connection and both current policy ceilings after the form body, src/channels/mcpApprovalView.test.ts::browser approval current source::requires a same-origin form even when current source proof is available

| A trusted Person view cannot approve or revoke a device, complete a connector, return an OAuth callback, or record saved consent; own and All views retain the existing flow | [unit] src/channels/personalMcpSetup.test.ts::personal MCP setup::refuses device writes in a trusted Person view before reading the form, while own and All views still approve, src/channels/mcpConnectView.test.ts::Person view connection writes::keeps bearer and OAuth tickets unchanged and refuses callbacks in a trusted Person view, src/channels/mcpApprovalView.test.ts::browser approval current source::leaves saved consent unchanged in a trusted Person view before buffering its body |

| Every static bearer header spelling accepted by the shared authentication gate retains the exact configured credential digest during current native admission. Scheme case and separator whitespace change no actor, self aliases or grants; token bytes remain case-sensitive. | [unit] src/channels/mcpAdmission.test.ts::MCP organization admission::retains the exact configured credential and native admission for an accepted header:…, src/channels/mcpAdmission.test.ts::MCP organization admission::does not admit an unknown credential or scheme after header normalization:… |

Native admission receipts and current credential proof ​

A failed optional final receipt refresh cannot erase the receipt this invocation already obtained. The adapter returns that original receipt when only refresh is unavailable. Explicit current refusal, fencing and conflict keep their existing dispositions. An unavailable initial admit-store result remains unknown; no receipt or owner is invented. Unconfirmed admission says “Admission could not be confirmed,” rather than claiming that nothing was admitted.

The MCP SSO verifier registry retains a current revalidation function for the exact native identity, credential digest and checked actor/connection binding. It does not key authentication by a request hash or conversation thread, and it never caches an allowed decision. Every use revalidates the exact credential. Original references and context-specific source authorities remain unchanged; the resolver rebuilds current grants for each original context. Without fresh exact-credential proof after restart, the original source stays unavailable. A different credential, expiry, revocation or a foreign binding cannot unlock it or replay an unknown owner. Different-credential historical recovery remains unqualified.

MCP and HTTP responses add a call-local diagnostic with the real request trace ID, closed admission stage/reason and receiptObserved. True means a receipt was observed; false does not prove no commit. A blocked historical source can also report its source family and same/different/unknown credential relation, without an identity or digest. The existing authorized trace reader accepts that trace ID. Unavailable or thrown admission ends the request trace as error; a known receipt with lost optional refresh keeps its admission result and records the refresh gap. Its existing request-root event also carries the receipt ID, refresh stage and a closed refresh failure class. Unclassified exceptions stay unknown; raw exception names and prose are never copied. Diagnostics contain no request body, actor, credential, epoch or raw exception, and cannot change admission when observers throw, reject or attempt mutation. No Worker wire result, durable schema or permission gate changes.

CriterionProof
An observed receipt survives only optional refresh loss; explicit final refusal/fencing/conflict remains in force, and the original command executes once[unit] src/channels/browserHomeInstallation.test.ts::native admission recovery::retains an observed receipt when only the final refresh is unavailable: %s, ::does not replace an explicit final %s with a receipt, ::retains an explicit final denial after the credential is revoked during admission
Fresh exact-credential proof restores same-thread/new-request and new-thread admission while the original owner stays held; foreign credentials/bindings and current revocation/resource grants remain denied[unit] src/channels/browserHomeInstallation.test.ts::native admission recovery::fresh exact-credential SSO admission survives retained authority after restart: %s, ::keeps %s fenced after verifier lookup, ::rebuilds current grants for the immutable original context after cached verification
A different valid credential cannot unlock old-source history; unavailable diagnostics use the actual authorized trace path without body/credential disclosure; concurrent or failed observers cannot alter admission[unit] src/channels/browserHomeInstallation.test.ts::native admission recovery::keeps different-credential history held and returns its real unavailable trace, ::returns the actual trace for a %s admission exception, ::keeps concurrent unavailable traces on their own requests, ::a %s trace observer failure cannot change admission or execution

Request-only Review admission ​

A new human MCP SSO request can select request-only context when it explicitly requests Review and names exactly one complete current-authored PR. The server records contextMode=request in the immutable source reference only for a new source key. Earlier history, notes, summaries, parent lineage and saved selections cannot supply this Review. The existing one-door decision must still bind Review to the same authored target. Current receiver, directory, repository, head and run-setting checks remain in force. Old keys and executions retain their original source mode and credential.

The request root records requestContextSelection. selected identifies a new request-only source, and retained_request identifies a retry that keeps its recorded mode. Other closed values name the first check that declined the new mode. This diagnostic cannot change admission or source authority.

CriterionProof
Complete current-authored Review targets qualify, including multiline requests and targets after character 300; quoted, incomplete and ambiguous controls or targets do not. Only one matching typed Review bind can execute. The full request supplies target evidence; the shortened display line does not.[unit] src/core/dispatch/requestOnlyReview.test.ts::request-only Review qualification::*, src/channels/browserHomeInstallation.test.ts::isolates a new Review and rechecks its current authority: %s
Request-only Review admission uses the existing effective operator mode. A loaded config with no operator field resolves to on, like explicit on; runtime overrides do not insert a separate base setting. Explicit off and shadow still decline request-only context. The default-on path can post a new exact-head Review without consuming an unavailable completed Review's context or changing its source.[unit] src/channels/browserHomeInstallation.test.ts::uses the loaded %s operator mode for new MCP Review context; ::isolates a new Review and rechecks its current authority: %s
A new signed credential after adapter restart gets a distinct canonical request-only receipt and checked empty history; the old owner remains held[unit] src/channels/browserHomeInstallation.test.ts::admits a fresh request-only Review after restart and a new signed credential
A prior completed private Review with execution-bound GitHub results, including prior child metadata, cannot seed a new Review; its new session posts its own exact-head verdict from fresh GitHub data. Expiry, revocation, lost rights, changed directory binding, disabled routing, invalid targets and late local or remote owners stop execution without writing an owner's inbox.[unit] src/channels/browserHomeInstallation.test.ts::isolates a new Review and rechecks its current authority: %s
Client fields and machine credentials cannot select request-only context. Original owner pointers retain the mode only for the same run and sequence. Legacy authority bytes remain exact, and old strict receivers refuse the new marker.[unit] src/channels/browserHomeInstallation.test.ts::keeps %s from selecting request-only context, src/core/dispatch/originalOrganizationInput.test.ts::Original media input marker::preserves request-only context only for the same original run and sequence, src/core/organization/contract.test.ts::canonical request context policy::keeps legacy authority bytes and refuses a mode an old receiver cannot preserve
The routing agent can bind the current PR without a private-context tool; an attempted history lookup uses bounded repair without reading old context. The configured-model deployment preflight includes this route.[unit] src/load/candidateDoorSmoke.test.ts::fixed candidate smoke repository dependency::routes a request-only Review after an attempted old-context lookup without reading that context
Request-only refusals identify the closed gate and reason in the existing trace without request, target or credential text. An observer failure preserves the refusal and cannot start work.[unit] src/channels/browserHomeInstallation.test.ts::records an invalid request-only context when its trace observer %s, ::isolates a new Review and rechecks its current authority: %s
A refusal after routing saves the operator decision, repair attempts and terminal refusal once in the existing door record. Exhausted repair, an owner fold, and late local or remote owners start no Review and write no existing owner's inbox. An observer failure preserves the same record and outcome.[unit] src/channels/browserHomeInstallation.test.ts::isolates a new Review and rechecks its current authority: %s
The request root distinguishes selected and retained request-only mode from an existing legacy key, a machine actor, an incomplete target, disabled routing or unsupported media. Selection reporting exposes no source text; a throwing, rejecting or mutating observer cannot change the canonical mode or admission.[unit] src/channels/browserHomeInstallation.test.ts::admits a fresh request-only Review after restart and a new signed credential, ::keeps %s from selecting request-only context, ::keeps a selected request-only admission when its %s observer fails, ::records media as the request-only selection decline without admitting a second request