
@cyanmycelium/mcp-broker
Routes MCP clients to multiple Model Context Protocol providers through a single host. WebSocket, Streamable HTTP, SSE, and stdio transports on both sides.
One MCP server is rarely enough
Real-world MCP deployments don't consist of a single isolated server. An organization typically needs to expose, behind one address:
- Industrial assets - PLCs, SCADA, machine telemetry - wrapped as MCP servers, each speaking its own protocol behind the wrapper.
- Sensors and data sources the agent can query on demand, often living on different networks with different latencies.
- Agent hosts - micro-containers, headless engines, RPA bots - reachable as tools rather than monolithic services.
- Browser-side providers (3D scenes, web apps) that cannot listen on a port and need an external broker to relay JSON-RPC frames.
- One client, many providers - Claude, MCP Inspector, custom agents should reach any of them through a single URL instead of juggling N configurations.
@cyanmycelium/mcp-broker is the relay layer that
lets all of those connect to a central point and be reached by MCP clients
without each client having to know every backend.
Three roles, one process
The broker sits between two populations and translates framing where needed. Each provider gets an isolated slot with its own pending requests and notification streams.
Each slot is created lazily the first time any client references a provider name - even before that provider's WebSocket connects. While disconnected, the broker responds with a JSON-RPC error on the client's transport.
Asymmetric by design
A provider speaks WebSocket; a client speaks SSE on the same slot. The broker translates framing without the participants knowing about each other.
3.1Provider side (inbound)
| Transport | Endpoint | Use case |
|---|---|---|
Dedicated WS |
ws[s]://broker/provider/<name> |
One provider, one socket. The default for in-process MCP servers. |
Multiplexed WS |
ws[s]://broker/providers |
One socket carries N providers via { provider, payload } envelopes. For micro-frontend or many-scene browser apps. |
Stdio upstream |
broker spawns child process | The broker manages the lifecycle of a local stdio MCP server. Configured at startup. |
3.2Client side (outbound)
| Transport | Endpoint | Use case |
|---|---|---|
Streamable HTTP |
POST/GET /<name>/mcp |
MCP 2025-03-26. MCP Inspector and modern SDKs. |
Raw WebSocket |
ws[s]://broker/<name> |
Custom MCP clients, lowest framing overhead. |
Legacy SSE |
GET /<name>/sse + POST /<name>/messages |
Older Claude transport. Still widely deployed. |
Stdio bridge |
broker reads stdin, writes stdout | Claude Desktop wrapping the broker as a stdio MCP server pointing at one provider. |
From zero to a connected client
No installation step. The broker is a CLI; npx runs the latest published version.
$ npx @cyanmycelium/mcp-broker
⚙️ mcp-broker started
────────────────────────────────────────────────────────────────
📡 Provider WebSocket ws://localhost:3000/provider/<name>
🔌 MCP (Streamable HTTP) http://localhost:3000/<name>/mcp
📺 Legacy SSE http://localhost:3000/<name>/sse
────────────────────────────────────────────────────────────────
Then point any MCP provider at the WS endpoint, and any MCP client at the HTTP one:
// Inside any MCP server (uses @cyanmycelium/mcp-core or any SDK).
import { McpServerBuilder, DirectTransport } from '@cyanmycelium/mcp-core/server'
const server = new McpServerBuilder()
.withName('my-provider') // the slot name on the broker
.withTransport(new DirectTransport(
'ws://localhost:3000/provider/my-provider'
))
.build()
await server.start()
The MCP client (Claude Desktop, MCP Inspector, custom) now reaches your tools at http://localhost:3000/my-provider/mcp. The provider can run anywhere - browser, container, edge - as long as it can open a WebSocket back to the broker.
The broker is itself an MCP server
When the broker starts, it registers itself under the reserved slot _broker. Any MCP client can connect to http://<host>/_broker/mcp and discover the broker's own state through standard MCP tools - no out-of-band documentation, no admin protocol to learn.
| Tool | What it returns |
|---|---|
broker_info |
Identity: name, version, uptime, host, port, TLS status, configured URL paths. |
providers_list |
Every provider slot (connected and disconnected): name, transport kind, client count, pending request count. |
provider_status |
Detail of one slot identified by name. |
Resources at broker://info, broker://providers, and the URI template broker://providers/{name} mirror the same data for clients that prefer resources/read to tools/call.
// Calling _broker like any other MCP provider.
→ tools/call { name: "broker_info" }
← {
name: "@cyanmycelium/mcp-broker",
version: "0.1.0",
uptimeSeconds: 128,
port: 3000,
tls: false
}
→ tools/call { name: "providers_list" }
← {
count: 3,
providers: [
{ name: "_broker", transport: "loopback", connected: true },
{ name: "babylon-scene", transport: "ws", connected: true },
{ name: "scene-2", transport: "ws", connected: true }
]
}
The broker's own tools, described per client family and per locale
The descriptions of the broker's own surface, the _broker tools, resources and resource templates, live in JSON files, not in the code. The broker ships a matrix of grammars indexed by (userAgent, locale) and resolves one combination per session: the client family from clientInfo.name at initialize, the locale from the deployment (MCP_BROKER_LOCALE, or locale in config.json). Provider tools are not touched: what a provider publishes in tools/list is what the client sees, on its own slot and in _all.
en |
fr |
zh |
|
|---|---|---|---|
default |
packaged | packaged | packaged |
claude |
packaged | packaged | → default:zh |
gpt |
→ default:en |
→ default:fr |
→ default:zh |
Each filled cell is one JSON file at grammars/<userAgent>/<locale>.json. Missing cells cascade: claude:zh → default:zh → claude:en → default:en. A new locale is a file: drop default/de.json and set MCP_BROKER_LOCALE=de. A new client family is a file plus one entry in the resolver's agents table, which maps substrings of clientInfo.name to a family (claude, gpt, mistral, copilot out of the box; anything else resolves to default). That table, and a per-client localeSource if one deployment must serve several languages, are set through grammarResolverOptions when the broker is embedded.
6.1Shape of a grammar file
{
"tools": {
"broker_info": {
"description": "Retourne le nom, la version, l'uptime, ..."
},
"provider_status": {
"description": "Retourne l'état d'un emplacement de provider...",
"properties": {
"name": "Nom exact du provider (sensible à la casse)."
}
}
},
"resources": {
"broker://info": {
"name": "Informations du broker",
"description": "Identité, version, uptime, configuration d'écoute."
}
},
"templates": {
"broker://providers/{name}": {
"name": "Provider du broker",
"description": "Snapshot d'un emplacement de provider..."
}
}
}
6.2Local overrides without forking
Need to retune a Claude-French description for your organization? Drop a file in .mcp-broker/grammars/claude/fr.json with only the entries you want to change:
{
"tools": {
"broker_info": {
"description": "Description sur-mesure pour Claude en français, propre à ton org."
}
}
}
The broker merges this on top of the packaged claude:fr grammar. Entries you don't override fall through to the packaged values. Translators edit JSON; developers edit code; the two never collide.
Your own tools get the same treatment in your own server, not in the broker. A provider built on @cyanmycelium/mcp-core carries the same resolver and the same four grammar layers (behavior, adapter, static, store), and it is the provider that answers tools/list. On a device, the wording is whatever the firmware sends. A per-slot overlay on the broker side, rewriting a provider's descriptions per client, is a possible evolution; it is not a feature today.
Embed the broker in your own process
Useful when the broker is one component of a larger Node service (gateway, fleet manager, dev harness) and you want to wire stdio upstreams or static mounts from code.
import { WsTunnelBuilder } from '@cyanmycelium/mcp-broker'
const broker = new WsTunnelBuilder()
.withPort(3000)
.withHost('0.0.0.0')
// Spawn a local stdio MCP server as a provider slot.
.withStdioUpstream('fs', 'npx', ['-y', '@modelcontextprotocol/server-filesystem', '/data'])
// Optional: serve a dev harness or admin UI.
.withStaticMount('/', './public')
// TLS in two lines.
.withTlsFiles('./certs/cert.pem', './certs/key.pem')
.build()
await broker.start()
Every builder method is also reachable via an environment variable when running the broker as a CLI (see next section). Pick the surface that suits the deployment.
One folder, or one env var per knob
Two sources, env vars always win over the file. The file is the static baseline; env vars are deploy-specific overrides.
8.1JSON config file (recommended)
Drop a .mcp-broker/ folder next to where you launch the broker. Paths inside config.json are resolved against the folder, so it stays self-contained - certs, www, and grammar overrides all live next to the config.
.mcp-broker/
├── config.json ← broker configuration
├── certs/ ← TLS material (optional)
├── grammars/ ← local grammar overrides (optional)
└── www/ ← static dev harness (optional)
{
"port": 3001,
"locale": "fr",
"tls": {
"cert": "certs/cert.pem",
"key": "certs/key.pem"
},
"www": {
"open": false,
"mounts": [{ "urlPrefix": "/", "dir": "www" }]
},
"stdioUpstreams": [
{ "name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"] }
]
}
Drop additional grammars/<userAgent>/<locale>.json files to override packaged tool/resource descriptions for your organization - no fork required. Full reference in node/docs/config.md.
8.2Environment variables
| Variable | Default | Notes |
|---|---|---|
MCP_BROKER_CONFIG | ./.mcp-broker/config.json | Path to a JSON config file |
MCP_BROKER_PORT | 3000 | TCP port |
MCP_BROKER_HOST | 0.0.0.0 | Interface to bind |
MCP_BROKER_LOCALE | en | BCP-47 tag fed to the broker grammar resolver |
MCP_BROKER_PROVIDER_PATH | /provider | Provider WS prefix |
MCP_BROKER_CLIENT_PATH | / | Raw WS client prefix |
MCP_BROKER_MCP_PATH | /mcp | Streamable HTTP suffix |
MCP_BROKER_WWW_DIR | (unset) | Shortcut: mount this directory at / |
MCP_BROKER_BUNDLE_DIR | (unset) | Shortcut: mount this directory at /bundle |
MCP_BROKER_OPEN | (unset) | 1 to auto-open browser on startup |
MCP_BROKER_TLS_CERT | (unset) | Path to a PEM certificate. Enables HTTPS/WSS |
MCP_BROKER_TLS_KEY | (unset) | Path to a PEM private key. Enables HTTPS/WSS |
MCP_BROKER_PROTOCOL | auto | http forces plain, https forces TLS |
MCP_BROKER_STDIO_PROVIDER | (unset) | Bridge stdin/stdout to this provider (Claude Desktop) |
Env-var paths are resolved against process.cwd(); config-file paths are resolved against the config file's directory. Two different reference points by design - env is the deploy override, the file is the self-contained bundle.
The same tools, in the simulator and in the field
The providers do not have to be Node processes. The tunnel client also exists in C99, libmcpb, with no allocation and no platform header, and it runs in the two places that need to agree with each other: on the device, and in the simulator of that device.
McpBroker plugin: one game-instance subsystem, one multiplexed socket on /providers for every simulated device the engine hosts, handlers and link events on the game thread, libmcpb on a worker thread. An engine is one process; it gets one socket._all. The agent that learned to drive pump-1 in the simulator drives the real pump-1 with the same calls: nothing on its side changes except which provider answers. The broker does not care whether a slot is a game object or a board on a bench.mcpb_esp ESP-IDF component: one FreeRTOS task, esp-tls, link events on esp_event, 7.4 KB of flash and 13 KB of RAM. One socket per board on /provider/<name>, reconnection with a jittered window, a slot that comes back by itself after a reboot. Validated on an Arduino Nano ESP32 under an overnight soak.| Piece | What it is | Where |
|---|---|---|
libmcpb |
The tunnel client in C99: RFC 6455 client, dedicated and multiplexed endpoints, reconnection, immediate link events with the peer's refusal. Six-function port, 153 checks without a network. The multiplexed endpoint is opt-in at build time, so a one-provider firmware carries none of it. | c/libmcpb |
mcpb_esp |
The ESP-IDF component and a Wi-Fi sample. Built for the ESP32-S3 in CI with the official IDF image. | c/espressif |
McpBroker |
The Unreal Engine 5 plugin and a host project with no content: two echo slots on one socket, built and run on UE 5.7. | c/unreal |
| ports | The six functions per platform: host (Linux, macOS, Windows), espressif (esp-tls, lwip), unreal (ISocketSubsystem). Every port is the same shape, and the library is compiled from its single copy everywhere. |
c/ports |
The C side is transport only. libmcpb carries JSON-RPC bytes and never interprets them, so the MCP layer stays whatever the host already has: a C++ server on the board and in the engine, mcp-core in Node or a browser. That boundary is what lets one implementation, benched against a fake link, run unchanged from a 240 KB microcontroller to a game engine.
Install
# Run without installing
$ npx @cyanmycelium/mcp-broker
# Or install globally
$ npm install -g @cyanmycelium/mcp-broker
$ mcp-broker
# Or as a dependency of a larger Node service
$ npm install @cyanmycelium/mcp-broker
Requires Node 20.11+. Apache-2.0.