@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.
- package/.mcp-broker.example/README.md +23 -0
- package/.mcp-broker.example/config.json +11 -0
- package/README.md +1 -16
- package/dist/bin.js +30 -3
- package/dist/bin.js.map +1 -1
- package/dist/broker/aggregate/aggregate.catalog.d.ts +54 -0
- package/dist/broker/aggregate/aggregate.catalog.js +105 -0
- package/dist/broker/aggregate/aggregate.catalog.js.map +1 -0
- package/dist/broker/aggregate/aggregate.server.d.ts +47 -0
- package/dist/broker/aggregate/aggregate.server.js +151 -0
- package/dist/broker/aggregate/aggregate.server.js.map +1 -0
- package/dist/broker/aggregate/provider.client.session.d.ts +52 -0
- package/dist/broker/aggregate/provider.client.session.js +140 -0
- package/dist/broker/aggregate/provider.client.session.js.map +1 -0
- package/dist/broker/broker.grammars.d.ts +50 -86
- package/dist/broker/broker.grammars.js +55 -84
- package/dist/broker/broker.grammars.js.map +1 -1
- package/dist/broker/broker.server.d.ts +23 -21
- package/dist/broker/broker.server.js +33 -71
- package/dist/broker/broker.server.js.map +1 -1
- package/dist/broker/index.d.ts +2 -2
- package/dist/broker/index.js +1 -1
- package/dist/broker/index.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/mcpb.loader.d.ts +24 -0
- package/dist/mcpb.loader.js +161 -0
- package/dist/mcpb.loader.js.map +1 -0
- package/dist/mcpb.unzip.d.ts +6 -0
- package/dist/mcpb.unzip.js +95 -0
- package/dist/mcpb.unzip.js.map +1 -0
- package/dist/remote.transports.d.ts +16 -0
- package/dist/remote.transports.js +297 -0
- package/dist/remote.transports.js.map +1 -0
- package/dist/remote.upstream.d.ts +36 -0
- package/dist/remote.upstream.js +52 -0
- package/dist/remote.upstream.js.map +1 -0
- package/dist/stdio.upstream.d.ts +4 -1
- package/dist/stdio.upstream.js.map +1 -1
- package/dist/upstream.d.ts +33 -0
- package/dist/upstream.js +2 -0
- package/dist/upstream.js.map +1 -0
- package/dist/ws.tunnel.builder.d.ts +14 -8
- package/dist/ws.tunnel.builder.js +17 -9
- package/dist/ws.tunnel.builder.js.map +1 -1
- package/dist/ws.tunnel.d.ts +85 -22
- package/dist/ws.tunnel.js +201 -82
- package/dist/ws.tunnel.js.map +1 -1
- package/package.json +3 -2
- package/scripts/pack-mcpb.mjs +84 -0
- package/scripts/sign-bundle.mjs +61 -0
- package/src/bin.ts +32 -3
- package/src/broker/aggregate/aggregate.catalog.ts +145 -0
- package/src/broker/aggregate/aggregate.server.ts +178 -0
- package/src/broker/aggregate/provider.client.session.ts +172 -0
- package/src/broker/broker.grammars.ts +74 -122
- package/src/broker/broker.server.ts +57 -99
- package/src/broker/index.ts +3 -5
- package/src/config.ts +37 -0
- package/src/index.ts +10 -10
- package/src/mcpb.loader.ts +186 -0
- package/src/mcpb.unzip.ts +103 -0
- package/src/remote.transports.ts +316 -0
- package/src/remote.upstream.ts +75 -0
- package/src/stdio.upstream.ts +4 -1
- package/src/upstream.ts +33 -0
- package/src/ws.tunnel.builder.ts +19 -9
- 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,
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
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
|
-
|
|
269
|
+
brokerGrammarResolverOptions?: Partial<GrammarResolverOptions>;
|
|
232
270
|
|
|
233
271
|
/**
|
|
234
272
|
* Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
|
|
235
|
-
* files are
|
|
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
|
-
/**
|
|
282
|
-
private readonly
|
|
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.
|
|
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.
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
604
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1039
|
-
const
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
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.
|
|
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
|
}
|