@cyanmycelium/mcp-broker 0.1.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 +53 -0
- package/.mcp-broker.example/config.json +32 -0
- package/.mcp-broker.example/grammars/claude/fr.json +7 -0
- package/LICENSE +201 -0
- package/README.md +253 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +208 -0
- package/dist/bin.js.map +1 -0
- package/dist/broker/adapters/broker.adapter.info.d.ts +16 -0
- package/dist/broker/adapters/broker.adapter.info.js +43 -0
- package/dist/broker/adapters/broker.adapter.info.js.map +1 -0
- package/dist/broker/adapters/broker.adapter.providers.d.ts +18 -0
- package/dist/broker/adapters/broker.adapter.providers.js +61 -0
- package/dist/broker/adapters/broker.adapter.providers.js.map +1 -0
- package/dist/broker/behaviors/broker.behavior.info.d.ts +15 -0
- package/dist/broker/behaviors/broker.behavior.info.js +41 -0
- package/dist/broker/behaviors/broker.behavior.info.js.map +1 -0
- package/dist/broker/behaviors/broker.behavior.providers.d.ts +19 -0
- package/dist/broker/behaviors/broker.behavior.providers.js +69 -0
- package/dist/broker/behaviors/broker.behavior.providers.js.map +1 -0
- package/dist/broker/broker.context.d.ts +59 -0
- package/dist/broker/broker.context.js +2 -0
- package/dist/broker/broker.context.js.map +1 -0
- package/dist/broker/broker.grammars.d.ts +166 -0
- package/dist/broker/broker.grammars.js +258 -0
- package/dist/broker/broker.grammars.js.map +1 -0
- package/dist/broker/broker.server.d.ts +64 -0
- package/dist/broker/broker.server.js +111 -0
- package/dist/broker/broker.server.js.map +1 -0
- package/dist/broker/grammars/claude/en.json +16 -0
- package/dist/broker/grammars/claude/fr.json +16 -0
- package/dist/broker/grammars/default/en.json +32 -0
- package/dist/broker/grammars/default/fr.json +32 -0
- package/dist/broker/grammars/default/zh.json +32 -0
- package/dist/broker/index.d.ts +9 -0
- package/dist/broker/index.js +7 -0
- package/dist/broker/index.js.map +1 -0
- package/dist/config.d.ts +101 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/stdio.upstream.d.ts +42 -0
- package/dist/stdio.upstream.js +85 -0
- package/dist/stdio.upstream.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/dist/ws.tunnel.builder.d.ts +133 -0
- package/dist/ws.tunnel.builder.js +197 -0
- package/dist/ws.tunnel.builder.js.map +1 -0
- package/dist/ws.tunnel.d.ts +310 -0
- package/dist/ws.tunnel.js +971 -0
- package/dist/ws.tunnel.js.map +1 -0
- package/package.json +86 -0
- package/scripts/copy-assets.mjs +34 -0
- package/scripts/gen-cert.mjs +94 -0
- package/src/bin.ts +231 -0
- package/src/broker/adapters/broker.adapter.info.ts +46 -0
- package/src/broker/adapters/broker.adapter.providers.ts +67 -0
- package/src/broker/behaviors/broker.behavior.info.ts +46 -0
- package/src/broker/behaviors/broker.behavior.providers.ts +82 -0
- package/src/broker/broker.context.ts +75 -0
- package/src/broker/broker.grammars.ts +336 -0
- package/src/broker/broker.server.ts +168 -0
- package/src/broker/grammars/claude/en.json +16 -0
- package/src/broker/grammars/claude/fr.json +16 -0
- package/src/broker/grammars/default/en.json +32 -0
- package/src/broker/grammars/default/fr.json +32 -0
- package/src/broker/grammars/default/zh.json +32 -0
- package/src/broker/index.ts +24 -0
- package/src/config.ts +155 -0
- package/src/index.ts +26 -0
- package/src/stdio.upstream.ts +114 -0
- package/src/version.ts +10 -0
- package/src/ws.tunnel.builder.ts +214 -0
- package/src/ws.tunnel.ts +1269 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as http from "http";
|
|
3
|
+
import * as https from "https";
|
|
4
|
+
import * as nodePath from "path";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
+
import { StdioUpstream } from "./stdio.upstream.js";
|
|
8
|
+
import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
|
|
9
|
+
import { VERSION, PACKAGE_NAME } from "./version.js";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Static-file helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/** Maps file extensions to their HTTP Content-Type values. */
|
|
14
|
+
const MIME = {
|
|
15
|
+
".html": "text/html; charset=utf-8",
|
|
16
|
+
".js": "application/javascript; charset=utf-8",
|
|
17
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
18
|
+
".css": "text/css; charset=utf-8",
|
|
19
|
+
".json": "application/json; charset=utf-8",
|
|
20
|
+
".map": "application/json; charset=utf-8",
|
|
21
|
+
".svg": "image/svg+xml",
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".ico": "image/x-icon",
|
|
24
|
+
".woff2": "font/woff2",
|
|
25
|
+
".woff": "font/woff",
|
|
26
|
+
};
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// WsTunnel
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* A multi-provider relay that bridges any number of MCP server instances
|
|
32
|
+
* (the **providers**) with their respective MCP clients.
|
|
33
|
+
*
|
|
34
|
+
* ## Transport overview
|
|
35
|
+
* ```
|
|
36
|
+
* Provider "<name>"
|
|
37
|
+
* ws://host/provider/<name> ← WebSocket registration
|
|
38
|
+
*
|
|
39
|
+
* MCP Inspector (Streamable HTTP, 2025-03-26)
|
|
40
|
+
* GET http://host/<name>/mcp ← persistent SSE notification stream
|
|
41
|
+
* POST http://host/<name>/mcp → JSON-RPC requests
|
|
42
|
+
*
|
|
43
|
+
* Claude (legacy SSE transport)
|
|
44
|
+
* GET http://host/<name>/sse ← SSE notification stream
|
|
45
|
+
* POST http://host/<name>/messages → JSON-RPC requests
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* Each provider gets its own isolated set of sessions, pending requests, and
|
|
49
|
+
* notification streams. Multiple providers can be connected simultaneously.
|
|
50
|
+
*/
|
|
51
|
+
export class WsTunnel {
|
|
52
|
+
_options;
|
|
53
|
+
_httpServer = null;
|
|
54
|
+
_wss = null;
|
|
55
|
+
/**
|
|
56
|
+
* Per-provider state, keyed by provider name.
|
|
57
|
+
* Created lazily: a slot is allocated the first time any client references
|
|
58
|
+
* a provider name, even before the provider WebSocket connects.
|
|
59
|
+
*/
|
|
60
|
+
_providers = new Map();
|
|
61
|
+
/** Maps a multiplexed WebSocket to the set of provider names it feeds. */
|
|
62
|
+
_multiplexSockets = new Map();
|
|
63
|
+
/** Stdio upstream providers, keyed by provider name. */
|
|
64
|
+
_stdioUpstreams = new Map();
|
|
65
|
+
/**
|
|
66
|
+
* In-process loopback transports registered as provider slots.
|
|
67
|
+
* Used by the embedded broker server (`_broker`) and any other component
|
|
68
|
+
* that wants to expose itself as a provider without going through a network.
|
|
69
|
+
*/
|
|
70
|
+
_loopbackProviders = new Map();
|
|
71
|
+
/** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
|
|
72
|
+
_brokerServer = null;
|
|
73
|
+
/** Provider name that the stdio client transport is bridged to, or null when disabled. */
|
|
74
|
+
_stdioClientProvider = null;
|
|
75
|
+
/** Buffered partial line from stdin (stdio client transport). */
|
|
76
|
+
_stdioClientBuffer = "";
|
|
77
|
+
/** Timestamp of the most recent successful `start()`. */
|
|
78
|
+
_startedAt = null;
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this._options = options;
|
|
81
|
+
}
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// BrokerContext implementation
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
get version() {
|
|
86
|
+
return VERSION;
|
|
87
|
+
}
|
|
88
|
+
get name() {
|
|
89
|
+
return this._options.brokerName ?? PACKAGE_NAME;
|
|
90
|
+
}
|
|
91
|
+
get startedAt() {
|
|
92
|
+
return this._startedAt;
|
|
93
|
+
}
|
|
94
|
+
get uptimeSeconds() {
|
|
95
|
+
if (!this._startedAt)
|
|
96
|
+
return 0;
|
|
97
|
+
return Math.floor((Date.now() - this._startedAt.getTime()) / 1000);
|
|
98
|
+
}
|
|
99
|
+
get host() {
|
|
100
|
+
return this._options.host;
|
|
101
|
+
}
|
|
102
|
+
get port() {
|
|
103
|
+
return this._options.port;
|
|
104
|
+
}
|
|
105
|
+
get tls() {
|
|
106
|
+
return !!this._options.tls;
|
|
107
|
+
}
|
|
108
|
+
get paths() {
|
|
109
|
+
const o = this._options;
|
|
110
|
+
return {
|
|
111
|
+
provider: o.providerPath ?? "/provider",
|
|
112
|
+
providers: o.providersPath ?? "/providers",
|
|
113
|
+
client: o.clientPath ?? "/",
|
|
114
|
+
mcp: o.mcpPath ?? "/mcp",
|
|
115
|
+
sse: o.ssePath ?? "/sse",
|
|
116
|
+
messages: o.messagesPath ?? "/messages",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
getProvidersInfo() {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [name, state] of this._providers) {
|
|
122
|
+
out.push(this._buildProviderInfo(name, state));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
getProviderInfo(name) {
|
|
127
|
+
const state = this._providers.get(name);
|
|
128
|
+
if (!state)
|
|
129
|
+
return undefined;
|
|
130
|
+
return this._buildProviderInfo(name, state);
|
|
131
|
+
}
|
|
132
|
+
_buildProviderInfo(name, state) {
|
|
133
|
+
let transport;
|
|
134
|
+
let connected;
|
|
135
|
+
if (this._loopbackProviders.get(name)?.isOpen) {
|
|
136
|
+
transport = "loopback";
|
|
137
|
+
connected = true;
|
|
138
|
+
}
|
|
139
|
+
else if (this._stdioUpstreams.get(name)?.isOpen) {
|
|
140
|
+
transport = "stdio";
|
|
141
|
+
connected = true;
|
|
142
|
+
}
|
|
143
|
+
else if (state.ws?.readyState === WebSocket.OPEN) {
|
|
144
|
+
transport = this._multiplexSockets.has(state.ws) ? "ws-multiplex" : "ws";
|
|
145
|
+
connected = true;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
transport = "none";
|
|
149
|
+
connected = false;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
name,
|
|
153
|
+
transport,
|
|
154
|
+
connected,
|
|
155
|
+
clientCount: state.wsClients.size,
|
|
156
|
+
sessionCount: state.sseSessions.size + state.mcpGetSessions.size,
|
|
157
|
+
pendingCount: state.pending.size,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// -------------------------------------------------------------------------
|
|
161
|
+
// Loopback provider registration (in-process transports)
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* Registers an in-process transport as a provider slot. Used by the embedded
|
|
165
|
+
* broker server and may be used by application code that wants to host an
|
|
166
|
+
* MCP server inside the same process without opening a real WebSocket.
|
|
167
|
+
*
|
|
168
|
+
* @throws if the name is already used by a stdio upstream or another loopback.
|
|
169
|
+
*/
|
|
170
|
+
registerLoopbackProvider(name, transport) {
|
|
171
|
+
if (this._loopbackProviders.has(name)) {
|
|
172
|
+
throw new Error(`Loopback provider "${name}" is already registered.`);
|
|
173
|
+
}
|
|
174
|
+
if (this._stdioUpstreams.has(name)) {
|
|
175
|
+
throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
|
|
176
|
+
}
|
|
177
|
+
const state = this._getOrCreateProviderState(name);
|
|
178
|
+
this._loopbackProviders.set(name, transport);
|
|
179
|
+
transport.onMessage = (data) => this._routeFromProvider(state, data);
|
|
180
|
+
transport.onClose = () => {
|
|
181
|
+
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);
|
|
191
|
+
}
|
|
192
|
+
else if (sink.type === "sse") {
|
|
193
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
194
|
+
if (sseRes)
|
|
195
|
+
this._sendSseEvent(sseRes, error);
|
|
196
|
+
}
|
|
197
|
+
else if (sink.type === "http") {
|
|
198
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
199
|
+
sink.res.end(error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
state.pending.clear();
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// -------------------------------------------------------------------------
|
|
206
|
+
// Public state
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
get isListening() {
|
|
209
|
+
return this._httpServer?.listening ?? false;
|
|
210
|
+
}
|
|
211
|
+
/** Total number of connected MCP clients across all providers. */
|
|
212
|
+
get clientCount() {
|
|
213
|
+
let n = 0;
|
|
214
|
+
for (const s of this._providers.values()) {
|
|
215
|
+
n += s.wsClients.size + s.sseSessions.size + s.mcpGetSessions.size;
|
|
216
|
+
}
|
|
217
|
+
return n;
|
|
218
|
+
}
|
|
219
|
+
/** Names of all providers that currently have an active connection. */
|
|
220
|
+
get providerNames() {
|
|
221
|
+
return [...this._providers.entries()].filter(([name, s]) => this._isProviderConnected(name, s)).map(([name]) => name);
|
|
222
|
+
}
|
|
223
|
+
/** @deprecated Check `providerNames.length > 0` instead. */
|
|
224
|
+
get hasProvider() {
|
|
225
|
+
return this.providerNames.length > 0;
|
|
226
|
+
}
|
|
227
|
+
// -------------------------------------------------------------------------
|
|
228
|
+
// Lifecycle
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
/**
|
|
231
|
+
* Starts the broker. Resolves once the HTTP server is listening.
|
|
232
|
+
*/
|
|
233
|
+
start() {
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
const handler = (req, res) => this._handleHttp(req, res);
|
|
236
|
+
this._httpServer = this._options.tls ? https.createServer({ cert: this._options.tls.cert, key: this._options.tls.key }, handler) : http.createServer(handler);
|
|
237
|
+
// Disable perMessageDeflate: payloads may be large base64-encoded blobs
|
|
238
|
+
// (snapshots, images) that are already compressed. Deflating them wastes
|
|
239
|
+
// CPU without reducing size, and caused multi-second stalls in practice.
|
|
240
|
+
this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false });
|
|
241
|
+
this._wss.on("connection", (ws, req) => {
|
|
242
|
+
const url = req.url ?? "/";
|
|
243
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
244
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
245
|
+
if (url === providersPath || url.startsWith(providersPath + "?")) {
|
|
246
|
+
// Multiplexed provider: one WebSocket carries N providers via envelopes.
|
|
247
|
+
this._onMultiplexProviderConnect(ws);
|
|
248
|
+
}
|
|
249
|
+
else if (url.startsWith(providerPath + "/") || url === providerPath) {
|
|
250
|
+
// Extract name: everything after "<providerPath>/"
|
|
251
|
+
const raw = url.slice(providerPath.length).replace(/^\//, "");
|
|
252
|
+
const name = decodeURIComponent(raw.split("?")[0]) || "(unnamed)";
|
|
253
|
+
this._onProviderConnect(ws, name);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
// Raw WS MCP client: URL is "/<providerName>" or "/"
|
|
257
|
+
const raw = url.replace(/^\//, "").split("?")[0];
|
|
258
|
+
const name = decodeURIComponent(raw) || "";
|
|
259
|
+
this._onClientConnect(ws, name);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
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);
|
|
266
|
+
upstream.onMessage = (data) => {
|
|
267
|
+
const state = this._getOrCreateProviderState(cfg.name);
|
|
268
|
+
this._routeFromProvider(state, data);
|
|
269
|
+
};
|
|
270
|
+
upstream.onError = (err) => {
|
|
271
|
+
console.error(`[broker] ${err.message}`);
|
|
272
|
+
};
|
|
273
|
+
this._stdioUpstreams.set(cfg.name, upstream);
|
|
274
|
+
upstream.connect();
|
|
275
|
+
}
|
|
276
|
+
// Attach stdio client transport if configured.
|
|
277
|
+
// stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
|
|
278
|
+
if (this._options.stdioClient) {
|
|
279
|
+
this._stdioClientProvider = this._options.stdioClient.providerName;
|
|
280
|
+
process.stdin.setEncoding("utf8");
|
|
281
|
+
process.stdin.on("data", (chunk) => {
|
|
282
|
+
this._stdioClientBuffer += chunk;
|
|
283
|
+
let nl;
|
|
284
|
+
while ((nl = this._stdioClientBuffer.indexOf("\n")) !== -1) {
|
|
285
|
+
const line = this._stdioClientBuffer.slice(0, nl).trim();
|
|
286
|
+
this._stdioClientBuffer = this._stdioClientBuffer.slice(nl + 1);
|
|
287
|
+
if (line) {
|
|
288
|
+
const state = this._getOrCreateProviderState(this._stdioClientProvider);
|
|
289
|
+
this._routeFromStdioClient(state, line);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
process.stdin.on("end", () => {
|
|
294
|
+
// Client disconnected — nothing to clean up; pending sinks will time out.
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
this._startedAt = new Date();
|
|
298
|
+
// Spawn the embedded broker server last so it can already report
|
|
299
|
+
// accurate state in its first `broker_info` call.
|
|
300
|
+
void this._maybeStartBrokerServer().then(() => resolve(), (err) => {
|
|
301
|
+
console.error("[broker] embedded broker server failed to start:", err);
|
|
302
|
+
// Keep the tunnel up even if the introspection server fails.
|
|
303
|
+
resolve();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Starts the in-process MCP server that exposes the broker's own behaviors
|
|
310
|
+
* (`broker_info`, `providers_list`, `provider_status`) under the reserved
|
|
311
|
+
* provider slot `_broker`. No-op when {@link WsTunnelOptions.enableBrokerProvider}
|
|
312
|
+
* is `false`.
|
|
313
|
+
*/
|
|
314
|
+
async _maybeStartBrokerServer() {
|
|
315
|
+
if (this._options.enableBrokerProvider === false)
|
|
316
|
+
return;
|
|
317
|
+
const { server, clientTransport } = await startBrokerServer(this, {
|
|
318
|
+
localeResolver: this._options.brokerLocaleResolver,
|
|
319
|
+
userAgentResolver: this._options.brokerUserAgentResolver,
|
|
320
|
+
localeSource: this._options.brokerLocaleSource,
|
|
321
|
+
localGrammarsDir: this._options.brokerLocalGrammarsDir,
|
|
322
|
+
});
|
|
323
|
+
this._brokerServer = server;
|
|
324
|
+
this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Gracefully closes all connections and stops the HTTP server.
|
|
328
|
+
*/
|
|
329
|
+
async stop() {
|
|
330
|
+
// Stop the embedded broker first so it does not see its loopback close
|
|
331
|
+
// as an unexpected disconnect (and to flush any pending broker responses).
|
|
332
|
+
const brokerServer = this._brokerServer;
|
|
333
|
+
this._brokerServer = null;
|
|
334
|
+
if (brokerServer) {
|
|
335
|
+
try {
|
|
336
|
+
await brokerServer.stop();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* best-effort; continue tearing down */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
for (const state of this._providers.values()) {
|
|
344
|
+
for (const res of state.sseSessions.values())
|
|
345
|
+
res.end();
|
|
346
|
+
state.sseSessions.clear();
|
|
347
|
+
for (const res of state.mcpGetSessions.values())
|
|
348
|
+
res.end();
|
|
349
|
+
state.mcpGetSessions.clear();
|
|
350
|
+
for (const client of state.wsClients)
|
|
351
|
+
client.close();
|
|
352
|
+
state.wsClients.clear();
|
|
353
|
+
state.ws?.close();
|
|
354
|
+
}
|
|
355
|
+
this._providers.clear();
|
|
356
|
+
this._multiplexSockets.clear();
|
|
357
|
+
for (const upstream of this._stdioUpstreams.values())
|
|
358
|
+
upstream.close();
|
|
359
|
+
this._stdioUpstreams.clear();
|
|
360
|
+
for (const loopback of this._loopbackProviders.values())
|
|
361
|
+
loopback.close();
|
|
362
|
+
this._loopbackProviders.clear();
|
|
363
|
+
this._startedAt = null;
|
|
364
|
+
this._wss?.close();
|
|
365
|
+
this._httpServer?.close((err) => (err ? reject(err) : resolve()));
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// -------------------------------------------------------------------------
|
|
369
|
+
// HTTP dispatcher
|
|
370
|
+
// -------------------------------------------------------------------------
|
|
371
|
+
_handleHttp(req, res) {
|
|
372
|
+
const method = req.method ?? "GET";
|
|
373
|
+
const rawUrl = (req.url ?? "/").split("?")[0];
|
|
374
|
+
// CORS
|
|
375
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
376
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
377
|
+
res.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] ?? "Content-Type, Accept, Mcp-Session-Id");
|
|
378
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
379
|
+
if (method === "OPTIONS") {
|
|
380
|
+
res.writeHead(204);
|
|
381
|
+
res.end();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// Samples index (no provider prefix)
|
|
385
|
+
const samplesIndexPath = this._options.samplesIndexPath ?? "/__samples_index__";
|
|
386
|
+
if (method === "GET" && rawUrl === samplesIndexPath) {
|
|
387
|
+
this._handleSamplesIndex(res);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Route /<providerName>/<endpoint>
|
|
391
|
+
const route = this._parseProviderRoute(rawUrl);
|
|
392
|
+
if (route) {
|
|
393
|
+
const { providerName, endpoint } = route;
|
|
394
|
+
const mcpSuffix = (this._options.mcpPath ?? "/mcp").replace(/^\//, "");
|
|
395
|
+
const sseSuffix = (this._options.ssePath ?? "/sse").replace(/^\//, "");
|
|
396
|
+
const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
|
|
397
|
+
if (endpoint === mcpSuffix) {
|
|
398
|
+
if (method === "GET") {
|
|
399
|
+
this._handleMcpGetStream(req, res, providerName);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (method === "POST") {
|
|
403
|
+
this._handleMcpPost(req, res, providerName);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (endpoint === sseSuffix && method === "GET") {
|
|
408
|
+
this._handleSseConnect(req, res, providerName);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (endpoint === messagesSuffix && method === "POST") {
|
|
412
|
+
this._handleSseMessage(req, res, providerName);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Static files
|
|
417
|
+
if (this._options.staticMounts?.length) {
|
|
418
|
+
this._serveStatic(req, res);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
res.writeHead(404);
|
|
422
|
+
res.end();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Parses `/<providerName>/<endpoint>` from a URL path.
|
|
427
|
+
* Returns `null` if the URL does not match this two-segment pattern.
|
|
428
|
+
*/
|
|
429
|
+
_parseProviderRoute(rawUrl) {
|
|
430
|
+
const parts = rawUrl.split("/").filter(Boolean);
|
|
431
|
+
if (parts.length !== 2)
|
|
432
|
+
return null;
|
|
433
|
+
const providerName = decodeURIComponent(parts[0]);
|
|
434
|
+
const endpoint = decodeURIComponent(parts[1]);
|
|
435
|
+
if (!providerName || !endpoint)
|
|
436
|
+
return null;
|
|
437
|
+
return { providerName, endpoint };
|
|
438
|
+
}
|
|
439
|
+
// -------------------------------------------------------------------------
|
|
440
|
+
// MCP / SSE transport (per provider)
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
/**
|
|
443
|
+
* Handles `GET /<providerName>/sse` — opens a long-lived SSE stream for Claude.
|
|
444
|
+
* Sends an `endpoint` event so Claude knows where to POST its requests.
|
|
445
|
+
*/
|
|
446
|
+
_handleSseConnect(req, res, providerName) {
|
|
447
|
+
const sessionId = randomUUID();
|
|
448
|
+
const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
|
|
449
|
+
const messagesUrl = `/${encodeURIComponent(providerName)}/${messagesSuffix}`;
|
|
450
|
+
res.writeHead(200, {
|
|
451
|
+
"Content-Type": "text/event-stream",
|
|
452
|
+
"Cache-Control": "no-cache, no-transform",
|
|
453
|
+
Connection: "keep-alive",
|
|
454
|
+
});
|
|
455
|
+
res.write(`event: endpoint\ndata: ${messagesUrl}?sessionId=${sessionId}\n\n`);
|
|
456
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
457
|
+
state.sseSessions.set(sessionId, res);
|
|
458
|
+
req.on("close", () => {
|
|
459
|
+
state.sseSessions.delete(sessionId);
|
|
460
|
+
for (const [id, sink] of state.pending) {
|
|
461
|
+
if (sink.type === "sse" && sink.sessionId === sessionId)
|
|
462
|
+
state.pending.delete(id);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Handles `POST /<providerName>/messages?sessionId=…` — receives a JSON-RPC
|
|
468
|
+
* request from Claude and forwards it to the provider.
|
|
469
|
+
* Always responds 202 Accepted; the real response arrives over SSE.
|
|
470
|
+
*/
|
|
471
|
+
_handleSseMessage(req, res, providerName) {
|
|
472
|
+
const params = new URL(req.url ?? "", "http://localhost").searchParams;
|
|
473
|
+
const sessionId = params.get("sessionId") ?? "";
|
|
474
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
475
|
+
if (!state.sseSessions.has(sessionId)) {
|
|
476
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
477
|
+
res.end("Unknown or expired session");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
let body = "";
|
|
481
|
+
req.on("data", (chunk) => {
|
|
482
|
+
body += chunk.toString();
|
|
483
|
+
});
|
|
484
|
+
req.on("end", () => {
|
|
485
|
+
try {
|
|
486
|
+
const msg = JSON.parse(body);
|
|
487
|
+
if (msg.id != null)
|
|
488
|
+
state.pending.set(msg.id, { type: "sse", sessionId });
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
/* malformed — forward anyway */
|
|
492
|
+
}
|
|
493
|
+
if (this._isProviderConnected(providerName, state)) {
|
|
494
|
+
this._sendToProvider(state, providerName, body);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const sseRes = state.sseSessions.get(sessionId);
|
|
498
|
+
if (sseRes) {
|
|
499
|
+
let errId = null;
|
|
500
|
+
try {
|
|
501
|
+
errId = JSON.parse(body).id ?? null;
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* */
|
|
505
|
+
}
|
|
506
|
+
this._sendSseEvent(sseRes, JSON.stringify({
|
|
507
|
+
jsonrpc: "2.0",
|
|
508
|
+
id: errId,
|
|
509
|
+
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
res.writeHead(202);
|
|
514
|
+
res.end();
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Handles `POST /<providerName>/mcp` — Streamable HTTP transport (MCP 2025-03-26).
|
|
519
|
+
* Forwards the JSON-RPC request to the provider and holds the HTTP response
|
|
520
|
+
* open until the reply arrives, then writes it as `application/json`.
|
|
521
|
+
*/
|
|
522
|
+
_handleMcpPost(req, res, providerName) {
|
|
523
|
+
let body = "";
|
|
524
|
+
req.on("data", (chunk) => {
|
|
525
|
+
body += chunk.toString();
|
|
526
|
+
});
|
|
527
|
+
req.on("end", () => {
|
|
528
|
+
let msg = {};
|
|
529
|
+
try {
|
|
530
|
+
msg = JSON.parse(body);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
534
|
+
res.end("Invalid JSON");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
538
|
+
if (!this._isProviderConnected(providerName, state)) {
|
|
539
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
540
|
+
res.end(JSON.stringify({
|
|
541
|
+
jsonrpc: "2.0",
|
|
542
|
+
id: msg.id ?? null,
|
|
543
|
+
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
544
|
+
}));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (msg.id != null) {
|
|
548
|
+
// Request: hold the response open; reply arrives in _routeFromProvider.
|
|
549
|
+
state.pending.set(msg.id, { type: "http", res });
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
// Notification: forward and acknowledge immediately.
|
|
553
|
+
res.writeHead(202);
|
|
554
|
+
res.end();
|
|
555
|
+
}
|
|
556
|
+
this._sendToProvider(state, providerName, body);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Handles `GET /<providerName>/mcp` — opens a persistent SSE stream per MCP 2025-03-26.
|
|
561
|
+
* Streamable HTTP clients (e.g. MCP Inspector) use this to receive
|
|
562
|
+
* server-initiated notifications without re-polling.
|
|
563
|
+
*/
|
|
564
|
+
_handleMcpGetStream(req, res, providerName) {
|
|
565
|
+
const sessionId = req.headers["mcp-session-id"] ?? randomUUID();
|
|
566
|
+
res.writeHead(200, {
|
|
567
|
+
"Content-Type": "text/event-stream",
|
|
568
|
+
"Cache-Control": "no-cache, no-transform",
|
|
569
|
+
Connection: "keep-alive",
|
|
570
|
+
"Mcp-Session-Id": sessionId,
|
|
571
|
+
});
|
|
572
|
+
res.write(": stream open\n\n");
|
|
573
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
574
|
+
state.mcpGetSessions.set(sessionId, res);
|
|
575
|
+
req.on("close", () => {
|
|
576
|
+
state.mcpGetSessions.delete(sessionId);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/** Writes one JSON-RPC message as an SSE `message` event. */
|
|
580
|
+
_sendSseEvent(res, data) {
|
|
581
|
+
// data is already a compact JSON string — no need to parse+re-serialize.
|
|
582
|
+
res.write(`event: message\ndata: ${data}\n\n`);
|
|
583
|
+
}
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
// WebSocket connection handlers
|
|
586
|
+
// -------------------------------------------------------------------------
|
|
587
|
+
_onProviderConnect(ws, name) {
|
|
588
|
+
if (this._stdioUpstreams.has(name)) {
|
|
589
|
+
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
|
|
590
|
+
`Rename one of them to avoid the conflict.`);
|
|
591
|
+
ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (this._loopbackProviders.has(name)) {
|
|
595
|
+
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — the slot is held by an in-process loopback (reserved system slot).`);
|
|
596
|
+
ws.close(1008, `Provider "${name}" is reserved by the broker`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const existing = this._providers.get(name);
|
|
600
|
+
if (existing?.ws?.readyState === WebSocket.OPEN) {
|
|
601
|
+
ws.close(1008, `Provider "${name}" is already connected`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const state = this._getOrCreateProviderState(name);
|
|
605
|
+
state.ws = ws;
|
|
606
|
+
ws.on("message", (data) => this._routeFromProvider(state, data.toString()));
|
|
607
|
+
ws.on("close", () => {
|
|
608
|
+
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();
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
_onClientConnect(ws, providerName) {
|
|
633
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
634
|
+
state.wsClients.add(ws);
|
|
635
|
+
ws.on("message", (data) => this._routeFromClient(ws, state, providerName, data.toString()));
|
|
636
|
+
ws.on("close", () => {
|
|
637
|
+
state.wsClients.delete(ws);
|
|
638
|
+
for (const [id, sink] of state.pending) {
|
|
639
|
+
if (sink.type === "ws" && sink.socket === ws)
|
|
640
|
+
state.pending.delete(id);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Handles a multiplexed provider WebSocket (`/providers`).
|
|
646
|
+
* A single socket carries traffic for multiple providers using the
|
|
647
|
+
* envelope format `{ provider: string, payload: object }`.
|
|
648
|
+
* Provider names are registered lazily on first message.
|
|
649
|
+
*/
|
|
650
|
+
_onMultiplexProviderConnect(ws) {
|
|
651
|
+
const providerNames = new Set();
|
|
652
|
+
this._multiplexSockets.set(ws, providerNames);
|
|
653
|
+
ws.on("message", (data) => {
|
|
654
|
+
let envelope;
|
|
655
|
+
try {
|
|
656
|
+
envelope = JSON.parse(data.toString());
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
return; // malformed — drop
|
|
660
|
+
}
|
|
661
|
+
const name = envelope.provider;
|
|
662
|
+
if (!name || envelope.payload === undefined)
|
|
663
|
+
return;
|
|
664
|
+
// Register provider name lazily on first encounter.
|
|
665
|
+
if (!providerNames.has(name)) {
|
|
666
|
+
if (this._stdioUpstreams.has(name)) {
|
|
667
|
+
console.warn(`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
|
|
668
|
+
`Rename one of them to avoid the conflict.`);
|
|
669
|
+
ws.send(JSON.stringify({
|
|
670
|
+
provider: name,
|
|
671
|
+
payload: {
|
|
672
|
+
jsonrpc: "2.0",
|
|
673
|
+
id: null,
|
|
674
|
+
error: { code: -32000, message: `Provider "${name}" is managed by a stdio upstream` },
|
|
675
|
+
},
|
|
676
|
+
}));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (this._loopbackProviders.has(name)) {
|
|
680
|
+
ws.send(JSON.stringify({
|
|
681
|
+
provider: name,
|
|
682
|
+
payload: {
|
|
683
|
+
jsonrpc: "2.0",
|
|
684
|
+
id: null,
|
|
685
|
+
error: { code: -32000, message: `Provider "${name}" is reserved by the broker` },
|
|
686
|
+
},
|
|
687
|
+
}));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const existing = this._providers.get(name);
|
|
691
|
+
if (existing?.ws?.readyState === WebSocket.OPEN) {
|
|
692
|
+
// Provider already connected via another socket — reject this name.
|
|
693
|
+
ws.send(JSON.stringify({
|
|
694
|
+
provider: name,
|
|
695
|
+
payload: {
|
|
696
|
+
jsonrpc: "2.0",
|
|
697
|
+
id: null,
|
|
698
|
+
error: { code: -32000, message: `Provider "${name}" is already connected` },
|
|
699
|
+
},
|
|
700
|
+
}));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
providerNames.add(name);
|
|
704
|
+
const state = this._getOrCreateProviderState(name);
|
|
705
|
+
state.ws = ws;
|
|
706
|
+
}
|
|
707
|
+
const state = this._providers.get(name);
|
|
708
|
+
this._routeFromProvider(state, JSON.stringify(envelope.payload));
|
|
709
|
+
});
|
|
710
|
+
ws.on("close", () => {
|
|
711
|
+
for (const name of providerNames) {
|
|
712
|
+
const state = this._providers.get(name);
|
|
713
|
+
if (state && state.ws === ws) {
|
|
714
|
+
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();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
this._multiplexSockets.delete(ws);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// -------------------------------------------------------------------------
|
|
742
|
+
// Message routing
|
|
743
|
+
// -------------------------------------------------------------------------
|
|
744
|
+
/**
|
|
745
|
+
* Sends a raw JSON-RPC message to a provider, wrapping it in a multiplex
|
|
746
|
+
* envelope when the provider's WebSocket is a multiplexed connection.
|
|
747
|
+
*/
|
|
748
|
+
_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);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// In-process loopback (e.g. the embedded `_broker`) takes the same priority.
|
|
756
|
+
const loopback = this._loopbackProviders.get(providerName);
|
|
757
|
+
if (loopback?.isOpen) {
|
|
758
|
+
loopback.send(data);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN)
|
|
762
|
+
return;
|
|
763
|
+
if (this._multiplexSockets.has(state.ws)) {
|
|
764
|
+
// Wrap in envelope for the multiplexed socket.
|
|
765
|
+
const payload = JSON.parse(data);
|
|
766
|
+
state.ws.send(JSON.stringify({ provider: providerName, payload }));
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
state.ws.send(data);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
_routeFromStdioClient(state, data) {
|
|
773
|
+
try {
|
|
774
|
+
const msg = JSON.parse(data);
|
|
775
|
+
if (msg?.id != null)
|
|
776
|
+
state.pending.set(msg.id, { type: "stdio" });
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
/* forward as-is */
|
|
780
|
+
}
|
|
781
|
+
if (this._isProviderConnected(this._stdioClientProvider, state)) {
|
|
782
|
+
this._sendToProvider(state, this._stdioClientProvider, data);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
let errId = null;
|
|
786
|
+
try {
|
|
787
|
+
errId = JSON.parse(data).id ?? null;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
/* */
|
|
791
|
+
}
|
|
792
|
+
if (errId != null) {
|
|
793
|
+
process.stdout.write(JSON.stringify({
|
|
794
|
+
jsonrpc: "2.0",
|
|
795
|
+
id: errId,
|
|
796
|
+
error: { code: -32000, message: `Provider "${this._stdioClientProvider}" not connected` },
|
|
797
|
+
}) + "\n");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
_routeFromClient(client, state, providerName, data) {
|
|
802
|
+
try {
|
|
803
|
+
const msg = JSON.parse(data);
|
|
804
|
+
if (msg?.id != null)
|
|
805
|
+
state.pending.set(msg.id, { type: "ws", socket: client });
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
/* forward as-is */
|
|
809
|
+
}
|
|
810
|
+
if (this._isProviderConnected(providerName, state)) {
|
|
811
|
+
this._sendToProvider(state, providerName, data);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
client.send(JSON.stringify({
|
|
815
|
+
jsonrpc: "2.0",
|
|
816
|
+
id: null,
|
|
817
|
+
error: { code: -32000, message: "No provider connected" },
|
|
818
|
+
}));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
_routeFromProvider(state, data) {
|
|
822
|
+
try {
|
|
823
|
+
const msg = JSON.parse(data);
|
|
824
|
+
if (msg.id != null) {
|
|
825
|
+
// Response: route to the specific sink that made the request.
|
|
826
|
+
const sink = state.pending.get(msg.id);
|
|
827
|
+
if (sink?.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
828
|
+
sink.socket.send(data);
|
|
829
|
+
}
|
|
830
|
+
else if (sink?.type === "sse") {
|
|
831
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
832
|
+
if (sseRes)
|
|
833
|
+
this._sendSseEvent(sseRes, data);
|
|
834
|
+
}
|
|
835
|
+
else if (sink?.type === "http") {
|
|
836
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
837
|
+
sink.res.end(data);
|
|
838
|
+
}
|
|
839
|
+
else if (sink?.type === "stdio") {
|
|
840
|
+
process.stdout.write(data + "\n");
|
|
841
|
+
}
|
|
842
|
+
state.pending.delete(msg.id);
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// Notification (no id): broadcast to all clients of this provider.
|
|
846
|
+
this._broadcast(state, data);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
this._broadcast(state, data);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Sends a message to all clients connected to one provider. */
|
|
854
|
+
_broadcast(state, data) {
|
|
855
|
+
for (const client of state.wsClients) {
|
|
856
|
+
if (client.readyState === WebSocket.OPEN)
|
|
857
|
+
client.send(data);
|
|
858
|
+
}
|
|
859
|
+
for (const sseRes of state.sseSessions.values()) {
|
|
860
|
+
this._sendSseEvent(sseRes, data);
|
|
861
|
+
}
|
|
862
|
+
for (const mcpRes of state.mcpGetSessions.values()) {
|
|
863
|
+
this._sendSseEvent(mcpRes, data);
|
|
864
|
+
}
|
|
865
|
+
// Forward notifications to the stdio client if it is watching this provider.
|
|
866
|
+
if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
|
|
867
|
+
process.stdout.write(data + "\n");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// -------------------------------------------------------------------------
|
|
871
|
+
// Provider state helpers
|
|
872
|
+
// -------------------------------------------------------------------------
|
|
873
|
+
/**
|
|
874
|
+
* Returns `true` if the provider is reachable — via a WebSocket connection,
|
|
875
|
+
* a stdio upstream, or an in-process loopback transport.
|
|
876
|
+
*/
|
|
877
|
+
_isProviderConnected(providerName, state) {
|
|
878
|
+
if (this._stdioUpstreams.get(providerName)?.isOpen)
|
|
879
|
+
return true;
|
|
880
|
+
if (this._loopbackProviders.get(providerName)?.isOpen)
|
|
881
|
+
return true;
|
|
882
|
+
if (state.ws?.readyState === WebSocket.OPEN)
|
|
883
|
+
return true;
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
/** Returns the state for `name`, creating it lazily if it doesn't exist yet. */
|
|
887
|
+
_getOrCreateProviderState(name) {
|
|
888
|
+
let state = this._providers.get(name);
|
|
889
|
+
if (!state) {
|
|
890
|
+
state = {
|
|
891
|
+
ws: null,
|
|
892
|
+
pending: new Map(),
|
|
893
|
+
sseSessions: new Map(),
|
|
894
|
+
mcpGetSessions: new Map(),
|
|
895
|
+
wsClients: new Set(),
|
|
896
|
+
};
|
|
897
|
+
this._providers.set(name, state);
|
|
898
|
+
}
|
|
899
|
+
return state;
|
|
900
|
+
}
|
|
901
|
+
// -------------------------------------------------------------------------
|
|
902
|
+
// Samples index
|
|
903
|
+
// -------------------------------------------------------------------------
|
|
904
|
+
_handleSamplesIndex(res) {
|
|
905
|
+
const rootMount = (this._options.staticMounts ?? []).find((m) => m.urlPrefix === "/");
|
|
906
|
+
let files = [];
|
|
907
|
+
if (rootMount) {
|
|
908
|
+
const samplesDir = nodePath.join(rootMount.dir, "samples");
|
|
909
|
+
try {
|
|
910
|
+
if (fs.existsSync(samplesDir) && fs.statSync(samplesDir).isDirectory()) {
|
|
911
|
+
files = fs.readdirSync(samplesDir).filter((name) => fs.statSync(nodePath.join(samplesDir, name)).isFile());
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
/* return empty list on any I/O error */
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
919
|
+
res.end(JSON.stringify({ files }));
|
|
920
|
+
}
|
|
921
|
+
// -------------------------------------------------------------------------
|
|
922
|
+
// Static file serving
|
|
923
|
+
// -------------------------------------------------------------------------
|
|
924
|
+
_serveStatic(req, res) {
|
|
925
|
+
const rawUrl = (req.url ?? "/").split("?")[0].split("#")[0];
|
|
926
|
+
const mounts = this._options.staticMounts ?? [];
|
|
927
|
+
const mount = [...mounts]
|
|
928
|
+
.filter((m) => {
|
|
929
|
+
const prefix = m.urlPrefix.endsWith("/") ? m.urlPrefix : m.urlPrefix + "/";
|
|
930
|
+
return rawUrl === m.urlPrefix || rawUrl.startsWith(prefix);
|
|
931
|
+
})
|
|
932
|
+
.sort((a, b) => b.urlPrefix.length - a.urlPrefix.length)[0];
|
|
933
|
+
if (!mount) {
|
|
934
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
935
|
+
res.end("Not found");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const relative = rawUrl.slice(mount.urlPrefix.length) || "/";
|
|
939
|
+
const normalized = nodePath.normalize(relative);
|
|
940
|
+
if (normalized.startsWith("..")) {
|
|
941
|
+
res.writeHead(403);
|
|
942
|
+
res.end("Forbidden");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const mountAbs = nodePath.resolve(mount.dir);
|
|
946
|
+
let filePath = nodePath.join(mountAbs, normalized);
|
|
947
|
+
if (!filePath.startsWith(mountAbs + nodePath.sep) && filePath !== mountAbs) {
|
|
948
|
+
res.writeHead(403);
|
|
949
|
+
res.end("Forbidden");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
if (fs.statSync(filePath).isDirectory())
|
|
954
|
+
filePath = nodePath.join(filePath, "index.html");
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
res.writeHead(404);
|
|
958
|
+
res.end("Not found");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (!fs.existsSync(filePath)) {
|
|
962
|
+
res.writeHead(404);
|
|
963
|
+
res.end("Not found");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const ext = nodePath.extname(filePath).toLowerCase();
|
|
967
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
968
|
+
fs.createReadStream(filePath).pipe(res);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
//# sourceMappingURL=ws.tunnel.js.map
|