@cyanmycelium/mcp-broker 0.2.0 → 0.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 (62) hide show
  1. package/.mcp-broker.example/README.md +23 -0
  2. package/.mcp-broker.example/config.json +11 -0
  3. package/README.md +1 -16
  4. package/dist/bin.js +30 -3
  5. package/dist/bin.js.map +1 -1
  6. package/dist/broker/aggregate/aggregate.catalog.d.ts +54 -0
  7. package/dist/broker/aggregate/aggregate.catalog.js +105 -0
  8. package/dist/broker/aggregate/aggregate.catalog.js.map +1 -0
  9. package/dist/broker/aggregate/aggregate.server.d.ts +47 -0
  10. package/dist/broker/aggregate/aggregate.server.js +151 -0
  11. package/dist/broker/aggregate/aggregate.server.js.map +1 -0
  12. package/dist/broker/aggregate/provider.client.session.d.ts +52 -0
  13. package/dist/broker/aggregate/provider.client.session.js +140 -0
  14. package/dist/broker/aggregate/provider.client.session.js.map +1 -0
  15. package/dist/broker/broker.server.js +12 -1
  16. package/dist/broker/broker.server.js.map +1 -1
  17. package/dist/config.d.ts +35 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcpb.loader.d.ts +24 -0
  23. package/dist/mcpb.loader.js +161 -0
  24. package/dist/mcpb.loader.js.map +1 -0
  25. package/dist/mcpb.unzip.d.ts +6 -0
  26. package/dist/mcpb.unzip.js +95 -0
  27. package/dist/mcpb.unzip.js.map +1 -0
  28. package/dist/remote.transports.d.ts +16 -0
  29. package/dist/remote.transports.js +297 -0
  30. package/dist/remote.transports.js.map +1 -0
  31. package/dist/remote.upstream.d.ts +36 -0
  32. package/dist/remote.upstream.js +52 -0
  33. package/dist/remote.upstream.js.map +1 -0
  34. package/dist/stdio.upstream.d.ts +4 -1
  35. package/dist/stdio.upstream.js.map +1 -1
  36. package/dist/upstream.d.ts +33 -0
  37. package/dist/upstream.js +2 -0
  38. package/dist/upstream.js.map +1 -0
  39. package/dist/ws.tunnel.builder.d.ts +14 -8
  40. package/dist/ws.tunnel.builder.js +17 -9
  41. package/dist/ws.tunnel.builder.js.map +1 -1
  42. package/dist/ws.tunnel.d.ts +67 -2
  43. package/dist/ws.tunnel.js +200 -79
  44. package/dist/ws.tunnel.js.map +1 -1
  45. package/package.json +3 -2
  46. package/scripts/pack-mcpb.mjs +84 -0
  47. package/scripts/sign-bundle.mjs +61 -0
  48. package/src/bin.ts +32 -3
  49. package/src/broker/aggregate/aggregate.catalog.ts +145 -0
  50. package/src/broker/aggregate/aggregate.server.ts +178 -0
  51. package/src/broker/aggregate/provider.client.session.ts +172 -0
  52. package/src/broker/broker.server.ts +12 -1
  53. package/src/config.ts +37 -0
  54. package/src/index.ts +8 -0
  55. package/src/mcpb.loader.ts +186 -0
  56. package/src/mcpb.unzip.ts +103 -0
  57. package/src/remote.transports.ts +316 -0
  58. package/src/remote.upstream.ts +75 -0
  59. package/src/stdio.upstream.ts +4 -1
  60. package/src/upstream.ts +33 -0
  61. package/src/ws.tunnel.builder.ts +19 -9
  62. package/src/ws.tunnel.ts +239 -74
package/src/ws.tunnel.ts CHANGED
@@ -7,8 +7,11 @@ import type { IncomingMessage, ServerResponse } from "http";
7
7
  import { WebSocket, WebSocketServer } from "ws";
8
8
  import type { IMessageTransport, IMcpServer } from "@cyanmycelium/mcp-core";
9
9
  import { StdioUpstream, type StdioUpstreamConfig } from "./stdio.upstream.js";
10
+ import { RemoteUpstream, type RemoteUpstreamConfig } from "./remote.upstream.js";
11
+ import type { Upstream } from "./upstream.js";
10
12
  import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
11
13
  import type { BrokerContext, BrokerLocaleResolver, BrokerProviderInfo, BrokerProviderTransport, BrokerUserAgentResolver } from "./broker/index.js";
14
+ import { AggregateServer } from "./broker/aggregate/aggregate.server.js";
12
15
  import { VERSION, PACKAGE_NAME } from "./version.js";
13
16
 
14
17
  // ---------------------------------------------------------------------------
@@ -38,9 +41,15 @@ const MIME: Readonly<Record<string, string>> = {
38
41
  * Where a JSON-RPC response should be delivered.
39
42
  * Either a WebSocket socket (raw WS client), an SSE session (legacy MCP/HTTP),
40
43
  * a held-open HTTP response (Streamable HTTP transport, MCP 2025-03-26),
41
- * or the process stdout (stdio transport for Claude Desktop).
44
+ * the process stdout (stdio transport for Claude Desktop), or an in-process
45
+ * internal client (e.g. the aggregate server).
42
46
  */
43
- type ResponseSink = { type: "ws"; socket: WebSocket } | { type: "sse"; sessionId: string } | { type: "http"; res: ServerResponse } | { type: "stdio" };
47
+ type ResponseSink =
48
+ | { type: "ws"; socket: WebSocket }
49
+ | { type: "sse"; sessionId: string }
50
+ | { type: "http"; res: ServerResponse }
51
+ | { type: "stdio" }
52
+ | { type: "internal"; client: InternalClient };
44
53
 
45
54
  /**
46
55
  * All mutable state for one named provider slot.
@@ -58,6 +67,8 @@ interface ProviderState {
58
67
  readonly mcpGetSessions: Map<string, ServerResponse>;
59
68
  /** Raw WebSocket MCP clients connected to this provider. */
60
69
  readonly wsClients: Set<WebSocket>;
70
+ /** In-process clients (e.g. the aggregate server) attached to this slot. */
71
+ readonly internalClients: Set<InternalClient>;
61
72
  }
62
73
 
63
74
  // ---------------------------------------------------------------------------
@@ -78,6 +89,28 @@ export interface StaticMount {
78
89
  dir: string;
79
90
  }
80
91
 
92
+ /**
93
+ * In-process client handle for a provider slot — the symmetric counterpart of
94
+ * {@link WsTunnel.registerLoopbackProvider}. Lets a component inside the broker
95
+ * process (e.g. the aggregate server) issue MCP requests to a provider slot and
96
+ * receive both the responses and the provider's broadcast notifications,
97
+ * without opening a real network connection.
98
+ */
99
+ export interface InternalClient {
100
+ /**
101
+ * Sends a JSON-RPC message to the provider slot. When the message carries an
102
+ * `id`, the matching response is delivered to {@link onMessage}. When the
103
+ * provider is not connected, a JSON-RPC error is delivered synchronously.
104
+ */
105
+ send(message: string): void;
106
+ /** Receives responses to this client's requests and the provider's notifications. */
107
+ onMessage: ((data: string) => void) | null;
108
+ /** Fires when the provider slot loses its connection. */
109
+ onClose: (() => void) | null;
110
+ /** Detaches this internal client; pending requests are dropped. */
111
+ close(): void;
112
+ }
113
+
81
114
  /**
82
115
  * Configuration options for a {@link WsTunnel} instance.
83
116
  */
@@ -158,6 +191,9 @@ export interface WsTunnelOptions {
158
191
  */
159
192
  stdioUpstreams?: StdioUpstreamConfig[];
160
193
 
194
+ /** Remote MCP servers reached by URL, exposed as provider slots. */
195
+ remoteUpstreams?: RemoteUpstreamConfig[];
196
+
161
197
  /**
162
198
  * Stdio client transport. When set, the broker reads JSON-RPC from
163
199
  * `process.stdin` and writes responses to `process.stdout`, bridging an
@@ -201,6 +237,17 @@ export interface WsTunnelOptions {
201
237
  */
202
238
  enableBrokerProvider?: boolean;
203
239
 
240
+ /**
241
+ * When `true` (default), the broker exposes the reserved slot `_all` — an
242
+ * aggregate MCP server that unions the tools and prompts of every provider
243
+ * that opted in via the registration handshake. Reachable like any other
244
+ * slot (`<host>/_all/mcp`, etc.).
245
+ *
246
+ * Set to `false` to disable aggregation entirely.
247
+ * @default true
248
+ */
249
+ enableAggregateProvider?: boolean;
250
+
204
251
  /**
205
252
  * Logical name reported by `broker_info`. Useful when running multiple
206
253
  * broker instances and you want to tell them apart from the agent side
@@ -278,8 +325,8 @@ export class WsTunnel implements BrokerContext {
278
325
  /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
279
326
  private readonly _multiplexSockets = new Map<WebSocket, Set<string>>();
280
327
 
281
- /** Stdio upstream providers, keyed by provider name. */
282
- private readonly _stdioUpstreams = new Map<string, StdioUpstream>();
328
+ /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
329
+ private readonly _upstreams = new Map<string, Upstream>();
283
330
 
284
331
  /**
285
332
  * In-process loopback transports registered as provider slots.
@@ -291,6 +338,9 @@ export class WsTunnel implements BrokerContext {
291
338
  /** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
292
339
  private _brokerServer: IMcpServer | null = null;
293
340
 
341
+ /** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
342
+ private _aggregateServer: AggregateServer | null = null;
343
+
294
344
  /** Provider name that the stdio client transport is bridged to, or null when disabled. */
295
345
  private _stdioClientProvider: string | null = null;
296
346
 
@@ -370,7 +420,7 @@ export class WsTunnel implements BrokerContext {
370
420
  if (this._loopbackProviders.get(name)?.isOpen) {
371
421
  transport = "loopback";
372
422
  connected = true;
373
- } else if (this._stdioUpstreams.get(name)?.isOpen) {
423
+ } else if (this._upstreams.get(name)?.isOpen) {
374
424
  transport = "stdio";
375
425
  connected = true;
376
426
  } else if (state.ws?.readyState === WebSocket.OPEN) {
@@ -406,7 +456,7 @@ export class WsTunnel implements BrokerContext {
406
456
  if (this._loopbackProviders.has(name)) {
407
457
  throw new Error(`Loopback provider "${name}" is already registered.`);
408
458
  }
409
- if (this._stdioUpstreams.has(name)) {
459
+ if (this._upstreams.has(name)) {
410
460
  throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
411
461
  }
412
462
 
@@ -416,25 +466,60 @@ export class WsTunnel implements BrokerContext {
416
466
  transport.onMessage = (data: string) => this._routeFromProvider(state, data);
417
467
  transport.onClose = () => {
418
468
  this._loopbackProviders.delete(name);
419
- // Tell every pending sink that the provider is gone, same as for a WS close.
420
- const error = JSON.stringify({
421
- jsonrpc: "2.0",
422
- id: null,
423
- error: { code: -32000, message: `Provider "${name}" disconnected` },
424
- });
425
- for (const sink of state.pending.values()) {
426
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
427
- sink.socket.send(error);
428
- } else if (sink.type === "sse") {
429
- const sseRes = state.sseSessions.get(sink.sessionId);
430
- if (sseRes) this._sendSseEvent(sseRes, error);
431
- } else if (sink.type === "http") {
432
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
433
- sink.res.end(error);
469
+ this._failProviderDisconnected(state, name);
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Opens an in-process client to a provider slot. The returned handle can
475
+ * issue MCP requests and receives both the responses and the provider's
476
+ * broadcast notifications. Used by the aggregate server to fan a single
477
+ * in-process client out to every aggregated provider.
478
+ *
479
+ * The slot does not need a provider attached yet — `send` returns a
480
+ * JSON-RPC error while the provider is disconnected.
481
+ */
482
+ public openInternalClient(providerName: string): InternalClient {
483
+ const state = this._getOrCreateProviderState(providerName);
484
+ let closed = false;
485
+
486
+ const client: InternalClient = {
487
+ onMessage: null,
488
+ onClose: null,
489
+ send: (message: string): void => {
490
+ if (closed) return;
491
+ let id: string | number | null = null;
492
+ try {
493
+ const parsed = JSON.parse(message) as { id?: string | number };
494
+ if (parsed?.id != null) id = parsed.id;
495
+ } catch {
496
+ /* forward as-is */
434
497
  }
435
- }
436
- state.pending.clear();
498
+ if (this._isProviderConnected(providerName, state)) {
499
+ if (id != null) state.pending.set(id, { type: "internal", client });
500
+ this._sendToProvider(state, providerName, message);
501
+ } else if (id != null) {
502
+ client.onMessage?.(
503
+ JSON.stringify({
504
+ jsonrpc: "2.0",
505
+ id,
506
+ error: { code: -32000, message: `Provider "${providerName}" not connected` },
507
+ })
508
+ );
509
+ }
510
+ },
511
+ close: (): void => {
512
+ if (closed) return;
513
+ closed = true;
514
+ state.internalClients.delete(client);
515
+ for (const [id, sink] of state.pending) {
516
+ if (sink.type === "internal" && sink.client === client) state.pending.delete(id);
517
+ }
518
+ },
437
519
  };
520
+
521
+ state.internalClients.add(client);
522
+ return client;
438
523
  }
439
524
 
440
525
  // -------------------------------------------------------------------------
@@ -502,9 +587,14 @@ export class WsTunnel implements BrokerContext {
502
587
  });
503
588
 
504
589
  this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
505
- // Spawn configured stdio upstream providers.
506
- for (const cfg of this._options.stdioUpstreams ?? []) {
507
- const upstream = new StdioUpstream(cfg);
590
+ // Bring the aggregate `_all` slot up before any upstream connects
591
+ // (a Streamable HTTP upstream opens synchronously on connect()).
592
+ this._maybeStartAggregateServer();
593
+
594
+ // Attach configured upstreams (stdio child processes + remote URL
595
+ // servers). Both implement the Upstream contract, so the wiring
596
+ // into a provider slot is identical.
597
+ const wireUpstream = (cfg: { name: string; aggregate?: boolean }, upstream: Upstream): void => {
508
598
  upstream.onMessage = (data) => {
509
599
  const state = this._getOrCreateProviderState(cfg.name);
510
600
  this._routeFromProvider(state, data);
@@ -512,9 +602,18 @@ export class WsTunnel implements BrokerContext {
512
602
  upstream.onError = (err) => {
513
603
  console.error(`[broker] ${err.message}`);
514
604
  };
515
- this._stdioUpstreams.set(cfg.name, upstream);
605
+ upstream.onClose = () => {
606
+ const state = this._providers.get(cfg.name);
607
+ if (state) this._failProviderDisconnected(state, cfg.name);
608
+ };
609
+ if (cfg.aggregate) {
610
+ upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
611
+ }
612
+ this._upstreams.set(cfg.name, upstream);
516
613
  upstream.connect();
517
- }
614
+ };
615
+ for (const cfg of this._options.stdioUpstreams ?? []) wireUpstream(cfg, new StdioUpstream(cfg));
616
+ for (const cfg of this._options.remoteUpstreams ?? []) wireUpstream(cfg, new RemoteUpstream(cfg));
518
617
 
519
618
  // Attach stdio client transport if configured.
520
619
  // stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
@@ -570,6 +669,27 @@ export class WsTunnel implements BrokerContext {
570
669
  });
571
670
  this._brokerServer = server;
572
671
  this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
672
+
673
+ // Aggregate the broker's own introspection tools into `_all`, so a stdio
674
+ // host pinned to `_all` still reaches broker_info / providers_list /
675
+ // provider_status alongside the other aggregated providers.
676
+ void this._aggregateServer?.addProvider(BROKER_PROVIDER_NAME);
677
+ }
678
+
679
+ /**
680
+ * Starts the aggregate MCP server and registers it on the reserved `_all`
681
+ * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
682
+ */
683
+ private _maybeStartAggregateServer(): void {
684
+ if (this._options.enableAggregateProvider === false) return;
685
+ try {
686
+ const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
687
+ server.start();
688
+ this.registerLoopbackProvider(AggregateServer.SLOT, server);
689
+ this._aggregateServer = server;
690
+ } catch (err) {
691
+ console.error(`[broker] aggregate server failed to start: ${(err as Error).message}`);
692
+ }
573
693
  }
574
694
 
575
695
  /**
@@ -588,6 +708,18 @@ export class WsTunnel implements BrokerContext {
588
708
  }
589
709
  }
590
710
 
711
+ // Close the aggregate server so its provider sessions and internal
712
+ // clients detach before the provider slots are torn down.
713
+ const aggregateServer = this._aggregateServer;
714
+ this._aggregateServer = null;
715
+ if (aggregateServer) {
716
+ try {
717
+ aggregateServer.close();
718
+ } catch {
719
+ /* best-effort; continue tearing down */
720
+ }
721
+ }
722
+
591
723
  return new Promise((resolve, reject) => {
592
724
  for (const state of this._providers.values()) {
593
725
  for (const res of state.sseSessions.values()) res.end();
@@ -600,8 +732,8 @@ export class WsTunnel implements BrokerContext {
600
732
  }
601
733
  this._providers.clear();
602
734
  this._multiplexSockets.clear();
603
- for (const upstream of this._stdioUpstreams.values()) upstream.close();
604
- this._stdioUpstreams.clear();
735
+ for (const upstream of this._upstreams.values()) upstream.close();
736
+ this._upstreams.clear();
605
737
  for (const loopback of this._loopbackProviders.values()) loopback.close();
606
738
  this._loopbackProviders.clear();
607
739
  this._startedAt = null;
@@ -855,7 +987,7 @@ export class WsTunnel implements BrokerContext {
855
987
  // -------------------------------------------------------------------------
856
988
 
857
989
  private _onProviderConnect(ws: WebSocket, name: string): void {
858
- if (this._stdioUpstreams.has(name)) {
990
+ if (this._upstreams.has(name)) {
859
991
  console.warn(
860
992
  `[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
861
993
  `Rename one of them to avoid the conflict.`
@@ -879,31 +1011,47 @@ export class WsTunnel implements BrokerContext {
879
1011
  const state = this._getOrCreateProviderState(name);
880
1012
  state.ws = ws;
881
1013
 
882
- ws.on("message", (data: Buffer) => this._routeFromProvider(state, data.toString()));
1014
+ // A provider MAY send a registration control frame as its very first
1015
+ // message (see _tryHandleRegistration). Any other first message —
1016
+ // including a normal MCP frame — is routed and leaves the provider
1017
+ // non-aggregated, so every pre-existing provider keeps working.
1018
+ let registrationChecked = false;
1019
+ ws.on("message", (data: Buffer) => {
1020
+ const text = data.toString();
1021
+ if (!registrationChecked) {
1022
+ registrationChecked = true;
1023
+ if (this._tryHandleRegistration(name, text)) return;
1024
+ }
1025
+ this._routeFromProvider(state, text);
1026
+ });
883
1027
 
884
1028
  ws.on("close", () => {
885
1029
  state.ws = null;
886
- // Notify all pending sinks that the provider is gone.
887
- const error = JSON.stringify({
888
- jsonrpc: "2.0",
889
- id: null,
890
- error: { code: -32000, message: `Provider "${name}" disconnected` },
891
- });
892
- for (const sink of state.pending.values()) {
893
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
894
- sink.socket.send(error);
895
- } else if (sink.type === "sse") {
896
- const sseRes = state.sseSessions.get(sink.sessionId);
897
- if (sseRes) this._sendSseEvent(sseRes, error);
898
- } else if (sink.type === "http") {
899
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
900
- sink.res.end(error);
901
- }
902
- }
903
- state.pending.clear();
1030
+ this._failProviderDisconnected(state, name);
904
1031
  });
905
1032
  }
906
1033
 
1034
+ /**
1035
+ * Inspects a provider's first WebSocket message for an optional registration
1036
+ * control frame `{ "type": "register", "aggregate": boolean }`. Returns
1037
+ * `true` when the message was a registration frame — and thus consumed, not
1038
+ * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
1039
+ * returns `false` and the provider stays non-aggregated.
1040
+ */
1041
+ private _tryHandleRegistration(name: string, text: string): boolean {
1042
+ let frame: { type?: unknown; jsonrpc?: unknown; aggregate?: unknown };
1043
+ try {
1044
+ frame = JSON.parse(text) as typeof frame;
1045
+ } catch {
1046
+ return false;
1047
+ }
1048
+ if (frame.jsonrpc !== undefined || frame.type !== "register") return false;
1049
+ if (frame.aggregate === true) {
1050
+ void this._aggregateServer?.addProvider(name);
1051
+ }
1052
+ return true;
1053
+ }
1054
+
907
1055
  private _onClientConnect(ws: WebSocket, providerName: string): void {
908
1056
  const state = this._getOrCreateProviderState(providerName);
909
1057
  state.wsClients.add(ws);
@@ -941,7 +1089,7 @@ export class WsTunnel implements BrokerContext {
941
1089
 
942
1090
  // Register provider name lazily on first encounter.
943
1091
  if (!providerNames.has(name)) {
944
- if (this._stdioUpstreams.has(name)) {
1092
+ if (this._upstreams.has(name)) {
945
1093
  console.warn(
946
1094
  `[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
947
1095
  `Rename one of them to avoid the conflict.`
@@ -1002,24 +1150,7 @@ export class WsTunnel implements BrokerContext {
1002
1150
  const state = this._providers.get(name);
1003
1151
  if (state && state.ws === ws) {
1004
1152
  state.ws = null;
1005
- // Notify pending sinks that the provider is gone.
1006
- const error = JSON.stringify({
1007
- jsonrpc: "2.0",
1008
- id: null,
1009
- error: { code: -32000, message: `Provider "${name}" disconnected` },
1010
- });
1011
- for (const sink of state.pending.values()) {
1012
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
1013
- sink.socket.send(error);
1014
- } else if (sink.type === "sse") {
1015
- const sseRes = state.sseSessions.get(sink.sessionId);
1016
- if (sseRes) this._sendSseEvent(sseRes, error);
1017
- } else if (sink.type === "http") {
1018
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1019
- sink.res.end(error);
1020
- }
1021
- }
1022
- state.pending.clear();
1153
+ this._failProviderDisconnected(state, name);
1023
1154
  }
1024
1155
  }
1025
1156
  this._multiplexSockets.delete(ws);
@@ -1035,10 +1166,10 @@ export class WsTunnel implements BrokerContext {
1035
1166
  * envelope when the provider's WebSocket is a multiplexed connection.
1036
1167
  */
1037
1168
  private _sendToProvider(state: ProviderState, providerName: string, data: string): void {
1038
- // Stdio upstreams take priority for exact name matches.
1039
- const stdioUpstream = this._stdioUpstreams.get(providerName);
1040
- if (stdioUpstream?.isOpen) {
1041
- stdioUpstream.send(data);
1169
+ // Upstreams (stdio child processes and remote URL servers) take priority.
1170
+ const upstream = this._upstreams.get(providerName);
1171
+ if (upstream?.isOpen) {
1172
+ upstream.send(data);
1042
1173
  return;
1043
1174
  }
1044
1175
 
@@ -1127,6 +1258,8 @@ export class WsTunnel implements BrokerContext {
1127
1258
  sink.res.end(data);
1128
1259
  } else if (sink?.type === "stdio") {
1129
1260
  process.stdout.write(data + "\n");
1261
+ } else if (sink?.type === "internal") {
1262
+ sink.client.onMessage?.(data);
1130
1263
  }
1131
1264
  state.pending.delete(msg.id);
1132
1265
  } else {
@@ -1149,12 +1282,43 @@ export class WsTunnel implements BrokerContext {
1149
1282
  for (const mcpRes of state.mcpGetSessions.values()) {
1150
1283
  this._sendSseEvent(mcpRes, data);
1151
1284
  }
1285
+ for (const ic of state.internalClients) {
1286
+ ic.onMessage?.(data);
1287
+ }
1152
1288
  // Forward notifications to the stdio client if it is watching this provider.
1153
1289
  if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
1154
1290
  process.stdout.write(data + "\n");
1155
1291
  }
1156
1292
  }
1157
1293
 
1294
+ /**
1295
+ * Notifies every pending sink and internal client that the provider slot
1296
+ * has disconnected, then clears the pending map. Shared by all provider
1297
+ * close handlers (dedicated WS, multiplexed WS, loopback).
1298
+ */
1299
+ private _failProviderDisconnected(state: ProviderState, name: string): void {
1300
+ const error = JSON.stringify({
1301
+ jsonrpc: "2.0",
1302
+ id: null,
1303
+ error: { code: -32000, message: `Provider "${name}" disconnected` },
1304
+ });
1305
+ for (const sink of state.pending.values()) {
1306
+ if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
1307
+ sink.socket.send(error);
1308
+ } else if (sink.type === "sse") {
1309
+ const sseRes = state.sseSessions.get(sink.sessionId);
1310
+ if (sseRes) this._sendSseEvent(sseRes, error);
1311
+ } else if (sink.type === "http") {
1312
+ sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1313
+ sink.res.end(error);
1314
+ } else if (sink.type === "internal") {
1315
+ sink.client.onMessage?.(error);
1316
+ }
1317
+ }
1318
+ state.pending.clear();
1319
+ for (const ic of state.internalClients) ic.onClose?.();
1320
+ }
1321
+
1158
1322
  // -------------------------------------------------------------------------
1159
1323
  // Provider state helpers
1160
1324
  // -------------------------------------------------------------------------
@@ -1164,7 +1328,7 @@ export class WsTunnel implements BrokerContext {
1164
1328
  * a stdio upstream, or an in-process loopback transport.
1165
1329
  */
1166
1330
  private _isProviderConnected(providerName: string, state: ProviderState): boolean {
1167
- if (this._stdioUpstreams.get(providerName)?.isOpen) return true;
1331
+ if (this._upstreams.get(providerName)?.isOpen) return true;
1168
1332
  if (this._loopbackProviders.get(providerName)?.isOpen) return true;
1169
1333
  if (state.ws?.readyState === WebSocket.OPEN) return true;
1170
1334
  return false;
@@ -1180,6 +1344,7 @@ export class WsTunnel implements BrokerContext {
1180
1344
  sseSessions: new Map(),
1181
1345
  mcpGetSessions: new Map(),
1182
1346
  wsClients: new Set(),
1347
+ internalClients: new Set(),
1183
1348
  };
1184
1349
  this._providers.set(name, state);
1185
1350
  }