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.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. package/skills/mcp-import/SKILL.md +216 -216
@@ -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
+ );