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.
@@ -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` + `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 }` —
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 server that breaks its stdin pipe surfaces as a
439
- failed connection, never an uncaught `EPIPE` crash.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.53.0",
3
+ "version": "0.54.1",
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
  /**
@@ -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
- const child = spawn(command, args, {
242
- stdio: ['pipe', 'pipe', 'pipe'],
243
- env: mergedEnv,
244
- ...(cwd && { cwd }),
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
- settle(id, false, new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
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(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
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
- if (err) settle(id, false, new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`));
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
- // 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
- }
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
- return { mcpTools, client };
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?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>}
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
- /** @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
- };
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
- errors.push({ server: name, error: err.message });
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 { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
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
- errors.push({ server: name, error: err.message });
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
- module.exports = { createMCPBridge, discoverServers, buildMetaTools };
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
- 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