bare-agent 0.53.0 → 0.54.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.
@@ -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.
@@ -429,7 +429,7 @@ every command it is about to spawn — `confirmServer` is how you actually *gate
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` + `tools/list`);
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 }` —
@@ -1535,6 +1535,8 @@ try {
1535
1535
 
1536
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
1537
 
1538
+ **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.
1539
+
1538
1540
  ```json
1539
1541
  {
1540
1542
  "discovered": "2026-04-13T12:00:00.000Z",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
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?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>",
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",
@@ -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?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>}
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?: Array<{
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
  /**
@@ -370,6 +372,14 @@ function unwrapContent(content) {
370
372
 
371
373
  // --- Tool wrapping ---
372
374
 
375
+ /**
376
+ * A server "sent annotations" only when it sent a non-null, non-array object. A non-object
377
+ * `annotations` value (null, array, string, ...) is treated as absent (dropped), so the key stays absent downstream.
378
+ * @param {any} a
379
+ * @returns {boolean}
380
+ */
381
+ const hasAnnotations = (a) => a !== null && typeof a === 'object' && !Array.isArray(a);
382
+
373
383
  // Runtime arg-dependent policy has moved to Loop-level (new Loop({ policy })).
374
384
  // mcp-bridge retains only the static .mcp-bridge.json allow/deny filter below —
375
385
  // that decides which tools are exposed to the Loop in the first place.
@@ -385,6 +395,8 @@ function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
385
395
  name: `${serverName}_${t.name}`,
386
396
  description: t.description || '',
387
397
  parameters: t.inputSchema || { type: 'object', properties: {} },
398
+ // Server `annotations` ride through as the SAME object, unmodified; key ABSENT when none sent.
399
+ ...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
388
400
  execute: async (args) => {
389
401
  const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout, { server: serverName, tool: t.name });
390
402
  if (result.isError) {
@@ -440,36 +452,79 @@ async function killServer(child) {
440
452
 
441
453
  // --- Connect + list tools from a server ---
442
454
 
455
+ // tools/list pagination cap. A server that keeps handing out cursors past this is faulty or
456
+ // hostile; we THROW (never truncate silently — a partial catalog reads as a complete one).
457
+ const MAX_LIST_PAGES = 100;
458
+
443
459
  /**
444
460
  * @param {string} name
445
461
  * @param {ServerDef} def
446
- * @param {number} [timeout]
462
+ * @param {number} [timeout] - Bound on EACH round-trip (initialize, every tools/list page).
463
+ * @param {{ deadlineMs?: number }} [o] - Optional ONE total wall-clock ceiling over the whole
464
+ * connect+list (internal; used by listServerTools). On trip the child is killed and a labelled
465
+ * TimeoutError rejects. The timer is always cleared.
447
466
  * @returns {Promise<{mcpTools: McpTool[], client: RpcClient}>}
448
467
  */
449
- async function connectAndListTools(name, def, timeout = 15000) {
468
+ async function connectAndListTools(name, def, timeout = 15000, o = {}) {
450
469
  const client = createRpcClient(name, def);
470
+ /** @type {NodeJS.Timeout | undefined} */
471
+ let deadlineTimer;
451
472
 
452
473
  try {
453
- // Both handshake round-trips are bounded by `timeout`. tools/list used to be
454
- // unbounded — a server that answered initialize but never replied to
455
- // tools/list would hang discovery (and the whole bridge) indefinitely.
456
- await client.rpc('initialize', {
457
- protocolVersion: '2024-11-05',
458
- capabilities: {},
459
- clientInfo: { name: 'bare-agent', version: '0.5.0' },
460
- }, timeout);
461
- client.notify('notifications/initialized');
462
-
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
- }
474
+ const work = (async () => {
475
+ // Every round-trip is bounded by `timeout`. tools/list used to be
476
+ // unbounded — a server that answered initialize but never replied to
477
+ // tools/list would hang discovery (and the whole bridge) indefinitely.
478
+ await client.rpc('initialize', {
479
+ protocolVersion: '2024-11-05',
480
+ capabilities: {},
481
+ clientInfo: { name: 'bare-agent', version: '0.5.0' },
482
+ }, timeout);
483
+ client.notify('notifications/initialized');
484
+
485
+ // Follow `nextCursor` until the server stops sending one. A reply without a `tools` array is a
486
+ // protocol-violating server, not an empty one: fail the connect HERE (inside the try, so the
487
+ // child is reaped) rather than let every caller's `.map`/`.filter` throw after the child is
488
+ // already running but before it is tracked.
489
+ /** @type {McpTool[]} */
490
+ const mcpTools = [];
491
+ /** @type {Set<string>} */
492
+ const seenCursors = new Set();
493
+ /** @type {string | undefined} */
494
+ let cursor;
495
+ for (let pages = 1; ; pages++) {
496
+ const listed = await client.rpc('tools/list', cursor === undefined ? {} : { cursor }, timeout);
497
+ if (!listed || !Array.isArray(listed.tools)) {
498
+ throw new Error(`MCP server "${name}" returned a malformed tools/list reply (no tools array)`);
499
+ }
500
+ mcpTools.push(...listed.tools);
501
+ const next = listed.nextCursor;
502
+ if (typeof next !== 'string' || next === '') break;
503
+ if (seenCursors.has(next)) {
504
+ throw new Error(`MCP server "${name}" repeated a tools/list cursor (pagination loop)`);
505
+ }
506
+ if (pages >= MAX_LIST_PAGES) {
507
+ throw new Error(`MCP server "${name}" exceeded the tools/list page cap (${MAX_LIST_PAGES} pages); refusing to return a truncated catalog`);
508
+ }
509
+ seenCursors.add(next);
510
+ cursor = next;
511
+ }
471
512
 
472
- return { mcpTools, client };
513
+ return { mcpTools, client };
514
+ })();
515
+
516
+ if (!o.deadlineMs) return await work;
517
+ // The loser of the race must not surface as an unhandled rejection.
518
+ work.catch(() => {});
519
+ const deadline = new Promise((_, reject) => {
520
+ deadlineTimer = setTimeout(() => {
521
+ reject(new TimeoutError(
522
+ `MCP server "${name}" exceeded the total deadline of ${o.deadlineMs}ms for connect + tools/list`,
523
+ { context: { timeout: true, bound: 'deadline', server: name, deadlineMs: o.deadlineMs } },
524
+ ));
525
+ }, o.deadlineMs);
526
+ });
527
+ return await Promise.race([work, deadline]);
473
528
  } catch (err) {
474
529
  // Connect failed (init timeout, tools/list error, etc.) — the child was
475
530
  // spawned but never returned to the caller, so the caller can't track it
@@ -477,6 +532,8 @@ async function connectAndListTools(name, def, timeout = 15000) {
477
532
  // alive and hangs the process on exit (notably node:test's file wrapper).
478
533
  await killServer(client.child);
479
534
  throw err;
535
+ } finally {
536
+ clearTimeout(deadlineTimer);
480
537
  }
481
538
  }
482
539
 
@@ -565,6 +622,7 @@ function buildMetaTools(tools, discoveredAt, excluded) {
565
622
  schema: t.parameters || { type: 'object', properties: {} },
566
623
  server: sep > 0 ? t.name.slice(0, sep) : t.name,
567
624
  tool: sep > 0 ? t.name.slice(sep + 1) : '',
625
+ ...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
568
626
  };
569
627
  });
570
628
  const byName = new Map(tools.map(t => [t.name, t]));
@@ -636,6 +694,45 @@ function buildMetaTools(tools, discoveredAt, excluded) {
636
694
  return [mcpDiscover, mcpInvoke];
637
695
  }
638
696
 
697
+ // --- Server vetting (shared by createMCPBridge and listServerTools) ---
698
+
699
+ /**
700
+ * Vet a server before spawning its command. `=== true` proceeds; anything else, or a throw, denies
701
+ * (fail-closed). No hook ⇒ trust all.
702
+ * @param {((name: string, def: ServerDef) => boolean | Promise<boolean>) | null} confirmServer
703
+ * @returns {(name: string, def: ServerDef) => Promise<boolean>}
704
+ */
705
+ function makeVetServer(confirmServer) {
706
+ return async (name, def) => {
707
+ if (!confirmServer) return true;
708
+ try { return (await confirmServer(name, def)) === true; }
709
+ catch { return false; }
710
+ };
711
+ }
712
+
713
+ /**
714
+ * Connecting to a server EXECUTES its `command`. The project-cwd `.mcp.json` is excluded from
715
+ * default discovery (see TRUSTED_CONFIG_PATHS / includeProjectConfig), so the untrusted-repo
716
+ * path is closed by default; this warning (stderr) covers the residual case where home/IDE configs
717
+ * (or an explicit opt-in) contribute commands and no confirmServer hook is present to vet them.
718
+ * Warns ONCE per returned function, BEFORE the first spawn.
719
+ * @param {Function | null} confirmServer
720
+ * @returns {(specs: Array<{name: string, command: string, args?: string[]}>) => void}
721
+ */
722
+ function makeUnvettedWarner(confirmServer) {
723
+ let warned = false;
724
+ return (specs) => {
725
+ if (confirmServer || warned || specs.length === 0) return;
726
+ warned = true;
727
+ const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
728
+ console.warn(
729
+ `[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
730
+ cmds.join('\n ') +
731
+ `\n Pass { confirmServer } to vet each command before it runs.`,
732
+ );
733
+ };
734
+ }
735
+
639
736
  // --- Signed allow-list mode (opts.allow) ---
640
737
 
641
738
  /** @param {any} v @returns {string} */
@@ -793,7 +890,7 @@ async function createAllowBridge(allow, o) {
793
890
  * discovered server is trusted — pass this to gate command execution. Presence
794
891
  * of this hook also opts default discovery into the project-cwd `./.mcp.json`,
795
892
  * since each command is then vetted regardless of source.
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}>}
893
+ * @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
894
  * @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
895
  * @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
896
  * @example
@@ -816,31 +913,8 @@ async function createMCPBridge(opts = {}) {
816
913
  // Vet a server before spawning its command. Fail-closed: an undefined hook
817
914
  // trusts all (unchanged behavior); a throw denies.
818
915
  const confirmServer = typeof opts.confirmServer === 'function' ? opts.confirmServer : null;
819
- /** @param {string} name @param {ServerDef} def @returns {Promise<boolean>} */
820
- const vetServer = async (name, def) => {
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
- };
916
+ const vetServer = makeVetServer(confirmServer);
917
+ const warnUnvettedSpawn = makeUnvettedWarner(confirmServer);
844
918
 
845
919
  if (opts.allow !== undefined) {
846
920
  const allow = validateAllow(opts.allow);
@@ -854,6 +928,29 @@ async function createMCPBridge(opts = {}) {
854
928
  let config = readBridgeConfig(bridgePath);
855
929
  const needsRefresh = opts.refresh || !config || isExpired(config);
856
930
 
931
+ // Per-server connect failures from BOTH passes (discovery, then main), first message wins. `errors`
932
+ // is derived from this once, at return time: servers that did not end up connected, once each.
933
+ /** @type {Map<string, string>} */
934
+ const failures = new Map();
935
+ /** @param {string} name @param {unknown} err */
936
+ const noteFailure = (name, err) => {
937
+ if (failures.has(name)) return;
938
+ const message = err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' ? err.message : String(err);
939
+ failures.set(name, message);
940
+ };
941
+ /** The one place `errors` is computed (and the failure warns issued). @param {string[]} connected */
942
+ const finalErrors = (connected) => {
943
+ const up = new Set(connected);
944
+ const errors = [...failures].filter(([name]) => !up.has(name)).map(([server, error]) => ({ server, error }));
945
+ if (errors.length > 0) console.warn('[MCP Bridge] Some servers failed to connect:', errors);
946
+ // Discovery failed but the cached definition connected: not an error, yet the config edit was not applied.
947
+ const cachedUsed = [...failures].filter(([name]) => up.has(name)).map(([server, error]) => ({ server, error }));
948
+ if (cachedUsed.length > 0) console.warn('[MCP Bridge] Discovery failed for server(s) that connected from the cached definition (config edit not applied):', cachedUsed);
949
+ return errors;
950
+ };
951
+ /** Early return: nothing connected. */
952
+ const nothingConnected = () => ({ tools: [], servers: [], systemContext: '', denied: [], errors: finalErrors([]), close: async () => {} });
953
+
857
954
  if (needsRefresh) {
858
955
  // Discover from IDE configs. The project-cwd `.mcp.json` is excluded by default (untrusted-repo
859
956
  // RCE vector); it is scanned only on explicit opt-in, or when a `confirmServer` hook is present
@@ -861,9 +958,7 @@ async function createMCPBridge(opts = {}) {
861
958
  const includeProjectConfig = opts.includeProjectConfig === true || !!confirmServer;
862
959
  const discovered = discoverServers(opts.configPaths, { includeProjectConfig });
863
960
 
864
- if (discovered.size === 0 && !config) {
865
- return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
866
- }
961
+ if (discovered.size === 0 && !config) return nothingConnected();
867
962
 
868
963
  // Only attempt connection when discovery found something.
869
964
  // If discovered.size === 0 but config exists, fall through and use the existing config
@@ -873,9 +968,6 @@ async function createMCPBridge(opts = {}) {
873
968
  const freshTools = new Map();
874
969
  /** @type {Map<string, RpcClient>} */
875
970
  const connectResults = new Map();
876
- /** @type {Array<{server: string, error: string}>} */
877
- const errors = [];
878
-
879
971
  const reqServers = opts.servers;
880
972
  const toDiscover = reqServers
881
973
  ? [...discovered.entries()].filter(([n]) => reqServers.includes(n))
@@ -892,14 +984,10 @@ async function createMCPBridge(opts = {}) {
892
984
  connectResults.set(name, result.client);
893
985
  freshTools.set(name, result.mcpTools);
894
986
  } catch (err) {
895
- errors.push({ server: name, error: err.message });
987
+ noteFailure(name, err);
896
988
  }
897
989
  }));
898
990
 
899
- if (errors.length > 0) {
900
- console.warn('[MCP Bridge] Some servers failed to connect:', errors);
901
- }
902
-
903
991
  // Discovery connections are killed in `finally`: a throw from the config merge/write (e.g. an
904
992
  // unwritable bridgePath) must not strand the live children held only in connectResults.
905
993
  try {
@@ -911,7 +999,7 @@ async function createMCPBridge(opts = {}) {
911
999
  writeBridgeConfig(bridgePath, config);
912
1000
  console.log(`[MCP Bridge] Wrote ${bridgePath}`);
913
1001
  } else if (!config) {
914
- return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
1002
+ return nothingConnected();
915
1003
  }
916
1004
 
917
1005
  } finally {
@@ -925,9 +1013,7 @@ async function createMCPBridge(opts = {}) {
925
1013
 
926
1014
  // At this point config is guaranteed non-null: it either existed, was just
927
1015
  // written by the refresh branch, or we returned early above.
928
- if (!config) {
929
- return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
930
- }
1016
+ if (!config) return nothingConnected();
931
1017
  /** @type {BridgeConfig} */
932
1018
  const cfg = config;
933
1019
 
@@ -937,9 +1023,7 @@ async function createMCPBridge(opts = {}) {
937
1023
  ? reqServers2.filter(n => cfg.servers[n])
938
1024
  : Object.keys(cfg.servers);
939
1025
 
940
- if (serverNames.length === 0) {
941
- return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
942
- }
1026
+ if (serverNames.length === 0) return nothingConnected();
943
1027
 
944
1028
  // Warn before the main-connect spawn too. On a warm run (config exists, no
945
1029
  // refresh) this is the first and only spawn; on a cold run the discovery phase
@@ -955,8 +1039,6 @@ async function createMCPBridge(opts = {}) {
955
1039
  const connected = [];
956
1040
  /** @type {DeniedTool[]} */
957
1041
  const denied = [];
958
- /** @type {Array<{server: string, error: string}>} */
959
- const errors = [];
960
1042
 
961
1043
  await Promise.all(serverNames.map(async (name) => {
962
1044
  const serverConf = cfg.servers[name];
@@ -988,14 +1070,10 @@ async function createMCPBridge(opts = {}) {
988
1070
  }
989
1071
  }
990
1072
  } catch (err) {
991
- errors.push({ server: name, error: err.message });
1073
+ noteFailure(name, err);
992
1074
  }
993
1075
  }));
994
1076
 
995
- if (errors.length > 0) {
996
- console.warn('[MCP Bridge] Some servers failed to connect:', errors);
997
- }
998
-
999
1077
  const systemContext = buildSystemContext(connected, tools, denied);
1000
1078
  if (connected.length > 0) console.log(systemContext);
1001
1079
 
@@ -1007,11 +1085,99 @@ async function createMCPBridge(opts = {}) {
1007
1085
  servers: connected,
1008
1086
  denied,
1009
1087
  systemContext,
1010
- errors,
1088
+ errors: finalErrors(connected),
1011
1089
  close: async () => {
1012
1090
  await Promise.all(children.map(killServer));
1013
1091
  },
1014
1092
  };
1015
1093
  }
1016
1094
 
1017
- module.exports = { createMCPBridge, discoverServers, buildMetaTools };
1095
+ // --- listServerTools: one-shot read-only listing ---
1096
+
1097
+ // Total-deadline multiplier for listServerTools. `timeout` bounds EACH round-trip; a server that
1098
+ // answers every page just under it could otherwise stretch a call to (1 + pages) × timeout. The one
1099
+ // total ceiling is 4 × timeout = initialize plus three further maximally-slow round-trips: a normal
1100
+ // server answers in milliseconds and a handful of pages never approaches it, while a server that
1101
+ // needs more than that is slow enough to treat as faulty. Derived, not a new option.
1102
+ const LIST_DEADLINE_MULTIPLIER = 4;
1103
+
1104
+ /**
1105
+ * @param {string} msg
1106
+ * @returns {ValidationError}
1107
+ */
1108
+ const listBad = (msg) => new ValidationError(`[MCP Bridge] listServerTools: ${msg}`, { context: { lib: 'bare-agent' } });
1109
+
1110
+ /**
1111
+ * List one configured MCP server's tools WITHOUT building a bridge: discover, vet, connect, list
1112
+ * (following pagination), then always kill the child. Never reads or writes `.mcp-bridge.json`
1113
+ * and prints nothing to stdout. `annotations` are the server's own HINTS, passed through unmodified
1114
+ * (key absent when the server sent none) — never an authorization signal.
1115
+ *
1116
+ * Total wall-clock ceiling is DERIVED: 4 × `timeout` (initialize + three maximally slow pages) over
1117
+ * the connect + list, excluding `confirmServer` (caller code, possibly interactive). On trip the
1118
+ * child is killed and a TimeoutError naming the total deadline rejects. A SMALL `timeout` shrinks the
1119
+ * total proportionally (timeout 500 ⇒ 2s total), so a server with many pages near that speed can trip it.
1120
+ * A non-object `annotations` value from the server is treated as absent (dropped).
1121
+ *
1122
+ * @param {object} opts
1123
+ * @param {string} opts.server - Server name as it appears in the discovered configs.
1124
+ * @param {string[]} [opts.configPaths] - Explicit config paths; omitted ⇒ trusted $HOME/IDE defaults.
1125
+ * @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer] - Same semantics as createMCPBridge: `=== true` proceeds, a throw denies. Implies includeProjectConfig.
1126
+ * @param {boolean} [opts.includeProjectConfig=false] - Also scan `./.mcp.json` during default discovery.
1127
+ * @param {number} [opts.timeout=15000] - Per-round-trip bound in ms (initialize and each tools/list page).
1128
+ * @returns {Promise<Array<{name: string, description: string, annotations?: Record<string, any>}>>}
1129
+ * @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
1130
+ * @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.
1131
+ * @example
1132
+ * const tools = await listServerTools({ server: 'files', configPaths: [cfg] });
1133
+ */
1134
+ async function listServerTools(opts) {
1135
+ if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
1136
+ throw listBad(`opts must be an object (got ${typeName(opts)})`);
1137
+ }
1138
+ const { server, configPaths, confirmServer, includeProjectConfig, timeout = 15000 } = opts;
1139
+ if (typeof server !== 'string' || server === '') {
1140
+ throw listBad(`server must be a non-empty string (got ${server === '' ? 'empty string' : typeName(server)})`);
1141
+ }
1142
+ if (configPaths !== undefined) {
1143
+ if (!Array.isArray(configPaths)) throw listBad(`configPaths must be an array of strings (got ${typeName(configPaths)})`);
1144
+ for (const p of configPaths) {
1145
+ if (typeof p !== 'string' || p === '') throw listBad(`configPaths entries must be non-empty strings (got ${p === '' ? 'empty string' : typeName(p)})`);
1146
+ }
1147
+ }
1148
+ if (confirmServer !== undefined && typeof confirmServer !== 'function') {
1149
+ throw listBad(`confirmServer must be a function (got ${typeName(confirmServer)})`);
1150
+ }
1151
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
1152
+ throw listBad(`timeout must be a positive finite number (got ${typeName(timeout)})`);
1153
+ }
1154
+
1155
+ const confirm = typeof confirmServer === 'function' ? confirmServer : null;
1156
+ const discovered = discoverServers(configPaths, { includeProjectConfig: includeProjectConfig === true || !!confirm });
1157
+ const def = discovered.get(server);
1158
+ if (!def) {
1159
+ throw new ToolError(`MCP server "${server}" not found in discovered MCP configs`, { context: { server, notFound: true } });
1160
+ }
1161
+ makeUnvettedWarner(confirm)([{ name: server, command: def.command, args: def.args }]);
1162
+ if (!(await makeVetServer(confirm)(server, def))) {
1163
+ throw new ToolError(`MCP server "${server}" was not approved by confirmServer`, { context: { server, denied: true } });
1164
+ }
1165
+
1166
+ const { mcpTools, client } = await connectAndListTools(server, def, timeout, { deadlineMs: timeout * LIST_DEADLINE_MULTIPLIER });
1167
+ try {
1168
+ return mcpTools.map(t => {
1169
+ if (t === null || typeof t !== 'object' || typeof t.name !== 'string' || t.name === '') {
1170
+ throw new ToolError(`MCP server "${server}" returned a malformed tools/list entry (no string name)`, { context: { server } });
1171
+ }
1172
+ return {
1173
+ name: t.name,
1174
+ description: t.description || '',
1175
+ ...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
1176
+ };
1177
+ });
1178
+ } finally {
1179
+ await killServer(client.child);
1180
+ }
1181
+ }
1182
+
1183
+ 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
- export { createMCPBridge, discoverServers, buildMetaTools };
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