@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
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * The broker's self-documentation, as plain Markdown constants.
3
+ *
4
+ * This module is the **source of truth** for how to integrate with an
5
+ * mcp-broker instance. It is served live over MCP by
6
+ * {@link BrokerGuideBehavior} (resources `broker://guide/*` and the
7
+ * `broker_guide` tool), so an agent that can reach the reserved `_broker`
8
+ * slot, or `_all` which aggregates it, learns the integration rules without
9
+ * ever opening a README.
10
+ *
11
+ * Every statement here is derived from the broker's own source, not from the
12
+ * repository's prose documentation. When the two disagree, this file is right
13
+ * and the prose is stale. The markdown docs and the samples import these
14
+ * constants rather than restating them, so there is exactly one copy of the
15
+ * rules to keep correct.
16
+ *
17
+ * Content is written for a machine reader with no prior knowledge of this
18
+ * project: rules are stated as rules, failure modes name their observable
19
+ * signature, and every diagnosis ends in an action.
20
+ */
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Topics
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Identifier of one guide page. Also the last URI segment of the matching
28
+ * `broker://guide/<topic>` resource, and the value accepted by the
29
+ * `broker_guide` tool's `topic` argument.
30
+ */
31
+ export type BrokerGuideTopic = "index" | "publish-provider" | "connect-client" | "host-config" | "deploy" | "troubleshooting";
32
+
33
+ /** Scheme + path prefix shared by every guide resource URI. */
34
+ export const BROKER_GUIDE_URI_PREFIX = "broker://guide/";
35
+
36
+ /** RFC 6570 URI template covering every guide page. */
37
+ export const BROKER_GUIDE_URI_TEMPLATE = "broker://guide/{topic}";
38
+
39
+ /** One guide page: its identity, its one-line pitch, and its Markdown body. */
40
+ export interface IBrokerGuide {
41
+ /** Stable topic id, used by the tool argument and the resource URI. */
42
+ topic: BrokerGuideTopic;
43
+
44
+ /** Canonical resource URI, `broker://guide/<topic>`. */
45
+ uri: string;
46
+
47
+ /** Short human title, e.g. "Publish a provider". */
48
+ title: string;
49
+
50
+ /** One line describing when to read this page. Shown in the index. */
51
+ summary: string;
52
+
53
+ /** The page body, Markdown, self-contained. */
54
+ content: string;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Shared fragments
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * The pairing rule, verbatim in every page that can be reached by someone
63
+ * about to make the mistake. Restated rather than cross-referenced on
64
+ * purpose: an agent may read exactly one page.
65
+ */
66
+ const PAIRING_RULE = [
67
+ "```",
68
+ " DirectTransport <-> ws://<host>/provider/<name> plain JSON-RPC frames",
69
+ " MultiplexTransport <-> ws://<host>/providers envelope frames { provider, payload }",
70
+ "```",
71
+ "",
72
+ "`ws://<host>/providers/<name>` is NEITHER of those two. The broker's router",
73
+ "matches `/providers` exactly and `/provider/` as a prefix, so a URL that starts",
74
+ "with `/providers/` falls through to the client branch and is accepted as an MCP",
75
+ "**client** on a slot literally named `providers/<name>`. Nothing errors. Your",
76
+ "provider is not published, and the slot you meant stays empty forever.",
77
+ ].join("\n");
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // index
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const GUIDE_INDEX = [
84
+ "# mcp-broker integration guide",
85
+ "",
86
+ "You are reading the built-in documentation of a running mcp-broker instance.",
87
+ "It is authoritative for THIS broker. You do not need a README, and you should",
88
+ "not trust one that disagrees with this text.",
89
+ "",
90
+ "## What the broker is",
91
+ "",
92
+ "One process that puts N MCP servers behind one host and one port. Each server",
93
+ "occupies a named **slot**. Servers reach a slot from the inside as *providers*;",
94
+ "MCP clients reach a slot from the outside over HTTP, SSE, WebSocket or stdio.",
95
+ "Slots are created lazily: any name works, there is no registry to declare.",
96
+ "",
97
+ "## The one rule that costs the most time",
98
+ "",
99
+ "A provider's transport and its URL path are a matched pair.",
100
+ "",
101
+ PAIRING_RULE,
102
+ "",
103
+ "Get it wrong in the DirectTransport-on-`/providers` direction and the slot is",
104
+ "never claimed. Get it wrong the other way and the slot reports itself connected",
105
+ "while every client request hangs. This broker detects both on your first frame",
106
+ "and closes the socket with code `1008` and a reason naming the correction, but",
107
+ "it cannot detect a provider that connects and never sends anything, and older",
108
+ "brokers say nothing at all. See `broker://guide/publish-provider`.",
109
+ "",
110
+ "## The pages",
111
+ "",
112
+ "| topic | read it when |",
113
+ "|---|---|",
114
+ "| `publish-provider` | you have an MCP server and want this broker to expose it |",
115
+ "| `connect-client` | you have an MCP client and want to reach a slot |",
116
+ "| `host-config` | you are wiring Claude Desktop or another stdio MCP host |",
117
+ "| `deploy` | you are choosing between embedding the broker and running it |",
118
+ "| `troubleshooting` | something is already broken: symptom, cause, fix |",
119
+ "",
120
+ "Read a page as a resource (`broker://guide/<topic>`) or call",
121
+ '`broker_guide({ topic: "<topic>" })`. Calling `broker_guide` with no argument',
122
+ "returns this index.",
123
+ "",
124
+ "## Tools on this slot",
125
+ "",
126
+ "| tool | what it answers |",
127
+ "|---|---|",
128
+ "| `broker_info` | identity, version, uptime, bind address, TLS, effective URL paths |",
129
+ "| `providers_list` | every known slot, connected or not, with live counters |",
130
+ "| `provider_status({ name })` | one slot in detail |",
131
+ "| `broker_guide({ topic? })` | these pages |",
132
+ "| `broker_diagnose({ slot? })` | live state PLUS detected problems, each with a fix |",
133
+ "",
134
+ "When something does not work, call `broker_diagnose` before reading anything.",
135
+ "It does the correlation for you and names the fix.",
136
+ "",
137
+ "## Reserved slots",
138
+ "",
139
+ "- `_broker`: this slot. Introspection only. It routes to nothing else.",
140
+ "- `_all`: an aggregate that unions the tools and prompts of every opted-in",
141
+ " provider, including `_broker`. Tool names are prefixed `<slot>-<original>`.",
142
+ " It is the correct target for a stdio MCP host. See `broker://guide/host-config`.",
143
+ "",
144
+ "## Vocabulary",
145
+ "",
146
+ "- **slot**: a name. Providers publish into it; clients connect to it.",
147
+ "- **provider**: an MCP *server* that connects INTO the broker to serve a slot.",
148
+ "- **client**: an MCP *client* that connects to the broker to reach a slot.",
149
+ "- **upstream**: a provider the broker itself starts or dials out to",
150
+ " (a child process, a remote URL, a signed `.mcpb` bundle).",
151
+ ].join("\n");
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // publish-provider
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const GUIDE_PUBLISH_PROVIDER = [
158
+ "# Publish an MCP server into a broker slot",
159
+ "",
160
+ "## 1. The pairing rule",
161
+ "",
162
+ "A provider's transport and its URL path are a matched pair. This is the single",
163
+ "most expensive mistake to make against this broker: both ways of getting it",
164
+ "wrong leave every side believing it succeeded.",
165
+ "",
166
+ PAIRING_RULE,
167
+ "",
168
+ "Two more paths that look plausible and are not. Both are now refused at the",
169
+ "WebSocket handshake with HTTP 400, and the response body is the full",
170
+ "diagnosis; a Node client reads it off the `unexpected-response` event, a",
171
+ "browser sees only a bare `error` event and you have to read the broker log:",
172
+ "",
173
+ "- `ws://<host>/provider` with no name. It used to be accepted and to occupy a",
174
+ " slot literally named `(unnamed)`, which no client could address.",
175
+ "- `ws://<host>/provider/a/b`, more than one segment after the prefix. A slot",
176
+ " name containing `/` must be percent-encoded: `/provider/a%2Fb`. Both",
177
+ " spellings resolve to the same slot, so only the encoded one is accepted and",
178
+ " nothing is lost. Prefer a flat name anyway.",
179
+ "",
180
+ "## 2. How each mismatch presents",
181
+ "",
182
+ "The broker inspects the first frame on a provider socket and refuses both",
183
+ "directions: it answers in the framing the peer can actually decode, then",
184
+ "closes with code `1008` and a reason naming both corrections. Only an",
185
+ "unambiguous frame triggers this (an envelope with no `jsonrpc` on the",
186
+ "slot-scoped path, a `jsonrpc` frame that is not an envelope on the multiplex",
187
+ "path), so a dialect the broker does not recognize is still left alone.",
188
+ "",
189
+ "Do not rely on the refusal. It cannot fire before your first frame, so a",
190
+ "provider that connects and stays silent still looks connected, and older",
191
+ "brokers say nothing at all. The signatures below are what you read then.",
192
+ "",
193
+ "**MultiplexTransport pointed at `/provider/<name>`.** The slot-scoped path takes",
194
+ "the slot name from the URL at connect time, so registration succeeds before any",
195
+ 'frame exists: `providers_list` reports `connected: true, transport: "ws"`. But',
196
+ "the broker now writes plain JSON-RPC onto that socket, and MultiplexTransport",
197
+ "drops every frame that is not an envelope. Your MCP server never receives",
198
+ "`initialize`. Meanwhile your envelope-wrapped replies carry no top-level `id`,",
199
+ "so the broker classifies them as notifications and broadcasts them instead of",
200
+ "answering the waiting request.",
201
+ "",
202
+ " Observable signature: `provider_status` shows `connected: true`,",
203
+ ' `transport: "ws"`, `pendingCount` climbing and never returning to 0, while the',
204
+ " client's `initialize` never resolves. `broker_diagnose` reports this as",
205
+ " `transport-path-mismatch`.",
206
+ "",
207
+ "**DirectTransport pointed at `/providers`.** The multiplex path learns slot names",
208
+ "only from envelopes. A plain JSON-RPC frame has no `provider`/`payload` pair, so",
209
+ "it is dropped and no slot is ever claimed.",
210
+ "",
211
+ " Observable signature: the socket is open on your side, and the slot does not",
212
+ " appear in `providers_list` at all. Clients get",
213
+ ' `-32000 Provider "<name>" not connected`.',
214
+ "",
215
+ "## 3. Dedicated socket (DirectTransport)",
216
+ "",
217
+ "One socket per slot. The slot is claimed the instant the socket opens, before",
218
+ "any frame is sent, so a client arriving first is served immediately.",
219
+ "",
220
+ "```ts",
221
+ 'import { DirectTransport } from "@cyanmycelium/mcp-broker-provider";',
222
+ 'import { McpServerBuilder } from "@cyanmycelium/mcp-core";',
223
+ "",
224
+ 'const transport = new DirectTransport("ws://localhost:3000/provider/my-slot");',
225
+ "const server = new McpServerBuilder()",
226
+ ' .withName("my-slot")',
227
+ " .withTransport(transport)",
228
+ " .register(/* your behaviors */)",
229
+ " .build();",
230
+ "",
231
+ "await server.start(); // opens the socket",
232
+ "```",
233
+ "",
234
+ "`server.start()` resolving means the socket reported itself open. It does NOT",
235
+ "mean the broker accepted the slot: a refusal arrives after the open as a",
236
+ "WebSocket close with code 1008, and the reason string says which refusal it was.",
237
+ "",
238
+ "## 4. Shared socket (MultiplexTransport)",
239
+ "",
240
+ "One socket carrying N slots, keyed per frame. Use it when one page or one",
241
+ "process publishes several servers.",
242
+ "",
243
+ "```ts",
244
+ 'import { MultiplexTransport } from "@cyanmycelium/mcp-broker-provider";',
245
+ "",
246
+ 'const a = MultiplexTransport.create("scene-1", "ws://localhost:3000/providers");',
247
+ 'const b = MultiplexTransport.create("scene-2", "ws://localhost:3000/providers");',
248
+ "// a and b share one WebSocket. Same URL means same socket.",
249
+ "```",
250
+ "",
251
+ "On open it sends one registration notification per slot,",
252
+ '`{ "provider": "<name>", "payload": { "jsonrpc": "2.0", "method": "notifications/register" } }`,',
253
+ "so the broker creates the slot before your first MCP frame. MultiplexTransport",
254
+ "reconnects on its own with exponential backoff and jitter, capped at 30s.",
255
+ "DirectTransport does not reconnect at all: if you need that, reconnect yourself",
256
+ "in `onClose`.",
257
+ "",
258
+ "## 5. Wire format",
259
+ "",
260
+ "Slot-scoped socket, both directions, plain MCP frames:",
261
+ "",
262
+ "```json",
263
+ '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }',
264
+ "```",
265
+ "",
266
+ "Multiplex socket, both directions, every frame wrapped:",
267
+ "",
268
+ "```json",
269
+ '{ "provider": "scene-1", "payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/list" } }',
270
+ "```",
271
+ "",
272
+ "A frame the broker cannot decode on the multiplex socket is dropped, and the",
273
+ "first drop per socket is logged with the reason. A plain JSON-RPC frame there",
274
+ "is not merely dropped: it is the DirectTransport-on-`/providers` mismatch, so",
275
+ "it is answered with a bare JSON-RPC error and the socket is closed with `1008`.",
276
+ "A tunnel-level refusal comes back as an envelope whose payload is a JSON-RPC",
277
+ "error with `id: null` and code `-32000` (slot unavailable) or `-32001`",
278
+ "(registration forbidden).",
279
+ "",
280
+ "## 6. Joining the `_all` aggregate",
281
+ "",
282
+ "A provider is NOT in `_all` by default. Opting in is per provider and depends on",
283
+ "how the provider reaches the broker.",
284
+ "",
285
+ "| provider kind | how it joins `_all` |",
286
+ "|---|---|",
287
+ "| WebSocket on `/provider/<name>` | send the aggregate opt-in as the socket's **first** frame, see below |",
288
+ "| WebSocket on `/providers` (multiplex) | announce it in the registration envelope, see below |",
289
+ '| `stdioUpstreams[]` config entry | set `"aggregate": true` (omitted means NOT aggregated) |',
290
+ '| `mcpServers[]` config entry | aggregated by default; set `"aggregate": false` to opt out |',
291
+ '| `mcpbBundles[]` config entry | aggregated by default; set `"aggregate": false` to opt out |',
292
+ "| `_broker` | always, automatically |",
293
+ "",
294
+ "### From the SDK",
295
+ "",
296
+ "```ts",
297
+ 'new DirectTransport("ws://localhost:3000/provider/my-slot", { aggregate: true });',
298
+ 'MultiplexTransport.create("my-slot", "ws://localhost:3000/providers", { aggregate: true });',
299
+ "```",
300
+ "",
301
+ "Both send the JSON-RPC-shaped registration notification on open,",
302
+ '`{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`,',
303
+ "wrapped in an envelope on the shared socket.",
304
+ "",
305
+ "### The raw control frame",
306
+ "",
307
+ "The broker also accepts a non-JSON-RPC control frame on the slot-scoped path,",
308
+ "which is what to send when you drive the socket yourself:",
309
+ "",
310
+ "```ts",
311
+ 'const ws = new WebSocket("ws://localhost:3000/provider/my-slot");',
312
+ "ws.onopen = () => {",
313
+ ' ws.send(JSON.stringify({ type: "register", aggregate: true }));',
314
+ " // ... then hand the socket to your transport / start the server",
315
+ "};",
316
+ "```",
317
+ "",
318
+ "It must be the FIRST frame on that socket. The broker inspects exactly one frame",
319
+ "per socket for this, and a frame carrying `jsonrpc` is routed as traffic, so a",
320
+ "socket that has already sent an MCP frame stays non-aggregated for its whole life.",
321
+ "",
322
+ "**Do not assume the opt-in took.** The two shapes are honored by different",
323
+ "broker versions, and a refused or ignored opt-in produces no error. Verify:",
324
+ "call `tools/list` on the `_all` slot and look for your `<slot>-<tool>` names, or",
325
+ "call `broker_diagnose()`, which reports `aggregate-empty` and",
326
+ "`aggregate-missing-live-slots` when a connected slot is not in the aggregate.",
327
+ "",
328
+ "**Install your MCP message handler before you opt in.** The moment the broker",
329
+ "accepts the aggregate registration it opens an internal client and sends",
330
+ "`initialize`. A provider that is not listening yet fails that handshake and is",
331
+ "dropped from `_all` silently, with no log and no retry.",
332
+ "",
333
+ "## 7. Lifecycle: connect, register, release",
334
+ "",
335
+ "1. **Connect.** Open the WebSocket at the path that matches your transport.",
336
+ "2. **Register.** Slot-scoped: automatic, from the URL. Multiplex: from the first",
337
+ " envelope, which the SDK sends on open. Optionally opt into `_all` first.",
338
+ "3. **Release.** Close the socket. There is no release message: the only control",
339
+ " frame the broker understands is the registration frame. On close the broker",
340
+ " clears the slot and fails every in-flight request with",
341
+ ' `-32000 Provider "<name>" disconnected`, echoing the original request id.',
342
+ "",
343
+ "**Release explicitly anyway.** A slot is occupied while the incumbent socket is",
344
+ "`readyState === OPEN`, and a socket that dies without sending a close frame (a",
345
+ "hard kill, a crashed tab, a suspended laptop, devtools set to offline) stays",
346
+ "OPEN until the OS TCP keepalive gives up, which is hours.",
347
+ "",
348
+ "```ts",
349
+ "// Browsers fire pagehide reliably on reload, navigation and tab close.",
350
+ 'addEventListener("pagehide", () => transport.close());',
351
+ "```",
352
+ "",
353
+ "A ws-level ping/pong heartbeat is what stops that from wedging the slot",
354
+ "forever. Each provider socket is pinged every `providerHeartbeatIntervalMs`",
355
+ "(default 30000; `0` disables it), and one that misses a full interval is",
356
+ "terminated and its slot freed. So a reconnect in the gap is still refused with",
357
+ 'close code 1008 and reason `Provider "<name>" is already connected`, but the',
358
+ "window is one interval rather than hours. Releasing on `pagehide` is what makes",
359
+ "it instant instead.",
360
+ "",
361
+ '`providerTakeover` decides who wins the contention: `"liveness"` (default) lets',
362
+ 'the newcomer in only once the incumbent has missed a heartbeat, `"reject"` never',
363
+ 'hands a slot over, and `"always"` is honored only when provider authentication',
364
+ "is configured and the newcomer authenticated as the same principal (otherwise",
365
+ 'the broker logs why and falls back to `"liveness"`).',
366
+ "",
367
+ "One honest limit: an RFC 6455 pong is answered by the browser's network stack,",
368
+ "so the heartbeat proves the process is alive, not that the page's JS thread is",
369
+ "serving. A throttled background tab answers pings and still never replies to a",
370
+ "request; `providerRequestTimeoutMs` is what covers that case.",
371
+ "",
372
+ "## 8. Refusals you can receive",
373
+ "",
374
+ "On the slot-scoped socket a refusal is a WebSocket close with code 1008; the",
375
+ "reason string is the message below. On the multiplex socket it is an error",
376
+ "envelope for that slot name and the socket stays open.",
377
+ "",
378
+ "| reason | cause | fix |",
379
+ "|---|---|---|",
380
+ '| `Provider "<n>" is already connected` | a live socket holds the slot | release on `pagehide`; a genuinely dead holder is reaped within one heartbeat interval (default 30s), so retry |',
381
+ '| `Provider "<n>" is reserved by the broker` | you targeted `_broker` or `_all` | pick another slot name |',
382
+ '| `Provider "<n>" is managed by a stdio upstream` | the name collides with a configured upstream | rename one of them |',
383
+ "| HTTP 401 at the handshake | provider auth is on and no credential was presented | see below |",
384
+ "| HTTP 403 at the handshake | your principal may not publish on that slot | widen `allowedResources` |",
385
+ "",
386
+ "## 9. In a browser",
387
+ "",
388
+ "- **A bundler is required.** Both packages ship ESM under `dist/`, with no",
389
+ " `browser` field and no UMD build, so a plain `<script>` tag will not do.",
390
+ "- **Provider authentication cannot work from a browser.** The broker reads the",
391
+ " provider secret from the `X-Provider-Token` header or from",
392
+ " `Authorization: Bearer`, and the browser `WebSocket` constructor cannot set",
393
+ " headers. Neither transport accepts a credential. If the broker is configured",
394
+ " with a provider secret, every browser provider is refused at the handshake",
395
+ " with HTTP 401, which reaches your page as a bare `error` event with no status.",
396
+ " Run the broker without a provider secret, or terminate provider auth in a",
397
+ " reverse proxy that injects the header.",
398
+ "- A page served by the broker's own static mount is still a browser origin for",
399
+ " the purposes of the client-side origin check. Serving the page does not",
400
+ " exempt it. See `broker://guide/connect-client`.",
401
+ "",
402
+ "## 10. In Node",
403
+ "",
404
+ "Both transports call the **global** `WebSocket` constructor with no import. Node",
405
+ "22 and later provide one. On Node 20 assign a polyfill before connecting:",
406
+ "",
407
+ "```ts",
408
+ 'import WebSocket from "ws";',
409
+ "globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;",
410
+ "```",
411
+ "",
412
+ "If the MCP server and the broker live in the same process, skip WebSockets",
413
+ "entirely and call `tunnel.registerLoopbackProvider(name, transport)` with an",
414
+ "in-process transport. See `broker://guide/deploy`.",
415
+ ].join("\n");
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // connect-client
419
+ // ---------------------------------------------------------------------------
420
+
421
+ const GUIDE_CONNECT_CLIENT = [
422
+ "# Reach a slot as an MCP client",
423
+ "",
424
+ "## 1. Endpoints, per slot",
425
+ "",
426
+ "| transport | endpoint | notes |",
427
+ "|---|---|---|",
428
+ "| Streamable HTTP | `POST`, `GET`, `DELETE` `http(s)://<host>/<slot>/mcp` | current MCP transport, use this |",
429
+ "| Legacy SSE | `GET http(s)://<host>/<slot>/sse` then `POST .../<slot>/messages` | 2024-11-05 HTTP+SSE, kept for old hosts |",
430
+ "| Raw WebSocket | `ws(s)://<host>/<slot>` | bare JSON-RPC frames, no session layer |",
431
+ "| stdio bridge | the broker process itself | one slot per broker process, see `broker://guide/host-config` |",
432
+ "",
433
+ "`<slot>` is the slot name. The HTTP router splits the path on `/` and requires",
434
+ "exactly two segments, so a slot name containing `/` must be percent-encoded:",
435
+ "slot `a/b` is reachable at `/a%2Fb/mcp`.",
436
+ "",
437
+ "Default path suffixes are `/mcp`, `/sse` and `/messages`; call `broker_info` to",
438
+ "read the effective values if the deployment customized them.",
439
+ "",
440
+ "## 2. Connecting to a slot that has no provider",
441
+ "",
442
+ "Slots are lazy. Connecting to a name nobody serves is not an error: the",
443
+ "connection succeeds and every request is answered with",
444
+ '`-32000 Provider "<slot>" not connected`. Call `providers_list` on `_broker` to',
445
+ "see which names actually exist, or `broker_diagnose` to be told why one is empty.",
446
+ "",
447
+ "## 3. Reserved slots",
448
+ "",
449
+ "### `_broker`",
450
+ "",
451
+ "The broker's own introspection server, and nothing else. It exposes",
452
+ "`broker_info`, `providers_list`, `provider_status`, `broker_guide` and",
453
+ "`broker_diagnose`, plus the resources `broker://info`, `broker://providers`,",
454
+ "`broker://providers/{name}` and `broker://guide/{topic}`. It does not proxy any",
455
+ "other provider. It is available from the moment the broker finishes starting.",
456
+ "",
457
+ "### `_all`",
458
+ "",
459
+ "An aggregate, not a proxy. It presents the union of the tools and prompts of",
460
+ "every **opted-in** provider as one MCP server. Membership covers configured",
461
+ "upstreams (`stdioUpstreams`, `mcpServers`, `mcpbBundles`), `_broker` itself, and",
462
+ "WebSocket provider slots that sent the aggregate opt-in frame. See",
463
+ "`broker://guide/publish-provider` for how a provider opts in.",
464
+ "",
465
+ "Names are prefixed with the origin slot:",
466
+ "",
467
+ "```",
468
+ " <slot>-<original> e.g. weather-forecast, _broker-broker_info",
469
+ "```",
470
+ "",
471
+ "**Never reconstruct a prefixed name.** The prefixed name is capped at 64",
472
+ "characters, and an overlong one is truncated and given a hash suffix; a",
473
+ "collision between two providers is broken with a `-2`, `-3` suffix. The mapping",
474
+ "back to `(provider, original)` is a lookup table, not a parse. Call `tools/list`",
475
+ "on `_all` and pass the returned string back verbatim in `tools/call`.",
476
+ "",
477
+ "Descriptions are tagged with their origin, `[<slot>] <original description>`, so",
478
+ "you can tell where a tool came from without parsing its name.",
479
+ "",
480
+ "`_all` implements `initialize`, `ping`, `tools/list`, `tools/call`,",
481
+ "`prompts/list` and `prompts/get`. **Everything else returns",
482
+ "`-32601 Method not found`**, including `resources/list` and `resources/read`. If",
483
+ "you need a provider's resources, connect to that provider's own slot.",
484
+ "",
485
+ "`_all` emits `notifications/tools/list_changed` and",
486
+ "`notifications/prompts/list_changed` whenever a provider joins, leaves, or",
487
+ "changes its catalog. A client that subscribes sees a provider that arrives",
488
+ "mid-session without reconnecting.",
489
+ "",
490
+ "## 4. Browser clients and the origin check",
491
+ "",
492
+ "All three HTTP client endpoints, `/<slot>/mcp`, `/<slot>/sse` and",
493
+ "`/<slot>/messages`, refuse any request carrying an `Origin` header that is not",
494
+ "on the allow list, with HTTP 403. **The list is empty by default**, so out of",
495
+ "the box no browser origin passes while every non-browser client (which sends no",
496
+ "`Origin`) passes. This is deliberate: without the check any page in the user's",
497
+ "browser could drive the broker.",
498
+ "",
499
+ "Serving the page from the broker's own static mount does NOT exempt it. Set the",
500
+ "origins explicitly:",
501
+ "",
502
+ "```json",
503
+ '{ "allowedOrigins": ["http://localhost:3000", "https://app.example.com"] }',
504
+ "```",
505
+ "",
506
+ "or `MCP_BROKER_ALLOWED_ORIGINS=http://localhost:3000,https://app.example.com`.",
507
+ "Match the scheme, host and port exactly, as the browser sends them; if the",
508
+ "broker runs with TLS the origin is `https://`, not `http://`.",
509
+ "",
510
+ "The 403 body names the refused origin, says whether the list was unset or set",
511
+ "but non-matching, and states the comparison rule, so the usual causes (a",
512
+ "trailing slash, `http` against a TLS broker, a missing port) are readable off",
513
+ "the response.",
514
+ "",
515
+ "The **WebSocket** endpoints carry no origin check at all: `ws://<host>/<slot>`",
516
+ "and the two provider paths are gated by authorization and by provider auth,",
517
+ "never by `Origin`. Do not treat a working WebSocket as evidence that the same",
518
+ "page's HTTP requests will pass.",
519
+ "",
520
+ "## 5. Authorization",
521
+ "",
522
+ "When OAuth 2.1 is enabled, every HTTP request to a slot must carry",
523
+ "`Authorization: Bearer <token>` issued for that slot's canonical resource URI. A",
524
+ "missing or invalid token gets 401 with an RFC 9728 `WWW-Authenticate` challenge;",
525
+ "a valid token lacking the required scope gets 403 with the missing scope named",
526
+ "in the challenge. Protected Resource Metadata is served unauthenticated so a",
527
+ "client can discover the authorization server after the first 401.",
528
+ "",
529
+ "On `_all`, authorization filters the catalog rather than rejecting the call: a",
530
+ "caller sees only the providers it is scoped for, and a tool it may not see",
531
+ "answers `-32602 Unknown aggregated tool`, deliberately indistinguishable from a",
532
+ "name that does not exist.",
533
+ "",
534
+ "## 6. Errors you can receive, and what they mean",
535
+ "",
536
+ "| error | meaning |",
537
+ "|---|---|",
538
+ '| `-32000 Provider "<n>" ... not connected` | the slot exists but nothing is serving it right now |',
539
+ '| `-32000 Provider "<n>" disconnected` | the provider dropped while your request was in flight |',
540
+ '| `-32000 Provider "<n>" is no longer connected` | `_all` routed to a provider that left between list and call |',
541
+ '| `-32000 Provider "<n>" did not respond within <ms>ms` | the provider stayed connected and never answered; raise `providerRequestTimeoutMs` for genuinely long tools |',
542
+ "| `-32601 Method not found` | on `_all`, a method outside tools and prompts |",
543
+ "| `-32602 Unknown aggregated tool` | on `_all`, a stale or invisible prefixed name; re-list |",
544
+ "| HTTP 403 `invalid_origin` | your `Origin` is not on the allow list |",
545
+ "| HTTP 401 / 403 | bearer token missing, invalid, or missing a scope |",
546
+ "",
547
+ "A request that produces no reply at all is not in this table. That is the",
548
+ "transport/path mismatch described in `broker://guide/troubleshooting`; call",
549
+ "`broker_diagnose` and it will name it.",
550
+ ].join("\n");
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // host-config
554
+ // ---------------------------------------------------------------------------
555
+
556
+ const GUIDE_HOST_CONFIG = [
557
+ "# Wiring an MCP host (Claude Desktop and friends)",
558
+ "",
559
+ "## 1. Point the bridge at `_all`, not at your slot",
560
+ "",
561
+ "This is the second most expensive mistake against this broker, and unlike the",
562
+ "transport pairing it is a pure ordering problem.",
563
+ "",
564
+ "An MCP host starts its servers when the host application launches. It sends",
565
+ "`initialize` immediately and treats a failure as a dead server: no retry, no",
566
+ "backoff, the entry is disabled for the session. Your provider, meanwhile, does",
567
+ "not exist yet. A browser provider needs a human to open a page; a provider in",
568
+ "another process needs that process to start.",
569
+ "",
570
+ "So pinning the stdio bridge to a real slot fails every single time: the bridge",
571
+ "answers the host's very first `initialize` with",
572
+ '`-32000 Provider "<slot>" not connected`, and the host gives up.',
573
+ "",
574
+ "**Pin it to `_all`.** `_all` is registered before the broker resumes stdin, it",
575
+ "answers `initialize` itself, it already aggregates `_broker` so the host always",
576
+ "has at least the introspection tools, and it pushes",
577
+ "`notifications/tools/list_changed` when a provider joins, so a page opened ten",
578
+ "minutes into the session appears in the host's tool list live.",
579
+ "",
580
+ "`_broker` is the fallback if you want introspection only. It is also always up,",
581
+ "but it will never show you anything except the broker's own five tools.",
582
+ "",
583
+ "## 2. Claude Desktop recipe",
584
+ "",
585
+ "```json",
586
+ "{",
587
+ ' "mcpServers": {',
588
+ ' "mcp-broker": {',
589
+ ' "command": "npx",',
590
+ ' "args": ["-y", "@cyanmycelium/mcp-broker"],',
591
+ ' "env": {',
592
+ ' "MCP_BROKER_STDIO_PROVIDER": "_all",',
593
+ ' "MCP_BROKER_PORT": "3000",',
594
+ ' "MCP_BROKER_HOST": "127.0.0.1"',
595
+ " }",
596
+ " }",
597
+ " }",
598
+ "}",
599
+ "```",
600
+ "",
601
+ "`MCP_BROKER_STDIO_PROVIDER` is what turns the process into a stdio bridge: stdin",
602
+ "carries the host's JSON-RPC, stdout carries the replies, and all logging is",
603
+ "redirected to stderr so it cannot corrupt the channel. The broker keeps",
604
+ "listening on its port at the same time, so providers can still tunnel in.",
605
+ "",
606
+ "## 3. One broker per port. Exactly one.",
607
+ "",
608
+ "Do not add a second host entry that spawns another broker. The second process",
609
+ "cannot bind the port and dies; the host reports `Connection closed` with no",
610
+ "cause, and the real cause (`EADDRINUSE`) is only in the host's",
611
+ "`mcp-server-<name>.log`. If you want two logical servers visible to the host,",
612
+ "publish both into the one broker and let `_all` union them. That is the entire",
613
+ "point of `_all`.",
614
+ "",
615
+ "If a broker is already running on the port from another launcher, give the host",
616
+ "entry a different `MCP_BROKER_PORT`, or drop the host entry and reach the",
617
+ "running broker over Streamable HTTP instead.",
618
+ "",
619
+ "## 4. The bridge is anonymous",
620
+ "",
621
+ "The stdio bridge forwards frames to `_all` with **no principal attached**. While",
622
+ "no authorization is configured that is invisible. The moment you enable",
623
+ "per-provider scopes or a policy that denies an anonymous subject, the host's",
624
+ "tool list silently empties: `tools/list` still returns 200 with `tools: []`, and",
625
+ "nothing anywhere says why. If you enable authorization and the host goes blank,",
626
+ "this is why.",
627
+ "",
628
+ "## 5. Environment variables the host entry can set",
629
+ "",
630
+ "| variable | default | meaning |",
631
+ "|---|---|---|",
632
+ "| `MCP_BROKER_STDIO_PROVIDER` | unset | slot the stdin/stdout bridge serves. Use `_all`. |",
633
+ "| `MCP_BROKER_PORT` | `3000` | TCP port |",
634
+ "| `MCP_BROKER_HOST` | `0.0.0.0` | bind interface; `127.0.0.1` keeps it local |",
635
+ "| `MCP_BROKER_CONFIG` | unset | path to a JSON config file |",
636
+ "| `MCP_BROKER_LOCALE` | `en` | locale for the `_broker` tool descriptions |",
637
+ "| `MCP_BROKER_ALLOWED_ORIGINS` | unset | comma-separated browser origins for `/<slot>/mcp` |",
638
+ "| `MCP_BROKER_TLS_CERT` / `_KEY` | unset | PEM paths; both set means HTTPS and WSS |",
639
+ "| `MCP_BROKER_PROTOCOL` | auto | force `http` or `https` |",
640
+ "| `MCP_BROKER_WWW_DIR` | unset | serve this directory at `/` |",
641
+ "| `MCP_BROKER_BUNDLE_DIR` | unset | serve this directory at `/bundle` |",
642
+ "",
643
+ "Environment variables win over the config file. Paths in env vars resolve",
644
+ "against the working directory; paths in the config file resolve against the",
645
+ "config file's own directory.",
646
+ "",
647
+ "## 6. Verifying the wiring",
648
+ "",
649
+ "From inside the host, the aggregated introspection tools are named",
650
+ "`_broker-broker_info`, `_broker-providers_list`, `_broker-provider_status`,",
651
+ "`_broker-broker_guide` and `_broker-broker_diagnose`. If you can see those, the",
652
+ "bridge works and `_all` is alive. If you can see nothing else, no provider has",
653
+ "opted into the aggregate: call `_broker-broker_diagnose` and it will say so.",
654
+ ].join("\n");
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // deploy
658
+ // ---------------------------------------------------------------------------
659
+
660
+ const GUIDE_DEPLOY = [
661
+ "# Deploying the broker",
662
+ "",
663
+ "## 1. Two ways to run it",
664
+ "",
665
+ "**As a process.** `npx @cyanmycelium/mcp-broker`, configured by",
666
+ "`.mcp-broker/config.json` and `MCP_BROKER_*` environment variables. Choose this",
667
+ "when the broker is infrastructure: a host entry, a container, a service.",
668
+ "",
669
+ "**As a library.** Build a tunnel and start it inside your own process. Choose",
670
+ "this when your application already owns the port, or when it hosts MCP servers",
671
+ "itself and you want them served without a WebSocket round trip.",
672
+ "",
673
+ "```ts",
674
+ 'import { WsTunnelBuilder } from "@cyanmycelium/mcp-broker";',
675
+ "",
676
+ "const tunnel = new WsTunnelBuilder()",
677
+ " .withPort(3000)",
678
+ ' .withHost("127.0.0.1")',
679
+ ' .withAllowedOrigins(["http://localhost:5173"])',
680
+ ' .withStaticMount("/", "/abs/path/to/www")',
681
+ " .build();",
682
+ "",
683
+ "await tunnel.start();",
684
+ "// ... later",
685
+ "await tunnel.stop();",
686
+ "```",
687
+ "",
688
+ "An MCP server living in the same process skips the network entirely:",
689
+ "",
690
+ "```ts",
691
+ 'import { LoopbackTransport } from "@cyanmycelium/mcp-core";',
692
+ "",
693
+ "const [serverEnd, clientEnd] = LoopbackTransport.createPair();",
694
+ "const server = new McpServerBuilder().withTransport(serverEnd).register(/* ... */).build();",
695
+ "await server.start();",
696
+ 'tunnel.registerLoopbackProvider("in-process", clientEnd);',
697
+ "```",
698
+ "",
699
+ "A loopback slot outranks a WebSocket provider of the same name: the WebSocket",
700
+ 'one is refused with `Provider "<n>" is reserved by the broker`.',
701
+ "",
702
+ "## 2. What `start()` does, in order",
703
+ "",
704
+ "1. Creates the HTTP(S) server and attaches the WebSocket server.",
705
+ "2. Binds the port.",
706
+ "3. Starts the provider heartbeat and the request-timeout sweep, both on",
707
+ " `unref`'d timers so they never hold a process open.",
708
+ "4. Registers the `_all` aggregate slot.",
709
+ "5. Wires and connects every configured upstream (`stdioUpstreams`, `mcpServers`,",
710
+ " `mcpbBundles`). Upstreams marked `aggregate` join `_all` when they open.",
711
+ "6. Attaches the stdio bridge, if configured, and resumes stdin.",
712
+ "7. Starts the embedded `_broker` server and adds it to `_all`.",
713
+ "",
714
+ "Step 4 before step 6 is what makes `_all` a safe stdio bridge target: the slot",
715
+ "exists before the host's first byte arrives. Nothing in this sequence waits for",
716
+ "a WebSocket provider, because none can exist yet.",
717
+ "",
718
+ "`start()` **rejects** on a listen failure, and the rejection names the port, the",
719
+ "one-broker-per-port rule and the URL to attach to the broker already holding it.",
720
+ "Await it and catch it: `void tunnel.start()` turns that into an unhandled",
721
+ "rejection. The original errno is preserved as `err.cause`.",
722
+ "",
723
+ "## 3. Topologies",
724
+ "",
725
+ "**Browser app plus broker.** The broker serves the page from a static mount and",
726
+ "the page publishes a provider over WebSocket. Needs `allowedOrigins` for the",
727
+ "page's own origin if the page is also an MCP client, and needs provider auth",
728
+ "OFF, because a browser cannot present the provider secret.",
729
+ "",
730
+ "**Stdio host plus broker plus browser provider.** The host launches the broker",
731
+ "pinned to `_all`; the human opens the page later; `_all` pushes",
732
+ "`list_changed` and the tools appear. This is the topology `broker://guide/host-config`",
733
+ "is written for.",
734
+ "",
735
+ "**Broker in front of remote MCP servers.** `mcpServers[]` entries: the broker",
736
+ "dials out over Streamable HTTP, SSE or WebSocket and republishes each as a slot.",
737
+ "Aggregated into `_all` by default.",
738
+ "",
739
+ "**Broker running signed bundles.** `mcpbBundles[]` entries: each `.mcpb` is a ZIP",
740
+ "with a manifest, signature-verified against a trusted public key before being",
741
+ "unpacked and spawned as a stdio upstream. A bundle that fails verification is",
742
+ "skipped and never spawned. Aggregated into `_all` by default.",
743
+ "",
744
+ "## 4. Configuration",
745
+ "",
746
+ "The CLI reads `.mcp-broker/config.json` (or `MCP_BROKER_CONFIG`). Environment",
747
+ "variables win over the file. Relative paths in the file resolve against the",
748
+ "file's directory; relative paths in env vars resolve against the working",
749
+ "directory.",
750
+ "",
751
+ "```json",
752
+ "{",
753
+ ' "port": 3000,',
754
+ ' "host": "127.0.0.1",',
755
+ ' "allowedOrigins": ["http://localhost:3000"],',
756
+ ' "stdioProvider": "_all",',
757
+ ' "www": { "mounts": [{ "urlPrefix": "/", "dir": "./www" }] },',
758
+ ' "stdioUpstreams": [',
759
+ ' { "name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"], "aggregate": true }',
760
+ " ],",
761
+ ' "mcpServers": [{ "name": "geo", "url": "https://geo.example.com/mcp" }]',
762
+ "}",
763
+ "```",
764
+ "",
765
+ "Note the asymmetry, it is real and it bites: a `stdioUpstreams` entry is NOT",
766
+ 'aggregated unless you write `"aggregate": true`, while `mcpServers` and',
767
+ '`mcpbBundles` entries ARE aggregated unless you write `"aggregate": false`.',
768
+ "",
769
+ "All six `paths.*` keys are read by the CLI, as `env ?? config ?? default`. One",
770
+ "key is still library-only: `brokerName` is a valid config key and `WsTunnel`",
771
+ "honors it, but `WsTunnelBuilder` has no `withBrokerName()`, so the CLI cannot",
772
+ "forward it and setting it in the file changes nothing. `enableBrokerProvider`",
773
+ "and `enableAggregateProvider` are `IWsTunnelOptions` fields only, not config",
774
+ "keys at all. Confirm the effective values with `broker_info` rather than",
775
+ "assuming the file won.",
776
+ "",
777
+ "## 5. Static files",
778
+ "",
779
+ "Mounts are matched longest-prefix-first and JSON-RPC routes always win over",
780
+ "them, so a slot named like a directory shadows that directory. Directory",
781
+ "requests serve `index.html`. Paths escaping the mount are refused with 403.",
782
+ "`MCP_BROKER_WWW_DIR` is a shortcut for a mount at `/`, `MCP_BROKER_BUNDLE_DIR`",
783
+ "for one at `/bundle`; a directory that does not exist is skipped with a warning",
784
+ "rather than failing startup.",
785
+ "",
786
+ "## 6. TLS",
787
+ "",
788
+ "Setting both a certificate and a key switches the whole server to HTTPS and WSS",
789
+ "at once. There is no mixed mode: providers then connect with `wss://` and",
790
+ "clients with `https://`, and browser origins in `allowedOrigins` must be spelled",
791
+ "with `https://`. The files are read synchronously when the tunnel is built, so a",
792
+ "wrong path throws before the port is bound.",
793
+ "",
794
+ "## 7. Shutdown",
795
+ "",
796
+ "`stop()` tears down in the reverse order of `start()`: the embedded broker",
797
+ "server first, then the aggregate, then the stdio bridge, then every session,",
798
+ "client socket, provider socket and upstream, then the heartbeat and",
799
+ "request-timeout timers, then the listener. It resolves when the HTTP server is",
800
+ "fully closed, and a second `stop()` is silent rather than an error. Call it",
801
+ "before your process exits so providers see a clean close rather than a dropped",
802
+ "connection.",
803
+ ].join("\n");
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // troubleshooting
807
+ // ---------------------------------------------------------------------------
808
+
809
+ const GUIDE_TROUBLESHOOTING = [
810
+ "# Troubleshooting",
811
+ "",
812
+ "Call `broker_diagnose()` first. It reads the live state and names the problems",
813
+ "it can prove, each with a fix. This page is the reference behind it, and covers",
814
+ "the failures the broker cannot see from the inside.",
815
+ "",
816
+ "## Symptom table",
817
+ "",
818
+ "| symptom | cause | fix |",
819
+ "|---|---|---|",
820
+ "| Client hangs forever on `initialize`, and the provider shows connected | transport / path mismatch: a MultiplexTransport on `/provider/<name>` | move the provider to `ws://<host>/providers`, or switch it to `DirectTransport` on `/provider/<name>` |",
821
+ "| Provider socket is open, but the slot never appears in `providers_list` | transport / path mismatch the other way: a DirectTransport on `/providers` | move it to `ws://<host>/provider/<name>`, or switch it to `MultiplexTransport` |",
822
+ "| Provider connects to `/providers/<name>` and nothing works | that path is neither endpoint: it is accepted as a CLIENT on a slot named `providers/<name>` | drop the name from the URL for multiplex, or add `/provider/` for a dedicated socket |",
823
+ "| WebSocket upgrade refused with HTTP 400 before it opens | the path cannot work: bare `/provider` (no slot name) or `/provider/a/b` (more than one segment) | append one slot name; percent-encode a `/` inside it. The 400 body is the full diagnosis; a browser sees only a bare `error` event, so read the broker log |",
824
+ "| Socket opens, then closes immediately with 1008 naming DirectTransport or MultiplexTransport | the broker detected the transport/path mismatch on your first frame | apply either correction the reason names; do not reconnect unchanged |",
825
+ "| WebSocket closes with 1008 `already connected` right after a page reload | a stale socket still holds the slot | release on `pagehide`; a dead holder is reaped within one heartbeat interval (default 30s), so retry rather than restart |",
826
+ "| MCP host reports `Connection closed` with no cause | a second broker could not bind the port | one broker per port; do not spawn one per host entry. `EADDRINUSE` is in the host's `mcp-server-<name>.log` |",
827
+ "| HTTP 403 `invalid_origin` from a page the broker itself serves | a static mount does not exempt the origin it serves | set `allowedOrigins` (or `MCP_BROKER_ALLOWED_ORIGINS`) to that exact origin, scheme and port included |",
828
+ '| `Provider "<slot>" not connected` at host start | ordering: the host starts before any provider exists | point `MCP_BROKER_STDIO_PROVIDER` at `_all` |',
829
+ "| Tool list is stale and never refreshes | a remote upstream's server-to-client stream dropped and is not reopened | restart the broker; the slot keeps answering requests, which is why nothing looks broken |",
830
+ "| `_all` shows only `_broker-*` tools | no provider opted into the aggregate, or the opt-in shape this broker honors differs from the one the provider sent | see the aggregate opt-in section in `broker://guide/publish-provider`, and never assume an opt-in took: verify with `tools/list` on `_all` |",
831
+ "| Host's tool list goes empty after enabling authorization | the stdio bridge reaches `_all` anonymously | grant the anonymous subject, or stop using the bridge for authorized deployments |",
832
+ "| Browser provider gets a bare `error` event and never connects | provider auth is on; a browser cannot send the required header | run without a provider secret, or authenticate in a reverse proxy |",
833
+ "| `-32601 Method not found` on `_all` for `resources/list` | `_all` covers tools and prompts only | connect to the provider's own slot for resources |",
834
+ "| `-32602 Unknown aggregated tool` on a name you built yourself | prefixed names are capped, hashed and de-duplicated | re-run `tools/list` and pass the returned name back verbatim |",
835
+ "",
836
+ "## The transport/path mismatch, in detail",
837
+ "",
838
+ "This one deserves its own section because it produces the least evidence.",
839
+ "",
840
+ "The broker refuses both directions on the first frame, answering in the framing",
841
+ "the peer can decode and closing with `1008` and a reason naming both",
842
+ "corrections. That covers the common case. It cannot cover a provider that",
843
+ "connects and sends nothing, and it does not exist in brokers before 1.3.0, so",
844
+ "the manual diagnosis below is still what you fall back to.",
845
+ "",
846
+ "`DirectTransport` speaks plain JSON-RPC; `MultiplexTransport` speaks",
847
+ "`{ provider, payload }` envelopes. The broker decides which language to speak on",
848
+ "a socket from the URL that socket connected to, not from what arrives on it.",
849
+ "",
850
+ "- Slot-scoped path `/provider/<name>`: the broker registers the slot from the",
851
+ " URL at connect time and then writes plain frames. A multiplex provider on that",
852
+ " path shows `connected: true` while silently discarding everything the broker",
853
+ " sends, because it is not an envelope.",
854
+ "- Multiplex path `/providers`: the broker learns slot names from envelopes only.",
855
+ " A plain frame decodes to nothing and is dropped, so no slot is ever created.",
856
+ "",
857
+ "**How to confirm it in ten seconds.** Call",
858
+ '`provider_status({ name: "<slot>" })` while a client request is outstanding:',
859
+ "",
860
+ '- `connected: true`, `transport: "ws"`, `pendingCount` > 0 and never falling:',
861
+ " a multiplex provider is sitting on the slot-scoped path. This is the case.",
862
+ "- the slot is absent from `providers_list` entirely while your socket is open:",
863
+ " a direct provider is sitting on the multiplex path.",
864
+ "",
865
+ "`broker_diagnose` runs exactly these two checks for you.",
866
+ "",
867
+ "## The slot is wedged after a crash",
868
+ "",
869
+ "A slot is occupied while the incumbent socket is `readyState === OPEN`, and a",
870
+ "socket killed without a close frame stays OPEN until the OS TCP keepalive",
871
+ "expires, which is hours. The heartbeat is what bounds that: every provider",
872
+ "socket is pinged every `providerHeartbeatIntervalMs` (default 30000), and one",
873
+ "that misses a full interval is terminated and its slot freed. So the wedge",
874
+ "lasts one interval, not a restart.",
875
+ "",
876
+ "If it lasts longer, check the three things that disable the recovery:",
877
+ "",
878
+ "- `providerHeartbeatIntervalMs: 0` turns the heartbeat off entirely. With no",
879
+ " liveness evidence the broker refuses every takeover, by design.",
880
+ '- `providerTakeover: "reject"` never hands a live slot over, whatever the',
881
+ " heartbeat says.",
882
+ "- The incumbent is answering pings but not serving. An RFC 6455 pong comes from",
883
+ " the network stack, so a throttled or wedged JS thread still looks alive. The",
884
+ " request timeout below is what catches that.",
885
+ "",
886
+ "The broker prints the reason it refused a takeover on every 1008, including",
887
+ "which of these applies. Prevention is still cheaper: release explicitly on",
888
+ "`pagehide` in a browser and on `SIGTERM` / `SIGINT` in a process.",
889
+ "",
890
+ "## Requests that never come back",
891
+ "",
892
+ "A pending entry is released by a matching response, by the provider",
893
+ "disconnecting, by the caller's own connection closing, or by",
894
+ "`providerRequestTimeoutMs` expiring (default 60000; `0` disables it). On expiry",
895
+ 'the caller gets `-32000 Provider "<name>" did not respond within <ms>ms`,',
896
+ "addressed to its own request id, and the broker logs the three usual causes.",
897
+ "",
898
+ "So a request that hangs *forever* now means either the timeout is disabled or",
899
+ "the request never reached a provider at all, which is the transport/path",
900
+ "mismatch above. A request that fails at the deadline means the provider is",
901
+ "connected and not answering: the ordinary case is a throttled background browser",
902
+ "tab. `pendingCount` in `provider_status` is the number of requests in flight,",
903
+ "and one that only grows is the signal. Raise the timeout only if you host",
904
+ "genuinely long-running tools.",
905
+ "",
906
+ "## Sessions outliving their clients",
907
+ "",
908
+ "Streamable HTTP and SSE sessions do not expire. A client that closes its tab",
909
+ "without sending `DELETE /<slot>/mcp` leaves its session alive, and every",
910
+ "notification the provider broadcasts is queued into it, without bound.",
911
+ "",
912
+ "The broker cannot tell a live session from an abandoned one, so no single",
913
+ "reading proves a leak. `sessionCount` in `provider_status` growing monotonically",
914
+ "across a run, and never falling when clients go away, is the signal. Note that",
915
+ "`clientCount` counts raw WebSocket clients only: a perfectly healthy HTTP client",
916
+ "shows as `clientCount: 0, sessionCount: 1`. Send the DELETE when a client is",
917
+ "done; restart the broker if the count is already large.",
918
+ "",
919
+ "## Reading the console",
920
+ "",
921
+ "The broker prints one line per accepted WebSocket upgrade, naming the path, the",
922
+ "role the router assigned (`dedicated-provider`, `multiplex-provider` or",
923
+ "`client`) and the slot it landed on. A provider URL that came out as",
924
+ "`role=client` is the mismatch, caught for free. In stdio mode every log goes to",
925
+ "stderr, so look in the host's `mcp-server-<name>.log`, never on stdout.",
926
+ "",
927
+ "Do not read silence as success or as failure. Client sessions, routed frames and",
928
+ "provider replies are not logged at all. `providers_list` and `broker_diagnose`",
929
+ "are the ground truth for what is actually connected.",
930
+ ].join("\n");
931
+
932
+ // ---------------------------------------------------------------------------
933
+ // Catalog
934
+ // ---------------------------------------------------------------------------
935
+
936
+ /**
937
+ * Every guide page, in the order an agent should meet them: the index first,
938
+ * then the two integration directions, then deployment, then the failure
939
+ * reference.
940
+ */
941
+ export const BROKER_GUIDES: readonly IBrokerGuide[] = [
942
+ {
943
+ topic: "index",
944
+ uri: `${BROKER_GUIDE_URI_PREFIX}index`,
945
+ title: "Guide index",
946
+ summary: "What the broker is, the transport pairing rule, and which page to read next.",
947
+ content: GUIDE_INDEX,
948
+ },
949
+ {
950
+ topic: "publish-provider",
951
+ uri: `${BROKER_GUIDE_URI_PREFIX}publish-provider`,
952
+ title: "Publish a provider",
953
+ summary: "Put an MCP server into a slot: the transport/path pairing rule, the aggregate opt-in, the lifecycle, browser limits.",
954
+ content: GUIDE_PUBLISH_PROVIDER,
955
+ },
956
+ {
957
+ topic: "connect-client",
958
+ uri: `${BROKER_GUIDE_URI_PREFIX}connect-client`,
959
+ title: "Connect a client",
960
+ summary: "Reach a slot: the four client transports, the reserved `_broker` and `_all` slots, origins, authorization, error codes.",
961
+ content: GUIDE_CONNECT_CLIENT,
962
+ },
963
+ {
964
+ topic: "host-config",
965
+ uri: `${BROKER_GUIDE_URI_PREFIX}host-config`,
966
+ title: "MCP host configuration",
967
+ summary: "Wire Claude Desktop or another stdio host: why the bridge must target `_all`, the recipe, the one-broker-per-port rule.",
968
+ content: GUIDE_HOST_CONFIG,
969
+ },
970
+ {
971
+ topic: "deploy",
972
+ uri: `${BROKER_GUIDE_URI_PREFIX}deploy`,
973
+ title: "Deploy the broker",
974
+ summary: "Embed as a library or run as a process: startup ordering, topologies, configuration, static files, TLS, shutdown.",
975
+ content: GUIDE_DEPLOY,
976
+ },
977
+ {
978
+ topic: "troubleshooting",
979
+ uri: `${BROKER_GUIDE_URI_PREFIX}troubleshooting`,
980
+ title: "Troubleshooting",
981
+ summary: "Symptom to cause to fix, starting with the silent failures that produce no log line anywhere.",
982
+ content: GUIDE_TROUBLESHOOTING,
983
+ },
984
+ ];
985
+
986
+ /** Every topic id, in catalog order. */
987
+ export const BROKER_GUIDE_TOPICS: readonly BrokerGuideTopic[] = BROKER_GUIDES.map((g) => g.topic);
988
+
989
+ /** MIME type every guide page is served with. */
990
+ export const BROKER_GUIDE_MIME_TYPE = "text/markdown";
991
+
992
+ /** Builds the canonical resource URI for a topic. */
993
+ export function brokerGuideUri(topic: BrokerGuideTopic): string {
994
+ return `${BROKER_GUIDE_URI_PREFIX}${topic}`;
995
+ }
996
+
997
+ /** Looks a guide page up by topic id. `undefined` when the topic is unknown. */
998
+ export function brokerGuide(topic: string): IBrokerGuide | undefined {
999
+ return BROKER_GUIDES.find((g) => g.topic === topic);
1000
+ }
1001
+
1002
+ /**
1003
+ * Extracts the topic from a `broker://guide/<topic>` URI, or `undefined` when
1004
+ * the URI is not a guide URI or names a topic that does not exist.
1005
+ *
1006
+ * Percent-encoding is decoded so `broker://guide/publish%2Dprovider` resolves,
1007
+ * and a decode failure is treated as "not a guide URI" rather than throwing:
1008
+ * the URI comes off the wire.
1009
+ */
1010
+ export function brokerGuideTopicFromUri(uri: string): BrokerGuideTopic | undefined {
1011
+ if (!uri.startsWith(BROKER_GUIDE_URI_PREFIX)) return undefined;
1012
+ const raw = uri.slice(BROKER_GUIDE_URI_PREFIX.length);
1013
+ if (!raw || raw.includes("/")) return undefined;
1014
+ let decoded: string;
1015
+ try {
1016
+ decoded = decodeURIComponent(raw);
1017
+ } catch {
1018
+ return undefined;
1019
+ }
1020
+ return brokerGuide(decoded)?.topic;
1021
+ }
1022
+
1023
+ /**
1024
+ * The index rendered as data rather than prose: one entry per page, for a
1025
+ * client that would rather plan its reads than parse a Markdown table.
1026
+ */
1027
+ export function brokerGuideIndex(): Array<{ topic: BrokerGuideTopic; uri: string; title: string; summary: string }> {
1028
+ return BROKER_GUIDES.map(({ topic, uri, title, summary }) => ({ topic, uri, title, summary }));
1029
+ }