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.
- package/bareagent.context.md +4 -2
- package/package.json +1 -1
- package/primitives.json +10 -1
- package/src/mcp-bridge.d.ts +42 -2
- package/src/mcp-bridge.js +241 -75
- 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.
|
|
@@ -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`
|
|
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
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
|
/**
|
|
@@ -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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|