@phi-code-admin/phi-code 0.86.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/docs/fork-policy.md +18 -0
  3. package/extensions/phi/agents.ts +7 -4
  4. package/extensions/phi/benchmark.ts +129 -49
  5. package/extensions/phi/browser.ts +10 -37
  6. package/extensions/phi/btw/btw.ts +1 -7
  7. package/extensions/phi/chrome/index.ts +1283 -741
  8. package/extensions/phi/commit.ts +9 -14
  9. package/extensions/phi/goal/index.ts +10 -33
  10. package/extensions/phi/init.ts +37 -47
  11. package/extensions/phi/keys.ts +2 -7
  12. package/extensions/phi/mcp/callback-server.ts +162 -165
  13. package/extensions/phi/mcp/config.ts +122 -136
  14. package/extensions/phi/mcp/errors.ts +18 -23
  15. package/extensions/phi/mcp/index.ts +322 -355
  16. package/extensions/phi/mcp/oauth-provider.ts +289 -289
  17. package/extensions/phi/mcp/server-manager.ts +390 -413
  18. package/extensions/phi/mcp/tool-bridge.ts +381 -415
  19. package/extensions/phi/memory.ts +2 -2
  20. package/extensions/phi/models.ts +27 -26
  21. package/extensions/phi/orchestrator.ts +338 -229
  22. package/extensions/phi/productivity.ts +4 -2
  23. package/extensions/phi/providers/agent-def.ts +1 -1
  24. package/extensions/phi/providers/alibaba.ts +56 -7
  25. package/extensions/phi/providers/context-window.ts +4 -1
  26. package/extensions/phi/providers/live-models.ts +5 -20
  27. package/extensions/phi/providers/orchestrator-helpers.ts +19 -5
  28. package/extensions/phi/providers/phase-machine.ts +220 -0
  29. package/extensions/phi/setup.ts +196 -169
  30. package/extensions/phi/skill-loader.ts +14 -17
  31. package/extensions/phi/smart-router.ts +6 -6
  32. package/extensions/phi/web-search.ts +90 -50
  33. package/package.json +1 -1
@@ -12,20 +12,19 @@
12
12
  * - Image/audio/resource content → text description passthrough
13
13
  */
14
14
 
15
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
16
  import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
17
- import * as Type from "typebox";
18
- import type { TSchema } from "typebox";
19
17
  import type { ExtensionAPI } from "phi-code";
20
- import { McpError } from "./errors.js";
18
+ import type { TSchema } from "typebox";
19
+ import * as Type from "typebox";
21
20
  import type { Settings } from "./config.js";
21
+ import { McpError } from "./errors.js";
22
22
 
23
23
  // ─── Types ────────────────────────────────────────────────────────────────────
24
24
 
25
25
  /** Subset of Pi's ExtensionAPI used by the bridge. */
26
26
  export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools" | "setActiveTools">;
27
27
 
28
-
29
28
  // ─── Schema Conversion ────────────────────────────────────────────────────────
30
29
 
31
30
  /**
@@ -40,162 +39,148 @@ export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools
40
39
  * - allOf → TypeBox Intersect
41
40
  * Falls back to Type.Any() for unresolvable $ref or missing type.
42
41
  */
43
- export function convertJsonSchemaToTypebox(
44
- schema: unknown,
45
- depth = 0,
46
- defs?: Record<string, unknown>,
47
- ): TSchema {
48
- // Guard against infinite recursion and malformed schemas
49
- if (!schema || typeof schema !== "object" || Array.isArray(schema) || depth > 10) {
50
- return Type.Any();
51
- }
52
-
53
- const s = schema as Record<string, unknown>;
54
- const description = typeof s["description"] === "string" ? s["description"] : undefined;
55
- const opts = description ? { description } : {};
56
-
57
- // Extract $defs / definitions for $ref resolution (carried through recursive calls)
58
- const resolvedDefs: Record<string, unknown> = {
59
- ...((s["$defs"] ?? s["definitions"]) as Record<string, unknown> | undefined),
60
- ...defs,
61
- };
62
-
63
- // ── Handle $ref ──────────────────────────────────────────────────────────
64
- if (typeof s["$ref"] === "string") {
65
- const ref = s["$ref"] as string;
66
- let resolved: unknown;
67
-
68
- // Local references: #/$defs/Foo, #/definitions/Foo
69
- if (ref.startsWith("#/")) {
70
- const parts = ref.slice(2).split("/");
71
- if (parts[0] === "$defs" || parts[0] === "definitions") {
72
- const key = parts.slice(1).join("/");
73
- resolved = resolvedDefs[key];
74
- } else {
75
- // Fallback: try walking the defs map by the last part
76
- const key = parts[parts.length - 1]!;
77
- resolved = resolvedDefs[key];
78
- }
79
- } else {
80
- // External $ref — cannot resolve, fall back
81
- console.warn(
82
- `[pi-mcp] Cannot resolve external $ref "${ref}", using Type.Any()`,
83
- );
84
- return Type.Any(opts);
85
- }
86
-
87
- if (!resolved) {
88
- console.warn(
89
- `[pi-mcp] Could not resolve $ref "${ref}", using Type.Any()`,
90
- );
91
- return Type.Any(opts);
92
- }
93
-
94
- // Merge description from referencing schema into resolved schema
95
- const merged = { ...(resolved as Record<string, unknown>) };
96
- if (description && !merged["description"]) {
97
- merged["description"] = description;
98
- }
99
- return convertJsonSchemaToTypebox(merged, depth + 1, resolvedDefs);
100
- }
101
-
102
- // ── Handle oneOf / anyOf TypeBox Union ─────────────────────────────────
103
- if (Array.isArray(s["oneOf"])) {
104
- const members = (s["oneOf"] as unknown[])
105
- .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
106
- return members.length === 1 ? members[0]! : Type.Union(members, opts);
107
- }
108
-
109
- if (Array.isArray(s["anyOf"])) {
110
- const members = (s["anyOf"] as unknown[])
111
- .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
112
- return members.length === 1 ? members[0]! : Type.Union(members, opts);
113
- }
114
-
115
- // ── Handle allOf → TypeBox Intersect ─────────────────────────────────────
116
- if (Array.isArray(s["allOf"])) {
117
- const members = (s["allOf"] as unknown[])
118
- .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
119
- return members.length === 1 ? members[0]! : Type.Intersect(members, opts);
120
- }
121
-
122
- // Handle nullable types: { "type": ["string", "null"] }
123
- const rawType = s["type"];
124
- const type = Array.isArray(rawType)
125
- ? rawType.find((t) => t !== "null") as string | undefined
126
- : typeof rawType === "string" ? rawType : undefined;
127
-
128
- const isNullable = Array.isArray(rawType) && rawType.includes("null");
129
-
130
- let base: TSchema;
131
-
132
- switch (type) {
133
- case "string": {
134
- const enumVals = s["enum"];
135
- if (Array.isArray(enumVals) && enumVals.every((v) => typeof v === "string")) {
136
- // TypeBox doesn't have a built-in StringEnum — use Union of Literals
137
- base = Type.Union(
138
- (enumVals as string[]).map((v) => Type.Literal(v)),
139
- opts,
140
- );
141
- } else {
142
- base = Type.String(opts);
143
- }
144
- break;
145
- }
146
- case "number":
147
- case "integer":
148
- base = Type.Number(opts);
149
- break;
150
- case "boolean":
151
- base = Type.Boolean(opts);
152
- break;
153
- case "null":
154
- base = Type.Null(opts);
155
- break;
156
- case "array": {
157
- const items = s["items"];
158
- base = Type.Array(
159
- items ? convertJsonSchemaToTypebox(items, depth + 1, resolvedDefs) : Type.Unknown(),
160
- opts,
161
- );
162
- break;
163
- }
164
- case "object": {
165
- const properties = s["properties"] as Record<string, unknown> | undefined;
166
- const required = new Set<string>(
167
- Array.isArray(s["required"]) ? (s["required"] as string[]) : [],
168
- );
169
- const additionalProperties = s["additionalProperties"];
170
-
171
- if (!properties) {
172
- // Open object — passthrough as Any to avoid over-constraining
173
- base = Type.Record(Type.String(), Type.Unknown(), opts);
174
- break;
175
- }
176
-
177
- const props: Record<string, TSchema> = {};
178
- for (const [key, value] of Object.entries(properties)) {
179
- const converted = convertJsonSchemaToTypebox(value, depth + 1, resolvedDefs);
180
- props[key] = required.has(key) ? converted : Type.Optional(converted);
181
- }
182
-
183
- const objOpts: Record<string, unknown> = { ...opts };
184
- if (additionalProperties === false) {
185
- objOpts["additionalProperties"] = false;
186
- }
187
-
188
- base = Type.Object(props, objOpts as any);
189
- break;
190
- }
191
- default: {
192
- // Truly unsupported or missing type field
193
- base = Type.Any(opts);
194
- break;
195
- }
196
- }
197
-
198
- return isNullable ? Type.Union([base, Type.Null()]) : base;
42
+ export function convertJsonSchemaToTypebox(schema: unknown, depth = 0, defs?: Record<string, unknown>): TSchema {
43
+ // Guard against infinite recursion and malformed schemas
44
+ if (!schema || typeof schema !== "object" || Array.isArray(schema) || depth > 10) {
45
+ return Type.Any();
46
+ }
47
+
48
+ const s = schema as Record<string, unknown>;
49
+ const description = typeof s.description === "string" ? s.description : undefined;
50
+ const opts = description ? { description } : {};
51
+
52
+ // Extract $defs / definitions for $ref resolution (carried through recursive calls)
53
+ const resolvedDefs: Record<string, unknown> = {
54
+ ...((s.$defs ?? s.definitions) as Record<string, unknown> | undefined),
55
+ ...defs,
56
+ };
57
+
58
+ // ── Handle $ref ──────────────────────────────────────────────────────────
59
+ if (typeof s.$ref === "string") {
60
+ const ref = s.$ref as string;
61
+ let resolved: unknown;
62
+
63
+ // Local references: #/$defs/Foo, #/definitions/Foo
64
+ if (ref.startsWith("#/")) {
65
+ const parts = ref.slice(2).split("/");
66
+ if (parts[0] === "$defs" || parts[0] === "definitions") {
67
+ const key = parts.slice(1).join("/");
68
+ resolved = resolvedDefs[key];
69
+ } else {
70
+ // Fallback: try walking the defs map by the last part
71
+ const key = parts[parts.length - 1]!;
72
+ resolved = resolvedDefs[key];
73
+ }
74
+ } else {
75
+ // External $ref cannot resolve, fall back
76
+ console.warn(`[pi-mcp] Cannot resolve external $ref "${ref}", using Type.Any()`);
77
+ return Type.Any(opts);
78
+ }
79
+
80
+ if (!resolved) {
81
+ console.warn(`[pi-mcp] Could not resolve $ref "${ref}", using Type.Any()`);
82
+ return Type.Any(opts);
83
+ }
84
+
85
+ // Merge description from referencing schema into resolved schema
86
+ const merged = { ...(resolved as Record<string, unknown>) };
87
+ if (description && !merged.description) {
88
+ merged.description = description;
89
+ }
90
+ return convertJsonSchemaToTypebox(merged, depth + 1, resolvedDefs);
91
+ }
92
+
93
+ // ── Handle oneOf / anyOf TypeBox Union ─────────────────────────────────
94
+ if (Array.isArray(s.oneOf)) {
95
+ const members = (s.oneOf as unknown[]).map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
96
+ return members.length === 1 ? members[0]! : Type.Union(members, opts);
97
+ }
98
+
99
+ if (Array.isArray(s.anyOf)) {
100
+ const members = (s.anyOf as unknown[]).map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
101
+ return members.length === 1 ? members[0]! : Type.Union(members, opts);
102
+ }
103
+
104
+ // ── Handle allOf TypeBox Intersect ─────────────────────────────────────
105
+ if (Array.isArray(s.allOf)) {
106
+ const members = (s.allOf as unknown[]).map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
107
+ return members.length === 1 ? members[0]! : Type.Intersect(members, opts);
108
+ }
109
+
110
+ // Handle nullable types: { "type": ["string", "null"] }
111
+ const rawType = s.type;
112
+ const type = Array.isArray(rawType)
113
+ ? (rawType.find((t) => t !== "null") as string | undefined)
114
+ : typeof rawType === "string"
115
+ ? rawType
116
+ : undefined;
117
+
118
+ const isNullable = Array.isArray(rawType) && rawType.includes("null");
119
+
120
+ let base: TSchema;
121
+
122
+ switch (type) {
123
+ case "string": {
124
+ const enumVals = s.enum;
125
+ if (Array.isArray(enumVals) && enumVals.every((v) => typeof v === "string")) {
126
+ // TypeBox doesn't have a built-in StringEnum — use Union of Literals
127
+ base = Type.Union(
128
+ (enumVals as string[]).map((v) => Type.Literal(v)),
129
+ opts,
130
+ );
131
+ } else {
132
+ base = Type.String(opts);
133
+ }
134
+ break;
135
+ }
136
+ case "number":
137
+ case "integer":
138
+ base = Type.Number(opts);
139
+ break;
140
+ case "boolean":
141
+ base = Type.Boolean(opts);
142
+ break;
143
+ case "null":
144
+ base = Type.Null(opts);
145
+ break;
146
+ case "array": {
147
+ const items = s.items;
148
+ base = Type.Array(items ? convertJsonSchemaToTypebox(items, depth + 1, resolvedDefs) : Type.Unknown(), opts);
149
+ break;
150
+ }
151
+ case "object": {
152
+ const properties = s.properties as Record<string, unknown> | undefined;
153
+ const required = new Set<string>(Array.isArray(s.required) ? (s.required as string[]) : []);
154
+ const additionalProperties = s.additionalProperties;
155
+
156
+ if (!properties) {
157
+ // Open object — passthrough as Any to avoid over-constraining
158
+ base = Type.Record(Type.String(), Type.Unknown(), opts);
159
+ break;
160
+ }
161
+
162
+ const props: Record<string, TSchema> = {};
163
+ for (const [key, value] of Object.entries(properties)) {
164
+ const converted = convertJsonSchemaToTypebox(value, depth + 1, resolvedDefs);
165
+ props[key] = required.has(key) ? converted : Type.Optional(converted);
166
+ }
167
+
168
+ const objOpts: Record<string, unknown> = { ...opts };
169
+ if (additionalProperties === false) {
170
+ objOpts.additionalProperties = false;
171
+ }
172
+
173
+ base = Type.Object(props, objOpts as any);
174
+ break;
175
+ }
176
+ default: {
177
+ // Truly unsupported or missing type field
178
+ base = Type.Any(opts);
179
+ break;
180
+ }
181
+ }
182
+
183
+ return isNullable ? Type.Union([base, Type.Null()]) : base;
199
184
  }
200
185
 
201
186
  // ─── Tool Name Sanitization ───────────────────────────────────────────────────
@@ -209,16 +194,14 @@ const MAX_TOOL_NAME_LEN = 64;
209
194
  * If truncation is needed, the last 8 chars are replaced with a hash to avoid collisions.
210
195
  */
211
196
  export function buildToolName(prefix: string, serverName: string, toolName: string): string {
212
- const raw = `${prefix}_${serverName}_${toolName}`;
213
- const safe = raw.replace(/[^a-zA-Z0-9_]/g, "_");
214
- if (safe.length <= MAX_TOOL_NAME_LEN) return safe;
215
- // Truncate with hash suffix to prevent collisions on long names
216
- const hash = Math.abs(
217
- safe.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0),
218
- )
219
- .toString(36)
220
- .slice(0, 8);
221
- return safe.slice(0, MAX_TOOL_NAME_LEN - 9) + "_" + hash;
197
+ const raw = `${prefix}_${serverName}_${toolName}`;
198
+ const safe = raw.replace(/[^a-zA-Z0-9_]/g, "_");
199
+ if (safe.length <= MAX_TOOL_NAME_LEN) return safe;
200
+ // Truncate with hash suffix to prevent collisions on long names
201
+ const hash = Math.abs(safe.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0))
202
+ .toString(36)
203
+ .slice(0, 8);
204
+ return `${safe.slice(0, MAX_TOOL_NAME_LEN - 9)}_${hash}`;
222
205
  }
223
206
 
224
207
  // ─── Content Conversion ───────────────────────────────────────────────────────
@@ -226,48 +209,48 @@ export function buildToolName(prefix: string, serverName: string, toolName: stri
226
209
  type PiTextContent = { type: "text"; text: string };
227
210
 
228
211
  function convertMcpContent(items: unknown[]): PiTextContent[] {
229
- return items.map((item: any) => {
230
- if (!item || typeof item !== "object") {
231
- return { type: "text", text: String(item) };
232
- }
233
- switch (item.type) {
234
- case "text":
235
- return { type: "text", text: String(item.text ?? "") };
236
- case "image":
237
- return {
238
- type: "text",
239
- text: `[Image: ${item.mimeType ?? "unknown"}, base64 encoded]`,
240
- };
241
- case "audio":
242
- return {
243
- type: "text",
244
- text: `[Audio: ${item.mimeType ?? "unknown"}, base64 encoded]`,
245
- };
246
- case "resource": {
247
- const r = item.resource;
248
- if (r?.text) return { type: "text", text: r.text };
249
- if (r?.blob) return { type: "text", text: `[Resource blob: ${r.uri}]` };
250
- return { type: "text", text: `[Resource: ${r?.uri ?? "unknown"}]` };
251
- }
252
- default:
253
- return { type: "text", text: JSON.stringify(item) };
254
- }
255
- });
212
+ return items.map((item: any) => {
213
+ if (!item || typeof item !== "object") {
214
+ return { type: "text", text: String(item) };
215
+ }
216
+ switch (item.type) {
217
+ case "text":
218
+ return { type: "text", text: String(item.text ?? "") };
219
+ case "image":
220
+ return {
221
+ type: "text",
222
+ text: `[Image: ${item.mimeType ?? "unknown"}, base64 encoded]`,
223
+ };
224
+ case "audio":
225
+ return {
226
+ type: "text",
227
+ text: `[Audio: ${item.mimeType ?? "unknown"}, base64 encoded]`,
228
+ };
229
+ case "resource": {
230
+ const r = item.resource;
231
+ if (r?.text) return { type: "text", text: r.text };
232
+ if (r?.blob) return { type: "text", text: `[Resource blob: ${r.uri}]` };
233
+ return { type: "text", text: `[Resource: ${r?.uri ?? "unknown"}]` };
234
+ }
235
+ default:
236
+ return { type: "text", text: JSON.stringify(item) };
237
+ }
238
+ });
256
239
  }
257
240
 
258
241
  // ─── Tool Listing ─────────────────────────────────────────────────────────────
259
242
 
260
243
  export interface McpToolDefinition {
261
- name: string;
262
- description?: string;
263
- inputSchema: Record<string, unknown>;
264
- annotations?: {
265
- readOnlyHint?: boolean;
266
- destructiveHint?: boolean;
267
- idempotentHint?: boolean;
268
- openWorldHint?: boolean;
269
- title?: string;
270
- };
244
+ name: string;
245
+ description?: string;
246
+ inputSchema: Record<string, unknown>;
247
+ annotations?: {
248
+ readOnlyHint?: boolean;
249
+ destructiveHint?: boolean;
250
+ idempotentHint?: boolean;
251
+ openWorldHint?: boolean;
252
+ title?: string;
253
+ };
271
254
  }
272
255
 
273
256
  /**
@@ -275,33 +258,30 @@ export interface McpToolDefinition {
275
258
  * The MCP spec mandates clients follow nextCursor until exhausted.
276
259
  * Includes a max-page guard to prevent infinite loops from broken servers.
277
260
  */
278
- export async function listAllTools(
279
- client: Client,
280
- requestTimeoutMs: number,
281
- ): Promise<McpToolDefinition[]> {
282
- const tools: McpToolDefinition[] = [];
283
- let cursor: string | undefined;
284
- const MAX_PAGES = 100;
285
- let pageCount = 0;
286
-
287
- do {
288
- if (pageCount >= MAX_PAGES) {
289
- console.warn(
290
- `[pi-mcp] tools/list pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`,
291
- );
292
- break;
293
- }
294
- const result = await client.request(
295
- { method: "tools/list", params: cursor ? { cursor } : {} },
296
- ListToolsResultSchema,
297
- { timeout: requestTimeoutMs },
298
- );
299
- tools.push(...(result.tools as McpToolDefinition[]));
300
- cursor = result.nextCursor;
301
- pageCount++;
302
- } while (cursor);
303
-
304
- return tools;
261
+ export async function listAllTools(client: Client, requestTimeoutMs: number): Promise<McpToolDefinition[]> {
262
+ const tools: McpToolDefinition[] = [];
263
+ let cursor: string | undefined;
264
+ const MAX_PAGES = 100;
265
+ let pageCount = 0;
266
+
267
+ do {
268
+ if (pageCount >= MAX_PAGES) {
269
+ console.warn(
270
+ `[pi-mcp] tools/list pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`,
271
+ );
272
+ break;
273
+ }
274
+ const result = await client.request(
275
+ { method: "tools/list", params: cursor ? { cursor } : {} },
276
+ ListToolsResultSchema,
277
+ { timeout: requestTimeoutMs },
278
+ );
279
+ tools.push(...(result.tools as McpToolDefinition[]));
280
+ cursor = result.nextCursor;
281
+ pageCount++;
282
+ } while (cursor);
283
+
284
+ return tools;
305
285
  }
306
286
 
307
287
  // ─── Tool Bridge ──────────────────────────────────────────────────────────────
@@ -311,184 +291,170 @@ export async function listAllTools(
311
291
  * Tools are registered once and activated/deactivated as servers connect/disconnect.
312
292
  */
313
293
  export class ToolBridge {
314
- private readonly settings: Settings;
315
- private readonly pi: PiExtensionAPI;
316
- /** Tracks which Pi tool names belong to which MCP server. */
317
- private readonly serverToolNames = new Map<string, Set<string>>();
318
-
319
- constructor(settings: Settings, pi: PiExtensionAPI) {
320
- this.settings = settings;
321
- this.pi = pi;
322
- }
323
-
324
- /**
325
- * Refresh tools for a server — called on initial connect and on list_changed.
326
- * Always re-registers tools with the current client reference so that
327
- * tool execute closures capture the latest client after reconnection.
328
- * Deactivates tools that are no longer in the server's list.
329
- * Note: Pi's registerTool() overwrites by name (Map.set), so re-registration is safe.
330
- */
331
- async refreshTools(serverName: string, client: Client): Promise<void> {
332
- const timeoutMs = this.settings.requestTimeoutMs;
333
-
334
- let tools: McpToolDefinition[];
335
- try {
336
- tools = await listAllTools(client, timeoutMs);
337
- } catch (err) {
338
- throw new McpError(
339
- `Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
340
- serverName,
341
- "protocol",
342
- err,
343
- );
344
- }
345
-
346
- const registeredForServer = this.serverToolNames.get(serverName) ?? new Set<string>();
347
-
348
- // Build the set of currently valid Pi tool names for this server
349
- const currentToolNames = new Set<string>();
350
-
351
- for (const tool of tools) {
352
- const piName = buildToolName(this.settings.toolPrefix, serverName, tool.name);
353
- // Detect collision: two different MCP tools mapping to the same Pi name
354
- // (e.g. "my-tool" and "my_tool" both sanitize to "my_tool")
355
- if (currentToolNames.has(piName)) {
356
- console.warn(
357
- `[pi-mcp] Tool name collision: "${tool.name}" maps to "${piName}" which is already taken. ` +
358
- `The later tool definition will overwrite the earlier one.`,
359
- );
360
- }
361
- currentToolNames.add(piName);
362
- // Always re-register — on reconnect the client reference changes and
363
- // Pi's registerTool overwrites by name, so this is idempotent.
364
- this._registerTool(piName, serverName, tool, client);
365
- }
366
-
367
- // Deactivate tools that were removed from the server (no longer in tools/list)
368
- for (const existingName of registeredForServer) {
369
- if (!currentToolNames.has(existingName)) {
370
- this._deactivateServerTool(existingName);
371
- }
372
- }
373
-
374
- this.serverToolNames.set(serverName, currentToolNames);
375
-
376
- // Activate all current tools for this server
377
- this._activateServerTools(serverName);
378
- }
379
-
380
- /** Deactivate all Pi tools belonging to a server (called on disconnect). */
381
- deactivateServer(serverName: string): void {
382
- this._deactivateServerTools(serverName);
383
- }
384
-
385
- /** Remove all tracking data for a server (called when config changes remove a server). */
386
- removeServer(serverName: string): void {
387
- this._deactivateServerTools(serverName);
388
- this.serverToolNames.delete(serverName);
389
- }
390
-
391
- /** Re-activate all Pi tools belonging to a server (called on reconnect). */
392
- activateServer(serverName: string): void {
393
- this._activateServerTools(serverName);
394
- }
395
-
396
- // ─── Internal ───────────────────────────────────────────────────────────────
397
-
398
- private _registerTool(
399
- piName: string,
400
- serverName: string,
401
- tool: McpToolDefinition,
402
- client: Client,
403
- ): void {
404
- // Build description with annotation hints for LLM guidance
405
- let description = tool.description ?? `MCP tool: ${tool.name}`;
406
- const ann = tool.annotations;
407
- if (ann) {
408
- const hints: string[] = [];
409
- if (ann.readOnlyHint) hints.push("read-only");
410
- if (ann.destructiveHint) hints.push("⚠️ destructive");
411
- if (ann.idempotentHint) hints.push("idempotent");
412
- if (ann.openWorldHint) hints.push("may have side effects");
413
- if (hints.length > 0) description += ` [${hints.join(", ")}]`;
414
- }
415
-
416
- const schema = convertJsonSchemaToTypebox(tool.inputSchema);
417
- const timeoutMs = this.settings.requestTimeoutMs;
418
-
419
- this.pi.registerTool({
420
- name: piName,
421
- label: ann?.title ?? tool.name,
422
- description,
423
- promptSnippet: description.slice(0, 120),
424
- parameters: schema,
425
-
426
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
427
- if (signal?.aborted) {
428
- return { content: [{ type: "text", text: "Cancelled" }], details: {} };
429
- }
430
-
431
- try {
432
- const result = await client.request(
433
- {
434
- method: "tools/call",
435
- params: { name: tool.name, arguments: params },
436
- },
437
- CallToolResultSchema,
438
- // Pass AbortSignal to SDK — it will automatically send
439
- // notifications/cancelled when the signal fires
440
- { timeout: timeoutMs, ...(signal ? { signal } : {}) },
441
- );
442
-
443
- const content = convertMcpContent(result.content as unknown[]);
444
-
445
- // Tool execution errors (isError: true) — distinct from protocol errors
446
- if (result.isError) {
447
- const errorText = content.map((c) => c.text).join("\n");
448
- throw new McpError(
449
- errorText || "Tool reported an error",
450
- serverName,
451
- "tool",
452
- );
453
- }
454
-
455
- return { content, details: {} };
456
- } catch (err) {
457
- if (err instanceof McpError) throw err;
458
- // Protocol-level errors (JSON-RPC error response, timeout, etc.)
459
- throw new McpError(
460
- err instanceof Error ? err.message : String(err),
461
- serverName,
462
- "protocol",
463
- err,
464
- );
465
- }
466
- },
467
- });
468
- }
469
-
470
- private _activateServerTools(serverName: string): void {
471
- const serverTools = this.serverToolNames.get(serverName);
472
- if (!serverTools || serverTools.size === 0) return;
473
-
474
- const currentActive = new Set(this.pi.getActiveTools());
475
- for (const name of serverTools) currentActive.add(name);
476
- this.pi.setActiveTools(Array.from(currentActive));
477
- }
478
-
479
- private _deactivateServerTools(serverName: string): void {
480
- const serverTools = this.serverToolNames.get(serverName);
481
- if (!serverTools || serverTools.size === 0) return;
482
-
483
- const currentActive = this.pi.getActiveTools();
484
- const remaining = currentActive.filter((n) => !serverTools.has(n));
485
- this.pi.setActiveTools(remaining);
486
- }
487
-
488
- /** Deactivate a single tool by Pi name (used when a tool is removed on list_changed). */
489
- private _deactivateServerTool(piName: string): void {
490
- const currentActive = this.pi.getActiveTools();
491
- const remaining = currentActive.filter((n) => n !== piName);
492
- this.pi.setActiveTools(remaining);
493
- }
294
+ private readonly settings: Settings;
295
+ private readonly pi: PiExtensionAPI;
296
+ /** Tracks which Pi tool names belong to which MCP server. */
297
+ private readonly serverToolNames = new Map<string, Set<string>>();
298
+
299
+ constructor(settings: Settings, pi: PiExtensionAPI) {
300
+ this.settings = settings;
301
+ this.pi = pi;
302
+ }
303
+
304
+ /**
305
+ * Refresh tools for a server — called on initial connect and on list_changed.
306
+ * Always re-registers tools with the current client reference so that
307
+ * tool execute closures capture the latest client after reconnection.
308
+ * Deactivates tools that are no longer in the server's list.
309
+ * Note: Pi's registerTool() overwrites by name (Map.set), so re-registration is safe.
310
+ */
311
+ async refreshTools(serverName: string, client: Client): Promise<void> {
312
+ const timeoutMs = this.settings.requestTimeoutMs;
313
+
314
+ let tools: McpToolDefinition[];
315
+ try {
316
+ tools = await listAllTools(client, timeoutMs);
317
+ } catch (err) {
318
+ throw new McpError(
319
+ `Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
320
+ serverName,
321
+ "protocol",
322
+ err,
323
+ );
324
+ }
325
+
326
+ const registeredForServer = this.serverToolNames.get(serverName) ?? new Set<string>();
327
+
328
+ // Build the set of currently valid Pi tool names for this server
329
+ const currentToolNames = new Set<string>();
330
+
331
+ for (const tool of tools) {
332
+ const piName = buildToolName(this.settings.toolPrefix, serverName, tool.name);
333
+ // Detect collision: two different MCP tools mapping to the same Pi name
334
+ // (e.g. "my-tool" and "my_tool" both sanitize to "my_tool")
335
+ if (currentToolNames.has(piName)) {
336
+ console.warn(
337
+ `[pi-mcp] Tool name collision: "${tool.name}" maps to "${piName}" which is already taken. ` +
338
+ `The later tool definition will overwrite the earlier one.`,
339
+ );
340
+ }
341
+ currentToolNames.add(piName);
342
+ // Always re-register — on reconnect the client reference changes and
343
+ // Pi's registerTool overwrites by name, so this is idempotent.
344
+ this._registerTool(piName, serverName, tool, client);
345
+ }
346
+
347
+ // Deactivate tools that were removed from the server (no longer in tools/list)
348
+ for (const existingName of registeredForServer) {
349
+ if (!currentToolNames.has(existingName)) {
350
+ this._deactivateServerTool(existingName);
351
+ }
352
+ }
353
+
354
+ this.serverToolNames.set(serverName, currentToolNames);
355
+
356
+ // Activate all current tools for this server
357
+ this._activateServerTools(serverName);
358
+ }
359
+
360
+ /** Deactivate all Pi tools belonging to a server (called on disconnect). */
361
+ deactivateServer(serverName: string): void {
362
+ this._deactivateServerTools(serverName);
363
+ }
364
+
365
+ /** Remove all tracking data for a server (called when config changes remove a server). */
366
+ removeServer(serverName: string): void {
367
+ this._deactivateServerTools(serverName);
368
+ this.serverToolNames.delete(serverName);
369
+ }
370
+
371
+ /** Re-activate all Pi tools belonging to a server (called on reconnect). */
372
+ activateServer(serverName: string): void {
373
+ this._activateServerTools(serverName);
374
+ }
375
+
376
+ // ─── Internal ───────────────────────────────────────────────────────────────
377
+
378
+ private _registerTool(piName: string, serverName: string, tool: McpToolDefinition, client: Client): void {
379
+ // Build description with annotation hints for LLM guidance
380
+ let description = tool.description ?? `MCP tool: ${tool.name}`;
381
+ const ann = tool.annotations;
382
+ if (ann) {
383
+ const hints: string[] = [];
384
+ if (ann.readOnlyHint) hints.push("read-only");
385
+ if (ann.destructiveHint) hints.push("⚠️ destructive");
386
+ if (ann.idempotentHint) hints.push("idempotent");
387
+ if (ann.openWorldHint) hints.push("may have side effects");
388
+ if (hints.length > 0) description += ` [${hints.join(", ")}]`;
389
+ }
390
+
391
+ const schema = convertJsonSchemaToTypebox(tool.inputSchema);
392
+ const timeoutMs = this.settings.requestTimeoutMs;
393
+
394
+ this.pi.registerTool({
395
+ name: piName,
396
+ label: ann?.title ?? tool.name,
397
+ description,
398
+ promptSnippet: description.slice(0, 120),
399
+ parameters: schema,
400
+
401
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
402
+ if (signal?.aborted) {
403
+ return { content: [{ type: "text", text: "Cancelled" }], details: {} };
404
+ }
405
+
406
+ try {
407
+ const result = await client.request(
408
+ {
409
+ method: "tools/call",
410
+ params: { name: tool.name, arguments: params },
411
+ },
412
+ CallToolResultSchema,
413
+ // Pass AbortSignal to SDK — it will automatically send
414
+ // notifications/cancelled when the signal fires
415
+ { timeout: timeoutMs, ...(signal ? { signal } : {}) },
416
+ );
417
+
418
+ const content = convertMcpContent(result.content as unknown[]);
419
+
420
+ // Tool execution errors (isError: true) distinct from protocol errors
421
+ if (result.isError) {
422
+ const errorText = content.map((c) => c.text).join("\n");
423
+ throw new McpError(errorText || "Tool reported an error", serverName, "tool");
424
+ }
425
+
426
+ return { content, details: {} };
427
+ } catch (err) {
428
+ if (err instanceof McpError) throw err;
429
+ // Protocol-level errors (JSON-RPC error response, timeout, etc.)
430
+ throw new McpError(err instanceof Error ? err.message : String(err), serverName, "protocol", err);
431
+ }
432
+ },
433
+ });
434
+ }
435
+
436
+ private _activateServerTools(serverName: string): void {
437
+ const serverTools = this.serverToolNames.get(serverName);
438
+ if (!serverTools || serverTools.size === 0) return;
439
+
440
+ const currentActive = new Set(this.pi.getActiveTools());
441
+ for (const name of serverTools) currentActive.add(name);
442
+ this.pi.setActiveTools(Array.from(currentActive));
443
+ }
444
+
445
+ private _deactivateServerTools(serverName: string): void {
446
+ const serverTools = this.serverToolNames.get(serverName);
447
+ if (!serverTools || serverTools.size === 0) return;
448
+
449
+ const currentActive = this.pi.getActiveTools();
450
+ const remaining = currentActive.filter((n) => !serverTools.has(n));
451
+ this.pi.setActiveTools(remaining);
452
+ }
453
+
454
+ /** Deactivate a single tool by Pi name (used when a tool is removed on list_changed). */
455
+ private _deactivateServerTool(piName: string): void {
456
+ const currentActive = this.pi.getActiveTools();
457
+ const remaining = currentActive.filter((n) => n !== piName);
458
+ this.pi.setActiveTools(remaining);
459
+ }
494
460
  }