dsh-oc-tui 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/README.md +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- package/skills/mcp-import/SKILL.md +216 -216
package/bin/mcp-sse-bridge.mjs
CHANGED
|
@@ -1,499 +1,499 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* mcp-sse-bridge — mount a legacy MCP HTTP+SSE server as a stdio MCP server.
|
|
4
|
-
*
|
|
5
|
-
* The host's mcp-client speaks only stdio and streamable-http, and the bundled
|
|
6
|
-
* MCP SDK has no legacy SSE transport. This script proxies newline-delimited
|
|
7
|
-
* JSON-RPC on stdin/stdout to a legacy HTTP+SSE endpoint (MCP 2024-11-05):
|
|
8
|
-
*
|
|
9
|
-
* GET <url> Accept: text/event-stream -> `event: endpoint` (POST url),
|
|
10
|
-
* then `event: message` frames
|
|
11
|
-
* POST <endpoint> application/json -> the server replies on the stream
|
|
12
|
-
*
|
|
13
|
-
* stdout is the MCP wire: diagnostics go to stderr only.
|
|
14
|
-
*
|
|
15
|
-
* Zero dependencies, `node:` builtins only (needs global fetch, Node >= 20).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import process from 'node:process';
|
|
19
|
-
import { createInterface } from 'node:readline';
|
|
20
|
-
|
|
21
|
-
const DEFAULT_TIMEOUT_MS = 30000;
|
|
22
|
-
|
|
23
|
-
const USAGE = [
|
|
24
|
-
'Usage: node bin/mcp-sse-bridge.mjs [options]',
|
|
25
|
-
'',
|
|
26
|
-
'Bridge a legacy MCP HTTP+SSE endpoint to newline-delimited JSON-RPC on stdio.',
|
|
27
|
-
'',
|
|
28
|
-
'Options:',
|
|
29
|
-
' --url <url> SSE endpoint URL (env: MCP_SSE_URL)',
|
|
30
|
-
' --header "Name: value" Extra request header; repeatable (env: MCP_SSE_HEADERS, JSON object)',
|
|
31
|
-
' --timeout-ms <n> Connect/POST timeout in ms (env: MCP_SSE_TIMEOUT_MS)',
|
|
32
|
-
' --help Print this help and exit 0',
|
|
33
|
-
'',
|
|
34
|
-
].join('\n');
|
|
35
|
-
|
|
36
|
-
class UsageError extends Error {}
|
|
37
|
-
|
|
38
|
-
/* ------------------------------------------------------------------ config */
|
|
39
|
-
|
|
40
|
-
function parseHeader(value) {
|
|
41
|
-
const idx = value.indexOf(':');
|
|
42
|
-
if (idx <= 0) throw new UsageError(`invalid --header (expected "Name: value"): ${value}`);
|
|
43
|
-
const name = value.slice(0, idx).trim();
|
|
44
|
-
const val = value.slice(idx + 1).trim();
|
|
45
|
-
if (!name) throw new UsageError(`invalid --header (empty name): ${value}`);
|
|
46
|
-
return [name, val];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function parseTimeout(value) {
|
|
50
|
-
const n = Number(value);
|
|
51
|
-
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
|
52
|
-
throw new UsageError(`invalid timeout: ${value} (expected a positive integer of milliseconds)`);
|
|
53
|
-
}
|
|
54
|
-
return n;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function setHeader(headers, name, value) {
|
|
58
|
-
const lower = name.toLowerCase();
|
|
59
|
-
const existing = headers.findIndex(([k]) => k.toLowerCase() === lower);
|
|
60
|
-
if (existing === -1) headers.push([name, value]);
|
|
61
|
-
else headers[existing] = [name, value];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function parseArgv(argv) {
|
|
65
|
-
const out = { help: false, url: undefined, headers: [], timeoutMs: undefined };
|
|
66
|
-
for (let i = 0; i < argv.length; i++) {
|
|
67
|
-
const arg = argv[i];
|
|
68
|
-
let name = arg;
|
|
69
|
-
let inline;
|
|
70
|
-
const eq = arg.indexOf('=');
|
|
71
|
-
if (arg.startsWith('--') && eq !== -1) {
|
|
72
|
-
name = arg.slice(0, eq);
|
|
73
|
-
inline = arg.slice(eq + 1);
|
|
74
|
-
}
|
|
75
|
-
const take = () => {
|
|
76
|
-
if (inline !== undefined) return inline;
|
|
77
|
-
const next = argv[++i];
|
|
78
|
-
if (next === undefined) throw new UsageError(`missing value for ${name}`);
|
|
79
|
-
return next;
|
|
80
|
-
};
|
|
81
|
-
switch (name) {
|
|
82
|
-
case '--help':
|
|
83
|
-
case '-h':
|
|
84
|
-
out.help = true;
|
|
85
|
-
break;
|
|
86
|
-
case '--url':
|
|
87
|
-
out.url = take();
|
|
88
|
-
break;
|
|
89
|
-
case '--header':
|
|
90
|
-
out.headers.push(parseHeader(take()));
|
|
91
|
-
break;
|
|
92
|
-
case '--timeout-ms':
|
|
93
|
-
out.timeoutMs = parseTimeout(take());
|
|
94
|
-
break;
|
|
95
|
-
default:
|
|
96
|
-
throw new UsageError(`unknown option: ${arg}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return out;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** argv wins over env. */
|
|
103
|
-
function resolveConfig(argv, env) {
|
|
104
|
-
const parsed = parseArgv(argv);
|
|
105
|
-
if (parsed.help) return { help: true };
|
|
106
|
-
|
|
107
|
-
let url = parsed.url;
|
|
108
|
-
if (url === undefined || url === '') url = env.MCP_SSE_URL;
|
|
109
|
-
if (url === undefined || url === '') {
|
|
110
|
-
throw new UsageError('missing required --url (or MCP_SSE_URL)');
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const headers = [];
|
|
114
|
-
const rawHeaders = env.MCP_SSE_HEADERS;
|
|
115
|
-
if (rawHeaders !== undefined && rawHeaders !== '') {
|
|
116
|
-
let obj;
|
|
117
|
-
try {
|
|
118
|
-
obj = JSON.parse(rawHeaders);
|
|
119
|
-
} catch (err) {
|
|
120
|
-
throw new UsageError(`MCP_SSE_HEADERS is not valid JSON: ${err.message}`);
|
|
121
|
-
}
|
|
122
|
-
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
123
|
-
throw new UsageError('MCP_SSE_HEADERS must be a JSON object of string values');
|
|
124
|
-
}
|
|
125
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
126
|
-
const t = typeof v;
|
|
127
|
-
if (t !== 'string' && t !== 'number' && t !== 'boolean') {
|
|
128
|
-
throw new UsageError(`MCP_SSE_HEADERS["${k}"] must be a string value`);
|
|
129
|
-
}
|
|
130
|
-
setHeader(headers, k, String(v));
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
for (const [k, v] of parsed.headers) setHeader(headers, k, v);
|
|
134
|
-
|
|
135
|
-
let timeoutMs = parsed.timeoutMs;
|
|
136
|
-
if (timeoutMs === undefined && env.MCP_SSE_TIMEOUT_MS !== undefined && env.MCP_SSE_TIMEOUT_MS !== '') {
|
|
137
|
-
timeoutMs = parseTimeout(env.MCP_SSE_TIMEOUT_MS);
|
|
138
|
-
}
|
|
139
|
-
if (timeoutMs === undefined) timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
140
|
-
|
|
141
|
-
return { help: false, url, headers, timeoutMs };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/* --------------------------------------------------------------- SSE parser */
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Incremental text/event-stream parser: `field: value` lines, blank line
|
|
148
|
-
* dispatches the frame, `data:` lines of one frame join with "\n", leading
|
|
149
|
-
* `:` lines are comments, CRLF (and lone CR) tolerated, multi-line data kept.
|
|
150
|
-
*/
|
|
151
|
-
function createSseParser(onFrame) {
|
|
152
|
-
let lineBuf = '';
|
|
153
|
-
let eventName = '';
|
|
154
|
-
let dataLines = [];
|
|
155
|
-
|
|
156
|
-
const dispatch = () => {
|
|
157
|
-
if (dataLines.length === 0 && eventName === '') return;
|
|
158
|
-
const frame = { event: eventName || 'message', data: dataLines.join('\n') };
|
|
159
|
-
eventName = '';
|
|
160
|
-
dataLines = [];
|
|
161
|
-
onFrame(frame);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const handleLine = (line) => {
|
|
165
|
-
if (line === '') {
|
|
166
|
-
dispatch();
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (line.startsWith(':')) return; // comment / keep-alive
|
|
170
|
-
const idx = line.indexOf(':');
|
|
171
|
-
let field;
|
|
172
|
-
let value;
|
|
173
|
-
if (idx === -1) {
|
|
174
|
-
field = line;
|
|
175
|
-
value = '';
|
|
176
|
-
} else {
|
|
177
|
-
field = line.slice(0, idx);
|
|
178
|
-
value = line.slice(idx + 1);
|
|
179
|
-
if (value.startsWith(' ')) value = value.slice(1);
|
|
180
|
-
}
|
|
181
|
-
if (field === 'event') eventName = value;
|
|
182
|
-
else if (field === 'data') dataLines.push(value);
|
|
183
|
-
// `id:` / `retry:` / unknown fields are ignored.
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
push(chunk) {
|
|
188
|
-
let text = lineBuf + chunk;
|
|
189
|
-
text = text.replace(/\r\n/g, '\n');
|
|
190
|
-
let hold = '';
|
|
191
|
-
if (text.endsWith('\r')) {
|
|
192
|
-
hold = '\r';
|
|
193
|
-
text = text.slice(0, -1);
|
|
194
|
-
}
|
|
195
|
-
text = text.replace(/\r/g, '\n');
|
|
196
|
-
lineBuf = hold;
|
|
197
|
-
let idx;
|
|
198
|
-
while ((idx = text.indexOf('\n')) !== -1) {
|
|
199
|
-
const line = text.slice(0, idx);
|
|
200
|
-
text = text.slice(idx + 1);
|
|
201
|
-
handleLine(line);
|
|
202
|
-
}
|
|
203
|
-
lineBuf += text;
|
|
204
|
-
},
|
|
205
|
-
end() {
|
|
206
|
-
if (lineBuf !== '') {
|
|
207
|
-
const line = lineBuf;
|
|
208
|
-
lineBuf = '';
|
|
209
|
-
handleLine(line);
|
|
210
|
-
}
|
|
211
|
-
dispatch();
|
|
212
|
-
},
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/* -------------------------------------------------------------------- main */
|
|
217
|
-
|
|
218
|
-
async function run(config) {
|
|
219
|
-
let target;
|
|
220
|
-
try {
|
|
221
|
-
target = new URL(config.url);
|
|
222
|
-
} catch {
|
|
223
|
-
process.stderr.write(`mcp-sse-bridge: invalid url: ${config.url}\n`);
|
|
224
|
-
return 2;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const headersObject = {};
|
|
228
|
-
for (const [k, v] of config.headers) headersObject[k] = v;
|
|
229
|
-
|
|
230
|
-
let exiting = false;
|
|
231
|
-
let reader = null;
|
|
232
|
-
const controller = new AbortController();
|
|
233
|
-
let endpointValue = null;
|
|
234
|
-
let endpointResolve;
|
|
235
|
-
const endpointReady = new Promise((resolve) => {
|
|
236
|
-
endpointResolve = resolve;
|
|
237
|
-
});
|
|
238
|
-
const queued = [];
|
|
239
|
-
let rl = null;
|
|
240
|
-
let postChain = Promise.resolve();
|
|
241
|
-
let outQueue = Promise.resolve();
|
|
242
|
-
|
|
243
|
-
const writeStdout = (text) => {
|
|
244
|
-
outQueue = outQueue
|
|
245
|
-
.then(
|
|
246
|
-
() =>
|
|
247
|
-
new Promise((resolve) => {
|
|
248
|
-
let flushed = false;
|
|
249
|
-
try {
|
|
250
|
-
flushed = process.stdout.write(text);
|
|
251
|
-
} catch {
|
|
252
|
-
resolve();
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
if (flushed) resolve();
|
|
256
|
-
else process.stdout.once('drain', resolve);
|
|
257
|
-
}),
|
|
258
|
-
)
|
|
259
|
-
.catch(() => {});
|
|
260
|
-
return outQueue;
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Exit by draining the event loop, not by calling process.exit(): on Windows
|
|
265
|
-
* process.exit() while stdin is being torn down trips a libuv assertion
|
|
266
|
-
* (`!(handle->flags & UV_HANDLE_CLOSING)`) and kills the bridge with a crash
|
|
267
|
-
* code instead of the intended one. The unref'd fallback only fires if some
|
|
268
|
-
* keep-alive handle (undici's pool) outlives stdin.
|
|
269
|
-
*/
|
|
270
|
-
const releaseStdio = () => {
|
|
271
|
-
try {
|
|
272
|
-
process.stdin.pause();
|
|
273
|
-
} catch {
|
|
274
|
-
/* ignore */
|
|
275
|
-
}
|
|
276
|
-
try {
|
|
277
|
-
if (typeof process.stdin.unref === 'function') process.stdin.unref();
|
|
278
|
-
} catch {
|
|
279
|
-
/* ignore */
|
|
280
|
-
}
|
|
281
|
-
const fallback = setTimeout(() => process.exit(process.exitCode ?? 0), 500);
|
|
282
|
-
if (typeof fallback.unref === 'function') fallback.unref();
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const shutdown = async (code, message) => {
|
|
286
|
-
if (exiting) return code;
|
|
287
|
-
exiting = true;
|
|
288
|
-
if (message) process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
|
|
289
|
-
if (rl) {
|
|
290
|
-
try {
|
|
291
|
-
rl.close();
|
|
292
|
-
} catch {
|
|
293
|
-
/* ignore */
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
try {
|
|
297
|
-
controller.abort();
|
|
298
|
-
} catch {
|
|
299
|
-
/* ignore */
|
|
300
|
-
}
|
|
301
|
-
if (reader) {
|
|
302
|
-
try {
|
|
303
|
-
await reader.cancel();
|
|
304
|
-
} catch {
|
|
305
|
-
/* ignore */
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
try {
|
|
309
|
-
await postChain;
|
|
310
|
-
} catch {
|
|
311
|
-
/* ignore */
|
|
312
|
-
}
|
|
313
|
-
try {
|
|
314
|
-
await outQueue;
|
|
315
|
-
} catch {
|
|
316
|
-
/* ignore */
|
|
317
|
-
}
|
|
318
|
-
process.exitCode = code;
|
|
319
|
-
releaseStdio();
|
|
320
|
-
return code;
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
/** One POST per line, serialized so stdin order is preserved. */
|
|
324
|
-
const post = (line) => {
|
|
325
|
-
postChain = postChain.then(async () => {
|
|
326
|
-
if (exiting) return;
|
|
327
|
-
try {
|
|
328
|
-
const res = await fetch(endpointValue, {
|
|
329
|
-
method: 'POST',
|
|
330
|
-
headers: {
|
|
331
|
-
'content-type': 'application/json',
|
|
332
|
-
accept: 'application/json, text/event-stream',
|
|
333
|
-
...headersObject,
|
|
334
|
-
},
|
|
335
|
-
body: line,
|
|
336
|
-
signal: AbortSignal.timeout(config.timeoutMs),
|
|
337
|
-
});
|
|
338
|
-
if (!res.ok) {
|
|
339
|
-
process.stderr.write(
|
|
340
|
-
`mcp-sse-bridge: POST ${endpointValue} failed: ${res.status} ${res.statusText}\n`,
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
try {
|
|
344
|
-
await res.arrayBuffer();
|
|
345
|
-
} catch {
|
|
346
|
-
/* ignore body drain errors */
|
|
347
|
-
}
|
|
348
|
-
} catch (err) {
|
|
349
|
-
process.stderr.write(
|
|
350
|
-
`mcp-sse-bridge: POST ${endpointValue} error: ${err && err.message ? err.message : err}\n`,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
});
|
|
354
|
-
return postChain;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
const onFrame = (frame) => {
|
|
358
|
-
if (frame.event === 'endpoint') {
|
|
359
|
-
if (endpointValue !== null) return;
|
|
360
|
-
const raw = frame.data.trim();
|
|
361
|
-
let resolved;
|
|
362
|
-
try {
|
|
363
|
-
resolved = new URL(raw, target);
|
|
364
|
-
} catch {
|
|
365
|
-
shutdown(1, `mcp-sse-bridge: server sent an invalid endpoint URL: ${frame.data}`);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
endpointValue = resolved.href;
|
|
369
|
-
endpointResolve();
|
|
370
|
-
for (const line of queued) post(line);
|
|
371
|
-
queued.length = 0;
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (frame.event === 'message') {
|
|
375
|
-
if (!exiting) writeStdout(`${frame.data}\n`);
|
|
376
|
-
}
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
const consumeSse = async () => {
|
|
380
|
-
let res;
|
|
381
|
-
let connectTimedOut = false;
|
|
382
|
-
const connectTimer = setTimeout(() => {
|
|
383
|
-
connectTimedOut = true;
|
|
384
|
-
controller.abort();
|
|
385
|
-
}, config.timeoutMs);
|
|
386
|
-
try {
|
|
387
|
-
res = await fetch(target, {
|
|
388
|
-
headers: { accept: 'text/event-stream', ...headersObject },
|
|
389
|
-
signal: controller.signal,
|
|
390
|
-
});
|
|
391
|
-
} catch (err) {
|
|
392
|
-
clearTimeout(connectTimer);
|
|
393
|
-
const detail = connectTimedOut
|
|
394
|
-
? `timed out after ${config.timeoutMs}ms`
|
|
395
|
-
: err && err.message
|
|
396
|
-
? err.message
|
|
397
|
-
: String(err);
|
|
398
|
-
return shutdown(1, `mcp-sse-bridge: GET ${target.href} failed: ${detail}`);
|
|
399
|
-
}
|
|
400
|
-
clearTimeout(connectTimer);
|
|
401
|
-
if (!res.ok) {
|
|
402
|
-
try {
|
|
403
|
-
await res.body?.cancel();
|
|
404
|
-
} catch {
|
|
405
|
-
/* ignore */
|
|
406
|
-
}
|
|
407
|
-
return shutdown(1, `mcp-sse-bridge: GET ${target.href} returned ${res.status} ${res.statusText}`);
|
|
408
|
-
}
|
|
409
|
-
if (!res.body) return shutdown(1, `mcp-sse-bridge: GET ${target.href} returned no body`);
|
|
410
|
-
|
|
411
|
-
reader = res.body.getReader();
|
|
412
|
-
const decoder = new TextDecoder();
|
|
413
|
-
const parser = createSseParser(onFrame);
|
|
414
|
-
for (;;) {
|
|
415
|
-
let chunk;
|
|
416
|
-
try {
|
|
417
|
-
chunk = await reader.read();
|
|
418
|
-
} catch (err) {
|
|
419
|
-
if (exiting) return 0;
|
|
420
|
-
return shutdown(
|
|
421
|
-
1,
|
|
422
|
-
`mcp-sse-bridge: SSE stream error: ${err && err.message ? err.message : err}`,
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
if (chunk.done) break;
|
|
426
|
-
parser.push(decoder.decode(chunk.value, { stream: true }));
|
|
427
|
-
}
|
|
428
|
-
if (exiting) return 0;
|
|
429
|
-
parser.end();
|
|
430
|
-
return shutdown(1, 'mcp-sse-bridge: SSE stream closed by the server');
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
const sseTask = consumeSse().catch((err) =>
|
|
434
|
-
shutdown(1, `mcp-sse-bridge: fatal SSE error: ${err && err.stack ? err.stack : err}`),
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
process.stdin.on('error', () => {});
|
|
438
|
-
process.stdout.on('error', () => {});
|
|
439
|
-
rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
440
|
-
|
|
441
|
-
for await (const line of rl) {
|
|
442
|
-
if (exiting) break;
|
|
443
|
-
const trimmed = line.trim();
|
|
444
|
-
if (trimmed === '') continue;
|
|
445
|
-
if (endpointValue === null) queued.push(trimmed);
|
|
446
|
-
else post(trimmed);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (exiting) {
|
|
450
|
-
await sseTask;
|
|
451
|
-
return 0;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// stdin EOF: wait a bounded time for the endpoint so queued messages can be
|
|
455
|
-
// flushed, then exit 0.
|
|
456
|
-
await new Promise((resolve) => {
|
|
457
|
-
const timer = setTimeout(resolve, config.timeoutMs);
|
|
458
|
-
endpointReady.then(() => {
|
|
459
|
-
clearTimeout(timer);
|
|
460
|
-
resolve();
|
|
461
|
-
});
|
|
462
|
-
});
|
|
463
|
-
if (endpointValue === null && queued.length > 0) {
|
|
464
|
-
process.stderr.write(
|
|
465
|
-
`mcp-sse-bridge: stdin closed before the SSE endpoint arrived; dropped ${queued.length} queued message(s)\n`,
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
try {
|
|
469
|
-
await postChain;
|
|
470
|
-
} catch {
|
|
471
|
-
/* ignore */
|
|
472
|
-
}
|
|
473
|
-
return shutdown(0);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
async function main() {
|
|
477
|
-
let config;
|
|
478
|
-
try {
|
|
479
|
-
config = resolveConfig(process.argv.slice(2), process.env);
|
|
480
|
-
} catch (err) {
|
|
481
|
-
process.stderr.write(`mcp-sse-bridge: ${err.message}\n\n${USAGE}\n`);
|
|
482
|
-
return 2;
|
|
483
|
-
}
|
|
484
|
-
if (config.help) {
|
|
485
|
-
process.stdout.write(USAGE);
|
|
486
|
-
return 0;
|
|
487
|
-
}
|
|
488
|
-
return run(config);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
main().then(
|
|
492
|
-
(code) => {
|
|
493
|
-
if (typeof code === 'number') process.exitCode = process.exitCode ?? code;
|
|
494
|
-
},
|
|
495
|
-
(err) => {
|
|
496
|
-
process.stderr.write(`mcp-sse-bridge: fatal: ${err && err.stack ? err.stack : err}\n`);
|
|
497
|
-
process.exitCode = 1;
|
|
498
|
-
},
|
|
499
|
-
);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mcp-sse-bridge — mount a legacy MCP HTTP+SSE server as a stdio MCP server.
|
|
4
|
+
*
|
|
5
|
+
* The host's mcp-client speaks only stdio and streamable-http, and the bundled
|
|
6
|
+
* MCP SDK has no legacy SSE transport. This script proxies newline-delimited
|
|
7
|
+
* JSON-RPC on stdin/stdout to a legacy HTTP+SSE endpoint (MCP 2024-11-05):
|
|
8
|
+
*
|
|
9
|
+
* GET <url> Accept: text/event-stream -> `event: endpoint` (POST url),
|
|
10
|
+
* then `event: message` frames
|
|
11
|
+
* POST <endpoint> application/json -> the server replies on the stream
|
|
12
|
+
*
|
|
13
|
+
* stdout is the MCP wire: diagnostics go to stderr only.
|
|
14
|
+
*
|
|
15
|
+
* Zero dependencies, `node:` builtins only (needs global fetch, Node >= 20).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
22
|
+
|
|
23
|
+
const USAGE = [
|
|
24
|
+
'Usage: node bin/mcp-sse-bridge.mjs [options]',
|
|
25
|
+
'',
|
|
26
|
+
'Bridge a legacy MCP HTTP+SSE endpoint to newline-delimited JSON-RPC on stdio.',
|
|
27
|
+
'',
|
|
28
|
+
'Options:',
|
|
29
|
+
' --url <url> SSE endpoint URL (env: MCP_SSE_URL)',
|
|
30
|
+
' --header "Name: value" Extra request header; repeatable (env: MCP_SSE_HEADERS, JSON object)',
|
|
31
|
+
' --timeout-ms <n> Connect/POST timeout in ms (env: MCP_SSE_TIMEOUT_MS)',
|
|
32
|
+
' --help Print this help and exit 0',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
class UsageError extends Error {}
|
|
37
|
+
|
|
38
|
+
/* ------------------------------------------------------------------ config */
|
|
39
|
+
|
|
40
|
+
function parseHeader(value) {
|
|
41
|
+
const idx = value.indexOf(':');
|
|
42
|
+
if (idx <= 0) throw new UsageError(`invalid --header (expected "Name: value"): ${value}`);
|
|
43
|
+
const name = value.slice(0, idx).trim();
|
|
44
|
+
const val = value.slice(idx + 1).trim();
|
|
45
|
+
if (!name) throw new UsageError(`invalid --header (empty name): ${value}`);
|
|
46
|
+
return [name, val];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseTimeout(value) {
|
|
50
|
+
const n = Number(value);
|
|
51
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
|
52
|
+
throw new UsageError(`invalid timeout: ${value} (expected a positive integer of milliseconds)`);
|
|
53
|
+
}
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setHeader(headers, name, value) {
|
|
58
|
+
const lower = name.toLowerCase();
|
|
59
|
+
const existing = headers.findIndex(([k]) => k.toLowerCase() === lower);
|
|
60
|
+
if (existing === -1) headers.push([name, value]);
|
|
61
|
+
else headers[existing] = [name, value];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArgv(argv) {
|
|
65
|
+
const out = { help: false, url: undefined, headers: [], timeoutMs: undefined };
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
let name = arg;
|
|
69
|
+
let inline;
|
|
70
|
+
const eq = arg.indexOf('=');
|
|
71
|
+
if (arg.startsWith('--') && eq !== -1) {
|
|
72
|
+
name = arg.slice(0, eq);
|
|
73
|
+
inline = arg.slice(eq + 1);
|
|
74
|
+
}
|
|
75
|
+
const take = () => {
|
|
76
|
+
if (inline !== undefined) return inline;
|
|
77
|
+
const next = argv[++i];
|
|
78
|
+
if (next === undefined) throw new UsageError(`missing value for ${name}`);
|
|
79
|
+
return next;
|
|
80
|
+
};
|
|
81
|
+
switch (name) {
|
|
82
|
+
case '--help':
|
|
83
|
+
case '-h':
|
|
84
|
+
out.help = true;
|
|
85
|
+
break;
|
|
86
|
+
case '--url':
|
|
87
|
+
out.url = take();
|
|
88
|
+
break;
|
|
89
|
+
case '--header':
|
|
90
|
+
out.headers.push(parseHeader(take()));
|
|
91
|
+
break;
|
|
92
|
+
case '--timeout-ms':
|
|
93
|
+
out.timeoutMs = parseTimeout(take());
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
throw new UsageError(`unknown option: ${arg}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** argv wins over env. */
|
|
103
|
+
function resolveConfig(argv, env) {
|
|
104
|
+
const parsed = parseArgv(argv);
|
|
105
|
+
if (parsed.help) return { help: true };
|
|
106
|
+
|
|
107
|
+
let url = parsed.url;
|
|
108
|
+
if (url === undefined || url === '') url = env.MCP_SSE_URL;
|
|
109
|
+
if (url === undefined || url === '') {
|
|
110
|
+
throw new UsageError('missing required --url (or MCP_SSE_URL)');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const headers = [];
|
|
114
|
+
const rawHeaders = env.MCP_SSE_HEADERS;
|
|
115
|
+
if (rawHeaders !== undefined && rawHeaders !== '') {
|
|
116
|
+
let obj;
|
|
117
|
+
try {
|
|
118
|
+
obj = JSON.parse(rawHeaders);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new UsageError(`MCP_SSE_HEADERS is not valid JSON: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
123
|
+
throw new UsageError('MCP_SSE_HEADERS must be a JSON object of string values');
|
|
124
|
+
}
|
|
125
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
126
|
+
const t = typeof v;
|
|
127
|
+
if (t !== 'string' && t !== 'number' && t !== 'boolean') {
|
|
128
|
+
throw new UsageError(`MCP_SSE_HEADERS["${k}"] must be a string value`);
|
|
129
|
+
}
|
|
130
|
+
setHeader(headers, k, String(v));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [k, v] of parsed.headers) setHeader(headers, k, v);
|
|
134
|
+
|
|
135
|
+
let timeoutMs = parsed.timeoutMs;
|
|
136
|
+
if (timeoutMs === undefined && env.MCP_SSE_TIMEOUT_MS !== undefined && env.MCP_SSE_TIMEOUT_MS !== '') {
|
|
137
|
+
timeoutMs = parseTimeout(env.MCP_SSE_TIMEOUT_MS);
|
|
138
|
+
}
|
|
139
|
+
if (timeoutMs === undefined) timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
140
|
+
|
|
141
|
+
return { help: false, url, headers, timeoutMs };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* --------------------------------------------------------------- SSE parser */
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Incremental text/event-stream parser: `field: value` lines, blank line
|
|
148
|
+
* dispatches the frame, `data:` lines of one frame join with "\n", leading
|
|
149
|
+
* `:` lines are comments, CRLF (and lone CR) tolerated, multi-line data kept.
|
|
150
|
+
*/
|
|
151
|
+
function createSseParser(onFrame) {
|
|
152
|
+
let lineBuf = '';
|
|
153
|
+
let eventName = '';
|
|
154
|
+
let dataLines = [];
|
|
155
|
+
|
|
156
|
+
const dispatch = () => {
|
|
157
|
+
if (dataLines.length === 0 && eventName === '') return;
|
|
158
|
+
const frame = { event: eventName || 'message', data: dataLines.join('\n') };
|
|
159
|
+
eventName = '';
|
|
160
|
+
dataLines = [];
|
|
161
|
+
onFrame(frame);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleLine = (line) => {
|
|
165
|
+
if (line === '') {
|
|
166
|
+
dispatch();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (line.startsWith(':')) return; // comment / keep-alive
|
|
170
|
+
const idx = line.indexOf(':');
|
|
171
|
+
let field;
|
|
172
|
+
let value;
|
|
173
|
+
if (idx === -1) {
|
|
174
|
+
field = line;
|
|
175
|
+
value = '';
|
|
176
|
+
} else {
|
|
177
|
+
field = line.slice(0, idx);
|
|
178
|
+
value = line.slice(idx + 1);
|
|
179
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
180
|
+
}
|
|
181
|
+
if (field === 'event') eventName = value;
|
|
182
|
+
else if (field === 'data') dataLines.push(value);
|
|
183
|
+
// `id:` / `retry:` / unknown fields are ignored.
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
push(chunk) {
|
|
188
|
+
let text = lineBuf + chunk;
|
|
189
|
+
text = text.replace(/\r\n/g, '\n');
|
|
190
|
+
let hold = '';
|
|
191
|
+
if (text.endsWith('\r')) {
|
|
192
|
+
hold = '\r';
|
|
193
|
+
text = text.slice(0, -1);
|
|
194
|
+
}
|
|
195
|
+
text = text.replace(/\r/g, '\n');
|
|
196
|
+
lineBuf = hold;
|
|
197
|
+
let idx;
|
|
198
|
+
while ((idx = text.indexOf('\n')) !== -1) {
|
|
199
|
+
const line = text.slice(0, idx);
|
|
200
|
+
text = text.slice(idx + 1);
|
|
201
|
+
handleLine(line);
|
|
202
|
+
}
|
|
203
|
+
lineBuf += text;
|
|
204
|
+
},
|
|
205
|
+
end() {
|
|
206
|
+
if (lineBuf !== '') {
|
|
207
|
+
const line = lineBuf;
|
|
208
|
+
lineBuf = '';
|
|
209
|
+
handleLine(line);
|
|
210
|
+
}
|
|
211
|
+
dispatch();
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* -------------------------------------------------------------------- main */
|
|
217
|
+
|
|
218
|
+
async function run(config) {
|
|
219
|
+
let target;
|
|
220
|
+
try {
|
|
221
|
+
target = new URL(config.url);
|
|
222
|
+
} catch {
|
|
223
|
+
process.stderr.write(`mcp-sse-bridge: invalid url: ${config.url}\n`);
|
|
224
|
+
return 2;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const headersObject = {};
|
|
228
|
+
for (const [k, v] of config.headers) headersObject[k] = v;
|
|
229
|
+
|
|
230
|
+
let exiting = false;
|
|
231
|
+
let reader = null;
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
let endpointValue = null;
|
|
234
|
+
let endpointResolve;
|
|
235
|
+
const endpointReady = new Promise((resolve) => {
|
|
236
|
+
endpointResolve = resolve;
|
|
237
|
+
});
|
|
238
|
+
const queued = [];
|
|
239
|
+
let rl = null;
|
|
240
|
+
let postChain = Promise.resolve();
|
|
241
|
+
let outQueue = Promise.resolve();
|
|
242
|
+
|
|
243
|
+
const writeStdout = (text) => {
|
|
244
|
+
outQueue = outQueue
|
|
245
|
+
.then(
|
|
246
|
+
() =>
|
|
247
|
+
new Promise((resolve) => {
|
|
248
|
+
let flushed = false;
|
|
249
|
+
try {
|
|
250
|
+
flushed = process.stdout.write(text);
|
|
251
|
+
} catch {
|
|
252
|
+
resolve();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (flushed) resolve();
|
|
256
|
+
else process.stdout.once('drain', resolve);
|
|
257
|
+
}),
|
|
258
|
+
)
|
|
259
|
+
.catch(() => {});
|
|
260
|
+
return outQueue;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Exit by draining the event loop, not by calling process.exit(): on Windows
|
|
265
|
+
* process.exit() while stdin is being torn down trips a libuv assertion
|
|
266
|
+
* (`!(handle->flags & UV_HANDLE_CLOSING)`) and kills the bridge with a crash
|
|
267
|
+
* code instead of the intended one. The unref'd fallback only fires if some
|
|
268
|
+
* keep-alive handle (undici's pool) outlives stdin.
|
|
269
|
+
*/
|
|
270
|
+
const releaseStdio = () => {
|
|
271
|
+
try {
|
|
272
|
+
process.stdin.pause();
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore */
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
if (typeof process.stdin.unref === 'function') process.stdin.unref();
|
|
278
|
+
} catch {
|
|
279
|
+
/* ignore */
|
|
280
|
+
}
|
|
281
|
+
const fallback = setTimeout(() => process.exit(process.exitCode ?? 0), 500);
|
|
282
|
+
if (typeof fallback.unref === 'function') fallback.unref();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const shutdown = async (code, message) => {
|
|
286
|
+
if (exiting) return code;
|
|
287
|
+
exiting = true;
|
|
288
|
+
if (message) process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
|
|
289
|
+
if (rl) {
|
|
290
|
+
try {
|
|
291
|
+
rl.close();
|
|
292
|
+
} catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
controller.abort();
|
|
298
|
+
} catch {
|
|
299
|
+
/* ignore */
|
|
300
|
+
}
|
|
301
|
+
if (reader) {
|
|
302
|
+
try {
|
|
303
|
+
await reader.cancel();
|
|
304
|
+
} catch {
|
|
305
|
+
/* ignore */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
await postChain;
|
|
310
|
+
} catch {
|
|
311
|
+
/* ignore */
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
await outQueue;
|
|
315
|
+
} catch {
|
|
316
|
+
/* ignore */
|
|
317
|
+
}
|
|
318
|
+
process.exitCode = code;
|
|
319
|
+
releaseStdio();
|
|
320
|
+
return code;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** One POST per line, serialized so stdin order is preserved. */
|
|
324
|
+
const post = (line) => {
|
|
325
|
+
postChain = postChain.then(async () => {
|
|
326
|
+
if (exiting) return;
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch(endpointValue, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: {
|
|
331
|
+
'content-type': 'application/json',
|
|
332
|
+
accept: 'application/json, text/event-stream',
|
|
333
|
+
...headersObject,
|
|
334
|
+
},
|
|
335
|
+
body: line,
|
|
336
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
337
|
+
});
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
process.stderr.write(
|
|
340
|
+
`mcp-sse-bridge: POST ${endpointValue} failed: ${res.status} ${res.statusText}\n`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await res.arrayBuffer();
|
|
345
|
+
} catch {
|
|
346
|
+
/* ignore body drain errors */
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
process.stderr.write(
|
|
350
|
+
`mcp-sse-bridge: POST ${endpointValue} error: ${err && err.message ? err.message : err}\n`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
return postChain;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const onFrame = (frame) => {
|
|
358
|
+
if (frame.event === 'endpoint') {
|
|
359
|
+
if (endpointValue !== null) return;
|
|
360
|
+
const raw = frame.data.trim();
|
|
361
|
+
let resolved;
|
|
362
|
+
try {
|
|
363
|
+
resolved = new URL(raw, target);
|
|
364
|
+
} catch {
|
|
365
|
+
shutdown(1, `mcp-sse-bridge: server sent an invalid endpoint URL: ${frame.data}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
endpointValue = resolved.href;
|
|
369
|
+
endpointResolve();
|
|
370
|
+
for (const line of queued) post(line);
|
|
371
|
+
queued.length = 0;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (frame.event === 'message') {
|
|
375
|
+
if (!exiting) writeStdout(`${frame.data}\n`);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const consumeSse = async () => {
|
|
380
|
+
let res;
|
|
381
|
+
let connectTimedOut = false;
|
|
382
|
+
const connectTimer = setTimeout(() => {
|
|
383
|
+
connectTimedOut = true;
|
|
384
|
+
controller.abort();
|
|
385
|
+
}, config.timeoutMs);
|
|
386
|
+
try {
|
|
387
|
+
res = await fetch(target, {
|
|
388
|
+
headers: { accept: 'text/event-stream', ...headersObject },
|
|
389
|
+
signal: controller.signal,
|
|
390
|
+
});
|
|
391
|
+
} catch (err) {
|
|
392
|
+
clearTimeout(connectTimer);
|
|
393
|
+
const detail = connectTimedOut
|
|
394
|
+
? `timed out after ${config.timeoutMs}ms`
|
|
395
|
+
: err && err.message
|
|
396
|
+
? err.message
|
|
397
|
+
: String(err);
|
|
398
|
+
return shutdown(1, `mcp-sse-bridge: GET ${target.href} failed: ${detail}`);
|
|
399
|
+
}
|
|
400
|
+
clearTimeout(connectTimer);
|
|
401
|
+
if (!res.ok) {
|
|
402
|
+
try {
|
|
403
|
+
await res.body?.cancel();
|
|
404
|
+
} catch {
|
|
405
|
+
/* ignore */
|
|
406
|
+
}
|
|
407
|
+
return shutdown(1, `mcp-sse-bridge: GET ${target.href} returned ${res.status} ${res.statusText}`);
|
|
408
|
+
}
|
|
409
|
+
if (!res.body) return shutdown(1, `mcp-sse-bridge: GET ${target.href} returned no body`);
|
|
410
|
+
|
|
411
|
+
reader = res.body.getReader();
|
|
412
|
+
const decoder = new TextDecoder();
|
|
413
|
+
const parser = createSseParser(onFrame);
|
|
414
|
+
for (;;) {
|
|
415
|
+
let chunk;
|
|
416
|
+
try {
|
|
417
|
+
chunk = await reader.read();
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (exiting) return 0;
|
|
420
|
+
return shutdown(
|
|
421
|
+
1,
|
|
422
|
+
`mcp-sse-bridge: SSE stream error: ${err && err.message ? err.message : err}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (chunk.done) break;
|
|
426
|
+
parser.push(decoder.decode(chunk.value, { stream: true }));
|
|
427
|
+
}
|
|
428
|
+
if (exiting) return 0;
|
|
429
|
+
parser.end();
|
|
430
|
+
return shutdown(1, 'mcp-sse-bridge: SSE stream closed by the server');
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const sseTask = consumeSse().catch((err) =>
|
|
434
|
+
shutdown(1, `mcp-sse-bridge: fatal SSE error: ${err && err.stack ? err.stack : err}`),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
process.stdin.on('error', () => {});
|
|
438
|
+
process.stdout.on('error', () => {});
|
|
439
|
+
rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
440
|
+
|
|
441
|
+
for await (const line of rl) {
|
|
442
|
+
if (exiting) break;
|
|
443
|
+
const trimmed = line.trim();
|
|
444
|
+
if (trimmed === '') continue;
|
|
445
|
+
if (endpointValue === null) queued.push(trimmed);
|
|
446
|
+
else post(trimmed);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (exiting) {
|
|
450
|
+
await sseTask;
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// stdin EOF: wait a bounded time for the endpoint so queued messages can be
|
|
455
|
+
// flushed, then exit 0.
|
|
456
|
+
await new Promise((resolve) => {
|
|
457
|
+
const timer = setTimeout(resolve, config.timeoutMs);
|
|
458
|
+
endpointReady.then(() => {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
resolve();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
if (endpointValue === null && queued.length > 0) {
|
|
464
|
+
process.stderr.write(
|
|
465
|
+
`mcp-sse-bridge: stdin closed before the SSE endpoint arrived; dropped ${queued.length} queued message(s)\n`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
await postChain;
|
|
470
|
+
} catch {
|
|
471
|
+
/* ignore */
|
|
472
|
+
}
|
|
473
|
+
return shutdown(0);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function main() {
|
|
477
|
+
let config;
|
|
478
|
+
try {
|
|
479
|
+
config = resolveConfig(process.argv.slice(2), process.env);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
process.stderr.write(`mcp-sse-bridge: ${err.message}\n\n${USAGE}\n`);
|
|
482
|
+
return 2;
|
|
483
|
+
}
|
|
484
|
+
if (config.help) {
|
|
485
|
+
process.stdout.write(USAGE);
|
|
486
|
+
return 0;
|
|
487
|
+
}
|
|
488
|
+
return run(config);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
main().then(
|
|
492
|
+
(code) => {
|
|
493
|
+
if (typeof code === 'number') process.exitCode = process.exitCode ?? code;
|
|
494
|
+
},
|
|
495
|
+
(err) => {
|
|
496
|
+
process.stderr.write(`mcp-sse-bridge: fatal: ${err && err.stack ? err.stack : err}\n`);
|
|
497
|
+
process.exitCode = 1;
|
|
498
|
+
},
|
|
499
|
+
);
|