Read this file first. It is written for an AI coding agent told “embed mcp-broker into this project” with no prior knowledge, and it is meant to be enough on its own.
A running broker documents itself over MCP on the reserved _broker slot (and through _all, which aggregates it). If you can reach one, do that instead of reading this file:
| call | gives you |
|---|---|
broker_guide({ topic? }) |
six Markdown pages: index, publish-provider, connect-client, host-config, deploy, troubleshooting |
resources broker://guide/{topic} |
the same pages, as MCP resources |
broker_diagnose({ slot? }) |
live state plus proven problems, each with symptom, evidence, fix; since 1.3.1 it also reads _all membership (aggregate-empty, aggregate-missing-live-slots) |
broker_info |
identity, version, uptime, bind address, TLS, the effective URL paths |
providers_list / provider_status({ name }) |
every slot / one slot in detail |
The guide is generated from the broker’s own source (node/packages/broker/src/broker/broker.guides.ts), and it injects the deployment’s effective configuration into every page. When it disagrees with this file, it wins. When something is broken, call broker_diagnose before reading anything.
No broker to ask, or no access to GitHub? The website publishes every document as Markdown: start at llms.txt or at the architecture page for agents (on the site), which maps every layer to its specification and its source. The six guide pages are also in docs/guide/, regenerated from broker.guides.ts.
One process that puts N MCP servers behind one host and one port. Each server occupies a named slot. Servers reach a slot from the inside as providers; MCP clients reach a slot from the outside. Slots are lazy: any name works, nothing is declared in advance.
| your situation | do this |
|---|---|
| You need a broker, and you do not own a Node process that should host it | Run the process: npx @cyanmycelium/mcp-broker |
| Your app already owns an HTTP port, or hosts MCP servers in-process | Embed the library: new WsTunnelBuilder()...build(), then await tunnel.start() |
| You are wiring Claude Desktop or another stdio MCP host | Run the process with MCP_BROKER_STDIO_PROVIDER=_all (see §4) |
| You are writing an MCP client | Install nothing of ours. Point a standard MCP client at http://<host>/<slot>/mcp |
| You are writing tests that need a broker, callers with identities, or provider identities | startTestBroker() from @cyanmycelium/mcp-broker/testing: a real broker, tokens are caller names, no authorization server (guide) |
| I am building | install | class / entry point | connects to |
|---|---|---|---|
| The broker, as a process | @cyanmycelium/mcp-broker |
npx @cyanmycelium/mcp-broker |
listens on :3000 |
| The broker, embedded in my app | @cyanmycelium/mcp-broker |
WsTunnelBuilder |
listens on the port you give it |
| An MCP server published into one slot | @cyanmycelium/mcp-broker-provider |
DirectTransport |
ws://<host>/provider/<name> |
| Several MCP servers from one page/process | @cyanmycelium/mcp-broker-provider |
MultiplexTransport |
ws://<host>/providers |
| An MCP server in the same process as the broker | @cyanmycelium/mcp-core |
LoopbackTransport + tunnel.registerLoopbackProvider(name, transport) |
no socket at all |
| An MCP client | nothing of ours | any spec-compliant MCP client | http://<host>/<slot>/mcp |
| Python functions published into a slot | python/ + its CMake native build |
mcp_provider.Provider + @provider.tool |
ws://<host>/provider/<name>, through c/libmcpb and ctypes |
| Several Python MCP servers on one connection | python/ + OpenSSL |
mcp_provider.Multiplex with Provider catalogs (tools, resources, prompts) |
ws[s]://<host>/providers, through c/libmcpb |
| A local stdio MCP server the broker should run | nothing | config key stdioUpstreams[] |
broker spawns it |
| A remote MCP server the broker should front | nothing | config key mcpServers[] |
broker dials out |
A signed .mcpb bundle the broker should run |
nothing | config key mcpbBundles[] |
broker verifies, unpacks, spawns |
| An MCP server on an ESP32 (or any C99 firmware) | c/libmcpb + a port; on ESP-IDF the mcpb_esp component |
mcpb_provider_t / mcpb_esp_start |
ws://<host>/provider/<name> |
| Several MCP servers from Unreal Engine 5 (or any native process) | the McpBroker plugin, built on c/libmcpb |
UMcpBrokerSubsystem::Connect |
ws://<host>/providers, one socket for all slots |
The _all opt-in rides on one frame, {"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}, sent first on the socket by provider 0.2.0 and by libmcpb. A broker older than 1.3.0 does not know that frame: the slot works, the provider stays out of _all. Pair provider 0.2.x and c/ with broker 1.3.0 or later.
┌──────────────────────────────────────────────────────────────────────────┐
│ A PROVIDER'S TRANSPORT AND ITS URL PATH ARE A MATCHED PAIR. │
│ │
│ DirectTransport <-> ws://<host>/provider/<name> │
│ plain JSON-RPC frames, one slot per socket │
│ │
│ MultiplexTransport <-> ws://<host>/providers │
│ envelopes { provider, payload }, N slots │
│ │
│ ws://<host>/providers/<name> IS NEITHER. It falls through to the │
│ client branch and is accepted as an MCP *client* on a slot literally │
│ named "providers/<name>". Nothing errors. Your slot stays empty. │
└──────────────────────────────────────────────────────────────────────────┘
Both mismatches used to fail silently. As of 1.3.0 the broker detects each on the first frame, answers in the framing the peer can actually decode, and closes with code 1008 and a reason naming both corrections. Do not rely on that: older brokers stay silent.
Observable signatures, if you are debugging one:
/provider/<name>: provider_status shows connected: true, transport: "ws", pendingCount climbing and never falling. The client’s initialize never resolves./providers: your socket is open, and the slot never appears in providers_list at all. Clients get -32000 Provider "<n>" not connected.broker_diagnose reports the first as transport-path-mismatch.
{
"mcpServers": {
"mcp-broker": {
"command": "npx",
"args": ["-y", "@cyanmycelium/mcp-broker"],
"env": {
"MCP_BROKER_STDIO_PROVIDER": "_all",
"MCP_BROKER_PORT": "3000",
"MCP_BROKER_HOST": "127.0.0.1"
}
}
}
}
Pin the bridge to _all, never to a real slot. The host sends initialize the instant it launches and treats a failure as a dead server: no retry, no backoff. Your provider does not exist yet, so a real slot answers -32000 Provider "<slot>" not connected and the host gives up permanently. _all is registered before the broker resumes stdin, answers initialize itself, already aggregates _broker, and pushes notifications/tools/list_changed when a provider joins later. _broker is the fallback if you want introspection only.
One broker per port. A second host entry spawning another broker cannot bind and dies; the host says Connection closed and the real EADDRINUSE diagnosis is only in mcp-server-<name>.log.
The bridge is anonymous. It reaches _all with no principal. Invisible until you enable authorization, at which point the host’s tool list silently empties.
A host that speaks Streamable HTTP does not need the bridge. Claude Code (.mcp.json), and any host with an http server type, can point straight at a slot of a broker you run once, on the side:
{
"mcpServers": {
"mcp-broker": { "type": "http", "url": "http://127.0.0.1:3000/_all/mcp" }
}
}
No second process and no start-order problem: the host talks to the slot when it needs it. _all for the tools, _broker for introspection only, /<slot>/mcp for one provider.
import { DirectTransport } from "@cyanmycelium/mcp-broker-provider";
import { McpServerBuilder } from "@cyanmycelium/mcp-core";
const transport = new DirectTransport("ws://localhost:3000/provider/my-slot", { aggregate: true });
const server = new McpServerBuilder().withName("my-slot").withTransport(transport).register(/* behaviors */).build();
await server.start();
// Release the slot, or a reload is refused with close code 1008 until the
// heartbeat notices the old socket is dead (up to one interval, 30 s).
addEventListener("pagehide", () => transport.close());
server.start() resolving means the socket reported itself open. It is not a connection guarantee and not proof the broker accepted the slot: a refusal arrives afterwards as a 1008 close whose reason says which refusal it was. Do not log “connected” there.{ aggregate: true } is what puts the slot in _all. Without it the slot is reachable at /<slot>/mcp but invisible to any stdio host bridged to _all.dist/, no browser field, no UMD.X-Provider-Token or Authorization: Bearer, and the browser WebSocket constructor cannot set headers. If the broker has a provider secret, every browser provider is refused with HTTP 401 that reaches your page as a bare error event. Run without a provider secret, or terminate provider auth in a reverse proxy.globalThis.WebSocket before connecting; Node 22+ has one.import { WsTunnelBuilder } from "@cyanmycelium/mcp-broker";
import { LoopbackTransport, McpServerBuilder } from "@cyanmycelium/mcp-core";
const tunnel = new WsTunnelBuilder()
.withPort(3000)
.withHost("127.0.0.1")
.withAllowedOrigins(["http://localhost:5173"]) // browsers are refused until listed
.withStaticMount("/", "/abs/path/to/www")
.withStdioUpstream({ name: "fs", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"], aggregate: true })
.build();
await tunnel.start(); // rejects on EADDRINUSE with a message naming the port and the fix
// An MCP server in this same process: no socket, no framing to get wrong.
const [serverEnd, clientEnd] = LoopbackTransport.createPair();
const server = new McpServerBuilder().withTransport(serverEnd).register(/* behaviors */).build();
await server.start();
tunnel.registerLoopbackProvider("in-process", clientEnd);
// ... on shutdown
await tunnel.stop();
| slot | what it is | anti-goal |
|---|---|---|
_broker |
The broker’s own introspection MCP server: broker_info, providers_list, provider_status, broker_guide, broker_diagnose, plus resources broker://info, broker://providers, broker://providers/{name}, broker://guide/{topic} |
It proxies nothing. It is not a route to other slots. |
_all |
An aggregate presenting the union of the tools and prompts of every opted-in provider as one MCP server | It is not a proxy and not automatic. |
_all, precisely:
{ aggregate: true } on the transport). stdioUpstreams[] entries need "aggregate": true. mcpServers[] and mcpbBundles[] are aggregated by default; set "aggregate": false to opt out. That asymmetry is real. _broker is always in.<slot>-<original>, descriptions tagged [<slot>] .... Never reconstruct a prefixed name: it is capped at 64 characters, an overlong one is truncated and hash-suffixed, and a collision is broken with -2, -3. Call tools/list and pass the returned string back verbatim.initialize, ping, tools/list, tools/call, prompts/list, prompts/get. Everything else, resources/list and resources/read included, returns -32601 Method not found. For resources, connect to the provider’s own slot.notifications/tools/list_changed and notifications/prompts/list_changed, so a provider that appears mid-session shows up without a reconnect./provider/_all is refused with Provider "_all" is reserved by the broker._broker, for events: broker://providers and broker://providers/<name> accept resources/subscribe. A slot appearing, a provider attaching or detaching, a slot joining or leaving _all each send notifications/resources/updated (batched per tick). Counters (pendingCount, clientCount, sessionCount) never notify: reading moves them. A slot name is percent-encoded in its URI (a/b is broker://providers/a%2Fb).
_all)// Client side: any MCP client. With mcp-core's McpClient:
client.onResourceUpdated.subscribe((uri) => reread(uri));
await client.subscribeResource("plant://gauge");
// Provider side, with mcp-core >= 1.3.0: the server answers subscribe/unsubscribe
// itself. A behavior only reports changes; an McpAdapterBase does it with:
this._forwardResourceContentChanged("plant://gauge");
resources/subscribe; the last one leaving sends one resources/unsubscribe. Concurrent subscribes are serialized per URI.notifications/resources/updated reaches only the sessions subscribed to that exact params.uri (no normalization). One without a usable uri is dropped and logged, never broadcast. Every other notification is still broadcast to the slot.DELETE /<slot>/mcp, stdio close, close() of a tunnel.openInternalClient() handle, and broker stop. In-process clients subscribe like any other. A Streamable HTTP session never deleted never expires, and neither do its subscriptions.initialize, notifications/initialized, then one subscribe per URI, and sends each subscriber one updated so it re-reads. A provider must install its message handler before its socket opens.resources/subscribe needs mcp.resources.read (broker.providers.read on _broker), and each update is re-checked per recipient; a recipient that lost the grant is unsubscribed. resources/unsubscribe is never refused.resourceSubscriptions in config.json, withResourceSubscriptionLimits(), or MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT / _PER_SLOT / MCP_BROKER_MAX_RESOURCE_URI_LENGTH): 64 URIs per client, 1024 client subscriptions per slot, 2048-character URIs. Past a limit: -32000; an overlong URI: -32602.A provider that serves its own kind of resource (SCADA first) can make the broker its decision point instead of carrying a policy.
providers table ({ id, secretEnv, subjects?, allowedResources? }), or a principal passed to registerLoopbackProvider(name, transport, { principal }). The shared secret identifies nobody and is refused.await transport.broker.declare({ version, domain, namespace: { resource }, capabilities, resources?, protects?, resultsRequired? }). A resource may carry engineering limits (minValue, maxValue, allowedValues, destinations; any other key refuses the declaration). Describes, grants nothing: assignments / roles / denies, a capability outside <domain>.*, a path outside the namespace or outside allowedResources refuse it whole, error.data.errors lists every problem.(provider identity, slot): declaring again replaces only that slot. One identity may declare several domains or the same domain on several slots, with overlapping namespaces. Authorization, concrete resource checks, provider limit patterns, result promises and budget units use the originating slot’s declaration; operator limits remain global. A domain is shared by subtree (unreleased): the first identity whose declaration of a domain on a subtree is accepted owns that subtree; another identity may own a sibling subtree with the same capabilities and resultsRequired, and is refused on a nested one (namespace "<n>" overlaps "<m>", owned by provider "<id>") or with another vocabulary (capability vocabulary differs from the domain's). authorization.domains in the security file ({ "<domain>": [{ "owner", "namespace" }] }, withDomains()) fixes the owners from startup; overlapping configured subtrees stop the start. Ownership lasts until withdrawn (below), even after disconnect or replacement by another domain. broker://domains lists, per domain, each subtree’s owner, slot and state, filtered to the subtrees the reader holds one of the domain’s capabilities on (all of them with broker.authorization.admin). broker_info and broker://authority list each declaration’s slot and domain; decision and result audits carry both. Prefer qualified native identifiers such as vannes:/site/nord/valves/V-012 in checks; their format is not enforced.await transport.broker.withdraw() sends broker/authorization/withdraw, with no parameter, at a clean shutdown or before another identity takes the domain over: the slot’s declaration goes, with every subtree that slot owned (domains in the answer). Idempotent (withdrawn: false when nothing was declared). For a provider that is gone, an operator calls broker_declaration_withdraw({ principal, slot? }) on _broker (needs broker.authorization.admin, audited with the operator’s subjects). A disconnection withdraws nothing; past withDeclarationOfflineThreshold() (5 min) broker_diagnose reports declaration-owner-offline. Protected slots stay closed either way: they come from the security file.params._meta["io.cyanmycelium/caller"] = { ref, correlationId, traceId? }, valid on that slot while that request is pending. correlationId is the client’s X-Correlation-Id when a Streamable HTTP or SSE client sent a usable one, otherwise the broker’s. The broker strips the key from every client frame, on every slot, _all included; calls through _all carry the caller of _all. With mcp-core 1.4.0 an adapter reads it from request?.meta (callerReferenceOf() in the provider package).await transport.broker.authorize({ principal: { type: "caller-ref", ref }, checks: [{ capability, resource, resourcePath, attributes? }] }), or principal: { type: "provider" } for the provider’s own work. One audited decision per check: { decisionId, effect, allowed, reason, policies?, obligations? }. An allow on a resource with declared limits is effect: "allow-with-constraints" with them in obligations.constraints, and allowed: false so a provider that ignores obligations refuses rather than oversteps. Only resourcePath is evaluated; resource is the native id, for the audit.limits in the security file, see node/packages/broker/docs/execution-limits.md): a budget reservation on a resource with declared limits returns effect: "allow-with-constraints" and the constraints; apply them. A stuck call holding a concurrency slot shows in broker_diagnose (limits-calls-held) and is released with broker_limits_release({ slot, requestId }) (needs broker.limits.admin); onTimeout: "release" on a rule frees its slots at the deadline.transport.broker.reportResult({ decisionId, result, nativeStatus?, errorCode? }) sends the broker/audit/result notification; the broker audits it next to the decision (phase: "result"). Decisions of capabilities listed in resultsRequired that stay unreported past withAuthorizationResultTimeout() (60 s) show up in broker_diagnose as decision-result-overdue.authorization.protectedSlots), closed from startup: only the declarer’s subjects may call one, only publishedBy may publish it, never in _all. A declaration confirms them in protects; it cannot create one.new DirectTransport(url, { secret }) (or MultiplexTransport.create(name, url, { secret })) sends X-Provider-Token. Node 22+ only; a browser cannot.startTestBroker({ callers, providers, policy, protectedSlots }) from @cyanmycelium/mcp-broker/testing does all of the above with no authorization server; see docs/testing.md.securityFile in config.json or MCP_BROKER_SECURITY_FILE. Holds auth, providers, authorization. Fails closed; no secret in clear; auth in both files refuses the start. Its hash starts every policyVersion (broker_info).{ resourcePattern: "/nord/valves/{id}", where: { id: "V-1\\d{2}" }, limits: { maxValue: 60 } } in resources, alongside concrete entries. A pattern cannot carry resource, resourcePath or effect. Literal segments, *, a final ** and named {segment} are supported; where only constrains named segments, with full-segment RE2 matching. Patterns must stay inside the namespace and provider allowedResources.authorization.resourceLimits: [{ id, pattern, where?, limits }] in the security file, or use withResourceLimits() when embedding. Rules may precede declarations and remain active until removed. They grant no rights. Their file hash changes policyVersion.empty-limits, including lists incompatible with the numeric range. Budgets use the same intersection. Providers must still apply obligations.constraints.limitSources. broker_info and broker://authority expose declared patterns and operator limits. broker_diagnose reports recent empty intersections and rejected RE2 expressions, with bounded history.RegExp arguments, are evaluated with re2js. Unsupported lookarounds, backreferences or flags refuse startup. i, m, s, u, g, y are accepted; g is stateless and y anchors at the start. Existing fixed syntax validators are unchanged. Run npm run bench:limits --workspace @cyanmycelium/mcp-broker from node/ for indexed and worst-case overlapping pattern measurements.broker/telemetry, never notifications/telemetry. The broker consumes it and never broadcasts it to MCP clients.params._meta.traceparent. A valid caller value is preserved; an absent or malformed one is replaced.traceparentOf, withTraceparent, childTraceparent and transport.broker.span(span) from @cyanmycelium/mcp-broker-provider.mcpb_traceparent_parse, mcpb_traceparent_format and mcpb_telemetry_encode. All are allocation-free and caller-buffered.correlationId and W3C traceId are deliberately distinct but linked in caller references and authorization audit events.broker_diagnose reports the telemetry counters and warns on exporter failures or queue-full drops. Tests use startTestBroker({ telemetry: true }) and inspect the snapshot at broker.spans.docs/telemetry.md.| symptom | cause | fix |
|---|---|---|
Client hangs on initialize, provider shows connected: true |
MultiplexTransport on /provider/<name> |
move to ws://<host>/providers, or switch to DirectTransport |
Provider socket open, slot never appears in providers_list |
DirectTransport on /providers |
move to ws://<host>/provider/<name>, or switch to MultiplexTransport |
Provider on /providers/<name>, nothing works |
that path is neither endpoint, it is a client slot | drop the name (multiplex) or add /provider/ (dedicated) |
WebSocket closes 1008 Slot already held by a live provider after a reload |
a stale socket still holds the slot and answered the last heartbeat | release on pagehide; under the default providerTakeover: "liveness" the newcomer takes the slot as soon as the incumbent misses a ping (one interval, default 30 s). "reject" never hands over; "always" needs providerAuth and the same principal, else it falls back to "liveness" and says so |
MCP host: Connection closed, no cause |
a second broker could not bind the port | one broker per port; read mcp-server-<name>.log for EADDRINUSE |
HTTP 403 invalid_origin from a page the broker itself serves |
a static mount does not exempt the origin it serves | list that exact origin (scheme and port included) in allowedOrigins / MCP_BROKER_ALLOWED_ORIGINS |
Provider "<slot>" not connected at host start |
ordering: the host starts before any provider exists | point MCP_BROKER_STDIO_PROVIDER at _all |
_all shows only _broker-* tools |
nothing opted in | see §5; verify with tools/list on _all, never assume an opt-in took |
| Host’s tool list goes empty after enabling authorization | the stdio bridge is anonymous | grant the anonymous subject, or stop bridging in authorized deployments |
Browser provider gets a bare error event |
provider auth is on; a browser cannot send the header | run without a provider secret, or authenticate in a proxy |
-32601 Method not found on _all |
_all covers tools and prompts only |
use the provider’s own slot |
-32602 Unknown aggregated tool |
you built the prefixed name yourself | re-run tools/list, pass the name back verbatim |
Request errors with did not respond within 60000ms |
the provider never answered | raise providerRequestTimeoutMs, or fix the provider |
Provider gets -32601 back for sampling/createMessage, roots/list, elicitation/create |
the broker relays no request opened by a provider and answers it at once (1.4.1; earlier brokers dropped it silently). ping is answered {} |
do not depend on server-to-client requests behind the broker |
Broker exits at start with Cannot use the config file |
the config file is missing (when designated), not valid JSON, or not an object; since 1.4.1 the broker fails closed instead of starting with no security settings | fix the file the message names |
Subscribed, notifications/resources/updated never arrives |
the update names another URI (matching is exact), has no params.uri (dropped, logged once), or the read grant was revoked (unsubscribed) |
compare URIs byte for byte; read the broker log |
resources/subscribe answers -32601 on a provider slot |
the provider does not implement it (mcp-core before 1.3.0 did not) | upgrade the provider to mcp-core 1.3.0 or implement the method |
-32000 Subscription limit reached |
maxSubscriptionsPerClient / maxSubscriptionsPerSlot |
unsubscribe what you no longer watch, or raise resourceSubscriptions |
broker/authorization/declare refused: anonymous or shared secret |
declaring needs a provider identity of its own | add the provider to the security file’s providers table |
broker/authorization/declare refused: capability vocabulary differs from the domain's |
another identity owns a subtree of that domain with other capabilities or resultsRequired |
declare exactly the domain’s vocabulary (broker://domains lists it), or roll the new vocabulary out after the other owners withdraw |
broker/authorization/declare refused: not one of its owners or outside the subtree the security file assigns |
authorization.domains fixes who declares that domain, and where |
declare from a listed identity, inside its listed subtree, or change the security file |
broker/authorization/declare refused: overlaps ... owned by provider |
another identity owns a subtree of that domain containing, or inside, yours, possibly since before it disconnected: ownership is kept until withdrawn | declare a sibling subtree, declare from the owning identity, or withdraw the old declaration: broker/authorization/withdraw from its owner, broker_declaration_withdraw if it is gone (broker_diagnose reports it as declaration-owner-offline) |
broker/authorize: -32602 principal.ref is unknown, expired |
the reference’s request was already answered, or it is used on another slot | ask before answering, on the socket the request came on |
Every decision is no-matching-grant |
the caller is anonymous (client OAuth off) or nothing grants the capability | enable client auth; grant the capability in auth.assignments |
| Client refused on a protected slot (1008 / HTTP 403) | only the declarer’s own client identity may call it | the protection working; go through the declaring provider |
resourceSubscriptionCount in provider_status only grows |
HTTP clients leave without DELETE |
send the DELETE on shutdown |
start() resolving is not a connection guarantee, on either side of the tunnel.www static mount does not exempt the origin it serves from the origin check. Conversely, the origin check covers the HTTP client endpoints only (/<slot>/mcp, /<slot>/sse, /<slot>/messages): a WebSocket upgrade carries no origin check at all._all is not automatic, not a proxy, and does not carry resources._broker routes to nothing else..mcp-broker/grammars/<userAgent>/<locale>.json, MCP_BROKER_LOCALE) reword the _broker tools, resources and templates only. Provider tools are relayed as published, on their slot and in _all; a provider localizes its own descriptions in its own server.not connected.DELETE /<slot>/mcp leaves its session alive forever, with its resource subscriptions, and sessionCount / resourceSubscriptionCount growing monotonically is the only signal.notifications/resources/updated is not broadcast. Only subscribers of that exact URI get it.brokerName is library-only. It is a valid config.json key and WsTunnel honors it, but WsTunnelBuilder has no withBrokerName(), so the CLI never forwards it and setting it in the file changes nothing. enableBrokerProvider and enableAggregateProvider are IWsTunnelOptions fields only, not config-file keys at all. Confirm effective values with broker_info rather than assuming the file won.Start with samples/. Every sample starts what it needs, proves itself end to end, and marks the decisions that produce a silent failure. samples/index.json is the same content machine-readable: id, when to use it, the exact command, the files, and what success looks like. Run cd samples && npm install once; both dependencies are file: links, so it is offline and instant.
| path | what it shows |
|---|---|
samples/browser-provider/ ← start here |
an MCP server published from a browser page, with the four wrong choices marked inline |
samples/host-config/ |
the two-entry Claude Desktop pattern, driven over stdio and HTTP against one process |
samples/provider-lifecycle/ |
connect, 1008 refusal, release, broker crash, reconnect, narrated |
samples/app-host/ |
the broker as your app’s HTTP host: two mounts, www.open on a sub-path, the origin list |
samples/embedded/ |
WsTunnelBuilder in-process, with a loopback provider and no socket |
python/examples/ |
Python tools through the native C99 transport; node python/examples/complete.mjs starts the broker, provider and clients, verifies direct and _all calls, then stops them |
node/packages/broker/.mcp-broker.example/ |
a complete config template plus per-key guides in EN and FR; config.stdio-bridge.json is the stdioProvider: "_all" pairing |
node/packages/broker/web/demos/provider-tunnel/ |
a browser page hosting an MCP server and tunnelling it to a slot |
node/packages/broker/web/demos/broker-explorer/ |
a browser MCP client driving _broker, _all or any slot |
node/packages/broker/web/demos/oauth-lab/ |
a full local OAuth 2.1 + policy environment, npm run demo:oauth from node/packages/broker |
c/samples/host-provider/ |
the C client on a PC: dedicated or --multiplex, static echo tool, one line per link event |
c/espressif/samples/provider/ |
the same provider on an ESP32 over Wi-Fi, menuconfig for the broker host and slot name |
c/unreal/Sample/ |
the same provider from Unreal Engine 5, two slots on one socket, headless with -game -nullrhi |
c/tests/roundtrip/ and c/tests/soak/ |
the C client against the Node broker: slots, _all, broker kill and restart; and an overnight load with the numbers a leak would move |
Reference docs, deepest last: broker README → config reference → endpoints → protocol → architecture → authorization. For the device side: c/README.md → libmcpb → ESP-IDF → Unreal.
The CLI supports plugins[]: trusted ESM modules supplied by the deployment, not dependencies of the broker. See node/packages/broker/docs/plugins.md for API version 1, provider identities, lifecycle and Docker composition. Required plugins roll startup back on failure. Plugins stop before the tunnel. Never add a dependency on a particular slot implementation to the broker package. Build Docker from node/ with docker build -f packages/broker/Dockerfile -t mcp-broker ..