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 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 (handleMcpRequest transport gating + JSON-RPC routing, McpIO single-shot ChannelIO, createMcpHandler node:http wrapper; auth reused from src/channels/http.tsauthenticate, readBody, MAX_BODY_BYTES, IngressConfig, IngressIdentity, DispatchFn); src/index.ts (wires POST /mcp into the existing http server alongside the health probe and POST /ingress, sharing the one parsed token map).
  • Tests: src/channels/mcp.test.ts.
  • Docs: AGENTS.md invariants 1, 2, 3, 4, HTTP ingress.

Behavior

  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. The tool requires the token's dispatch scope (the default when an entry names none): 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>.
  2. JSON-RPC methods. 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 } — 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>, inputSchema derived from the command's zod input). With no registry wired (McpOptions.commands absent) dispatch is the only tool. 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 are the token's scopes>, channel?: "mcp:<pinned channel>" } — the raw arguments go to invoke unchanged; 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 with data.codeunauthorized|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); history() is [] (a tool call carries no prior turns — conversation state, if any, rides on threadKey).
  4. Bearer auth, fail-closed (reused from http.ts). authenticate(headers, config) requires Authorization: Bearer <token>; a missing, malformed, or unknown token → 401 + a JSON-RPC auth error. If no tokens are configured the endpoint is disabled503, never open. The map is token -> { subject, channel? }; the mapped subject becomes the userId, so a token can only ever act as its assigned identity and existing gates apply unchanged. A token may pin a channel that overrides the call's channel argument (locks the config scope). Compares are constant-time and non-short-circuiting; token material is never logged.
  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 / 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 every other path to the existing ok health probe, all fed by the one SWITCHBOARD_INGRESS_TOKENS map. With no tokens configured the startup log says ingress + MCP are DISABLED.

Validation criteria

CriterionEvidence
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.