@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.
- 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.server.js +12 -1
- package/dist/broker/broker.server.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- 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 +67 -2
- package/dist/ws.tunnel.js +200 -79
- 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.server.ts +12 -1
- package/src/config.ts +37 -0
- package/src/index.ts +8 -0
- 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 +239 -74
package/dist/ws.tunnel.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
194
|
-
private readonly
|
|
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
|
-
/**
|
|
64
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
if (sseRes)
|
|
195
|
-
this._sendSseEvent(sseRes, error);
|
|
213
|
+
catch {
|
|
214
|
+
/* forward as-is */
|
|
196
215
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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.
|
|
444
|
+
for (const upstream of this._upstreams.values())
|
|
358
445
|
upstream.close();
|
|
359
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
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.
|
|
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
|
}
|