@cyanmycelium/mcp-broker 0.2.0 → 0.4.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 (71) 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.grammars.d.ts +50 -86
  16. package/dist/broker/broker.grammars.js +55 -84
  17. package/dist/broker/broker.grammars.js.map +1 -1
  18. package/dist/broker/broker.server.d.ts +23 -21
  19. package/dist/broker/broker.server.js +33 -71
  20. package/dist/broker/broker.server.js.map +1 -1
  21. package/dist/broker/index.d.ts +2 -2
  22. package/dist/broker/index.js +1 -1
  23. package/dist/broker/index.js.map +1 -1
  24. package/dist/config.d.ts +35 -0
  25. package/dist/config.js.map +1 -1
  26. package/dist/index.d.ts +8 -2
  27. package/dist/index.js +5 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcpb.loader.d.ts +24 -0
  30. package/dist/mcpb.loader.js +161 -0
  31. package/dist/mcpb.loader.js.map +1 -0
  32. package/dist/mcpb.unzip.d.ts +6 -0
  33. package/dist/mcpb.unzip.js +95 -0
  34. package/dist/mcpb.unzip.js.map +1 -0
  35. package/dist/remote.transports.d.ts +16 -0
  36. package/dist/remote.transports.js +297 -0
  37. package/dist/remote.transports.js.map +1 -0
  38. package/dist/remote.upstream.d.ts +36 -0
  39. package/dist/remote.upstream.js +52 -0
  40. package/dist/remote.upstream.js.map +1 -0
  41. package/dist/stdio.upstream.d.ts +4 -1
  42. package/dist/stdio.upstream.js.map +1 -1
  43. package/dist/upstream.d.ts +33 -0
  44. package/dist/upstream.js +2 -0
  45. package/dist/upstream.js.map +1 -0
  46. package/dist/ws.tunnel.builder.d.ts +14 -8
  47. package/dist/ws.tunnel.builder.js +17 -9
  48. package/dist/ws.tunnel.builder.js.map +1 -1
  49. package/dist/ws.tunnel.d.ts +85 -22
  50. package/dist/ws.tunnel.js +201 -82
  51. package/dist/ws.tunnel.js.map +1 -1
  52. package/package.json +3 -2
  53. package/scripts/pack-mcpb.mjs +84 -0
  54. package/scripts/sign-bundle.mjs +61 -0
  55. package/src/bin.ts +32 -3
  56. package/src/broker/aggregate/aggregate.catalog.ts +145 -0
  57. package/src/broker/aggregate/aggregate.server.ts +178 -0
  58. package/src/broker/aggregate/provider.client.session.ts +172 -0
  59. package/src/broker/broker.grammars.ts +74 -122
  60. package/src/broker/broker.server.ts +57 -99
  61. package/src/broker/index.ts +3 -5
  62. package/src/config.ts +37 -0
  63. package/src/index.ts +10 -10
  64. package/src/mcpb.loader.ts +186 -0
  65. package/src/mcpb.unzip.ts +103 -0
  66. package/src/remote.transports.ts +316 -0
  67. package/src/remote.upstream.ts +75 -0
  68. package/src/stdio.upstream.ts +4 -1
  69. package/src/upstream.ts +33 -0
  70. package/src/ws.tunnel.builder.ts +19 -9
  71. package/src/ws.tunnel.ts +258 -99
package/src/ws.tunnel.ts CHANGED
@@ -5,10 +5,13 @@ import * as nodePath from "path";
5
5
  import { randomUUID } from "crypto";
6
6
  import type { IncomingMessage, ServerResponse } from "http";
7
7
  import { WebSocket, WebSocketServer } from "ws";
8
- import type { IMessageTransport, IMcpServer } from "@cyanmycelium/mcp-core";
8
+ import type { GrammarResolverOptions, 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
- import type { BrokerContext, BrokerLocaleResolver, BrokerProviderInfo, BrokerProviderTransport, BrokerUserAgentResolver } from "./broker/index.js";
13
+ import type { BrokerContext, BrokerProviderInfo, BrokerProviderTransport } 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
@@ -210,30 +257,26 @@ export interface WsTunnelOptions {
210
257
  brokerName?: string;
211
258
 
212
259
  /**
213
- * Custom resolver picking the grammar locale for the embedded broker
214
- * server. Defaults to `defaultBrokerLocaleResolver` (keeps the ISO 639-1
215
- * prefix of a BCP-47 tag read from `MCP_BROKER_LOCALE`).
216
- */
217
- brokerLocaleResolver?: BrokerLocaleResolver;
218
-
219
- /**
220
- * Custom resolver mapping a connecting client's identity to a user-agent
221
- * family. Defaults to `defaultBrokerUserAgentResolver` (substring match on
222
- * `clientInfo.name` against known LLM families).
223
- */
224
- brokerUserAgentResolver?: BrokerUserAgentResolver;
225
-
226
- /**
227
- * Custom source of the raw locale string fed to the locale resolver.
228
- * Defaults to `() => process.env.MCP_BROKER_LOCALE`. Override when the
229
- * locale should come from a config file, HTTP header, etc.
260
+ * Overrides for the embedded broker server's grammar resolver — passed
261
+ * straight through to `mcp-core`'s `grammarResolverFromOptions`. The
262
+ * broker installs its own default `localeSource` (reads
263
+ * `process.env.MCP_BROKER_LOCALE`); anything you set here wins.
264
+ *
265
+ * Use this to inject a custom `localeSource` (e.g. read from an HTTP
266
+ * header proxied by your transport), enable the optional `versionFrom`
267
+ * dimension, or extend the `agents` map with additional LLM families.
230
268
  */
231
- brokerLocaleSource?: () => string | undefined;
269
+ brokerGrammarResolverOptions?: Partial<GrammarResolverOptions>;
232
270
 
233
271
  /**
234
272
  * Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
235
- * files are merged **on top of** the packaged grammars used by the embedded
236
- * broker server. Typically pointed at `.mcp-broker/grammars/`.
273
+ * files are registered alongside the packaged grammars used by the
274
+ * embedded broker server. Typically pointed at `.mcp-broker/grammars/`.
275
+ *
276
+ * The candidate-chain resolution in `McpServer.initialize`
277
+ * (mcp-core@0.3.0) handles cascade across user-agent and locale
278
+ * dimensions, so partial files no longer need to be pre-merged with a
279
+ * baseline.
237
280
  */
238
281
  brokerLocalGrammarsDir?: string;
239
282
  }
@@ -278,8 +321,8 @@ export class WsTunnel implements BrokerContext {
278
321
  /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
279
322
  private readonly _multiplexSockets = new Map<WebSocket, Set<string>>();
280
323
 
281
- /** Stdio upstream providers, keyed by provider name. */
282
- private readonly _stdioUpstreams = new Map<string, StdioUpstream>();
324
+ /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
325
+ private readonly _upstreams = new Map<string, Upstream>();
283
326
 
284
327
  /**
285
328
  * In-process loopback transports registered as provider slots.
@@ -291,6 +334,9 @@ export class WsTunnel implements BrokerContext {
291
334
  /** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
292
335
  private _brokerServer: IMcpServer | null = null;
293
336
 
337
+ /** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
338
+ private _aggregateServer: AggregateServer | null = null;
339
+
294
340
  /** Provider name that the stdio client transport is bridged to, or null when disabled. */
295
341
  private _stdioClientProvider: string | null = null;
296
342
 
@@ -370,7 +416,7 @@ export class WsTunnel implements BrokerContext {
370
416
  if (this._loopbackProviders.get(name)?.isOpen) {
371
417
  transport = "loopback";
372
418
  connected = true;
373
- } else if (this._stdioUpstreams.get(name)?.isOpen) {
419
+ } else if (this._upstreams.get(name)?.isOpen) {
374
420
  transport = "stdio";
375
421
  connected = true;
376
422
  } else if (state.ws?.readyState === WebSocket.OPEN) {
@@ -406,7 +452,7 @@ export class WsTunnel implements BrokerContext {
406
452
  if (this._loopbackProviders.has(name)) {
407
453
  throw new Error(`Loopback provider "${name}" is already registered.`);
408
454
  }
409
- if (this._stdioUpstreams.has(name)) {
455
+ if (this._upstreams.has(name)) {
410
456
  throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
411
457
  }
412
458
 
@@ -416,25 +462,60 @@ export class WsTunnel implements BrokerContext {
416
462
  transport.onMessage = (data: string) => this._routeFromProvider(state, data);
417
463
  transport.onClose = () => {
418
464
  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);
465
+ this._failProviderDisconnected(state, name);
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Opens an in-process client to a provider slot. The returned handle can
471
+ * issue MCP requests and receives both the responses and the provider's
472
+ * broadcast notifications. Used by the aggregate server to fan a single
473
+ * in-process client out to every aggregated provider.
474
+ *
475
+ * The slot does not need a provider attached yet — `send` returns a
476
+ * JSON-RPC error while the provider is disconnected.
477
+ */
478
+ public openInternalClient(providerName: string): InternalClient {
479
+ const state = this._getOrCreateProviderState(providerName);
480
+ let closed = false;
481
+
482
+ const client: InternalClient = {
483
+ onMessage: null,
484
+ onClose: null,
485
+ send: (message: string): void => {
486
+ if (closed) return;
487
+ let id: string | number | null = null;
488
+ try {
489
+ const parsed = JSON.parse(message) as { id?: string | number };
490
+ if (parsed?.id != null) id = parsed.id;
491
+ } catch {
492
+ /* forward as-is */
434
493
  }
435
- }
436
- state.pending.clear();
494
+ if (this._isProviderConnected(providerName, state)) {
495
+ if (id != null) state.pending.set(id, { type: "internal", client });
496
+ this._sendToProvider(state, providerName, message);
497
+ } else if (id != null) {
498
+ client.onMessage?.(
499
+ JSON.stringify({
500
+ jsonrpc: "2.0",
501
+ id,
502
+ error: { code: -32000, message: `Provider "${providerName}" not connected` },
503
+ })
504
+ );
505
+ }
506
+ },
507
+ close: (): void => {
508
+ if (closed) return;
509
+ closed = true;
510
+ state.internalClients.delete(client);
511
+ for (const [id, sink] of state.pending) {
512
+ if (sink.type === "internal" && sink.client === client) state.pending.delete(id);
513
+ }
514
+ },
437
515
  };
516
+
517
+ state.internalClients.add(client);
518
+ return client;
438
519
  }
439
520
 
440
521
  // -------------------------------------------------------------------------
@@ -502,9 +583,14 @@ export class WsTunnel implements BrokerContext {
502
583
  });
503
584
 
504
585
  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);
586
+ // Bring the aggregate `_all` slot up before any upstream connects
587
+ // (a Streamable HTTP upstream opens synchronously on connect()).
588
+ this._maybeStartAggregateServer();
589
+
590
+ // Attach configured upstreams (stdio child processes + remote URL
591
+ // servers). Both implement the Upstream contract, so the wiring
592
+ // into a provider slot is identical.
593
+ const wireUpstream = (cfg: { name: string; aggregate?: boolean }, upstream: Upstream): void => {
508
594
  upstream.onMessage = (data) => {
509
595
  const state = this._getOrCreateProviderState(cfg.name);
510
596
  this._routeFromProvider(state, data);
@@ -512,9 +598,18 @@ export class WsTunnel implements BrokerContext {
512
598
  upstream.onError = (err) => {
513
599
  console.error(`[broker] ${err.message}`);
514
600
  };
515
- this._stdioUpstreams.set(cfg.name, upstream);
601
+ upstream.onClose = () => {
602
+ const state = this._providers.get(cfg.name);
603
+ if (state) this._failProviderDisconnected(state, cfg.name);
604
+ };
605
+ if (cfg.aggregate) {
606
+ upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
607
+ }
608
+ this._upstreams.set(cfg.name, upstream);
516
609
  upstream.connect();
517
- }
610
+ };
611
+ for (const cfg of this._options.stdioUpstreams ?? []) wireUpstream(cfg, new StdioUpstream(cfg));
612
+ for (const cfg of this._options.remoteUpstreams ?? []) wireUpstream(cfg, new RemoteUpstream(cfg));
518
613
 
519
614
  // Attach stdio client transport if configured.
520
615
  // stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
@@ -563,13 +658,32 @@ export class WsTunnel implements BrokerContext {
563
658
  private async _maybeStartBrokerServer(): Promise<void> {
564
659
  if (this._options.enableBrokerProvider === false) return;
565
660
  const { server, clientTransport } = await startBrokerServer(this, {
566
- localeResolver: this._options.brokerLocaleResolver,
567
- userAgentResolver: this._options.brokerUserAgentResolver,
568
- localeSource: this._options.brokerLocaleSource,
661
+ grammarResolverOptions: this._options.brokerGrammarResolverOptions,
569
662
  localGrammarsDir: this._options.brokerLocalGrammarsDir,
570
663
  });
571
664
  this._brokerServer = server;
572
665
  this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
666
+
667
+ // Aggregate the broker's own introspection tools into `_all`, so a stdio
668
+ // host pinned to `_all` still reaches broker_info / providers_list /
669
+ // provider_status alongside the other aggregated providers.
670
+ void this._aggregateServer?.addProvider(BROKER_PROVIDER_NAME);
671
+ }
672
+
673
+ /**
674
+ * Starts the aggregate MCP server and registers it on the reserved `_all`
675
+ * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
676
+ */
677
+ private _maybeStartAggregateServer(): void {
678
+ if (this._options.enableAggregateProvider === false) return;
679
+ try {
680
+ const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
681
+ server.start();
682
+ this.registerLoopbackProvider(AggregateServer.SLOT, server);
683
+ this._aggregateServer = server;
684
+ } catch (err) {
685
+ console.error(`[broker] aggregate server failed to start: ${(err as Error).message}`);
686
+ }
573
687
  }
574
688
 
575
689
  /**
@@ -588,6 +702,18 @@ export class WsTunnel implements BrokerContext {
588
702
  }
589
703
  }
590
704
 
705
+ // Close the aggregate server so its provider sessions and internal
706
+ // clients detach before the provider slots are torn down.
707
+ const aggregateServer = this._aggregateServer;
708
+ this._aggregateServer = null;
709
+ if (aggregateServer) {
710
+ try {
711
+ aggregateServer.close();
712
+ } catch {
713
+ /* best-effort; continue tearing down */
714
+ }
715
+ }
716
+
591
717
  return new Promise((resolve, reject) => {
592
718
  for (const state of this._providers.values()) {
593
719
  for (const res of state.sseSessions.values()) res.end();
@@ -600,8 +726,8 @@ export class WsTunnel implements BrokerContext {
600
726
  }
601
727
  this._providers.clear();
602
728
  this._multiplexSockets.clear();
603
- for (const upstream of this._stdioUpstreams.values()) upstream.close();
604
- this._stdioUpstreams.clear();
729
+ for (const upstream of this._upstreams.values()) upstream.close();
730
+ this._upstreams.clear();
605
731
  for (const loopback of this._loopbackProviders.values()) loopback.close();
606
732
  this._loopbackProviders.clear();
607
733
  this._startedAt = null;
@@ -855,7 +981,7 @@ export class WsTunnel implements BrokerContext {
855
981
  // -------------------------------------------------------------------------
856
982
 
857
983
  private _onProviderConnect(ws: WebSocket, name: string): void {
858
- if (this._stdioUpstreams.has(name)) {
984
+ if (this._upstreams.has(name)) {
859
985
  console.warn(
860
986
  `[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
861
987
  `Rename one of them to avoid the conflict.`
@@ -879,31 +1005,47 @@ export class WsTunnel implements BrokerContext {
879
1005
  const state = this._getOrCreateProviderState(name);
880
1006
  state.ws = ws;
881
1007
 
882
- ws.on("message", (data: Buffer) => this._routeFromProvider(state, data.toString()));
1008
+ // A provider MAY send a registration control frame as its very first
1009
+ // message (see _tryHandleRegistration). Any other first message —
1010
+ // including a normal MCP frame — is routed and leaves the provider
1011
+ // non-aggregated, so every pre-existing provider keeps working.
1012
+ let registrationChecked = false;
1013
+ ws.on("message", (data: Buffer) => {
1014
+ const text = data.toString();
1015
+ if (!registrationChecked) {
1016
+ registrationChecked = true;
1017
+ if (this._tryHandleRegistration(name, text)) return;
1018
+ }
1019
+ this._routeFromProvider(state, text);
1020
+ });
883
1021
 
884
1022
  ws.on("close", () => {
885
1023
  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();
1024
+ this._failProviderDisconnected(state, name);
904
1025
  });
905
1026
  }
906
1027
 
1028
+ /**
1029
+ * Inspects a provider's first WebSocket message for an optional registration
1030
+ * control frame `{ "type": "register", "aggregate": boolean }`. Returns
1031
+ * `true` when the message was a registration frame — and thus consumed, not
1032
+ * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
1033
+ * returns `false` and the provider stays non-aggregated.
1034
+ */
1035
+ private _tryHandleRegistration(name: string, text: string): boolean {
1036
+ let frame: { type?: unknown; jsonrpc?: unknown; aggregate?: unknown };
1037
+ try {
1038
+ frame = JSON.parse(text) as typeof frame;
1039
+ } catch {
1040
+ return false;
1041
+ }
1042
+ if (frame.jsonrpc !== undefined || frame.type !== "register") return false;
1043
+ if (frame.aggregate === true) {
1044
+ void this._aggregateServer?.addProvider(name);
1045
+ }
1046
+ return true;
1047
+ }
1048
+
907
1049
  private _onClientConnect(ws: WebSocket, providerName: string): void {
908
1050
  const state = this._getOrCreateProviderState(providerName);
909
1051
  state.wsClients.add(ws);
@@ -941,7 +1083,7 @@ export class WsTunnel implements BrokerContext {
941
1083
 
942
1084
  // Register provider name lazily on first encounter.
943
1085
  if (!providerNames.has(name)) {
944
- if (this._stdioUpstreams.has(name)) {
1086
+ if (this._upstreams.has(name)) {
945
1087
  console.warn(
946
1088
  `[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
947
1089
  `Rename one of them to avoid the conflict.`
@@ -1002,24 +1144,7 @@ export class WsTunnel implements BrokerContext {
1002
1144
  const state = this._providers.get(name);
1003
1145
  if (state && state.ws === ws) {
1004
1146
  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();
1147
+ this._failProviderDisconnected(state, name);
1023
1148
  }
1024
1149
  }
1025
1150
  this._multiplexSockets.delete(ws);
@@ -1035,10 +1160,10 @@ export class WsTunnel implements BrokerContext {
1035
1160
  * envelope when the provider's WebSocket is a multiplexed connection.
1036
1161
  */
1037
1162
  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);
1163
+ // Upstreams (stdio child processes and remote URL servers) take priority.
1164
+ const upstream = this._upstreams.get(providerName);
1165
+ if (upstream?.isOpen) {
1166
+ upstream.send(data);
1042
1167
  return;
1043
1168
  }
1044
1169
 
@@ -1127,6 +1252,8 @@ export class WsTunnel implements BrokerContext {
1127
1252
  sink.res.end(data);
1128
1253
  } else if (sink?.type === "stdio") {
1129
1254
  process.stdout.write(data + "\n");
1255
+ } else if (sink?.type === "internal") {
1256
+ sink.client.onMessage?.(data);
1130
1257
  }
1131
1258
  state.pending.delete(msg.id);
1132
1259
  } else {
@@ -1149,12 +1276,43 @@ export class WsTunnel implements BrokerContext {
1149
1276
  for (const mcpRes of state.mcpGetSessions.values()) {
1150
1277
  this._sendSseEvent(mcpRes, data);
1151
1278
  }
1279
+ for (const ic of state.internalClients) {
1280
+ ic.onMessage?.(data);
1281
+ }
1152
1282
  // Forward notifications to the stdio client if it is watching this provider.
1153
1283
  if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
1154
1284
  process.stdout.write(data + "\n");
1155
1285
  }
1156
1286
  }
1157
1287
 
1288
+ /**
1289
+ * Notifies every pending sink and internal client that the provider slot
1290
+ * has disconnected, then clears the pending map. Shared by all provider
1291
+ * close handlers (dedicated WS, multiplexed WS, loopback).
1292
+ */
1293
+ private _failProviderDisconnected(state: ProviderState, name: string): void {
1294
+ const error = JSON.stringify({
1295
+ jsonrpc: "2.0",
1296
+ id: null,
1297
+ error: { code: -32000, message: `Provider "${name}" disconnected` },
1298
+ });
1299
+ for (const sink of state.pending.values()) {
1300
+ if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
1301
+ sink.socket.send(error);
1302
+ } else if (sink.type === "sse") {
1303
+ const sseRes = state.sseSessions.get(sink.sessionId);
1304
+ if (sseRes) this._sendSseEvent(sseRes, error);
1305
+ } else if (sink.type === "http") {
1306
+ sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1307
+ sink.res.end(error);
1308
+ } else if (sink.type === "internal") {
1309
+ sink.client.onMessage?.(error);
1310
+ }
1311
+ }
1312
+ state.pending.clear();
1313
+ for (const ic of state.internalClients) ic.onClose?.();
1314
+ }
1315
+
1158
1316
  // -------------------------------------------------------------------------
1159
1317
  // Provider state helpers
1160
1318
  // -------------------------------------------------------------------------
@@ -1164,7 +1322,7 @@ export class WsTunnel implements BrokerContext {
1164
1322
  * a stdio upstream, or an in-process loopback transport.
1165
1323
  */
1166
1324
  private _isProviderConnected(providerName: string, state: ProviderState): boolean {
1167
- if (this._stdioUpstreams.get(providerName)?.isOpen) return true;
1325
+ if (this._upstreams.get(providerName)?.isOpen) return true;
1168
1326
  if (this._loopbackProviders.get(providerName)?.isOpen) return true;
1169
1327
  if (state.ws?.readyState === WebSocket.OPEN) return true;
1170
1328
  return false;
@@ -1180,6 +1338,7 @@ export class WsTunnel implements BrokerContext {
1180
1338
  sseSessions: new Map(),
1181
1339
  mcpGetSessions: new Map(),
1182
1340
  wsClients: new Set(),
1341
+ internalClients: new Set(),
1183
1342
  };
1184
1343
  this._providers.set(name, state);
1185
1344
  }