bare-agent 0.54.0 → 0.54.1
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/bareagent.context.md +8 -3
- package/package.json +1 -1
- package/src/mcp-bridge.js +67 -9
package/bareagent.context.md
CHANGED
|
@@ -425,7 +425,7 @@ naming the opt-in — rather than silently ignoring it. Pass `confirmServer(name
|
|
|
425
425
|
def) => boolean` to approve each server **before its command is spawned** (return
|
|
426
426
|
`false` to skip it; a throw fails closed). When no `confirmServer` is set, the
|
|
427
427
|
bridge still trusts all *discovered* servers and prints a one-time warning naming
|
|
428
|
-
every command it is about to spawn — `confirmServer` is how you actually *gate* it.
|
|
428
|
+
every server and command it is about to spawn (an arg count only, never arg or env values) — `confirmServer` is how you actually *gate* it.
|
|
429
429
|
|
|
430
430
|
**RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
|
|
431
431
|
server that never answers can't hang the bridge or the loop. `opts.timeout`
|
|
@@ -435,8 +435,13 @@ timed-out tool call rejects with a labelled `ToolError`
|
|
|
435
435
|
(`MCP server "s" tool "t" timed out after Nms`, `context: { timeout: true, server, tool, timeoutMs, stderr }` —
|
|
436
436
|
stderr is in `context`, not the message; `wireGate`'s `onToolResult` forwards only the
|
|
437
437
|
non-stderr label fields to the audit row as an allow-listed `errorContext`) rather
|
|
438
|
-
than blocking forever; a
|
|
439
|
-
|
|
438
|
+
than blocking forever; a handshake timeout (`initialize`/`tools/list`) is labelled the same way
|
|
439
|
+
(`MCP server "s" "method" timed out after Nms`, `context: { timeout: true, server, method, timeoutMs, stderr }`);
|
|
440
|
+
a server that breaks its stdin pipe surfaces as a failed connection, never an uncaught `EPIPE`
|
|
441
|
+
crash; and a server whose `command` cannot start (ENOENT/EACCES, or a sync `spawn()` refusal such as an empty command or NUL byte) rejects with
|
|
442
|
+
`MCP server "s" failed to start: command "<cmd>" (<code>)`, `context: { spawnError: true, server, code }`
|
|
443
|
+
(`code` = Node error code if a string, else `'ESPAWN'`; command only with control chars escaped, never args/env) — `listServerTools` throws it, `allow` mode puts it in `errors` + `missing`,
|
|
444
|
+
bulk mode in `errors` — instead of crashing the host.
|
|
440
445
|
|
|
441
446
|
## Wiring with bareguard
|
|
442
447
|
|
package/package.json
CHANGED
package/src/mcp-bridge.js
CHANGED
|
@@ -231,6 +231,23 @@ function resolveEnv(env) {
|
|
|
231
231
|
|
|
232
232
|
// --- JSON-RPC stdio client ---
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Labelled spawn-failure error. Names the command ONLY (args/env can carry tokens); control
|
|
236
|
+
* characters in the command are escaped so a NUL/newline can't corrupt logs. `code` is the Node
|
|
237
|
+
* error code when it is a string, else 'ESPAWN'.
|
|
238
|
+
* @param {string} name
|
|
239
|
+
* @param {any} command
|
|
240
|
+
* @param {any} err
|
|
241
|
+
* @returns {ToolError}
|
|
242
|
+
*/
|
|
243
|
+
function spawnFailure(name, command, err) {
|
|
244
|
+
const code = typeof err?.code === 'string' ? err.code : 'ESPAWN';
|
|
245
|
+
const shown = String(command).replace(/[\u0000-\u001f\u007f]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
246
|
+
return new ToolError(`MCP server "${name}" failed to start: command "${shown}" (${code})`, {
|
|
247
|
+
context: { spawnError: true, server: name, code },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
234
251
|
/**
|
|
235
252
|
* @param {string} name
|
|
236
253
|
* @param {ServerDef} def
|
|
@@ -240,11 +257,28 @@ function createRpcClient(name, def) {
|
|
|
240
257
|
const { command, args = [], env, cwd } = def;
|
|
241
258
|
const mergedEnv = { ...process.env, ...resolveEnv(env) };
|
|
242
259
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
/** @type {import('node:child_process').ChildProcessWithoutNullStreams} */
|
|
261
|
+
let child;
|
|
262
|
+
try {
|
|
263
|
+
child = spawn(command, args, {
|
|
264
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
265
|
+
env: mergedEnv,
|
|
266
|
+
...(cwd && { cwd }),
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
// spawn() throws SYNCHRONOUSLY for some bad input (empty command, NUL byte, E2BIG), so no
|
|
270
|
+
// child 'error' event will ever fire. Same labelled shape as the async path; never the args.
|
|
271
|
+
// Dead client: rpc() rejects every call; notify() is a no-op; `child` is a minimal stand-in
|
|
272
|
+
// that killServer() skips (exitCode !== null) — the simplest shape that needs no null-guards
|
|
273
|
+
// in connectAndListTools / killServer / close().
|
|
274
|
+
const dead = spawnFailure(name, command, err);
|
|
275
|
+
return {
|
|
276
|
+
rpc: () => Promise.reject(dead),
|
|
277
|
+
notify: () => {},
|
|
278
|
+
child: /** @type {any} */ ({ exitCode: -1, pid: undefined }),
|
|
279
|
+
stderr: '',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
248
282
|
|
|
249
283
|
/** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void, timer: NodeJS.Timeout | null}>} */
|
|
250
284
|
const pending = new Map();
|
|
@@ -297,9 +331,25 @@ function createRpcClient(name, def) {
|
|
|
297
331
|
// and rpc()/notify() guard writability before writing.
|
|
298
332
|
child.stdin.on('error', () => { /* child gone; surfaced via close + write guards */ });
|
|
299
333
|
|
|
334
|
+
// Spawn failure (ENOENT/EACCES/...): with NO 'error' listener Node re-throws it as an uncaught
|
|
335
|
+
// exception and crashes the HOST. Record it once; it WINS over any later pipe/close error and
|
|
336
|
+
// rejects every pending and future rpc. Names the command ONLY (args/env can carry tokens).
|
|
337
|
+
/** @type {ToolError | null} */
|
|
338
|
+
let spawnErr = null;
|
|
339
|
+
child.on('error', (err) => {
|
|
340
|
+
// Only a child that never got a pid is a spawn failure. Node also emits 'error' for a failed
|
|
341
|
+
// kill() or IPC send on a LIVE child; keep the listener (so it can't crash the host) but do
|
|
342
|
+
// not mark the server failed — a live server's lifecycle belongs to the 'close' handler.
|
|
343
|
+
if (child.pid !== undefined || spawnErr) return;
|
|
344
|
+
spawnErr = spawnFailure(name, command, err);
|
|
345
|
+
for (const id of [...pending.keys()]) settle(id, false, spawnErr);
|
|
346
|
+
});
|
|
347
|
+
|
|
300
348
|
child.on('close', (code) => {
|
|
301
349
|
for (const id of [...pending.keys()]) {
|
|
302
|
-
|
|
350
|
+
// Belt-and-braces: on Node 22 the observed order is error -> EPIPE -> close, so the 'error'
|
|
351
|
+
// handler has already settled these ids; `spawnErr ||` guards a different ordering elsewhere.
|
|
352
|
+
settle(id, false, spawnErr || new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
|
|
303
353
|
}
|
|
304
354
|
});
|
|
305
355
|
|
|
@@ -318,6 +368,7 @@ function createRpcClient(name, def) {
|
|
|
318
368
|
function rpc(method, params = {}, timeoutMs = 0, label) {
|
|
319
369
|
const id = nextId++;
|
|
320
370
|
return new Promise((resolve, reject) => {
|
|
371
|
+
if (spawnErr) return reject(spawnErr);
|
|
321
372
|
if (!child.stdin.writable) {
|
|
322
373
|
return reject(new ToolError(`MCP server "${name}" stdin is not writable (process exited or pipe closed). stderr: ${stderrBuf.slice(-500)}`));
|
|
323
374
|
}
|
|
@@ -331,7 +382,10 @@ function createRpcClient(name, def) {
|
|
|
331
382
|
{ context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
|
|
332
383
|
));
|
|
333
384
|
} else {
|
|
334
|
-
settle(id, false, new ToolError(
|
|
385
|
+
settle(id, false, new ToolError(
|
|
386
|
+
`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms`,
|
|
387
|
+
{ context: { timeout: true, server: name, method, timeoutMs, stderr: stderrBuf.slice(-500) } },
|
|
388
|
+
));
|
|
335
389
|
}
|
|
336
390
|
}, timeoutMs);
|
|
337
391
|
timer.unref?.();
|
|
@@ -340,7 +394,10 @@ function createRpcClient(name, def) {
|
|
|
340
394
|
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
341
395
|
child.stdin.write(msg, (err) => {
|
|
342
396
|
// settle() no-ops if 'close'/timeout already settled this id.
|
|
343
|
-
|
|
397
|
+
// Belt-and-braces (Node 22 order is error -> EPIPE -> close, already settled by 'error').
|
|
398
|
+
// Deferred a tick: a failed spawn's EPIPE/destroyed-stream callback can land before the
|
|
399
|
+
// 'error' event; the recorded spawn error must win, so re-check after it has had its turn.
|
|
400
|
+
if (err) setImmediate(() => settle(id, false, spawnErr || new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`)));
|
|
344
401
|
});
|
|
345
402
|
});
|
|
346
403
|
}
|
|
@@ -724,7 +781,8 @@ function makeUnvettedWarner(confirmServer) {
|
|
|
724
781
|
return (specs) => {
|
|
725
782
|
if (confirmServer || warned || specs.length === 0) return;
|
|
726
783
|
warned = true;
|
|
727
|
-
|
|
784
|
+
// Server name + command ONLY: args/env can carry tokens and stderr lands in logs. A count only.
|
|
785
|
+
const cmds = specs.map(s => `${s.name} → ${s.command}${s.args && s.args.length ? ` (${s.args.length} args)` : ''}`);
|
|
728
786
|
console.warn(
|
|
729
787
|
`[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
|
|
730
788
|
cmds.join('\n ') +
|