toolfunnel 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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,524 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * mcp-client.js — the MCP CLIENT (architecture notes §1, §7; Phase 2 aggregator).
5
+ *
6
+ * This is the REVERSE of src/mcp/server.js. Where server.js is a JSON-RPC 2.0
7
+ * server that the CLI connects TO, this is a JSON-RPC 2.0 client that spawns an
8
+ * UPSTREAM MCP server as a child process and talks to it over stdio. The Phase 2
9
+ * aggregator owns one McpClient per upstream MCP server, lists their tools, and
10
+ * routes curated calls through them.
11
+ *
12
+ * Transport contract (mirrors server.js's framing helpers but in the other direction):
13
+ * WRITE: LSP-style `Content-Length: N\r\n\r\n<body>` framing — this is what a real
14
+ * MCP server requires on its stdin.
15
+ * READ: tolerate BOTH `Content-Length:` framing AND newline-delimited JSON, so the
16
+ * same client works against a real MCP server AND against our own server.js
17
+ * (which WRITES newline-delimited JSON). This is server.js's dual-drain logic
18
+ * re-used in the client.
19
+ *
20
+ * JSON-RPC requests are matched to responses by `id`. Every request carries a
21
+ * timeout (requestTimeoutMs) — a slow/dead upstream rejects the in-flight promise
22
+ * instead of leaking it. Notifications (no `id`) from the server are ignored.
23
+ *
24
+ * SAFETY / defensiveness:
25
+ * - close() is idempotent and NEVER leaves a zombie child: it ends stdin, then if the
26
+ * child is still alive tree-kills it (win32: taskkill /T /F; else child.kill()).
27
+ * - connect() that fails (spawn error or initialize timeout) calls close() so no child
28
+ * is left running, then rejects with a clear error.
29
+ * - A child that exits while requests are in flight rejects every waiter (no hang).
30
+ * - spawnImpl is injectable so a unit test can fake the child, but the real round-trip
31
+ * against the fixture is the load-bearing test.
32
+ *
33
+ * CommonJS only. Node built-ins only. No @modelcontextprotocol/sdk.
34
+ */
35
+
36
+ const childProcess = require('node:child_process');
37
+ const path = require('node:path');
38
+
39
+ const JSONRPC = '2.0';
40
+ const PROTOCOL_VERSION = '2024-11-05';
41
+ const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
42
+
43
+ /**
44
+ * Cross-platform launch resolution for the upstream command.
45
+ *
46
+ * POSIX: pass through unchanged — execvp resolves `npx` / `uvx` / `node` / … on PATH.
47
+ *
48
+ * Windows: child_process with shell:false CANNOT run a `.cmd` / `.bat` shim — a bare name ENOENTs,
49
+ * and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
50
+ * exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, …), so on Windows route anything that isn't already
51
+ * a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
52
+ * (Node escapes each one under shell:false) so nothing is shell-concatenated — no injection surface on
53
+ * this privileged path, and none of the `shell:true` DEP0190 hazard. close()'s taskkill /T still reaps
54
+ * the whole cmd → shim → server process tree.
55
+ *
56
+ * @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
57
+ * @param {string[]} args the configured argv
58
+ * @returns {{ command: string, args: string[] }} what to actually hand to spawn()
59
+ */
60
+ function winLaunch(command, args) {
61
+ const argv = Array.isArray(args) ? args : [];
62
+ if (process.platform !== 'win32') return { command, args: argv };
63
+ // A concrete executable can be spawned directly — no shim resolution needed.
64
+ if (path.isAbsolute(command) && /\.(exe|com)$/i.test(command)) return { command, args: argv };
65
+ // Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT.
66
+ const comspec = process.env.ComSpec || 'cmd.exe';
67
+ return { command: comspec, args: ['/c', command, ...argv] };
68
+ }
69
+
70
+ /**
71
+ * McpClient — a hand-rolled JSON-RPC 2.0 client over a child process's stdio.
72
+ *
73
+ * Lifecycle: `new McpClient(opts)` → `await connect()` → `listTools()` / `callTool()`
74
+ * → `close()` (idempotent).
75
+ */
76
+ class McpClient {
77
+ /**
78
+ * @param {object} opts
79
+ * @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
80
+ * @param {string} opts.command executable to spawn (e.g. process.execPath)
81
+ * @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
82
+ * @param {object} [opts.env] extra env merged over process.env for the child
83
+ * @param {string} [opts.cwd] child working directory
84
+ * @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
85
+ * @param {number} [opts.requestTimeoutMs] per-request timeout in ms (default 10000)
86
+ * @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
87
+ * while not deliberately close()d). Lets the owner
88
+ * (the aggregator) schedule a background reconnect.
89
+ */
90
+ constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs = 10000, onClose } = {}) {
91
+ if (typeof command !== 'string' || command.length === 0) {
92
+ throw new Error('McpClient: "command" (non-empty string) is required');
93
+ }
94
+ this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
95
+ this._command = command;
96
+ this._args = Array.isArray(args) ? args.slice() : [];
97
+ this._env = env && typeof env === 'object' ? env : {};
98
+ this._cwd = cwd;
99
+ this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
100
+ this._requestTimeoutMs =
101
+ Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
102
+ this._onClose = typeof onClose === 'function' ? onClose : null;
103
+ this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
104
+ this._everConnected = false; // true once the handshake completed — onClose fires ONLY for the
105
+ // death of an ESTABLISHED connection, never a connect-time failure
106
+
107
+ // Transport state.
108
+ this._child = null;
109
+ this._connected = false;
110
+ this._closed = false;
111
+ this._buf = Buffer.alloc(0);
112
+ this._stderr = '';
113
+ this._nextId = 1;
114
+ /** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
115
+ this._waiters = new Map();
116
+
117
+ // The initialize result, captured for connect()'s return value.
118
+ this._initializeResult = null;
119
+ }
120
+
121
+ /** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
122
+ get connected() {
123
+ return this._connected === true && this._closed === false;
124
+ }
125
+
126
+ // ── Diagnostics → never throw. ───────────────────────────────────────────────────────────────
127
+ _logErr(...parts) {
128
+ try {
129
+ process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
130
+ } catch (_e) {
131
+ /* never let logging throw */
132
+ }
133
+ }
134
+
135
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
136
+ // connect(): spawn the child, perform the MCP initialize handshake, send the
137
+ // notifications/initialized notification, set connected. On ANY failure (spawn
138
+ // error, initialize timeout/error) close() the child and reject — never leave a zombie.
139
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
140
+ async connect() {
141
+ if (this._closed) throw new Error('McpClient: connect() called after close()');
142
+ if (this._connected) return this._initializeResult;
143
+
144
+ // 1) Spawn the child. A synchronous spawn throw (bad command) is caught and rethrown clean.
145
+ try {
146
+ // Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
147
+ const launch = winLaunch(this._command, this._args);
148
+ this._child = this._spawn(launch.command, launch.args, {
149
+ stdio: ['pipe', 'pipe', 'pipe'],
150
+ cwd: this._cwd,
151
+ env: Object.assign({}, process.env, this._env),
152
+ windowsHide: true,
153
+ });
154
+ } catch (err) {
155
+ this._child = null;
156
+ throw new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
157
+ }
158
+
159
+ if (!this._child || !this._child.stdin || !this._child.stdout) {
160
+ // A fake/odd spawnImpl that didn't give us pipes — bail cleanly.
161
+ this.close();
162
+ throw new Error('McpClient: spawned child is missing stdio pipes');
163
+ }
164
+
165
+ this._wireChild();
166
+
167
+ // 2) initialize handshake. If the child fails to spawn the 'error' event fires and
168
+ // rejects this request (via the waiter-reject path); if it never answers the
169
+ // request timeout fires. Either way we close() and surface a clear error.
170
+ try {
171
+ const result = await this._request('initialize', {
172
+ protocolVersion: PROTOCOL_VERSION,
173
+ clientInfo: CLIENT_INFO,
174
+ capabilities: {},
175
+ });
176
+ this._initializeResult = result;
177
+ } catch (err) {
178
+ this.close();
179
+ throw new Error(`McpClient: initialize failed: ${(err && err.message) || String(err)}`);
180
+ }
181
+
182
+ // 3) Tell the server the handshake is complete (notification — no id, no reply).
183
+ this._notify('notifications/initialized', {});
184
+
185
+ this._connected = true;
186
+ this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
187
+ return this._initializeResult;
188
+ }
189
+
190
+ /**
191
+ * tools/list → the upstream's tool definitions. Returns the raw array
192
+ * [{name, description, inputSchema}] from result.tools (empty array if the
193
+ * server returned no tools). Rejects on timeout / transport failure.
194
+ * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
195
+ */
196
+ async listTools() {
197
+ const result = await this._request('tools/list', {});
198
+ const tools = result && Array.isArray(result.tools) ? result.tools : [];
199
+ return tools;
200
+ }
201
+
202
+ /**
203
+ * tools/call → invoke one upstream tool. Returns the MCP tools/call result
204
+ * envelope { content, isError }. Rejects on timeout / transport failure (a tool
205
+ * that ran but returned an error sets isError:true in the resolved value — that
206
+ * is NOT a rejection).
207
+ * @param {string} name the upstream tool name
208
+ * @param {any} args structured arguments (forwarded as the `arguments` field)
209
+ * @returns {Promise<{content:any[], isError:boolean}>}
210
+ */
211
+ async callTool(name, args) {
212
+ if (typeof name !== 'string' || name.length === 0) {
213
+ throw new Error('McpClient.callTool: "name" (non-empty string) is required');
214
+ }
215
+ const result = await this._request('tools/call', { name, arguments: args == null ? {} : args });
216
+ const r = result && typeof result === 'object' ? result : {};
217
+ return {
218
+ content: Array.isArray(r.content) ? r.content : [],
219
+ isError: r.isError === true,
220
+ };
221
+ }
222
+
223
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
224
+ // close(): idempotent teardown. End child stdin so a well-behaved server exits on
225
+ // EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
226
+ // process tree; else child.kill()). Reject every in-flight request so nothing leaks.
227
+ // NEVER throws — it is called from error paths.
228
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
229
+ close() {
230
+ if (this._closed) return;
231
+ this._closed = true;
232
+ this._connected = false;
233
+
234
+ const child = this._child;
235
+
236
+ // Reject any in-flight requests first (so they don't fire their timers after teardown).
237
+ this._rejectAllWaiters(new Error('McpClient: connection closed'));
238
+
239
+ if (!child) return;
240
+
241
+ // Best-effort: end stdin to let the server exit on EOF.
242
+ try {
243
+ if (child.stdin && !child.stdin.destroyed) child.stdin.end();
244
+ } catch (_e) {
245
+ /* ignore */
246
+ }
247
+
248
+ // If still alive, tree-kill so no descendant is orphaned (zombie guard).
249
+ try {
250
+ const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
251
+ if (alive && typeof child.pid === 'number') {
252
+ if (process.platform === 'win32') {
253
+ // /T = whole tree, /F = force. spawnSync so it completes synchronously here.
254
+ try {
255
+ childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
256
+ windowsHide: true,
257
+ });
258
+ } catch (_e) {
259
+ // Fall back to a plain kill if taskkill is unavailable.
260
+ try { child.kill(); } catch (_e2) { /* ignore */ }
261
+ }
262
+ } else {
263
+ child.kill();
264
+ }
265
+ } else if (typeof child.kill === 'function') {
266
+ // Already exited or a fake child — a no-op kill is harmless.
267
+ try { child.kill(); } catch (_e) { /* ignore */ }
268
+ }
269
+ } catch (_e) {
270
+ /* never throw out of close */
271
+ }
272
+
273
+ this._child = null;
274
+ }
275
+
276
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
277
+ // Internals.
278
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
279
+
280
+ /** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
281
+ _wireChild() {
282
+ const child = this._child;
283
+
284
+ child.stdout.on('data', (chunk) => this._onData(chunk));
285
+ child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
286
+
287
+ if (child.stderr) {
288
+ child.stderr.on('data', (d) => {
289
+ this._stderr += d.toString('utf8');
290
+ // Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
291
+ if (this._stderr.length > 64 * 1024) {
292
+ this._stderr = this._stderr.slice(-32 * 1024);
293
+ }
294
+ });
295
+ child.stderr.on('error', () => { /* ignore */ });
296
+ }
297
+
298
+ // Either of these means the child is gone — fail every in-flight request so nothing hangs.
299
+ child.on('exit', (code, signal) => {
300
+ this._connected = false;
301
+ this._rejectAllWaiters(
302
+ new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
303
+ );
304
+ this._fireOnClose(`exited (code=${code}, signal=${signal})`);
305
+ });
306
+ child.on('error', (err) => {
307
+ // Spawn-level failure (e.g. ENOENT) — surfaces here, not as a throw from spawn().
308
+ this._connected = false;
309
+ this._rejectAllWaiters(
310
+ new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`)
311
+ );
312
+ this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death — never when close() was
318
+ * called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
319
+ * 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
320
+ * reconnect. Wrapped — a throwing callback must not escape an event handler.
321
+ * @param {string} reason
322
+ */
323
+ _fireOnClose(reason) {
324
+ // ONLY an established connection dying counts. A connect-time failure (child exits during the
325
+ // handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
326
+ // the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
327
+ // reconnect attempt reset the backoff counter to 0 → an unbounded ~1s respawn storm.
328
+ if (!this._everConnected) return;
329
+ if (this._closed) return; // deliberate close() — not an unexpected death
330
+ if (this._onCloseFired) return; // exit AND error can both fire; notify once
331
+ this._onCloseFired = true;
332
+ if (typeof this._onClose === 'function') {
333
+ try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
334
+ }
335
+ }
336
+
337
+ /** Reject + clear every pending waiter (used on close / exit / error). */
338
+ _rejectAllWaiters(err) {
339
+ if (this._waiters.size === 0) return;
340
+ for (const [, w] of this._waiters) {
341
+ try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
342
+ try { w.reject(err); } catch (_e) { /* ignore */ }
343
+ }
344
+ this._waiters.clear();
345
+ }
346
+
347
+ /**
348
+ * Send a JSON-RPC request with an id and await its matching response. Rejects on
349
+ * timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
350
+ * with the response's `result` (or rejects with its `error`).
351
+ */
352
+ _request(method, params) {
353
+ if (this._closed) {
354
+ return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
355
+ }
356
+ if (!this._child || !this._child.stdin) {
357
+ return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
358
+ }
359
+
360
+ const id = this._nextId++;
361
+ const payload = { jsonrpc: JSONRPC, id, method };
362
+ if (params !== undefined) payload.params = params;
363
+
364
+ return new Promise((resolve, reject) => {
365
+ const timer = setTimeout(() => {
366
+ this._waiters.delete(id);
367
+ reject(new Error(
368
+ `McpClient: timeout after ${this._requestTimeoutMs}ms waiting for "${method}". stderr so far:\n${this._stderr}`
369
+ ));
370
+ }, this._requestTimeoutMs);
371
+ // Don't let a pending timer keep the event loop (and the test process) alive.
372
+ if (timer && typeof timer.unref === 'function') timer.unref();
373
+
374
+ this._waiters.set(id, { resolve, reject, timer, method });
375
+
376
+ let framed;
377
+ try {
378
+ framed = frameRequest(payload);
379
+ } catch (err) {
380
+ this._waiters.delete(id);
381
+ clearTimeout(timer);
382
+ reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
383
+ return;
384
+ }
385
+
386
+ try {
387
+ this._child.stdin.write(framed, (err) => {
388
+ if (err) {
389
+ this._waiters.delete(id);
390
+ clearTimeout(timer);
391
+ reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
392
+ }
393
+ });
394
+ } catch (err) {
395
+ this._waiters.delete(id);
396
+ clearTimeout(timer);
397
+ reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
398
+ }
399
+ });
400
+ }
401
+
402
+ /** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
403
+ _notify(method, params) {
404
+ if (this._closed || !this._child || !this._child.stdin) return;
405
+ const payload = { jsonrpc: JSONRPC, method };
406
+ if (params !== undefined) payload.params = params;
407
+ let framed;
408
+ try {
409
+ framed = frameRequest(payload);
410
+ } catch (err) {
411
+ this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
412
+ return;
413
+ }
414
+ try {
415
+ this._child.stdin.write(framed);
416
+ } catch (err) {
417
+ this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
418
+ }
419
+ }
420
+
421
+ /** Buffer incoming stdout bytes and drain every complete message (dual framing). */
422
+ _onData(chunk) {
423
+ this._buf = Buffer.concat([this._buf, chunk]);
424
+ this._drain();
425
+ }
426
+
427
+ /**
428
+ * Drain as many complete messages as are buffered. Header (Content-Length) framing
429
+ * is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
430
+ * JSON. Mirrors server.js's dual-drain so the client reads either wire format.
431
+ */
432
+ _drain() {
433
+ for (;;) {
434
+ if (this._tryHeaderFramed()) continue;
435
+ if (this._tryLineFramed()) continue;
436
+ break;
437
+ }
438
+ }
439
+
440
+ /** Pull one Content-Length-framed message off the front of the buffer. */
441
+ _tryHeaderFramed() {
442
+ const headerEnd = this._buf.indexOf('\r\n\r\n');
443
+ if (headerEnd === -1) return false;
444
+ const header = this._buf.slice(0, headerEnd).toString('utf8');
445
+ const m = /Content-Length:\s*(\d+)/i.exec(header);
446
+ if (!m) return false; // a \r\n\r\n that isn't a Content-Length header → not this framing
447
+ const len = parseInt(m[1], 10);
448
+ const bodyStart = headerEnd + 4;
449
+ if (this._buf.length < bodyStart + len) return false; // body not all here yet
450
+ const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
451
+ this._buf = this._buf.slice(bodyStart + len);
452
+ this._parseAndDispatch(body);
453
+ return true;
454
+ }
455
+
456
+ /** Pull one newline-delimited JSON message off the front of the buffer. */
457
+ _tryLineFramed() {
458
+ const nl = this._buf.indexOf(0x0a); // '\n'
459
+ if (nl === -1) return false;
460
+ const line = this._buf.slice(0, nl).toString('utf8').trim();
461
+ this._buf = this._buf.slice(nl + 1);
462
+ if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
463
+ // A stray header line ("Content-Length: …") on the line path means the body hasn't
464
+ // arrived yet — but header framing is always tried first, so anything non-JSON here is
465
+ // a framing artifact; ignore it rather than erroring.
466
+ if (line[0] !== '{' && line[0] !== '[') return true;
467
+ this._parseAndDispatch(line);
468
+ return true;
469
+ }
470
+
471
+ /** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
472
+ _parseAndDispatch(text) {
473
+ let msg;
474
+ try {
475
+ msg = JSON.parse(text);
476
+ } catch (_e) {
477
+ this._logErr('dropping unparseable message from upstream');
478
+ return;
479
+ }
480
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
481
+
482
+ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
483
+ if (!hasId) return; // server-initiated notification — we don't consume these yet
484
+
485
+ const w = this._waiters.get(msg.id);
486
+ if (!w) return; // response to an id we no longer await (timed out / closed) — drop it
487
+ this._waiters.delete(msg.id);
488
+ try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
489
+
490
+ if (msg.error) {
491
+ const e = msg.error;
492
+ const message = e && e.message ? e.message : 'JSON-RPC error';
493
+ const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
494
+ w.reject(new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`));
495
+ return;
496
+ }
497
+ w.resolve(msg.result);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Frame a JSON-RPC message with LSP-style Content-Length headers (the format a real
503
+ * MCP server requires on its stdin). UTF-8 byte length is used so multi-byte payloads
504
+ * frame correctly.
505
+ * @param {object} payload
506
+ * @returns {string}
507
+ */
508
+ function frameRequest(payload) {
509
+ // MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited — NOT
510
+ // LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
511
+ // cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
512
+ // Content-Length here is what silently broke every real upstream (the read side still tolerates
513
+ // both framings for robustness against the occasional Content-Length server).
514
+ return JSON.stringify(payload) + '\n';
515
+ }
516
+
517
+ module.exports = {
518
+ McpClient,
519
+ // exported for unit tests / reuse
520
+ frameRequest,
521
+ winLaunch,
522
+ PROTOCOL_VERSION,
523
+ CLIENT_INFO,
524
+ };