@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
@@ -1,6 +1,7 @@
1
- import type { IMessageTransport } from "@cyanmycelium/mcp-core";
1
+ import type { GrammarResolverOptions, IMessageTransport } from "@cyanmycelium/mcp-core";
2
2
  import { type StdioUpstreamConfig } from "./stdio.upstream.js";
3
- import type { BrokerContext, BrokerLocaleResolver, BrokerProviderInfo, BrokerUserAgentResolver } from "./broker/index.js";
3
+ import { type RemoteUpstreamConfig } from "./remote.upstream.js";
4
+ import type { BrokerContext, BrokerProviderInfo } from "./broker/index.js";
4
5
  /**
5
6
  * A single static-file mount: serves the contents of `dir` under `urlPrefix`.
6
7
  *
@@ -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
@@ -133,27 +167,25 @@ export interface WsTunnelOptions {
133
167
  */
134
168
  brokerName?: string;
135
169
  /**
136
- * Custom resolver picking the grammar locale for the embedded broker
137
- * server. Defaults to `defaultBrokerLocaleResolver` (keeps the ISO 639-1
138
- * prefix of a BCP-47 tag read from `MCP_BROKER_LOCALE`).
139
- */
140
- brokerLocaleResolver?: BrokerLocaleResolver;
141
- /**
142
- * Custom resolver mapping a connecting client's identity to a user-agent
143
- * family. Defaults to `defaultBrokerUserAgentResolver` (substring match on
144
- * `clientInfo.name` against known LLM families).
145
- */
146
- brokerUserAgentResolver?: BrokerUserAgentResolver;
147
- /**
148
- * Custom source of the raw locale string fed to the locale resolver.
149
- * Defaults to `() => process.env.MCP_BROKER_LOCALE`. Override when the
150
- * locale should come from a config file, HTTP header, etc.
170
+ * Overrides for the embedded broker server's grammar resolver — passed
171
+ * straight through to `mcp-core`'s `grammarResolverFromOptions`. The
172
+ * broker installs its own default `localeSource` (reads
173
+ * `process.env.MCP_BROKER_LOCALE`); anything you set here wins.
174
+ *
175
+ * Use this to inject a custom `localeSource` (e.g. read from an HTTP
176
+ * header proxied by your transport), enable the optional `versionFrom`
177
+ * dimension, or extend the `agents` map with additional LLM families.
151
178
  */
152
- brokerLocaleSource?: () => string | undefined;
179
+ brokerGrammarResolverOptions?: Partial<GrammarResolverOptions>;
153
180
  /**
154
181
  * Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
155
- * files are merged **on top of** the packaged grammars used by the embedded
156
- * broker server. Typically pointed at `.mcp-broker/grammars/`.
182
+ * files are registered alongside the packaged grammars used by the
183
+ * embedded broker server. Typically pointed at `.mcp-broker/grammars/`.
184
+ *
185
+ * The candidate-chain resolution in `McpServer.initialize`
186
+ * (mcp-core@0.3.0) handles cascade across user-agent and locale
187
+ * dimensions, so partial files no longer need to be pre-merged with a
188
+ * baseline.
157
189
  */
158
190
  brokerLocalGrammarsDir?: string;
159
191
  }
@@ -190,8 +222,8 @@ export declare class WsTunnel implements BrokerContext {
190
222
  private readonly _providers;
191
223
  /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
192
224
  private readonly _multiplexSockets;
193
- /** Stdio upstream providers, keyed by provider name. */
194
- private readonly _stdioUpstreams;
225
+ /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
226
+ private readonly _upstreams;
195
227
  /**
196
228
  * In-process loopback transports registered as provider slots.
197
229
  * Used by the embedded broker server (`_broker`) and any other component
@@ -200,6 +232,8 @@ export declare class WsTunnel implements BrokerContext {
200
232
  private readonly _loopbackProviders;
201
233
  /** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
202
234
  private _brokerServer;
235
+ /** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
236
+ private _aggregateServer;
203
237
  /** Provider name that the stdio client transport is bridged to, or null when disabled. */
204
238
  private _stdioClientProvider;
205
239
  /** Buffered partial line from stdin (stdio client transport). */
@@ -226,6 +260,16 @@ export declare class WsTunnel implements BrokerContext {
226
260
  * @throws if the name is already used by a stdio upstream or another loopback.
227
261
  */
228
262
  registerLoopbackProvider(name: string, transport: IMessageTransport): void;
263
+ /**
264
+ * Opens an in-process client to a provider slot. The returned handle can
265
+ * issue MCP requests and receives both the responses and the provider's
266
+ * broadcast notifications. Used by the aggregate server to fan a single
267
+ * in-process client out to every aggregated provider.
268
+ *
269
+ * The slot does not need a provider attached yet — `send` returns a
270
+ * JSON-RPC error while the provider is disconnected.
271
+ */
272
+ openInternalClient(providerName: string): InternalClient;
229
273
  get isListening(): boolean;
230
274
  /** Total number of connected MCP clients across all providers. */
231
275
  get clientCount(): number;
@@ -244,6 +288,11 @@ export declare class WsTunnel implements BrokerContext {
244
288
  * is `false`.
245
289
  */
246
290
  private _maybeStartBrokerServer;
291
+ /**
292
+ * Starts the aggregate MCP server and registers it on the reserved `_all`
293
+ * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
294
+ */
295
+ private _maybeStartAggregateServer;
247
296
  /**
248
297
  * Gracefully closes all connections and stops the HTTP server.
249
298
  */
@@ -280,6 +329,14 @@ export declare class WsTunnel implements BrokerContext {
280
329
  /** Writes one JSON-RPC message as an SSE `message` event. */
281
330
  private _sendSseEvent;
282
331
  private _onProviderConnect;
332
+ /**
333
+ * Inspects a provider's first WebSocket message for an optional registration
334
+ * control frame `{ "type": "register", "aggregate": boolean }`. Returns
335
+ * `true` when the message was a registration frame — and thus consumed, not
336
+ * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
337
+ * returns `false` and the provider stays non-aggregated.
338
+ */
339
+ private _tryHandleRegistration;
283
340
  private _onClientConnect;
284
341
  /**
285
342
  * Handles a multiplexed provider WebSocket (`/providers`).
@@ -298,6 +355,12 @@ export declare class WsTunnel implements BrokerContext {
298
355
  private _routeFromProvider;
299
356
  /** Sends a message to all clients connected to one provider. */
300
357
  private _broadcast;
358
+ /**
359
+ * Notifies every pending sink and internal client that the provider slot
360
+ * has disconnected, then clears the pending map. Shared by all provider
361
+ * close handlers (dedicated WS, multiplexed WS, loopback).
362
+ */
363
+ private _failProviderDisconnected;
301
364
  /**
302
365
  * Returns `true` if the provider is reachable — via a WebSocket connection,
303
366
  * 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) {
@@ -315,13 +369,32 @@ export class WsTunnel {
315
369
  if (this._options.enableBrokerProvider === false)
316
370
  return;
317
371
  const { server, clientTransport } = await startBrokerServer(this, {
318
- localeResolver: this._options.brokerLocaleResolver,
319
- userAgentResolver: this._options.brokerUserAgentResolver,
320
- localeSource: this._options.brokerLocaleSource,
372
+ grammarResolverOptions: this._options.brokerGrammarResolverOptions,
321
373
  localGrammarsDir: this._options.brokerLocalGrammarsDir,
322
374
  });
323
375
  this._brokerServer = server;
324
376
  this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
377
+ // Aggregate the broker's own introspection tools into `_all`, so a stdio
378
+ // host pinned to `_all` still reaches broker_info / providers_list /
379
+ // provider_status alongside the other aggregated providers.
380
+ void this._aggregateServer?.addProvider(BROKER_PROVIDER_NAME);
381
+ }
382
+ /**
383
+ * Starts the aggregate MCP server and registers it on the reserved `_all`
384
+ * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
385
+ */
386
+ _maybeStartAggregateServer() {
387
+ if (this._options.enableAggregateProvider === false)
388
+ return;
389
+ try {
390
+ const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
391
+ server.start();
392
+ this.registerLoopbackProvider(AggregateServer.SLOT, server);
393
+ this._aggregateServer = server;
394
+ }
395
+ catch (err) {
396
+ console.error(`[broker] aggregate server failed to start: ${err.message}`);
397
+ }
325
398
  }
326
399
  /**
327
400
  * Gracefully closes all connections and stops the HTTP server.
@@ -339,6 +412,18 @@ export class WsTunnel {
339
412
  /* best-effort; continue tearing down */
340
413
  }
341
414
  }
415
+ // Close the aggregate server so its provider sessions and internal
416
+ // clients detach before the provider slots are torn down.
417
+ const aggregateServer = this._aggregateServer;
418
+ this._aggregateServer = null;
419
+ if (aggregateServer) {
420
+ try {
421
+ aggregateServer.close();
422
+ }
423
+ catch {
424
+ /* best-effort; continue tearing down */
425
+ }
426
+ }
342
427
  return new Promise((resolve, reject) => {
343
428
  for (const state of this._providers.values()) {
344
429
  for (const res of state.sseSessions.values())
@@ -354,9 +439,9 @@ export class WsTunnel {
354
439
  }
355
440
  this._providers.clear();
356
441
  this._multiplexSockets.clear();
357
- for (const upstream of this._stdioUpstreams.values())
442
+ for (const upstream of this._upstreams.values())
358
443
  upstream.close();
359
- this._stdioUpstreams.clear();
444
+ this._upstreams.clear();
360
445
  for (const loopback of this._loopbackProviders.values())
361
446
  loopback.close();
362
447
  this._loopbackProviders.clear();
@@ -585,7 +670,7 @@ export class WsTunnel {
585
670
  // WebSocket connection handlers
586
671
  // -------------------------------------------------------------------------
587
672
  _onProviderConnect(ws, name) {
588
- if (this._stdioUpstreams.has(name)) {
673
+ if (this._upstreams.has(name)) {
589
674
  console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
590
675
  `Rename one of them to avoid the conflict.`);
591
676
  ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
@@ -603,32 +688,47 @@ export class WsTunnel {
603
688
  }
604
689
  const state = this._getOrCreateProviderState(name);
605
690
  state.ws = ws;
606
- ws.on("message", (data) => this._routeFromProvider(state, data.toString()));
691
+ // A provider MAY send a registration control frame as its very first
692
+ // message (see _tryHandleRegistration). Any other first message —
693
+ // including a normal MCP frame — is routed and leaves the provider
694
+ // non-aggregated, so every pre-existing provider keeps working.
695
+ let registrationChecked = false;
696
+ ws.on("message", (data) => {
697
+ const text = data.toString();
698
+ if (!registrationChecked) {
699
+ registrationChecked = true;
700
+ if (this._tryHandleRegistration(name, text))
701
+ return;
702
+ }
703
+ this._routeFromProvider(state, text);
704
+ });
607
705
  ws.on("close", () => {
608
706
  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();
707
+ this._failProviderDisconnected(state, name);
630
708
  });
631
709
  }
710
+ /**
711
+ * Inspects a provider's first WebSocket message for an optional registration
712
+ * control frame `{ "type": "register", "aggregate": boolean }`. Returns
713
+ * `true` when the message was a registration frame — and thus consumed, not
714
+ * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
715
+ * returns `false` and the provider stays non-aggregated.
716
+ */
717
+ _tryHandleRegistration(name, text) {
718
+ let frame;
719
+ try {
720
+ frame = JSON.parse(text);
721
+ }
722
+ catch {
723
+ return false;
724
+ }
725
+ if (frame.jsonrpc !== undefined || frame.type !== "register")
726
+ return false;
727
+ if (frame.aggregate === true) {
728
+ void this._aggregateServer?.addProvider(name);
729
+ }
730
+ return true;
731
+ }
632
732
  _onClientConnect(ws, providerName) {
633
733
  const state = this._getOrCreateProviderState(providerName);
634
734
  state.wsClients.add(ws);
@@ -663,7 +763,7 @@ export class WsTunnel {
663
763
  return;
664
764
  // Register provider name lazily on first encounter.
665
765
  if (!providerNames.has(name)) {
666
- if (this._stdioUpstreams.has(name)) {
766
+ if (this._upstreams.has(name)) {
667
767
  console.warn(`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
668
768
  `Rename one of them to avoid the conflict.`);
669
769
  ws.send(JSON.stringify({
@@ -712,27 +812,7 @@ export class WsTunnel {
712
812
  const state = this._providers.get(name);
713
813
  if (state && state.ws === ws) {
714
814
  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();
815
+ this._failProviderDisconnected(state, name);
736
816
  }
737
817
  }
738
818
  this._multiplexSockets.delete(ws);
@@ -746,10 +826,10 @@ export class WsTunnel {
746
826
  * envelope when the provider's WebSocket is a multiplexed connection.
747
827
  */
748
828
  _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);
829
+ // Upstreams (stdio child processes and remote URL servers) take priority.
830
+ const upstream = this._upstreams.get(providerName);
831
+ if (upstream?.isOpen) {
832
+ upstream.send(data);
753
833
  return;
754
834
  }
755
835
  // In-process loopback (e.g. the embedded `_broker`) takes the same priority.
@@ -839,6 +919,9 @@ export class WsTunnel {
839
919
  else if (sink?.type === "stdio") {
840
920
  process.stdout.write(data + "\n");
841
921
  }
922
+ else if (sink?.type === "internal") {
923
+ sink.client.onMessage?.(data);
924
+ }
842
925
  state.pending.delete(msg.id);
843
926
  }
844
927
  else {
@@ -862,11 +945,46 @@ export class WsTunnel {
862
945
  for (const mcpRes of state.mcpGetSessions.values()) {
863
946
  this._sendSseEvent(mcpRes, data);
864
947
  }
948
+ for (const ic of state.internalClients) {
949
+ ic.onMessage?.(data);
950
+ }
865
951
  // Forward notifications to the stdio client if it is watching this provider.
866
952
  if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
867
953
  process.stdout.write(data + "\n");
868
954
  }
869
955
  }
956
+ /**
957
+ * Notifies every pending sink and internal client that the provider slot
958
+ * has disconnected, then clears the pending map. Shared by all provider
959
+ * close handlers (dedicated WS, multiplexed WS, loopback).
960
+ */
961
+ _failProviderDisconnected(state, name) {
962
+ const error = JSON.stringify({
963
+ jsonrpc: "2.0",
964
+ id: null,
965
+ error: { code: -32000, message: `Provider "${name}" disconnected` },
966
+ });
967
+ for (const sink of state.pending.values()) {
968
+ if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
969
+ sink.socket.send(error);
970
+ }
971
+ else if (sink.type === "sse") {
972
+ const sseRes = state.sseSessions.get(sink.sessionId);
973
+ if (sseRes)
974
+ this._sendSseEvent(sseRes, error);
975
+ }
976
+ else if (sink.type === "http") {
977
+ sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
978
+ sink.res.end(error);
979
+ }
980
+ else if (sink.type === "internal") {
981
+ sink.client.onMessage?.(error);
982
+ }
983
+ }
984
+ state.pending.clear();
985
+ for (const ic of state.internalClients)
986
+ ic.onClose?.();
987
+ }
870
988
  // -------------------------------------------------------------------------
871
989
  // Provider state helpers
872
990
  // -------------------------------------------------------------------------
@@ -875,7 +993,7 @@ export class WsTunnel {
875
993
  * a stdio upstream, or an in-process loopback transport.
876
994
  */
877
995
  _isProviderConnected(providerName, state) {
878
- if (this._stdioUpstreams.get(providerName)?.isOpen)
996
+ if (this._upstreams.get(providerName)?.isOpen)
879
997
  return true;
880
998
  if (this._loopbackProviders.get(providerName)?.isOpen)
881
999
  return true;
@@ -893,6 +1011,7 @@ export class WsTunnel {
893
1011
  sseSessions: new Map(),
894
1012
  mcpGetSessions: new Map(),
895
1013
  wsClients: new Set(),
1014
+ internalClients: new Set(),
896
1015
  };
897
1016
  this._providers.set(name, state);
898
1017
  }