mcp-broker

mcp-broker architecture, for agents

This page is the map. It names every layer of the broker, states the one rule per layer that is most often got wrong, and links each concept to the document that specifies it and to the source file that implements it. Read it before any other page; then read only the pages the task needs.

If you can reach a running broker, ask it instead: the reserved _broker slot serves broker_guide({ topic? }) and broker_diagnose({ slot? }), generated from the broker’s own source and completed with the deployment’s effective configuration. When they disagree with this page, they win. The same guide pages are published statically under guide/.

In one sentence

mcp-broker puts many MCP servers behind one host and one port: each server occupies a named slot, servers reach their slot from the inside as providers, and any standard MCP client reaches a slot from the outside, alone at /<slot>/mcp or aggregated with every other opted-in provider at /_all/mcp.

The layers

                MCP clients: Claude, MCP Inspector, SDKs, agents, a browser page
                        |  Streamable HTTP /<slot>/mcp   SSE /<slot>/sse
                        |  raw WebSocket /<slot>        stdio bridge
                        v
 +---------------------------------------------------------------------------+
 | 3  CLIENT EGRESS     sessions, origin check, MCP-Protocol-Version          |
 | 5  EDGE SECURITY     OAuth 2.1 resource server, provider identities        |
 | 6  AUTHORITY         hierarchical policy, declarations, authorize, limits, |
 |                      budgets, audited decisions and results                |
 | 4  RESERVED SLOTS    _broker (introspection)   _all (aggregate)            |
 | 2  SLOTS / ROUTING   lazy slots, broker-owned request ids, subscriptions   |
 | 7  TELEMETRY         traceparent on every request, broker/telemetry, OTLP  |
 | 8  DISCOVERY         mDNS/DNS-SD (opt-in), RFC 9728 metadata, self-docs    |
 | 1  PROVIDER INGRESS  /provider/<slot>  /providers  stdio  remote  .mcpb    |
 +---------------------------------------------------------------------------+
                        ^
                        |  DirectTransport, MultiplexTransport, LoopbackTransport
                        |  libmcpb (C99), McpBroker (Unreal), Python Provider
                MCP servers: browser pages, Node apps, ESP32 boards,
                Unreal Engine simulations, stdio tools, remote servers

The broker is one Node process. The whole tunnel is JSON-RPC 2.0; the broker translates framing between transports and rewrites request ids, and otherwise relays MCP as published.

Vocabulary

Term Meaning
slot A name under which one MCP server is reachable. Created lazily on first reference; never declared. An empty slot answers -32000 Provider "<slot>" not connected
provider An MCP server that publishes into a slot. On the tunnel it is a WebSocket client of the broker, which is why the word “client” is avoided for it
client (or consumer) An MCP client that calls a slot over a standard MCP transport. It never speaks the tunnel protocol
dedicated endpoint ws[s]://<host>/provider/<slot>, one slot per socket, plain JSON-RPC frames, DirectTransport
multiplexed endpoint ws[s]://<host>/providers, N slots per socket, { provider, payload } envelopes, MultiplexTransport
upstream A provider the broker starts or dials itself: stdioUpstreams[], mcpServers[], mcpbBundles[]
loopback provider An MCP server in the broker’s own process, tunnel.registerLoopbackProvider(), no socket
_broker Reserved slot: the broker’s own introspection MCP server. Routes to nothing
_all Reserved slot: union of the tools and prompts of opted-in providers, names prefixed <slot>-
declaration What a provider tells the broker about the resources it governs (broker/authorization/declare). Describes; grants nothing
caller reference params._meta["io.cyanmycelium/caller"], attached by the broker to each request for a declaring provider, so the provider can ask broker/authorize about the real caller

Layer by layer

1. Provider ingress

How an MCP server gets into a slot.

Way in Endpoint or key Implemented by
Dedicated WebSocket ws[s]://<host>/provider/<slot> DirectTransport (provider package), mcpb_provider_t (C)
Multiplexed WebSocket ws[s]://<host>/providers MultiplexTransport (source), mcpb_mux_t (C), the Unreal plugin
Child process over stdio stdioUpstreams[] stdio.upstream.ts
Remote server the broker dials mcpServers[] remote.upstream.ts
Signed .mcpb bundle mcpbBundles[] mcpb.loader.ts
Same process tunnel.registerLoopbackProvider(name, transport) LoopbackTransport from @cyanmycelium/mcp-core

The rule: a provider’s transport and its URL path are one decision. DirectTransport pairs with /provider/<slot>, MultiplexTransport with /providers. /providers/<slot> is neither: it is accepted as an MCP client of a slot literally named providers/<slot>. Since 1.3.0 the broker detects both mismatches on the first frame and closes with 1008; older brokers stay silent.

Joining _all is opt-in: a WebSocket provider sends {"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}} first (the aggregate transport option does it); a stdioUpstreams[] entry needs "aggregate": true; mcpServers[] and mcpbBundles[] join by default.

Specified in: protocol.md, endpoints.md. Router: ws.tunnel.ts. Wire envelope: envelope.ts.

2. Slots and routing

Specified in: architecture.md, protocol.md. Source: broker.slots.ts, resource.subscription.registry.ts.

3. Client egress

Transport Path
Streamable HTTP POST, GET, DELETE /<slot>/mcp
Legacy SSE GET /<slot>/sse, POST /<slot>/messages?sessionId=
Raw WebSocket ws[s]://<host>/<slot>
stdio bridge MCP_BROKER_STDIO_PROVIDER=<slot>; always _all for an MCP host

Specified in: endpoints.md, guide connect-client, config.md.

4. Reserved slots

Specified in: architecture.md. Source: broker.server.ts, aggregate.server.ts, broker.guides.ts, broker.diagnostics.ts.

5. Edge security

All of it is opt-in: with no auth configuration the broker authenticates nobody, which is only appropriate behind a trusted network boundary.

Specified in: authorization.md, HOWTO.md (in French). Source: auth/.

6. Authority

The broker can be the authorization decision point for providers that govern their own kind of resource (SCADA first).

  1. Policy (operator, security file): subjects, roles of capabilities, assignments on hierarchical resource paths (ISA-95 aligned), explicit denies. authorize(subject, capability, resourcePath) -> allow | deny.
  2. Declaration (provider, broker/authorization/declare): its domain, namespace, capabilities and resources, with optional engineering limits and resource patterns. One owner identity per domain, one declaration per (identity, slot). It grants nothing.
  3. Decision (provider asks, broker/authorize, about a caller reference or about itself): one audited decision per check. A resource with limits returns allow-with-constraints, with allowed: false so a provider that ignores obligations refuses rather than oversteps.
  4. Execution limits and budgets: concurrency and budget reservations, released on answer or by an operator.
  5. Result (provider, broker/audit/result): audited next to the decision; unreported results of required capabilities show up in broker_diagnose.

Specified in: hierarchical-authorization.md, authorization.md, execution-limits.md, the “Declared authorization” section of AGENTS.md. Source: authorization/, authority/, limits/controller.ts.

7. Telemetry and traces

Specified in: telemetry.md. Source: telemetry/.

8. Discovery

Specified in: mdns in config.md. Source: discovery/mdns.ts.

The device side

Providers do not have to be Node. c/libmcpb is a C99 tunnel client (no allocation, a six-function platform port) that speaks exactly the two WebSocket framings; it carries JSON-RPC bytes and never interprets them. It runs on an ESP32 (mcpb_esp), inside Unreal Engine 5 (plugin McpBroker, one multiplexed socket for every simulated device), and under Python through ctypes (mcp_provider.Provider). A simulated pump-1 and a real pump-1 publish the same slot and tool schemas.

Specified in: c/README.md, c/libmcpb/README.md, c/espressif/README.md, c/unreal/README.md, python/README.md.

Three paths through the broker

A browser page publishes a tool. The page builds an McpServer with DirectTransport("ws://<host>/provider/my-slot", { aggregate: true }), starts it, and closes the transport on pagehide. start() resolving means the socket opened, not that the slot was accepted: a refusal arrives later as a 1008 close. The page needs a bundler, and cannot authenticate as a provider. Runnable: samples/browser-provider.

An agent calls it. The agent’s MCP client points at http://<host>/my-slot/mcp, or at http://<host>/_all/mcp and calls my-slot-<tool> exactly as tools/list spelled it. A stdio host uses the broker with MCP_BROKER_STDIO_PROVIDER=_all. Runnable: samples/host-config.

A governed write. A declaring provider receives tools/call with a caller reference, asks broker/authorize with that reference, applies obligations.constraints when the effect is allow-with-constraints, acts, then reports the outcome with broker/audit/result. Tests: startTestBroker() from @cyanmycelium/mcp-broker/testing (testing.md).

What the broker is not

Reading order

  1. This page.
  2. AGENTS.md: topology table, copy-pasteable configurations, symptom-to-fix table.
  3. The guide page for the task: publish-provider, connect-client, host-config, deploy, troubleshooting.
  4. The specification for the layer: protocol.md, endpoints.md, authorization.md, telemetry.md, config.md.
  5. Runnable proof: samples/index.json.