bare-agent 0.52.0 → 0.53.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/bareagent.context.md +12 -3
- package/package.json +1 -1
- package/primitives.json +4 -4
- package/src/bareguard-adapter.d.ts +4 -0
- package/src/bareguard-adapter.js +38 -0
- package/src/mcp-bridge.d.ts +29 -6
- package/src/mcp-bridge.js +192 -28
package/bareagent.context.md
CHANGED
|
@@ -72,7 +72,7 @@ Eight entry points:
|
|
|
72
72
|
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
|
|
73
73
|
| Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
|
|
74
74
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
75
|
-
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
75
|
+
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` (or per-run `{ allow }`) |
|
|
76
76
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
77
77
|
| Route policy decisions per user / tenant / chat | `wireGate(gate).policy` + `loop.run(msgs, tools, { ctx })` (ctx routes to bareguard's check via `_ctx`) |
|
|
78
78
|
| Cap total USD spend per run | `new Gate({ budget: { maxCostUsd: 0.50 } })` |
|
|
@@ -431,7 +431,10 @@ every command it is about to spawn — `confirmServer` is how you actually *gate
|
|
|
431
431
|
server that never answers can't hang the bridge or the loop. `opts.timeout`
|
|
432
432
|
(default 15 s) bounds the handshake (`initialize` + `tools/list`);
|
|
433
433
|
`opts.callTimeout` (default 120 s, `0` disables) bounds each `tools/call`. A
|
|
434
|
-
timed-out tool call rejects with a
|
|
434
|
+
timed-out tool call rejects with a labelled `ToolError`
|
|
435
|
+
(`MCP server "s" tool "t" timed out after Nms`, `context: { timeout: true, server, tool, timeoutMs, stderr }` —
|
|
436
|
+
stderr is in `context`, not the message; `wireGate`'s `onToolResult` forwards only the
|
|
437
|
+
non-stderr label fields to the audit row as an allow-listed `errorContext`) rather
|
|
435
438
|
than blocking forever; a server that breaks its stdin pipe surfaces as a
|
|
436
439
|
failed connection, never an uncaught `EPIPE` crash.
|
|
437
440
|
|
|
@@ -477,6 +480,8 @@ if (result.error?.startsWith('halt:')) {
|
|
|
477
480
|
|
|
478
481
|
**Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). It also fires for the out-of-band `ctx.summarize` call (R-C6) tagged `kind:'summarize'`; main-loop rounds carry `kind:'turn'` — so summary-window tokens count against the budget too, and a consumer can tell the two apart. `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
|
|
479
482
|
|
|
483
|
+
`onToolResult` on a tool ERROR records `{error, durationMs}` plus an allow-listed `errorContext` copied from `error.context` — `timeout`/`denied` (boolean), `server`/`tool` (string, ≤200 chars), `timeoutMs` (finite number); wrong-typed values are dropped and `stderr`/everything else is never forwarded (the gate writes the record verbatim into the audit row). The key is absent when no allowed field is present.
|
|
484
|
+
|
|
480
485
|
Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
|
|
481
486
|
|
|
482
487
|
**A bound that fires PRESERVES the model's work (BA-5, v0.27+).** Every terminating path — governance halt, the deny-spin short-circuit, a provider error under `throwOnError:false`, `loop.stop()`, and the internal hard round limit — returns the **last non-empty assistant text** in `result.text` instead of `''`. If you drive bare-agent from an outer retry loop (`while not-done and under-cap: run the worker`), a bound firing is **normal termination**, not an exception, and that text is your only channel from attempt N to attempt N+1 — before this, a bounded attempt taught its successor nothing. Two rules: **`error` is the sole success signal** (a non-empty `text` never means the run converged — always branch on `error`), and `text` is `''` when the model genuinely produced none (no placeholder is invented, so you can trust emptiness). Under `recurse` this is what finally populates `best` on an incomplete node. Relatedly, **`loop.stop()` now returns `error: null`** — a deliberate stop is not a fault; it previously fell through to the hard-round-limit return and reported that safety warning as its error, indistinguishable from a runaway.
|
|
@@ -1508,7 +1513,7 @@ const { createMCPBridge } = require('bare-agent/mcp');
|
|
|
1508
1513
|
const provider = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' });
|
|
1509
1514
|
|
|
1510
1515
|
const bridge = await createMCPBridge();
|
|
1511
|
-
// bridge = { tools, servers, denied, systemContext, errors, close }
|
|
1516
|
+
// bridge = { tools, servers, denied, systemContext, errors, close } (+ missing when `allow` is given)
|
|
1512
1517
|
|
|
1513
1518
|
const loop = new Loop({
|
|
1514
1519
|
provider,
|
|
@@ -1528,6 +1533,8 @@ try {
|
|
|
1528
1533
|
|
|
1529
1534
|
**Governance via `.mcp-bridge.json`.** On first run, the bridge writes `.mcp-bridge.json` in the cwd listing every discovered server and tool with permission `"allow"`. Edit any entry to `"deny"` and the tool is dropped from the next run's tool array; the LLM sees it listed in `systemContext` as restricted, with instructions not to retry it. Re-discovery happens automatically after TTL expiry (default `24h`, settable via `ttl` field in the file).
|
|
1530
1535
|
|
|
1536
|
+
**Signed per-run allow-list (`allow`).** `createMCPBridge({ allow: { server: ['raw_tool_name', ...] } })` skips `.mcp-bridge.json` entirely (never read or written — no cache, no TTL; `bridgePath`/`refresh`/`servers` ignored), takes server definitions only from `discoverServers(configPaths)`, spawns only the named servers (still `confirmServer`-vetted), connects once and exposes signed ∩ listed. Everything else is absent from `tools`, the `metaTools` catalog and `systemContext` (no "Restricted tools" text, `denied: []`), nothing goes to stdout. The return gains `missing: [{server, tool}]` (signed tools the server did not list, or whose server was not found/failed/rejected) — never a throw. `mcp_invoke` of a listed-but-unsigned name throws `ToolError` with `context.denied === true`. Bad `allow` shape (including an empty tool list) throws `ValidationError`. In every mode (not only `allow`), a server whose `tools/list` reply has no `tools` array (e.g. `{}`) now fails its connect — it lands in `errors`, its child is killed — where it used to read as a working empty server.
|
|
1537
|
+
|
|
1531
1538
|
```json
|
|
1532
1539
|
{
|
|
1533
1540
|
"discovered": "2026-04-13T12:00:00.000Z",
|
|
@@ -1596,6 +1603,8 @@ MCP tools arrive with the server name prepended (`beeperbox_send_message`, not `
|
|
|
1596
1603
|
| `servers` | all discovered | Limit to a subset by name |
|
|
1597
1604
|
| `timeout` | `15000` | Per-server init timeout in ms |
|
|
1598
1605
|
| `refresh` | `false` | Force re-discovery regardless of TTL |
|
|
1606
|
+
| `allow` | none | Signed per-run allow-list `{ server: [rawToolNames] }` — in-memory, no `.mcp-bridge.json`; see "Signed per-run allow-list" above |
|
|
1607
|
+
| `callTimeout` | `120000` | Per-`tools/call` timeout in ms (`0` disables) |
|
|
1599
1608
|
|
|
1600
1609
|
### Recipe 10: beeperbox — multi-messenger reach via MCP bridge
|
|
1601
1610
|
|
package/package.json
CHANGED
package/primitives.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"category": "mcp",
|
|
34
34
|
"when": "you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation",
|
|
35
35
|
"import": "import { buildMetaTools } from 'bare-agent/mcp'",
|
|
36
|
-
"signature": "buildMetaTools(tools: ToolDef[], discoveredAt?: string) => ToolDef[]",
|
|
36
|
+
"signature": "buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded?: Map<string, {server: string, tool: string}>) => ToolDef[]",
|
|
37
37
|
"fails": "returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).",
|
|
38
38
|
"example": "const metaTools = buildMetaTools(tools);"
|
|
39
39
|
},
|
|
@@ -121,10 +121,10 @@
|
|
|
121
121
|
{
|
|
122
122
|
"name": "createMCPBridge",
|
|
123
123
|
"category": "mcp",
|
|
124
|
-
"when": "you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution",
|
|
124
|
+
"when": "you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools",
|
|
125
125
|
"import": "import { createMCPBridge } from 'bare-agent/mcp'",
|
|
126
|
-
"signature": "createMCPBridge(opts?: object) => Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>",
|
|
127
|
-
"fails": "a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.",
|
|
126
|
+
"signature": "createMCPBridge(opts?: object) => Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>",
|
|
127
|
+
"fails": "a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.",
|
|
128
128
|
"example": "const { tools, close } = await createMCPBridge();\nconst loop = new Loop({ provider, tools });"
|
|
129
129
|
},
|
|
130
130
|
{
|
|
@@ -130,6 +130,10 @@ export type Annotation = {
|
|
|
130
130
|
* (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
|
|
131
131
|
* activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
|
|
132
132
|
* when unset — see CHANGELOG) — override when a different shape is needed.
|
|
133
|
+
* `onToolResult` on a tool error records `{error: message, durationMs}` plus, only when present, an
|
|
134
|
+
* allow-listed `errorContext` copied from `error.context` (`timeout`/`denied` booleans, `server`/`tool`
|
|
135
|
+
* strings capped at 200 chars, finite `timeoutMs`; wrong-typed values dropped) — so an MCP timeout is
|
|
136
|
+
* distinguishable in the audit row. `stderr` and every other context field are NEVER forwarded.
|
|
133
137
|
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
134
138
|
* @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
|
|
135
139
|
* @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
|
package/src/bareguard-adapter.js
CHANGED
|
@@ -41,6 +41,38 @@ function safeStringify(value) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Keys of a tool error's `context` that onToolResult may forward to gate.record (and so into the
|
|
45
|
+
// audit row), with the exact type each must have. ALLOW-LIST, never the whole object: MCP timeout
|
|
46
|
+
// errors carry `stderr` (server output, possibly secret-bearing) in `context`, and gate.record writes
|
|
47
|
+
// its result verbatim to the audit log. Anything not listed here, or of the wrong type, is dropped.
|
|
48
|
+
const ERROR_CONTEXT_KEYS = /** @type {const} */ ({
|
|
49
|
+
timeout: 'boolean', denied: 'boolean', server: 'string', tool: 'string', timeoutMs: 'number',
|
|
50
|
+
});
|
|
51
|
+
const ERROR_CONTEXT_MAX_STR = 200;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {any} error
|
|
55
|
+
* @returns {Record<string, boolean|string|number>|undefined} undefined when no allowed key is present
|
|
56
|
+
*/
|
|
57
|
+
function pickErrorContext(error) {
|
|
58
|
+
/** @type {Record<string, boolean|string|number>} */
|
|
59
|
+
const out = {};
|
|
60
|
+
try {
|
|
61
|
+
const ctx = error?.context;
|
|
62
|
+
if (ctx === null || typeof ctx !== 'object') return undefined;
|
|
63
|
+
for (const [key, type] of Object.entries(ERROR_CONTEXT_KEYS)) {
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(ctx, key)) continue;
|
|
65
|
+
const v = ctx[key];
|
|
66
|
+
if (typeof v !== type) continue;
|
|
67
|
+
if (type === 'number' && !Number.isFinite(v)) continue;
|
|
68
|
+
out[key] = type === 'string' ? v.slice(0, ERROR_CONTEXT_MAX_STR) : v;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined; // a throwing getter / exotic context must never break the audit write
|
|
72
|
+
}
|
|
73
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
// Module-scope so a process that spawns many child agents (each with its own
|
|
45
77
|
// wireGate call) only prints the wrapTool deprecation warning once.
|
|
46
78
|
let warnedWrap = false;
|
|
@@ -98,6 +130,10 @@ let warnedWrap = false;
|
|
|
98
130
|
* (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
|
|
99
131
|
* activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
|
|
100
132
|
* when unset — see CHANGELOG) — override when a different shape is needed.
|
|
133
|
+
* `onToolResult` on a tool error records `{error: message, durationMs}` plus, only when present, an
|
|
134
|
+
* allow-listed `errorContext` copied from `error.context` (`timeout`/`denied` booleans, `server`/`tool`
|
|
135
|
+
* strings capped at 200 chars, finite `timeoutMs`; wrong-typed values dropped) — so an MCP timeout is
|
|
136
|
+
* distinguishable in the audit row. `stderr` and every other context field are NEVER forwarded.
|
|
101
137
|
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
102
138
|
* @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
|
|
103
139
|
* @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
|
|
@@ -201,8 +237,10 @@ function wireGate(gate, options = {}) {
|
|
|
201
237
|
const onToolResult = async ({ name, args, result, error, durationMs, ctx }) => {
|
|
202
238
|
const action = translate(name, args, ctx);
|
|
203
239
|
if (error) {
|
|
240
|
+
const errorContext = pickErrorContext(error);
|
|
204
241
|
await gate.record(action, {
|
|
205
242
|
error: error?.message || String(error),
|
|
243
|
+
...(errorContext && { errorContext }),
|
|
206
244
|
durationMs: durationMs ?? null,
|
|
207
245
|
});
|
|
208
246
|
} else {
|
package/src/mcp-bridge.d.ts
CHANGED
|
@@ -52,7 +52,10 @@ export type DeniedTool = {
|
|
|
52
52
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
53
53
|
*/
|
|
54
54
|
export type RpcClient = {
|
|
55
|
-
rpc: (method: string, params?: object, timeoutMs?: number
|
|
55
|
+
rpc: (method: string, params?: object, timeoutMs?: number, label?: {
|
|
56
|
+
server: string;
|
|
57
|
+
tool: string;
|
|
58
|
+
}) => Promise<any>;
|
|
56
59
|
notify: (method: string, params?: object) => void;
|
|
57
60
|
child: import("node:child_process").ChildProcessWithoutNullStreams;
|
|
58
61
|
stderr: string;
|
|
@@ -79,7 +82,16 @@ export type RpcClient = {
|
|
|
79
82
|
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
80
83
|
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
81
84
|
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
82
|
-
* @param {string[]} [opts.
|
|
85
|
+
* @param {Record<string, string[]>} [opts.allow] - Signed allow-list `{ [serverName]: rawMcpToolNames[] }`
|
|
86
|
+
* (raw MCP names, NOT the `server_tool` prefixed form; each list must be non-empty). Switches to an IN-MEMORY mode: never reads or
|
|
87
|
+
* writes `.mcp-bridge.json` (no cache, no TTL; `bridgePath`/`refresh`/`servers` are ignored); server
|
|
88
|
+
* definitions come only from `discoverServers(configPaths)`; only servers named here are spawned (still
|
|
89
|
+
* vetted by `confirmServer`); each connects ONCE and exposes signed ∩ listed — every other tool is ABSENT
|
|
90
|
+
* (not in `tools`, `metaTools` catalog or `systemContext`). Signed tools the server does not list (or whose
|
|
91
|
+
* server was not found / failed / was rejected) come back in `missing`. `denied` is always `[]`, nothing is
|
|
92
|
+
* written to stdout, and `mcp_invoke` on a listed-but-unsigned name throws `context.denied === true`.
|
|
93
|
+
* Invalid shape throws `ValidationError` (type only, never values).
|
|
94
|
+
* @param {string[]} [opts.servers] - Limit to these server names (ignored when `allow` is given).
|
|
83
95
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
84
96
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
85
97
|
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
@@ -91,9 +103,9 @@ export type RpcClient = {
|
|
|
91
103
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
92
104
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
93
105
|
* since each command is then vetted regardless of source.
|
|
94
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>}
|
|
95
|
-
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution
|
|
96
|
-
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.
|
|
106
|
+
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>}
|
|
107
|
+
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools
|
|
108
|
+
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.
|
|
97
109
|
* @example
|
|
98
110
|
* const { tools, close } = await createMCPBridge();
|
|
99
111
|
* const loop = new Loop({ provider, tools });
|
|
@@ -102,6 +114,7 @@ export function createMCPBridge(opts?: {
|
|
|
102
114
|
bridgePath?: string | undefined;
|
|
103
115
|
configPaths?: string[] | undefined;
|
|
104
116
|
includeProjectConfig?: boolean | undefined;
|
|
117
|
+
allow?: Record<string, string[]> | undefined;
|
|
105
118
|
servers?: string[] | undefined;
|
|
106
119
|
timeout?: number | undefined;
|
|
107
120
|
callTimeout?: number | undefined;
|
|
@@ -117,6 +130,10 @@ export function createMCPBridge(opts?: {
|
|
|
117
130
|
server: string;
|
|
118
131
|
error: string;
|
|
119
132
|
}>;
|
|
133
|
+
missing?: Array<{
|
|
134
|
+
server: string;
|
|
135
|
+
tool: string;
|
|
136
|
+
}>;
|
|
120
137
|
close: Function;
|
|
121
138
|
}>;
|
|
122
139
|
/**
|
|
@@ -150,10 +167,16 @@ export function discoverServers(configPaths?: string[], { includeProjectConfig }
|
|
|
150
167
|
*
|
|
151
168
|
* @param {ToolDef[]} tools - The bulk-loaded, name-prefixed tools array.
|
|
152
169
|
* @param {string} [discoveredAt] - ISO timestamp from .mcp-bridge.json.
|
|
170
|
+
* @param {Map<string, {server: string, tool: string}>} [excluded] - Allow mode only: names the server
|
|
171
|
+
* LISTS but the signed allow-list excludes. mcp_invoke refuses them with `context.denied === true`;
|
|
172
|
+
* they never appear in the catalog. Omitted ⇒ unchanged behaviour.
|
|
153
173
|
* @returns {ToolDef[]} [mcp_discover, mcp_invoke]
|
|
154
174
|
* @when you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation
|
|
155
175
|
* @fails returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).
|
|
156
176
|
* @example
|
|
157
177
|
* const metaTools = buildMetaTools(tools);
|
|
158
178
|
*/
|
|
159
|
-
export function buildMetaTools(tools: ToolDef[], discoveredAt?: string
|
|
179
|
+
export function buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded?: Map<string, {
|
|
180
|
+
server: string;
|
|
181
|
+
tool: string;
|
|
182
|
+
}>): ToolDef[];
|
package/src/mcp-bridge.js
CHANGED
|
@@ -4,7 +4,7 @@ const { spawn } = require('node:child_process');
|
|
|
4
4
|
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
|
|
5
5
|
const { join } = require('node:path');
|
|
6
6
|
const { homedir } = require('node:os');
|
|
7
|
-
const { ToolError } = require('./errors');
|
|
7
|
+
const { ToolError, ValidationError } = require('./errors');
|
|
8
8
|
|
|
9
9
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
10
10
|
|
|
@@ -54,7 +54,7 @@ const { ToolError } = require('./errors');
|
|
|
54
54
|
/**
|
|
55
55
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
56
56
|
* @typedef {object} RpcClient
|
|
57
|
-
* @property {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
|
|
57
|
+
* @property {(method: string, params?: object, timeoutMs?: number, label?: {server: string, tool: string}) => Promise<any>} rpc
|
|
58
58
|
* @property {(method: string, params?: object) => void} notify
|
|
59
59
|
* @property {import('node:child_process').ChildProcessWithoutNullStreams} child
|
|
60
60
|
* @property {string} stderr
|
|
@@ -309,9 +309,11 @@ function createRpcClient(name, def) {
|
|
|
309
309
|
* @param {string} method
|
|
310
310
|
* @param {object} [params]
|
|
311
311
|
* @param {number} [timeoutMs=0] - Reject if no response arrives within this many ms (0 = no limit).
|
|
312
|
+
* @param {{server: string, tool: string}} [label] - tools/call only: when given, a timeout throws a
|
|
313
|
+
* labelled ToolError (`context: {timeout, server, tool, timeoutMs, stderr}`, stderr kept OUT of the message).
|
|
312
314
|
* @returns {Promise<any>}
|
|
313
315
|
*/
|
|
314
|
-
function rpc(method, params = {}, timeoutMs = 0) {
|
|
316
|
+
function rpc(method, params = {}, timeoutMs = 0, label) {
|
|
315
317
|
const id = nextId++;
|
|
316
318
|
return new Promise((resolve, reject) => {
|
|
317
319
|
if (!child.stdin.writable) {
|
|
@@ -321,7 +323,14 @@ function createRpcClient(name, def) {
|
|
|
321
323
|
let timer = null;
|
|
322
324
|
if (timeoutMs > 0) {
|
|
323
325
|
timer = setTimeout(() => {
|
|
324
|
-
|
|
326
|
+
if (label) {
|
|
327
|
+
settle(id, false, new ToolError(
|
|
328
|
+
`MCP server "${label.server}" tool "${label.tool}" timed out after ${timeoutMs}ms`,
|
|
329
|
+
{ context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
|
|
330
|
+
));
|
|
331
|
+
} else {
|
|
332
|
+
settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
|
|
333
|
+
}
|
|
325
334
|
}, timeoutMs);
|
|
326
335
|
timer.unref?.();
|
|
327
336
|
}
|
|
@@ -367,7 +376,7 @@ function unwrapContent(content) {
|
|
|
367
376
|
/**
|
|
368
377
|
* @param {string} serverName
|
|
369
378
|
* @param {McpTool[]} mcpTools
|
|
370
|
-
* @param {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
|
|
379
|
+
* @param {(method: string, params?: object, timeoutMs?: number, label?: {server: string, tool: string}) => Promise<any>} rpc
|
|
371
380
|
* @param {number} [callTimeout=0] - Per-invocation timeout (ms) for tools/call; 0 = no limit.
|
|
372
381
|
* @returns {ToolDef[]}
|
|
373
382
|
*/
|
|
@@ -377,7 +386,7 @@ function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
|
|
|
377
386
|
description: t.description || '',
|
|
378
387
|
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
379
388
|
execute: async (args) => {
|
|
380
|
-
const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout);
|
|
389
|
+
const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout, { server: serverName, tool: t.name });
|
|
381
390
|
if (result.isError) {
|
|
382
391
|
throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
|
|
383
392
|
context: { server: serverName, tool: t.name },
|
|
@@ -451,7 +460,14 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
451
460
|
}, timeout);
|
|
452
461
|
client.notify('notifications/initialized');
|
|
453
462
|
|
|
454
|
-
const
|
|
463
|
+
const listed = await client.rpc('tools/list', {}, timeout);
|
|
464
|
+
// A reply without a `tools` array is a protocol-violating server, not an empty one: fail the
|
|
465
|
+
// connect HERE (inside the try, so the child is reaped) rather than let every caller's
|
|
466
|
+
// `.map`/`.filter` throw after the child is already running but before it is tracked.
|
|
467
|
+
const mcpTools = listed && listed.tools;
|
|
468
|
+
if (!Array.isArray(mcpTools)) {
|
|
469
|
+
throw new Error(`MCP server "${name}" returned a malformed tools/list reply (no tools array)`);
|
|
470
|
+
}
|
|
455
471
|
|
|
456
472
|
return { mcpTools, client };
|
|
457
473
|
} catch (err) {
|
|
@@ -470,9 +486,10 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
470
486
|
* @param {string[]} servers
|
|
471
487
|
* @param {ToolDef[]} tools
|
|
472
488
|
* @param {DeniedTool[]} denied
|
|
489
|
+
* @param {boolean} [allowMode] - Signed allow-list mode: say nothing about restricted/other tools or the cache file.
|
|
473
490
|
* @returns {string}
|
|
474
491
|
*/
|
|
475
|
-
function buildSystemContext(servers, tools, denied) {
|
|
492
|
+
function buildSystemContext(servers, tools, denied, allowMode = false) {
|
|
476
493
|
/** @type {string[]} */
|
|
477
494
|
const lines = [];
|
|
478
495
|
lines.push(`MCP Bridge: ${tools.length} tools available from ${servers.length} server(s): ${servers.join(', ')}.`);
|
|
@@ -489,6 +506,8 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
489
506
|
lines.push(` ${server}: ${toolNames.join(', ')}`);
|
|
490
507
|
}
|
|
491
508
|
|
|
509
|
+
if (allowMode) return lines.join('\n');
|
|
510
|
+
|
|
492
511
|
if (denied.length > 0) {
|
|
493
512
|
lines.push(`Restricted tools (${denied.length}, not available to you):`);
|
|
494
513
|
for (const d of denied) {
|
|
@@ -526,13 +545,16 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
526
545
|
*
|
|
527
546
|
* @param {ToolDef[]} tools - The bulk-loaded, name-prefixed tools array.
|
|
528
547
|
* @param {string} [discoveredAt] - ISO timestamp from .mcp-bridge.json.
|
|
548
|
+
* @param {Map<string, {server: string, tool: string}>} [excluded] - Allow mode only: names the server
|
|
549
|
+
* LISTS but the signed allow-list excludes. mcp_invoke refuses them with `context.denied === true`;
|
|
550
|
+
* they never appear in the catalog. Omitted ⇒ unchanged behaviour.
|
|
529
551
|
* @returns {ToolDef[]} [mcp_discover, mcp_invoke]
|
|
530
552
|
* @when you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation
|
|
531
553
|
* @fails returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).
|
|
532
554
|
* @example
|
|
533
555
|
* const metaTools = buildMetaTools(tools);
|
|
534
556
|
*/
|
|
535
|
-
function buildMetaTools(tools, discoveredAt) {
|
|
557
|
+
function buildMetaTools(tools, discoveredAt, excluded) {
|
|
536
558
|
// Catalog descriptors: same info the LLM would see for bulk-loaded tools,
|
|
537
559
|
// but exposed via mcp_discover instead of taking up tool-array slots upfront.
|
|
538
560
|
const catalog = tools.map(t => {
|
|
@@ -596,6 +618,12 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
596
618
|
},
|
|
597
619
|
execute: async (/** @type {{ name: string, args?: object }} */ { name, args }) => {
|
|
598
620
|
const tool = byName.get(name);
|
|
621
|
+
const ex = !tool && excluded ? excluded.get(name) : undefined;
|
|
622
|
+
if (ex) {
|
|
623
|
+
throw new ToolError(`mcp_invoke: "${name}" is not on this run's allowed list`, {
|
|
624
|
+
context: { denied: true, server: ex.server, tool: ex.tool },
|
|
625
|
+
});
|
|
626
|
+
}
|
|
599
627
|
if (!tool) {
|
|
600
628
|
throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
|
|
601
629
|
context: { name, knownNames: [...byName.keys()] },
|
|
@@ -608,6 +636,118 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
608
636
|
return [mcpDiscover, mcpInvoke];
|
|
609
637
|
}
|
|
610
638
|
|
|
639
|
+
// --- Signed allow-list mode (opts.allow) ---
|
|
640
|
+
|
|
641
|
+
/** @param {any} v @returns {string} */
|
|
642
|
+
const typeName = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Validate `opts.allow`. Messages name the TYPE only — never echo a value.
|
|
646
|
+
* @param {any} allow
|
|
647
|
+
* @returns {Map<string, string[]>} server name -> de-duplicated signed raw tool names
|
|
648
|
+
*/
|
|
649
|
+
function validateAllow(allow) {
|
|
650
|
+
const bad = (/** @type {string} */ msg) =>
|
|
651
|
+
new ValidationError(`[MCP Bridge] ${msg}`, { context: { lib: 'bare-agent' } });
|
|
652
|
+
const proto = allow !== null && typeof allow === 'object' ? Object.getPrototypeOf(allow) : undefined;
|
|
653
|
+
if (typeof allow !== 'object' || allow === null || Array.isArray(allow) || (proto !== Object.prototype && proto !== null)) {
|
|
654
|
+
throw bad(`allow must be a plain object of { serverName: string[] } (got ${typeName(allow)})`);
|
|
655
|
+
}
|
|
656
|
+
const entries = Object.entries(allow);
|
|
657
|
+
if (entries.length === 0) throw bad('allow must name at least one server (got an empty object)');
|
|
658
|
+
/** @type {Map<string, string[]>} */
|
|
659
|
+
const out = new Map();
|
|
660
|
+
for (const [server, list] of entries) {
|
|
661
|
+
if (!Array.isArray(list)) throw bad(`allow values must be arrays of tool names (got ${typeName(list)})`);
|
|
662
|
+
if (list.length === 0) throw bad('allow values must be non-empty arrays of tool names (got an empty array)');
|
|
663
|
+
for (const t of list) {
|
|
664
|
+
if (typeof t !== 'string' || t === '') {
|
|
665
|
+
throw bad(`allow tool names must be non-empty strings (got ${t === '' ? 'empty string' : typeName(t)})`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
out.set(server, [...new Set(list)]);
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* allow-mode body of createMCPBridge: in memory only (no .mcp-bridge.json read or write, no TTL),
|
|
675
|
+
* server definitions come ONLY from discoverServers(), one connect per signed server, expose
|
|
676
|
+
* signed ∩ listed, silent on stdout, and the model is never told other tools exist.
|
|
677
|
+
* @param {Map<string, string[]>} allow
|
|
678
|
+
* @param {object} o
|
|
679
|
+
* @param {string[]} [o.configPaths]
|
|
680
|
+
* @param {boolean} o.includeProjectConfig
|
|
681
|
+
* @param {number} o.timeout
|
|
682
|
+
* @param {number} o.callTimeout
|
|
683
|
+
* @param {(name: string, def: ServerDef) => Promise<boolean>} o.vetServer
|
|
684
|
+
* @param {(specs: Array<{name: string, command: string, args?: string[]}>) => void} o.warnUnvettedSpawn
|
|
685
|
+
*/
|
|
686
|
+
async function createAllowBridge(allow, o) {
|
|
687
|
+
const discovered = discoverServers(o.configPaths, { includeProjectConfig: o.includeProjectConfig });
|
|
688
|
+
/** @type {Array<[string, ServerDef]>} */
|
|
689
|
+
const toConnect = [];
|
|
690
|
+
/** @type {Array<{server: string, error: string}>} */
|
|
691
|
+
const errors = [];
|
|
692
|
+
for (const name of allow.keys()) {
|
|
693
|
+
const def = discovered.get(name);
|
|
694
|
+
if (def) toConnect.push([name, def]);
|
|
695
|
+
else errors.push({ server: name, error: 'server not found in discovered MCP configs' });
|
|
696
|
+
}
|
|
697
|
+
o.warnUnvettedSpawn(toConnect.map(([name, def]) => ({ name, command: def.command, args: def.args })));
|
|
698
|
+
|
|
699
|
+
/** @type {ToolDef[]} */
|
|
700
|
+
const tools = [];
|
|
701
|
+
/** @type {import('node:child_process').ChildProcess[]} */
|
|
702
|
+
const children = [];
|
|
703
|
+
/** @type {string[]} */
|
|
704
|
+
const connected = [];
|
|
705
|
+
/** @type {Array<{server: string, tool: string}>} */
|
|
706
|
+
const missing = [];
|
|
707
|
+
/** @type {Map<string, {server: string, tool: string}>} */
|
|
708
|
+
const excluded = new Map();
|
|
709
|
+
/** @type {Set<string>} */
|
|
710
|
+
const listedOk = new Set();
|
|
711
|
+
|
|
712
|
+
await Promise.all(toConnect.map(async ([name, def]) => {
|
|
713
|
+
try {
|
|
714
|
+
if (!(await o.vetServer(name, def))) return;
|
|
715
|
+
const { mcpTools, client } = await connectAndListTools(name, def, o.timeout);
|
|
716
|
+
// Track the child BEFORE any processing so close() reaps it even if what follows throws.
|
|
717
|
+
children.push(client.child);
|
|
718
|
+
const signed = new Set(allow.get(name));
|
|
719
|
+
const listed = new Set(mcpTools.map(t => t.name));
|
|
720
|
+
tools.push(...wrapTools(name, mcpTools.filter(t => signed.has(t.name)), client.rpc, o.callTimeout));
|
|
721
|
+
for (const t of mcpTools) {
|
|
722
|
+
if (!signed.has(t.name)) excluded.set(`${name}_${t.name}`, { server: name, tool: t.name });
|
|
723
|
+
}
|
|
724
|
+
for (const t of signed) if (!listed.has(t)) missing.push({ server: name, tool: t });
|
|
725
|
+
connected.push(name);
|
|
726
|
+
listedOk.add(name);
|
|
727
|
+
} catch (err) {
|
|
728
|
+
errors.push({ server: name, error: err.message });
|
|
729
|
+
}
|
|
730
|
+
}));
|
|
731
|
+
|
|
732
|
+
// Every signed tool of a server that was not found / failed / was rejected by confirmServer is missing.
|
|
733
|
+
for (const [server, list] of allow) {
|
|
734
|
+
if (!listedOk.has(server)) for (const tool of list) missing.push({ server, tool });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (errors.length > 0) console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
tools,
|
|
741
|
+
metaTools: buildMetaTools(tools, undefined, excluded),
|
|
742
|
+
servers: connected,
|
|
743
|
+
denied: [],
|
|
744
|
+
systemContext: buildSystemContext(connected, tools, [], true),
|
|
745
|
+
errors,
|
|
746
|
+
missing,
|
|
747
|
+
close: async () => { await Promise.all(children.map(killServer)); },
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
611
751
|
// --- Main entry point ---
|
|
612
752
|
|
|
613
753
|
/**
|
|
@@ -632,7 +772,16 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
632
772
|
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
633
773
|
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
634
774
|
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
635
|
-
* @param {string[]} [opts.
|
|
775
|
+
* @param {Record<string, string[]>} [opts.allow] - Signed allow-list `{ [serverName]: rawMcpToolNames[] }`
|
|
776
|
+
* (raw MCP names, NOT the `server_tool` prefixed form; each list must be non-empty). Switches to an IN-MEMORY mode: never reads or
|
|
777
|
+
* writes `.mcp-bridge.json` (no cache, no TTL; `bridgePath`/`refresh`/`servers` are ignored); server
|
|
778
|
+
* definitions come only from `discoverServers(configPaths)`; only servers named here are spawned (still
|
|
779
|
+
* vetted by `confirmServer`); each connects ONCE and exposes signed ∩ listed — every other tool is ABSENT
|
|
780
|
+
* (not in `tools`, `metaTools` catalog or `systemContext`). Signed tools the server does not list (or whose
|
|
781
|
+
* server was not found / failed / was rejected) come back in `missing`. `denied` is always `[]`, nothing is
|
|
782
|
+
* written to stdout, and `mcp_invoke` on a listed-but-unsigned name throws `context.denied === true`.
|
|
783
|
+
* Invalid shape throws `ValidationError` (type only, never values).
|
|
784
|
+
* @param {string[]} [opts.servers] - Limit to these server names (ignored when `allow` is given).
|
|
636
785
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
637
786
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
638
787
|
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
@@ -644,9 +793,9 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
644
793
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
645
794
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
646
795
|
* since each command is then vetted regardless of source.
|
|
647
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>}
|
|
648
|
-
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution
|
|
649
|
-
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.
|
|
796
|
+
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>}
|
|
797
|
+
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools
|
|
798
|
+
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.
|
|
650
799
|
* @example
|
|
651
800
|
* const { tools, close } = await createMCPBridge();
|
|
652
801
|
* const loop = new Loop({ provider, tools });
|
|
@@ -693,6 +842,15 @@ async function createMCPBridge(opts = {}) {
|
|
|
693
842
|
);
|
|
694
843
|
};
|
|
695
844
|
|
|
845
|
+
if (opts.allow !== undefined) {
|
|
846
|
+
const allow = validateAllow(opts.allow);
|
|
847
|
+
return createAllowBridge(allow, {
|
|
848
|
+
configPaths: opts.configPaths,
|
|
849
|
+
includeProjectConfig: opts.includeProjectConfig === true || !!confirmServer,
|
|
850
|
+
timeout, callTimeout, vetServer, warnUnvettedSpawn,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
696
854
|
let config = readBridgeConfig(bridgePath);
|
|
697
855
|
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
698
856
|
|
|
@@ -731,8 +889,8 @@ async function createMCPBridge(opts = {}) {
|
|
|
731
889
|
// decision, not a connection failure, so it must not land in `errors`.
|
|
732
890
|
if (!(await vetServer(name, def))) return;
|
|
733
891
|
const result = await connectAndListTools(name, def, timeout);
|
|
734
|
-
freshTools.set(name, result.mcpTools);
|
|
735
892
|
connectResults.set(name, result.client);
|
|
893
|
+
freshTools.set(name, result.mcpTools);
|
|
736
894
|
} catch (err) {
|
|
737
895
|
errors.push({ server: name, error: err.message });
|
|
738
896
|
}
|
|
@@ -742,20 +900,25 @@ async function createMCPBridge(opts = {}) {
|
|
|
742
900
|
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
743
901
|
}
|
|
744
902
|
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
903
|
+
// Discovery connections are killed in `finally`: a throw from the config merge/write (e.g. an
|
|
904
|
+
// unwritable bridgePath) must not strand the live children held only in connectResults.
|
|
905
|
+
try {
|
|
906
|
+
// Only write config when at least one server connected successfully.
|
|
907
|
+
// If all servers failed, retain the existing config unchanged so
|
|
908
|
+
// user-curated allow/deny settings are not destroyed on transient failures.
|
|
909
|
+
if (freshTools.size > 0) {
|
|
910
|
+
config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
|
|
911
|
+
writeBridgeConfig(bridgePath, config);
|
|
912
|
+
console.log(`[MCP Bridge] Wrote ${bridgePath}`);
|
|
913
|
+
} else if (!config) {
|
|
914
|
+
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
915
|
+
}
|
|
755
916
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
917
|
+
} finally {
|
|
918
|
+
// Close the discovery connections — we'll reconnect below using the config
|
|
919
|
+
for (const client of connectResults.values()) {
|
|
920
|
+
await killServer(client.child);
|
|
921
|
+
}
|
|
759
922
|
}
|
|
760
923
|
}
|
|
761
924
|
}
|
|
@@ -808,13 +971,14 @@ async function createMCPBridge(opts = {}) {
|
|
|
808
971
|
// Denied by confirmServer: skip silently (the caller's decision, not a failure).
|
|
809
972
|
if (!(await vetServer(name, serverConf))) return;
|
|
810
973
|
const { mcpTools, client } = await connectAndListTools(name, serverConf, timeout);
|
|
974
|
+
// Track the child BEFORE any processing so close() reaps it even if what follows throws.
|
|
975
|
+
children.push(client.child);
|
|
811
976
|
|
|
812
977
|
// Only wrap tools that are allowed in config
|
|
813
978
|
const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
|
|
814
979
|
const wrapped = wrapTools(name, allowed, client.rpc, callTimeout);
|
|
815
980
|
|
|
816
981
|
tools.push(...wrapped);
|
|
817
|
-
children.push(client.child);
|
|
818
982
|
connected.push(name);
|
|
819
983
|
|
|
820
984
|
// Track denied tools with descriptions from the server
|