
@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 }
]
}
Tool descriptions tuned per client, per locale
Tool, property, resource, and resource-template descriptions live in JSON files - not in the code. The broker ships a matrix of grammars indexed by (userAgent, locale), and the session resolver picks the right combination when a client connects.
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. Add a new locale or a new user-agent family by dropping a file. No code change.
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.
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.
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.