@cyanmycelium/mcp-broker 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +300 -44
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +313 -44
  3. package/.mcp-broker.example/README.md +73 -2
  4. package/.mcp-broker.example/config.json +18 -9
  5. package/.mcp-broker.example/config.stdio-bridge.json +16 -0
  6. package/README.md +407 -27
  7. package/dist/bin.js +215 -20
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-BZUZYXVA.js +5955 -0
  10. package/dist/chunk-BZUZYXVA.js.map +1 -0
  11. package/dist/grammars/claude/en.json +12 -0
  12. package/dist/grammars/claude/fr.json +12 -0
  13. package/dist/grammars/default/en.json +40 -0
  14. package/dist/grammars/default/fr.json +40 -0
  15. package/dist/grammars/default/zh.json +40 -0
  16. package/dist/index.d.ts +991 -25
  17. package/dist/index.js +1 -1
  18. package/package.json +3 -3
  19. package/src/auth/index.ts +3 -1
  20. package/src/auth/provider.auth.ts +126 -8
  21. package/src/authorization/policy.engine.ts +11 -2
  22. package/src/authorization/policy.types.ts +25 -1
  23. package/src/bin.ts +325 -28
  24. package/src/broker/adapters/broker.adapter.diagnose.ts +45 -0
  25. package/src/broker/adapters/broker.adapter.guide.ts +108 -0
  26. package/src/broker/aggregate/aggregate.server.ts +82 -15
  27. package/src/broker/aggregate/provider.client.session.ts +85 -11
  28. package/src/broker/behaviors/broker.behavior.diagnose.ts +47 -0
  29. package/src/broker/behaviors/broker.behavior.guide.ts +79 -0
  30. package/src/broker/broker.context.ts +65 -0
  31. package/src/broker/broker.diagnostics.ts +495 -0
  32. package/src/broker/broker.guides.ts +1029 -0
  33. package/src/broker/broker.server.ts +23 -7
  34. package/src/broker/broker.slots.ts +36 -0
  35. package/src/broker/grammars/claude/en.json +12 -0
  36. package/src/broker/grammars/claude/fr.json +12 -0
  37. package/src/broker/grammars/default/en.json +40 -0
  38. package/src/broker/grammars/default/fr.json +40 -0
  39. package/src/broker/grammars/default/zh.json +40 -0
  40. package/src/config.ts +191 -4
  41. package/src/index.ts +38 -3
  42. package/src/remote.transports.ts +127 -10
  43. package/src/remote.upstream.ts +4 -1
  44. package/src/ws/ws.interfaces.ts +148 -3
  45. package/src/ws/ws.tunnel.builder.ts +63 -1
  46. package/src/ws/ws.tunnel.ts +1150 -173
  47. package/web/README.md +31 -4
  48. package/dist/chunk-FTDKH2C4.js +0 -3670
  49. package/dist/chunk-FTDKH2C4.js.map +0 -1
@@ -4,12 +4,21 @@
4
4
  "locale": "fr",
5
5
  "brokerName": "broker-eu-west",
6
6
 
7
+ "allowedOrigins": ["https://app.factory.local", "https://mcp.factory.local"],
8
+
7
9
  "paths": {
8
- "provider": "/provider",
9
- "client": "/",
10
- "mcp": "/mcp"
10
+ "provider": "/provider",
11
+ "providers": "/providers",
12
+ "client": "/",
13
+ "mcp": "/mcp",
14
+ "sse": "/sse",
15
+ "messages": "/messages"
11
16
  },
12
17
 
18
+ "providerHeartbeatIntervalMs": 30000,
19
+ "providerRequestTimeoutMs": 60000,
20
+ "providerTakeover": "liveness",
21
+
13
22
  "tls": {
14
23
  "cert": "certs/cert.pem",
15
24
  "key": "certs/key.pem"
@@ -23,7 +32,7 @@
23
32
  },
24
33
 
25
34
  "auth": {
26
- "enabled": true,
35
+ "enabled": false,
27
36
  "publicBaseUrl": "https://mcp.factory.local",
28
37
  "authorizationServers": [
29
38
  "https://identity.factory.local"
@@ -104,15 +113,15 @@
104
113
  },
105
114
  "audit": {
106
115
  "logAllowed": false
107
- },
108
- "providerSecret": "change-me"
116
+ }
109
117
  },
110
118
 
111
119
  "stdioUpstreams": [
112
120
  {
113
- "name": "fs",
114
- "command": "npx",
115
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
121
+ "name": "fs",
122
+ "command": "npx",
123
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
124
+ "aggregate": true
116
125
  }
117
126
  ],
118
127
 
@@ -0,0 +1,16 @@
1
+ {
2
+ "port": 3001,
3
+ "host": "127.0.0.1",
4
+ "locale": "en",
5
+
6
+ "stdioProvider": "_all",
7
+
8
+ "stdioUpstreams": [
9
+ {
10
+ "name": "fs",
11
+ "command": "npx",
12
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
13
+ "aggregate": true
14
+ }
15
+ ]
16
+ }
package/README.md CHANGED
@@ -10,7 +10,17 @@
10
10
 
11
11
  WebSocket-based [Model Context Protocol](https://modelcontextprotocol.io/) broker. Aggregates multiple MCP providers behind a single endpoint, with WebSocket, Streamable HTTP, SSE, and stdio client transports.
12
12
 
13
- > This is the Node/TypeScript implementation of the broker. Architecture, wire protocol, and endpoints are documented language-neutrally in [../docs](../docs).
13
+ > This is the Node/TypeScript implementation of the broker. Architecture, wire protocol, and endpoints are documented language-neutrally in the [repository `docs/` folder](https://github.com/pandaGaume/mcp-broker/tree/main/docs), which is not shipped in the npm package. Every link out of this file is therefore absolute.
14
+
15
+ | I want to | go to |
16
+ |---|---|
17
+ | publish my MCP server into a slot | [the pairing rule](#the-one-rule-that-costs-the-most-time) |
18
+ | wire Claude Desktop or another stdio host | [Claude Desktop integration](#claude-desktop-integration) |
19
+ | reach a slot as a client | [Install and run](#install-and-run), [Browser origins](#browser-origins) |
20
+ | know what `_broker` and `_all` are | [Reserved slots](#reserved-slots-_broker-and-_all) |
21
+ | configure the process | [Configuration](#configuration) |
22
+ | embed the broker in my own process | [Programmatic API](#programmatic-api) |
23
+ | fix something that is already broken | [Troubleshooting](#troubleshooting), or call `broker_diagnose` |
14
24
 
15
25
  ## Install and run
16
26
 
@@ -28,6 +38,47 @@ The broker starts on `http://localhost:3000` by default.
28
38
  - Connect your MCP provider to: `ws://localhost:3000/provider/<name>`
29
39
  - Point an MCP client at: `http://localhost:3000/<name>/mcp` (Streamable HTTP) or `http://localhost:3000/<name>/sse` (legacy SSE) or `ws://localhost:3000/<name>` (raw WS)
30
40
 
41
+ ### The broker documents itself
42
+
43
+ Once it is running you do not need this README. The reserved `_broker` slot
44
+ serves the integration guide over MCP, and diagnoses the deployment:
45
+
46
+ | call it with | and you get |
47
+ |---|---|
48
+ | `broker_guide({ topic? })`, or read `broker://guide/{topic}` | six Markdown pages (`index`, `publish-provider`, `connect-client`, `host-config`, `deploy`, `troubleshooting`) with **this** broker's effective configuration injected into each |
49
+ | `broker_diagnose({ slot? })` | live state plus the problems the broker can prove, each with `symptom`, `evidence` and a `fix` |
50
+
51
+ Point any MCP client at `http://localhost:3000/_broker/mcp` and call them. From
52
+ a stdio host bridged to `_all` they are named `_broker-broker_guide` and
53
+ `_broker-broker_diagnose`. When something does not work, call `broker_diagnose`
54
+ before reading anything: it does the correlation for you and names the fix.
55
+
56
+ ### The one rule that costs the most time
57
+
58
+ A provider's transport and its URL path are a matched pair:
59
+
60
+ ```
61
+ DirectTransport <-> ws://<host>/provider/<name> plain JSON-RPC frames
62
+ MultiplexTransport <-> ws://<host>/providers envelopes { provider, payload }
63
+ ```
64
+
65
+ `ws://<host>/providers/<name>` is **neither**. The router matches `/providers`
66
+ exactly and `/provider/` as a prefix, so a URL starting with `/providers/` falls
67
+ through to the client branch and is accepted as an MCP *client* on a slot named
68
+ `providers/<name>`. Nothing errors; your slot stays empty.
69
+
70
+ Both mismatches are detected on the first frame and refused with WebSocket close
71
+ code `1008` and a reason naming the correction. Older brokers stay silent, so
72
+ the observable signatures are worth knowing:
73
+
74
+ - **MultiplexTransport on `/provider/<name>`**: `provider_status` reports
75
+ `connected: true`, `transport: "ws"`, `pendingCount` climbing and never
76
+ falling, while the client's `initialize` never resolves.
77
+ - **DirectTransport on `/providers`**: your socket is open and the slot never
78
+ appears in `providers_list` at all.
79
+
80
+ `broker_diagnose` reports the first as `transport-path-mismatch`.
81
+
31
82
  ## Configuration
32
83
 
33
84
  Two sources, env vars **always win** over the file. The file is the static baseline you ship with the broker; env vars are deploy-specific overrides.
@@ -53,10 +104,7 @@ Minimal `config.json`:
53
104
  {
54
105
  "port": 3001,
55
106
  "locale": "fr",
56
- "tls": {
57
- "cert": "certs/cert.pem",
58
- "key": "certs/key.pem"
59
- },
107
+ "allowedOrigins": ["http://localhost:3001"],
60
108
  "www": {
61
109
  "open": false,
62
110
  "mounts": [{ "urlPrefix": "/", "dir": "www" }]
@@ -65,42 +113,188 @@ Minimal `config.json`:
65
113
  {
66
114
  "name": "fs",
67
115
  "command": "npx",
68
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
116
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
117
+ "aggregate": true
69
118
  }
119
+ ],
120
+ "mcpServers": [
121
+ { "name": "geo", "url": "https://geo.example.com/mcp" }
70
122
  ]
71
123
  }
72
124
  ```
73
125
 
74
- Start from the [`.mcp-broker.example/`](.mcp-broker.example/) template:
126
+ Three ways to give the broker a provider without writing any code:
127
+ `stdioUpstreams[]` spawns a child process, `mcpServers[]` dials out to a remote
128
+ MCP server by URL, and `mcpbBundles[]` verifies and runs a signed `.mcpb`
129
+ bundle. Note that `stdioUpstreams` entries are **not** in `_all` unless you
130
+ write `"aggregate": true`, while the other two are in unless you write
131
+ `"aggregate": false`.
132
+
133
+ Start from the [`.mcp-broker.example/`](.mcp-broker.example/) template, which
134
+ ships with authorization off and every key annotated:
75
135
 
76
136
  ```sh
77
137
  cp -r node_modules/@cyanmycelium/mcp-broker/.mcp-broker.example .mcp-broker
78
138
  ```
79
139
 
80
- Full reference (every field, defaults, recipes, grammar overrides): **[docs/config.md](docs/config.md)**.
140
+ Full reference (every field, defaults, recipes, grammar overrides): **[docs/config.md](https://github.com/pandaGaume/mcp-broker/blob/main/node/packages/broker/docs/config.md)**.
81
141
 
82
142
  ### Option B: Environment variables
83
143
 
144
+ This table is complete: it lists every `MCP_BROKER_*` variable the CLI reads.
145
+
84
146
  | Variable | Default | Notes |
85
147
  |---|---|---|
86
148
  | `MCP_BROKER_CONFIG` | `./.mcp-broker/config.json` | Path to a JSON config file (see above) |
87
149
  | `MCP_BROKER_PORT` | `3000` | TCP port to listen on |
88
150
  | `MCP_BROKER_HOST` | `0.0.0.0` | Interface to bind |
89
- | `MCP_BROKER_PROVIDER_PATH` | `/provider` | Prefix for provider WS connections |
151
+ | `MCP_BROKER_LOCALE` | `en` | BCP-47 tag driving the `_broker` tool descriptions |
152
+ | `MCP_BROKER_ALLOWED_ORIGINS` | (unset) | Comma-separated browser origins allowed on the client endpoints. **Unset means no browser origin passes.** See below |
153
+ | `MCP_BROKER_PROVIDER_PATH` | `/provider` | Prefix for dedicated provider WS connections (`DirectTransport`, plain frames) |
154
+ | `MCP_BROKER_PROVIDERS_PATH` | `/providers` | Exact path for multiplexed provider WS connections (`MultiplexTransport`, envelopes) |
90
155
  | `MCP_BROKER_CLIENT_PATH` | `/` | Prefix for raw WS clients |
91
- | `MCP_BROKER_MCP_PATH` | `/mcp` | Suffix for Streamable HTTP |
156
+ | `MCP_BROKER_MCP_PATH` | `/mcp` | Per-slot suffix for Streamable HTTP |
157
+ | `MCP_BROKER_SSE_PATH` | `/sse` | Per-slot suffix for the legacy SSE stream |
158
+ | `MCP_BROKER_MESSAGES_PATH` | `/messages` | Per-slot suffix for legacy SSE posts |
159
+ | `MCP_BROKER_PROVIDER_HEARTBEAT_MS` | `30000` | ws-level ping interval on provider sockets. `0` disables |
160
+ | `MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS` | `60000` | How long a provider has to answer one request before it is failed. `0` disables |
161
+ | `MCP_BROKER_PROVIDER_TAKEOVER` | `liveness` | `reject`, `liveness` or `always` when a second provider claims an occupied slot |
92
162
  | `MCP_BROKER_WWW_DIR` | (unset) | If set, serve this directory at `/` |
93
163
  | `MCP_BROKER_BUNDLE_DIR` | (unset) | If set, serve this directory at `/bundle` |
94
- | `MCP_BROKER_OPEN` | (unset) | `1` to auto-open the browser at the root URL on startup (requires `MCP_BROKER_WWW_DIR`) |
164
+ | `MCP_BROKER_OPEN` | (unset) | `1` opens the broker root on startup; a `/path` or a same-origin absolute URL opens that page. Opens only when a static mount actually covers the resolved path |
95
165
  | `MCP_BROKER_TLS_CERT` | (unset) | Path to a PEM certificate. Enables HTTPS/WSS |
96
166
  | `MCP_BROKER_TLS_KEY` | (unset) | Path to a PEM private key. Enables HTTPS/WSS |
97
167
  | `MCP_BROKER_PROTOCOL` | auto | `http` forces plain, `https` forces TLS, unset auto-detects from cert+key |
98
- | `MCP_BROKER_STDIO_PROVIDER` | (unset) | When set, bridge stdin/stdout JSON-RPC to this provider (Claude Desktop integration) |
168
+ | `MCP_BROKER_STDIO_PROVIDER` | (unset) | When set, bridge stdin/stdout JSON-RPC to this slot. **Use `_all`**, see [Claude Desktop integration](#claude-desktop-integration) |
99
169
  | `MCP_BROKER_AUTH_ENABLED` | (unset) | `1` to turn on the OAuth 2.1 resource server (requires the three below) |
100
170
  | `MCP_BROKER_PUBLIC_BASE_URL` | (unset) | Public origin used to build canonical resource URIs, e.g. `https://mcp.example.com` |
101
171
  | `MCP_BROKER_JWKS` | (unset) | Authorization server's JWKS URL, used to verify token signatures |
102
172
  | `MCP_BROKER_ISSUER` | (unset) | Expected token issuer (defaults to the sole authorization server) |
103
- | `MCP_BROKER_PROVIDER_SECRET` | (unset) | Shared secret every provider must present to occupy a slot |
173
+ | `MCP_BROKER_PROVIDER_SECRET` | (unset) | Shared secret every provider must present to occupy a slot. **Not gated by `auth.enabled`**: setting it alone turns provider authentication on |
174
+
175
+ `mcp-broker --help` prints the same list against the running build.
176
+
177
+ ### Browser origins
178
+
179
+ Client endpoints (`/<slot>/mcp`, `/<slot>/sse`, `/<slot>/messages`) refuse any
180
+ request carrying an `Origin` header that is not allowed, with `403` and an
181
+ `invalid_origin` body. **The allow list is empty by default**, so out of the box
182
+ no browser origin passes while every non-browser client, which sends no
183
+ `Origin`, passes unchanged. This is the DNS-rebinding protection the MCP
184
+ specification asks for.
185
+
186
+ **A static mount does not exempt the origin it serves.** A page the broker
187
+ itself serves at `http://localhost:3000/` is still a browser origin and must be
188
+ listed:
189
+
190
+ ```sh
191
+ MCP_BROKER_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
192
+ ```
193
+
194
+ Match the scheme, host and port exactly as the browser sends them. If the broker
195
+ runs with TLS, the origin is `https://`, not `http://`.
196
+
197
+ Scope, precisely: the check covers the three **HTTP** client endpoints. A raw
198
+ WebSocket upgrade (`ws://<host>/<slot>`) and both provider endpoints are not
199
+ origin-checked, so `allowedOrigins` is not a substitute for authentication.
200
+
201
+ Full reference, including the regular-expression form and the
202
+ `withAllowedOrigins` predicate, in [docs/config.md](https://github.com/pandaGaume/mcp-broker/blob/main/node/packages/broker/docs/config.md#allowedorigins).
203
+
204
+ ## Reserved slots: `_broker` and `_all`
205
+
206
+ Two slot names are reserved. A provider that tries to claim either is refused
207
+ with `Provider "<name>" is reserved by the broker`.
208
+
209
+ ### `_broker`, introspection
210
+
211
+ The broker registers **itself** as a provider under `_broker`, over an
212
+ in-process loopback transport. Reach it through any client transport, e.g.
213
+ `http://localhost:3000/_broker/mcp`. It proxies nothing: it is not a route to
214
+ other slots.
215
+
216
+ | tool | arguments | returns |
217
+ |---|---|---|
218
+ | `broker_info` | none | `{ name, version, uptimeSeconds, host, port, tls, paths }` |
219
+ | `providers_list` | none | every slot known to the broker, connected or not |
220
+ | `provider_status` | `{ name }` | one slot in detail |
221
+ | `broker_guide` | `{ topic? }` | one of the six guide pages; no argument returns the index |
222
+ | `broker_diagnose` | `{ slot? }` | live state plus proven problems, each with `symptom`, `evidence`, `fix` |
223
+
224
+ Matching resources for clients that prefer `resources/read`: `broker://info`,
225
+ `broker://providers`, the template `broker://providers/{name}`, the six
226
+ `broker://guide/<topic>` pages and the template `broker://guide/{topic}`.
227
+
228
+ Each provider entry from `providers_list` / `provider_status`:
229
+
230
+ ```json
231
+ {
232
+ "name": "weather",
233
+ "transport": "ws",
234
+ "connected": true,
235
+ "clientCount": 0,
236
+ "sessionCount": 1,
237
+ "pendingCount": 0
238
+ }
239
+ ```
240
+
241
+ - `transport` is `ws` (dedicated socket), `ws-multiplex` (shared socket),
242
+ `stdio` (**any** configured upstream: a child process from `stdioUpstreams`,
243
+ a `.mcpb` bundle, *or* a remote URL from `mcpServers`), `loopback`
244
+ (in-process), or `none` (the slot exists but nothing is serving it).
245
+ - `clientCount` counts **raw WebSocket clients only**. A perfectly healthy
246
+ Streamable HTTP client reads as `clientCount: 0, sessionCount: 1`.
247
+ - `pendingCount` is the number of in-flight requests. One that only grows is the
248
+ signature of a provider that is connected and not answering.
249
+
250
+ ### `_all`, the aggregate
251
+
252
+ `_all` presents the union of the tools and prompts of every **opted-in**
253
+ provider as one MCP server. It is not a proxy and not automatic.
254
+
255
+ Membership, per provider kind:
256
+
257
+ | provider kind | joins `_all` when |
258
+ |---|---|
259
+ | WebSocket, `DirectTransport` | `new DirectTransport(url, { aggregate: true })` |
260
+ | WebSocket, `MultiplexTransport` | `MultiplexTransport.create(name, url, { aggregate: true })` |
261
+ | `stdioUpstreams[]` | `"aggregate": true` (**omitted means NOT aggregated**) |
262
+ | `mcpServers[]` | by default; `"aggregate": false` opts out |
263
+ | `mcpbBundles[]` | by default; `"aggregate": false` opts out |
264
+ | `_broker` | always, automatically |
265
+
266
+ That default asymmetry between `stdioUpstreams` and the other two is real and it
267
+ is worth re-reading before assuming a slot is in.
268
+
269
+ Names are prefixed with the origin slot, `<slot>-<original>`, and descriptions
270
+ are tagged `[<slot>] <original description>`.
271
+
272
+ > **Never reconstruct a prefixed name.** It is capped at 64 characters, an
273
+ > overlong one is truncated and given a hash suffix, and a collision between two
274
+ > providers is broken with a `-2`, `-3` suffix. The mapping back to
275
+ > `(provider, original)` is a lookup table, not a parse. Call `tools/list` on
276
+ > `_all` and pass the returned string back verbatim in `tools/call`.
277
+
278
+ `_all` implements `initialize`, `ping`, `tools/list`, `tools/call`,
279
+ `prompts/list` and `prompts/get`. **Everything else returns `-32601 Method not
280
+ found`, including `resources/list` and `resources/read`.** For a provider's
281
+ resources, connect to that provider's own slot.
282
+
283
+ It emits `notifications/tools/list_changed` and
284
+ `notifications/prompts/list_changed` when a provider joins, leaves or changes
285
+ its catalog, so a subscribing client sees a provider that arrives mid-session
286
+ without reconnecting. That is what makes `_all` the correct stdio-bridge target.
287
+
288
+ Registration snippets must **install the MCP message handler before announcing
289
+ the slot**. The broker sends `initialize` the moment it accepts an aggregate
290
+ registration; a provider not listening yet fails that handshake, is dropped from
291
+ `_all` and logged, with no retry. The SDK's `aggregate` option already does this
292
+ in the right order.
293
+
294
+ When authorization is enabled, `_all` **filters the catalog** rather than
295
+ rejecting the call: a caller sees only the providers it is scoped for, and a
296
+ tool it may not see answers `-32602 Unknown aggregated tool`, deliberately
297
+ indistinguishable from a name that does not exist.
104
298
 
105
299
  ## Authorization (OAuth 2.1)
106
300
 
@@ -171,10 +365,22 @@ With this on:
171
365
  - Structured provider principals can publish only inside their allowed resource
172
366
  namespace.
173
367
 
174
- Full model, flows, scopes, and endpoints: **[../docs/authorization.md](../docs/authorization.md)**.
368
+ Two things this does **not** do. It does not authenticate the stdio bridge:
369
+ `MCP_BROKER_STDIO_PROVIDER` reaches `_all` with no principal attached, so
370
+ enabling per-provider scopes silently empties an MCP host's tool list. And it
371
+ cannot authenticate a browser-hosted provider: the secret is read from the
372
+ `X-Provider-Token` header or from `Authorization: Bearer`, and the browser
373
+ `WebSocket` constructor cannot set headers, so setting `providerSecret` locks
374
+ every browser provider out permanently with an HTTP 401 that reaches the page as
375
+ a bare `error` event. Run without a provider secret, or terminate provider auth
376
+ in a reverse proxy that injects the header.
377
+
378
+ Full model, flows, scopes, and endpoints:
379
+ **[docs/authorization.md](https://github.com/pandaGaume/mcp-broker/blob/main/docs/authorization.md)**.
175
380
  Roles, resource paths, denies, and migration:
176
- **[../docs/hierarchical-authorization.md](../docs/hierarchical-authorization.md)**.
177
- Config field reference: **[docs/config.md](docs/config.md#auth-oauth-21-authorization)**.
381
+ **[docs/hierarchical-authorization.md](https://github.com/pandaGaume/mcp-broker/blob/main/docs/hierarchical-authorization.md)**.
382
+ Config field reference: **[docs/config.md](https://github.com/pandaGaume/mcp-broker/blob/main/node/packages/broker/docs/config.md#auth-oauth-21-authorization)**.
383
+ (The first two live in the repository and are not shipped in the npm package.)
178
384
 
179
385
  ## Programmatic API
180
386
 
@@ -186,8 +392,12 @@ const broker = new WsTunnelBuilder()
186
392
  .withHost("0.0.0.0")
187
393
  .withProviderPath("/provider")
188
394
  .withMcpPath("/mcp")
189
- // Optional: bridge a local stdio MCP server as a provider.
190
- .withStdioUpstream("my-server", "node", ["./my-server.js"])
395
+ // Required before any browser page can reach a client endpoint.
396
+ .withAllowedOrigins(["http://localhost:5173"])
397
+ // Optional: bridge a local stdio MCP server as a provider. One object, not positional args.
398
+ .withStdioUpstream({ name: "my-server", command: "node", args: ["./my-server.js"], aggregate: true })
399
+ // Optional: front a remote MCP server reached by URL.
400
+ .withRemoteUpstream({ name: "geo", url: "https://geo.example.com/mcp" })
191
401
  // Optional: serve a dev harness at /
192
402
  .withStaticMount("/", "/abs/path/to/www")
193
403
  // Optional: enable the OAuth 2.1 resource server + provider auth
@@ -214,6 +424,37 @@ const broker = new WsTunnelBuilder()
214
424
  await broker.start();
215
425
  ```
216
426
 
427
+ `start()` **rejects** on a listen failure, with a message naming the port and
428
+ what to do about it. Await it, or handle the rejection: `void broker.start()`
429
+ produces an unhandled rejection.
430
+
431
+ An MCP server living in the same process needs no WebSocket at all:
432
+
433
+ ```ts
434
+ import { LoopbackTransport, McpServerBuilder } from "@cyanmycelium/mcp-core";
435
+
436
+ const [serverEnd, clientEnd] = LoopbackTransport.createPair();
437
+ const server = new McpServerBuilder().withTransport(serverEnd).register(/* behaviors */).build();
438
+ await server.start();
439
+ broker.registerLoopbackProvider("in-process", clientEnd);
440
+ ```
441
+
442
+ A loopback slot outranks a WebSocket provider of the same name: the WebSocket
443
+ one is refused with `Provider "<name>" is reserved by the broker`.
444
+
445
+ Provider liveness has three knobs, all also settable from the config file and
446
+ from `MCP_BROKER_*`:
447
+
448
+ | method | default | what it addresses |
449
+ |---|---|---|
450
+ | `withProviderHeartbeat(ms)` | `30000` | a half-open socket holding a slot until TCP keepalive expires, hours later. `0` disables |
451
+ | `withProviderTakeover(mode)` | `"liveness"` | `"reject"` keeps the incumbent always; `"always"` needs provider auth and a matching principal, and degrades to `"liveness"` with a log otherwise |
452
+ | `withProviderRequestTimeout(ms)` | `60000` | a provider that stays connected and never answers, the ordinary state of a throttled background browser tab. `0` disables |
453
+
454
+ A pong is answered by the peer's network stack, so the heartbeat proves the
455
+ *process* is alive, not that it is serving. `withProviderRequestTimeout` is what
456
+ covers the second case.
457
+
217
458
  All builder methods are documented inline. The full options interface is
218
459
  `IWsTunnelOptions`, also exported. Use `withAuthorizationPolicy` for a
219
460
  standalone policy configuration, `withPolicyEngine` for a custom engine, and
@@ -222,6 +463,44 @@ validator (for example RFC 7662 introspection) or provider authenticator, use
222
463
  `withAuth(resolvedAuth)` or `withProviderAuth(authenticator)` with your own
223
464
  `ITokenValidator` or `IProviderAuthenticator`.
224
465
 
466
+ `brokerName` is honored by `IWsTunnelOptions` but there is no
467
+ `withBrokerName()`, so the CLI cannot forward the config-file key. Set it
468
+ through `IWsTunnelOptions` directly if you need it.
469
+
470
+ ## Runnable samples
471
+
472
+ Five self-contained integration samples live in the repository, outside this
473
+ package: [samples/](https://github.com/pandaGaume/mcp-broker/tree/main/samples).
474
+ Each starts what it needs and proves itself end to end, and
475
+ [samples/index.json](https://github.com/pandaGaume/mcp-broker/blob/main/samples/index.json)
476
+ indexes them for an agent. They are not in this package's `files`, so they are
477
+ on GitHub rather than in `node_modules`.
478
+
479
+ Everything below *does* ship inside the npm package under `web/`, so it is
480
+ available from `node_modules/@cyanmycelium/mcp-broker/web`.
481
+
482
+ | path | what it shows |
483
+ |---|---|
484
+ | [`web/demos/provider-tunnel/`](web/demos/provider-tunnel/) | a browser page hosting an MCP server and tunnelling it into a slot |
485
+ | [`web/demos/broker-explorer/`](web/demos/broker-explorer/) | a browser MCP **client** driving `_broker`, `_all` or any slot |
486
+ | [`web/demos/oauth-lab/`](web/demos/oauth-lab/) | a complete local OAuth 2.1 and policy environment |
487
+ | [`.mcp-broker.example/`](.mcp-broker.example/) | a config template with per-key guides in [English](.mcp-broker.example/CONFIGURATION-EN.md) and [French](.mcp-broker.example/CONFIGURATION-FR.md), plus `config.stdio-bridge.json` |
488
+
489
+ Serve the whole `web/` folder from an installed package in one command:
490
+
491
+ ```sh
492
+ MCP_BROKER_WWW_DIR=node_modules/@cyanmycelium/mcp-broker/web \
493
+ MCP_BROKER_ALLOWED_ORIGINS=http://localhost:3000 \
494
+ MCP_BROKER_OPEN=1 \
495
+ npx @cyanmycelium/mcp-broker
496
+ ```
497
+
498
+ `MCP_BROKER_ALLOWED_ORIGINS` is there because a page that reaches a slot over
499
+ **HTTP** (`/<slot>/mcp`, `/<slot>/sse`, `/<slot>/messages`) is origin-checked,
500
+ and the broker serving the page does not exempt it. A page that uses only
501
+ WebSocket transports does not need it: WebSocket upgrades carry no origin check
502
+ at all, which is worth knowing in both directions.
503
+
225
504
  ## Interactive OAuth demo
226
505
 
227
506
  The bundled [OAuth Policy Lab](web/demos/oauth-lab/) runs a complete local
@@ -239,15 +518,37 @@ and automated smoke test.
239
518
 
240
519
  ## TLS for local development
241
520
 
521
+ `gen-cert` is a repository script, not part of the published package: it needs
522
+ `selfsigned`, which is a devDependency. From a checkout of this repo, in
523
+ `node/packages/broker`:
524
+
242
525
  ```sh
243
- npm run gen-cert
244
- # Writes ../certs/cert.pem and ../certs/key.pem (repo root)
526
+ npm run gen-cert -- --out .mcp-broker/certs
527
+ # Writes .mcp-broker/certs/cert.pem and .mcp-broker/certs/key.pem
245
528
 
246
- $env:MCP_BROKER_TLS_CERT="../certs/cert.pem"
247
- $env:MCP_BROKER_TLS_KEY="../certs/key.pem"
529
+ MCP_BROKER_TLS_CERT=.mcp-broker/certs/cert.pem \
530
+ MCP_BROKER_TLS_KEY=.mcp-broker/certs/key.pem \
248
531
  npm start
249
532
  ```
250
533
 
534
+ Without `--out` the script writes to `../certs` resolved against the working
535
+ directory, which for an npm script is the package directory. Pass `--out`
536
+ whenever you care where the files land.
537
+
538
+ Outside this repository, `openssl` does the same job:
539
+
540
+ ```sh
541
+ mkdir -p .mcp-broker/certs && openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
542
+ -keyout .mcp-broker/certs/key.pem -out .mcp-broker/certs/cert.pem \
543
+ -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
544
+ ```
545
+
546
+ Setting both a certificate and a key switches the **whole** server to HTTPS and
547
+ WSS at once. There is no mixed mode: providers then connect with `wss://`,
548
+ clients with `https://`, and every entry in `allowedOrigins` must be spelled
549
+ `https://` or it will match nothing. The files are read synchronously when the
550
+ tunnel is built, so a wrong path fails before the port is bound.
551
+
251
552
  The generated certificate covers `localhost`, `127.0.0.1`, `::1` for 365 days. Browsers will warn about an untrusted issuer on first visit. Click "Advanced → Proceed". MCP clients (Claude, Inspector) ignore certificate validation by default.
252
553
 
253
554
  ## Docker
@@ -312,21 +613,100 @@ services:
312
613
 
313
614
  ## Claude Desktop integration
314
615
 
315
- The broker can act as a stdio MCP server for Claude Desktop, bridging to any provider it manages:
616
+ The broker can act as a stdio MCP server for Claude Desktop and any other stdio
617
+ MCP host. **Point the bridge at `_all`, not at one of your slots:**
316
618
 
317
619
  ```json
318
620
  {
319
621
  "mcpServers": {
320
- "broker": {
622
+ "mcp-broker": {
321
623
  "command": "npx",
322
624
  "args": ["-y", "@cyanmycelium/mcp-broker"],
323
- "env": { "MCP_BROKER_STDIO_PROVIDER": "<your-provider-name>" }
625
+ "env": {
626
+ "MCP_BROKER_STDIO_PROVIDER": "_all",
627
+ "MCP_BROKER_PORT": "3000",
628
+ "MCP_BROKER_HOST": "127.0.0.1"
629
+ }
324
630
  }
325
631
  }
326
632
  }
327
633
  ```
328
634
 
329
- In this mode all broker logging goes to stderr; stdout is reserved for the JSON-RPC stream Claude expects.
635
+ ### Why `_all` and not your slot
636
+
637
+ This is an ordering problem, and pinning a real slot fails every single time.
638
+
639
+ An MCP host starts its servers when the host application launches. It sends
640
+ `initialize` immediately and treats a failure as a dead server: no retry, no
641
+ backoff, the entry is disabled for the session. Your provider does not exist
642
+ yet. A browser provider needs a human to open a page; a provider in another
643
+ process needs that process to start. So the bridge answers the host's very first
644
+ `initialize` with `-32000 Provider "<slot>" not connected`, and the host gives
645
+ up. **Pinning a real slot never works for a browser-hosted provider at all.**
646
+
647
+ `_all` is registered before the broker resumes stdin, so the slot exists before
648
+ the host's first byte arrives. It answers `initialize` itself. It already
649
+ aggregates `_broker`, so the host always has at least the five introspection
650
+ tools. And it pushes `notifications/tools/list_changed` when a provider joins,
651
+ so a page opened ten minutes into the session appears in the host's tool list
652
+ live, with no reconnect.
653
+
654
+ `_broker` is the fallback if you want introspection only. It is also always up,
655
+ but it will never show anything except the broker's own five tools.
656
+
657
+ Providers still have to **opt in** to `_all` (see
658
+ [Reserved slots](#reserved-slots-_broker-and-_all)). If the host shows only
659
+ `_broker-*` tools, nothing opted in; call `_broker-broker_diagnose` and it will
660
+ say so.
661
+
662
+ ### One broker per port
663
+
664
+ Do not add a second host entry that spawns another broker. The second process
665
+ cannot bind the port and dies; the host reports `Connection closed` with no
666
+ cause, and the real `EADDRINUSE` diagnosis is only in the host's
667
+ `mcp-server-<name>.log`. If you want two logical servers visible to the host,
668
+ publish both into the one broker and let `_all` union them.
669
+
670
+ ### The bridge is anonymous
671
+
672
+ The stdio bridge forwards frames to `_all` with **no principal attached**. While
673
+ no authorization is configured that is invisible. The moment you enable
674
+ per-provider scopes, or any policy that denies an anonymous subject, the host's
675
+ tool list silently empties: `tools/list` still succeeds and returns
676
+ `tools: []`, and nothing anywhere says why.
677
+
678
+ In this mode all broker logging goes to stderr; stdout is reserved for the JSON-RPC stream the host expects.
679
+
680
+ ## Troubleshooting
681
+
682
+ Call `broker_diagnose()` on the `_broker` slot first. It reads the live state
683
+ and names the problems it can prove. This table is the reference behind it, and
684
+ covers the failures the broker cannot see from the inside.
685
+
686
+ | symptom | cause | fix |
687
+ |---|---|---|
688
+ | Client hangs forever on `initialize`, provider shows `connected: true` | `MultiplexTransport` on `/provider/<name>` | move it to `ws://<host>/providers`, or switch it to `DirectTransport` |
689
+ | Provider socket open, slot never appears in `providers_list` | `DirectTransport` on `/providers` | move it to `ws://<host>/provider/<name>`, or switch it to `MultiplexTransport` |
690
+ | Provider on `/providers/<name>`, nothing works | that path is neither endpoint: it is accepted as a **client** on a slot named `providers/<name>` | drop the name for multiplex, or add `/provider/` for a dedicated socket |
691
+ | WebSocket closes `1008 already connected` after a reload | a stale socket still holds the slot | release on `pagehide`; the heartbeat frees a genuinely dead one within one interval |
692
+ | Host reports `Connection closed` with no cause | a second broker could not bind the port | one broker per port; `EADDRINUSE` is in `mcp-server-<name>.log` |
693
+ | `403 invalid_origin` from a page this broker serves | a static mount does not exempt the origin it serves | list that exact origin, scheme and port included |
694
+ | `Provider "<slot>" not connected` at host start | ordering: the host starts before any provider exists | point `MCP_BROKER_STDIO_PROVIDER` at `_all` |
695
+ | `_all` shows only `_broker-*` tools | nothing opted into the aggregate | see [Reserved slots](#reserved-slots-_broker-and-_all); verify with `tools/list` on `_all` |
696
+ | Host's tool list empties after enabling authorization | the stdio bridge is anonymous | grant the anonymous subject, or stop bridging in authorized deployments |
697
+ | 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 |
698
+ | `-32601 Method not found` on `_all` | `_all` covers tools and prompts only | use the provider's own slot for resources |
699
+ | `-32602 Unknown aggregated tool` | the prefixed name was reconstructed rather than echoed | re-run `tools/list`, pass the name back verbatim |
700
+ | `did not respond within 60000ms` | the provider stayed connected and never answered | raise `providerRequestTimeoutMs`, or fix the provider |
701
+ | `sessionCount` grows and never falls | Streamable HTTP and SSE sessions do not expire | send `DELETE /<slot>/mcp` when a client is done; restart if it is already large |
702
+
703
+ Reading the console: the broker prints one line per accepted WebSocket upgrade
704
+ naming the path, the role the router assigned (`dedicated-provider`,
705
+ `multiplex-provider` or `client`) and the slot. **A provider URL that came out
706
+ as `role=client` is the mismatch, caught for free.** In stdio mode every log
707
+ goes to stderr, so look in the host's `mcp-server-<name>.log`, never on stdout.
708
+ Do not read silence as success: routed frames and provider replies are not
709
+ logged at all, and `providers_list` plus `broker_diagnose` are the ground truth.
330
710
 
331
711
  ## Development
332
712
 
@@ -342,7 +722,7 @@ Requires Node 20.11+.
342
722
 
343
723
  ## Releasing
344
724
 
345
- The package is published to npm by [`.github/workflows/release-node.yml`](../.github/workflows/release-node.yml), triggered by tags of the form `node-v*`.
725
+ The package is published to npm by [`.github/workflows/release-node.yml`](https://github.com/pandaGaume/mcp-broker/blob/main/.github/workflows/release-node.yml), triggered by tags of the form `node-v*`.
346
726
 
347
727
  ```sh
348
728
  # from the node/ directory: