@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
@@ -10,12 +10,16 @@ import { StdioTransport, StreamableHttpEndpoint } from "@cyanmycelium/mcp-core/n
10
10
  // The broker is itself a provider: it publishes its own `_broker` and `_all`
11
11
  // slots: so sharing the provider package's wire contract is the natural way to
12
12
  // keep one definition of the envelope rather than two that drift.
13
- import { decodeEnvelope, encodeEnvelope, encodeErrorEnvelope, envelopeFrame, TunnelErrorCodes } from "@cyanmycelium/mcp-broker-provider/protocol";
13
+ import { decodeEnvelope, encodeEnvelope, encodeErrorEnvelope, envelopeFrame, TUNNEL_REGISTER_METHOD, TunnelErrorCodes } from "@cyanmycelium/mcp-broker-provider/protocol";
14
14
  import { StdioUpstream } from "../stdio.upstream";
15
15
  import { RemoteUpstream } from "../remote.upstream";
16
16
  import type { IUpstream } from "../upstream";
17
17
  import { startBrokerServer, BROKER_PROVIDER_NAME } from "../broker/index";
18
18
  import type { IBrokerContext, IBrokerProviderInfo, BrokerProviderTransport } from "../broker/index";
19
+ // Imported from the defining module rather than the barrel: these two are the
20
+ // optional `IBrokerContext` extensions `broker_diagnose` reads, and the barrel
21
+ // does not re-export them yet.
22
+ import type { IBrokerSecurityInfo } from "../broker/broker.context";
19
23
  import { AggregateServer } from "../broker/aggregate/aggregate.server";
20
24
  import {
21
25
  HttpAuthGuard,
@@ -38,7 +42,18 @@ import {
38
42
  type ISlotResourceResolver,
39
43
  } from "../authorization/index";
40
44
  import { VERSION, PACKAGE_NAME } from "../version";
41
- import type { AllowedOrigins, IHttpSession, IInternalClient, IProviderState, IWsTunnelOptions, McpEndpointKind } from "./ws.interfaces";
45
+ import type {
46
+ AllowedOrigins,
47
+ IHttpSession,
48
+ IInternalClient,
49
+ IProviderState,
50
+ IWsTunnelOptions,
51
+ McpEndpointKind,
52
+ ProviderTakeoverMode,
53
+ ResponseSink,
54
+ WsConnectRole,
55
+ WsRouteClassification,
56
+ } from "./ws.interfaces";
42
57
 
43
58
  // ---------------------------------------------------------------------------
44
59
  // Static-file helpers
@@ -80,6 +95,89 @@ function requestIdOf(frame: string): string | number | undefined {
80
95
  return undefined;
81
96
  }
82
97
 
98
+ /**
99
+ * Prefix of every broker-assigned JSON-RPC id.
100
+ *
101
+ * Requests are re-numbered on their way to a provider so two clients on one
102
+ * slot cannot collide (see {@link WsTunnel._trackRequest}). The prefix is
103
+ * cosmetic but deliberate: it makes a stray id recognizable as the broker's in
104
+ * a provider's own logs, instead of looking like a client's.
105
+ */
106
+ const BROKER_REQUEST_ID_PREFIX = "brk-";
107
+
108
+ /** Default heartbeat period, in ms, for `providerHeartbeatIntervalMs`. */
109
+ const DEFAULT_PROVIDER_HEARTBEAT_MS = 30_000;
110
+
111
+ /** Default per-request deadline, in ms, for `providerRequestTimeoutMs`. */
112
+ const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 60_000;
113
+
114
+ /**
115
+ * RFC 6455 caps a close reason at 123 bytes and `ws` throws a `RangeError`
116
+ * rather than truncating, which would turn a diagnostic into a crash inside a
117
+ * connection handler. Every close reason goes through {@link truncateReason}.
118
+ */
119
+ const MAX_CLOSE_REASON_BYTES = 123;
120
+
121
+ /** Shortens a close reason to what RFC 6455 allows, without splitting a character. */
122
+ function truncateReason(reason: string): string {
123
+ if (Buffer.byteLength(reason, "utf8") <= MAX_CLOSE_REASON_BYTES) return reason;
124
+ let cut = reason;
125
+ while (Buffer.byteLength(cut + "...", "utf8") > MAX_CLOSE_REASON_BYTES) {
126
+ cut = cut.slice(0, -1);
127
+ }
128
+ return cut + "...";
129
+ }
130
+
131
+ /**
132
+ * `decodeURIComponent` that returns its input instead of throwing.
133
+ *
134
+ * A slot name arrives from a URL, so it is attacker-controlled: `%zz` throws a
135
+ * `URIError`, and both call sites here run inside a WebSocket connection
136
+ * handler, where an exception is an uncaught one.
137
+ */
138
+ function safeDecode(raw: string): string {
139
+ try {
140
+ return decodeURIComponent(raw);
141
+ } catch {
142
+ return raw;
143
+ }
144
+ }
145
+
146
+ /** Parses a frame into a plain JSON object, or `undefined` for anything else. */
147
+ function parseObjectFrame(text: string): Record<string, unknown> | undefined {
148
+ let parsed: unknown;
149
+ try {
150
+ parsed = JSON.parse(text);
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
155
+ return parsed as Record<string, unknown>;
156
+ }
157
+
158
+ /**
159
+ * `true` when the frame is the tunnel registration notification asking to join
160
+ * the `_all` aggregate slot, in either of the two shapes the broker accepts.
161
+ *
162
+ * Both are notifications a peer that does not know them ignores, which is why
163
+ * the opt-in could be added without a protocol version: the JSON-RPC form
164
+ * `{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`
165
+ * is what the provider SDK sends on both paths, and the legacy control frame
166
+ * `{"type":"register","aggregate":true}` is what hand-written providers send on
167
+ * the slot-scoped path.
168
+ */
169
+ function registrationAsksForAggregate(payload: unknown): boolean {
170
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return false;
171
+ const frame = payload as { jsonrpc?: unknown; method?: unknown; params?: unknown; type?: unknown; aggregate?: unknown };
172
+ if (frame.jsonrpc !== undefined) {
173
+ if (frame.method !== TUNNEL_REGISTER_METHOD) return false;
174
+ const params = frame.params;
175
+ if (typeof params !== "object" || params === null) return false;
176
+ return (params as { aggregate?: unknown }).aggregate === true;
177
+ }
178
+ return frame.type === "register" && frame.aggregate === true;
179
+ }
180
+
83
181
  /**
84
182
  * Turns the three shapes an operator may configure into the single predicate
85
183
  * the Streamable HTTP endpoint expects.
@@ -157,6 +255,53 @@ export class WsTunnel implements IBrokerContext {
157
255
  /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
158
256
  private readonly _multiplexSockets = new Map<WebSocket, Set<string>>();
159
257
 
258
+ /**
259
+ * Every inbound provider socket the heartbeat watches, dedicated and
260
+ * multiplexed alike.
261
+ *
262
+ * An explicit collection is needed because there was none: `_providers`
263
+ * holds only the socket that currently owns a slot (so it misses a
264
+ * multiplex socket that has not announced a name yet), and
265
+ * `_providerPrincipals` only has entries when provider auth is configured.
266
+ */
267
+ private readonly _providerSockets = new Set<WebSocket>();
268
+
269
+ /**
270
+ * Liveness of each provider socket: `true` when it answered the last ping,
271
+ * `false` while a ping is outstanding.
272
+ *
273
+ * A `WeakMap` so a terminated socket needs no cleanup here, and so a socket
274
+ * the heartbeat never saw (heartbeat disabled) reads back `undefined`,
275
+ * which the admission check treats as "no evidence either way" and refuses
276
+ * the takeover.
277
+ */
278
+ private readonly _alive = new WeakMap<WebSocket, boolean>();
279
+
280
+ /** Heartbeat sweep timer; `null` while stopped or disabled. */
281
+ private _heartbeatTimer: NodeJS.Timeout | null = null;
282
+
283
+ /** Pending-request deadline sweep timer; `null` while stopped or disabled. */
284
+ private _requestTimeoutTimer: NodeJS.Timeout | null = null;
285
+
286
+ /**
287
+ * Counter behind every broker-assigned request id. Monotonic per process,
288
+ * which is all the uniqueness the pending maps need.
289
+ */
290
+ private _nextRequestId = 1;
291
+
292
+ /**
293
+ * Slots already warned about for answering with an id the broker never
294
+ * issued, so a provider that does this on every frame warns once.
295
+ */
296
+ private readonly _unmatchedIdWarnedProviders = new Set<string>();
297
+
298
+ /**
299
+ * Slots already warned about for emitting a frame that is not JSON-RPC.
300
+ * A provider that speaks a non-JSON dialect emits one on every message, so
301
+ * the warning fires once per slot instead of flooding the log.
302
+ */
303
+ private readonly _nonJsonWarnedProviders = new Set<string>();
304
+
160
305
  /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
161
306
  private readonly _upstreams = new Map<string, IUpstream>();
162
307
 
@@ -265,6 +410,29 @@ export class WsTunnel implements IBrokerContext {
265
410
  };
266
411
  }
267
412
 
413
+ /**
414
+ * What the broker enforces on its listening surface right now, for
415
+ * `broker_diagnose`.
416
+ *
417
+ * The rule it feeds is the one that catches a page the broker serves itself
418
+ * being refused by its own origin check: static files mounted, no browser
419
+ * origin allowed. That combination now costs more than it did, because the
420
+ * check reaches the legacy SSE endpoints too.
421
+ */
422
+ public getSecurityInfo(): IBrokerSecurityInfo {
423
+ return {
424
+ allowedOriginsConfigured: this._options.allowedOrigins !== undefined,
425
+ clientAuthEnabled: this._authGuard !== null,
426
+ providerAuthEnabled: this._providerAuth !== null,
427
+ staticMountPrefixes: (this._options.staticMounts ?? []).map((m) => m.urlPrefix),
428
+ };
429
+ }
430
+
431
+ /** Slot the stdio bridge is pinned to, or `null` when there is no bridge. */
432
+ public getStdioBridgeTarget(): string | null {
433
+ return this._options.stdioClient?.providerName ?? null;
434
+ }
435
+
268
436
  public getProvidersInfo(): IBrokerProviderInfo[] {
269
437
  const out: IBrokerProviderInfo[] = [];
270
438
  for (const [name, state] of this._providers) {
@@ -354,32 +522,18 @@ export class WsTunnel implements IBrokerContext {
354
522
  onClose: null,
355
523
  send: (message: string): void => {
356
524
  if (closed) return;
357
- let id: string | number | null = null;
358
- try {
359
- const parsed = JSON.parse(message) as { id?: string | number };
360
- if (parsed?.id != null) id = parsed.id;
361
- } catch {
362
- /* forward as-is */
363
- }
364
525
  if (this._isProviderConnected(providerName, state)) {
365
- if (id != null) state.pending.set(id, { type: "internal", client });
366
- this._sendToProvider(state, providerName, message);
367
- } else if (id != null) {
368
- client.onMessage?.(
369
- JSON.stringify({
370
- jsonrpc: "2.0",
371
- id,
372
- error: { code: -32000, message: `Provider "${providerName}" not connected` },
373
- })
374
- );
526
+ this._sendToProvider(state, providerName, this._trackRequest(state, message, { type: "internal", client }));
527
+ } else if (requestIdOf(message) !== undefined) {
528
+ client.onMessage?.(this._notConnectedPayload(providerName, message));
375
529
  }
376
530
  },
377
531
  close: (): void => {
378
532
  if (closed) return;
379
533
  closed = true;
380
534
  state.internalClients.delete(client);
381
- for (const [id, sink] of state.pending) {
382
- if (sink.type === "internal" && sink.client === client) state.pending.delete(id);
535
+ for (const [brokerId, entry] of state.pending) {
536
+ if (entry.sink.type === "internal" && entry.sink.client === client) state.pending.delete(brokerId);
383
537
  }
384
538
  },
385
539
  };
@@ -420,10 +574,17 @@ export class WsTunnel implements IBrokerContext {
420
574
  // -------------------------------------------------------------------------
421
575
 
422
576
  /**
423
- * Starts the broker. Resolves once the HTTP server is listening.
577
+ * Starts the broker. Resolves once the HTTP server is listening, and
578
+ * **rejects** when the listen fails.
579
+ *
580
+ * The rejection is the point: until the port is bound, a listen failure has
581
+ * nowhere else to go. `ws` mirrors the HTTP server's `'error'` onto the
582
+ * `WebSocketServer` from a listener it installs inside its own constructor,
583
+ * so an unhandled one is rethrown by Node as an uncaught exception, outside
584
+ * any caller's `await` and outside any `catch` the caller wrote.
424
585
  */
425
586
  start(): Promise<void> {
426
- return new Promise((resolve) => {
587
+ return new Promise((resolve, reject) => {
427
588
  const handler = (req: IncomingMessage, res: ServerResponse) => this._handleHttp(req, res);
428
589
  this._httpServer = this._options.tls ? https.createServer({ cert: this._options.tls.cert, key: this._options.tls.key }, handler) : http.createServer(handler);
429
590
  // Disable perMessageDeflate: payloads may be large base64-encoded blobs
@@ -431,28 +592,76 @@ export class WsTunnel implements IBrokerContext {
431
592
  // CPU without reducing size, and caused multi-second stalls in practice.
432
593
  this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false, verifyClient: this._makeVerifyClient() });
433
594
 
595
+ // Server-level errors, of which the one that actually happens is
596
+ // `EADDRINUSE` on the very first bind.
597
+ //
598
+ // `settled` guards the reject: once the server is listening the
599
+ // promise is spoken for, and a later error (a socket reset during an
600
+ // upgrade, say) must be logged instead, or it lands as a rejection on
601
+ // an already-resolved promise, which Node reports as unhandled.
602
+ //
603
+ // The same handler goes on BOTH emitters deliberately. `ws` forwards
604
+ // the HTTP server's `'error'` to the `WebSocketServer` from a listener
605
+ // registered inside `new WebSocketServer({ server })`; being first, it
606
+ // rethrows an unhandled event before any listener added here to
607
+ // `_httpServer` could run. The `_httpServer` listener is still needed
608
+ // for the errors `ws` does not forward. `lastError` is what stops the
609
+ // doubly delivered listen failure from being reported twice.
610
+ let settled = false;
611
+ let lastError: unknown = null;
612
+ const onServerError = (err: Error): void => {
613
+ if (err === lastError) return;
614
+ lastError = err;
615
+ if (settled) {
616
+ console.error(`[broker] http/websocket server error: ${err.message}`);
617
+ return;
618
+ }
619
+ settled = true;
620
+ reject(this._listenError(err));
621
+ };
622
+ this._wss.on("error", onServerError);
623
+ this._httpServer.on("error", onServerError);
624
+
434
625
  this._wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
435
626
  const url = req.url ?? "/";
436
- const providerPath = this._options.providerPath ?? "/provider";
437
- const providersPath = this._options.providersPath ?? "/providers";
438
-
439
- if (url === providersPath || url.startsWith(providersPath + "?")) {
440
- // Multiplexed provider: one WebSocket carries N providers via envelopes.
441
- this._onMultiplexProviderConnect(ws, req);
442
- } else if (url.startsWith(providerPath + "/") || url === providerPath) {
443
- // Extract name: everything after "<providerPath>/"
444
- const raw = url.slice(providerPath.length).replace(/^\//, "");
445
- const name = decodeURIComponent(raw.split("?")[0]) || "(unnamed)";
446
- this._onProviderConnect(ws, name, req);
447
- } else {
448
- // Raw WS MCP client: URL is "/<providerName>" or "/"
449
- const raw = url.replace(/^\//, "").split("?")[0];
450
- const name = decodeURIComponent(raw) || "";
451
- this._onClientConnect(ws, name, req);
627
+ const route = this._classifyWsRoute(url);
628
+
629
+ switch (route.role) {
630
+ case "reject":
631
+ // Defence in depth. `verifyClient` refuses these before the
632
+ // handshake completes, with the full diagnosis in the HTTP
633
+ // body, so this branch should be unreachable: it exists so
634
+ // the invariant "a rejected path never reaches a handler"
635
+ // holds even if the hook is ever changed.
636
+ console.warn(`[broker] ws connect path="${url}" REFUSED after handshake: ${route.detail}`);
637
+ this._closeWs(ws, 1008, route.closeReason);
638
+ return;
639
+ case "multiplex-provider":
640
+ // Multiplexed provider: one WebSocket carries N providers via envelopes.
641
+ this._logWsConnection(url, "multiplex-provider", "(announced per envelope)");
642
+ this._onMultiplexProviderConnect(ws, req);
643
+ return;
644
+ case "dedicated-provider":
645
+ this._logWsConnection(url, "dedicated-provider", route.slot);
646
+ this._onProviderConnect(ws, route.slot, req);
647
+ return;
648
+ case "client":
649
+ this._logWsConnection(url, "client", route.slot);
650
+ this._onClientConnect(ws, route.slot, req);
651
+ return;
452
652
  }
453
653
  });
454
654
 
455
655
  this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
656
+ // Listening: from here on `onServerError` logs instead of rejecting.
657
+ settled = true;
658
+
659
+ // Both sweeps run on `unref`'d timers cleared by `stop()`, so an
660
+ // embedder (or a test file) that starts a tunnel is never held
661
+ // alive by them.
662
+ this._startHeartbeat();
663
+ this._startRequestTimeoutSweep();
664
+
456
665
  // Bring the aggregate `_all` slot up before any upstream connects
457
666
  // (a Streamable HTTP upstream opens synchronously on connect()).
458
667
  this._maybeStartAggregateServer();
@@ -516,11 +725,58 @@ export class WsTunnel implements IBrokerContext {
516
725
  });
517
726
  }
518
727
 
728
+ /**
729
+ * Turns a listen failure into an error whose message says what to do next.
730
+ *
731
+ * `EADDRINUSE` is the one that happens in practice, and the reflex it
732
+ * triggers is usually the wrong one: another broker gets spawned on another
733
+ * port. Two brokers do not share anything, a slot is held by exactly one
734
+ * process, so the second instance's providers are invisible to the first
735
+ * instance's clients. Hence the message points at attaching to the running
736
+ * broker first, and only then at changing the port.
737
+ */
738
+ private _listenError(err: Error): Error {
739
+ const host = this._options.host ?? "0.0.0.0";
740
+ const port = this._options.port;
741
+ const code = (err as NodeJS.ErrnoException).code;
742
+
743
+ if (code === "EADDRINUSE") {
744
+ const scheme = this._options.tls ? "https" : "http";
745
+ // A wildcard bind is not a reachable authority; name one that is.
746
+ const reachable = host === "0.0.0.0" || host === "::" ? "localhost" : host;
747
+ return new Error(
748
+ `Cannot listen on ${host}:${port}: the address is already in use. Another broker (or another process) already holds that port. ` +
749
+ `If it is a broker, use it: attach clients to ${scheme}://${reachable}:${port}/<slot>/mcp and inspect it at ${scheme}://${reachable}:${port}/_broker/mcp, ` +
750
+ `rather than starting a second instance, which would share no provider slot with the first. ` +
751
+ `To run a second one anyway, give it a free port (MCP_BROKER_PORT, or the \`port\` option).`,
752
+ { cause: err }
753
+ );
754
+ }
755
+
756
+ if (code === "EACCES") {
757
+ return new Error(
758
+ `Cannot listen on ${host}:${port}: permission denied. Ports below 1024 require elevated privileges; ` +
759
+ `pick a port above 1024 (MCP_BROKER_PORT, or the \`port\` option), or put a reverse proxy in front.`,
760
+ { cause: err }
761
+ );
762
+ }
763
+
764
+ if (code === "EADDRNOTAVAIL") {
765
+ return new Error(
766
+ `Cannot listen on ${host}:${port}: the host address is not available on this machine. ` +
767
+ `Bind to "0.0.0.0" (every interface) or "127.0.0.1" (local only) via MCP_BROKER_HOST, or the \`host\` option.`,
768
+ { cause: err }
769
+ );
770
+ }
771
+
772
+ return new Error(`Cannot listen on ${host}:${port}: ${err.message}`, { cause: err });
773
+ }
774
+
519
775
  /**
520
776
  * Starts the in-process MCP server that exposes the broker's own behaviors
521
- * (`broker_info`, `providers_list`, `provider_status`) under the reserved
522
- * provider slot `_broker`. No-op when {@link IWsTunnelOptions.enableBrokerProvider}
523
- * is `false`.
777
+ * (`broker_info`, `providers_list`, `provider_status`, `broker_guide`,
778
+ * `broker_diagnose`) under the reserved provider slot `_broker`. No-op when
779
+ * {@link IWsTunnelOptions.enableBrokerProvider} is `false`.
524
780
  */
525
781
  private async _maybeStartBrokerServer(): Promise<void> {
526
782
  if (this._options.enableBrokerProvider === false) return;
@@ -559,6 +815,14 @@ export class WsTunnel implements IBrokerContext {
559
815
  * Gracefully closes all connections and stops the HTTP server.
560
816
  */
561
817
  async stop(): Promise<void> {
818
+ // First thing, before any `await`: a live interval keeps the event loop
819
+ // busy, and both of these fire on sockets that are about to be torn
820
+ // down. They are `unref`'d, so they cannot by themselves hold a process
821
+ // open, but a test runner that checks for leaked handles counts them.
822
+ this._stopHeartbeat();
823
+ this._stopRequestTimeoutSweep();
824
+ this._providerSockets.clear();
825
+
562
826
  // Stop the embedded broker first so it does not see its loopback close
563
827
  // as an unexpected disconnect (and to flush any pending broker responses).
564
828
  const brokerServer = this._brokerServer;
@@ -610,10 +874,336 @@ export class WsTunnel implements IBrokerContext {
610
874
  this._loopbackProviders.clear();
611
875
  this._startedAt = null;
612
876
  this._wss?.close();
613
- this._httpServer?.close((err) => (err ? reject(err) : resolve()));
877
+
878
+ const httpServer = this._httpServer;
879
+ if (!httpServer) {
880
+ // Never started: there is no close callback coming, and waiting
881
+ // for one would hang the caller's teardown.
882
+ resolve();
883
+ return;
884
+ }
885
+ httpServer.close((err) => {
886
+ // A tunnel whose `start()` rejected (the port was already taken)
887
+ // has a server object that never listened, and closing that one
888
+ // answers `ERR_SERVER_NOT_RUNNING`. Failing the teardown on it
889
+ // would replace the real diagnosis with a meaningless one, in the
890
+ // `finally` where the caller is trying to clean up after the first.
891
+ if (err && (err as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") reject(err);
892
+ else resolve();
893
+ });
614
894
  });
615
895
  }
616
896
 
897
+ // -------------------------------------------------------------------------
898
+ // Provider liveness (heartbeat) and slot takeover
899
+ // -------------------------------------------------------------------------
900
+
901
+ /** Effective heartbeat period in ms; `0` means the heartbeat is off. */
902
+ private _heartbeatIntervalMs(): number {
903
+ const configured = this._options.providerHeartbeatIntervalMs;
904
+ return Math.max(0, configured ?? DEFAULT_PROVIDER_HEARTBEAT_MS);
905
+ }
906
+
907
+ /** Effective per-request deadline in ms; `0` means requests never expire. */
908
+ private _requestTimeoutMs(): number {
909
+ const configured = this._options.providerRequestTimeoutMs;
910
+ return Math.max(0, configured ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS);
911
+ }
912
+
913
+ /**
914
+ * Starts watching every provider socket for a missed pong.
915
+ *
916
+ * `unref()` is not cosmetic: without it this interval alone keeps the Node
917
+ * event loop alive, so every test file that starts a tunnel would hang on
918
+ * teardown, and an embedder's process would refuse to exit.
919
+ */
920
+ private _startHeartbeat(): void {
921
+ const interval = this._heartbeatIntervalMs();
922
+ if (interval === 0) return;
923
+ this._heartbeatTimer = setInterval(() => this._sweepHeartbeats(), interval);
924
+ this._heartbeatTimer.unref?.();
925
+ }
926
+
927
+ private _stopHeartbeat(): void {
928
+ if (!this._heartbeatTimer) return;
929
+ clearInterval(this._heartbeatTimer);
930
+ this._heartbeatTimer = null;
931
+ }
932
+
933
+ /**
934
+ * One heartbeat round: terminate whoever did not answer the previous ping,
935
+ * then ping everybody still standing.
936
+ *
937
+ * The two-phase shape is what gives a provider a full interval to reply,
938
+ * and it is why {@link _alive} means "answered since the last sweep" rather
939
+ * than "is connected".
940
+ */
941
+ private _sweepHeartbeats(): void {
942
+ const interval = this._heartbeatIntervalMs();
943
+ for (const ws of [...this._providerSockets]) {
944
+ if (ws.readyState !== WebSocket.OPEN) {
945
+ this._providerSockets.delete(ws);
946
+ continue;
947
+ }
948
+ if (this._alive.get(ws) === false) {
949
+ const slots = this._slotsOfSocket(ws);
950
+ console.warn(
951
+ `[broker] provider socket for ${slots} did not answer a WebSocket ping within ${interval}ms; terminating it and freeing the slot. ` +
952
+ `The provider process, tab or network path is gone. It may reconnect immediately. ` +
953
+ `If this provider is alive but slow, raise providerHeartbeatIntervalMs (or set it to 0 to disable the heartbeat).`
954
+ );
955
+ this._providerSockets.delete(ws);
956
+ ws.terminate();
957
+ continue;
958
+ }
959
+ this._alive.set(ws, false);
960
+ try {
961
+ ws.ping();
962
+ } catch (err) {
963
+ // A socket that died between the readyState check and the ping.
964
+ // Nothing to diagnose: the close handler is already on its way.
965
+ this._providerSockets.delete(ws);
966
+ void err;
967
+ }
968
+ }
969
+ }
970
+
971
+ /**
972
+ * Puts one accepted provider socket under the heartbeat.
973
+ *
974
+ * No-op when the heartbeat is disabled, which is what keeps
975
+ * {@link _alive} empty and makes the admission check fall back to refusing
976
+ * every takeover: with no liveness evidence, evicting the incumbent would
977
+ * be a guess.
978
+ */
979
+ private _watchProviderSocket(ws: WebSocket): void {
980
+ if (this._heartbeatIntervalMs() === 0) return;
981
+ this._alive.set(ws, true);
982
+ this._providerSockets.add(ws);
983
+ ws.on("pong", () => this._alive.set(ws, true));
984
+ ws.on("close", () => this._providerSockets.delete(ws));
985
+ }
986
+
987
+ /** Human-readable list of the slots one provider socket currently serves. */
988
+ private _slotsOfSocket(ws: WebSocket): string {
989
+ const announced = this._multiplexSockets.get(ws);
990
+ if (announced) return announced.size > 0 ? `multiplexed slots [${[...announced].join(", ")}]` : "a multiplexed socket that announced no slot";
991
+ const owned = [...this._providers.entries()].filter(([, state]) => state.ws === ws).map(([name]) => name);
992
+ return owned.length > 0 ? `slot "${owned.join('", "')}"` : "a slot it no longer owns";
993
+ }
994
+
995
+ /**
996
+ * Decides whether a newly connected provider socket may take the slot
997
+ * `name`, evicting the incumbent when it is allowed to.
998
+ *
999
+ * Returns `null` when the newcomer is admitted, or the refusal otherwise:
1000
+ * `detail` is the full diagnosis for the log and for the envelope the
1001
+ * multiplexed path can carry, `closeReason` the same thing squeezed into the
1002
+ * 123 bytes RFC 6455 allows on a close frame, with the actionable part
1003
+ * first so truncation eats the slot name (which the peer already knows)
1004
+ * rather than the instruction.
1005
+ *
1006
+ * See {@link ProviderTakeoverMode} for what each mode means and why
1007
+ * `"always"` is gated on provider authentication.
1008
+ */
1009
+ private _claimSlot(name: string, ws: WebSocket): { detail: string; closeReason: string } | null {
1010
+ const incumbent = this._providers.get(name)?.ws ?? null;
1011
+ if (!incumbent || incumbent === ws || incumbent.readyState !== WebSocket.OPEN) return null;
1012
+
1013
+ const mode: ProviderTakeoverMode = this._options.providerTakeover ?? "liveness";
1014
+
1015
+ if (mode === "always") {
1016
+ if (this._sameProviderPrincipal(incumbent, ws)) {
1017
+ console.warn(`[broker] provider "${name}": takeover mode "always", the incumbent socket is being terminated in favor of the new one from the same principal.`);
1018
+ incumbent.terminate();
1019
+ return null;
1020
+ }
1021
+ // Not a refusal on its own: fall through to the liveness rule. Saying
1022
+ // so out loud matters, because the operator asked for "always" and is
1023
+ // entitled to know why they did not get it.
1024
+ console.warn(
1025
+ `[broker] provider "${name}": takeover mode "always" is not honored here and the broker fell back to "liveness". ` +
1026
+ `Unconditional takeover is only safe when providerAuth is configured AND the new socket authenticated as the same principal as the incumbent; ` +
1027
+ `otherwise anyone able to reach the provider URL could evict the real provider at will. Configure providerAuth (withProviderSecret) to enable it.`
1028
+ );
1029
+ }
1030
+
1031
+ if (mode !== "reject" && this._alive.get(incumbent) === false) {
1032
+ console.warn(`[broker] provider "${name}": the incumbent socket missed its last heartbeat, so it is terminated and the new connection takes the slot.`);
1033
+ incumbent.terminate();
1034
+ return null;
1035
+ }
1036
+
1037
+ const heartbeat = this._heartbeatIntervalMs();
1038
+ const why =
1039
+ mode === "reject"
1040
+ ? `providerTakeover is "reject", so a live slot is never handed over.`
1041
+ : heartbeat === 0
1042
+ ? `The heartbeat is disabled (providerHeartbeatIntervalMs: 0), so the broker has no evidence the incumbent is dead and will not evict it.`
1043
+ : `The incumbent answered the last heartbeat, so it is treated as alive.`;
1044
+ const recovery =
1045
+ heartbeat === 0
1046
+ ? `Enable the heartbeat (providerHeartbeatIntervalMs) so a dead socket frees its slot on its own`
1047
+ : `wait up to ${heartbeat}ms for the heartbeat to notice a dead socket and reconnect`;
1048
+ return {
1049
+ detail:
1050
+ `Provider "${name}" is already connected. ${why} ` +
1051
+ `If the previous instance really is gone, ${recovery}, close the old socket cleanly from the provider side, or publish on a different slot name. ` +
1052
+ `Call provider_status on the _broker slot to see which socket holds it.`,
1053
+ closeReason: `Slot already held by a live provider; call provider_status on _broker. Slot: "${name}"`,
1054
+ };
1055
+ }
1056
+
1057
+ /**
1058
+ * `true` when two provider sockets authenticated as the same principal.
1059
+ *
1060
+ * Deliberately `false` when provider auth is off: with no authenticator
1061
+ * there are no principals to compare, and treating "both anonymous" as
1062
+ * "the same provider" is exactly the spoofing primitive the gate exists to
1063
+ * prevent.
1064
+ */
1065
+ private _sameProviderPrincipal(a: WebSocket, b: WebSocket): boolean {
1066
+ if (!this._providerAuth) return false;
1067
+ const left = this._providerPrincipals.get(a);
1068
+ const right = this._providerPrincipals.get(b);
1069
+ return left !== undefined && right !== undefined && left.id === right.id;
1070
+ }
1071
+
1072
+ /** Closes a socket with a reason RFC 6455 actually allows on the wire. */
1073
+ private _closeWs(ws: WebSocket, code: number, reason: string): void {
1074
+ ws.close(code, truncateReason(reason));
1075
+ }
1076
+
1077
+ // -------------------------------------------------------------------------
1078
+ // Pending requests: correlation and deadlines
1079
+ // -------------------------------------------------------------------------
1080
+
1081
+ /**
1082
+ * Registers one outbound request against a slot and returns the frame to
1083
+ * put on the wire, with the client's JSON-RPC id replaced by a
1084
+ * broker-assigned one.
1085
+ *
1086
+ * The rewrite is the fix for a cross-client response leak. `state.pending`
1087
+ * is one map per **slot**, written from five different ingresses (raw WS,
1088
+ * legacy SSE, Streamable HTTP, the stdio bridge, in-process clients), and
1089
+ * it used to be keyed by the id the client chose. Two clients on one slot
1090
+ * that both start numbering at 1 (MCP Inspector plus Claude, the pairing
1091
+ * the architecture doc explicitly advertises) therefore shared one entry:
1092
+ * the second write replaced the first, one client received the other's
1093
+ * result, and the overwritten request hung forever with no error. Rewriting
1094
+ * also stops the provider from seeing two concurrent requests with the same
1095
+ * id, which it has no way to answer correctly either.
1096
+ *
1097
+ * Invisible to conforming clients: {@link _routeFromProvider} restores the
1098
+ * original id before the answer is delivered.
1099
+ *
1100
+ * Frames with no usable id (notifications) and JSON-RPC batches (an array,
1101
+ * which has no top-level id) are returned untouched and untracked, exactly
1102
+ * as before.
1103
+ */
1104
+ private _trackRequest(state: IProviderState, frame: string, sink: ResponseSink): string {
1105
+ const message = parseObjectFrame(frame);
1106
+ if (!message) return frame;
1107
+ const clientId = message.id;
1108
+ if (typeof clientId !== "string" && typeof clientId !== "number") return frame;
1109
+
1110
+ const brokerId = `${BROKER_REQUEST_ID_PREFIX}${this._nextRequestId++}`;
1111
+ const timeout = this._requestTimeoutMs();
1112
+ state.pending.set(brokerId, { sink, clientId, expiresAt: timeout > 0 ? Date.now() + timeout : 0 });
1113
+ message.id = brokerId;
1114
+ return JSON.stringify(message);
1115
+ }
1116
+
1117
+ /** Delivers one already-addressed frame to the sink that is waiting for it. */
1118
+ private _deliverToSink(state: IProviderState, sink: ResponseSink, data: string): void {
1119
+ switch (sink.type) {
1120
+ case "ws":
1121
+ if (sink.socket.readyState === WebSocket.OPEN) sink.socket.send(data);
1122
+ return;
1123
+ case "sse": {
1124
+ const sseRes = state.sseSessions.get(sink.sessionId);
1125
+ if (sseRes) this._sendSseEvent(sseRes, data);
1126
+ return;
1127
+ }
1128
+ case "http-session":
1129
+ // The session transport decides whether this answers a held-open
1130
+ // POST or travels on the GET stream: it correlates by id.
1131
+ state.httpSessions.get(sink.sessionId)?.transport.send(data);
1132
+ return;
1133
+ case "stdio":
1134
+ this._stdioClientTransport?.send(data);
1135
+ return;
1136
+ case "internal":
1137
+ sink.client.onMessage?.(data);
1138
+ return;
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Starts the sweep that fails requests a provider never answered.
1144
+ *
1145
+ * Same `unref()` requirement as the heartbeat. The period is derived from
1146
+ * the deadline rather than fixed, so a short timeout (a test, or a
1147
+ * latency-sensitive deployment) is still honored roughly on time instead of
1148
+ * being rounded up to the next sweep minutes later.
1149
+ */
1150
+ private _startRequestTimeoutSweep(): void {
1151
+ const timeout = this._requestTimeoutMs();
1152
+ if (timeout === 0) return;
1153
+ const period = Math.max(250, Math.min(5_000, Math.floor(timeout / 2)));
1154
+ this._requestTimeoutTimer = setInterval(() => this._sweepPendingTimeouts(), period);
1155
+ this._requestTimeoutTimer.unref?.();
1156
+ }
1157
+
1158
+ private _stopRequestTimeoutSweep(): void {
1159
+ if (!this._requestTimeoutTimer) return;
1160
+ clearInterval(this._requestTimeoutTimer);
1161
+ this._requestTimeoutTimer = null;
1162
+ }
1163
+
1164
+ /**
1165
+ * Fails every request whose deadline has passed.
1166
+ *
1167
+ * Until this existed a pending entry was released only by a matching
1168
+ * response, a provider disconnect, or the client's own close, so a provider
1169
+ * that stayed connected and simply never answered (a browser tab throttled
1170
+ * in the background is the ordinary case) left the caller waiting with
1171
+ * nothing to release it and no trace anywhere. A named error is strictly
1172
+ * better than a hang.
1173
+ */
1174
+ private _sweepPendingTimeouts(): void {
1175
+ const timeout = this._requestTimeoutMs();
1176
+ if (timeout === 0) return;
1177
+ const now = Date.now();
1178
+
1179
+ for (const [name, state] of this._providers) {
1180
+ for (const [brokerId, entry] of state.pending) {
1181
+ if (entry.expiresAt === 0 || entry.expiresAt > now) continue;
1182
+ state.pending.delete(brokerId);
1183
+ console.warn(
1184
+ `[broker] provider "${name}" did not answer a request within ${timeout}ms; the caller was sent a timeout error. ` +
1185
+ `The socket is still connected, so this is the provider not replying rather than a disconnect: check that its MCP message handler was installed BEFORE the transport connected, ` +
1186
+ `that it echoes the JSON-RPC id it was given, and that the page hosting it is not throttled in a background tab. ` +
1187
+ `Raise providerRequestTimeoutMs for genuinely long-running tools.`
1188
+ );
1189
+ this._deliverToSink(
1190
+ state,
1191
+ entry.sink,
1192
+ JSON.stringify({
1193
+ jsonrpc: "2.0",
1194
+ id: entry.clientId,
1195
+ error: {
1196
+ code: -32000,
1197
+ message:
1198
+ `Provider "${name}" did not respond within ${timeout}ms. The provider socket is still connected but sent no answer for this request. ` +
1199
+ `Call broker_diagnose on the _broker slot for the live state of this slot.`,
1200
+ },
1201
+ })
1202
+ );
1203
+ }
1204
+ }
1205
+ }
1206
+
617
1207
  private _authorizationSubject(principal: IPrincipal | null): IAuthorizationSubject {
618
1208
  if (principal?.subject) return principal.subject;
619
1209
  if (!principal) return { ids: [] };
@@ -625,9 +1215,18 @@ export class WsTunnel implements IBrokerContext {
625
1215
  }
626
1216
  );
627
1217
  } catch (error) {
628
- if (!(error instanceof SubjectMappingError)) {
629
- console.error("[broker] authorization subject mapping failed.");
630
- }
1218
+ // Both branches are logged, deliberately. A `SubjectMappingError` used
1219
+ // to be swallowed here, which made the single most likely
1220
+ // misconfiguration the one case that produced no output at all, while
1221
+ // the empty subject returned below makes the policy engine deny every
1222
+ // request from this caller. It is a configuration fault, not attacker
1223
+ // noise, and the HTTP path already diagnoses it out loud (a 403 saying
1224
+ // "Malformed configured JWT subject claim", see `auth/http.auth.ts`).
1225
+ const kind = error instanceof SubjectMappingError ? "malformed configured JWT subject claim" : "subject mapping failed";
1226
+ console.error(
1227
+ `[broker] authorization ${kind}: ${(error as Error).message}` +
1228
+ ` -- every request from this caller is denied while this stands. Check authorization.subjectMapping against the claims the token actually carries.`
1229
+ );
631
1230
  return { ids: [], claims: principal.claims };
632
1231
  }
633
1232
  }
@@ -683,9 +1282,14 @@ export class WsTunnel implements IBrokerContext {
683
1282
  for (const value of operations) {
684
1283
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
685
1284
  const operation = value as Readonly<{ method?: string; params?: unknown }>;
1285
+ // Hoisted out of the `try` so the failure path can still name the
1286
+ // capability when the throw came from the engine rather than from the
1287
+ // classifier.
1288
+ let capability: string | undefined;
686
1289
  try {
687
1290
  const classified = authorization.capabilityClassifier.classify(operation, resource, providerName);
688
1291
  if (!classified) continue;
1292
+ capability = classified.capability;
689
1293
  const decision = authorization.engine.authorize({
690
1294
  subject,
691
1295
  capability: classified.capability,
@@ -695,10 +1299,22 @@ export class WsTunnel implements IBrokerContext {
695
1299
  });
696
1300
  this._auditDecision(subject, providerName, resource, classified.capability, classified.tool, decision);
697
1301
  if (!decision.allowed) return false;
698
- } catch {
699
- console.error("[broker] policy evaluation failed.");
700
- const decision: IAuthorizationDecision = { allowed: false, reason: "no-matching-grant" };
701
- this._auditDecision(subject, providerName, resource, undefined, undefined, decision);
1302
+ } catch (error) {
1303
+ // The evaluation itself threw: a classifier or engine fault, not a
1304
+ // policy miss. The two call for opposite fixes, and the audit event
1305
+ // used to claim "no-matching-grant", which sends the operator off
1306
+ // to write grants that can never help. So say what actually
1307
+ // happened, with everything needed to reproduce it.
1308
+ console.error(
1309
+ `[broker] policy evaluation error on slot "${providerName}": ${(error as Error).message}` +
1310
+ ` -- subject=[${subject.ids.join(", ") || "(none)"}] resource="${resource.value}" capability="${capability ?? "(unclassified)"}" method="${operation.method ?? "(none)"}".` +
1311
+ ` The request is denied. This is a fault in the authorization configuration or in the frame, not a missing grant.`
1312
+ );
1313
+ // "evaluation-error", not "no-matching-grant": nothing was
1314
+ // decided here, the request is denied because that is the safe
1315
+ // answer to a fault.
1316
+ const decision: IAuthorizationDecision = { allowed: false, reason: "evaluation-error" };
1317
+ this._auditDecision(subject, providerName, resource, capability, undefined, decision);
702
1318
  return false;
703
1319
  }
704
1320
  }
@@ -775,6 +1391,10 @@ export class WsTunnel implements IBrokerContext {
775
1391
  const { providerName, endpoint } = route;
776
1392
  const kind = this._mcpEndpointKind(endpoint, method);
777
1393
  if (kind) {
1394
+ // The `mcp` kind is left out on purpose: `StreamableHttpEndpoint`
1395
+ // already runs the identical check, with the same predicate.
1396
+ if ((kind === "sse-connect" || kind === "sse-message") && !this._sseOriginAllowed(req, res, providerName, kind)) return;
1397
+
778
1398
  if (this._authGuard) {
779
1399
  // Gate on a valid bearer token issued for this slot before touching
780
1400
  // the provider. On failure, emit an RFC 9728 challenge / 500.
@@ -799,6 +1419,47 @@ export class WsTunnel implements IBrokerContext {
799
1419
  }
800
1420
  }
801
1421
 
1422
+ /**
1423
+ * Applies the browser-origin check to the legacy SSE pair, answering `403`
1424
+ * and returning `false` when the origin is refused.
1425
+ *
1426
+ * This closes a hole, and it is the one behavior change in this release a
1427
+ * browser page can notice. `allowedOrigins` reached exactly one place, the
1428
+ * Streamable HTTP endpoint, so `/<slot>/sse` and `/<slot>/messages` were
1429
+ * open to every page on the machine: `Access-Control-Allow-Origin: *` is
1430
+ * set unconditionally a few lines above, and a POST of JSON to
1431
+ * `/<slot>/messages` is a CORS *simple* request, so any page could open an
1432
+ * `EventSource`, read the session id off the `endpoint` event, and drive the
1433
+ * broker. That is verbatim the attack the origin check exists to stop.
1434
+ *
1435
+ * The contract of the check is preserved exactly: a request carrying **no**
1436
+ * `Origin` always passes, which is every non-browser client (Claude Desktop,
1437
+ * Inspector, the server-side SDKs), and an `Origin` passes only if
1438
+ * `allowedOrigins` names it. The same predicate object is used as for
1439
+ * `/<slot>/mcp`, so the two endpoints cannot drift apart.
1440
+ */
1441
+ private _sseOriginAllowed(req: IncomingMessage, res: ServerResponse, providerName: string, kind: McpEndpointKind): boolean {
1442
+ const origin = req.headers.origin;
1443
+ // A request with no Origin cannot come from a browser, so it is not this
1444
+ // check's business and passes straight through.
1445
+ if (typeof origin !== "string" || origin.length === 0) return true;
1446
+ if (this._allowedOrigins(origin)) return true;
1447
+
1448
+ const endpoint = kind === "sse-connect" ? `/${providerName}${this._options.ssePath ?? "/sse"}` : `/${providerName}${this._options.messagesPath ?? "/messages"}`;
1449
+ const configured = this._options.allowedOrigins !== undefined;
1450
+ const description = configured
1451
+ ? `Origin "${origin}" is not allowed to reach "${endpoint}". It is compared verbatim against the configured allowedOrigins, so a different scheme, port or a trailing slash does not match. ` +
1452
+ `Add the exact origin the browser sends (MCP_BROKER_ALLOWED_ORIGINS, the \`allowedOrigins\` config key, or WsTunnelBuilder.withAllowedOrigins).`
1453
+ : `Origin "${origin}" is refused because no browser origin is allowed: \`allowedOrigins\` is unset, and the default is closed. ` +
1454
+ `A page served by this broker is not exempt, its origin has to be listed too. ` +
1455
+ `Set MCP_BROKER_ALLOWED_ORIGINS (the \`allowedOrigins\` config key, or WsTunnelBuilder.withAllowedOrigins) to "${origin}" to open it, and only then.`;
1456
+
1457
+ console.warn(`[broker] refused SSE request on "${endpoint}": ${description}`);
1458
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
1459
+ res.end(JSON.stringify({ error: "invalid_origin", error_description: description }));
1460
+ return false;
1461
+ }
1462
+
802
1463
  /**
803
1464
  * Parses `/<providerName>/<endpoint>` from a URL path.
804
1465
  * Returns `null` if the URL does not match this two-segment pattern.
@@ -857,38 +1518,117 @@ export class WsTunnel implements IBrokerContext {
857
1518
  }
858
1519
 
859
1520
  /**
860
- * Builds the `ws` verifyClient hook that authenticates every WebSocket
861
- * upgrade before the connection is accepted, returning a real `401`/`403`
862
- * during the handshake rather than a post-handshake close:
1521
+ * Decides, from the URL alone, what one WebSocket upgrade is asking for.
863
1522
  *
1523
+ * One function rather than the two `startsWith` chains this used to be, one
1524
+ * in the connection handler and one in `verifyClient`. They agreed by
1525
+ * coincidence, and a disagreement would mean a socket authenticated as one
1526
+ * role and then served as another.
1527
+ *
1528
+ * The three refusals are paths that cannot work whatever is behind them:
1529
+ * a provider URL with no slot name (which used to mint a slot literally
1530
+ * named `(unnamed)` that nothing could ever address) and a slot-scoped
1531
+ * provider URL with more than one segment (`/provider/a/b`, which aliases
1532
+ * the `%2F`-encoded spelling of the same slot, so refusing it removes
1533
+ * nothing and forces one spelling).
1534
+ *
1535
+ * What is deliberately **not** refused: a slot name that matches nothing
1536
+ * configured. Claiming a free slot by connecting to it is how a provider
1537
+ * registers, so an unknown name is the normal case, not an error.
1538
+ */
1539
+ private _classifyWsRoute(url: string): WsRouteClassification {
1540
+ const providerPath = this._options.providerPath ?? "/provider";
1541
+ const providersPath = this._options.providersPath ?? "/providers";
1542
+
1543
+ // Exact match plus query string: the multiplex endpoint takes its slot
1544
+ // names from the envelopes, never from the path.
1545
+ if (url === providersPath || url.startsWith(providersPath + "?")) return { role: "multiplex-provider" };
1546
+
1547
+ // `${providerPath}?x=1` is deliberately absent from this test: it has
1548
+ // always been served as a client on a slot named after the path, and
1549
+ // narrowing that is not this change's business.
1550
+ if (url === providerPath) {
1551
+ return {
1552
+ role: "reject",
1553
+ detail:
1554
+ `"${providerPath}" is the provider path PREFIX, not an endpoint. A slot-scoped provider appends its slot name: "${providerPath}/<name>". ` +
1555
+ `If you meant the shared multiplexed socket, that is "${providersPath}" (exact path, one socket for many slots, envelope framing). ` +
1556
+ `Connecting here used to occupy a slot literally named "(unnamed)", which no client could address.`,
1557
+ closeReason: `Missing slot name: connect to ${providerPath}/<name>, or to ${providersPath}`,
1558
+ };
1559
+ }
1560
+
1561
+ if (url.startsWith(providerPath + "/")) {
1562
+ const raw = url.slice(providerPath.length + 1).split("?")[0];
1563
+ if (raw.length === 0) {
1564
+ return {
1565
+ role: "reject",
1566
+ detail: `"${url}" has an empty slot name. Append the slot the provider publishes: "${providerPath}/<name>".`,
1567
+ closeReason: `Empty slot name: connect to ${providerPath}/<name>`,
1568
+ };
1569
+ }
1570
+ // Tested on the RAW segment, before decoding: hierarchical slot names
1571
+ // are legitimate and travel percent-encoded (`%2Fsite-a%2Fline-3`),
1572
+ // so decoding first would refuse exactly the shape the authorization
1573
+ // layer is built around.
1574
+ if (raw.includes("/")) {
1575
+ return {
1576
+ role: "reject",
1577
+ detail:
1578
+ `"${url}" has more than one path segment after "${providerPath}/". A slot-scoped provider URL carries exactly one segment. ` +
1579
+ `A slot name containing "/" must be percent-encoded: "${providerPath}/${encodeURIComponent(safeDecode(raw))}". ` +
1580
+ `Both spellings resolve to the same slot, so only the encoded one is accepted.`,
1581
+ closeReason: `Too many path segments: percent-encode the slot name after ${providerPath}/`,
1582
+ };
1583
+ }
1584
+ return { role: "dedicated-provider", slot: safeDecode(raw) };
1585
+ }
1586
+
1587
+ // Raw WS MCP client: URL is "/<slot>" or "/".
1588
+ return { role: "client", slot: safeDecode(url.replace(/^\//, "").split("?")[0]) };
1589
+ }
1590
+
1591
+ /**
1592
+ * Builds the `ws` verifyClient hook, which decides every WebSocket upgrade
1593
+ * before the connection is accepted, returning a real HTTP status during
1594
+ * the handshake rather than a post-handshake close:
1595
+ *
1596
+ * - Paths that cannot work are refused with `400` and the full reason as the
1597
+ * response body (see {@link _classifyWsRoute}).
864
1598
  * - **Raw MCP clients** (`/<slot>`) are gated by the OAuth 2.1 resource
865
1599
  * server ({@link _authGuard}) with the RFC 9728 `WWW-Authenticate` challenge.
866
1600
  * - **Providers** (`/provider/<slot>`, `/providers`) are gated by the
867
1601
  * {@link _providerAuth} shared-secret / custom authenticator.
868
1602
  *
869
- * Each side is independent: a branch with no authenticator configured is let
870
- * through unchanged. Returns `undefined` (no hook) when neither is set.
1603
+ * Each authentication side is independent: a branch with no authenticator
1604
+ * configured is let through unchanged. The hook is always installed now,
1605
+ * because the path check applies whether or not anything is authenticated.
871
1606
  */
872
- private _makeVerifyClient(): VerifyClientCallbackAsync | undefined {
1607
+ private _makeVerifyClient(): VerifyClientCallbackAsync {
873
1608
  const guard = this._authGuard;
874
1609
  const providerAuth = this._providerAuth;
875
- if (!guard && !providerAuth) return undefined;
876
-
877
- const providerPath = this._options.providerPath ?? "/provider";
878
- const providersPath = this._options.providersPath ?? "/providers";
879
1610
 
880
1611
  return (info, cb) => {
881
1612
  const url = info.req.url ?? "/";
882
- const isMultiplex = url === providersPath || url.startsWith(providersPath + "?");
883
- const isDedicated = url.startsWith(providerPath + "/") || url === providerPath;
1613
+ const route = this._classifyWsRoute(url);
1614
+
1615
+ if (route.role === "reject") {
1616
+ // `ws` writes this string as the body of the refused upgrade, so
1617
+ // a Node client reads it off `unexpected-response`. A browser
1618
+ // gets no status from script, which is why the connect log line
1619
+ // carries the same diagnosis on the server side.
1620
+ console.warn(`[broker] ws upgrade REFUSED path="${url}": ${route.detail}`);
1621
+ cb(false, 400, route.detail);
1622
+ return;
1623
+ }
884
1624
 
885
- if (isMultiplex || isDedicated) {
1625
+ if (route.role === "multiplex-provider" || route.role === "dedicated-provider") {
886
1626
  // Provider (engine) upgrade, shared-secret / custom authenticator.
887
1627
  if (!providerAuth) {
888
1628
  cb(true);
889
1629
  return;
890
1630
  }
891
- const slot = isDedicated ? decodeURIComponent(url.slice(providerPath.length).replace(/^\//, "").split("?")[0]) || undefined : undefined;
1631
+ const slot = route.role === "dedicated-provider" ? route.slot : undefined;
892
1632
  Promise.resolve(providerAuth.authenticate(info.req, slot)).then(
893
1633
  (rawResult) => {
894
1634
  const result = normalizeProviderAuthentication(rawResult);
@@ -965,8 +1705,8 @@ export class WsTunnel implements IBrokerContext {
965
1705
 
966
1706
  req.on("close", () => {
967
1707
  state.sseSessions.delete(sessionId);
968
- for (const [id, sink] of state.pending) {
969
- if (sink.type === "sse" && sink.sessionId === sessionId) state.pending.delete(id);
1708
+ for (const [brokerId, entry] of state.pending) {
1709
+ if (entry.sink.type === "sse" && entry.sink.sessionId === sessionId) state.pending.delete(brokerId);
970
1710
  }
971
1711
  });
972
1712
  }
@@ -997,33 +1737,15 @@ export class WsTunnel implements IBrokerContext {
997
1737
  res.end(this._policyDeniedPayload(body));
998
1738
  return;
999
1739
  }
1000
- try {
1001
- const msg = JSON.parse(body) as { id?: string | number };
1002
- if (msg.id != null) state.pending.set(msg.id, { type: "sse", sessionId });
1003
- } catch {
1004
- /* malformed, forward anyway */
1005
- }
1006
-
1007
1740
  if (this._isProviderConnected(providerName, state)) {
1008
- this._sendToProvider(state, providerName, body, principal);
1741
+ // Tracked only once the frame is actually on its way out: an
1742
+ // entry added before the connectivity check has nothing to
1743
+ // answer it and would pin that id until the slot next
1744
+ // disconnects (the raw-WS path had the same ordering bug).
1745
+ this._sendToProvider(state, providerName, this._trackRequest(state, body, { type: "sse", sessionId }), principal);
1009
1746
  } else {
1010
1747
  const sseRes = state.sseSessions.get(sessionId);
1011
- if (sseRes) {
1012
- let errId: string | number | null = null;
1013
- try {
1014
- errId = (JSON.parse(body) as { id?: string | number }).id ?? null;
1015
- } catch {
1016
- /* */
1017
- }
1018
- this._sendSseEvent(
1019
- sseRes,
1020
- JSON.stringify({
1021
- jsonrpc: "2.0",
1022
- id: errId,
1023
- error: { code: -32000, message: `Provider "${providerName}" not connected` },
1024
- })
1025
- );
1026
- }
1748
+ if (sseRes) this._sendSseEvent(sseRes, this._notConnectedPayload(providerName, body));
1027
1749
  }
1028
1750
 
1029
1751
  res.writeHead(202);
@@ -1088,8 +1810,8 @@ export class WsTunnel implements IBrokerContext {
1088
1810
  state.httpSessions.delete(sessionId);
1089
1811
  // Nothing can answer the requests this session had in
1090
1812
  // flight; leaving them would pin the ids forever.
1091
- for (const [id, sink] of state.pending) {
1092
- if (sink.type === "http-session" && sink.sessionId === sessionId) state.pending.delete(id);
1813
+ for (const [brokerId, entry] of state.pending) {
1814
+ if (entry.sink.type === "http-session" && entry.sink.sessionId === sessionId) state.pending.delete(brokerId);
1093
1815
  }
1094
1816
  },
1095
1817
  };
@@ -1111,10 +1833,7 @@ export class WsTunnel implements IBrokerContext {
1111
1833
  return;
1112
1834
  }
1113
1835
 
1114
- const id = requestIdOf(frame);
1115
- if (id !== undefined) state.pending.set(id, { type: "http-session", sessionId });
1116
-
1117
- this._sendToProvider(state, providerName, frame, session.principal);
1836
+ this._sendToProvider(state, providerName, this._trackRequest(state, frame, { type: "http-session", sessionId }), session.principal);
1118
1837
  }
1119
1838
 
1120
1839
  /** Writes one JSON-RPC message as an SSE `message` event. */
@@ -1127,6 +1846,39 @@ export class WsTunnel implements IBrokerContext {
1127
1846
  // WebSocket connection handlers
1128
1847
  // -------------------------------------------------------------------------
1129
1848
 
1849
+ /**
1850
+ * Prints one line for every accepted WebSocket upgrade: the path asked for,
1851
+ * the role the router gave it, and the slot it landed on.
1852
+ *
1853
+ * Without it a successful connect produces no output whatsoever, so a
1854
+ * mistyped provider URL looks exactly like a working one until nothing ever
1855
+ * answers. The last router branch accepts **any** unmatched path as a client
1856
+ * slot, so `/providers/foo` (neither the multiplex endpoint `/providers` nor
1857
+ * a dedicated `/provider/foo`) becomes a client on a slot literally named
1858
+ * `providers/foo`; that case gets the fix spelled out in the same line.
1859
+ *
1860
+ * `console.log` is safe here: in stdio mode `bin.ts` rebinds the console to
1861
+ * stderr before the tunnel starts, so this never reaches the JSON-RPC stream.
1862
+ */
1863
+ private _logWsConnection(path: string, role: WsConnectRole, slot: string): void {
1864
+ let hint = "";
1865
+ if (role === "client") {
1866
+ const providerPath = this._options.providerPath ?? "/provider";
1867
+ const providersPath = this._options.providersPath ?? "/providers";
1868
+ const providerSlot = providerPath.replace(/^\//, "");
1869
+ const providersSlot = providersPath.replace(/^\//, "");
1870
+ // `/provider?x=1` matches neither provider branch (they test the bare
1871
+ // path and the `<path>/` prefix), and `/providers/foo` matches neither
1872
+ // the exact multiplex path nor the dedicated prefix.
1873
+ if (slot === providerSlot || slot === providersSlot || slot.startsWith(providersSlot + "/")) {
1874
+ hint =
1875
+ ` -- this looks like a provider URL but matched no provider route, so it was accepted as a CLIENT slot and nothing will ever answer it. ` +
1876
+ `Connect a MultiplexTransport to "${providersPath}" (exact path, slots announced per envelope), or a DirectTransport to "${providerPath}/<name>".`;
1877
+ }
1878
+ }
1879
+ console.log(`[broker] ws connect path="${path}" role=${role} slot="${slot}"${hint}`);
1880
+ }
1881
+
1130
1882
  private _logProviderRegistration(principal: IProviderPrincipal, slot: string, resource: ResourcePath | undefined, allowed: boolean): void {
1131
1883
  const event = {
1132
1884
  timestamp: new Date().toISOString(),
@@ -1150,24 +1902,26 @@ export class WsTunnel implements IBrokerContext {
1150
1902
  console.warn(
1151
1903
  `[broker] WARNING: WebSocket provider "${name}" rejected: a stdio upstream with the same name is already configured. ` + `Rename one of them to avoid the conflict.`
1152
1904
  );
1153
- ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
1905
+ this._closeWs(ws, 1008, `Provider "${name}" is managed by a stdio upstream`);
1154
1906
  return;
1155
1907
  }
1156
1908
 
1157
1909
  if (this._loopbackProviders.has(name)) {
1158
1910
  console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected: the slot is held by an in-process loopback (reserved system slot).`);
1159
- ws.close(1008, `Provider "${name}" is reserved by the broker`);
1911
+ this._closeWs(ws, 1008, `Provider "${name}" is reserved by the broker`);
1160
1912
  return;
1161
1913
  }
1162
1914
 
1163
- const existing = this._providers.get(name);
1164
- if (existing?.ws?.readyState === WebSocket.OPEN) {
1165
- ws.close(1008, `Provider "${name}" is already connected`);
1915
+ const refusal = this._claimSlot(name, ws);
1916
+ if (refusal) {
1917
+ console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected. ${refusal.detail}`);
1918
+ this._closeWs(ws, 1008, refusal.closeReason);
1166
1919
  return;
1167
1920
  }
1168
1921
 
1169
1922
  const state = this._getOrCreateProviderState(name);
1170
1923
  state.ws = ws;
1924
+ this._watchProviderSocket(ws);
1171
1925
  if (providerPrincipal) {
1172
1926
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
1173
1927
  }
@@ -1181,35 +1935,164 @@ export class WsTunnel implements IBrokerContext {
1181
1935
  const text = data.toString();
1182
1936
  if (!registrationChecked) {
1183
1937
  registrationChecked = true;
1938
+ if (this._refuseEnvelopeOnSlotPath(ws, name, text)) return;
1184
1939
  if (this._tryHandleRegistration(name, text)) return;
1185
1940
  }
1186
1941
  this._routeFromProvider(state, name, text);
1187
1942
  });
1188
1943
 
1189
1944
  ws.on("close", () => {
1945
+ // Only the socket that currently holds the slot may release it.
1946
+ //
1947
+ // A provider that reconnects while its predecessor is still CLOSING
1948
+ // is admitted (the incumbent is no longer OPEN), takes `state.ws`,
1949
+ // and would then have its own registration nulled out by the
1950
+ // predecessor's deferred close. The slot stays wedged: the socket is
1951
+ // open and answering, `provider_status` reports `connected: false`,
1952
+ // and `_failProviderDisconnected` has already evicted the slot from
1953
+ // `_all`, which the aggregate opt-in cannot restore because it is
1954
+ // checked once per socket, on the first frame. The multiplexed path
1955
+ // has always guarded this way.
1956
+ if (state.ws !== ws) return;
1190
1957
  state.ws = null;
1191
1958
  this._failProviderDisconnected(state, name);
1192
1959
  });
1960
+
1961
+ // `ws` emits 'error' for receive-side protocol faults, an invalid UTF-8
1962
+ // text frame or a bad opcode among them, and Node rethrows an unhandled
1963
+ // 'error' event as an uncaught exception. Without this listener one
1964
+ // malformed frame from one provider takes the whole broker down and every
1965
+ // other provider with it. Log and let the socket close on its own: `ws`
1966
+ // closes it right after emitting.
1967
+ ws.on("error", (err: Error) => {
1968
+ console.error(`[broker] provider "${name}" socket error: ${err.message}. The socket is being closed; the provider should reconnect.`);
1969
+ });
1193
1970
  }
1194
1971
 
1195
1972
  /**
1196
- * Inspects a provider's first WebSocket message for an optional registration
1197
- * control frame `{ "type": "register", "aggregate": boolean }`. Returns
1198
- * `true` when the message was a registration frame: and thus consumed, not
1199
- * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
1973
+ * Inspects a provider's first WebSocket message for a registration frame,
1974
+ * and consumes it when that is what it is. Returns `true` when the message
1975
+ * was a registration, and thus must not be routed as MCP traffic.
1976
+ *
1977
+ * Two shapes are accepted, both notifications a peer that does not know them
1978
+ * ignores:
1979
+ *
1980
+ * - `{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`,
1981
+ * what `@cyanmycelium/mcp-broker-provider` sends on **both** paths. This
1982
+ * is the shape to write new providers against: the multiplexed socket has
1983
+ * always used it (wrapped in an envelope), so there is now one
1984
+ * registration to learn instead of one per path.
1985
+ * - `{"type":"register","aggregate":true}`, the legacy control frame, kept
1986
+ * working indefinitely for hand-written providers already sending it.
1987
+ *
1988
+ * `aggregate` is opt-in and stays that way. Joining `_all` publishes a
1989
+ * provider's tools and prompts to every client of the aggregate slot, which
1990
+ * is a confidentiality boundary: a provider that never asks is reachable
1991
+ * only on its own slot.
1992
+ *
1993
+ * A normal MCP frame carries `jsonrpc` with some other `method`, so it
1200
1994
  * returns `false` and the provider stays non-aggregated.
1201
1995
  */
1202
1996
  private _tryHandleRegistration(name: string, text: string): boolean {
1203
- let frame: { type?: unknown; jsonrpc?: unknown; aggregate?: unknown };
1997
+ const frame = parseObjectFrame(text);
1998
+ if (!frame) return false;
1999
+
2000
+ const isJsonRpcRegister = frame.jsonrpc !== undefined && frame.method === TUNNEL_REGISTER_METHOD;
2001
+ const isLegacyRegister = frame.jsonrpc === undefined && frame.type === "register";
2002
+ if (!isJsonRpcRegister && !isLegacyRegister) return false;
2003
+
2004
+ if (registrationAsksForAggregate(frame)) {
2005
+ void this._aggregateServer?.addProvider(name);
2006
+ }
2007
+ return true;
2008
+ }
2009
+
2010
+ /**
2011
+ * Detects a `MultiplexTransport` connected to a slot-scoped provider URL,
2012
+ * the single most reported way to wire this broker up wrong, and refuses it
2013
+ * instead of letting it look like it worked.
2014
+ *
2015
+ * What the mistake looks like without this check: the socket connects, the
2016
+ * broker registers the slot from the URL before any frame exists, so
2017
+ * `provider_status` reports the provider connected; then every frame the
2018
+ * broker sends is a plain JSON-RPC one that the peer's envelope decoder
2019
+ * drops on the floor, and every frame the peer sends is an envelope with no
2020
+ * top-level `id`, so it matches no pending request. Both sides believe they
2021
+ * are connected and no request is ever answered. Nothing is logged anywhere.
2022
+ *
2023
+ * Only an **unambiguous** envelope closes the socket: a JSON object with no
2024
+ * `jsonrpc` member that decodes as `{provider, payload}`. A malformed frame
2025
+ * is left alone and routed as before, since a provider is entitled to speak
2026
+ * a dialect the broker does not recognize. The legacy
2027
+ * `{"type":"register","aggregate":true}` frame has no `provider`/`payload`
2028
+ * pair and so is never mistaken for one.
2029
+ *
2030
+ * The diagnosis goes out three ways because each reaches a different reader:
2031
+ * the broker log, an error **envelope** (the only framing this particular
2032
+ * peer can decode), and the close reason, which the provider SDK surfaces
2033
+ * through `onError` and prints to the browser console.
2034
+ */
2035
+ private _refuseEnvelopeOnSlotPath(ws: WebSocket, name: string, text: string): boolean {
2036
+ const frame = parseObjectFrame(text);
2037
+ if (!frame || frame.jsonrpc !== undefined) return false;
2038
+ const envelope = decodeEnvelope(text);
2039
+ if (!envelope) return false;
2040
+
2041
+ const providerPath = this._options.providerPath ?? "/provider";
2042
+ const providersPath = this._options.providersPath ?? "/providers";
2043
+ const detail =
2044
+ `Provider "${name}" is registered on the slot-scoped path "${providerPath}/${name}", which carries plain JSON-RPC frames, but its first frame is a multiplex envelope ` +
2045
+ `(it announced slot "${envelope.provider}"). This is a MultiplexTransport pointed at a DirectTransport URL. Two corrections, either works: ` +
2046
+ `use DirectTransport for "${providerPath}/${name}" (plain frames, one slot fixed by the URL), or keep MultiplexTransport and connect it to "${providersPath}" ` +
2047
+ `(exact path, envelope framing, slots announced per frame). The connection is being closed; it would otherwise look connected and answer nothing.`;
2048
+
2049
+ console.warn(`[broker] ${detail}`);
1204
2050
  try {
1205
- frame = JSON.parse(text) as typeof frame;
2051
+ // Addressed to the slot the peer announced, not to the URL's: that is
2052
+ // the name its own decoder will match the envelope against.
2053
+ ws.send(encodeErrorEnvelope(envelope.provider, TunnelErrorCodes.ProviderUnavailable, detail));
1206
2054
  } catch {
1207
- return false;
2055
+ /* the socket may already be gone; the log and the close reason stand */
1208
2056
  }
1209
- if (frame.jsonrpc !== undefined || frame.type !== "register") return false;
1210
- if (frame.aggregate === true) {
1211
- void this._aggregateServer?.addProvider(name);
2057
+ this._closeWs(ws, 1008, `Envelope frame on slot-scoped path ${providerPath}/${name}: use DirectTransport here, or connect to ${providersPath}`);
2058
+ return true;
2059
+ }
2060
+
2061
+ /**
2062
+ * The mirror image: a `DirectTransport` connected to the shared multiplexed
2063
+ * base. Refuses it instead of dropping its frames one by one in silence.
2064
+ *
2065
+ * Signature of the mistake: the socket opens, the broker never learns a slot
2066
+ * name (they only arrive inside envelopes), so no slot is ever claimed and
2067
+ * every client is told the provider is not connected, while the provider
2068
+ * believes it published successfully.
2069
+ *
2070
+ * Only a frame that is unambiguously plain JSON-RPC (a JSON object carrying
2071
+ * `jsonrpc`, that did not decode as an envelope) closes the socket. Anything
2072
+ * else keeps the old behavior of dropping the frame, now with one warning.
2073
+ */
2074
+ private _refusePlainFrameOnMultiplexPath(ws: WebSocket, text: string): boolean {
2075
+ const frame = parseObjectFrame(text);
2076
+ if (!frame || frame.jsonrpc === undefined) return false;
2077
+
2078
+ const providerPath = this._options.providerPath ?? "/provider";
2079
+ const providersPath = this._options.providersPath ?? "/providers";
2080
+ const detail =
2081
+ `A provider connected to the multiplexed path "${providersPath}", which carries envelope frames {"provider":"<slot>","payload":{...}}, but sent a plain JSON-RPC frame ` +
2082
+ `(method "${String(frame.method ?? "(none)")}"). This is a DirectTransport pointed at a MultiplexTransport URL. Two corrections, either works: ` +
2083
+ `use MultiplexTransport for "${providersPath}" (it wraps every frame and announces its slots), or keep DirectTransport and connect it to "${providerPath}/<name>" ` +
2084
+ `(the slot comes from the URL there). The connection is being closed; on this path the broker never learns a slot name, so every client would be told the provider is not connected.`;
2085
+
2086
+ console.warn(`[broker] ${detail}`);
2087
+ try {
2088
+ // A bare JSON-RPC error, matching what this peer speaks: an envelope
2089
+ // would be dropped by a DirectTransport exactly the way its own
2090
+ // frames are being dropped here.
2091
+ ws.send(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: TunnelErrorCodes.ProviderUnavailable, message: detail } }));
2092
+ } catch {
2093
+ /* the socket may already be gone; the log and the close reason stand */
1212
2094
  }
2095
+ this._closeWs(ws, 1008, `Plain JSON-RPC on multiplex path ${providersPath}: use MultiplexTransport here, or connect to ${providerPath}/<name>`);
1213
2096
  return true;
1214
2097
  }
1215
2098
 
@@ -1229,10 +2112,17 @@ export class WsTunnel implements IBrokerContext {
1229
2112
 
1230
2113
  ws.on("close", () => {
1231
2114
  state.wsClients.delete(ws);
1232
- for (const [id, sink] of state.pending) {
1233
- if (sink.type === "ws" && sink.socket === ws) state.pending.delete(id);
2115
+ for (const [brokerId, entry] of state.pending) {
2116
+ if (entry.sink.type === "ws" && entry.sink.socket === ws) state.pending.delete(brokerId);
1234
2117
  }
1235
2118
  });
2119
+
2120
+ // Same reason as on the provider sockets: an unhandled 'error' event is
2121
+ // rethrown by Node, so a single client sending a malformed frame would
2122
+ // otherwise kill the broker for everybody.
2123
+ ws.on("error", (err: Error) => {
2124
+ console.error(`[broker] client socket error on slot "${providerName}": ${err.message}. The socket is being closed; the client should reconnect.`);
2125
+ });
1236
2126
  }
1237
2127
 
1238
2128
  /**
@@ -1244,15 +2134,39 @@ export class WsTunnel implements IBrokerContext {
1244
2134
  private _onMultiplexProviderConnect(ws: WebSocket, req: IncomingMessage): void {
1245
2135
  const providerNames = new Set<string>();
1246
2136
  this._multiplexSockets.set(ws, providerNames);
2137
+ this._watchProviderSocket(ws);
1247
2138
  const providerPrincipal = this._pendingProviderPrincipals.get(req);
1248
2139
  if (providerPrincipal) {
1249
2140
  this._pendingProviderPrincipals.delete(req);
1250
2141
  this._providerPrincipals.set(ws, providerPrincipal);
1251
2142
  }
1252
2143
 
2144
+ // Undecodable frames used to be dropped without a word. The first one is
2145
+ // where a DirectTransport on the wrong URL is caught; later ones warn
2146
+ // once, because a peer that speaks the wrong dialect speaks it on every
2147
+ // frame and must not be allowed to flood the log.
2148
+ let firstFrame = true;
2149
+ let undecodableWarned = false;
2150
+
1253
2151
  ws.on("message", (data: Buffer) => {
1254
- const envelope = decodeEnvelope(data.toString());
1255
- if (!envelope) return; // malformed, drop
2152
+ const text = data.toString();
2153
+ const envelope = decodeEnvelope(text);
2154
+ const wasFirstFrame = firstFrame;
2155
+ firstFrame = false;
2156
+
2157
+ if (!envelope) {
2158
+ if (wasFirstFrame && this._refusePlainFrameOnMultiplexPath(ws, text)) return;
2159
+ if (!undecodableWarned) {
2160
+ undecodableWarned = true;
2161
+ const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
2162
+ console.warn(
2163
+ `[broker] multiplexed provider socket sent a frame that is not a tunnel envelope; it was dropped. ` +
2164
+ `This path expects {"provider":"<slot>","payload":{ ...JSON-RPC... }} on every frame. First 200 chars: ${JSON.stringify(preview)}. ` +
2165
+ `Further undecodable frames from this socket are not logged.`
2166
+ );
2167
+ }
2168
+ return;
2169
+ }
1256
2170
 
1257
2171
  const name = envelope.provider;
1258
2172
 
@@ -1280,10 +2194,15 @@ export class WsTunnel implements IBrokerContext {
1280
2194
  return;
1281
2195
  }
1282
2196
 
1283
- const existing = this._providers.get(name);
1284
- if (existing?.ws?.readyState === WebSocket.OPEN) {
1285
- // Provider already connected via another socket, reject this name.
1286
- ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.ProviderUnavailable, `Provider "${name}" is already connected`));
2197
+ const refusal = this._claimSlot(name, ws);
2198
+ if (refusal) {
2199
+ // Provider already connected via another socket, reject this
2200
+ // name. The socket itself stays open on purpose: it may be
2201
+ // carrying other slots it is legitimately serving.
2202
+ console.warn(`[broker] WARNING: multiplexed WebSocket provider "${name}" rejected. ${refusal.detail}`);
2203
+ // An envelope has no length limit, so the peer gets the full
2204
+ // diagnosis here, not the close-frame abbreviation.
2205
+ ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.ProviderUnavailable, refusal.detail));
1287
2206
  return;
1288
2207
  }
1289
2208
  providerNames.add(name);
@@ -1292,6 +2211,16 @@ export class WsTunnel implements IBrokerContext {
1292
2211
  if (providerPrincipal) {
1293
2212
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
1294
2213
  }
2214
+
2215
+ // The multiplexed path could not aggregate at all before this:
2216
+ // it had no `addProvider` call anywhere, so the one transport the
2217
+ // provider README demonstrates was the one that could never join
2218
+ // `_all`. The opt-in rides on the `notifications/register`
2219
+ // notification the transport already sends on every open, as
2220
+ // `params.aggregate`.
2221
+ if (registrationAsksForAggregate(envelope.payload)) {
2222
+ void this._aggregateServer?.addProvider(name);
2223
+ }
1295
2224
  }
1296
2225
 
1297
2226
  const state = this._providers.get(name)!;
@@ -1308,6 +2237,17 @@ export class WsTunnel implements IBrokerContext {
1308
2237
  }
1309
2238
  this._multiplexSockets.delete(ws);
1310
2239
  });
2240
+
2241
+ // An unhandled 'error' event is rethrown by Node. One multiplex socket
2242
+ // carries every slot it announced, so losing the process over a single
2243
+ // malformed frame is the worst of the three cases: name the slots this
2244
+ // socket was serving, since they are all about to go with it.
2245
+ ws.on("error", (err: Error) => {
2246
+ const slots = providerNames.size > 0 ? [...providerNames].join(", ") : "(none announced yet)";
2247
+ console.error(
2248
+ `[broker] multiplex provider socket error: ${err.message}. Slots carried by this socket: ${slots}. The socket is being closed; the provider should reconnect.`
2249
+ );
2250
+ });
1311
2251
  }
1312
2252
 
1313
2253
  // -------------------------------------------------------------------------
@@ -1352,15 +2292,8 @@ export class WsTunnel implements IBrokerContext {
1352
2292
  }
1353
2293
 
1354
2294
  private _routeFromStdioClient(state: IProviderState, data: string): void {
1355
- try {
1356
- const msg = JSON.parse(data) as { id?: string | number };
1357
- if (msg?.id != null) state.pending.set(msg.id, { type: "stdio" });
1358
- } catch {
1359
- /* forward as-is */
1360
- }
1361
-
1362
2295
  if (this._isProviderConnected(this._stdioClientProvider!, state)) {
1363
- this._sendToProvider(state, this._stdioClientProvider!, data);
2296
+ this._sendToProvider(state, this._stdioClientProvider!, this._trackRequest(state, data, { type: "stdio" }));
1364
2297
  } else {
1365
2298
  let errId: string | number | null = null;
1366
2299
  try {
@@ -1369,11 +2302,23 @@ export class WsTunnel implements IBrokerContext {
1369
2302
  /* */
1370
2303
  }
1371
2304
  if (errId != null) {
2305
+ // A stdio host (Claude Desktop) starts the broker and sends
2306
+ // `initialize` immediately, long before a browser or engine
2307
+ // provider has had a chance to connect, and it treats the failure
2308
+ // as a dead server with no retry. So the error names the two slots
2309
+ // that exist from the first millisecond instead of just reporting
2310
+ // the miss.
1372
2311
  this._stdioClientTransport?.send(
1373
2312
  JSON.stringify({
1374
2313
  jsonrpc: "2.0",
1375
2314
  id: errId,
1376
- error: { code: -32000, message: `Provider "${this._stdioClientProvider}" not connected` },
2315
+ error: {
2316
+ code: -32000,
2317
+ message:
2318
+ `Provider "${this._stdioClientProvider}" not connected. A stdio host cannot wait for a provider to appear: pin ` +
2319
+ `MCP_BROKER_STDIO_PROVIDER to "_all" (the aggregate slot, present at startup, and it announces tools as providers join) ` +
2320
+ `or to "_broker" (introspection only: broker_info, providers_list, provider_status).`,
2321
+ },
1377
2322
  })
1378
2323
  );
1379
2324
  }
@@ -1387,24 +2332,31 @@ export class WsTunnel implements IBrokerContext {
1387
2332
  return;
1388
2333
  }
1389
2334
 
1390
- try {
1391
- const msg = JSON.parse(data) as { id?: string | number };
1392
- if (msg?.id != null) state.pending.set(msg.id, { type: "ws", socket: client });
1393
- } catch {
1394
- /* forward as-is */
1395
- }
1396
-
1397
- if (this._isProviderConnected(providerName, state)) {
1398
- this._sendToProvider(state, providerName, data, principal);
1399
- } else {
2335
+ if (!this._isProviderConnected(providerName, state)) {
2336
+ // Echo the request id and name the slot. With `id: null` a client that
2337
+ // correlates by id (every SDK does) drops the frame and then waits for
2338
+ // an answer that is never coming, so the symptom of an absent provider
2339
+ // was an indefinite hang rather than an error. The pointer to
2340
+ // `providers_list` is what turns it into something actionable: the
2341
+ // usual cause is a slot name that does not match the one the provider
2342
+ // registered.
1400
2343
  client.send(
1401
2344
  JSON.stringify({
1402
2345
  jsonrpc: "2.0",
1403
- id: null,
1404
- error: { code: -32000, message: "No provider connected" },
2346
+ id: requestIdOf(data) ?? null,
2347
+ error: {
2348
+ code: -32000,
2349
+ message: `Provider "${providerName}" is not connected. Call providers_list on the _broker slot to see which slots are live.`,
2350
+ },
1405
2351
  })
1406
2352
  );
2353
+ return;
1407
2354
  }
2355
+
2356
+ // Registered only once the frame is actually on its way out: an entry
2357
+ // added before the connectivity check has nothing to answer it and would
2358
+ // pin that id until the slot next disconnects.
2359
+ this._sendToProvider(state, providerName, this._trackRequest(state, data, { type: "ws", socket: client }), principal);
1408
2360
  }
1409
2361
 
1410
2362
  private _routeFromProvider(state: IProviderState, providerName: string, data: string): void {
@@ -1412,32 +2364,67 @@ export class WsTunnel implements IBrokerContext {
1412
2364
  const msg = JSON.parse(data) as { id?: string | number };
1413
2365
 
1414
2366
  if (msg.id != null) {
1415
- // Response: route to the specific sink that made the request.
1416
- const sink = state.pending.get(msg.id);
1417
- if (sink?.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
1418
- sink.socket.send(data);
1419
- } else if (sink?.type === "sse") {
1420
- const sseRes = state.sseSessions.get(sink.sessionId);
1421
- if (sseRes) this._sendSseEvent(sseRes, data);
1422
- } else if (sink?.type === "http-session") {
1423
- // The session transport decides whether this answers a held-open
1424
- // POST or travels on the GET stream: it correlates by id.
1425
- state.httpSessions.get(sink.sessionId)?.transport.send(data);
1426
- } else if (sink?.type === "stdio") {
1427
- this._stdioClientTransport?.send(data);
1428
- } else if (sink?.type === "internal") {
1429
- sink.client.onMessage?.(data);
2367
+ // Response: route to the specific sink that made the request, and
2368
+ // put the client's own id back in place of the broker's before it
2369
+ // is delivered (see `_trackRequest`).
2370
+ const entry = state.pending.get(msg.id);
2371
+ if (entry) {
2372
+ state.pending.delete(msg.id);
2373
+ (msg as { id: unknown }).id = entry.clientId;
2374
+ this._deliverToSink(state, entry.sink, JSON.stringify(msg));
2375
+ } else {
2376
+ this._warnUnmatchedResponseId(providerName, msg.id);
1430
2377
  }
1431
- state.pending.delete(msg.id);
1432
2378
  } else {
1433
2379
  // Notification (no id): broadcast to all clients of this provider.
1434
2380
  this._broadcast(state, providerName, data);
1435
2381
  }
1436
2382
  } catch {
2383
+ // The frame could not be parsed and routed as JSON-RPC. It is still
2384
+ // broadcast, because some providers legitimately put non-JSON-RPC text
2385
+ // on this socket and silently dropping it would be a behavior change,
2386
+ // but it no longer travels unannounced: the usual source is a reverse
2387
+ // proxy answering with an HTML error page under a 200, which then
2388
+ // surfaces in every client as an "unexpected token <" with nothing
2389
+ // naming the provider it came from.
2390
+ if (!this._nonJsonWarnedProviders.has(providerName)) {
2391
+ this._nonJsonWarnedProviders.add(providerName);
2392
+ const preview = data.length > 200 ? `${data.slice(0, 200)}...` : data;
2393
+ console.warn(
2394
+ `[broker] provider "${providerName}" sent a frame the broker could not route as JSON-RPC; it is being broadcast to that slot's clients verbatim. ` +
2395
+ `First 200 chars: ${JSON.stringify(preview)}. If this is HTML, something between the broker and the provider (a proxy, a login page) is answering instead of the provider. ` +
2396
+ `Further such frames from this slot are not logged.`
2397
+ );
2398
+ }
1437
2399
  this._broadcast(state, providerName, data);
1438
2400
  }
1439
2401
  }
1440
2402
 
2403
+ /**
2404
+ * Reports a frame carrying an id nothing is waiting for.
2405
+ *
2406
+ * Two very different causes, so the message names both. Either the provider
2407
+ * did not echo the id it was given (the frame is then unroutable and the
2408
+ * real client hangs until the request deadline), or the provider is opening
2409
+ * a **server-to-client** request of its own (`sampling/createMessage`,
2410
+ * `roots/list`, `elicitation/create`), which this broker does not relay: the
2411
+ * frame is dropped and the provider waits for an answer that never comes.
2412
+ * Both used to be silent, which is what made the second one impossible to
2413
+ * diagnose from either end.
2414
+ *
2415
+ * Once per slot: a provider doing this does it on every frame.
2416
+ */
2417
+ private _warnUnmatchedResponseId(providerName: string, id: string | number): void {
2418
+ if (this._unmatchedIdWarnedProviders.has(providerName)) return;
2419
+ this._unmatchedIdWarnedProviders.add(providerName);
2420
+ console.warn(
2421
+ `[broker] provider "${providerName}" sent a frame with id ${JSON.stringify(id)}, which matches no request the broker is waiting on; it was dropped. ` +
2422
+ `Either the provider answered with a different id than the one it received (JSON-RPC requires echoing it verbatim, including its type: 1 and "1" are not the same id), ` +
2423
+ `or it is initiating a request of its own (sampling/createMessage, roots/list, elicitation/create), which the broker does not relay to clients. ` +
2424
+ `Further unmatched ids from this slot are not logged.`
2425
+ );
2426
+ }
2427
+
1441
2428
  /** Sends a message to all clients connected to one provider. */
1442
2429
  private _broadcast(state: IProviderState, providerName: string, data: string): void {
1443
2430
  for (const client of state.wsClients) {
@@ -1475,27 +2462,17 @@ export class WsTunnel implements IBrokerContext {
1475
2462
  * close handlers (dedicated WS, multiplexed WS, loopback).
1476
2463
  */
1477
2464
  private _failProviderDisconnected(state: IProviderState, name: string): void {
1478
- // Echoing the pending id rather than `null`: a Streamable HTTP session
1479
- // matches the answer to its held-open POST by id, so an unaddressed
1480
- // error would leave that request hanging until it times out.
1481
- for (const [id, sink] of state.pending) {
2465
+ // Echoing the **client's** id rather than `null` or the broker's: a
2466
+ // Streamable HTTP session matches the answer to its held-open POST by
2467
+ // the id it chose, so an unaddressed error would leave that request
2468
+ // hanging until it times out.
2469
+ for (const entry of state.pending.values()) {
1482
2470
  const error = JSON.stringify({
1483
2471
  jsonrpc: "2.0",
1484
- id,
2472
+ id: entry.clientId,
1485
2473
  error: { code: -32000, message: `Provider "${name}" disconnected` },
1486
2474
  });
1487
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
1488
- sink.socket.send(error);
1489
- } else if (sink.type === "sse") {
1490
- const sseRes = state.sseSessions.get(sink.sessionId);
1491
- if (sseRes) this._sendSseEvent(sseRes, error);
1492
- } else if (sink.type === "http-session") {
1493
- state.httpSessions.get(sink.sessionId)?.transport.send(error);
1494
- } else if (sink.type === "stdio") {
1495
- this._stdioClientTransport?.send(error);
1496
- } else if (sink.type === "internal") {
1497
- sink.client.onMessage?.(error);
1498
- }
2475
+ this._deliverToSink(state, entry.sink, error);
1499
2476
  }
1500
2477
  state.pending.clear();
1501
2478
  for (const ic of state.internalClients) ic.onClose?.();