@alphacoder-v0/pi-loops 0.17.3

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,885 @@
1
+ /**
2
+ * The MCP client. pi has no built-in one, so this extension carries the part it needs: enough
3
+ * JSON-RPC to initialize a server over stdio or streamable HTTP, turn its server→client
4
+ * notifications into triggers, and register its tools with the agent.
5
+ *
6
+ * Notifications are the reason it exists — something outside the machine deciding that now is the
7
+ * moment — and the tools come along because a server that can tell you something can usually also
8
+ * be asked.
9
+ */
10
+ import { spawn, type ChildProcess } from "node:child_process";
11
+ import { createHash } from "node:crypto";
12
+ import type { ReplacementPolicy, Trigger } from "./triggers.ts";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+ import { newTraceId } from "./triggers.ts";
18
+ import { parseToml } from "./toml.ts";
19
+ import { PI_LOOPS_VERSION } from "./version.ts";
20
+ import { stamp } from "./schedule.ts";
21
+
22
+ export interface McpServerConfig {
23
+ name: string;
24
+ kind: "stdio" | "streamable_http";
25
+ command?: string;
26
+ args?: string[];
27
+ /** Extra environment for stdio servers */
28
+ env?: Record<string, string>;
29
+ endpoint?: string;
30
+ /** `auth = { kind = "bearer", token_keychain_ref = "<credential name>" }`. `token` inline is a pi-loops convenience. */
31
+ auth?: { kind: string; tokenKeychainRef?: string; token?: string };
32
+ requestTimeoutMs: number;
33
+ sseIdleTimeoutMs: number;
34
+ bodyCapBytes: number;
35
+ reconnect: { initialMs: number; maxMs: number; maxAttempts?: number };
36
+ injectSummary: boolean;
37
+ injectAndRun: boolean;
38
+ source: "user" | "project";
39
+ }
40
+
41
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
42
+ export const DEFAULT_SSE_IDLE_TIMEOUT_MS = 60_000;
43
+ export const DEFAULT_BODY_CAP_BYTES = 1024 * 1024;
44
+ /** Only environment variables with this prefix may be named by an MCP config file. */
45
+ export const MCP_TOKEN_ENV_PREFIX = "PI_MCP_TOKEN_";
46
+
47
+ /**
48
+ * The environment half of credential resolution, prefix-bound. Every caller must go through this:
49
+ * reading `process.env[ref]` directly would let a project's `.pi/mcp.toml` name an unrelated secret
50
+ * (a model API key) and have it sent as a bearer token to that server's own endpoint. A ref names
51
+ * pi's credential store first; only a variable under this prefix is accepted as the fallback.
52
+ */
53
+ export function mcpTokenFromEnv(ref: string): string | undefined {
54
+ return ref.startsWith(MCP_TOKEN_ENV_PREFIX) ? process.env[ref] : undefined;
55
+ }
56
+ const USER_AGENT = `pi-loops/${PI_LOOPS_VERSION} (mcp-streamable-http/2025-03-26)`;
57
+
58
+ export interface McpNotification {
59
+ method: string;
60
+ params: any;
61
+ }
62
+
63
+ export type HookState = "pending" | "connected" | "reconnecting" | "disconnected" | "disabled" | "auth_failed";
64
+
65
+ export interface SourceStatus {
66
+ label: string;
67
+ state: HookState;
68
+ reason?: string;
69
+ subscriptionLabels: string[];
70
+ queuedCount: number;
71
+ droppedCount: number;
72
+ dedupedCount: number;
73
+ lastEventAt?: string;
74
+ /** Last stderr line of a stdio server; diagnostic only, never an error. */
75
+ lastStderr?: string;
76
+ lastError?: string;
77
+ requiresAttention?: string;
78
+ }
79
+
80
+ export interface ParsedMcpConfig {
81
+ servers: McpServerConfig[];
82
+ /** One "mcp server '<name>' failed: …" line per bad server; the good ones still connect. */
83
+ diagnostics: string[];
84
+ }
85
+
86
+ /** Parse one `mcp.toml` document (`[[server]]`), validated per server. */
87
+ export function parseMcpConfig(doc: Record<string, unknown>, source: "user" | "project" = "user"): ParsedMcpConfig {
88
+ const servers = Array.isArray(doc.server) ? (doc.server as any[]) : [];
89
+ const out: ParsedMcpConfig = { servers: [], diagnostics: [] };
90
+ const seen = new Set<string>();
91
+ servers.forEach((s, i) => {
92
+ try {
93
+ const cfg = parseOneServer(s, i, source, seen);
94
+ // A repeated name replaces the earlier entry (last wins); say so.
95
+ const dup = out.servers.findIndex((x) => x.name === cfg.name);
96
+ if (dup >= 0) {
97
+ out.servers[dup] = cfg;
98
+ out.diagnostics.push(`mcp server '${cfg.name}': duplicate name in the ${source} config; the later entry wins`);
99
+ } else out.servers.push(cfg);
100
+ } catch (err: any) {
101
+ const name = typeof s?.name === "string" ? s.name : `#${i + 1}`;
102
+ out.diagnostics.push(`mcp server '${name}' failed: ${err?.message ?? err}`);
103
+ }
104
+ });
105
+ return out;
106
+ }
107
+
108
+ function parseOneServer(s: any, i: number, source: "user" | "project", seen: Set<string>): McpServerConfig {
109
+ const positive = (name: string, v: unknown, field: string): number | undefined => {
110
+ if (v === undefined) return undefined;
111
+ if (typeof v !== "number" || v <= 0) throw new Error(`MCP server '${name}' ${field} must be positive`);
112
+ return v;
113
+ };
114
+ {
115
+ if (!s || typeof s.name !== "string" || !s.name.trim()) throw new Error(`server #${i + 1} in the ${source} config needs a name`);
116
+ seen.add(s.name);
117
+ const kind = s.kind ?? "stdio";
118
+ if (kind !== "stdio" && kind !== "streamable_http") throw new Error(`mcp server '${s.name}': unknown kind "${kind}" (stdio | streamable_http)`);
119
+ if (kind === "stdio") {
120
+ if (s.endpoint !== undefined || s.auth !== undefined) throw new Error(`stdio MCP server '${s.name}' must not set endpoint or auth; remove streamable_http fields`);
121
+ if (typeof s.command !== "string") throw new Error(`stdio MCP server '${s.name}' missing command`);
122
+ } else {
123
+ if (s.command || (Array.isArray(s.args) && s.args.length)) throw new Error(`streamable_http MCP server '${s.name}' must set endpoint, not command/args`);
124
+ if (typeof s.endpoint !== "string") throw new Error(`streamable_http MCP server '${s.name}' missing endpoint`);
125
+ let url: URL;
126
+ try {
127
+ url = new URL(s.endpoint);
128
+ } catch {
129
+ throw new Error(`streamable_http MCP server '${s.name}': invalid endpoint`);
130
+ }
131
+ if (url.protocol !== "https:" && url.hostname !== "127.0.0.1") throw new Error(`streamable_http MCP server '${s.name}': endpoint must be https, except 127.0.0.1 test fixtures`);
132
+ }
133
+ let auth: McpServerConfig["auth"];
134
+ if (s.auth !== undefined) {
135
+ if (!s.auth || typeof s.auth !== "object") throw new Error(`streamable_http MCP server '${s.name}': auth must be a table`);
136
+ if (s.auth.kind !== "bearer") throw new Error(`streamable_http MCP server '${s.name}': unsupported streamable_http auth kind; expected bearer`);
137
+ if (typeof s.auth.token_keychain_ref !== "string" && typeof s.auth.token !== "string") throw new Error(`streamable_http MCP server '${s.name}': bearer auth requires token_keychain_ref`);
138
+ auth = { kind: "bearer", tokenKeychainRef: s.auth.token_keychain_ref, token: s.auth.token };
139
+ }
140
+ const rc = s.reconnect;
141
+ if (rc !== undefined && (!rc || typeof rc !== "object")) throw new Error(`streamable_http MCP server '${s.name}': reconnect must be a table`);
142
+ if (rc && (rc.initial_ms === 0 || rc.max_ms === 0)) throw new Error(`streamable_http MCP server '${s.name}' reconnect delays must be positive`);
143
+ return {
144
+ name: s.name,
145
+ kind,
146
+ command: s.command,
147
+ args: Array.isArray(s.args) ? s.args.map(String) : [],
148
+ env: s.env && typeof s.env === "object" ? Object.fromEntries(Object.entries(s.env).map(([k, v]) => [k, String(v)])) : undefined,
149
+ endpoint: s.endpoint,
150
+ auth,
151
+ requestTimeoutMs: positive(s.name, s.request_timeout_ms, "request_timeout_ms") ?? DEFAULT_REQUEST_TIMEOUT_MS,
152
+ sseIdleTimeoutMs: positive(s.name, s.sse_idle_timeout_ms, "sse_idle_timeout_ms") ?? DEFAULT_SSE_IDLE_TIMEOUT_MS,
153
+ bodyCapBytes: positive(s.name, s.body_cap_bytes, "body_cap_bytes") ?? DEFAULT_BODY_CAP_BYTES,
154
+ reconnect: { initialMs: typeof rc?.initial_ms === "number" ? rc.initial_ms : 500, maxMs: typeof rc?.max_ms === "number" ? rc.max_ms : 30_000, maxAttempts: typeof rc?.max_attempts === "number" ? rc.max_attempts : undefined },
155
+ injectSummary: s.inject_summary === true,
156
+ injectAndRun: s.inject_and_run === true,
157
+ source,
158
+ };
159
+ }
160
+ }
161
+
162
+ /** User config first, project config overrides servers with the same name. */
163
+ export function mergeMcpConfigs(user: McpServerConfig[], project: McpServerConfig[]): McpServerConfig[] {
164
+ const out = [...user];
165
+ for (const s of project) {
166
+ const i = out.findIndex((x) => x.name === s.name);
167
+ if (i >= 0) out[i] = s;
168
+ else out.push(s);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /* ------------------------------------------------- notification → trigger */
174
+
175
+ const SUMMARY_CAP = 200;
176
+
177
+ function redactNotificationText(value: string): string {
178
+ return value
179
+ .split(/\s+/)
180
+ .map((part) => {
181
+ const lower = part.toLowerCase();
182
+ return lower.startsWith("hub_agent_") || lower.startsWith("hub_hs_") || lower.startsWith("hub_ep_") || lower.startsWith("sk-") || lower.includes("bearer") || lower.includes("token") ? "[redacted]" : part;
183
+ })
184
+ .join(" ");
185
+ }
186
+
187
+ function truncateChars(value: string, cap: number): string {
188
+ const chars = Array.from(value);
189
+ return chars.length <= cap ? value : `${chars.slice(0, cap - 1).join("")}…`;
190
+ }
191
+
192
+ export function safeDisplay(value: string, cap: number): string {
193
+ return truncateChars(redactNotificationText(value).replace(/\n/g, " "), cap);
194
+ }
195
+
196
+ function safeIdempotencySegment(value: string): string {
197
+ const redacted = redactNotificationText(value);
198
+ if (redacted !== value || Array.from(value).length > 200 || /\p{Cc}/u.test(value)) {
199
+ return `hash:${createHash("sha256").update(value).digest("hex").slice(0, 12)}`;
200
+ }
201
+ return value;
202
+ }
203
+
204
+ /** A custom notification's own idempotency key: `_meta.pi_dedup_key`, the one place it is read from. */
205
+ function extractDedupKey(params: any): string | undefined {
206
+ const key = params?._meta?.pi_dedup_key;
207
+ return typeof key === "string" ? key : undefined;
208
+ }
209
+
210
+ function idempotencyFor(server: string, method: string, params: any): { key: string; policy: ReplacementPolicy } | undefined {
211
+ const prefix = `mcp:${server}:`;
212
+ switch (method) {
213
+ case "notifications/tools/listChanged":
214
+ return { key: `${prefix}tools`, policy: "latest_replaces" };
215
+ case "notifications/resources/listChanged":
216
+ return { key: `${prefix}resources`, policy: "latest_replaces" };
217
+ case "notifications/prompts/listChanged":
218
+ return { key: `${prefix}prompts`, policy: "latest_replaces" };
219
+ case "notifications/resources/updated": {
220
+ const uri = typeof params?.uri === "string" ? params.uri : "unknown";
221
+ return { key: `${prefix}resources:${safeIdempotencySegment(uri)}`, policy: "latest_replaces" };
222
+ }
223
+ default: {
224
+ const dedupKey = extractDedupKey(params);
225
+ return dedupKey === undefined ? undefined : { key: `${prefix}custom:${safeIdempotencySegment(dedupKey)}`, policy: "drop" };
226
+ }
227
+ }
228
+ }
229
+
230
+ /** Method name plus bounded, redacted display metadata; never raw params. */
231
+ function renderSummary(method: string, params: any): string {
232
+ switch (method) {
233
+ case "notifications/resources/updated":
234
+ return typeof params?.uri === "string" ? `${method} uri=${safeDisplay(params.uri, SUMMARY_CAP)}` : method;
235
+ case "notifications/tools/listChanged":
236
+ case "notifications/resources/listChanged":
237
+ case "notifications/prompts/listChanged":
238
+ return method;
239
+ default: {
240
+ const custom = params?._meta?.pi_summary;
241
+ return typeof custom === "string" ? `${method} ${safeDisplay(custom, SUMMARY_CAP)}` : method;
242
+ }
243
+ }
244
+ }
245
+
246
+ /** The status wording for a custom notification dropped at the adapter. */
247
+ export function droppedNotificationMessage(method: string): string {
248
+ return `dropped custom notification ${JSON.stringify(method)}: missing \`_meta.pi_dedup_key\``;
249
+ }
250
+
251
+ /** undefined means "drop at the adapter" (custom method without a dedup key). */
252
+ export function mapNotification(server: string, n: McpNotification): Trigger | undefined {
253
+ const idem = idempotencyFor(server, n.method, n.params);
254
+ if (!idem) return undefined;
255
+ return {
256
+ source: { kind: "mcp", serverName: server, method: n.method },
257
+ sourceKind: "mcp",
258
+ sourceLabel: `mcp:${server}`,
259
+ eventLabel: n.method,
260
+ payloadSummary: renderSummary(n.method, n.params),
261
+ idempotencyKey: idem.key,
262
+ replacementPolicy: idem.policy,
263
+ traceId: newTraceId(),
264
+ receivedAt: stamp(),
265
+ };
266
+ }
267
+
268
+ /* ------------------------------------------------------------- client */
269
+
270
+ export interface McpClientHooks {
271
+ onNotification: (n: McpNotification) => void;
272
+ onStatus?: (s: SourceStatus) => void;
273
+ /** Fired after the initialize handshake on every (re)connect; the host registers the server's tools here. */
274
+ onConnected?: (source: McpSource) => void | Promise<void>;
275
+ log?: (message: string) => void;
276
+ /** Resolve `auth.token_keychain_ref` to a bearer token (env var, pi credential store, …). */
277
+ resolveToken?: (ref: string) => string | undefined;
278
+ }
279
+
280
+ /** `tools/list` entry (MCP `Tool`). */
281
+ export interface McpToolDef {
282
+ name: string;
283
+ description?: string;
284
+ inputSchema: Record<string, unknown>;
285
+ }
286
+
287
+ /** `tools/call` result content, normalized. */
288
+ export type McpToolContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } | { type: "resource"; resource: unknown };
289
+
290
+ export interface McpToolCallResult {
291
+ content: McpToolContent[];
292
+ isError: boolean;
293
+ }
294
+
295
+
296
+ const CLIENT_INFO = { name: "pi-loops", version: PI_LOOPS_VERSION };
297
+ const PROTOCOL_VERSION = "2025-03-26";
298
+
299
+ export class McpSource {
300
+ readonly config: McpServerConfig;
301
+ readonly status: SourceStatus;
302
+ private readonly hooks: McpClientHooks;
303
+ private proc: ChildProcess | undefined;
304
+ private stopped = false;
305
+ private attempts = 0;
306
+ private nextId = 1;
307
+ private readonly pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
308
+ private httpAbort: AbortController | undefined;
309
+ private httpSessionId: string | undefined;
310
+ /** The server rejected our session id: the next attempt must start a new MCP session. */
311
+ private sessionLost = false;
312
+ /** Resolves the "no server-push stream" park so the connect loop can retry. */
313
+ private wakeParked: (() => void) | undefined;
314
+ private lastEventId: string | undefined;
315
+ /** Outbound frame sender for the live transport (stdio stdin or HTTP POST); undefined while disconnected. */
316
+ private sendFrame: ((frame: unknown) => void) | undefined;
317
+ /** Server tool catalog after `tools/list`, cached on the client. */
318
+ catalog: McpToolDef[] = [];
319
+
320
+ constructor(config: McpServerConfig, hooks: McpClientHooks) {
321
+ this.config = config;
322
+ this.hooks = hooks;
323
+ this.status = { label: `mcp:${config.name}`, state: "pending", subscriptionLabels: [`mcp:${config.name}`], queuedCount: 0, droppedCount: 0, dedupedCount: 0 };
324
+ }
325
+
326
+ start(): void {
327
+ this.stopped = false;
328
+ // The loop handles its own connection errors; this catch is for everything else, because an
329
+ // escaping rejection has no handler above it (pi installs none) and would end the session.
330
+ void this.connectLoop().catch((err: any) => this.hooks.log?.(`mcp:${this.config.name}: connect loop stopped: ${err?.message ?? err}`));
331
+ }
332
+
333
+ get connected(): boolean {
334
+ return this.status.state === "connected" && this.sendFrame !== undefined;
335
+ }
336
+
337
+ /** JSON-RPC request over the live transport. Rejects when disconnected or on timeout. */
338
+ async call(method: string, params: unknown, signal?: AbortSignal): Promise<any> {
339
+ const send = this.sendFrame;
340
+ if (!send) throw new Error(`mcp:${this.config.name} is not connected`);
341
+ return this.request(send, method, params, signal);
342
+ }
343
+
344
+ /** Fetch and cache the server's tool catalog. */
345
+ async listTools(): Promise<McpToolDef[]> {
346
+ const result = await this.call("tools/list", {});
347
+ const tools = Array.isArray(result?.tools) ? result.tools : [];
348
+ this.catalog = tools
349
+ .filter((t: any) => t && typeof t.name === "string")
350
+ .map((t: any) => ({ name: t.name, description: typeof t.description === "string" ? t.description : undefined, inputSchema: t.inputSchema && typeof t.inputSchema === "object" ? t.inputSchema : { type: "object", properties: {} } }));
351
+ return this.catalog;
352
+ }
353
+
354
+ /** Invoke a server tool; an aborted signal sends `notifications/cancelled` best-effort. */
355
+ async callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolCallResult> {
356
+ const result = await this.call("tools/call", { name, arguments: args ?? {} }, signal);
357
+ const content: McpToolContent[] = [];
358
+ for (const block of Array.isArray(result?.content) ? result.content : []) {
359
+ if (block?.type === "text" && typeof block.text === "string") content.push({ type: "text", text: block.text });
360
+ else if (block?.type === "image" && typeof block.data === "string") content.push({ type: "image", data: block.data, mimeType: typeof block.mimeType === "string" ? block.mimeType : "image/png" });
361
+ else if (block?.type === "resource") content.push({ type: "resource", resource: block.resource });
362
+ }
363
+ return { content, isError: result?.isError === true };
364
+ }
365
+
366
+ async stop(): Promise<void> {
367
+ this.stopped = true;
368
+ this.cancelStableTimer();
369
+ this.wakeParked?.();
370
+ this.sendFrame = undefined;
371
+ this.setState("disabled");
372
+ this.httpAbort?.abort();
373
+ if (this.proc) {
374
+ const proc = this.proc;
375
+ this.proc = undefined;
376
+ try {
377
+ proc.kill("SIGTERM");
378
+ // Killing outright is the alternative. A server that traps SIGTERM would otherwise be
379
+ // orphaned when pi exits, so it gets a grace period and then SIGKILL.
380
+ const hard = setTimeout(() => {
381
+ try {
382
+ proc.kill("SIGKILL");
383
+ } catch {
384
+ /* gone */
385
+ }
386
+ }, 2000);
387
+ hard.unref?.();
388
+ proc.once("exit", () => clearTimeout(hard));
389
+ } catch {
390
+ /* gone */
391
+ }
392
+ }
393
+ this.failPending("stopped");
394
+ }
395
+
396
+ private setState(state: HookState, reason?: string): void {
397
+ if (this.stopped && state !== "disabled") return; // stop() has the last word
398
+ this.status.state = state;
399
+ this.status.reason = reason;
400
+ if (reason) this.status.lastError = reason;
401
+ this.hooks.onStatus?.(this.status);
402
+ }
403
+
404
+ /** Default reconnect budget: ~10 minutes of backoff, then give up until pi restarts. */
405
+ static readonly DEFAULT_MAX_ATTEMPTS = 20;
406
+ /** A connection must last this long before its reconnect budget is refunded. */
407
+ static readonly STABLE_MS = 30_000;
408
+ private stableTimer?: NodeJS.Timeout;
409
+
410
+ private async connectLoop(): Promise<void> {
411
+ const rc = this.config.reconnect;
412
+
413
+ const maxAttempts = rc.maxAttempts ?? McpSource.DEFAULT_MAX_ATTEMPTS;
414
+ let lastLogged: string | undefined;
415
+ while (!this.stopped) {
416
+ try {
417
+ this.attempts++;
418
+ if (this.config.kind === "stdio") await this.runStdio();
419
+ else {
420
+ // A restarted remote server drops its sessions: carrying the old id would 404 every
421
+ // POST for the life of the process. A plain reconnect keeps the id (and resumes the
422
+ // stream from Last-Event-ID); a session the server rejected is started fresh.
423
+ if (this.sessionLost) {
424
+ this.httpSessionId = undefined;
425
+ this.lastEventId = undefined;
426
+ this.sessionLost = false;
427
+ }
428
+ await this.runHttp();
429
+ }
430
+ this.cancelStableTimer();
431
+ if (this.stopped) return;
432
+ this.setState("reconnecting", "connection closed");
433
+ } catch (err: any) {
434
+ this.cancelStableTimer();
435
+ if (this.stopped) return;
436
+ const msg = err?.message ?? String(err);
437
+ // Only a real auth status stops the loop for good, and only from the HTTP transport: a
438
+ // stdio command path containing "auth" (`authbind`, /opt/oauth-mcp/…) or an exit code
439
+ // that happens to read 403 used to disable the server permanently.
440
+ if (this.config.kind !== "stdio" && /MCP HTTP (SSE )?status (401|403)\b|unauthori[sz]ed/i.test(msg)) {
441
+ this.setState("auth_failed", msg);
442
+ this.hooks.log?.(`mcp:${this.config.name}: ${msg}`);
443
+ return;
444
+ }
445
+ this.setState("reconnecting", msg);
446
+ // Say it once per distinct error, not once per attempt: a broken command must not
447
+ // turn into a notification every 30 seconds.
448
+ if (msg !== lastLogged) {
449
+ lastLogged = msg;
450
+ this.hooks.log?.(`mcp:${this.config.name}: ${msg} (retrying with backoff, up to ${maxAttempts} attempts)`);
451
+ }
452
+ }
453
+ if (this.attempts >= maxAttempts) {
454
+ this.setState("disconnected", `reconnect attempts exhausted (${this.attempts}); last error: ${this.status.lastError ?? "unknown"}`);
455
+ this.status.requiresAttention = "fix the server config and restart pi, or /reload";
456
+ this.hooks.log?.(`mcp:${this.config.name}: giving up after ${this.attempts} attempts`);
457
+ return;
458
+ }
459
+ const delay = Math.min(rc.maxMs, rc.initialMs * 2 ** Math.min(this.attempts - 1, 16));
460
+ await new Promise((r) => setTimeout(r, delay));
461
+ }
462
+ }
463
+
464
+ /* stdio transport */
465
+ private runStdio(): Promise<void> {
466
+ return new Promise<void>((resolve, reject) => {
467
+ const proc = spawn(this.config.command!, this.config.args ?? [], {
468
+ stdio: ["pipe", "pipe", "pipe"],
469
+ env: { ...process.env, ...(this.config.env ?? {}) },
470
+ });
471
+ this.proc = proc;
472
+ let buffer = "";
473
+ let settled = false;
474
+ const finish = (err?: Error) => {
475
+ if (settled) return;
476
+ settled = true;
477
+ this.proc = undefined;
478
+ this.sendFrame = undefined;
479
+ this.failPending("transport closed");
480
+ err ? reject(err) : resolve();
481
+ };
482
+ this.sendFrame = (frame) => proc.stdin?.write(`${JSON.stringify(frame)}\n`);
483
+ proc.stdout?.on("data", (chunk) => {
484
+ buffer += chunk.toString();
485
+ const lines = buffer.split("\n");
486
+ buffer = lines.pop() ?? "";
487
+ for (const line of lines) this.handleFrame(line, (frame) => proc.stdin?.write(`${JSON.stringify(frame)}\n`));
488
+ });
489
+ proc.stderr?.on("data", (chunk) => {
490
+ // Kept apart from lastError: chatter must not mask a real error.
491
+ const text = chunk.toString().trim();
492
+ if (text) this.status.lastStderr = text.split("\n").slice(-1)[0].slice(0, 200);
493
+ });
494
+ proc.on("error", (err) => finish(new Error(`failed to spawn ${this.config.command}: ${err.message}`)));
495
+ proc.on("close", (code) => finish(this.stopped ? undefined : new Error(`server exited with code ${code}`)));
496
+ void this.initialize((msg) => proc.stdin?.write(`${JSON.stringify(msg)}\n`)).catch((err) => {
497
+ proc.kill("SIGTERM");
498
+ const hard = setTimeout(() => proc.kill("SIGKILL"), 2000);
499
+ hard.unref?.();
500
+ proc.once("exit", () => clearTimeout(hard));
501
+ finish(err);
502
+ });
503
+ });
504
+ }
505
+
506
+ private bearerToken(): string | undefined {
507
+ const auth = this.config.auth;
508
+ if (!auth) return undefined;
509
+ if (auth.tokenKeychainRef) {
510
+ // pi's credential store answers first. Reading any environment variable a config file
511
+ // names would let `.pi/mcp.toml` send an unrelated secret (a model API key) to its own
512
+ // endpoint, so the env fallback is bound to the `PI_MCP_TOKEN_` prefix.
513
+ const ref = auth.tokenKeychainRef;
514
+ const token = this.hooks.resolveToken?.(ref) ?? mcpTokenFromEnv(ref);
515
+ if (!token) throw new Error(`configured bearer credential was not found; store it with pi's credential store, or export it as ${MCP_TOKEN_ENV_PREFIX}… and name that variable`);
516
+ return token;
517
+ }
518
+ return auth.token;
519
+ }
520
+
521
+ /**
522
+ * Streamable HTTP: POST initialize + initialized, then hold the
523
+ * server→client GET stream. Body cap, SSE idle timeout, `Last-Event-ID` resume,
524
+ * and POST responses that are themselves event streams are all honored.
525
+ */
526
+ private async runHttp(): Promise<void> {
527
+ const endpoint = this.config.endpoint!;
528
+ const token = this.bearerToken();
529
+ // One controller for the whole connection, held from the first POST: stop() during the
530
+ // handshake aborts it instead of leaving a stream nobody owns.
531
+ const abort = new AbortController();
532
+ this.httpAbort = abort;
533
+ abort.signal.addEventListener("abort", () => this.failPending("stopped"), { once: true });
534
+ const base: Record<string, string> = { "User-Agent": USER_AGENT, ...(token ? { Authorization: `Bearer ${token}` } : {}) };
535
+ const withSession = (h: Record<string, string>) => (this.httpSessionId ? { ...h, "Mcp-Session-Id": this.httpSessionId } : h);
536
+ const cap = this.config.bodyCapBytes;
537
+ const post = async (body: unknown): Promise<void> => {
538
+ const json = JSON.stringify(body);
539
+ if (Buffer.byteLength(json) > cap) throw new Error("MCP HTTP request exceeded body cap");
540
+ const res = await fetch(endpoint, {
541
+ method: "POST",
542
+ headers: withSession({ ...base, "Content-Type": "application/json", Accept: "application/json, text/event-stream" }),
543
+ body: json,
544
+ signal: AbortSignal.any([abort.signal, AbortSignal.timeout(this.config.requestTimeoutMs)]),
545
+ });
546
+ const sid = res.headers.get("mcp-session-id");
547
+ if (sid) this.httpSessionId = sid;
548
+ if (res.status === 404 || res.status === 400) {
549
+ this.sessionLost = true;
550
+ this.wakeParked?.(); // a source parked on "no push stream" has to reconnect to recover
551
+ }
552
+ if (!res.ok) throw new Error(`MCP HTTP status ${res.status}; response body redacted`);
553
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
554
+ if (ct.startsWith("text/event-stream")) {
555
+ await this.readSse(res, undefined, (data) => this.handleFrame(data, (frame) => void post(frame).catch(() => {})));
556
+ return;
557
+ }
558
+ const text = await this.cappedText(res, cap);
559
+ if (text.trim()) this.handleFrame(text.trim(), (frame) => void post(frame).catch(() => {}));
560
+ };
561
+ await this.request((frame) => void post(frame).catch((err) => this.hooks.log?.(`mcp:${this.config.name}: ${err?.message ?? err}`)), "initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO });
562
+ await post({ jsonrpc: "2.0", method: "notifications/initialized" });
563
+ this.sendFrame = (frame) => void post(frame).catch((err) => this.hooks.log?.(`mcp:${this.config.name}: ${err?.message ?? err}`));
564
+ if (this.stopped) throw new Error("stopped during handshake");
565
+ this.markConnected();
566
+ await this.hooks.onConnected?.(this);
567
+ if (this.stopped) throw new Error("stopped during handshake");
568
+ const headers = withSession({ ...base, Accept: "text/event-stream", ...(this.lastEventId ? { "Last-Event-ID": this.lastEventId } : {}) });
569
+ // sse_idle_timeout bounds the wait for the response headers and then for
570
+ // each chunk (readSse). It is never a deadline on the whole stream, which stays open as
571
+ // long as the server keeps talking.
572
+ const connect = new AbortController();
573
+ const connectTimer = setTimeout(() => connect.abort(new Error(`MCP HTTP SSE connect timed out after ${Math.round(this.config.sseIdleTimeoutMs / 1000)}s`)), this.config.sseIdleTimeoutMs);
574
+ connectTimer.unref();
575
+ let res: Response;
576
+ try {
577
+ try {
578
+ res = await fetch(endpoint, { method: "GET", headers, signal: AbortSignal.any([abort.signal, connect.signal]) });
579
+ } finally {
580
+ clearTimeout(connectTimer);
581
+ }
582
+ // The server→client GET stream is optional in the spec: 405/404 means "this server has
583
+ // no push channel", not "this server is unusable". POST keeps working regardless, so a
584
+ // tool call must never depend on the stream having been opened.
585
+ if (res.status === 404 && this.httpSessionId) {
586
+ this.sessionLost = true;
587
+ throw new Error("MCP HTTP SSE status 404 (session expired); reconnecting with a new session");
588
+ }
589
+ if (res.status === 405 || res.status === 404) {
590
+ this.status.lastError = `no server-push stream (HTTP ${res.status} on GET); tools work, notifications do not`;
591
+ this.hooks.log?.(`mcp:${this.config.name}: ${this.status.lastError}`);
592
+ // Park until stopped — or until a POST tells us the server dropped our session, which
593
+ // only a reconnect can repair. Without this the source would look connected forever
594
+ // while every tool call failed.
595
+ await new Promise<void>((r) => {
596
+ if (abort.signal.aborted) return r();
597
+ abort.signal.addEventListener("abort", () => r(), { once: true });
598
+ this.wakeParked = r;
599
+ });
600
+ this.wakeParked = undefined;
601
+ if (this.sessionLost) throw new Error("server rejected the MCP session; reconnecting");
602
+ return;
603
+ }
604
+ if (!res.ok) throw new Error(`MCP HTTP SSE status ${res.status}`);
605
+ await this.readSse(res, this.config.sseIdleTimeoutMs, (data) => this.handleFrame(data, (frame) => void post(frame).catch(() => {})), true);
606
+ } finally {
607
+ this.sendFrame = undefined;
608
+ this.failPending("transport closed");
609
+ }
610
+ }
611
+
612
+ private failPending(reason: string): void {
613
+ for (const p of [...this.pending.values()]) p.reject(new Error(reason));
614
+ this.pending.clear();
615
+ }
616
+
617
+ private async cappedText(res: Response, cap: number): Promise<string> {
618
+ if (!res.body) return "";
619
+ const reader = res.body.getReader();
620
+ const chunks: Uint8Array[] = [];
621
+ let size = 0;
622
+ for (;;) {
623
+ const { value, done } = await reader.read();
624
+ if (done) break;
625
+ size += value.byteLength;
626
+ if (size > cap) {
627
+ await reader.cancel().catch(() => {});
628
+ throw new Error("MCP HTTP response body exceeded cap");
629
+ }
630
+ chunks.push(value);
631
+ }
632
+ return Buffer.concat(chunks).toString("utf8");
633
+ }
634
+
635
+ /**
636
+ * Parse an SSE body; `idleTimeoutMs` bounds the wait for the next chunk.
637
+ *
638
+ * `isEventStream` marks the server→client GET stream, the only one whose `id:` fields belong to
639
+ * the resume cursor. A POST response that happens to be an event stream has its own id space,
640
+ * and recording those would make a reconnect ask the GET stream to resume from an id it never
641
+ * issued (replaying or skipping notifications).
642
+ */
643
+ private async readSse(res: Response, idleTimeoutMs: number | undefined, onData: (data: string) => void, isEventStream = false): Promise<void> {
644
+ if (!res.body) return;
645
+ const reader = res.body.getReader();
646
+ const decoder = new TextDecoder();
647
+ const cap = this.config.bodyCapBytes;
648
+ let buffer = "";
649
+ for (;;) {
650
+ let timer: NodeJS.Timeout | undefined;
651
+ const next = idleTimeoutMs
652
+ ? Promise.race([
653
+ reader.read(),
654
+ new Promise<never>((_, reject) => {
655
+ timer = setTimeout(() => {
656
+ reader.cancel().catch(() => {});
657
+ reject(new Error(`MCP HTTP SSE idle for ${Math.round(idleTimeoutMs / 1000)}s`));
658
+ }, idleTimeoutMs);
659
+ timer.unref();
660
+ }),
661
+ ])
662
+ : reader.read();
663
+ let result: ReadableStreamReadResult<Uint8Array>;
664
+ try {
665
+ result = await next;
666
+ } finally {
667
+ if (timer) clearTimeout(timer);
668
+ }
669
+ if (result.done) return;
670
+ buffer += decoder.decode(result.value, { stream: true });
671
+ if (Buffer.byteLength(buffer) > cap) throw new Error("MCP HTTP SSE frame exceeded cap");
672
+ let idx: number;
673
+ while ((idx = buffer.indexOf("\n\n")) >= 0) {
674
+ const raw = buffer.slice(0, idx);
675
+ buffer = buffer.slice(idx + 2);
676
+ let id: string | undefined;
677
+ const data: string[] = [];
678
+ for (const line of raw.split("\n")) {
679
+ if (!line || line.startsWith(":")) continue;
680
+ if (line.startsWith("id:")) id = line.slice(3).trimStart();
681
+ else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
682
+ }
683
+ if (id !== undefined && isEventStream) this.lastEventId = id;
684
+ if (data.length) onData(data.join("\n"));
685
+ }
686
+ }
687
+ }
688
+
689
+ private async initialize(send: (msg: unknown) => void): Promise<void> {
690
+ const result = await this.request(send, "initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO });
691
+ if (!result || typeof result !== "object") throw new Error("initialize returned no result");
692
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
693
+ this.markConnected();
694
+ await this.hooks.onConnected?.(this);
695
+ }
696
+
697
+ /**
698
+ * A handshake is not yet a working server: one that answers `initialize` and then exits (a
699
+ * missing key, a bad argument) would reset the counter every time and be respawned forever.
700
+ * The attempt counter is only cleared once the connection has lasted `STABLE_MS`.
701
+ */
702
+ private markConnected(): void {
703
+ this.setState("connected");
704
+ this.cancelStableTimer();
705
+ this.stableTimer = setTimeout(() => {
706
+ this.attempts = 0;
707
+ this.stableTimer = undefined;
708
+ }, McpSource.STABLE_MS);
709
+ this.stableTimer.unref?.();
710
+ }
711
+
712
+ /** The refund is owed to a connection that lasted; a dropped one takes it back with it. */
713
+ private cancelStableTimer(): void {
714
+ if (this.stableTimer) clearTimeout(this.stableTimer);
715
+ this.stableTimer = undefined;
716
+ }
717
+
718
+ private request(send: (msg: unknown) => void, method: string, params: unknown, signal?: AbortSignal): Promise<any> {
719
+ const id = this.nextId++;
720
+ return new Promise((resolve, reject) => {
721
+ const settle = (fn: () => void) => {
722
+ const p = this.pending.get(id);
723
+ if (!p) return;
724
+ this.pending.delete(id);
725
+ clearTimeout(p.timer);
726
+ signal?.removeEventListener("abort", onAbort);
727
+ fn();
728
+ };
729
+ const onAbort = () => {
730
+ // Cancelling: drop the in-flight entry, tell the server best-effort, return Cancelled.
731
+ settle(() => reject(new Error("cancelled")));
732
+ try {
733
+ send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, reason: "client cancelled" } });
734
+ } catch {
735
+ /* transport gone */
736
+ }
737
+ };
738
+ const timer = setTimeout(() => settle(() => reject(new Error(`${method} timed out after ${Math.round(this.config.requestTimeoutMs / 1000)}s`))), this.config.requestTimeoutMs);
739
+ this.pending.set(id, { resolve: (v) => settle(() => resolve(v)), reject: (e) => settle(() => reject(e)), timer });
740
+ if (signal?.aborted) {
741
+ onAbort();
742
+ return;
743
+ }
744
+ signal?.addEventListener("abort", onAbort, { once: true });
745
+ send({ jsonrpc: "2.0", id, method, params });
746
+ });
747
+ }
748
+
749
+ /** One JSON-RPC frame from the server. Responses settle requests; notifications flow out; requests get a polite error. */
750
+ handleFrame(line: string, send: (frame: unknown) => void): void {
751
+ const text = line.trim();
752
+ if (!text) return;
753
+ let msg: any;
754
+ try {
755
+ msg = JSON.parse(text);
756
+ } catch {
757
+ return;
758
+ }
759
+ const frames = Array.isArray(msg) ? msg : [msg];
760
+ for (const f of frames) {
761
+ if (!f || typeof f !== "object") continue;
762
+ if (f.id !== undefined && f.method === undefined) {
763
+ const p = this.pending.get(f.id);
764
+ if (!p) continue;
765
+ // resolve/reject settle the entry themselves (delete + clear timer + detach abort listener).
766
+ if (f.error) p.reject(new Error(f.error.message ?? JSON.stringify(f.error)));
767
+ else p.resolve(f.result);
768
+ } else if (f.method !== undefined && f.id !== undefined) {
769
+ if (f.method === "ping") send({ jsonrpc: "2.0", id: f.id, result: {} });
770
+ else send({ jsonrpc: "2.0", id: f.id, error: { code: -32601, message: `pi-loops is a notification-only client; ${f.method} is not supported` } });
771
+ } else if (typeof f.method === "string") {
772
+ this.status.lastEventAt = stamp();
773
+ this.status.queuedCount++;
774
+ this.status.lastError = undefined; // A successful push clears the last error
775
+ this.hooks.onNotification({ method: f.method, params: f.params ?? {} });
776
+ }
777
+ }
778
+ }
779
+ }
780
+
781
+ /* -------------------------------------------------- config files, tool definitions */
782
+
783
+ /** A project's own `mcp.toml`, if it has one. */
784
+ function findProjectMcpConfig(cwd: string): string | undefined {
785
+ const file = path.join(cwd, ".pi", "mcp.toml");
786
+ return fs.existsSync(file) ? file : undefined;
787
+ }
788
+
789
+ /** Just `<cwd>`'s own file, for lending a project's servers to a run that happens in it. */
790
+ export function loadProjectMcpConfig(cwd: string): { servers: McpServerConfig[]; diagnostics: string[] } {
791
+ const diagnostics: string[] = [];
792
+ const file = findProjectMcpConfig(cwd);
793
+ if (!file) return { servers: [], diagnostics };
794
+ try {
795
+ const parsed = parseMcpConfig(parseToml(fs.readFileSync(file, "utf8")), "project");
796
+ return { servers: parsed.servers, diagnostics: parsed.diagnostics };
797
+ } catch (err: any) {
798
+ return { servers: [], diagnostics: [`mcp config (project, ${file}): parse failed: ${err?.message ?? err}`] };
799
+ }
800
+ }
801
+
802
+ /**
803
+ * The user file, then the project file when the project is trusted. Diagnostics are collected,
804
+ * never thrown: one unreadable file must not cost the session every other server.
805
+ */
806
+ export function loadMcpConfigFiles(opts: { dir: string; cwd?: string; projectTrusted: boolean }): { servers: McpServerConfig[]; diagnostics: string[] } {
807
+ const diagnostics: string[] = [];
808
+ const read = (file: string, source: "user" | "project"): McpServerConfig[] => {
809
+ let text: string;
810
+ try {
811
+ text = fs.readFileSync(file, "utf8");
812
+ } catch (err: any) {
813
+ if (err?.code !== "ENOENT") diagnostics.push(`mcp config (${source}, ${file}): read failed: ${err?.message ?? err}`);
814
+ return [];
815
+ }
816
+ try {
817
+ const parsed = parseMcpConfig(parseToml(text), source);
818
+ diagnostics.push(...parsed.diagnostics);
819
+ return parsed.servers;
820
+ } catch (err: any) {
821
+ diagnostics.push(`mcp config (${source}, ${file}): parse failed: ${err?.message ?? err}`);
822
+ return [];
823
+ }
824
+ };
825
+ const user = read(path.join(opts.dir, "mcp.toml"), "user");
826
+ let project: McpServerConfig[] = [];
827
+ if (opts.cwd) {
828
+ const projectFile = findProjectMcpConfig(opts.cwd);
829
+ if (projectFile) {
830
+ if (opts.projectTrusted) project = read(projectFile, "project");
831
+ else diagnostics.push(`project MCP config ignored at ${projectFile}: project is not trusted (pi --approve, or trust it when prompted)`);
832
+ }
833
+ }
834
+ return { servers: mergeMcpConfigs(user, project), diagnostics };
835
+ }
836
+
837
+ /** One server tool as a pi tool definition (schema passed through, content mapped, cancel forwarded). */
838
+ export function mcpToolDefinition(source: McpSource, tool: McpToolDef, name: string): ToolDefinition<any, any> {
839
+ return {
840
+ name,
841
+ label: `${source.config.name}: ${tool.name}`,
842
+ description: tool.description ?? `${tool.name} (MCP server ${source.config.name})`,
843
+ parameters: Type.Unsafe<Record<string, unknown>>(tool.inputSchema),
844
+ async execute(_id, params, signal) {
845
+ let result: Awaited<ReturnType<McpSource["callTool"]>>;
846
+ try {
847
+ result = await source.callTool(tool.name, params as Record<string, unknown>, signal);
848
+ } catch (err: any) {
849
+ const msg = err?.message ?? String(err);
850
+ return { content: [{ type: "text", text: msg === "cancelled" ? "cancelled" : `mcp call: ${msg}` }], isError: true, details: { name: tool.name, server: source.config.name, isError: true } };
851
+ }
852
+ const content = result.content.map((b) => (b.type === "text" ? { type: "text" as const, text: b.text } : b.type === "image" ? { type: "image" as const, data: b.data, mimeType: b.mimeType } : { type: "text" as const, text: `<resource>${JSON.stringify(b.resource)}</resource>` }));
853
+ if (result.isError) {
854
+ return { content: [{ type: "text", text: content.filter((c) => c.type === "text").map((c) => (c as { text: string }).text).join("\n") || "tool reported an error" }], isError: true, details: { name: tool.name, server: source.config.name, isError: true } };
855
+ }
856
+ return { content: content.length ? content : [{ type: "text", text: "(no content)" }], details: { name: tool.name, server: source.config.name, isError: false } };
857
+ },
858
+ };
859
+ }
860
+
861
+ /**
862
+ * pi's built-in tool names. A custom tool registered under one of these replaces it in pi's
863
+ * registry (custom tools are applied after built-ins), so every `taken` set must start from here —
864
+ * including where the parent excluded a built-in with `-xt`, which keeps the name reserved.
865
+ * Mirrors `allToolNames` in pi's `core/tools`, which the package does not re-export; the test
866
+ * `mcp.test.ts` pins the list against the installed pi.
867
+ */
868
+ export const PI_BUILTIN_TOOL_NAMES: readonly string[] = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"];
869
+
870
+ /**
871
+ * Register a server's tools under collision-free names (prefixed with the server name on a
872
+ * clash). `taken` holds every name already known; returns the new definitions and names.
873
+ */
874
+ export function mcpToolDefinitions(source: McpSource, tools: McpToolDef[], taken: Set<string>, already: string[]): Array<{ name: string; def: ToolDefinition<any, any> }> {
875
+ const out: Array<{ name: string; def: ToolDefinition<any, any> }> = [];
876
+ for (const tool of tools) {
877
+ if (already.some((n) => n === tool.name || n === `${source.config.name}_${tool.name}`)) continue;
878
+ const name = taken.has(tool.name) ? `${source.config.name}_${tool.name}` : tool.name;
879
+ if (taken.has(name)) continue;
880
+ taken.add(name);
881
+ already.push(name);
882
+ out.push({ name, def: mcpToolDefinition(source, tool, name) });
883
+ }
884
+ return out;
885
+ }