mcp-broker

Architecture

mcp-broker is a single-process server that brokers JSON-RPC traffic between MCP providers (the things that expose tools and resources) and MCP clients (the things that consume them).

Roles

   ┌──────────────┐      ┌────────────┐      ┌────────────┐
   │  Provider A  │◀───▶│            │◀───▶│  Client 1  │
   ├──────────────┤      │            │      ├────────────┤
   │  Provider B  │◀───▶│   BROKER   │◀───▶│  Client 2  │
   ├──────────────┤      │            │      ├────────────┤
   │  Provider C  │◀───▶│            │◀───▶│  Client N  │
   └──────────────┘      └────────────┘      └────────────┘
       (WS, stdio)         (this repo)         (HTTP, WS, SSE, stdio)

A provider registers under a named slot. Each slot is independent: pending requests, notification streams, and connected clients are tracked per slot.

A client addresses a provider by name. Multiple clients can target the same provider simultaneously and each receives the responses to its own requests plus broadcast notifications from that provider, and the notifications/resources/updated for the URIs it subscribed to.

Provider transports (incoming)

Transport How it connects SDK class Notes
Dedicated WebSocket ws[s]://broker/provider/<name> DirectTransport One WS per provider, plain JSON-RPC frames
Multiplexed WebSocket ws[s]://broker/providers with envelope { provider, payload } MultiplexTransport One WS carries N providers, envelope frames
Stdio upstream Broker spawns a child process at startup, talks newline-delimited JSON-RPC (none) Config key stdioUpstreams[]
Remote upstream Broker dials out to a URL over Streamable HTTP, SSE or WebSocket (none) Config key mcpServers[]
.mcpb bundle Broker verifies a detached signature, unpacks, and spawns the bundle as a stdio upstream (none) Config key mcpbBundles[]. A bundle that fails verification is skipped and never spawned
Loopback tunnel.registerLoopbackProvider(name, transport) LoopbackTransport Same process, no socket. Outranks a WebSocket provider of the same name
Dedicated WebSocket, from C ws[s]://broker/provider/<name> mcpb_provider_t (c/libmcpb) The same framing as DirectTransport, from a firmware or a native process. On the ESP32 through the mcpb_esp component
Multiplexed WebSocket, from C ws[s]://broker/providers mcpb_mux_t (c/libmcpb, opt-in at build time) The same envelope as MultiplexTransport. What the Unreal Engine plugin uses: one socket for every slot the engine hosts

The pairing rule. A provider’s transport and its URL path are a matched pair: DirectTransport with /provider/<name>, MultiplexTransport with /providers. The broker decides which framing to speak on a socket from the URL that socket connected to, never from what arrives on it. ws[s]://broker/providers/<name> is neither: the router matches /providers exactly and /provider/ as a prefix, so it falls through to the client branch and is accepted as an MCP client on a slot named providers/<name>.

The three configured kinds (stdioUpstreams, mcpServers, mcpbBundles) all report transport: "stdio" in providers_list, because the broker tracks them in one upstream registry. A transport: "stdio" entry is therefore not necessarily a child process; it may be a remote URL.

The C client speaks exactly the two WebSocket framings above and nothing the broker does not already accept: a board and an Unreal instance are ordinary providers, indistinguishable from a Node one in providers_list (transport: "ws"). That is what makes the simulator-to-field story work without a line of broker code: a simulated pump-1 in the engine and a real pump-1 on a board publish the same slot name and the same tool schemas, and the agent’s calls do not change when one replaces the other. See c/README.md.

Client transports (outgoing)

Transport Path Use case
Raw WebSocket ws[s]://broker/<name> Custom MCP clients, low overhead
Streamable HTTP (MCP 2025-03-26) POST/GET http[s]://broker/<name>/mcp MCP Inspector and modern SDKs
Legacy SSE GET /<name>/sse + POST /<name>/messages?sessionId=… Older Claude transport
Stdio bridge broker reads stdin, writes stdout Claude Desktop wrapping the broker as a stdio MCP server

Per-provider state

When any of these references a provider name for the first time, the broker creates a lazy state slot:

A request from any client is assigned a broker-side id (brk-<n>), which replaces the client’s id on the wire to the provider and is restored before the response is delivered. That namespacing is what lets two clients on one slot both use JSON-RPC id 1 without colliding; keying pending on the client’s raw id, as earlier versions did, hung one caller and delivered the other caller’s body to it. A provider therefore sees string ids it must echo verbatim. Notifications without an id are not tracked and are broadcast to all sinks of that slot, with one exception: notifications/resources/updated goes only to the sessions subscribed to its URI.

resources/subscribe and resources/unsubscribe never reach the provider one per client. A per-slot ResourceSubscriptionRegistry keeps who is subscribed to what and sends the provider one subscribe per URI (first subscriber) and one unsubscribe (last one out), with broker-owned ids whose answers it consumes. Operations on one URI run through a queue, so concurrent subscribers cannot race. A provider disconnect keeps the subscribers; the next provider on the slot gets the last client initialize replayed, then one subscribe per URI. See protocol.md.

A pending entry is released by a matching response, by the provider disconnecting, by the caller’s own connection closing, or by expiry (providerRequestTimeoutMs, 60 s by default), which fails the request with -32000 Provider "<name>" did not respond within <ms>ms rather than leaving the caller waiting.

The reserved _broker slot: self-introspection

The broker registers itself as a provider under the reserved slot _broker. Any MCP client that connects to <host>/_broker/mcp reaches the broker’s own MCP server (in-process, over a loopback transport) and can discover the broker’s state through standard MCP tools:

Matching resources at broker://info, broker://providers, the URI template broker://providers/{name}, the six broker://guide/<topic> pages and the template broker://guide/{topic} mirror the same data for clients that prefer resources/read to tools/call. broker_diagnose deliberately has no backing resource: resource content is cached, and a cached diagnosis is stale at exactly the moment it matters.

_broker proxies nothing. It is an introspection server, not a route to other slots.

The reserved _all slot: aggregation

_all presents the union of the tools and prompts of every opted-in provider as a single MCP server, over the same client-side endpoints (/_all/mcp, /_all/sse, ws://…/_all). It is registered at startup, before the stdio bridge resumes stdin, which is what makes it the only safe stdio-host target.

Why this design

What the broker does not do

Stated explicitly, because each of these has cost an integrator time: