bare-agent 0.52.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 +16 -5
- package/package.json +1 -1
- package/primitives.json +13 -4
- package/src/bareguard-adapter.d.ts +4 -0
- package/src/bareguard-adapter.js +38 -0
- package/src/mcp-bridge.d.ts +70 -7
- package/src/mcp-bridge.js +423 -93
- 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.
|
|
@@ -72,7 +72,7 @@ Eight entry points:
|
|
|
72
72
|
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
|
|
73
73
|
| Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
|
|
74
74
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
75
|
-
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
75
|
+
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` (or per-run `{ allow }`) |
|
|
76
76
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
77
77
|
| Route policy decisions per user / tenant / chat | `wireGate(gate).policy` + `loop.run(msgs, tools, { ctx })` (ctx routes to bareguard's check via `_ctx`) |
|
|
78
78
|
| Cap total USD spend per run | `new Gate({ budget: { maxCostUsd: 0.50 } })` |
|
|
@@ -429,9 +429,12 @@ 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
|
-
timed-out tool call rejects with a
|
|
434
|
+
timed-out tool call rejects with a labelled `ToolError`
|
|
435
|
+
(`MCP server "s" tool "t" timed out after Nms`, `context: { timeout: true, server, tool, timeoutMs, stderr }` —
|
|
436
|
+
stderr is in `context`, not the message; `wireGate`'s `onToolResult` forwards only the
|
|
437
|
+
non-stderr label fields to the audit row as an allow-listed `errorContext`) rather
|
|
435
438
|
than blocking forever; a server that breaks its stdin pipe surfaces as a
|
|
436
439
|
failed connection, never an uncaught `EPIPE` crash.
|
|
437
440
|
|
|
@@ -477,6 +480,8 @@ if (result.error?.startsWith('halt:')) {
|
|
|
477
480
|
|
|
478
481
|
**Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). It also fires for the out-of-band `ctx.summarize` call (R-C6) tagged `kind:'summarize'`; main-loop rounds carry `kind:'turn'` — so summary-window tokens count against the budget too, and a consumer can tell the two apart. `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
|
|
479
482
|
|
|
483
|
+
`onToolResult` on a tool ERROR records `{error, durationMs}` plus an allow-listed `errorContext` copied from `error.context` — `timeout`/`denied` (boolean), `server`/`tool` (string, ≤200 chars), `timeoutMs` (finite number); wrong-typed values are dropped and `stderr`/everything else is never forwarded (the gate writes the record verbatim into the audit row). The key is absent when no allowed field is present.
|
|
484
|
+
|
|
480
485
|
Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
|
|
481
486
|
|
|
482
487
|
**A bound that fires PRESERVES the model's work (BA-5, v0.27+).** Every terminating path — governance halt, the deny-spin short-circuit, a provider error under `throwOnError:false`, `loop.stop()`, and the internal hard round limit — returns the **last non-empty assistant text** in `result.text` instead of `''`. If you drive bare-agent from an outer retry loop (`while not-done and under-cap: run the worker`), a bound firing is **normal termination**, not an exception, and that text is your only channel from attempt N to attempt N+1 — before this, a bounded attempt taught its successor nothing. Two rules: **`error` is the sole success signal** (a non-empty `text` never means the run converged — always branch on `error`), and `text` is `''` when the model genuinely produced none (no placeholder is invented, so you can trust emptiness). Under `recurse` this is what finally populates `best` on an incomplete node. Relatedly, **`loop.stop()` now returns `error: null`** — a deliberate stop is not a fault; it previously fell through to the hard-round-limit return and reported that safety warning as its error, indistinguishable from a runaway.
|
|
@@ -1508,7 +1513,7 @@ const { createMCPBridge } = require('bare-agent/mcp');
|
|
|
1508
1513
|
const provider = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' });
|
|
1509
1514
|
|
|
1510
1515
|
const bridge = await createMCPBridge();
|
|
1511
|
-
// bridge = { tools, servers, denied, systemContext, errors, close }
|
|
1516
|
+
// bridge = { tools, servers, denied, systemContext, errors, close } (+ missing when `allow` is given)
|
|
1512
1517
|
|
|
1513
1518
|
const loop = new Loop({
|
|
1514
1519
|
provider,
|
|
@@ -1528,6 +1533,10 @@ try {
|
|
|
1528
1533
|
|
|
1529
1534
|
**Governance via `.mcp-bridge.json`.** On first run, the bridge writes `.mcp-bridge.json` in the cwd listing every discovered server and tool with permission `"allow"`. Edit any entry to `"deny"` and the tool is dropped from the next run's tool array; the LLM sees it listed in `systemContext` as restricted, with instructions not to retry it. Re-discovery happens automatically after TTL expiry (default `24h`, settable via `ttl` field in the file).
|
|
1530
1535
|
|
|
1536
|
+
**Signed per-run allow-list (`allow`).** `createMCPBridge({ allow: { server: ['raw_tool_name', ...] } })` skips `.mcp-bridge.json` entirely (never read or written — no cache, no TTL; `bridgePath`/`refresh`/`servers` ignored), takes server definitions only from `discoverServers(configPaths)`, spawns only the named servers (still `confirmServer`-vetted), connects once and exposes signed ∩ listed. Everything else is absent from `tools`, the `metaTools` catalog and `systemContext` (no "Restricted tools" text, `denied: []`), nothing goes to stdout. The return gains `missing: [{server, tool}]` (signed tools the server did not list, or whose server was not found/failed/rejected) — never a throw. `mcp_invoke` of a listed-but-unsigned name throws `ToolError` with `context.denied === true`. Bad `allow` shape (including an empty tool list) throws `ValidationError`. In every mode (not only `allow`), a server whose `tools/list` reply has no `tools` array (e.g. `{}`) now fails its connect — it lands in `errors`, its child is killed — where it used to read as a working empty server.
|
|
1537
|
+
|
|
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
|
+
|
|
1531
1540
|
```json
|
|
1532
1541
|
{
|
|
1533
1542
|
"discovered": "2026-04-13T12:00:00.000Z",
|
|
@@ -1596,6 +1605,8 @@ MCP tools arrive with the server name prepended (`beeperbox_send_message`, not `
|
|
|
1596
1605
|
| `servers` | all discovered | Limit to a subset by name |
|
|
1597
1606
|
| `timeout` | `15000` | Per-server init timeout in ms |
|
|
1598
1607
|
| `refresh` | `false` | Force re-discovery regardless of TTL |
|
|
1608
|
+
| `allow` | none | Signed per-run allow-list `{ server: [rawToolNames] }` — in-memory, no `.mcp-bridge.json`; see "Signed per-run allow-list" above |
|
|
1609
|
+
| `callTimeout` | `120000` | Per-`tools/call` timeout in ms (`0` disables) |
|
|
1599
1610
|
|
|
1600
1611
|
### Recipe 10: beeperbox — multi-messenger reach via MCP bridge
|
|
1601
1612
|
|
package/package.json
CHANGED
package/primitives.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"category": "mcp",
|
|
34
34
|
"when": "you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation",
|
|
35
35
|
"import": "import { buildMetaTools } from 'bare-agent/mcp'",
|
|
36
|
-
"signature": "buildMetaTools(tools: ToolDef[], discoveredAt?: string) => ToolDef[]",
|
|
36
|
+
"signature": "buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded?: Map<string, {server: string, tool: string}>) => ToolDef[]",
|
|
37
37
|
"fails": "returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).",
|
|
38
38
|
"example": "const metaTools = buildMetaTools(tools);"
|
|
39
39
|
},
|
|
@@ -121,10 +121,10 @@
|
|
|
121
121
|
{
|
|
122
122
|
"name": "createMCPBridge",
|
|
123
123
|
"category": "mcp",
|
|
124
|
-
"when": "you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution",
|
|
124
|
+
"when": "you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools",
|
|
125
125
|
"import": "import { createMCPBridge } from 'bare-agent/mcp'",
|
|
126
|
-
"signature": "createMCPBridge(opts?: object) => Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors
|
|
127
|
-
"fails": "a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.",
|
|
126
|
+
"signature": "createMCPBridge(opts?: object) => Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>",
|
|
127
|
+
"fails": "a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.",
|
|
128
128
|
"example": "const { tools, close } = await createMCPBridge();\nconst loop = new Loop({ provider, tools });"
|
|
129
129
|
},
|
|
130
130
|
{
|
|
@@ -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",
|
|
@@ -130,6 +130,10 @@ export type Annotation = {
|
|
|
130
130
|
* (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
|
|
131
131
|
* activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
|
|
132
132
|
* when unset — see CHANGELOG) — override when a different shape is needed.
|
|
133
|
+
* `onToolResult` on a tool error records `{error: message, durationMs}` plus, only when present, an
|
|
134
|
+
* allow-listed `errorContext` copied from `error.context` (`timeout`/`denied` booleans, `server`/`tool`
|
|
135
|
+
* strings capped at 200 chars, finite `timeoutMs`; wrong-typed values dropped) — so an MCP timeout is
|
|
136
|
+
* distinguishable in the audit row. `stderr` and every other context field are NEVER forwarded.
|
|
133
137
|
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
134
138
|
* @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
|
|
135
139
|
* @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
|
package/src/bareguard-adapter.js
CHANGED
|
@@ -41,6 +41,38 @@ function safeStringify(value) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Keys of a tool error's `context` that onToolResult may forward to gate.record (and so into the
|
|
45
|
+
// audit row), with the exact type each must have. ALLOW-LIST, never the whole object: MCP timeout
|
|
46
|
+
// errors carry `stderr` (server output, possibly secret-bearing) in `context`, and gate.record writes
|
|
47
|
+
// its result verbatim to the audit log. Anything not listed here, or of the wrong type, is dropped.
|
|
48
|
+
const ERROR_CONTEXT_KEYS = /** @type {const} */ ({
|
|
49
|
+
timeout: 'boolean', denied: 'boolean', server: 'string', tool: 'string', timeoutMs: 'number',
|
|
50
|
+
});
|
|
51
|
+
const ERROR_CONTEXT_MAX_STR = 200;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {any} error
|
|
55
|
+
* @returns {Record<string, boolean|string|number>|undefined} undefined when no allowed key is present
|
|
56
|
+
*/
|
|
57
|
+
function pickErrorContext(error) {
|
|
58
|
+
/** @type {Record<string, boolean|string|number>} */
|
|
59
|
+
const out = {};
|
|
60
|
+
try {
|
|
61
|
+
const ctx = error?.context;
|
|
62
|
+
if (ctx === null || typeof ctx !== 'object') return undefined;
|
|
63
|
+
for (const [key, type] of Object.entries(ERROR_CONTEXT_KEYS)) {
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(ctx, key)) continue;
|
|
65
|
+
const v = ctx[key];
|
|
66
|
+
if (typeof v !== type) continue;
|
|
67
|
+
if (type === 'number' && !Number.isFinite(v)) continue;
|
|
68
|
+
out[key] = type === 'string' ? v.slice(0, ERROR_CONTEXT_MAX_STR) : v;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined; // a throwing getter / exotic context must never break the audit write
|
|
72
|
+
}
|
|
73
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
// Module-scope so a process that spawns many child agents (each with its own
|
|
45
77
|
// wireGate call) only prints the wrapTool deprecation warning once.
|
|
46
78
|
let warnedWrap = false;
|
|
@@ -98,6 +130,10 @@ let warnedWrap = false;
|
|
|
98
130
|
* (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
|
|
99
131
|
* activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
|
|
100
132
|
* when unset — see CHANGELOG) — override when a different shape is needed.
|
|
133
|
+
* `onToolResult` on a tool error records `{error: message, durationMs}` plus, only when present, an
|
|
134
|
+
* allow-listed `errorContext` copied from `error.context` (`timeout`/`denied` booleans, `server`/`tool`
|
|
135
|
+
* strings capped at 200 chars, finite `timeoutMs`; wrong-typed values dropped) — so an MCP timeout is
|
|
136
|
+
* distinguishable in the audit row. `stderr` and every other context field are NEVER forwarded.
|
|
101
137
|
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
102
138
|
* @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
|
|
103
139
|
* @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
|
|
@@ -201,8 +237,10 @@ function wireGate(gate, options = {}) {
|
|
|
201
237
|
const onToolResult = async ({ name, args, result, error, durationMs, ctx }) => {
|
|
202
238
|
const action = translate(name, args, ctx);
|
|
203
239
|
if (error) {
|
|
240
|
+
const errorContext = pickErrorContext(error);
|
|
204
241
|
await gate.record(action, {
|
|
205
242
|
error: error?.message || String(error),
|
|
243
|
+
...(errorContext && { errorContext }),
|
|
206
244
|
durationMs: durationMs ?? null,
|
|
207
245
|
});
|
|
208
246
|
} else {
|
package/src/mcp-bridge.d.ts
CHANGED
|
@@ -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.
|
|
@@ -52,7 +57,10 @@ export type DeniedTool = {
|
|
|
52
57
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
53
58
|
*/
|
|
54
59
|
export type RpcClient = {
|
|
55
|
-
rpc: (method: string, params?: object, timeoutMs?: number
|
|
60
|
+
rpc: (method: string, params?: object, timeoutMs?: number, label?: {
|
|
61
|
+
server: string;
|
|
62
|
+
tool: string;
|
|
63
|
+
}) => Promise<any>;
|
|
56
64
|
notify: (method: string, params?: object) => void;
|
|
57
65
|
child: import("node:child_process").ChildProcessWithoutNullStreams;
|
|
58
66
|
stderr: string;
|
|
@@ -79,7 +87,16 @@ export type RpcClient = {
|
|
|
79
87
|
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
80
88
|
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
81
89
|
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
82
|
-
* @param {string[]} [opts.
|
|
90
|
+
* @param {Record<string, string[]>} [opts.allow] - Signed allow-list `{ [serverName]: rawMcpToolNames[] }`
|
|
91
|
+
* (raw MCP names, NOT the `server_tool` prefixed form; each list must be non-empty). Switches to an IN-MEMORY mode: never reads or
|
|
92
|
+
* writes `.mcp-bridge.json` (no cache, no TTL; `bridgePath`/`refresh`/`servers` are ignored); server
|
|
93
|
+
* definitions come only from `discoverServers(configPaths)`; only servers named here are spawned (still
|
|
94
|
+
* vetted by `confirmServer`); each connects ONCE and exposes signed ∩ listed — every other tool is ABSENT
|
|
95
|
+
* (not in `tools`, `metaTools` catalog or `systemContext`). Signed tools the server does not list (or whose
|
|
96
|
+
* server was not found / failed / was rejected) come back in `missing`. `denied` is always `[]`, nothing is
|
|
97
|
+
* written to stdout, and `mcp_invoke` on a listed-but-unsigned name throws `context.denied === true`.
|
|
98
|
+
* Invalid shape throws `ValidationError` (type only, never values).
|
|
99
|
+
* @param {string[]} [opts.servers] - Limit to these server names (ignored when `allow` is given).
|
|
83
100
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
84
101
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
85
102
|
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
@@ -91,9 +108,9 @@ export type RpcClient = {
|
|
|
91
108
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
92
109
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
93
110
|
* since each command is then vetted regardless of source.
|
|
94
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors
|
|
95
|
-
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution
|
|
96
|
-
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.
|
|
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}>}
|
|
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
|
|
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.
|
|
97
114
|
* @example
|
|
98
115
|
* const { tools, close } = await createMCPBridge();
|
|
99
116
|
* const loop = new Loop({ provider, tools });
|
|
@@ -102,6 +119,7 @@ export function createMCPBridge(opts?: {
|
|
|
102
119
|
bridgePath?: string | undefined;
|
|
103
120
|
configPaths?: string[] | undefined;
|
|
104
121
|
includeProjectConfig?: boolean | undefined;
|
|
122
|
+
allow?: Record<string, string[]> | undefined;
|
|
105
123
|
servers?: string[] | undefined;
|
|
106
124
|
timeout?: number | undefined;
|
|
107
125
|
callTimeout?: number | undefined;
|
|
@@ -113,10 +131,14 @@ export function createMCPBridge(opts?: {
|
|
|
113
131
|
servers: string[];
|
|
114
132
|
systemContext: string;
|
|
115
133
|
denied: DeniedTool[];
|
|
116
|
-
errors
|
|
134
|
+
errors: Array<{
|
|
117
135
|
server: string;
|
|
118
136
|
error: string;
|
|
119
137
|
}>;
|
|
138
|
+
missing?: Array<{
|
|
139
|
+
server: string;
|
|
140
|
+
tool: string;
|
|
141
|
+
}>;
|
|
120
142
|
close: Function;
|
|
121
143
|
}>;
|
|
122
144
|
/**
|
|
@@ -150,10 +172,51 @@ export function discoverServers(configPaths?: string[], { includeProjectConfig }
|
|
|
150
172
|
*
|
|
151
173
|
* @param {ToolDef[]} tools - The bulk-loaded, name-prefixed tools array.
|
|
152
174
|
* @param {string} [discoveredAt] - ISO timestamp from .mcp-bridge.json.
|
|
175
|
+
* @param {Map<string, {server: string, tool: string}>} [excluded] - Allow mode only: names the server
|
|
176
|
+
* LISTS but the signed allow-list excludes. mcp_invoke refuses them with `context.denied === true`;
|
|
177
|
+
* they never appear in the catalog. Omitted ⇒ unchanged behaviour.
|
|
153
178
|
* @returns {ToolDef[]} [mcp_discover, mcp_invoke]
|
|
154
179
|
* @when you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation
|
|
155
180
|
* @fails returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).
|
|
156
181
|
* @example
|
|
157
182
|
* const metaTools = buildMetaTools(tools);
|
|
158
183
|
*/
|
|
159
|
-
export function buildMetaTools(tools: ToolDef[], discoveredAt?: string
|
|
184
|
+
export function buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded?: Map<string, {
|
|
185
|
+
server: string;
|
|
186
|
+
tool: string;
|
|
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 } = 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 } = 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
|
/**
|
|
@@ -54,7 +56,7 @@ const { ToolError } = require('./errors');
|
|
|
54
56
|
/**
|
|
55
57
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
56
58
|
* @typedef {object} RpcClient
|
|
57
|
-
* @property {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
|
|
59
|
+
* @property {(method: string, params?: object, timeoutMs?: number, label?: {server: string, tool: string}) => Promise<any>} rpc
|
|
58
60
|
* @property {(method: string, params?: object) => void} notify
|
|
59
61
|
* @property {import('node:child_process').ChildProcessWithoutNullStreams} child
|
|
60
62
|
* @property {string} stderr
|
|
@@ -309,9 +311,11 @@ function createRpcClient(name, def) {
|
|
|
309
311
|
* @param {string} method
|
|
310
312
|
* @param {object} [params]
|
|
311
313
|
* @param {number} [timeoutMs=0] - Reject if no response arrives within this many ms (0 = no limit).
|
|
314
|
+
* @param {{server: string, tool: string}} [label] - tools/call only: when given, a timeout throws a
|
|
315
|
+
* labelled ToolError (`context: {timeout, server, tool, timeoutMs, stderr}`, stderr kept OUT of the message).
|
|
312
316
|
* @returns {Promise<any>}
|
|
313
317
|
*/
|
|
314
|
-
function rpc(method, params = {}, timeoutMs = 0) {
|
|
318
|
+
function rpc(method, params = {}, timeoutMs = 0, label) {
|
|
315
319
|
const id = nextId++;
|
|
316
320
|
return new Promise((resolve, reject) => {
|
|
317
321
|
if (!child.stdin.writable) {
|
|
@@ -321,7 +325,14 @@ function createRpcClient(name, def) {
|
|
|
321
325
|
let timer = null;
|
|
322
326
|
if (timeoutMs > 0) {
|
|
323
327
|
timer = setTimeout(() => {
|
|
324
|
-
|
|
328
|
+
if (label) {
|
|
329
|
+
settle(id, false, new ToolError(
|
|
330
|
+
`MCP server "${label.server}" tool "${label.tool}" timed out after ${timeoutMs}ms`,
|
|
331
|
+
{ context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
|
|
332
|
+
));
|
|
333
|
+
} else {
|
|
334
|
+
settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
|
|
335
|
+
}
|
|
325
336
|
}, timeoutMs);
|
|
326
337
|
timer.unref?.();
|
|
327
338
|
}
|
|
@@ -361,13 +372,21 @@ function unwrapContent(content) {
|
|
|
361
372
|
|
|
362
373
|
// --- Tool wrapping ---
|
|
363
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
|
+
|
|
364
383
|
// Runtime arg-dependent policy has moved to Loop-level (new Loop({ policy })).
|
|
365
384
|
// mcp-bridge retains only the static .mcp-bridge.json allow/deny filter below —
|
|
366
385
|
// that decides which tools are exposed to the Loop in the first place.
|
|
367
386
|
/**
|
|
368
387
|
* @param {string} serverName
|
|
369
388
|
* @param {McpTool[]} mcpTools
|
|
370
|
-
* @param {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
|
|
389
|
+
* @param {(method: string, params?: object, timeoutMs?: number, label?: {server: string, tool: string}) => Promise<any>} rpc
|
|
371
390
|
* @param {number} [callTimeout=0] - Per-invocation timeout (ms) for tools/call; 0 = no limit.
|
|
372
391
|
* @returns {ToolDef[]}
|
|
373
392
|
*/
|
|
@@ -376,8 +395,10 @@ function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
|
|
|
376
395
|
name: `${serverName}_${t.name}`,
|
|
377
396
|
description: t.description || '',
|
|
378
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 }),
|
|
379
400
|
execute: async (args) => {
|
|
380
|
-
const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout);
|
|
401
|
+
const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout, { server: serverName, tool: t.name });
|
|
381
402
|
if (result.isError) {
|
|
382
403
|
throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
|
|
383
404
|
context: { server: serverName, tool: t.name },
|
|
@@ -431,29 +452,79 @@ async function killServer(child) {
|
|
|
431
452
|
|
|
432
453
|
// --- Connect + list tools from a server ---
|
|
433
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
|
+
|
|
434
459
|
/**
|
|
435
460
|
* @param {string} name
|
|
436
461
|
* @param {ServerDef} def
|
|
437
|
-
* @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.
|
|
438
466
|
* @returns {Promise<{mcpTools: McpTool[], client: RpcClient}>}
|
|
439
467
|
*/
|
|
440
|
-
async function connectAndListTools(name, def, timeout = 15000) {
|
|
468
|
+
async function connectAndListTools(name, def, timeout = 15000, o = {}) {
|
|
441
469
|
const client = createRpcClient(name, def);
|
|
470
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
471
|
+
let deadlineTimer;
|
|
442
472
|
|
|
443
473
|
try {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
+
}
|
|
512
|
+
|
|
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]);
|
|
457
528
|
} catch (err) {
|
|
458
529
|
// Connect failed (init timeout, tools/list error, etc.) — the child was
|
|
459
530
|
// spawned but never returned to the caller, so the caller can't track it
|
|
@@ -461,6 +532,8 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
461
532
|
// alive and hangs the process on exit (notably node:test's file wrapper).
|
|
462
533
|
await killServer(client.child);
|
|
463
534
|
throw err;
|
|
535
|
+
} finally {
|
|
536
|
+
clearTimeout(deadlineTimer);
|
|
464
537
|
}
|
|
465
538
|
}
|
|
466
539
|
|
|
@@ -470,9 +543,10 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
470
543
|
* @param {string[]} servers
|
|
471
544
|
* @param {ToolDef[]} tools
|
|
472
545
|
* @param {DeniedTool[]} denied
|
|
546
|
+
* @param {boolean} [allowMode] - Signed allow-list mode: say nothing about restricted/other tools or the cache file.
|
|
473
547
|
* @returns {string}
|
|
474
548
|
*/
|
|
475
|
-
function buildSystemContext(servers, tools, denied) {
|
|
549
|
+
function buildSystemContext(servers, tools, denied, allowMode = false) {
|
|
476
550
|
/** @type {string[]} */
|
|
477
551
|
const lines = [];
|
|
478
552
|
lines.push(`MCP Bridge: ${tools.length} tools available from ${servers.length} server(s): ${servers.join(', ')}.`);
|
|
@@ -489,6 +563,8 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
489
563
|
lines.push(` ${server}: ${toolNames.join(', ')}`);
|
|
490
564
|
}
|
|
491
565
|
|
|
566
|
+
if (allowMode) return lines.join('\n');
|
|
567
|
+
|
|
492
568
|
if (denied.length > 0) {
|
|
493
569
|
lines.push(`Restricted tools (${denied.length}, not available to you):`);
|
|
494
570
|
for (const d of denied) {
|
|
@@ -526,13 +602,16 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
526
602
|
*
|
|
527
603
|
* @param {ToolDef[]} tools - The bulk-loaded, name-prefixed tools array.
|
|
528
604
|
* @param {string} [discoveredAt] - ISO timestamp from .mcp-bridge.json.
|
|
605
|
+
* @param {Map<string, {server: string, tool: string}>} [excluded] - Allow mode only: names the server
|
|
606
|
+
* LISTS but the signed allow-list excludes. mcp_invoke refuses them with `context.denied === true`;
|
|
607
|
+
* they never appear in the catalog. Omitted ⇒ unchanged behaviour.
|
|
529
608
|
* @returns {ToolDef[]} [mcp_discover, mcp_invoke]
|
|
530
609
|
* @when you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation
|
|
531
610
|
* @fails returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).
|
|
532
611
|
* @example
|
|
533
612
|
* const metaTools = buildMetaTools(tools);
|
|
534
613
|
*/
|
|
535
|
-
function buildMetaTools(tools, discoveredAt) {
|
|
614
|
+
function buildMetaTools(tools, discoveredAt, excluded) {
|
|
536
615
|
// Catalog descriptors: same info the LLM would see for bulk-loaded tools,
|
|
537
616
|
// but exposed via mcp_discover instead of taking up tool-array slots upfront.
|
|
538
617
|
const catalog = tools.map(t => {
|
|
@@ -543,6 +622,7 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
543
622
|
schema: t.parameters || { type: 'object', properties: {} },
|
|
544
623
|
server: sep > 0 ? t.name.slice(0, sep) : t.name,
|
|
545
624
|
tool: sep > 0 ? t.name.slice(sep + 1) : '',
|
|
625
|
+
...(hasAnnotations(t.annotations) && { annotations: t.annotations }),
|
|
546
626
|
};
|
|
547
627
|
});
|
|
548
628
|
const byName = new Map(tools.map(t => [t.name, t]));
|
|
@@ -596,6 +676,12 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
596
676
|
},
|
|
597
677
|
execute: async (/** @type {{ name: string, args?: object }} */ { name, args }) => {
|
|
598
678
|
const tool = byName.get(name);
|
|
679
|
+
const ex = !tool && excluded ? excluded.get(name) : undefined;
|
|
680
|
+
if (ex) {
|
|
681
|
+
throw new ToolError(`mcp_invoke: "${name}" is not on this run's allowed list`, {
|
|
682
|
+
context: { denied: true, server: ex.server, tool: ex.tool },
|
|
683
|
+
});
|
|
684
|
+
}
|
|
599
685
|
if (!tool) {
|
|
600
686
|
throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
|
|
601
687
|
context: { name, knownNames: [...byName.keys()] },
|
|
@@ -608,6 +694,157 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
608
694
|
return [mcpDiscover, mcpInvoke];
|
|
609
695
|
}
|
|
610
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
|
+
|
|
736
|
+
// --- Signed allow-list mode (opts.allow) ---
|
|
737
|
+
|
|
738
|
+
/** @param {any} v @returns {string} */
|
|
739
|
+
const typeName = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Validate `opts.allow`. Messages name the TYPE only — never echo a value.
|
|
743
|
+
* @param {any} allow
|
|
744
|
+
* @returns {Map<string, string[]>} server name -> de-duplicated signed raw tool names
|
|
745
|
+
*/
|
|
746
|
+
function validateAllow(allow) {
|
|
747
|
+
const bad = (/** @type {string} */ msg) =>
|
|
748
|
+
new ValidationError(`[MCP Bridge] ${msg}`, { context: { lib: 'bare-agent' } });
|
|
749
|
+
const proto = allow !== null && typeof allow === 'object' ? Object.getPrototypeOf(allow) : undefined;
|
|
750
|
+
if (typeof allow !== 'object' || allow === null || Array.isArray(allow) || (proto !== Object.prototype && proto !== null)) {
|
|
751
|
+
throw bad(`allow must be a plain object of { serverName: string[] } (got ${typeName(allow)})`);
|
|
752
|
+
}
|
|
753
|
+
const entries = Object.entries(allow);
|
|
754
|
+
if (entries.length === 0) throw bad('allow must name at least one server (got an empty object)');
|
|
755
|
+
/** @type {Map<string, string[]>} */
|
|
756
|
+
const out = new Map();
|
|
757
|
+
for (const [server, list] of entries) {
|
|
758
|
+
if (!Array.isArray(list)) throw bad(`allow values must be arrays of tool names (got ${typeName(list)})`);
|
|
759
|
+
if (list.length === 0) throw bad('allow values must be non-empty arrays of tool names (got an empty array)');
|
|
760
|
+
for (const t of list) {
|
|
761
|
+
if (typeof t !== 'string' || t === '') {
|
|
762
|
+
throw bad(`allow tool names must be non-empty strings (got ${t === '' ? 'empty string' : typeName(t)})`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
out.set(server, [...new Set(list)]);
|
|
766
|
+
}
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* allow-mode body of createMCPBridge: in memory only (no .mcp-bridge.json read or write, no TTL),
|
|
772
|
+
* server definitions come ONLY from discoverServers(), one connect per signed server, expose
|
|
773
|
+
* signed ∩ listed, silent on stdout, and the model is never told other tools exist.
|
|
774
|
+
* @param {Map<string, string[]>} allow
|
|
775
|
+
* @param {object} o
|
|
776
|
+
* @param {string[]} [o.configPaths]
|
|
777
|
+
* @param {boolean} o.includeProjectConfig
|
|
778
|
+
* @param {number} o.timeout
|
|
779
|
+
* @param {number} o.callTimeout
|
|
780
|
+
* @param {(name: string, def: ServerDef) => Promise<boolean>} o.vetServer
|
|
781
|
+
* @param {(specs: Array<{name: string, command: string, args?: string[]}>) => void} o.warnUnvettedSpawn
|
|
782
|
+
*/
|
|
783
|
+
async function createAllowBridge(allow, o) {
|
|
784
|
+
const discovered = discoverServers(o.configPaths, { includeProjectConfig: o.includeProjectConfig });
|
|
785
|
+
/** @type {Array<[string, ServerDef]>} */
|
|
786
|
+
const toConnect = [];
|
|
787
|
+
/** @type {Array<{server: string, error: string}>} */
|
|
788
|
+
const errors = [];
|
|
789
|
+
for (const name of allow.keys()) {
|
|
790
|
+
const def = discovered.get(name);
|
|
791
|
+
if (def) toConnect.push([name, def]);
|
|
792
|
+
else errors.push({ server: name, error: 'server not found in discovered MCP configs' });
|
|
793
|
+
}
|
|
794
|
+
o.warnUnvettedSpawn(toConnect.map(([name, def]) => ({ name, command: def.command, args: def.args })));
|
|
795
|
+
|
|
796
|
+
/** @type {ToolDef[]} */
|
|
797
|
+
const tools = [];
|
|
798
|
+
/** @type {import('node:child_process').ChildProcess[]} */
|
|
799
|
+
const children = [];
|
|
800
|
+
/** @type {string[]} */
|
|
801
|
+
const connected = [];
|
|
802
|
+
/** @type {Array<{server: string, tool: string}>} */
|
|
803
|
+
const missing = [];
|
|
804
|
+
/** @type {Map<string, {server: string, tool: string}>} */
|
|
805
|
+
const excluded = new Map();
|
|
806
|
+
/** @type {Set<string>} */
|
|
807
|
+
const listedOk = new Set();
|
|
808
|
+
|
|
809
|
+
await Promise.all(toConnect.map(async ([name, def]) => {
|
|
810
|
+
try {
|
|
811
|
+
if (!(await o.vetServer(name, def))) return;
|
|
812
|
+
const { mcpTools, client } = await connectAndListTools(name, def, o.timeout);
|
|
813
|
+
// Track the child BEFORE any processing so close() reaps it even if what follows throws.
|
|
814
|
+
children.push(client.child);
|
|
815
|
+
const signed = new Set(allow.get(name));
|
|
816
|
+
const listed = new Set(mcpTools.map(t => t.name));
|
|
817
|
+
tools.push(...wrapTools(name, mcpTools.filter(t => signed.has(t.name)), client.rpc, o.callTimeout));
|
|
818
|
+
for (const t of mcpTools) {
|
|
819
|
+
if (!signed.has(t.name)) excluded.set(`${name}_${t.name}`, { server: name, tool: t.name });
|
|
820
|
+
}
|
|
821
|
+
for (const t of signed) if (!listed.has(t)) missing.push({ server: name, tool: t });
|
|
822
|
+
connected.push(name);
|
|
823
|
+
listedOk.add(name);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
errors.push({ server: name, error: err.message });
|
|
826
|
+
}
|
|
827
|
+
}));
|
|
828
|
+
|
|
829
|
+
// Every signed tool of a server that was not found / failed / was rejected by confirmServer is missing.
|
|
830
|
+
for (const [server, list] of allow) {
|
|
831
|
+
if (!listedOk.has(server)) for (const tool of list) missing.push({ server, tool });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (errors.length > 0) console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
tools,
|
|
838
|
+
metaTools: buildMetaTools(tools, undefined, excluded),
|
|
839
|
+
servers: connected,
|
|
840
|
+
denied: [],
|
|
841
|
+
systemContext: buildSystemContext(connected, tools, [], true),
|
|
842
|
+
errors,
|
|
843
|
+
missing,
|
|
844
|
+
close: async () => { await Promise.all(children.map(killServer)); },
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
611
848
|
// --- Main entry point ---
|
|
612
849
|
|
|
613
850
|
/**
|
|
@@ -632,7 +869,16 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
632
869
|
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
633
870
|
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
634
871
|
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
635
|
-
* @param {string[]} [opts.
|
|
872
|
+
* @param {Record<string, string[]>} [opts.allow] - Signed allow-list `{ [serverName]: rawMcpToolNames[] }`
|
|
873
|
+
* (raw MCP names, NOT the `server_tool` prefixed form; each list must be non-empty). Switches to an IN-MEMORY mode: never reads or
|
|
874
|
+
* writes `.mcp-bridge.json` (no cache, no TTL; `bridgePath`/`refresh`/`servers` are ignored); server
|
|
875
|
+
* definitions come only from `discoverServers(configPaths)`; only servers named here are spawned (still
|
|
876
|
+
* vetted by `confirmServer`); each connects ONCE and exposes signed ∩ listed — every other tool is ABSENT
|
|
877
|
+
* (not in `tools`, `metaTools` catalog or `systemContext`). Signed tools the server does not list (or whose
|
|
878
|
+
* server was not found / failed / was rejected) come back in `missing`. `denied` is always `[]`, nothing is
|
|
879
|
+
* written to stdout, and `mcp_invoke` on a listed-but-unsigned name throws `context.denied === true`.
|
|
880
|
+
* Invalid shape throws `ValidationError` (type only, never values).
|
|
881
|
+
* @param {string[]} [opts.servers] - Limit to these server names (ignored when `allow` is given).
|
|
636
882
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
637
883
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
638
884
|
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
@@ -644,9 +890,9 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
644
890
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
645
891
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
646
892
|
* since each command is then vetted regardless of source.
|
|
647
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors
|
|
648
|
-
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution
|
|
649
|
-
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.
|
|
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}>}
|
|
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
|
|
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.
|
|
650
896
|
* @example
|
|
651
897
|
* const { tools, close } = await createMCPBridge();
|
|
652
898
|
* const loop = new Loop({ provider, tools });
|
|
@@ -667,35 +913,44 @@ async function createMCPBridge(opts = {}) {
|
|
|
667
913
|
// Vet a server before spawning its command. Fail-closed: an undefined hook
|
|
668
914
|
// trusts all (unchanged behavior); a throw denies.
|
|
669
915
|
const confirmServer = typeof opts.confirmServer === 'function' ? opts.confirmServer : null;
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
// Warn ONCE per call, BEFORE the first spawn — and the first spawn is the
|
|
682
|
-
// discovery phase on a cold/refresh run, not the main-connect phase below.
|
|
683
|
-
let warnedUnvetted = false;
|
|
684
|
-
/** @param {Array<{name: string, command: string, args?: string[]}>} specs */
|
|
685
|
-
const warnUnvettedSpawn = (specs) => {
|
|
686
|
-
if (confirmServer || warnedUnvetted || specs.length === 0) return;
|
|
687
|
-
warnedUnvetted = true;
|
|
688
|
-
const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
|
|
689
|
-
console.warn(
|
|
690
|
-
`[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
|
|
691
|
-
cmds.join('\n ') +
|
|
692
|
-
`\n Pass { confirmServer } to vet each command before it runs.`,
|
|
693
|
-
);
|
|
694
|
-
};
|
|
916
|
+
const vetServer = makeVetServer(confirmServer);
|
|
917
|
+
const warnUnvettedSpawn = makeUnvettedWarner(confirmServer);
|
|
918
|
+
|
|
919
|
+
if (opts.allow !== undefined) {
|
|
920
|
+
const allow = validateAllow(opts.allow);
|
|
921
|
+
return createAllowBridge(allow, {
|
|
922
|
+
configPaths: opts.configPaths,
|
|
923
|
+
includeProjectConfig: opts.includeProjectConfig === true || !!confirmServer,
|
|
924
|
+
timeout, callTimeout, vetServer, warnUnvettedSpawn,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
695
927
|
|
|
696
928
|
let config = readBridgeConfig(bridgePath);
|
|
697
929
|
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
698
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
|
+
|
|
699
954
|
if (needsRefresh) {
|
|
700
955
|
// Discover from IDE configs. The project-cwd `.mcp.json` is excluded by default (untrusted-repo
|
|
701
956
|
// RCE vector); it is scanned only on explicit opt-in, or when a `confirmServer` hook is present
|
|
@@ -703,9 +958,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
703
958
|
const includeProjectConfig = opts.includeProjectConfig === true || !!confirmServer;
|
|
704
959
|
const discovered = discoverServers(opts.configPaths, { includeProjectConfig });
|
|
705
960
|
|
|
706
|
-
if (discovered.size === 0 && !config)
|
|
707
|
-
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
708
|
-
}
|
|
961
|
+
if (discovered.size === 0 && !config) return nothingConnected();
|
|
709
962
|
|
|
710
963
|
// Only attempt connection when discovery found something.
|
|
711
964
|
// If discovered.size === 0 but config exists, fall through and use the existing config
|
|
@@ -715,9 +968,6 @@ async function createMCPBridge(opts = {}) {
|
|
|
715
968
|
const freshTools = new Map();
|
|
716
969
|
/** @type {Map<string, RpcClient>} */
|
|
717
970
|
const connectResults = new Map();
|
|
718
|
-
/** @type {Array<{server: string, error: string}>} */
|
|
719
|
-
const errors = [];
|
|
720
|
-
|
|
721
971
|
const reqServers = opts.servers;
|
|
722
972
|
const toDiscover = reqServers
|
|
723
973
|
? [...discovered.entries()].filter(([n]) => reqServers.includes(n))
|
|
@@ -731,40 +981,39 @@ async function createMCPBridge(opts = {}) {
|
|
|
731
981
|
// decision, not a connection failure, so it must not land in `errors`.
|
|
732
982
|
if (!(await vetServer(name, def))) return;
|
|
733
983
|
const result = await connectAndListTools(name, def, timeout);
|
|
734
|
-
freshTools.set(name, result.mcpTools);
|
|
735
984
|
connectResults.set(name, result.client);
|
|
985
|
+
freshTools.set(name, result.mcpTools);
|
|
736
986
|
} catch (err) {
|
|
737
|
-
|
|
987
|
+
noteFailure(name, err);
|
|
738
988
|
}
|
|
739
989
|
}));
|
|
740
990
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
}
|
|
991
|
+
// Discovery connections are killed in `finally`: a throw from the config merge/write (e.g. an
|
|
992
|
+
// unwritable bridgePath) must not strand the live children held only in connectResults.
|
|
993
|
+
try {
|
|
994
|
+
// Only write config when at least one server connected successfully.
|
|
995
|
+
// If all servers failed, retain the existing config unchanged so
|
|
996
|
+
// user-curated allow/deny settings are not destroyed on transient failures.
|
|
997
|
+
if (freshTools.size > 0) {
|
|
998
|
+
config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
|
|
999
|
+
writeBridgeConfig(bridgePath, config);
|
|
1000
|
+
console.log(`[MCP Bridge] Wrote ${bridgePath}`);
|
|
1001
|
+
} else if (!config) {
|
|
1002
|
+
return nothingConnected();
|
|
1003
|
+
}
|
|
755
1004
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1005
|
+
} finally {
|
|
1006
|
+
// Close the discovery connections — we'll reconnect below using the config
|
|
1007
|
+
for (const client of connectResults.values()) {
|
|
1008
|
+
await killServer(client.child);
|
|
1009
|
+
}
|
|
759
1010
|
}
|
|
760
1011
|
}
|
|
761
1012
|
}
|
|
762
1013
|
|
|
763
1014
|
// At this point config is guaranteed non-null: it either existed, was just
|
|
764
1015
|
// written by the refresh branch, or we returned early above.
|
|
765
|
-
if (!config)
|
|
766
|
-
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
767
|
-
}
|
|
1016
|
+
if (!config) return nothingConnected();
|
|
768
1017
|
/** @type {BridgeConfig} */
|
|
769
1018
|
const cfg = config;
|
|
770
1019
|
|
|
@@ -774,9 +1023,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
774
1023
|
? reqServers2.filter(n => cfg.servers[n])
|
|
775
1024
|
: Object.keys(cfg.servers);
|
|
776
1025
|
|
|
777
|
-
if (serverNames.length === 0)
|
|
778
|
-
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
779
|
-
}
|
|
1026
|
+
if (serverNames.length === 0) return nothingConnected();
|
|
780
1027
|
|
|
781
1028
|
// Warn before the main-connect spawn too. On a warm run (config exists, no
|
|
782
1029
|
// refresh) this is the first and only spawn; on a cold run the discovery phase
|
|
@@ -792,8 +1039,6 @@ async function createMCPBridge(opts = {}) {
|
|
|
792
1039
|
const connected = [];
|
|
793
1040
|
/** @type {DeniedTool[]} */
|
|
794
1041
|
const denied = [];
|
|
795
|
-
/** @type {Array<{server: string, error: string}>} */
|
|
796
|
-
const errors = [];
|
|
797
1042
|
|
|
798
1043
|
await Promise.all(serverNames.map(async (name) => {
|
|
799
1044
|
const serverConf = cfg.servers[name];
|
|
@@ -808,13 +1053,14 @@ async function createMCPBridge(opts = {}) {
|
|
|
808
1053
|
// Denied by confirmServer: skip silently (the caller's decision, not a failure).
|
|
809
1054
|
if (!(await vetServer(name, serverConf))) return;
|
|
810
1055
|
const { mcpTools, client } = await connectAndListTools(name, serverConf, timeout);
|
|
1056
|
+
// Track the child BEFORE any processing so close() reaps it even if what follows throws.
|
|
1057
|
+
children.push(client.child);
|
|
811
1058
|
|
|
812
1059
|
// Only wrap tools that are allowed in config
|
|
813
1060
|
const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
|
|
814
1061
|
const wrapped = wrapTools(name, allowed, client.rpc, callTimeout);
|
|
815
1062
|
|
|
816
1063
|
tools.push(...wrapped);
|
|
817
|
-
children.push(client.child);
|
|
818
1064
|
connected.push(name);
|
|
819
1065
|
|
|
820
1066
|
// Track denied tools with descriptions from the server
|
|
@@ -824,14 +1070,10 @@ async function createMCPBridge(opts = {}) {
|
|
|
824
1070
|
}
|
|
825
1071
|
}
|
|
826
1072
|
} catch (err) {
|
|
827
|
-
|
|
1073
|
+
noteFailure(name, err);
|
|
828
1074
|
}
|
|
829
1075
|
}));
|
|
830
1076
|
|
|
831
|
-
if (errors.length > 0) {
|
|
832
|
-
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
1077
|
const systemContext = buildSystemContext(connected, tools, denied);
|
|
836
1078
|
if (connected.length > 0) console.log(systemContext);
|
|
837
1079
|
|
|
@@ -843,11 +1085,99 @@ async function createMCPBridge(opts = {}) {
|
|
|
843
1085
|
servers: connected,
|
|
844
1086
|
denied,
|
|
845
1087
|
systemContext,
|
|
846
|
-
errors,
|
|
1088
|
+
errors: finalErrors(connected),
|
|
847
1089
|
close: async () => {
|
|
848
1090
|
await Promise.all(children.map(killServer));
|
|
849
1091
|
},
|
|
850
1092
|
};
|
|
851
1093
|
}
|
|
852
1094
|
|
|
853
|
-
|
|
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
|
|