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 OpenSwitchboard 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.
Transport is a minimal MCP server over streamable-HTTP: JSON-RPC 2.0 over a single POST /mcp, one JSON response per request (no SSE — request/response tool calls don't need it). The JSON-RPC subset is hand-implemented; no MCP SDK dependency was added. Like the HTTP ingress, the endpoint is our own service (not behind a platform's auth), so it reuses http.ts's security wholesale: the same bearer-token auth, fail-closed, constant-time compares, and the same SWITCHBOARD_INGRESS_TOKENS env config — one token map for both surfaces. The only difference is the identity namespace (mcp:), so MCP and HTTP callers are distinct actors — the registry tools are decided by the policy table over the mcp:<subject> actor's grants (the token's scopes, authorization.md) and the existing canRunAgent/canUseRepo gates apply to dispatch unchanged.
- Code:
src/channels/mcp.ts(handleMcpRequesttransport gating + JSON-RPC routing,McpIOsingle-shotChannelIO,createMcpHandlernode:http wrapper; auth reused fromsrc/channels/http.ts—authenticate,readBody,MAX_BODY_BYTES,IngressConfig,IngressIdentity,DispatchFn);src/index.ts(wiresPOST /mcpinto the existing http server alongside the health probe andPOST /ingress, sharing the one parsed token map). - Tests:
src/channels/mcp.test.ts. - Docs: AGENTS.md invariants 1, 2, 3, 4, HTTP ingress.
Behavior
- Pure transport, same core. A
tools/callfor thedispatchtool becomes anIncomingMessageand is handed to the unchangeddispatch(deps, msg, io)— no dispatcher, runner, or permission change. The tool requires the token'sdispatchscope (the default when an entry names none): a registry-only token (e.g.["runs:read"]) gets JSON-RPC-32001withdata.code:"unauthorized"anddispatch()is never called, while its registry tools keep working. Identities are platform-namespaced (invariant 4), mirroring HTTP but with themcp:prefix:userId=mcp:<subject>,channelId=mcp:<channel|default>,threadKey=mcp:<channel>:<thread|default>. - JSON-RPC methods.
initializereturns the protocol version,capabilities.tools, andserverInfo.tools/listadvertises the hand-writtendispatchtool — description "send a request to Switchboard",inputSchema{ text: string (required), thread?: string, channel?: string }— FIRST, followed by every command registry command exposed to MCP (runs_list,runs_get,runs_events,runs_friction,runs_stop,friction_report,friction_propose,repo_list; name =<group>_<verb>,inputSchemaderived from the command's zod input). With no registry wired (McpOptions.commandsabsent)dispatchis the only tool.tools/callfordispatchruns the request and returns{ content: [{ type: "text", text: <reply> }] }. A JSON-RPC notification (noid, e.g.notifications/initialized) is accepted and never answered (HTTP 202, empty body). Registry tools.tools/callon a registry name invokes the command with caller{ kind:"mcp", id:"mcp:<subject>", actor: <the service Actor whose grants are the token's scopes>, channel?: "mcp:<pinned channel>" }— the raw arguments go toinvokeunchanged; authorization (the policy table over the actor's grants: a token's default["dispatch"]holds no registry command) 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 withdata.code∈unauthorized|invalid_input|not_found|conflict|internal(codes-32001,-32602,-32002,-32003,-32603). No output ever contains a run's capability token. - 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);history()is[](a tool call carries no prior turns — conversation state, if any, rides onthreadKey). - Bearer auth, fail-closed (reused from http.ts).
authenticate(headers, config)requiresAuthorization: Bearer <token>; a missing, malformed, or unknown token →401+ a JSON-RPC auth error. If no tokens are configured the endpoint is disabled →503, never open. The map istoken -> { subject, channel? }; the mapped subject becomes theuserId, so a token can only ever act as its assigned identity and existing gates apply unchanged. A token may pin achannelthat overrides the call'schannelargument (locks the config scope). Compares are constant-time and non-short-circuiting; token material is never logged. - JSON-RPC framing. Malformed JSON →
-32700(idnull); a non-object / array / method-less message →-32600; an unknown method →-32601; an unknown tool or a missing/blank/non-stringtextargument →-32602. Post-auth JSON-RPC responses carry HTTP200; transport gating uses405(non-POST) /401/503. - Input hardening. The body is size-capped at read time (~1 MB via http.ts's
readBody,413before it is fully buffered, request destroyed). Malformed env token config is treated as "no tokens" (disabled), never as open (proven by http.ts'sparseIngressTokenstests, reused unchanged). - Wiring keeps health + HTTP ingress working. The
PORTserver routes/mcpto the MCP handler,/ingressto the HTTP handler, and every other path to the existingokhealth probe, all fed by the oneSWITCHBOARD_INGRESS_TOKENSmap. With no tokens configured the startup log says ingress + MCP are DISABLED.
Validation criteria
| Criterion | Evidence |
|---|---|
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 exactly the dispatch tool with a text/thread/channel input schema (text required) | [unit] ::handleMcpRequest — tools/list::advertises exactly one tool with a text/thread/channel input schema |
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 dispatch-only tokens, records mcp:<subject> 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) |
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 |
Namespacing: channel/thread defaults; token-pinned channel overrides the call argument; history is [] | [unit] ::handleMcpRequest — tools/call::defaults channel/thread when the arguments omit them, ::a token-pinned channel overrides the arguments' channel, ::history is empty for a single-shot MCP tool call |
A token without the dispatch scope 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 |
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. |