@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
@@ -1,5 +1,6 @@
1
1
  import type { IMessageTransport } from "@cyanmycelium/mcp-core";
2
2
  import { type StdioUpstreamConfig } from "./stdio.upstream.js";
3
+ import { type RemoteUpstreamConfig } from "./remote.upstream.js";
3
4
  import type { BrokerContext, BrokerLocaleResolver, BrokerProviderInfo, BrokerUserAgentResolver } from "./broker/index.js";
4
5
  /**
5
6
  * A single static-file mount: serves the contents of `dir` under `urlPrefix`.
@@ -14,6 +15,27 @@ export interface StaticMount {
14
15
  /** Absolute path to the directory to serve. */
15
16
  dir: string;
16
17
  }
18
+ /**
19
+ * In-process client handle for a provider slot — the symmetric counterpart of
20
+ * {@link WsTunnel.registerLoopbackProvider}. Lets a component inside the broker
21
+ * process (e.g. the aggregate server) issue MCP requests to a provider slot and
22
+ * receive both the responses and the provider's broadcast notifications,
23
+ * without opening a real network connection.
24
+ */
25
+ export interface InternalClient {
26
+ /**
27
+ * Sends a JSON-RPC message to the provider slot. When the message carries an
28
+ * `id`, the matching response is delivered to {@link onMessage}. When the
29
+ * provider is not connected, a JSON-RPC error is delivered synchronously.
30
+ */
31
+ send(message: string): void;
32
+ /** Receives responses to this client's requests and the provider's notifications. */
33
+ onMessage: ((data: string) => void) | null;
34
+ /** Fires when the provider slot loses its connection. */
35
+ onClose: (() => void) | null;
36
+ /** Detaches this internal client; pending requests are dropped. */
37
+ close(): void;
38
+ }
17
39
  /**
18
40
  * Configuration options for a {@link WsTunnel} instance.
19
41
  */
@@ -83,6 +105,8 @@ export interface WsTunnelOptions {
83
105
  * @default undefined — no stdio providers
84
106
  */
85
107
  stdioUpstreams?: StdioUpstreamConfig[];
108
+ /** Remote MCP servers reached by URL, exposed as provider slots. */
109
+ remoteUpstreams?: RemoteUpstreamConfig[];
86
110
  /**
87
111
  * Stdio client transport. When set, the broker reads JSON-RPC from
88
112
  * `process.stdin` and writes responses to `process.stdout`, bridging an
@@ -125,6 +149,16 @@ export interface WsTunnelOptions {
125
149
  * @default true
126
150
  */
127
151
  enableBrokerProvider?: boolean;
152
+ /**
153
+ * When `true` (default), the broker exposes the reserved slot `_all` — an
154
+ * aggregate MCP server that unions the tools and prompts of every provider
155
+ * that opted in via the registration handshake. Reachable like any other
156
+ * slot (`<host>/_all/mcp`, etc.).
157
+ *
158
+ * Set to `false` to disable aggregation entirely.
159
+ * @default true
160
+ */
161
+ enableAggregateProvider?: boolean;
128
162
  /**
129
163
  * Logical name reported by `broker_info`. Useful when running multiple
130
164
  * broker instances and you want to tell them apart from the agent side
@@ -190,8 +224,8 @@ export declare class WsTunnel implements BrokerContext {
190
224
  private readonly _providers;
191
225
  /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
192
226
  private readonly _multiplexSockets;
193
- /** Stdio upstream providers, keyed by provider name. */
194
- private readonly _stdioUpstreams;
227
+ /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
228
+ private readonly _upstreams;
195
229
  /**
196
230
  * In-process loopback transports registered as provider slots.
197
231
  * Used by the embedded broker server (`_broker`) and any other component
@@ -200,6 +234,8 @@ export declare class WsTunnel implements BrokerContext {
200
234
  private readonly _loopbackProviders;
201
235
  /** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
202
236
  private _brokerServer;
237
+ /** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
238
+ private _aggregateServer;
203
239
  /** Provider name that the stdio client transport is bridged to, or null when disabled. */
204
240
  private _stdioClientProvider;
205
241
  /** Buffered partial line from stdin (stdio client transport). */
@@ -226,6 +262,16 @@ export declare class WsTunnel implements BrokerContext {
226
262
  * @throws if the name is already used by a stdio upstream or another loopback.
227
263
  */
228
264
  registerLoopbackProvider(name: string, transport: IMessageTransport): void;
265
+ /**
266
+ * Opens an in-process client to a provider slot. The returned handle can
267
+ * issue MCP requests and receives both the responses and the provider's
268
+ * broadcast notifications. Used by the aggregate server to fan a single
269
+ * in-process client out to every aggregated provider.
270
+ *
271
+ * The slot does not need a provider attached yet — `send` returns a
272
+ * JSON-RPC error while the provider is disconnected.
273
+ */
274
+ openInternalClient(providerName: string): InternalClient;
229
275
  get isListening(): boolean;
230
276
  /** Total number of connected MCP clients across all providers. */
231
277
  get clientCount(): number;
@@ -244,6 +290,11 @@ export declare class WsTunnel implements BrokerContext {
244
290
  * is `false`.
245
291
  */
246
292
  private _maybeStartBrokerServer;
293
+ /**
294
+ * Starts the aggregate MCP server and registers it on the reserved `_all`
295
+ * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
296
+ */
297
+ private _maybeStartAggregateServer;
247
298
  /**
248
299
  * Gracefully closes all connections and stops the HTTP server.
249
300
  */
@@ -280,6 +331,14 @@ export declare class WsTunnel implements BrokerContext {
280
331
  /** Writes one JSON-RPC message as an SSE `message` event. */
281
332
  private _sendSseEvent;
282
333
  private _onProviderConnect;
334
+ /**
335
+ * Inspects a provider's first WebSocket message for an optional registration
336
+ * control frame `{ "type": "register", "aggregate": boolean }`. Returns
337
+ * `true` when the message was a registration frame — and thus consumed, not
338
+ * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
339
+ * returns `false` and the provider stays non-aggregated.
340
+ */
341
+ private _tryHandleRegistration;
283
342
  private _onClientConnect;
284
343
  /**
285
344
  * Handles a multiplexed provider WebSocket (`/providers`).
@@ -298,6 +357,12 @@ export declare class WsTunnel implements BrokerContext {
298
357
  private _routeFromProvider;
299
358
  /** Sends a message to all clients connected to one provider. */
300
359
  private _broadcast;
360
+ /**
361
+ * Notifies every pending sink and internal client that the provider slot
362
+ * has disconnected, then clears the pending map. Shared by all provider
363
+ * close handlers (dedicated WS, multiplexed WS, loopback).
364
+ */
365
+ private _failProviderDisconnected;
301
366
  /**
302
367
  * Returns `true` if the provider is reachable — via a WebSocket connection,
303
368
  * a stdio upstream, or an in-process loopback transport.
package/dist/ws.tunnel.js CHANGED
@@ -5,7 +5,9 @@ import * as nodePath from "path";
5
5
  import { randomUUID } from "crypto";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
7
  import { StdioUpstream } from "./stdio.upstream.js";
8
+ import { RemoteUpstream } from "./remote.upstream.js";
8
9
  import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
10
+ import { AggregateServer } from "./broker/aggregate/aggregate.server.js";
9
11
  import { VERSION, PACKAGE_NAME } from "./version.js";
10
12
  // ---------------------------------------------------------------------------
11
13
  // Static-file helpers
@@ -60,8 +62,8 @@ export class WsTunnel {
60
62
  _providers = new Map();
61
63
  /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
62
64
  _multiplexSockets = new Map();
63
- /** Stdio upstream providers, keyed by provider name. */
64
- _stdioUpstreams = new Map();
65
+ /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
66
+ _upstreams = new Map();
65
67
  /**
66
68
  * In-process loopback transports registered as provider slots.
67
69
  * Used by the embedded broker server (`_broker`) and any other component
@@ -70,6 +72,8 @@ export class WsTunnel {
70
72
  _loopbackProviders = new Map();
71
73
  /** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
72
74
  _brokerServer = null;
75
+ /** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
76
+ _aggregateServer = null;
73
77
  /** Provider name that the stdio client transport is bridged to, or null when disabled. */
74
78
  _stdioClientProvider = null;
75
79
  /** Buffered partial line from stdin (stdio client transport). */
@@ -136,7 +140,7 @@ export class WsTunnel {
136
140
  transport = "loopback";
137
141
  connected = true;
138
142
  }
139
- else if (this._stdioUpstreams.get(name)?.isOpen) {
143
+ else if (this._upstreams.get(name)?.isOpen) {
140
144
  transport = "stdio";
141
145
  connected = true;
142
146
  }
@@ -171,7 +175,7 @@ export class WsTunnel {
171
175
  if (this._loopbackProviders.has(name)) {
172
176
  throw new Error(`Loopback provider "${name}" is already registered.`);
173
177
  }
174
- if (this._stdioUpstreams.has(name)) {
178
+ if (this._upstreams.has(name)) {
175
179
  throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
176
180
  }
177
181
  const state = this._getOrCreateProviderState(name);
@@ -179,28 +183,62 @@ export class WsTunnel {
179
183
  transport.onMessage = (data) => this._routeFromProvider(state, data);
180
184
  transport.onClose = () => {
181
185
  this._loopbackProviders.delete(name);
182
- // Tell every pending sink that the provider is gone, same as for a WS close.
183
- const error = JSON.stringify({
184
- jsonrpc: "2.0",
185
- id: null,
186
- error: { code: -32000, message: `Provider "${name}" disconnected` },
187
- });
188
- for (const sink of state.pending.values()) {
189
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
190
- sink.socket.send(error);
186
+ this._failProviderDisconnected(state, name);
187
+ };
188
+ }
189
+ /**
190
+ * Opens an in-process client to a provider slot. The returned handle can
191
+ * issue MCP requests and receives both the responses and the provider's
192
+ * broadcast notifications. Used by the aggregate server to fan a single
193
+ * in-process client out to every aggregated provider.
194
+ *
195
+ * The slot does not need a provider attached yet — `send` returns a
196
+ * JSON-RPC error while the provider is disconnected.
197
+ */
198
+ openInternalClient(providerName) {
199
+ const state = this._getOrCreateProviderState(providerName);
200
+ let closed = false;
201
+ const client = {
202
+ onMessage: null,
203
+ onClose: null,
204
+ send: (message) => {
205
+ if (closed)
206
+ return;
207
+ let id = null;
208
+ try {
209
+ const parsed = JSON.parse(message);
210
+ if (parsed?.id != null)
211
+ id = parsed.id;
191
212
  }
192
- else if (sink.type === "sse") {
193
- const sseRes = state.sseSessions.get(sink.sessionId);
194
- if (sseRes)
195
- this._sendSseEvent(sseRes, error);
213
+ catch {
214
+ /* forward as-is */
196
215
  }
197
- else if (sink.type === "http") {
198
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
199
- sink.res.end(error);
216
+ if (this._isProviderConnected(providerName, state)) {
217
+ if (id != null)
218
+ state.pending.set(id, { type: "internal", client });
219
+ this._sendToProvider(state, providerName, message);
200
220
  }
201
- }
202
- state.pending.clear();
221
+ else if (id != null) {
222
+ client.onMessage?.(JSON.stringify({
223
+ jsonrpc: "2.0",
224
+ id,
225
+ error: { code: -32000, message: `Provider "${providerName}" not connected` },
226
+ }));
227
+ }
228
+ },
229
+ close: () => {
230
+ if (closed)
231
+ return;
232
+ closed = true;
233
+ state.internalClients.delete(client);
234
+ for (const [id, sink] of state.pending) {
235
+ if (sink.type === "internal" && sink.client === client)
236
+ state.pending.delete(id);
237
+ }
238
+ },
203
239
  };
240
+ state.internalClients.add(client);
241
+ return client;
204
242
  }
205
243
  // -------------------------------------------------------------------------
206
244
  // Public state
@@ -260,9 +298,13 @@ export class WsTunnel {
260
298
  }
261
299
  });
262
300
  this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
263
- // Spawn configured stdio upstream providers.
264
- for (const cfg of this._options.stdioUpstreams ?? []) {
265
- const upstream = new StdioUpstream(cfg);
301
+ // Bring the aggregate `_all` slot up before any upstream connects
302
+ // (a Streamable HTTP upstream opens synchronously on connect()).
303
+ this._maybeStartAggregateServer();
304
+ // Attach configured upstreams (stdio child processes + remote URL
305
+ // servers). Both implement the Upstream contract, so the wiring
306
+ // into a provider slot is identical.
307
+ const wireUpstream = (cfg, upstream) => {
266
308
  upstream.onMessage = (data) => {
267
309
  const state = this._getOrCreateProviderState(cfg.name);
268
310
  this._routeFromProvider(state, data);
@@ -270,9 +312,21 @@ export class WsTunnel {
270
312
  upstream.onError = (err) => {
271
313
  console.error(`[broker] ${err.message}`);
272
314
  };
273
- this._stdioUpstreams.set(cfg.name, upstream);
315
+ upstream.onClose = () => {
316
+ const state = this._providers.get(cfg.name);
317
+ if (state)
318
+ this._failProviderDisconnected(state, cfg.name);
319
+ };
320
+ if (cfg.aggregate) {
321
+ upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
322
+ }
323
+ this._upstreams.set(cfg.name, upstream);
274
324
  upstream.connect();
275
- }
325
+ };
326
+ for (const cfg of this._options.stdioUpstreams ?? [])
327
+ wireUpstream(cfg, new StdioUpstream(cfg));
328
+ for (const cfg of this._options.remoteUpstreams ?? [])
329
+ wireUpstream(cfg, new RemoteUpstream(cfg));
276
330
  // Attach stdio client transport if configured.
277
331
  // stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
278
332
  if (this._options.stdioClient) {
@@ -322,6 +376,27 @@ export class WsTunnel {
322
376
  });
323
377
  this._brokerServer = server;
324
378
  this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
379
+ // Aggregate the broker's own introspection tools into `_all`, so a stdio
380
+ // host pinned to `_all` still reaches broker_info / providers_list /
381
+ // provider_status alongside the other aggregated providers.
382
+ void this._aggregateServer?.addProvider(BROKER_PROVIDER_NAME);
383
+ }
384
+ /**
385
+ * Starts the aggregate MCP server and registers it on the reserved `_all`
386
+ * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
387
+ */
388
+ _maybeStartAggregateServer() {
389
+ if (this._options.enableAggregateProvider === false)
390
+ return;
391
+ try {
392
+ const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
393
+ server.start();
394
+ this.registerLoopbackProvider(AggregateServer.SLOT, server);
395
+ this._aggregateServer = server;
396
+ }
397
+ catch (err) {
398
+ console.error(`[broker] aggregate server failed to start: ${err.message}`);
399
+ }
325
400
  }
326
401
  /**
327
402
  * Gracefully closes all connections and stops the HTTP server.
@@ -339,6 +414,18 @@ export class WsTunnel {
339
414
  /* best-effort; continue tearing down */
340
415
  }
341
416
  }
417
+ // Close the aggregate server so its provider sessions and internal
418
+ // clients detach before the provider slots are torn down.
419
+ const aggregateServer = this._aggregateServer;
420
+ this._aggregateServer = null;
421
+ if (aggregateServer) {
422
+ try {
423
+ aggregateServer.close();
424
+ }
425
+ catch {
426
+ /* best-effort; continue tearing down */
427
+ }
428
+ }
342
429
  return new Promise((resolve, reject) => {
343
430
  for (const state of this._providers.values()) {
344
431
  for (const res of state.sseSessions.values())
@@ -354,9 +441,9 @@ export class WsTunnel {
354
441
  }
355
442
  this._providers.clear();
356
443
  this._multiplexSockets.clear();
357
- for (const upstream of this._stdioUpstreams.values())
444
+ for (const upstream of this._upstreams.values())
358
445
  upstream.close();
359
- this._stdioUpstreams.clear();
446
+ this._upstreams.clear();
360
447
  for (const loopback of this._loopbackProviders.values())
361
448
  loopback.close();
362
449
  this._loopbackProviders.clear();
@@ -585,7 +672,7 @@ export class WsTunnel {
585
672
  // WebSocket connection handlers
586
673
  // -------------------------------------------------------------------------
587
674
  _onProviderConnect(ws, name) {
588
- if (this._stdioUpstreams.has(name)) {
675
+ if (this._upstreams.has(name)) {
589
676
  console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
590
677
  `Rename one of them to avoid the conflict.`);
591
678
  ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
@@ -603,32 +690,47 @@ export class WsTunnel {
603
690
  }
604
691
  const state = this._getOrCreateProviderState(name);
605
692
  state.ws = ws;
606
- ws.on("message", (data) => this._routeFromProvider(state, data.toString()));
693
+ // A provider MAY send a registration control frame as its very first
694
+ // message (see _tryHandleRegistration). Any other first message —
695
+ // including a normal MCP frame — is routed and leaves the provider
696
+ // non-aggregated, so every pre-existing provider keeps working.
697
+ let registrationChecked = false;
698
+ ws.on("message", (data) => {
699
+ const text = data.toString();
700
+ if (!registrationChecked) {
701
+ registrationChecked = true;
702
+ if (this._tryHandleRegistration(name, text))
703
+ return;
704
+ }
705
+ this._routeFromProvider(state, text);
706
+ });
607
707
  ws.on("close", () => {
608
708
  state.ws = null;
609
- // Notify all pending sinks that the provider is gone.
610
- const error = JSON.stringify({
611
- jsonrpc: "2.0",
612
- id: null,
613
- error: { code: -32000, message: `Provider "${name}" disconnected` },
614
- });
615
- for (const sink of state.pending.values()) {
616
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
617
- sink.socket.send(error);
618
- }
619
- else if (sink.type === "sse") {
620
- const sseRes = state.sseSessions.get(sink.sessionId);
621
- if (sseRes)
622
- this._sendSseEvent(sseRes, error);
623
- }
624
- else if (sink.type === "http") {
625
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
626
- sink.res.end(error);
627
- }
628
- }
629
- state.pending.clear();
709
+ this._failProviderDisconnected(state, name);
630
710
  });
631
711
  }
712
+ /**
713
+ * Inspects a provider's first WebSocket message for an optional registration
714
+ * control frame `{ "type": "register", "aggregate": boolean }`. Returns
715
+ * `true` when the message was a registration frame — and thus consumed, not
716
+ * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
717
+ * returns `false` and the provider stays non-aggregated.
718
+ */
719
+ _tryHandleRegistration(name, text) {
720
+ let frame;
721
+ try {
722
+ frame = JSON.parse(text);
723
+ }
724
+ catch {
725
+ return false;
726
+ }
727
+ if (frame.jsonrpc !== undefined || frame.type !== "register")
728
+ return false;
729
+ if (frame.aggregate === true) {
730
+ void this._aggregateServer?.addProvider(name);
731
+ }
732
+ return true;
733
+ }
632
734
  _onClientConnect(ws, providerName) {
633
735
  const state = this._getOrCreateProviderState(providerName);
634
736
  state.wsClients.add(ws);
@@ -663,7 +765,7 @@ export class WsTunnel {
663
765
  return;
664
766
  // Register provider name lazily on first encounter.
665
767
  if (!providerNames.has(name)) {
666
- if (this._stdioUpstreams.has(name)) {
768
+ if (this._upstreams.has(name)) {
667
769
  console.warn(`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
668
770
  `Rename one of them to avoid the conflict.`);
669
771
  ws.send(JSON.stringify({
@@ -712,27 +814,7 @@ export class WsTunnel {
712
814
  const state = this._providers.get(name);
713
815
  if (state && state.ws === ws) {
714
816
  state.ws = null;
715
- // Notify pending sinks that the provider is gone.
716
- const error = JSON.stringify({
717
- jsonrpc: "2.0",
718
- id: null,
719
- error: { code: -32000, message: `Provider "${name}" disconnected` },
720
- });
721
- for (const sink of state.pending.values()) {
722
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
723
- sink.socket.send(error);
724
- }
725
- else if (sink.type === "sse") {
726
- const sseRes = state.sseSessions.get(sink.sessionId);
727
- if (sseRes)
728
- this._sendSseEvent(sseRes, error);
729
- }
730
- else if (sink.type === "http") {
731
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
732
- sink.res.end(error);
733
- }
734
- }
735
- state.pending.clear();
817
+ this._failProviderDisconnected(state, name);
736
818
  }
737
819
  }
738
820
  this._multiplexSockets.delete(ws);
@@ -746,10 +828,10 @@ export class WsTunnel {
746
828
  * envelope when the provider's WebSocket is a multiplexed connection.
747
829
  */
748
830
  _sendToProvider(state, providerName, data) {
749
- // Stdio upstreams take priority for exact name matches.
750
- const stdioUpstream = this._stdioUpstreams.get(providerName);
751
- if (stdioUpstream?.isOpen) {
752
- stdioUpstream.send(data);
831
+ // Upstreams (stdio child processes and remote URL servers) take priority.
832
+ const upstream = this._upstreams.get(providerName);
833
+ if (upstream?.isOpen) {
834
+ upstream.send(data);
753
835
  return;
754
836
  }
755
837
  // In-process loopback (e.g. the embedded `_broker`) takes the same priority.
@@ -839,6 +921,9 @@ export class WsTunnel {
839
921
  else if (sink?.type === "stdio") {
840
922
  process.stdout.write(data + "\n");
841
923
  }
924
+ else if (sink?.type === "internal") {
925
+ sink.client.onMessage?.(data);
926
+ }
842
927
  state.pending.delete(msg.id);
843
928
  }
844
929
  else {
@@ -862,11 +947,46 @@ export class WsTunnel {
862
947
  for (const mcpRes of state.mcpGetSessions.values()) {
863
948
  this._sendSseEvent(mcpRes, data);
864
949
  }
950
+ for (const ic of state.internalClients) {
951
+ ic.onMessage?.(data);
952
+ }
865
953
  // Forward notifications to the stdio client if it is watching this provider.
866
954
  if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
867
955
  process.stdout.write(data + "\n");
868
956
  }
869
957
  }
958
+ /**
959
+ * Notifies every pending sink and internal client that the provider slot
960
+ * has disconnected, then clears the pending map. Shared by all provider
961
+ * close handlers (dedicated WS, multiplexed WS, loopback).
962
+ */
963
+ _failProviderDisconnected(state, name) {
964
+ const error = JSON.stringify({
965
+ jsonrpc: "2.0",
966
+ id: null,
967
+ error: { code: -32000, message: `Provider "${name}" disconnected` },
968
+ });
969
+ for (const sink of state.pending.values()) {
970
+ if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
971
+ sink.socket.send(error);
972
+ }
973
+ else if (sink.type === "sse") {
974
+ const sseRes = state.sseSessions.get(sink.sessionId);
975
+ if (sseRes)
976
+ this._sendSseEvent(sseRes, error);
977
+ }
978
+ else if (sink.type === "http") {
979
+ sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
980
+ sink.res.end(error);
981
+ }
982
+ else if (sink.type === "internal") {
983
+ sink.client.onMessage?.(error);
984
+ }
985
+ }
986
+ state.pending.clear();
987
+ for (const ic of state.internalClients)
988
+ ic.onClose?.();
989
+ }
870
990
  // -------------------------------------------------------------------------
871
991
  // Provider state helpers
872
992
  // -------------------------------------------------------------------------
@@ -875,7 +995,7 @@ export class WsTunnel {
875
995
  * a stdio upstream, or an in-process loopback transport.
876
996
  */
877
997
  _isProviderConnected(providerName, state) {
878
- if (this._stdioUpstreams.get(providerName)?.isOpen)
998
+ if (this._upstreams.get(providerName)?.isOpen)
879
999
  return true;
880
1000
  if (this._loopbackProviders.get(providerName)?.isOpen)
881
1001
  return true;
@@ -893,6 +1013,7 @@ export class WsTunnel {
893
1013
  sseSessions: new Map(),
894
1014
  mcpGetSessions: new Map(),
895
1015
  wsClients: new Set(),
1016
+ internalClients: new Set(),
896
1017
  };
897
1018
  this._providers.set(name, state);
898
1019
  }