@phi-code-admin/phi-code 0.82.2 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/core/default-models.json +39 -5
- package/docs/usage.md +1 -1
- package/extensions/phi/mcp/LICENSE +21 -0
- package/extensions/phi/mcp/callback-server.ts +263 -0
- package/extensions/phi/mcp/config.ts +195 -0
- package/extensions/phi/mcp/errors.ts +35 -0
- package/extensions/phi/mcp/index.ts +376 -0
- package/extensions/phi/mcp/oauth-provider.ts +367 -0
- package/extensions/phi/mcp/server-manager.ts +464 -0
- package/extensions/phi/mcp/tool-bridge.ts +494 -0
- package/extensions/phi/providers/alibaba.ts +16 -10
- package/package.json +4 -2
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool bridge for pi-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Converts MCP tools to Pi tools and manages their lifecycle:
|
|
5
|
+
* - Paginated tools/list (cursor loop per spec)
|
|
6
|
+
* - JSON Schema → TypeBox conversion (common types + Type.Any() fallback)
|
|
7
|
+
* - Tool name sanitization (Pi-compatible identifiers)
|
|
8
|
+
* - Tool annotations → description hints
|
|
9
|
+
* - AbortSignal → SDK's built-in cancellation (notifications/cancelled)
|
|
10
|
+
* - Protocol error vs tool execution error distinction
|
|
11
|
+
* - Activate/deactivate pattern (register once, toggle on server state change)
|
|
12
|
+
* - Image/audio/resource content → text description passthrough
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
|
+
import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
import * as Type from "typebox";
|
|
18
|
+
import type { TSchema } from "typebox";
|
|
19
|
+
import type { ExtensionAPI } from "phi-code";
|
|
20
|
+
import { McpError } from "./errors.js";
|
|
21
|
+
import type { Settings } from "./config.js";
|
|
22
|
+
|
|
23
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Subset of Pi's ExtensionAPI used by the bridge. */
|
|
26
|
+
export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools" | "setActiveTools">;
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// ─── Schema Conversion ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a JSON Schema object to a TypeBox schema.
|
|
33
|
+
* Handles the common subset used by real-world MCP servers:
|
|
34
|
+
* - Primitives (string, number, integer, boolean, null)
|
|
35
|
+
* - Arrays, objects (with required/optional/additionalProperties)
|
|
36
|
+
* - Enums (string enums → Union of Literals)
|
|
37
|
+
* - Nullable types ("type": ["string", "null"])
|
|
38
|
+
* - $ref (local #/$defs/ and #/definitions/ references)
|
|
39
|
+
* - oneOf / anyOf → TypeBox Union
|
|
40
|
+
* - allOf → TypeBox Intersect
|
|
41
|
+
* Falls back to Type.Any() for unresolvable $ref or missing type.
|
|
42
|
+
*/
|
|
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;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Tool Name Sanitization ───────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
const MAX_TOOL_NAME_LEN = 64;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build a Pi-compatible tool name.
|
|
207
|
+
* Format: <prefix>_<server>_<tool>
|
|
208
|
+
* Rules: [a-zA-Z0-9_], max 64 chars.
|
|
209
|
+
* If truncation is needed, the last 8 chars are replaced with a hash to avoid collisions.
|
|
210
|
+
*/
|
|
211
|
+
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;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Content Conversion ───────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
type PiTextContent = { type: "text"; text: string };
|
|
227
|
+
|
|
228
|
+
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
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Tool Listing ─────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
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
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Fetch all tools from a server using cursor-based pagination.
|
|
275
|
+
* The MCP spec mandates clients follow nextCursor until exhausted.
|
|
276
|
+
* Includes a max-page guard to prevent infinite loops from broken servers.
|
|
277
|
+
*/
|
|
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;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Tool Bridge ──────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Manages MCP tools as Pi tools for a set of servers.
|
|
311
|
+
* Tools are registered once and activated/deactivated as servers connect/disconnect.
|
|
312
|
+
*/
|
|
313
|
+
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
|
+
}
|
|
494
|
+
}
|
|
@@ -40,21 +40,27 @@ export interface AlibabaModelSpec {
|
|
|
40
40
|
contextWindow: number;
|
|
41
41
|
maxTokens: number;
|
|
42
42
|
reasoning: boolean;
|
|
43
|
+
/** Multimodal: accepts image input in addition to text. */
|
|
44
|
+
vision: boolean;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Bundled Alibaba models. Refresh via `scripts/refresh-alibaba-models.ts`.
|
|
47
|
-
* Last verified: 2026-
|
|
49
|
+
* Last verified: 2026-06-14 against the Model Studio model list.
|
|
50
|
+
* Vision flag follows the Coding Plan page (qwen *-plus flagships + kimi-k2.5
|
|
51
|
+
* are multimodal); the qwen3-coder / qwen3-max / glm / MiniMax entries are text.
|
|
48
52
|
*/
|
|
49
53
|
export const ALIBABA_MODELS: readonly AlibabaModelSpec[] = [
|
|
50
|
-
{ id: "qwen3.
|
|
51
|
-
{ id: "qwen3-
|
|
52
|
-
{ id: "qwen3-
|
|
53
|
-
{ id: "qwen3-
|
|
54
|
-
{ id: "
|
|
55
|
-
{ id: "
|
|
56
|
-
{ id: "
|
|
57
|
-
{ id: "
|
|
54
|
+
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
|
|
55
|
+
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
|
|
56
|
+
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
|
|
57
|
+
{ id: "qwen3-max-2026-01-23", name: "Qwen 3 Max", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: false },
|
|
58
|
+
{ id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
|
|
59
|
+
{ id: "qwen3-coder-next", name: "Qwen 3 Coder Next", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
|
|
60
|
+
{ id: "kimi-k2.5", name: "Kimi K2.5", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: true },
|
|
61
|
+
{ id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
|
|
62
|
+
{ id: "glm-4.7", name: "GLM 4.7", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
|
|
63
|
+
{ id: "MiniMax-M2.5", name: "MiniMax M2.5", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
|
|
58
64
|
] as const;
|
|
59
65
|
|
|
60
66
|
export const ALIBABA_RATE_LIMITS = {
|
|
@@ -94,7 +100,7 @@ export function buildAlibabaProviderConfig(variant: "openai" | "anthropic", apiK
|
|
|
94
100
|
id: m.id,
|
|
95
101
|
name: isAnthropic ? `${m.name} (Anthropic-compat)` : m.name,
|
|
96
102
|
reasoning: m.reasoning,
|
|
97
|
-
input: ["text"] as
|
|
103
|
+
input: (m.vision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
|
|
98
104
|
contextWindow: m.contextWindow,
|
|
99
105
|
maxTokens: m.maxTokens,
|
|
100
106
|
...(isAnthropic ? { compat: { supportsLongCacheRetention: true } } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phi-code-admin/phi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.0",
|
|
4
4
|
"description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"piConfig": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@mariozechner/jiti": "^2.6.5",
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
52
|
"@phi-code-admin/browser": "^1.0.4",
|
|
52
53
|
"@silvia-odwyer/photon-node": "^0.3.4",
|
|
53
54
|
"chalk": "^5.5.0",
|
|
@@ -71,7 +72,8 @@
|
|
|
71
72
|
"typebox": "^1.1.24",
|
|
72
73
|
"undici": "^7.19.1",
|
|
73
74
|
"uuid": "^14.0.0",
|
|
74
|
-
"yaml": "^2.8.2"
|
|
75
|
+
"yaml": "^2.8.2",
|
|
76
|
+
"zod": "^3.25.0"
|
|
75
77
|
},
|
|
76
78
|
"overrides": {
|
|
77
79
|
"rimraf": "6.1.2",
|