bare-agent 0.12.2 → 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 +6 -4
- package/bareagent.context.md +44 -5
- package/bin/cli.js +54 -2
- package/examples/README.md +1 -0
- package/examples/litectx-as-store.mjs +78 -0
- package/examples/litectx-assemble.mjs +78 -0
- package/examples/litectx-mcp-child.mjs +57 -0
- package/examples/wake.sh +8 -0
- package/index.d.ts +4 -1
- package/index.js +4 -0
- package/package.json +3 -2
- package/src/context-units.d.ts +44 -0
- package/src/context-units.js +225 -0
- package/src/loop.d.ts +11 -0
- package/src/loop.js +31 -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/src/tools.d.ts +2 -1
- package/src/tools.js +2 -0
- package/tools/litectx-mcp.d.ts +28 -0
- package/tools/litectx-mcp.js +65 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// RT-1 — the msgs⇄units adapter for the `assemble` context-assembly seam (src/loop.js).
|
|
4
|
+
//
|
|
5
|
+
// Division of labour (litectx CE-PRD §8.2, line 321 — the frozen contract):
|
|
6
|
+
// - bareagent owns GRAMMAR: msgs→units, units→msgs, atomic bundling of a tool-call + its result(s),
|
|
7
|
+
// `pinned` flags, the pairing seatbelt, and fail-open. Broken tool-pairing and a dropped system
|
|
8
|
+
// prompt are UNREPRESENTABLE here, by construction — not by trusting the consumer.
|
|
9
|
+
// - litectx owns CONTENT + RELEVANCE: a single verb `assemble(units, ctx) → { units, dropped, tokens }`
|
|
10
|
+
// (the AssembleResult envelope — `dropped[]` is load-bearing, ships in the same slice, never silent)
|
|
11
|
+
// that does SELECT (keep / drop / reorder / recall-inject) + COMPRESS (rewrite a unit's `content`) +
|
|
12
|
+
// fit-to-`ctx.budget`, best-effort. It never learns the message grammar. `unitAssembler` unwraps `.units`.
|
|
13
|
+
//
|
|
14
|
+
// The neutral unit (the SOCKET litectx codes against — exactly these 7 enumerable fields):
|
|
15
|
+
// { id, role, content, kind, pinned, atomic, tokensApprox }
|
|
16
|
+
// id — stable within one assemble() call; litectx references units by it.
|
|
17
|
+
// role — 'system' | 'user' | 'assistant' | 'tool' (the message grammar role).
|
|
18
|
+
// content — flat string view litectx reads to score and MAY rewrite to COMPRESS.
|
|
19
|
+
// kind — litectx's memory-node kind ('code'|'doc'|'fact'|'episode') on units litectx
|
|
20
|
+
// INJECTS from recall. Transcript-derived units carry `null` — and that is the
|
|
21
|
+
// CORRECT value, not a placeholder. `role` and `kind` are orthogonal: `role`
|
|
22
|
+
// (user/assistant/tool/system) carries conversational position = bareagent's
|
|
23
|
+
// grammar, present on every unit; `kind` carries litectx's graph-node type, present
|
|
24
|
+
// only on units litectx owns. Typing a live turn would force litectx to learn the
|
|
25
|
+
// provider's conversation structure — the exact coupling the keystone boundary
|
|
26
|
+
// forbids (PRD §1.2 / litectx CE-PRD §8.2). RATIFIED by litectx (2026-06-12): do
|
|
27
|
+
// NOT define transcript-kinds. injected = kinded, transcript = null.
|
|
28
|
+
// pinned — true ⇒ bareagent guarantees the unit is never dropped and never reordered
|
|
29
|
+
// (system prompt, first user/task turn). litectx still sees its tokensApprox so the
|
|
30
|
+
// pin counts against the budget: pin-don't-hide.
|
|
31
|
+
// atomic — group-id (string) | null. litectx keeps/drops units sharing one group-id WHOLE,
|
|
32
|
+
// never split. bareagent pre-bundles an assistant tool-call + ALL its result(s) into
|
|
33
|
+
// one unit, so each bundle is its OWN group (group-id = the unit's id); everything
|
|
34
|
+
// else is null. NOTE: string|null, not boolean — a boolean collapses every bundle
|
|
35
|
+
// under one key and litectx would fit them all-or-nothing (test/context-units.test.js (real-litectx sweep)).
|
|
36
|
+
// tokensApprox — chars/4 estimate over the unit's backing messages, for litectx's budget math.
|
|
37
|
+
//
|
|
38
|
+
// `_msgs` (the verbatim backing messages) is attached NON-ENUMERABLE: it does not appear in the 7-field
|
|
39
|
+
// view, JSON, or iteration. It is bareagent's reconstruction backing — litectx must not read it. A unit
|
|
40
|
+
// with no backing (one litectx minted via recall-inject) is synthesised into a single message on the
|
|
41
|
+
// way out.
|
|
42
|
+
|
|
43
|
+
/** chars/4 token estimate over a list of messages (matches poc2 / the Loop's own heuristic). */
|
|
44
|
+
function approxTokens(msgs) {
|
|
45
|
+
let c = 0;
|
|
46
|
+
for (const m of msgs) {
|
|
47
|
+
if (m.content != null) c += String(m.content).length;
|
|
48
|
+
if (m.tool_calls) c += JSON.stringify(m.tool_calls).length;
|
|
49
|
+
}
|
|
50
|
+
return Math.ceil(c / 4);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Flat string view of a unit's backing messages — what litectx scores on and may rewrite. */
|
|
54
|
+
function renderContent(msgs) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const m of msgs) {
|
|
57
|
+
if (m.content != null && String(m.content).length) parts.push(String(m.content));
|
|
58
|
+
}
|
|
59
|
+
return parts.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let _seq = 0;
|
|
63
|
+
/** Attach the verbatim backing + original-content fingerprint as non-enumerable carry. */
|
|
64
|
+
function withBacking(unit, msgs) {
|
|
65
|
+
Object.defineProperty(unit, '_msgs', { value: msgs, enumerable: false, writable: true });
|
|
66
|
+
Object.defineProperty(unit, '_origContent', { value: unit.content, enumerable: false, writable: true });
|
|
67
|
+
return unit;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* msgs → neutral units. Bundles each assistant-tool-call message with the contiguous tool result(s)
|
|
72
|
+
* that answer its ids into ONE atomic unit (so pairing can never be split). system + first user turn
|
|
73
|
+
* are pinned.
|
|
74
|
+
* @param {Array<Record<string, any>>} msgs
|
|
75
|
+
* @returns {Array<Record<string, any>>}
|
|
76
|
+
*/
|
|
77
|
+
function toUnits(msgs) {
|
|
78
|
+
const units = [];
|
|
79
|
+
let seenUser = false;
|
|
80
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
81
|
+
const m = msgs[i];
|
|
82
|
+
|
|
83
|
+
if (m.role === 'system') {
|
|
84
|
+
units.push(withBacking({ id: `u${_seq++}`, role: 'system', content: renderContent([m]), kind: null, pinned: true, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (m.role === 'user') {
|
|
88
|
+
const pinned = !seenUser; // first user turn = the task; pin it
|
|
89
|
+
seenUser = true;
|
|
90
|
+
units.push(withBacking({ id: `u${_seq++}`, role: 'user', content: renderContent([m]), kind: null, pinned, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
94
|
+
// bundle this assistant message with the contiguous tool results answering its ids
|
|
95
|
+
const ids = new Set(m.tool_calls.map((t) => t.id));
|
|
96
|
+
const group = [m];
|
|
97
|
+
let j = i + 1;
|
|
98
|
+
while (j < msgs.length && msgs[j].role === 'tool' && ids.has(msgs[j].tool_call_id)) {
|
|
99
|
+
group.push(msgs[j]);
|
|
100
|
+
j++;
|
|
101
|
+
}
|
|
102
|
+
// `atomic` is litectx's group-id (string|null), NOT a boolean: units sharing one are kept/dropped
|
|
103
|
+
// whole. bareagent already pre-bundles a tool-call + its result(s) into ONE unit, so each bundle is
|
|
104
|
+
// its OWN group — group-id = the unit's id. (A boolean would collapse every bundle under one key,
|
|
105
|
+
// making litectx fit them all-or-nothing — see test/context-units.test.js (real-litectx sweep).)
|
|
106
|
+
const gid = `u${_seq++}`;
|
|
107
|
+
units.push(withBacking({ id: gid, role: 'assistant', content: renderContent(group.slice(1)), kind: null, pinned: false, atomic: gid, tokensApprox: approxTokens(group) }, group));
|
|
108
|
+
i = j - 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// plain assistant text, or a stray tool message (handled by the seatbelt on the way out)
|
|
112
|
+
units.push(withBacking({ id: `u${_seq++}`, role: m.role, content: renderContent([m]), kind: null, pinned: false, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
|
|
113
|
+
}
|
|
114
|
+
return units;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Drop any tool-result whose tool_call_id has no open assistant tool-call before it, and any assistant
|
|
119
|
+
* tool-call message left with zero surviving results. The final grammar guard: even if litectx hands
|
|
120
|
+
* back something that would orphan a pair, the wire is always valid. Returns a fresh array.
|
|
121
|
+
*/
|
|
122
|
+
function pairingSeatbelt(msgs) {
|
|
123
|
+
// pass 1: which tool_call ids actually have a result present?
|
|
124
|
+
const resultIds = new Set();
|
|
125
|
+
for (const m of msgs) if (m.role === 'tool' && m.tool_call_id != null) resultIds.add(m.tool_call_id);
|
|
126
|
+
|
|
127
|
+
const out = [];
|
|
128
|
+
const open = new Set();
|
|
129
|
+
for (const m of msgs) {
|
|
130
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
131
|
+
const surviving = m.tool_calls.filter((tc) => resultIds.has(tc.id));
|
|
132
|
+
if (!surviving.length) continue; // assistant tool-call with no results at all → drop the message
|
|
133
|
+
for (const tc of surviving) open.add(tc.id);
|
|
134
|
+
out.push(surviving.length === m.tool_calls.length ? m : { ...m, tool_calls: surviving });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (m.role === 'tool') {
|
|
138
|
+
if (!open.has(m.tool_call_id)) continue; // orphan result → drop
|
|
139
|
+
open.delete(m.tool_call_id);
|
|
140
|
+
out.push(m);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
out.push(m);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* units → msgs. Honors drop (absent units), reorder (order of the returned array), recall-inject
|
|
150
|
+
* (units with no backing → one synthesised message), and COMPRESS (a unit whose `content` was rewritten
|
|
151
|
+
* is reconstructed from the new content). Atomic units keep their assistant tool-call message verbatim
|
|
152
|
+
* so pairing holds; a content rewrite lands on the tool RESULT. A multi-result atomic bundle whose
|
|
153
|
+
* content was rewritten is kept VERBATIM — a flat string can't be faithfully split back into N
|
|
154
|
+
* results, and splitting is grammar (bareagent's), not litectx's to attempt. This isn't a special
|
|
155
|
+
* case: litectx's compress() is a pure text→text render that returns verbatim when handed no single
|
|
156
|
+
* parseable format (compress.js — "never returns less than the body losslessly"), so a flattened
|
|
157
|
+
* multi-result unit round-trips unchanged on both sides. RATIFIED by litectx (2026-06-12). The pairing
|
|
158
|
+
* seatbelt is the final guard.
|
|
159
|
+
* @param {Array<Record<string, any>>} units
|
|
160
|
+
* @returns {Array<Record<string, any>>}
|
|
161
|
+
*/
|
|
162
|
+
function fromUnits(units) {
|
|
163
|
+
const msgs = [];
|
|
164
|
+
for (const u of units) {
|
|
165
|
+
if (!u || typeof u !== 'object') continue;
|
|
166
|
+
const backing = u._msgs;
|
|
167
|
+
|
|
168
|
+
if (!Array.isArray(backing)) {
|
|
169
|
+
// litectx-minted (recall-inject): synthesise one message from role + content
|
|
170
|
+
msgs.push({ role: u.role || 'user', content: u.content == null ? '' : String(u.content) });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const edited = u.content !== u._origContent;
|
|
175
|
+
if (!edited) {
|
|
176
|
+
for (const m of backing) msgs.push(m); // verbatim — preserves tool_call_id pairing exactly
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!u.atomic) {
|
|
181
|
+
// single backing message, content rewritten
|
|
182
|
+
msgs.push({ ...backing[0], content: u.content == null ? '' : String(u.content) });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// atomic + content rewritten: assistant message stays verbatim (pairing); rewrite lands on the
|
|
187
|
+
// single result. Multi-result bundle → keep verbatim (unsplittable).
|
|
188
|
+
const results = backing.slice(1);
|
|
189
|
+
if (results.length === 1) {
|
|
190
|
+
msgs.push(backing[0]);
|
|
191
|
+
msgs.push({ ...results[0], content: u.content == null ? '' : String(u.content) });
|
|
192
|
+
} else {
|
|
193
|
+
for (const m of backing) msgs.push(m);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return pairingSeatbelt(msgs);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Wrap litectx's `assemble(units, ctx)` verb into the Loop's msgs-level `assemble(msgs, ctx)` seam.
|
|
201
|
+
* litectx ships the **`AssembleResult` envelope** `{ units, dropped, tokens }` (CE-PRD §8.2: `dropped[]`
|
|
202
|
+
* is load-bearing — it ships in the same slice, never silently truncated). This wrapper accepts that
|
|
203
|
+
* envelope (uses `.units`) OR a bare `units` array (a simpler consumer). `dropped`/`tokens` are litectx's
|
|
204
|
+
* accounting; the Loop's seam is msgs-in/msgs-out, so they're not threaded onward here (the canonical
|
|
205
|
+
* transcript already holds every dropped unit by id — restorable on demand).
|
|
206
|
+
* Fail-OPEN at this layer too: any other return shape → the original msgs are sent unchanged. A thrown
|
|
207
|
+
* error (incl. HaltError) is left to the Loop's own fail-open / HaltError handling — not swallowed here.
|
|
208
|
+
* @param {(units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)} assembleUnits
|
|
209
|
+
* @returns {(msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>}
|
|
210
|
+
*/
|
|
211
|
+
function unitAssembler(assembleUnits) {
|
|
212
|
+
if (typeof assembleUnits !== 'function') {
|
|
213
|
+
throw new Error('[context-units] unitAssembler(fn): fn must be (units, ctx) => units');
|
|
214
|
+
}
|
|
215
|
+
return async (msgs, ctx) => {
|
|
216
|
+
const units = toUnits(msgs);
|
|
217
|
+
const out = await assembleUnits(units, ctx);
|
|
218
|
+
// litectx returns { units, dropped, tokens }; a bare array is also accepted. Anything else → fail-open.
|
|
219
|
+
const view = Array.isArray(out) ? out : (out && Array.isArray(out.units) ? out.units : null);
|
|
220
|
+
if (!view) return msgs; // fail-open: unrecognised return → full context
|
|
221
|
+
return fromUnits(view);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { toUnits, fromUnits, unitAssembler, approxTokens, pairingSeatbelt };
|
package/src/loop.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ export type LoopOptions = {
|
|
|
20
20
|
onError?: Function | undefined;
|
|
21
21
|
throwOnError?: boolean | undefined;
|
|
22
22
|
policy?: Function | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the
|
|
25
|
+
* window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the
|
|
26
|
+
* canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a
|
|
27
|
+
* thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
|
|
28
|
+
* same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
|
|
29
|
+
* neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter
|
|
30
|
+
* (src/context-units.js), which composes over this msgs-level seam.
|
|
31
|
+
*/
|
|
32
|
+
assemble?: Function | undefined;
|
|
23
33
|
onLlmResult?: Function | undefined;
|
|
24
34
|
onToolResult?: Function | undefined;
|
|
25
35
|
/**
|
|
@@ -49,6 +59,7 @@ export class Loop {
|
|
|
49
59
|
throwOnError: boolean;
|
|
50
60
|
store: import("../types").Store | null;
|
|
51
61
|
policy: Function | null;
|
|
62
|
+
assemble: Function | null;
|
|
52
63
|
onLlmResult: Function | null;
|
|
53
64
|
onToolResult: Function | null;
|
|
54
65
|
_stopped: boolean;
|
package/src/loop.js
CHANGED
|
@@ -26,6 +26,13 @@ const { ToolError, HaltError } = require('./errors');
|
|
|
26
26
|
* @property {Function} [onError]
|
|
27
27
|
* @property {boolean} [throwOnError]
|
|
28
28
|
* @property {Function} [policy]
|
|
29
|
+
* @property {Function} [assemble] - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the
|
|
30
|
+
* window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the
|
|
31
|
+
* canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a
|
|
32
|
+
* thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
|
|
33
|
+
* same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
|
|
34
|
+
* neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter
|
|
35
|
+
* (src/context-units.js), which composes over this msgs-level seam.
|
|
29
36
|
* @property {Function} [onLlmResult]
|
|
30
37
|
* @property {Function} [onToolResult]
|
|
31
38
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
@@ -132,6 +139,10 @@ class Loop {
|
|
|
132
139
|
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
133
140
|
}
|
|
134
141
|
this.policy = options.policy || null;
|
|
142
|
+
if (options.assemble != null && typeof options.assemble !== 'function') {
|
|
143
|
+
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
144
|
+
}
|
|
145
|
+
this.assemble = options.assemble || null;
|
|
135
146
|
if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
|
|
136
147
|
throw new Error('[Loop] options.onLlmResult must be a function');
|
|
137
148
|
}
|
|
@@ -243,10 +254,29 @@ class Loop {
|
|
|
243
254
|
for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
|
|
244
255
|
if (this._stopped) break;
|
|
245
256
|
|
|
257
|
+
// RT-1: context-assembly chokepoint. Let a caller (e.g. a context-engineering library) shape
|
|
258
|
+
// the window sent to the provider this round. Returns a VIEW — the canonical `msgs` transcript
|
|
259
|
+
// is never mutated, so result.msgs stays complete and correct. Fail-OPEN: an assembly error
|
|
260
|
+
// degrades to sending full context (a context-optimizer bug must not halt the agent); a thrown
|
|
261
|
+
// HaltError is a governance exit and propagates (same contract as onLlmResult).
|
|
262
|
+
let toSend = msgs;
|
|
263
|
+
if (this.assemble) {
|
|
264
|
+
try {
|
|
265
|
+
const view = await this.assemble(msgs, ctx);
|
|
266
|
+
if (Array.isArray(view)) {
|
|
267
|
+
toSend = view;
|
|
268
|
+
this._safeEmit({ type: 'loop:assemble', data: { round, before: msgs.length, after: toSend.length } });
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err instanceof HaltError) throw err;
|
|
272
|
+
this._reportError('assemble', err, { round });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
246
276
|
let result;
|
|
247
277
|
const llmStartedAt = Date.now();
|
|
248
278
|
try {
|
|
249
|
-
const generate = () => this.provider.generate(
|
|
279
|
+
const generate = () => this.provider.generate(toSend, tools, options);
|
|
250
280
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
251
281
|
} catch (err) {
|
|
252
282
|
this._reportError('provider', err, { round });
|
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: {
|
package/src/tools.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ import { createSpawnTool } from "../tools/spawn";
|
|
|
5
5
|
import { spawnChild } from "../tools/spawn";
|
|
6
6
|
import { createDeferTool } from "../tools/defer";
|
|
7
7
|
import { readQueue as readDeferQueue } from "../tools/defer";
|
|
8
|
-
|
|
8
|
+
import { liteCtxMcpBridgeConfig } from "../tools/litectx-mcp";
|
|
9
|
+
export { createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, spawnChild, createDeferTool, readDeferQueue, liteCtxMcpBridgeConfig };
|
package/src/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ const { createMobileTools } = require('../tools/mobile');
|
|
|
5
5
|
const { createShellTools } = require('../tools/shell');
|
|
6
6
|
const { createSpawnTool, spawnChild } = require('../tools/spawn');
|
|
7
7
|
const { createDeferTool, readQueue: readDeferQueue } = require('../tools/defer');
|
|
8
|
+
const { liteCtxMcpBridgeConfig } = require('../tools/litectx-mcp');
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
10
11
|
createBrowsingTools,
|
|
@@ -14,4 +15,5 @@ module.exports = {
|
|
|
14
15
|
spawnChild,
|
|
15
16
|
createDeferTool,
|
|
16
17
|
readDeferQueue,
|
|
18
|
+
liteCtxMcpBridgeConfig,
|
|
17
19
|
};
|