bare-agent 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bareagent.context.md +16 -2
- package/examples/litectx-assemble.mjs +78 -0
- package/examples/wake.sh +8 -0
- package/package.json +1 -1
- package/src/mcp-bridge.d.ts +5 -2
- package/src/mcp-bridge.js +92 -29
- package/src/provider-openai.d.ts +1 -4
- package/src/provider-openai.js +17 -0
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
66
66
|
|
|
67
67
|
| Component | What it does |
|
|
68
68
|
|---|---|
|
|
69
|
-
| **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. Context engineering via `Loop({ assemble })` — a per-round `assemble(msgs, ctx)` chokepoint to recall/compress/trim the window sent to the model (the seam litectx plugs into); returns a view, the canonical transcript stays intact, fail-open. The exported `unitAssembler`/`toUnits`/`fromUnits` adapter lets a consumer work over a neutral unit `{id, role, content, kind, pinned, atomic, tokensApprox}` — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task, a pairing seatbelt), the consumer owns content + relevance. `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
|
|
69
|
+
| **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. Context engineering via `Loop({ assemble })` — a per-round `assemble(msgs, ctx)` chokepoint to recall/compress/trim the window sent to the model (the seam litectx plugs into); returns a view, the canonical transcript stays intact, fail-open. The exported `unitAssembler`/`toUnits`/`fromUnits` adapter lets a consumer work over a neutral unit `{id, role, content, kind, pinned, atomic, tokensApprox}` — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task, a pairing seatbelt), the consumer owns content + relevance. The CE function reads its inputs from the per-run `ctx` — litectx's budget-fitter uses `ctx.budget` (and `ctx.task`), so you **must** populate it via `run(msgs, tools, { ctx })`: an unset `ctx.budget` means the fitter has no budget, keeps everything, and returns the window unchanged — a silent no-op, not a bug (see `examples/litectx-assemble.mjs`). `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
|
|
70
70
|
| **Planner** | Break a goal into a step DAG via LLM. Built-in caching (`cacheTTL`) |
|
|
71
71
|
| **runPlan** | Execute steps in parallel waves. Dependency-aware, failure propagation, per-step retry |
|
|
72
72
|
| **Retry** | Exponential/linear backoff with jitter. Respects `err.retryable` |
|
|
@@ -82,11 +82,11 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
82
82
|
| **Browsing** | Web navigation, clicking, typing, reading via `barebrowse` (17 tools). Two modes: library tools (inline snapshots, pass to Loop) or CLI session (disk-based snapshots, token-efficient for multi-step flows). Optional `assess` tool (privacy scan) when `wearehere` is installed |
|
|
83
83
|
| **Mobile** | Android + iOS device control via `baremobile`. Same two modes: library tools (`createMobileTools` — action tools auto-return snapshots) or CLI session (`baremobile` CLI — disk-based snapshots) |
|
|
84
84
|
| **Shell** | Cross-platform `shell_read`, `shell_grep`, `shell_run` (argv, no shell), `shell_exec` (raw shell). Pure Node — no `grep`/`rg`/`findstr` dependency. Injection-proof `shell_run` for policy-gated use |
|
|
85
|
-
| **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Returns both bulk `tools` (one per MCP tool) and `metaTools` (`mcp_discover` + `mcp_invoke` for token-thrifty access to large catalogs). Zero deps |
|
|
85
|
+
| **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Returns both bulk `tools` (one per MCP tool) and `metaTools` (`mcp_discover` + `mcp_invoke` for token-thrifty access to large catalogs). Connecting runs a server's `command` (which may come from a cwd `.mcp.json`): pass `confirmServer` to vet each before it spawns — otherwise the bridge warns naming every command it runs. Every RPC is time-bounded (`timeout` for the handshake, `callTimeout` for `tools/call`), and a server that breaks its stdin pipe fails the connection instead of crashing the host. Zero deps |
|
|
86
86
|
| **Spawn** | Fork a child bareagent process as a specialist agent. LLM-callable form blocks until child exits; library form returns a handle (`wait`, `onLine`, `kill`). One JSONL channel per child — child stderr captured and re-emitted as `child:stderr` events on the parent stream. Threads `BAREGUARD_AUDIT_PATH` / `BAREGUARD_PARENT_RUN_ID` / `BAREGUARD_BUDGET_FILE` / `BAREGUARD_SPAWN_DEPTH` so the family stitches into one audit + budget. `bareguard ^0.2.0` adds `spawn.ratePerMinute` + `limits.maxDepth` per-family caps |
|
|
87
87
|
| **Defer** | Append a `{action, when}` record to a JSONL queue for a separate waker (cron / systemd timer / `examples/wake.sh`) to fire later. Two-phase governance: emit-time `gate.check` on the `defer` action; fire-time `gate.check` on the inner action when the waker re-invokes. `bareguard ^0.2.0` adds `defer.ratePerMinute` family-wide cap |
|
|
88
88
|
|
|
89
|
-
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely.
|
|
89
|
+
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely. The OpenAI provider warns if it would send your key over plaintext `http://` to a non-loopback host (use `https`, or drop `apiKey` for keyless local endpoints).
|
|
90
90
|
|
|
91
91
|
**Tools:** Any function is a tool. REST APIs, MCP servers, CLI commands, shell scripts — if it's a function, it works. Built-in: `barebrowse` for web browsing, `baremobile` for Android + iOS device control (both optional) — library tools for inline results or CLI session mode for token-efficient disk-based snapshots.
|
|
92
92
|
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.13.
|
|
4
|
+
> v0.13.1 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -254,7 +254,19 @@ or `content.denyPatterns` over the serialized action.
|
|
|
254
254
|
repo) as well as your home/IDE configs. Pass `confirmServer(name, def)
|
|
255
255
|
=> boolean` to `createMCPBridge` to approve each server **before its
|
|
256
256
|
command is spawned** (return `false` to skip it; a throw fails closed).
|
|
257
|
-
Default trusts all discovered servers — unchanged behavior.
|
|
257
|
+
Default trusts all discovered servers — unchanged behavior. **When no
|
|
258
|
+
`confirmServer` is set, the bridge prints a one-time warning naming every
|
|
259
|
+
command it is about to spawn** (before the first spawn, discovery included),
|
|
260
|
+
so a cwd `.mcp.json` can't run a command unannounced — `confirmServer` is
|
|
261
|
+
still how you actually *gate* it.
|
|
262
|
+
|
|
263
|
+
**RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
|
|
264
|
+
server that never answers can't hang the bridge or the loop. `opts.timeout`
|
|
265
|
+
(default 15 s) bounds the handshake (`initialize` + `tools/list`);
|
|
266
|
+
`opts.callTimeout` (default 120 s, `0` disables) bounds each `tools/call`. A
|
|
267
|
+
timed-out tool call rejects with a `timed out after Nms` `ToolError` rather
|
|
268
|
+
than blocking forever; a server that breaks its stdin pipe surfaces as a
|
|
269
|
+
failed connection, never an uncaught `EPIPE` crash.
|
|
258
270
|
|
|
259
271
|
## Wiring with bareguard
|
|
260
272
|
|
|
@@ -544,6 +556,8 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens } }`. CLIPipe
|
|
|
544
556
|
|
|
545
557
|
**Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
|
|
546
558
|
|
|
559
|
+
**Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
|
|
560
|
+
|
|
547
561
|
**Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. Pricing covers OpenAI and Anthropic models; unknown models use a default average. To adjust rates, edit `COST_PER_1K` at the top of `src/loop.js`.
|
|
548
562
|
|
|
549
563
|
## Store options
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// examples/litectx-assemble.mjs
|
|
2
|
+
//
|
|
3
|
+
// RT-1 — wire litectx's budget-fit `assemble` verb into bareagent's Loop context-assembly seam,
|
|
4
|
+
// and show the ONE footgun: you must populate `ctx.budget`, or the fit is a silent no-op.
|
|
5
|
+
//
|
|
6
|
+
// Run: node examples/litectx-assemble.mjs
|
|
7
|
+
// (runs litectx's real verb if installed — `npm install litectx` — otherwise an inline
|
|
8
|
+
// stand-in with identical budget semantics, so the lesson runs zero-dep.)
|
|
9
|
+
//
|
|
10
|
+
// How the seam works:
|
|
11
|
+
// - Loop({ assemble }) calls `assemble(msgs, ctx)` before EVERY provider call, sending the
|
|
12
|
+
// returned view (the canonical transcript is never mutated).
|
|
13
|
+
// - bareagent's `unitAssembler()` wraps a litectx-shaped `assemble(units, ctx)` into that
|
|
14
|
+
// msgs-level seam — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task),
|
|
15
|
+
// litectx owns content + relevance.
|
|
16
|
+
// - litectx reads its inputs from the per-run `ctx`: `ctx.budget` (token budget) and `ctx.task`
|
|
17
|
+
// (recall intent). You pass that ctx via `loop.run(msgs, tools, { ctx })`.
|
|
18
|
+
//
|
|
19
|
+
// THE FOOTGUN: an unset `ctx.budget` is NOT a litectx bug. With no budget the fit defaults to
|
|
20
|
+
// Infinity, keeps everything, and returns the window unchanged — so litectx's core verb LOOKS
|
|
21
|
+
// broken when it is really a wiring omission. Always pass `ctx.budget`.
|
|
22
|
+
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { unitAssembler } = require('bare-agent');
|
|
26
|
+
|
|
27
|
+
// litectx's real assemble verb if installed; else an inline stand-in with the same budget semantics
|
|
28
|
+
// (best-effort, recency-anchored, never drops `pinned`, returns the { units, dropped, tokens } envelope).
|
|
29
|
+
let assembleVerb;
|
|
30
|
+
try {
|
|
31
|
+
({ assemble: assembleVerb } = require('litectx')); // free function on the main entry (litectx 0.11+)
|
|
32
|
+
console.log("using litectx's real assemble() verb\n");
|
|
33
|
+
} catch {
|
|
34
|
+
console.log('litectx not installed — using an inline stand-in with identical budget semantics\n');
|
|
35
|
+
const tok = (u) => (Number.isFinite(u.tokensApprox) ? u.tokensApprox : Math.ceil((u.content?.length ?? 0) / 4));
|
|
36
|
+
assembleVerb = (units, ctx = {}) => {
|
|
37
|
+
const budget = Number.isFinite(ctx.budget) ? ctx.budget : Infinity;
|
|
38
|
+
const keep = new Set();
|
|
39
|
+
let used = 0;
|
|
40
|
+
for (const u of units) if (u.pinned) { keep.add(u.id); used += tok(u); } // pinned always kept
|
|
41
|
+
// newest-first, skip-and-continue greedy over the un-pinned remainder
|
|
42
|
+
const rest = units.map((u, i) => ({ u, i })).filter(({ u }) => !u.pinned).sort((a, b) => b.i - a.i);
|
|
43
|
+
for (const { u } of rest) if (used + tok(u) <= budget) { keep.add(u.id); used += tok(u); }
|
|
44
|
+
const kept = units.filter((u) => keep.has(u.id));
|
|
45
|
+
const dropped = units.filter((u) => !keep.has(u.id)).map((u) => ({ id: u.id, reason: 'budget' }));
|
|
46
|
+
return { units: kept, dropped, tokens: used };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// the seam bareagent's Loop calls: assemble(msgs, ctx) => msgs
|
|
51
|
+
const assemble = unitAssembler(assembleVerb);
|
|
52
|
+
|
|
53
|
+
// a transcript grown past budget: a pinned system prompt + the task, then several tool rounds.
|
|
54
|
+
const msgs = [
|
|
55
|
+
{ role: 'system', content: 'You are a helpful coding agent. '.repeat(20) },
|
|
56
|
+
{ role: 'user', content: 'Find and fix the rate-limiter bug in the auth service.' },
|
|
57
|
+
];
|
|
58
|
+
for (let i = 1; i <= 8; i++) {
|
|
59
|
+
const id = `call_${i}`;
|
|
60
|
+
msgs.push({ role: 'assistant', content: `Round ${i}: inspecting.`, tool_calls: [{ id, type: 'function', function: { name: 'read_file', arguments: `{"path":"src/auth/round${i}.js"}` } }] });
|
|
61
|
+
msgs.push({ role: 'tool', tool_call_id: id, content: `// round ${i} file contents — `.repeat(40) });
|
|
62
|
+
}
|
|
63
|
+
const before = msgs.length;
|
|
64
|
+
|
|
65
|
+
// (1) ctx.budget SET — the fit drops the oldest un-pinned rounds to fit the budget.
|
|
66
|
+
const fitted = await assemble(msgs, { budget: 400, task: 'rate-limiter bug' });
|
|
67
|
+
console.log(`with ctx.budget=400 : ${before} msgs -> ${fitted.length} msgs (fit dropped ${before - fitted.length})`);
|
|
68
|
+
|
|
69
|
+
// (2) ctx.budget UNSET — the footgun. No budget => Infinity => nothing drops => window unchanged.
|
|
70
|
+
const noop = await assemble(msgs, { task: 'rate-limiter bug' }); // <-- budget missing
|
|
71
|
+
console.log(`with ctx.budget unset: ${before} msgs -> ${noop.length} msgs (fit dropped ${before - noop.length}) <-- silent no-op!`);
|
|
72
|
+
|
|
73
|
+
// the pinned system prompt + task always survive the fit (pin, don't hide):
|
|
74
|
+
console.log(`\nsystem prompt survives the tight fit: ${fitted.some((m) => m.role === 'system')}`);
|
|
75
|
+
console.log(`task (first user turn) survives the tight fit: ${fitted.some((m) => m.role === 'user')}`);
|
|
76
|
+
|
|
77
|
+
console.log('\nLesson: wire it as loop.run(msgs, tools, { ctx: { budget, task } }).');
|
|
78
|
+
console.log('An unset ctx.budget is not a litectx bug — the fitter correctly keeps everything when given no budget.');
|
package/examples/wake.sh
CHANGED
|
@@ -63,6 +63,14 @@ echo "$PENDING" | while IFS= read -r record; do
|
|
|
63
63
|
ID=$(echo "$record" | jq -r '.id')
|
|
64
64
|
ACTION=$(echo "$record" | jq -c '.action')
|
|
65
65
|
|
|
66
|
+
# The defer tool generates ids as def_<base36>_<hex>. Anything else means a
|
|
67
|
+
# hand-edited / untrusted queue line — reject before $ID reaches a file path
|
|
68
|
+
# below (defence-in-depth against path traversal via a crafted id).
|
|
69
|
+
case "$ID" in
|
|
70
|
+
def_[a-z0-9]*_[a-f0-9]*) ;;
|
|
71
|
+
*) echo "[wake $NOW] skipping record with unexpected id: $ID" >&2; continue ;;
|
|
72
|
+
esac
|
|
73
|
+
|
|
66
74
|
# Append "fired" status line first (defer queue is append-only).
|
|
67
75
|
printf '{"id":"%s","status":"fired","ts":"%s"}\n' "$ID" "$NOW" >> "$QUEUE"
|
|
68
76
|
|
package/package.json
CHANGED
package/src/mcp-bridge.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export type DeniedTool = {
|
|
|
52
52
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
53
53
|
*/
|
|
54
54
|
export type RpcClient = {
|
|
55
|
-
rpc: (method: string, params?: object) => Promise<any>;
|
|
55
|
+
rpc: (method: string, params?: object, timeoutMs?: number) => Promise<any>;
|
|
56
56
|
notify: (method: string, params?: object) => void;
|
|
57
57
|
child: import("node:child_process").ChildProcessWithoutNullStreams;
|
|
58
58
|
stderr: string;
|
|
@@ -76,7 +76,9 @@ export type RpcClient = {
|
|
|
76
76
|
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
77
77
|
* @param {string[]} [opts.configPaths] - IDE config paths for discovery.
|
|
78
78
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
79
|
-
* @param {number} [opts.timeout=15000] - Per-server
|
|
79
|
+
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
80
|
+
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
81
|
+
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
80
82
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
81
83
|
* @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer]
|
|
82
84
|
* Vet each discovered server BEFORE its `command` is spawned. Connecting to an
|
|
@@ -92,6 +94,7 @@ export function createMCPBridge(opts?: {
|
|
|
92
94
|
configPaths?: string[] | undefined;
|
|
93
95
|
servers?: string[] | undefined;
|
|
94
96
|
timeout?: number | undefined;
|
|
97
|
+
callTimeout?: number | undefined;
|
|
95
98
|
refresh?: boolean | undefined;
|
|
96
99
|
confirmServer?: ((name: string, def: ServerDef) => boolean | Promise<boolean>) | undefined;
|
|
97
100
|
}): Promise<{
|
package/src/mcp-bridge.js
CHANGED
|
@@ -54,7 +54,7 @@ const { ToolError } = require('./errors');
|
|
|
54
54
|
/**
|
|
55
55
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
56
56
|
* @typedef {object} RpcClient
|
|
57
|
-
* @property {(method: string, params?: object) => Promise<any>} rpc
|
|
57
|
+
* @property {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
|
|
58
58
|
* @property {(method: string, params?: object) => void} notify
|
|
59
59
|
* @property {import('node:child_process').ChildProcessWithoutNullStreams} child
|
|
60
60
|
* @property {string} stderr
|
|
@@ -215,11 +215,25 @@ function createRpcClient(name, def) {
|
|
|
215
215
|
...(cwd && { cwd }),
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
/** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void}>} */
|
|
218
|
+
/** @type {Map<number, {resolve: (v: any) => void, reject: (e: any) => void, timer: NodeJS.Timeout | null}>} */
|
|
219
219
|
const pending = new Map();
|
|
220
220
|
let nextId = 1;
|
|
221
221
|
let buffer = '';
|
|
222
222
|
|
|
223
|
+
// Settle a pending request exactly once, clearing its timeout timer. Returns
|
|
224
|
+
// false if the id was already settled (response/close/timeout raced) so callers
|
|
225
|
+
// can avoid double-settling. Every settle path (response, close, write error,
|
|
226
|
+
// timeout) funnels through here.
|
|
227
|
+
/** @param {number} id @param {boolean} ok @param {any} payload @returns {boolean} */
|
|
228
|
+
function settle(id, ok, payload) {
|
|
229
|
+
const p = pending.get(id);
|
|
230
|
+
if (!p) return false;
|
|
231
|
+
pending.delete(id);
|
|
232
|
+
if (p.timer) clearTimeout(p.timer);
|
|
233
|
+
if (ok) p.resolve(payload); else p.reject(payload);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
223
237
|
child.stdout.setEncoding('utf8');
|
|
224
238
|
child.stdout.on('data', (chunk) => {
|
|
225
239
|
buffer += chunk;
|
|
@@ -231,15 +245,12 @@ function createRpcClient(name, def) {
|
|
|
231
245
|
let msg;
|
|
232
246
|
try { msg = JSON.parse(line); } catch { continue; }
|
|
233
247
|
if (!msg.id) continue;
|
|
234
|
-
const p = pending.get(msg.id);
|
|
235
|
-
if (!p) continue;
|
|
236
|
-
pending.delete(msg.id);
|
|
237
248
|
if (msg.error) {
|
|
238
|
-
|
|
249
|
+
settle(msg.id, false, new ToolError(`MCP server "${name}": ${msg.error.message}`, {
|
|
239
250
|
context: { code: msg.error.code },
|
|
240
251
|
}));
|
|
241
252
|
} else {
|
|
242
|
-
|
|
253
|
+
settle(msg.id, true, msg.result);
|
|
243
254
|
}
|
|
244
255
|
}
|
|
245
256
|
});
|
|
@@ -248,24 +259,49 @@ function createRpcClient(name, def) {
|
|
|
248
259
|
child.stderr?.setEncoding('utf8');
|
|
249
260
|
child.stderr?.on('data', (chunk) => { stderrBuf += chunk; });
|
|
250
261
|
|
|
262
|
+
// A child can exit (crash, fast-exit before init, killed) at any moment.
|
|
263
|
+
// Writing to its stdin then emits an 'error' on the pipe; with NO listener,
|
|
264
|
+
// Node re-throws it as an uncaught exception and takes down the HOST process.
|
|
265
|
+
// Swallow it here — pending rpc()s are rejected by the 'close' handler below,
|
|
266
|
+
// and rpc()/notify() guard writability before writing.
|
|
267
|
+
child.stdin.on('error', () => { /* child gone; surfaced via close + write guards */ });
|
|
268
|
+
|
|
251
269
|
child.on('close', (code) => {
|
|
252
|
-
for (const
|
|
253
|
-
|
|
270
|
+
for (const id of [...pending.keys()]) {
|
|
271
|
+
settle(id, false, new ToolError(`MCP server "${name}" exited (code ${code}). stderr: ${stderrBuf.slice(-500)}`));
|
|
254
272
|
}
|
|
255
|
-
pending.clear();
|
|
256
273
|
});
|
|
257
274
|
|
|
258
275
|
/**
|
|
276
|
+
* Send a JSON-RPC request and await its response. Bounded by `timeoutMs`: a
|
|
277
|
+
* server that accepts the write but never answers (or answers a different id)
|
|
278
|
+
* would otherwise hang the caller forever — only `initialize` used to be
|
|
279
|
+
* bounded, leaving `tools/list` and `tools/call` open-ended. Pass 0 to disable.
|
|
259
280
|
* @param {string} method
|
|
260
281
|
* @param {object} [params]
|
|
282
|
+
* @param {number} [timeoutMs=0] - Reject if no response arrives within this many ms (0 = no limit).
|
|
261
283
|
* @returns {Promise<any>}
|
|
262
284
|
*/
|
|
263
|
-
function rpc(method, params = {}) {
|
|
285
|
+
function rpc(method, params = {}, timeoutMs = 0) {
|
|
264
286
|
const id = nextId++;
|
|
265
287
|
return new Promise((resolve, reject) => {
|
|
266
|
-
|
|
288
|
+
if (!child.stdin.writable) {
|
|
289
|
+
return reject(new ToolError(`MCP server "${name}" stdin is not writable (process exited or pipe closed). stderr: ${stderrBuf.slice(-500)}`));
|
|
290
|
+
}
|
|
291
|
+
/** @type {NodeJS.Timeout | null} */
|
|
292
|
+
let timer = null;
|
|
293
|
+
if (timeoutMs > 0) {
|
|
294
|
+
timer = setTimeout(() => {
|
|
295
|
+
settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
|
|
296
|
+
}, timeoutMs);
|
|
297
|
+
timer.unref?.();
|
|
298
|
+
}
|
|
299
|
+
pending.set(id, { resolve, reject, timer });
|
|
267
300
|
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
268
|
-
child.stdin.write(msg)
|
|
301
|
+
child.stdin.write(msg, (err) => {
|
|
302
|
+
// settle() no-ops if 'close'/timeout already settled this id.
|
|
303
|
+
if (err) settle(id, false, new ToolError(`MCP server "${name}" write failed: ${err.message}. stderr: ${stderrBuf.slice(-500)}`));
|
|
304
|
+
});
|
|
269
305
|
});
|
|
270
306
|
}
|
|
271
307
|
|
|
@@ -274,8 +310,9 @@ function createRpcClient(name, def) {
|
|
|
274
310
|
* @param {object} [params]
|
|
275
311
|
*/
|
|
276
312
|
function notify(method, params = {}) {
|
|
313
|
+
if (!child.stdin.writable) return;
|
|
277
314
|
const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
|
|
278
|
-
child.stdin.write(msg);
|
|
315
|
+
child.stdin.write(msg, () => { /* write errors swallowed via stdin 'error' handler */ });
|
|
279
316
|
}
|
|
280
317
|
|
|
281
318
|
return { rpc, notify, child, get stderr() { return stderrBuf; } };
|
|
@@ -301,16 +338,17 @@ function unwrapContent(content) {
|
|
|
301
338
|
/**
|
|
302
339
|
* @param {string} serverName
|
|
303
340
|
* @param {McpTool[]} mcpTools
|
|
304
|
-
* @param {(method: string, params?: object) => Promise<any>} rpc
|
|
341
|
+
* @param {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
|
|
342
|
+
* @param {number} [callTimeout=0] - Per-invocation timeout (ms) for tools/call; 0 = no limit.
|
|
305
343
|
* @returns {ToolDef[]}
|
|
306
344
|
*/
|
|
307
|
-
function wrapTools(serverName, mcpTools, rpc) {
|
|
345
|
+
function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
|
|
308
346
|
return mcpTools.map(t => ({
|
|
309
347
|
name: `${serverName}_${t.name}`,
|
|
310
348
|
description: t.description || '',
|
|
311
349
|
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
312
350
|
execute: async (args) => {
|
|
313
|
-
const result = await rpc('tools/call', { name: t.name, arguments: args });
|
|
351
|
+
const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout);
|
|
314
352
|
if (result.isError) {
|
|
315
353
|
throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
|
|
316
354
|
context: { server: serverName, tool: t.name },
|
|
@@ -374,21 +412,17 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
374
412
|
const client = createRpcClient(name, def);
|
|
375
413
|
|
|
376
414
|
try {
|
|
377
|
-
|
|
415
|
+
// Both handshake round-trips are bounded by `timeout`. tools/list used to be
|
|
416
|
+
// unbounded — a server that answered initialize but never replied to
|
|
417
|
+
// tools/list would hang discovery (and the whole bridge) indefinitely.
|
|
418
|
+
await client.rpc('initialize', {
|
|
378
419
|
protocolVersion: '2024-11-05',
|
|
379
420
|
capabilities: {},
|
|
380
421
|
clientInfo: { name: 'bare-agent', version: '0.5.0' },
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
let timerId;
|
|
384
|
-
const timer = new Promise((_, reject) => {
|
|
385
|
-
timerId = setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
try { await Promise.race([init, timer]); } finally { clearTimeout(timerId); }
|
|
422
|
+
}, timeout);
|
|
389
423
|
client.notify('notifications/initialized');
|
|
390
424
|
|
|
391
|
-
const { tools: mcpTools } = await client.rpc('tools/list');
|
|
425
|
+
const { tools: mcpTools } = await client.rpc('tools/list', {}, timeout);
|
|
392
426
|
|
|
393
427
|
return { mcpTools, client };
|
|
394
428
|
} catch (err) {
|
|
@@ -562,7 +596,9 @@ function buildMetaTools(tools, discoveredAt) {
|
|
|
562
596
|
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
563
597
|
* @param {string[]} [opts.configPaths] - IDE config paths for discovery.
|
|
564
598
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
565
|
-
* @param {number} [opts.timeout=15000] - Per-server
|
|
599
|
+
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
600
|
+
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
601
|
+
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
566
602
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
567
603
|
* @param {(name: string, def: ServerDef) => boolean | Promise<boolean>} [opts.confirmServer]
|
|
568
604
|
* Vet each discovered server BEFORE its `command` is spawned. Connecting to an
|
|
@@ -583,6 +619,8 @@ async function createMCPBridge(opts = {}) {
|
|
|
583
619
|
}
|
|
584
620
|
const bridgePath = opts.bridgePath || DEFAULT_BRIDGE_PATH();
|
|
585
621
|
const timeout = opts.timeout || 15000;
|
|
622
|
+
// 0 is a valid explicit "unbounded"; only undefined falls back to the default.
|
|
623
|
+
const callTimeout = opts.callTimeout ?? 120000;
|
|
586
624
|
|
|
587
625
|
// Vet a server before spawning its command. Fail-closed: an undefined hook
|
|
588
626
|
// trusts all (unchanged behavior); a throw denies.
|
|
@@ -594,6 +632,24 @@ async function createMCPBridge(opts = {}) {
|
|
|
594
632
|
catch { return false; }
|
|
595
633
|
};
|
|
596
634
|
|
|
635
|
+
// Connecting to a server EXECUTES its `command`, which can originate from a
|
|
636
|
+
// cwd-relative .mcp.json in an untrusted repo (discoverServers reads project
|
|
637
|
+
// configs). With no confirmServer hook, every discovered command runs unvetted.
|
|
638
|
+
// Warn ONCE per call, BEFORE the first spawn — and the first spawn is the
|
|
639
|
+
// discovery phase on a cold/refresh run, not the main-connect phase below.
|
|
640
|
+
let warnedUnvetted = false;
|
|
641
|
+
/** @param {Array<{name: string, command: string, args?: string[]}>} specs */
|
|
642
|
+
const warnUnvettedSpawn = (specs) => {
|
|
643
|
+
if (confirmServer || warnedUnvetted || specs.length === 0) return;
|
|
644
|
+
warnedUnvetted = true;
|
|
645
|
+
const cmds = specs.map(s => `${s.name} → ${s.command} ${(s.args || []).join(' ')}`.trim());
|
|
646
|
+
console.warn(
|
|
647
|
+
`[MCP Bridge] spawning ${specs.length} server command(s) without a confirmServer hook:\n ` +
|
|
648
|
+
cmds.join('\n ') +
|
|
649
|
+
`\n Pass { confirmServer } to vet each command before it runs.`,
|
|
650
|
+
);
|
|
651
|
+
};
|
|
652
|
+
|
|
597
653
|
let config = readBridgeConfig(bridgePath);
|
|
598
654
|
const needsRefresh = opts.refresh || !config || isExpired(config);
|
|
599
655
|
|
|
@@ -621,6 +677,8 @@ async function createMCPBridge(opts = {}) {
|
|
|
621
677
|
? [...discovered.entries()].filter(([n]) => reqServers.includes(n))
|
|
622
678
|
: [...discovered.entries()];
|
|
623
679
|
|
|
680
|
+
warnUnvettedSpawn(toDiscover.map(([name, def]) => ({ name, command: def.command, args: def.args })));
|
|
681
|
+
|
|
624
682
|
await Promise.all(toDiscover.map(async ([name, def]) => {
|
|
625
683
|
try {
|
|
626
684
|
// Denied by confirmServer: skip silently — this is the caller's own
|
|
@@ -674,6 +732,11 @@ async function createMCPBridge(opts = {}) {
|
|
|
674
732
|
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
675
733
|
}
|
|
676
734
|
|
|
735
|
+
// Warn before the main-connect spawn too. On a warm run (config exists, no
|
|
736
|
+
// refresh) this is the first and only spawn; on a cold run the discovery phase
|
|
737
|
+
// already warned, so the once-flag makes this a no-op.
|
|
738
|
+
warnUnvettedSpawn(serverNames.map(n => ({ name: n, command: cfg.servers[n].command, args: cfg.servers[n].args })));
|
|
739
|
+
|
|
677
740
|
// Connect to servers and wrap only allowed tools
|
|
678
741
|
/** @type {ToolDef[]} */
|
|
679
742
|
const tools = [];
|
|
@@ -702,7 +765,7 @@ async function createMCPBridge(opts = {}) {
|
|
|
702
765
|
|
|
703
766
|
// Only wrap tools that are allowed in config
|
|
704
767
|
const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
|
|
705
|
-
const wrapped = wrapTools(name, allowed, client.rpc);
|
|
768
|
+
const wrapped = wrapTools(name, allowed, client.rpc, callTimeout);
|
|
706
769
|
|
|
707
770
|
tools.push(...wrapped);
|
|
708
771
|
children.push(client.child);
|
package/src/provider-openai.d.ts
CHANGED
|
@@ -15,10 +15,6 @@ export type OpenAIOptions = {
|
|
|
15
15
|
*/
|
|
16
16
|
exposeErrorBody?: boolean | undefined;
|
|
17
17
|
};
|
|
18
|
-
/** @typedef {import('../types').Message} Message */
|
|
19
|
-
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
20
|
-
/** @typedef {import('../types').ToolCall} ToolCall */
|
|
21
|
-
/** @typedef {import('../types').GenerateResult} GenerateResult */
|
|
22
18
|
/**
|
|
23
19
|
* @typedef {object} OpenAIOptions
|
|
24
20
|
* @property {string} [apiKey]
|
|
@@ -54,4 +50,5 @@ export class OpenAIProvider {
|
|
|
54
50
|
* @returns {Promise<any>}
|
|
55
51
|
*/
|
|
56
52
|
_request(path: string, body: Record<string, any>): Promise<any>;
|
|
53
|
+
_warnedInsecure: boolean | undefined;
|
|
57
54
|
}
|
package/src/provider-openai.js
CHANGED
|
@@ -9,6 +9,12 @@ const { ProviderError } = require('./errors');
|
|
|
9
9
|
/** @typedef {import('../types').ToolCall} ToolCall */
|
|
10
10
|
/** @typedef {import('../types').GenerateResult} GenerateResult */
|
|
11
11
|
|
|
12
|
+
/** @param {string} hostname @returns {boolean} */
|
|
13
|
+
function isLoopbackHost(hostname) {
|
|
14
|
+
const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
|
15
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.startsWith('127.');
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
/**
|
|
13
19
|
* @typedef {object} OpenAIOptions
|
|
14
20
|
* @property {string} [apiKey]
|
|
@@ -84,6 +90,17 @@ class OpenAIProvider {
|
|
|
84
90
|
const transport = url.protocol === 'https:' ? https : http;
|
|
85
91
|
const payload = JSON.stringify(body);
|
|
86
92
|
|
|
93
|
+
// Sending a Bearer key over plaintext http to a non-loopback host exposes
|
|
94
|
+
// it to anyone on-path. Loopback (local proxies / Ollama-style endpoints)
|
|
95
|
+
// is the legitimate keyless case, so only warn for remote http. Warn once.
|
|
96
|
+
if (this.apiKey && url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !this._warnedInsecure) {
|
|
97
|
+
this._warnedInsecure = true;
|
|
98
|
+
console.warn(
|
|
99
|
+
`[OpenAIProvider] sending Authorization key over PLAINTEXT http to ${url.hostname} — ` +
|
|
100
|
+
`the key is exposed on the wire. Use https, or drop the apiKey for keyless local endpoints.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
const req = transport.request(url, {
|
|
88
105
|
method: 'POST',
|
|
89
106
|
headers: {
|