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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- 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
|
+
};
|