bare-agent 0.53.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 +12 -5
- package/package.json +1 -1
- package/primitives.json +10 -1
- package/src/mcp-bridge.d.ts +42 -2
- package/src/mcp-bridge.js +307 -83
- package/src/mcp.d.ts +2 -1
- package/src/mcp.js +2 -2
- package/types/index.d.ts +2 -0
package/bareagent.context.md
CHANGED
|
@@ -20,7 +20,7 @@ Eight entry points:
|
|
|
20
20
|
- `require('bare-agent/stores')` — SQLite (FTS5), JsonFile
|
|
21
21
|
- `require('bare-agent/transports')` — JsonlTransport
|
|
22
22
|
- `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, resolveToolPath, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
|
|
23
|
-
- `require('bare-agent/mcp')` — createMCPBridge (returns `tools` + `metaTools`), discoverServers, buildMetaTools
|
|
23
|
+
- `require('bare-agent/mcp')` — createMCPBridge (returns `tools` + `metaTools`), discoverServers, buildMetaTools, listServerTools
|
|
24
24
|
- `require('bare-agent/bareguard')` — wireGate (one-line bareguard Gate integration), defaultActionTranslator
|
|
25
25
|
|
|
26
26
|
**TypeScript:** bareagent is pure JS + JSDoc but ships `.d.ts` declarations (generated from that JSDoc, v0.11+). Every entry point above resolves types automatically — `import { Loop } from 'bare-agent'` and `import { OpenAI } from 'bare-agent/providers'` give full autocomplete and type-checking, including required-option enforcement (e.g. `new Loop({})` is a type error: `provider` is required). No `@types/bare-agent` needed. Shared shapes (`Provider`, `Message`, `ToolDef`, `ToolCall`, `Usage`, `GenerateResult`, `Store`) are exported from the package's `types/`. The repo itself runs `tsc --checkJs` in CI on every push/PR so the JSDoc and the code can't drift.
|
|
@@ -425,18 +425,23 @@ 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`
|
|
432
|
-
(default 15 s) bounds the handshake (`initialize`
|
|
432
|
+
(default 15 s) bounds the handshake (`initialize` and each `tools/list` page);
|
|
433
433
|
`opts.callTimeout` (default 120 s, `0` disables) bounds each `tools/call`. A
|
|
434
434
|
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
|
|
|
@@ -1535,6 +1540,8 @@ try {
|
|
|
1535
1540
|
|
|
1536
1541
|
**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
1542
|
|
|
1543
|
+
**Annotations, listing, pagination, `errors` (Unreleased).** If a server sends `annotations` on a tool (`readOnlyHint`, `destructiveHint`, ...), each bridged tool (`ToolDef.annotations`) and each `mcp_discover` catalog entry carries them as the same unmodified object, in every mode; the key is absent when none was sent (a non-object value — null/array/string — is dropped). They are server hints, never authorization, and `systemContext` does not mention them. `listServerTools({ server, configPaths?, confirmServer?, includeProjectConfig?, timeout? })` (`bare-agent/mcp`) returns `[{name, description, annotations?}]` for one server so you can pick an `allow` list: no bridge, never reads or writes `.mcp-bridge.json`, nothing on stdout, vetted by `confirmServer` exactly like `createMCPBridge`, and the child is always killed. It throws (never returns `[]`) on server not found, declined/throwing `confirmServer`, connect/handshake failure, timeout or a malformed reply; one total deadline of 4 x `timeout` bounds the call (a small `timeout` shrinks it proportionally). `tools/list` follows `nextCursor` in every mode; more than 100 pages or a repeated cursor fails that server's connect (in `errors`; in `allow` mode its signed tools land in `missing`) rather than returning a truncated catalog. In bulk mode `errors` is always present and lists each server that did not connect this run, once (first failure message wins); a server that failed discovery but connected from the cached definition is not in `errors` but draws one separate `console.warn` saying the config edit was not applied.
|
|
1544
|
+
|
|
1538
1545
|
```json
|
|
1539
1546
|
{
|
|
1540
1547
|
"discovered": "2026-04-13T12:00:00.000Z",
|
package/package.json
CHANGED
package/primitives.json
CHANGED
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"category": "mcp",
|
|
124
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
|
|
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
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
|
},
|
|
@@ -262,6 +262,15 @@
|
|
|
262
262
|
"fails": "never throws and never calls the gate; surfaces fail-open (a non-honored verdict surfaces) and bounds fields against the sink's silent caps with a visible clip marker.",
|
|
263
263
|
"example": "gate.annotate(judgeToAnnotation(await judge({ request, artifact, provider })));"
|
|
264
264
|
},
|
|
265
|
+
{
|
|
266
|
+
"name": "listServerTools",
|
|
267
|
+
"category": "mcp",
|
|
268
|
+
"when": "you want to list one MCP server's tools and their annotation hints before deciding what to allow — no bridge, no cache file, no tools kept alive",
|
|
269
|
+
"import": "import { listServerTools } from 'bare-agent/mcp'",
|
|
270
|
+
"signature": "listServerTools(opts: object) => Promise<Array<{name: string, description: string, annotations?: Record<string, any>}>>",
|
|
271
|
+
"fails": "throws (never returns []) when the server is not found, confirmServer declines or throws, the connect/handshake fails or times out, the total deadline trips, or the reply is malformed; the child is always reaped.",
|
|
272
|
+
"example": "const tools = await listServerTools({ server: 'files', configPaths: [cfg] });"
|
|
273
|
+
},
|
|
265
274
|
{
|
|
266
275
|
"name": "litectxCorpus",
|
|
267
276
|
"category": "integration",
|
package/src/mcp-bridge.d.ts
CHANGED
|
@@ -15,6 +15,11 @@ export type McpTool = {
|
|
|
15
15
|
name: string;
|
|
16
16
|
description?: string | undefined;
|
|
17
17
|
inputSchema?: Record<string, any> | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* - Server-supplied behavioural HINTS (readOnlyHint,
|
|
20
|
+
* destructiveHint, ...). Untrusted metadata from the server: never an authorization signal.
|
|
21
|
+
*/
|
|
22
|
+
annotations?: Record<string, any> | undefined;
|
|
18
23
|
};
|
|
19
24
|
/**
|
|
20
25
|
* Per-server entry persisted in .mcp-bridge.json.
|
|
@@ -103,7 +108,7 @@ export type RpcClient = {
|
|
|
103
108
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
104
109
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
105
110
|
* since each command is then vetted regardless of source.
|
|
106
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors
|
|
111
|
+
* @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
112
|
* @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
113
|
* @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.
|
|
109
114
|
* @example
|
|
@@ -126,7 +131,7 @@ export function createMCPBridge(opts?: {
|
|
|
126
131
|
servers: string[];
|
|
127
132
|
systemContext: string;
|
|
128
133
|
denied: DeniedTool[];
|
|
129
|
-
errors
|
|
134
|
+
errors: Array<{
|
|
130
135
|
server: string;
|
|
131
136
|
error: string;
|
|
132
137
|
}>;
|
|
@@ -180,3 +185,38 @@ export function buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded
|
|
|
180
185
|
server: string;
|
|
181
186
|
tool: string;
|
|
182
187
|
}>): ToolDef[];
|
|
188
|
+
/**
|
|
189
|
+
* List one configured MCP server's tools WITHOUT building a bridge: discover, vet, connect, list
|
|
190
|
+
* (following pagination), then always kill the child. Never reads or writes `.mcp-bridge.json`
|
|
191
|
+
* and prints nothing to stdout. `annotations` are the server's own HINTS, passed through unmodified
|
|
192
|
+
* (key absent when the server sent none) — never an authorization signal.
|
|
193
|
+
*
|
|
194
|
+
* Total wall-clock ceiling is DERIVED: 4 × `timeout` (initialize + three maximally slow pages) over
|
|
195
|
+
* the connect + list, excluding `confirmServer` (caller code, possibly interactive). On trip the
|
|
196
|
+
* child is killed and a TimeoutError naming the total deadline rejects. A SMALL `timeout` shrinks the
|
|
197
|
+
* total proportionally (timeout 500 ⇒ 2s total), so a server with many pages near that speed can trip it.
|
|
198
|
+
* A non-object `annotations` value from the server is treated as absent (dropped).
|
|
199
|
+
*
|
|
200
|
+
* @param {object} opts
|
|
201
|
+
* @param {string} opts.server - Server name as it appears in the discovered configs.
|
|
202
|
+
* @param {string[]} [opts.configPaths] - Explicit config paths; omitted ⇒ trusted $HOME/IDE defaults.
|
|
203
|
+
* @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer] - Same semantics as createMCPBridge: `=== true` proceeds, a throw denies. Implies includeProjectConfig.
|
|
204
|
+
* @param {boolean} [opts.includeProjectConfig=false] - Also scan `./.mcp.json` during default discovery.
|
|
205
|
+
* @param {number} [opts.timeout=15000] - Per-round-trip bound in ms (initialize and each tools/list page).
|
|
206
|
+
* @returns {Promise<Array<{name: string, description: string, annotations?: Record<string, any>}>>}
|
|
207
|
+
* @when you want to list one MCP server's tools and their annotation hints before deciding what to allow — no bridge, no cache file, no tools kept alive
|
|
208
|
+
* @fails throws (never returns []) when the server is not found, confirmServer declines or throws, the connect/handshake fails or times out, the total deadline trips, or the reply is malformed; the child is always reaped.
|
|
209
|
+
* @example
|
|
210
|
+
* const tools = await listServerTools({ server: 'files', configPaths: [cfg] });
|
|
211
|
+
*/
|
|
212
|
+
export function listServerTools(opts: {
|
|
213
|
+
server: string;
|
|
214
|
+
configPaths?: string[] | undefined;
|
|
215
|
+
confirmServer?: ((name: string, def: ServerDef) => boolean | Promise<boolean>) | undefined;
|
|
216
|
+
includeProjectConfig?: boolean | undefined;
|
|
217
|
+
timeout?: number | undefined;
|
|
218
|
+
}): Promise<Array<{
|
|
219
|
+
name: string;
|
|
220
|
+
description: string;
|
|
221
|
+
annotations?: Record<string, any>;
|
|
222
|
+
}>>;
|
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, ValidationError } = require('./errors');
|
|
7
|
+
const { ToolError, ValidationError, TimeoutError } = require('./errors');
|
|
8
8
|
|
|
9
9
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
10
10
|
|
|
@@ -23,6 +23,8 @@ const { ToolError, ValidationError } = require('./errors');
|
|
|
23
23
|
* @property {string} name
|
|
24
24
|
* @property {string} [description]
|
|
25
25
|
* @property {Record<string, any>} [inputSchema]
|
|
26
|
+
* @property {Record<string, any>} [annotations] - Server-supplied behavioural HINTS (readOnlyHint,
|
|
27
|
+
* destructiveHint, ...). Untrusted metadata from the server: never an authorization signal.
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -229,6 +231,23 @@ function resolveEnv(env) {
|
|
|
229
231
|
|
|
230
232
|
// --- JSON-RPC stdio client ---
|
|
231
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
|
+
|
|
232
251
|
/**
|
|
233
252
|
* @param {string} name
|
|
234
253
|
* @param {ServerDef} def
|
|
@@ -238,11 +257,28 @@ function createRpcClient(name, def) {
|
|
|
238
257
|
const { command, args = [], env, cwd } = def;
|
|
239
258
|
const mergedEnv = { ...process.env, ...resolveEnv(env) };
|
|
240
259
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
+
}
|
|
246
282
|
|
|
247
283
|
/** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void, timer: NodeJS.Timeout | null}>} */
|
|
248
284
|
const pending = new Map();
|
|
@@ -295,9 +331,25 @@ function createRpcClient(name, def) {
|
|
|
295
331
|
// and rpc()/notify() guard writability before writing.
|
|
296
332
|
child.stdin.on('error', () => { /* child gone; surfaced via close + write guards */ });
|
|
297
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
|
+
|
|
298
348
|
child.on('close', (code) => {
|
|
299
349
|
for (const id of [...pending.keys()]) {
|
|
300
|
-
|
|
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)}`));
|
|
301
353
|
}
|
|
302
354
|
});
|
|
303
355
|
|
|
@@ -316,6 +368,7 @@ function createRpcClient(name, def) {
|
|
|
316
368
|
function rpc(method, params = {}, timeoutMs = 0, label) {
|
|
317
369
|
const id = nextId++;
|
|
318
370
|
return new Promise((resolve, reject) => {
|
|
371
|
+
if (spawnErr) return reject(spawnErr);
|
|
319
372
|
if (!child.stdin.writable) {
|
|
320
373
|
return reject(new ToolError(`MCP server "${name}" stdin is not writable (process exited or pipe closed). stderr: ${stderrBuf.slice(-500)}`));
|
|
321
374
|
}
|
|
@@ -329,7 +382,10 @@ function createRpcClient(name, def) {
|
|
|
329
382
|
{ context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
|
|
330
383
|
));
|
|
331
384
|
} else {
|
|
332
|
-
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
|
+
));
|
|
333
389
|
}
|
|
334
390
|
}, timeoutMs);
|
|
335
391
|
timer.unref?.();
|
|
@@ -338,7 +394,10 @@ function createRpcClient(name, def) {
|
|
|
338
394
|
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
339
395
|
child.stdin.write(msg, (err) => {
|
|
340
396
|
// settle() no-ops if 'close'/timeout already settled this id.
|
|
341
|
-
|
|
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)}`)));
|
|
342
401
|
});
|
|
343
402
|
});
|
|
344
403
|
}
|
|
@@ -370,6 +429,14 @@ function unwrapContent(content) {
|
|
|
370
429
|
|
|
371
430
|
// --- Tool wrapping ---
|
|
372
431
|
|
|
432
|
+
/**
|
|
433
|
+
* A server "sent annotations" only when it sent a non-null, non-array object. A non-object
|
|
434
|
+
* `annotations` value (null, array, string, ...) is treated as absent (dropped), so the key stays absent downstream.
|
|
435
|
+
* @param {any} a
|
|
436
|
+
* @returns {boolean}
|
|
437
|
+
*/
|
|
438
|
+
const hasAnnotations = (a) => a !== null && typeof a === 'object' && !Array.isArray(a);
|
|
439
|
+
|
|
373
440
|
// Runtime arg-dependent policy has moved to Loop-level (new Loop({ policy })).
|
|
374
441
|
// mcp-bridge retains only the static .mcp-bridge.json allow/deny filter below —
|
|
375
442
|
// that decides which tools are exposed to the Loop in the first place.
|
|
@@ -385,6 +452,8 @@ function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
|
|
|
385
452
|
name: `${serverName}_${t.name}`,
|
|
386
453
|
description: t.description || '',
|
|
387
454
|
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
455
|
+
// Server `annotations` ride through as the SAME object, unmodified; key ABSENT when none sent.
|
|
456
|
+
...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
|
|
388
457
|
execute: async (args) => {
|
|
389
458
|
const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout, { server: serverName, tool: t.name });
|
|
390
459
|
if (result.isError) {
|
|
@@ -440,36 +509,79 @@ async function killServer(child) {
|
|
|
440
509
|
|
|
441
510
|
// --- Connect + list tools from a server ---
|
|
442
511
|
|
|
512
|
+
// tools/list pagination cap. A server that keeps handing out cursors past this is faulty or
|
|
513
|
+
// hostile; we THROW (never truncate silently — a partial catalog reads as a complete one).
|
|
514
|
+
const MAX_LIST_PAGES = 100;
|
|
515
|
+
|
|
443
516
|
/**
|
|
444
517
|
* @param {string} name
|
|
445
518
|
* @param {ServerDef} def
|
|
446
|
-
* @param {number} [timeout]
|
|
519
|
+
* @param {number} [timeout] - Bound on EACH round-trip (initialize, every tools/list page).
|
|
520
|
+
* @param {{ deadlineMs?: number }} [o] - Optional ONE total wall-clock ceiling over the whole
|
|
521
|
+
* connect+list (internal; used by listServerTools). On trip the child is killed and a labelled
|
|
522
|
+
* TimeoutError rejects. The timer is always cleared.
|
|
447
523
|
* @returns {Promise<{mcpTools: McpTool[], client: RpcClient}>}
|
|
448
524
|
*/
|
|
449
|
-
async function connectAndListTools(name, def, timeout = 15000) {
|
|
525
|
+
async function connectAndListTools(name, def, timeout = 15000, o = {}) {
|
|
450
526
|
const client = createRpcClient(name, def);
|
|
527
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
528
|
+
let deadlineTimer;
|
|
451
529
|
|
|
452
530
|
try {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
531
|
+
const work = (async () => {
|
|
532
|
+
// Every round-trip is bounded by `timeout`. tools/list used to be
|
|
533
|
+
// unbounded — a server that answered initialize but never replied to
|
|
534
|
+
// tools/list would hang discovery (and the whole bridge) indefinitely.
|
|
535
|
+
await client.rpc('initialize', {
|
|
536
|
+
protocolVersion: '2024-11-05',
|
|
537
|
+
capabilities: {},
|
|
538
|
+
clientInfo: { name: 'bare-agent', version: '0.5.0' },
|
|
539
|
+
}, timeout);
|
|
540
|
+
client.notify('notifications/initialized');
|
|
541
|
+
|
|
542
|
+
// Follow `nextCursor` until the server stops sending one. A reply without a `tools` array is a
|
|
543
|
+
// protocol-violating server, not an empty one: fail the connect HERE (inside the try, so the
|
|
544
|
+
// child is reaped) rather than let every caller's `.map`/`.filter` throw after the child is
|
|
545
|
+
// already running but before it is tracked.
|
|
546
|
+
/** @type {McpTool[]} */
|
|
547
|
+
const mcpTools = [];
|
|
548
|
+
/** @type {Set<string>} */
|
|
549
|
+
const seenCursors = new Set();
|
|
550
|
+
/** @type {string | undefined} */
|
|
551
|
+
let cursor;
|
|
552
|
+
for (let pages = 1; ; pages++) {
|
|
553
|
+
const listed = await client.rpc('tools/list', cursor === undefined ? {} : { cursor }, timeout);
|
|
554
|
+
if (!listed || !Array.isArray(listed.tools)) {
|
|
555
|
+
throw new Error(`MCP server "${name}" returned a malformed tools/list reply (no tools array)`);
|
|
556
|
+
}
|
|
557
|
+
mcpTools.push(...listed.tools);
|
|
558
|
+
const next = listed.nextCursor;
|
|
559
|
+
if (typeof next !== 'string' || next === '') break;
|
|
560
|
+
if (seenCursors.has(next)) {
|
|
561
|
+
throw new Error(`MCP server "${name}" repeated a tools/list cursor (pagination loop)`);
|
|
562
|
+
}
|
|
563
|
+
if (pages >= MAX_LIST_PAGES) {
|
|
564
|
+
throw new Error(`MCP server "${name}" exceeded the tools/list page cap (${MAX_LIST_PAGES} pages); refusing to return a truncated catalog`);
|
|
565
|
+
}
|
|
566
|
+
seenCursors.add(next);
|
|
567
|
+
cursor = next;
|
|
568
|
+
}
|
|
471
569
|
|
|
472
|
-
|
|
570
|
+
return { mcpTools, client };
|
|
571
|
+
})();
|
|
572
|
+
|
|
573
|
+
if (!o.deadlineMs) return await work;
|
|
574
|
+
// The loser of the race must not surface as an unhandled rejection.
|
|
575
|
+
work.catch(() => {});
|
|
576
|
+
const deadline = new Promise((_, reject) => {
|
|
577
|
+
deadlineTimer = setTimeout(() => {
|
|
578
|
+
reject(new TimeoutError(
|
|
579
|
+
`MCP server "${name}" exceeded the total deadline of ${o.deadlineMs}ms for connect + tools/list`,
|
|
580
|
+
{ context: { timeout: true, bound: 'deadline', server: name, deadlineMs: o.deadlineMs } },
|
|
581
|
+
));
|
|
582
|
+
}, o.deadlineMs);
|
|
583
|
+
});
|
|
584
|
+
return await Promise.race([work, deadline]);
|
|
473
585
|
} catch (err) {
|
|
474
586
|
// Connect failed (init timeout, tools/list error, etc.) — the child was
|
|
475
587
|
// spawned but never returned to the caller, so the caller can't track it
|
|
@@ -477,6 +589,8 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
477
589
|
// alive and hangs the process on exit (notably node:test's file wrapper).
|
|
478
590
|
await killServer(client.child);
|
|
479
591
|
throw err;
|
|
592
|
+
} finally {
|
|
593
|
+
clearTimeout(deadlineTimer);
|
|
480
594
|
}
|
|
481
595
|
}
|
|
482
596
|
|
|
@@ -565,6 +679,7 @@ function buildMetaTools(tools, discoveredAt, excluded) {
|
|
|
565
679
|
schema: t.parameters || { type: 'object', properties: {} },
|
|
566
680
|
server: sep > 0 ? t.name.slice(0, sep) : t.name,
|
|
567
681
|
tool: sep > 0 ? t.name.slice(sep + 1) : '',
|
|
682
|
+
...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
|
|
568
683
|
};
|
|
569
684
|
});
|
|
570
685
|
const byName = new Map(tools.map(t => [t.name, t]));
|
|
@@ -636,6 +751,46 @@ function buildMetaTools(tools, discoveredAt, excluded) {
|
|
|
636
751
|
return [mcpDiscover, mcpInvoke];
|
|
637
752
|
}
|
|
638
753
|
|
|
754
|
+
// --- Server vetting (shared by createMCPBridge and listServerTools) ---
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Vet a server before spawning its command. `=== true` proceeds; anything else, or a throw, denies
|
|
758
|
+
* (fail-closed). No hook ⇒ trust all.
|
|
759
|
+
* @param {((name: string, def: ServerDef) => boolean | Promise<boolean>) | null} confirmServer
|
|
760
|
+
* @returns {(name: string, def: ServerDef) => Promise<boolean>}
|
|
761
|
+
*/
|
|
762
|
+
function makeVetServer(confirmServer) {
|
|
763
|
+
return async (name, def) => {
|
|
764
|
+
if (!confirmServer) return true;
|
|
765
|
+
try { return (await confirmServer(name, def)) === true; }
|
|
766
|
+
catch { return false; }
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Connecting to a server EXECUTES its `command`. The project-cwd `.mcp.json` is excluded from
|
|
772
|
+
* default discovery (see TRUSTED_CONFIG_PATHS / includeProjectConfig), so the untrusted-repo
|
|
773
|
+
* path is closed by default; this warning (stderr) covers the residual case where home/IDE configs
|
|
774
|
+
* (or an explicit opt-in) contribute commands and no confirmServer hook is present to vet them.
|
|
775
|
+
* Warns ONCE per returned function, BEFORE the first spawn.
|
|
776
|
+
* @param {Function | null} confirmServer
|
|
777
|
+
* @returns {(specs: Array<{name: string, command: string, args?: string[]}>) => void}
|
|
778
|
+
*/
|
|
779
|
+
function makeUnvettedWarner(confirmServer) {
|
|
780
|
+
let warned = false;
|
|
781
|
+
return (specs) => {
|
|
782
|
+
if (confirmServer || warned || specs.length === 0) return;
|
|
783
|
+
warned = true;
|
|
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)` : ''}`);
|
|
786
|
+
console.warn(
|
|
787
|
+
`[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
|
|
788
|
+
cmds.join('\n ') +
|
|
789
|
+
`\n Pass { confirmServer } to vet each command before it runs.`,
|
|
790
|
+
);
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
639
794
|
// --- Signed allow-list mode (opts.allow) ---
|
|
640
795
|
|
|
641
796
|
/** @param {any} v @returns {string} */
|
|
@@ -793,7 +948,7 @@ async function createAllowBridge(allow, o) {
|
|
|
793
948
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
794
949
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
795
950
|
* since each command is then vetted regardless of source.
|
|
796
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors
|
|
951
|
+
* @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
952
|
* @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
953
|
* @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.
|
|
799
954
|
* @example
|
|
@@ -816,31 +971,8 @@ async function createMCPBridge(opts = {}) {
|
|
|
816
971
|
// Vet a server before spawning its command. Fail-closed: an undefined hook
|
|
817
972
|
// trusts all (unchanged behavior); a throw denies.
|
|
818
973
|
const confirmServer = typeof opts.confirmServer === 'function' ? opts.confirmServer : null;
|
|
819
|
-
|
|
820
|
-
const
|
|
821
|
-
if (!confirmServer) return true;
|
|
822
|
-
try { return (await confirmServer(name, def)) === true; }
|
|
823
|
-
catch { return false; }
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
// Connecting to a server EXECUTES its `command`. The project-cwd `.mcp.json` is excluded from
|
|
827
|
-
// default discovery (see TRUSTED_CONFIG_PATHS / includeProjectConfig), so the untrusted-repo
|
|
828
|
-
// path is closed by default; this warning covers the residual case where home/IDE configs (or an
|
|
829
|
-
// explicit opt-in) contribute commands and no confirmServer hook is present to vet them.
|
|
830
|
-
// Warn ONCE per call, BEFORE the first spawn — and the first spawn is the
|
|
831
|
-
// discovery phase on a cold/refresh run, not the main-connect phase below.
|
|
832
|
-
let warnedUnvetted = false;
|
|
833
|
-
/** @param {Array<{name: string, command: string, args?: string[]}>} specs */
|
|
834
|
-
const warnUnvettedSpawn = (specs) => {
|
|
835
|
-
if (confirmServer || warnedUnvetted || specs.length === 0) return;
|
|
836
|
-
warnedUnvetted = true;
|
|
837
|
-
const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
|
|
838
|
-
console.warn(
|
|
839
|
-
`[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
|
|
840
|
-
cmds.join('\n ') +
|
|
841
|
-
`\n Pass { confirmServer } to vet each command before it runs.`,
|
|
842
|
-
);
|
|
843
|
-
};
|
|
974
|
+
const vetServer = makeVetServer(confirmServer);
|
|
975
|
+
const warnUnvettedSpawn = makeUnvettedWarner(confirmServer);
|
|
844
976
|
|
|
845
977
|
if (opts.allow !== undefined) {
|
|
846
978
|
const allow = validateAllow(opts.allow);
|
|
@@ -854,6 +986,29 @@ async function createMCPBridge(opts = {}) {
|
|
|
854
986
|
let config = readBridgeConfig(bridgePath);
|
|
855
987
|
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
856
988
|
|
|
989
|
+
// Per-server connect failures from BOTH passes (discovery, then main), first message wins. `errors`
|
|
990
|
+
// is derived from this once, at return time: servers that did not end up connected, once each.
|
|
991
|
+
/** @type {Map<string, string>} */
|
|
992
|
+
const failures = new Map();
|
|
993
|
+
/** @param {string} name @param {unknown} err */
|
|
994
|
+
const noteFailure = (name, err) => {
|
|
995
|
+
if (failures.has(name)) return;
|
|
996
|
+
const message = err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' ? err.message : String(err);
|
|
997
|
+
failures.set(name, message);
|
|
998
|
+
};
|
|
999
|
+
/** The one place `errors` is computed (and the failure warns issued). @param {string[]} connected */
|
|
1000
|
+
const finalErrors = (connected) => {
|
|
1001
|
+
const up = new Set(connected);
|
|
1002
|
+
const errors = [...failures].filter(([name]) => !up.has(name)).map(([server, error]) => ({ server, error }));
|
|
1003
|
+
if (errors.length > 0) console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
1004
|
+
// Discovery failed but the cached definition connected: not an error, yet the config edit was not applied.
|
|
1005
|
+
const cachedUsed = [...failures].filter(([name]) => up.has(name)).map(([server, error]) => ({ server, error }));
|
|
1006
|
+
if (cachedUsed.length > 0) console.warn('[MCP Bridge] Discovery failed for server(s) that connected from the cached definition (config edit not applied):', cachedUsed);
|
|
1007
|
+
return errors;
|
|
1008
|
+
};
|
|
1009
|
+
/** Early return: nothing connected. */
|
|
1010
|
+
const nothingConnected = () => ({ tools: [], servers: [], systemContext: '', denied: [], errors: finalErrors([]), close: async () => {} });
|
|
1011
|
+
|
|
857
1012
|
if (needsRefresh) {
|
|
858
1013
|
// Discover from IDE configs. The project-cwd `.mcp.json` is excluded by default (untrusted-repo
|
|
859
1014
|
// RCE vector); it is scanned only on explicit opt-in, or when a `confirmServer` hook is present
|
|
@@ -861,9 +1016,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
861
1016
|
const includeProjectConfig = opts.includeProjectConfig === true || !!confirmServer;
|
|
862
1017
|
const discovered = discoverServers(opts.configPaths, { includeProjectConfig });
|
|
863
1018
|
|
|
864
|
-
if (discovered.size === 0 && !config)
|
|
865
|
-
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
866
|
-
}
|
|
1019
|
+
if (discovered.size === 0 && !config) return nothingConnected();
|
|
867
1020
|
|
|
868
1021
|
// Only attempt connection when discovery found something.
|
|
869
1022
|
// If discovered.size === 0 but config exists, fall through and use the existing config
|
|
@@ -873,9 +1026,6 @@ async function createMCPBridge(opts = {}) {
|
|
|
873
1026
|
const freshTools = new Map();
|
|
874
1027
|
/** @type {Map<string, RpcClient>} */
|
|
875
1028
|
const connectResults = new Map();
|
|
876
|
-
/** @type {Array<{server: string, error: string}>} */
|
|
877
|
-
const errors = [];
|
|
878
|
-
|
|
879
1029
|
const reqServers = opts.servers;
|
|
880
1030
|
const toDiscover = reqServers
|
|
881
1031
|
? [...discovered.entries()].filter(([n]) => reqServers.includes(n))
|
|
@@ -892,14 +1042,10 @@ async function createMCPBridge(opts = {}) {
|
|
|
892
1042
|
connectResults.set(name, result.client);
|
|
893
1043
|
freshTools.set(name, result.mcpTools);
|
|
894
1044
|
} catch (err) {
|
|
895
|
-
|
|
1045
|
+
noteFailure(name, err);
|
|
896
1046
|
}
|
|
897
1047
|
}));
|
|
898
1048
|
|
|
899
|
-
if (errors.length > 0) {
|
|
900
|
-
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
1049
|
// Discovery connections are killed in `finally`: a throw from the config merge/write (e.g. an
|
|
904
1050
|
// unwritable bridgePath) must not strand the live children held only in connectResults.
|
|
905
1051
|
try {
|
|
@@ -911,7 +1057,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
911
1057
|
writeBridgeConfig(bridgePath, config);
|
|
912
1058
|
console.log(`[MCP Bridge] Wrote ${bridgePath}`);
|
|
913
1059
|
} else if (!config) {
|
|
914
|
-
return
|
|
1060
|
+
return nothingConnected();
|
|
915
1061
|
}
|
|
916
1062
|
|
|
917
1063
|
} finally {
|
|
@@ -925,9 +1071,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
925
1071
|
|
|
926
1072
|
// At this point config is guaranteed non-null: it either existed, was just
|
|
927
1073
|
// written by the refresh branch, or we returned early above.
|
|
928
|
-
if (!config)
|
|
929
|
-
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
930
|
-
}
|
|
1074
|
+
if (!config) return nothingConnected();
|
|
931
1075
|
/** @type {BridgeConfig} */
|
|
932
1076
|
const cfg = config;
|
|
933
1077
|
|
|
@@ -937,9 +1081,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
937
1081
|
? reqServers2.filter(n => cfg.servers[n])
|
|
938
1082
|
: Object.keys(cfg.servers);
|
|
939
1083
|
|
|
940
|
-
if (serverNames.length === 0)
|
|
941
|
-
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
942
|
-
}
|
|
1084
|
+
if (serverNames.length === 0) return nothingConnected();
|
|
943
1085
|
|
|
944
1086
|
// Warn before the main-connect spawn too. On a warm run (config exists, no
|
|
945
1087
|
// refresh) this is the first and only spawn; on a cold run the discovery phase
|
|
@@ -955,8 +1097,6 @@ async function createMCPBridge(opts = {}) {
|
|
|
955
1097
|
const connected = [];
|
|
956
1098
|
/** @type {DeniedTool[]} */
|
|
957
1099
|
const denied = [];
|
|
958
|
-
/** @type {Array<{server: string, error: string}>} */
|
|
959
|
-
const errors = [];
|
|
960
1100
|
|
|
961
1101
|
await Promise.all(serverNames.map(async (name) => {
|
|
962
1102
|
const serverConf = cfg.servers[name];
|
|
@@ -988,14 +1128,10 @@ async function createMCPBridge(opts = {}) {
|
|
|
988
1128
|
}
|
|
989
1129
|
}
|
|
990
1130
|
} catch (err) {
|
|
991
|
-
|
|
1131
|
+
noteFailure(name, err);
|
|
992
1132
|
}
|
|
993
1133
|
}));
|
|
994
1134
|
|
|
995
|
-
if (errors.length > 0) {
|
|
996
|
-
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
1135
|
const systemContext = buildSystemContext(connected, tools, denied);
|
|
1000
1136
|
if (connected.length > 0) console.log(systemContext);
|
|
1001
1137
|
|
|
@@ -1007,11 +1143,99 @@ async function createMCPBridge(opts = {}) {
|
|
|
1007
1143
|
servers: connected,
|
|
1008
1144
|
denied,
|
|
1009
1145
|
systemContext,
|
|
1010
|
-
errors,
|
|
1146
|
+
errors: finalErrors(connected),
|
|
1011
1147
|
close: async () => {
|
|
1012
1148
|
await Promise.all(children.map(killServer));
|
|
1013
1149
|
},
|
|
1014
1150
|
};
|
|
1015
1151
|
}
|
|
1016
1152
|
|
|
1017
|
-
|
|
1153
|
+
// --- listServerTools: one-shot read-only listing ---
|
|
1154
|
+
|
|
1155
|
+
// Total-deadline multiplier for listServerTools. `timeout` bounds EACH round-trip; a server that
|
|
1156
|
+
// answers every page just under it could otherwise stretch a call to (1 + pages) × timeout. The one
|
|
1157
|
+
// total ceiling is 4 × timeout = initialize plus three further maximally-slow round-trips: a normal
|
|
1158
|
+
// server answers in milliseconds and a handful of pages never approaches it, while a server that
|
|
1159
|
+
// needs more than that is slow enough to treat as faulty. Derived, not a new option.
|
|
1160
|
+
const LIST_DEADLINE_MULTIPLIER = 4;
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* @param {string} msg
|
|
1164
|
+
* @returns {ValidationError}
|
|
1165
|
+
*/
|
|
1166
|
+
const listBad = (msg) => new ValidationError(`[MCP Bridge] listServerTools: ${msg}`, { context: { lib: 'bare-agent' } });
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* List one configured MCP server's tools WITHOUT building a bridge: discover, vet, connect, list
|
|
1170
|
+
* (following pagination), then always kill the child. Never reads or writes `.mcp-bridge.json`
|
|
1171
|
+
* and prints nothing to stdout. `annotations` are the server's own HINTS, passed through unmodified
|
|
1172
|
+
* (key absent when the server sent none) — never an authorization signal.
|
|
1173
|
+
*
|
|
1174
|
+
* Total wall-clock ceiling is DERIVED: 4 × `timeout` (initialize + three maximally slow pages) over
|
|
1175
|
+
* the connect + list, excluding `confirmServer` (caller code, possibly interactive). On trip the
|
|
1176
|
+
* child is killed and a TimeoutError naming the total deadline rejects. A SMALL `timeout` shrinks the
|
|
1177
|
+
* total proportionally (timeout 500 ⇒ 2s total), so a server with many pages near that speed can trip it.
|
|
1178
|
+
* A non-object `annotations` value from the server is treated as absent (dropped).
|
|
1179
|
+
*
|
|
1180
|
+
* @param {object} opts
|
|
1181
|
+
* @param {string} opts.server - Server name as it appears in the discovered configs.
|
|
1182
|
+
* @param {string[]} [opts.configPaths] - Explicit config paths; omitted ⇒ trusted $HOME/IDE defaults.
|
|
1183
|
+
* @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer] - Same semantics as createMCPBridge: `=== true` proceeds, a throw denies. Implies includeProjectConfig.
|
|
1184
|
+
* @param {boolean} [opts.includeProjectConfig=false] - Also scan `./.mcp.json` during default discovery.
|
|
1185
|
+
* @param {number} [opts.timeout=15000] - Per-round-trip bound in ms (initialize and each tools/list page).
|
|
1186
|
+
* @returns {Promise<Array<{name: string, description: string, annotations?: Record<string, any>}>>}
|
|
1187
|
+
* @when you want to list one MCP server's tools and their annotation hints before deciding what to allow — no bridge, no cache file, no tools kept alive
|
|
1188
|
+
* @fails throws (never returns []) when the server is not found, confirmServer declines or throws, the connect/handshake fails or times out, the total deadline trips, or the reply is malformed; the child is always reaped.
|
|
1189
|
+
* @example
|
|
1190
|
+
* const tools = await listServerTools({ server: 'files', configPaths: [cfg] });
|
|
1191
|
+
*/
|
|
1192
|
+
async function listServerTools(opts) {
|
|
1193
|
+
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
1194
|
+
throw listBad(`opts must be an object (got ${typeName(opts)})`);
|
|
1195
|
+
}
|
|
1196
|
+
const { server, configPaths, confirmServer, includeProjectConfig, timeout = 15000 } = opts;
|
|
1197
|
+
if (typeof server !== 'string' || server === '') {
|
|
1198
|
+
throw listBad(`server must be a non-empty string (got ${server === '' ? 'empty string' : typeName(server)})`);
|
|
1199
|
+
}
|
|
1200
|
+
if (configPaths !== undefined) {
|
|
1201
|
+
if (!Array.isArray(configPaths)) throw listBad(`configPaths must be an array of strings (got ${typeName(configPaths)})`);
|
|
1202
|
+
for (const p of configPaths) {
|
|
1203
|
+
if (typeof p !== 'string' || p === '') throw listBad(`configPaths entries must be non-empty strings (got ${p === '' ? 'empty string' : typeName(p)})`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (confirmServer !== undefined && typeof confirmServer !== 'function') {
|
|
1207
|
+
throw listBad(`confirmServer must be a function (got ${typeName(confirmServer)})`);
|
|
1208
|
+
}
|
|
1209
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
|
|
1210
|
+
throw listBad(`timeout must be a positive finite number (got ${typeName(timeout)})`);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const confirm = typeof confirmServer === 'function' ? confirmServer : null;
|
|
1214
|
+
const discovered = discoverServers(configPaths, { includeProjectConfig: includeProjectConfig === true || !!confirm });
|
|
1215
|
+
const def = discovered.get(server);
|
|
1216
|
+
if (!def) {
|
|
1217
|
+
throw new ToolError(`MCP server "${server}" not found in discovered MCP configs`, { context: { server, notFound: true } });
|
|
1218
|
+
}
|
|
1219
|
+
makeUnvettedWarner(confirm)([{ name: server, command: def.command, args: def.args }]);
|
|
1220
|
+
if (!(await makeVetServer(confirm)(server, def))) {
|
|
1221
|
+
throw new ToolError(`MCP server "${server}" was not approved by confirmServer`, { context: { server, denied: true } });
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const { mcpTools, client } = await connectAndListTools(server, def, timeout, { deadlineMs: timeout * LIST_DEADLINE_MULTIPLIER });
|
|
1225
|
+
try {
|
|
1226
|
+
return mcpTools.map(t => {
|
|
1227
|
+
if (t === null || typeof t !== 'object' || typeof t.name !== 'string' || t.name === '') {
|
|
1228
|
+
throw new ToolError(`MCP server "${server}" returned a malformed tools/list entry (no string name)`, { context: { server } });
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
name: t.name,
|
|
1232
|
+
description: t.description || '',
|
|
1233
|
+
...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
|
|
1234
|
+
};
|
|
1235
|
+
});
|
|
1236
|
+
} finally {
|
|
1237
|
+
await killServer(client.child);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
module.exports = { createMCPBridge, discoverServers, buildMetaTools, listServerTools };
|
package/src/mcp.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMCPBridge } from "./mcp-bridge";
|
|
2
2
|
import { discoverServers } from "./mcp-bridge";
|
|
3
3
|
import { buildMetaTools } from "./mcp-bridge";
|
|
4
|
-
|
|
4
|
+
import { listServerTools } from "./mcp-bridge";
|
|
5
|
+
export { createMCPBridge, discoverServers, buildMetaTools, listServerTools };
|
package/src/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { createMCPBridge, discoverServers, buildMetaTools } = require('./mcp-bridge');
|
|
3
|
+
const { createMCPBridge, discoverServers, buildMetaTools, listServerTools } = require('./mcp-bridge');
|
|
4
4
|
|
|
5
|
-
module.exports = { createMCPBridge, discoverServers, buildMetaTools };
|
|
5
|
+
module.exports = { createMCPBridge, discoverServers, buildMetaTools, listServerTools };
|
package/types/index.d.ts
CHANGED
|
@@ -224,6 +224,8 @@ export interface ToolDef {
|
|
|
224
224
|
name: string;
|
|
225
225
|
description?: string;
|
|
226
226
|
parameters?: Record<string, any>;
|
|
227
|
+
/** MCP tools only: the server's own behavioural hints (readOnlyHint, ...), passed through unmodified. Untrusted metadata, never an authorization signal. Absent when the server sent none; a non-object value from the server is treated as absent. */
|
|
228
|
+
annotations?: Record<string, any>;
|
|
227
229
|
execute(args: any): any | Promise<any>;
|
|
228
230
|
}
|
|
229
231
|
|