pi-mcp-extension 1.9.2 → 1.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -1023,6 +1023,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1023
1023
  name: "mcp_status",
1024
1024
  label: "MCP status",
1025
1025
  description: "List configured MCP servers, lifecycle, connection and OAuth state. Use mcp_connect to connect one server.",
1026
+ promptSnippet: "Inspect configured MCP servers, their connection state, discovered tools, and authentication status.",
1026
1027
  parameters: Type.Object({}),
1027
1028
  async execute() {
1028
1029
  const rows = await Promise.all(manager.getAllServers().map(async (server) => {
@@ -1046,6 +1047,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1046
1047
  name: "mcp_connect",
1047
1048
  label: "Connect MCP server",
1048
1049
  description: "Connect one named configured MCP server and activate its tools. May request interactive OAuth authorization.",
1050
+ promptSnippet: "Connect or reconnect to a configured MCP server by name and register its tools.",
1049
1051
  parameters: Type.Object({ name: Type.String({ description: "Exact configured MCP server name" }) }),
1050
1052
  async execute(_id, { name }, signal, _update, ctx) {
1051
1053
  try {
@@ -12,6 +12,7 @@
12
12
  * - Image content → ImageContent passthrough; audio/resource → text description
13
13
  */
14
14
 
15
+ import { createHash } from "node:crypto";
15
16
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
17
  import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
17
18
  import * as Type from "typebox";
@@ -28,174 +29,521 @@ export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools
28
29
 
29
30
  // ─── Schema Conversion ────────────────────────────────────────────────────────
30
31
 
32
+ const KIND_SYMBOL = Symbol.for("TypeBox.Kind");
33
+
34
+ function isNullSchema(m: TSchema): boolean {
35
+ const rec = m as Record<string | symbol, unknown>;
36
+ return (
37
+ Type.IsNull(m) ||
38
+ rec["~kind"] === "Null" ||
39
+ rec[KIND_SYMBOL] === "Null" ||
40
+ rec["type"] === "null"
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Place Type.Null() members before non-null union members.
46
+ * WHY: Pi's validateToolArguments runs TypeBox's Value.Convert(schema, args).
47
+ * When Type.String(), Type.Number(), Type.Integer(), or Type.Boolean() precedes
48
+ * Type.Null() in Type.Union([...]), Value.Convert coerces `null` into `""`, `0`,
49
+ * or `false` before reaching the Null branch.
50
+ */
51
+ export function orderUnionMembers(members: TSchema[]): TSchema[] {
52
+ const nulls: TSchema[] = [];
53
+ const nonNulls: TSchema[] = [];
54
+ for (const m of members) {
55
+ if (isNullSchema(m)) {
56
+ nulls.push(m);
57
+ } else {
58
+ nonNulls.push(m);
59
+ }
60
+ }
61
+ return [...nulls, ...nonNulls];
62
+ }
63
+
64
+ function extractSchemaOptions(schema: Record<string, unknown>): Record<string, unknown> {
65
+ const opts: Record<string, unknown> = {};
66
+ if (typeof schema["description"] === "string") opts["description"] = schema["description"];
67
+ if (typeof schema["title"] === "string") opts["title"] = schema["title"];
68
+ if ("default" in schema && schema["default"] !== undefined) opts["default"] = schema["default"];
69
+ if (typeof schema["format"] === "string") opts["format"] = schema["format"];
70
+ if (typeof schema["pattern"] === "string") opts["pattern"] = schema["pattern"];
71
+ if (typeof schema["minLength"] === "number") opts["minLength"] = schema["minLength"];
72
+ if (typeof schema["maxLength"] === "number") opts["maxLength"] = schema["maxLength"];
73
+ if (typeof schema["minimum"] === "number") opts["minimum"] = schema["minimum"];
74
+ if (typeof schema["maximum"] === "number") opts["maximum"] = schema["maximum"];
75
+ if (typeof schema["exclusiveMinimum"] === "number") opts["exclusiveMinimum"] = schema["exclusiveMinimum"];
76
+ if (typeof schema["exclusiveMaximum"] === "number") opts["exclusiveMaximum"] = schema["exclusiveMaximum"];
77
+ if (typeof schema["multipleOf"] === "number") opts["multipleOf"] = schema["multipleOf"];
78
+ if (typeof schema["minItems"] === "number") opts["minItems"] = schema["minItems"];
79
+ if (typeof schema["maxItems"] === "number") opts["maxItems"] = schema["maxItems"];
80
+ if (typeof schema["uniqueItems"] === "boolean") opts["uniqueItems"] = schema["uniqueItems"];
81
+ if (typeof schema["minProperties"] === "number") opts["minProperties"] = schema["minProperties"];
82
+ if (typeof schema["maxProperties"] === "number") opts["maxProperties"] = schema["maxProperties"];
83
+ return opts;
84
+ }
85
+
86
+ function withSchemaOptions(schema: TSchema, opts: Record<string, unknown>): TSchema {
87
+ if (Object.keys(opts).length === 0) return schema;
88
+ const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(schema)) as TSchema;
89
+ return Object.assign(clone, opts);
90
+ }
91
+
92
+ function unescapeJsonPointer(segment: string): string {
93
+ // WHY: RFC 6901 §4 requires unescaping "~1" to "/" before "~0" to "~" so "~01" becomes "~1".
94
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
95
+ }
96
+
97
+ function stepJsonPointer(curr: unknown, part: string): unknown {
98
+ if (!curr || typeof curr !== "object") {
99
+ return undefined;
100
+ }
101
+ if (Array.isArray(curr)) {
102
+ if (!/^(?:0|[1-9][0-9]*)$/.test(part)) {
103
+ return undefined;
104
+ }
105
+ const idx = Number(part);
106
+ return idx >= 0 && idx < curr.length ? curr[idx] : undefined;
107
+ }
108
+ if (!Object.prototype.hasOwnProperty.call(curr, part)) {
109
+ return undefined;
110
+ }
111
+ return (curr as Record<string, unknown>)[part];
112
+ }
113
+
114
+ export function resolveRefTarget(
115
+ ref: string,
116
+ rootDefs: Record<string, Record<string, unknown>> | undefined,
117
+ rootSchema: Record<string, unknown>,
118
+ ): Record<string, unknown> | undefined {
119
+ if (ref === "#") {
120
+ return rootSchema;
121
+ }
122
+ if (!ref.startsWith("#/")) {
123
+ return undefined;
124
+ }
125
+
126
+ const parts = ref.slice(2).split("/").map(unescapeJsonPointer);
127
+
128
+ if (
129
+ (parts[0] === "$defs" || parts[0] === "definitions") &&
130
+ parts.length >= 2 &&
131
+ rootDefs &&
132
+ Object.prototype.hasOwnProperty.call(rootDefs, parts[1]!)
133
+ ) {
134
+ const defEntry = rootDefs[parts[1]!];
135
+ if (defEntry && typeof defEntry === "object" && !Array.isArray(defEntry)) {
136
+ if (parts.length === 2) {
137
+ return defEntry;
138
+ }
139
+ let curr: unknown = defEntry;
140
+ for (let i = 2; i < parts.length; i++) {
141
+ curr = stepJsonPointer(curr, parts[i]!);
142
+ if (curr === undefined) break;
143
+ }
144
+ if (curr && typeof curr === "object" && !Array.isArray(curr)) {
145
+ return curr as Record<string, unknown>;
146
+ }
147
+ }
148
+ }
149
+
150
+ let curr: unknown = rootSchema;
151
+ for (const part of parts) {
152
+ curr = stepJsonPointer(curr, part);
153
+ if (curr === undefined) {
154
+ return undefined;
155
+ }
156
+ }
157
+ if (curr && typeof curr === "object" && !Array.isArray(curr)) {
158
+ return curr as Record<string, unknown>;
159
+ }
160
+ return undefined;
161
+ }
162
+
31
163
  /**
32
164
  * Convert a JSON Schema object to a TypeBox schema.
33
165
  * Handles the common subset used by real-world MCP servers:
34
166
  * - 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)
167
+ * - Arrays, tuples (prefixItems / array items), objects (with required/optional/additionalProperties)
168
+ * - Enums and const (string, numeric, boolean, and nullable enums → Union of Literals)
169
+ * - Nullable and multi-type arrays ("type": ["string", "null"]) with Type.Null() ordered first
170
+ * - Cycle-safe $ref (#, #/$defs/, #/definitions/, and arbitrary RFC 6901 JSON Pointers)
39
171
  * - oneOf / anyOf → TypeBox Union
40
172
  * - allOf → TypeBox Intersect
41
- * Falls back to Type.Any() for unresolvable $ref or missing type.
173
+ * Falls back to Type.Any() for unresolvable $ref, recursive cycles, or missing type.
42
174
  */
43
175
  export function convertJsonSchemaToTypebox(
44
- schema: unknown,
45
- depth = 0,
46
- defs?: Record<string, unknown>,
176
+ schema: Record<string, unknown>,
177
+ defs?: Record<string, Record<string, unknown>>,
178
+ rootSchema?: Record<string, unknown>,
179
+ seenRefs: ReadonlySet<string> = new Set(),
47
180
  ): TSchema {
48
- // Guard against infinite recursion and malformed schemas
49
- if (!schema || typeof schema !== "object" || Array.isArray(schema) || depth > 10) {
181
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
50
182
  return Type.Any();
51
183
  }
52
184
 
53
- const s = schema as Record<string, unknown>;
54
- const description = typeof s["description"] === "string" ? s["description"] : undefined;
55
- const opts = description ? { description } : {};
185
+ const effectiveRoot = rootSchema ?? schema;
186
+ const opts = extractSchemaOptions(schema);
56
187
 
57
188
  // 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),
189
+ const localDefs = (schema["$defs"] ?? schema["definitions"]) as
190
+ | Record<string, Record<string, unknown>>
191
+ | undefined;
192
+ const rootDefs: Record<string, Record<string, unknown>> = {
193
+ ...(localDefs && typeof localDefs === "object" && !Array.isArray(localDefs) ? localDefs : {}),
60
194
  ...defs,
61
195
  };
62
196
 
63
197
  // ── 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
- );
198
+ if (typeof schema["$ref"] === "string") {
199
+ const ref = schema["$ref"];
200
+ if (seenRefs.has(ref)) {
201
+ // WHY: Break recursive $ref cycles along the active resolution path to prevent
202
+ // stack overflow or exponential blowup on self-referential schemas.
84
203
  return Type.Any(opts);
85
204
  }
86
205
 
206
+ const resolved = resolveRefTarget(ref, rootDefs, effectiveRoot);
87
207
  if (!resolved) {
88
- console.warn(
89
- `[pi-mcp] Could not resolve $ref "${ref}", using Type.Any()`,
90
- );
91
208
  return Type.Any(opts);
92
209
  }
93
210
 
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;
211
+ // Merge sibling metadata from the $ref site into the resolved schema
212
+ const merged: Record<string, unknown> = { ...resolved };
213
+ if (typeof schema["description"] === "string") {
214
+ merged["description"] = schema["description"];
215
+ }
216
+ if (typeof schema["title"] === "string") {
217
+ merged["title"] = schema["title"];
218
+ }
219
+ if ("default" in schema && schema["default"] !== undefined) {
220
+ merged["default"] = schema["default"];
98
221
  }
99
- return convertJsonSchemaToTypebox(merged, depth + 1, resolvedDefs);
222
+
223
+ const nextSeen = new Set(seenRefs);
224
+ nextSeen.add(ref);
225
+ return convertJsonSchemaToTypebox(merged, rootDefs, effectiveRoot, nextSeen);
100
226
  }
101
227
 
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);
228
+ // ── Handle const ─────────────────────────────────────────────────────────
229
+ if ("const" in schema && schema["const"] !== undefined) {
230
+ const val = schema["const"];
231
+ if (val === null) {
232
+ return Type.Null(opts);
233
+ }
234
+ if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
235
+ return Type.Literal(val, opts);
236
+ }
237
+ return Type.Any(opts);
107
238
  }
108
239
 
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);
240
+ // ── Handle enum (untyped, string, number, boolean, or nullable) ──────────
241
+ if (Array.isArray(schema["enum"])) {
242
+ const enumVals = schema["enum"] as unknown[];
243
+ const members: TSchema[] = [];
244
+ let hasNullMember = false;
245
+ for (const v of enumVals) {
246
+ if (v === null) {
247
+ members.push(Type.Null());
248
+ hasNullMember = true;
249
+ } else if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
250
+ members.push(Type.Literal(v));
251
+ }
252
+ }
253
+ if (Array.isArray(schema["type"]) && schema["type"].includes("null") && !hasNullMember) {
254
+ members.push(Type.Null());
255
+ }
256
+ if (members.length === 0) {
257
+ return Type.Any(opts);
258
+ }
259
+ const ordered = orderUnionMembers(members);
260
+ if (ordered.length === 1) {
261
+ return withSchemaOptions(ordered[0]!, opts);
262
+ }
263
+ return Type.Union(ordered, opts);
113
264
  }
114
265
 
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);
266
+ const hasObjectKeywords =
267
+ (typeof schema["properties"] === "object" &&
268
+ schema["properties"] !== null &&
269
+ !Array.isArray(schema["properties"])) ||
270
+ Array.isArray(schema["required"]) ||
271
+ schema["additionalProperties"] !== undefined ||
272
+ typeof schema["minProperties"] === "number" ||
273
+ typeof schema["maxProperties"] === "number";
274
+
275
+ const hasArrayKeywords =
276
+ schema["items"] !== undefined || Array.isArray(schema["prefixItems"]);
277
+
278
+ const rawType = schema["type"];
279
+
280
+ // ── Handle non-object combinators (oneOf / anyOf / allOf) ────────────────
281
+ if (
282
+ !hasObjectKeywords &&
283
+ !hasArrayKeywords &&
284
+ rawType !== "object" &&
285
+ rawType !== "array" &&
286
+ !Array.isArray(rawType)
287
+ ) {
288
+ const inheritType = (sub: unknown): Record<string, unknown> => {
289
+ if (!sub || typeof sub !== "object" || Array.isArray(sub)) {
290
+ return sub as Record<string, unknown>;
291
+ }
292
+ const subObj = sub as Record<string, unknown>;
293
+ if (
294
+ typeof rawType === "string" &&
295
+ subObj["type"] === undefined &&
296
+ subObj["$ref"] === undefined &&
297
+ subObj["const"] === undefined &&
298
+ subObj["enum"] === undefined &&
299
+ subObj["oneOf"] === undefined &&
300
+ subObj["anyOf"] === undefined &&
301
+ subObj["allOf"] === undefined
302
+ ) {
303
+ return { type: rawType, ...subObj };
304
+ }
305
+ return subObj;
306
+ };
307
+
308
+ if (Array.isArray(schema["oneOf"])) {
309
+ const members = orderUnionMembers(
310
+ (schema["oneOf"] as unknown[]).map((sub) =>
311
+ convertJsonSchemaToTypebox(inheritType(sub), rootDefs, effectiveRoot, seenRefs),
312
+ ),
313
+ );
314
+ if (members.length === 0) return Type.Any(opts);
315
+ return members.length === 1
316
+ ? withSchemaOptions(members[0]!, opts)
317
+ : Type.Union(members, opts);
318
+ }
319
+
320
+ if (Array.isArray(schema["anyOf"])) {
321
+ const members = orderUnionMembers(
322
+ (schema["anyOf"] as unknown[]).map((sub) =>
323
+ convertJsonSchemaToTypebox(inheritType(sub), rootDefs, effectiveRoot, seenRefs),
324
+ ),
325
+ );
326
+ if (members.length === 0) return Type.Any(opts);
327
+ return members.length === 1
328
+ ? withSchemaOptions(members[0]!, opts)
329
+ : Type.Union(members, opts);
330
+ }
331
+
332
+ if (Array.isArray(schema["allOf"])) {
333
+ const members = (schema["allOf"] as unknown[]).map((sub) =>
334
+ convertJsonSchemaToTypebox(inheritType(sub), rootDefs, effectiveRoot, seenRefs),
335
+ );
336
+ if (members.length === 0) return Type.Any(opts);
337
+ return members.length === 1
338
+ ? withSchemaOptions(members[0]!, opts)
339
+ : Type.Intersect(members, opts);
340
+ }
120
341
  }
121
342
 
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;
343
+ // ── Handle multi-type arrays: { "type": ["string", "null"] } ─────────────
344
+ if (Array.isArray(rawType)) {
345
+ const typeList = rawType.filter((t): t is string => typeof t === "string");
346
+ const hasNull = typeList.includes("null");
347
+ const nonNull = typeList.filter((t) => t !== "null");
127
348
 
128
- const isNullable = Array.isArray(rawType) && rawType.includes("null");
349
+ if (nonNull.length === 0) {
350
+ return hasNull ? Type.Null(opts) : Type.Any(opts);
351
+ }
352
+ if (nonNull.length === 1) {
353
+ const inner = convertJsonSchemaToTypebox(
354
+ { ...schema, type: nonNull[0] },
355
+ rootDefs,
356
+ effectiveRoot,
357
+ seenRefs,
358
+ );
359
+ return hasNull ? Type.Union(orderUnionMembers([Type.Null(), inner]), opts) : inner;
360
+ }
361
+ const members: TSchema[] = nonNull.map((t) =>
362
+ convertJsonSchemaToTypebox(
363
+ { ...schema, type: t },
364
+ rootDefs,
365
+ effectiveRoot,
366
+ seenRefs,
367
+ ),
368
+ );
369
+ if (hasNull) {
370
+ members.unshift(Type.Null());
371
+ }
372
+ return Type.Union(orderUnionMembers(members), opts);
373
+ }
129
374
 
130
- let base: TSchema;
375
+ const type =
376
+ typeof rawType === "string"
377
+ ? rawType
378
+ : hasObjectKeywords
379
+ ? "object"
380
+ : hasArrayKeywords
381
+ ? "array"
382
+ : undefined;
131
383
 
132
384
  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
- }
385
+ case "string":
386
+ return Type.String(opts);
146
387
  case "number":
388
+ return Type.Number(opts);
147
389
  case "integer":
148
- base = Type.Number(opts);
149
- break;
390
+ return Type.Integer(opts);
150
391
  case "boolean":
151
- base = Type.Boolean(opts);
152
- break;
392
+ return Type.Boolean(opts);
153
393
  case "null":
154
- base = Type.Null(opts);
155
- break;
394
+ return Type.Null(opts);
156
395
  case "array": {
157
- const items = s["items"];
158
- base = Type.Array(
159
- items ? convertJsonSchemaToTypebox(items, depth + 1, resolvedDefs) : Type.Unknown(),
396
+ const tupleItems = Array.isArray(schema["prefixItems"])
397
+ ? (schema["prefixItems"] as unknown[])
398
+ : Array.isArray(schema["items"])
399
+ ? (schema["items"] as unknown[])
400
+ : undefined;
401
+ if (tupleItems) {
402
+ return Type.Tuple(
403
+ tupleItems.map((item) =>
404
+ convertJsonSchemaToTypebox(
405
+ item as Record<string, unknown>,
406
+ rootDefs,
407
+ effectiveRoot,
408
+ seenRefs,
409
+ ),
410
+ ),
411
+ opts,
412
+ );
413
+ }
414
+ const items = schema["items"];
415
+ return Type.Array(
416
+ items && typeof items === "object" && !Array.isArray(items)
417
+ ? convertJsonSchemaToTypebox(
418
+ items as Record<string, unknown>,
419
+ rootDefs,
420
+ effectiveRoot,
421
+ seenRefs,
422
+ )
423
+ : Type.Unknown(),
160
424
  opts,
161
425
  );
162
- break;
163
426
  }
164
427
  case "object": {
165
- const properties = s["properties"] as Record<string, unknown> | undefined;
428
+ const rawProperties: Record<string, unknown> =
429
+ typeof schema["properties"] === "object" &&
430
+ schema["properties"] !== null &&
431
+ !Array.isArray(schema["properties"])
432
+ ? { ...(schema["properties"] as Record<string, unknown>) }
433
+ : {};
166
434
  const required = new Set<string>(
167
- Array.isArray(s["required"]) ? (s["required"] as string[]) : [],
435
+ Array.isArray(schema["required"])
436
+ ? (schema["required"] as unknown[]).filter((r): r is string => typeof r === "string")
437
+ : [],
168
438
  );
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;
439
+ const additionalProperties = schema["additionalProperties"];
440
+
441
+ let allOfSchemas: TSchema[] | undefined;
442
+ if (Array.isArray(schema["allOf"])) {
443
+ allOfSchemas = (schema["allOf"] as unknown[]).map((sub) =>
444
+ convertJsonSchemaToTypebox(
445
+ sub as Record<string, unknown>,
446
+ rootDefs,
447
+ effectiveRoot,
448
+ seenRefs,
449
+ ),
450
+ );
451
+ for (const sub of schema["allOf"] as unknown[]) {
452
+ if (sub && typeof sub === "object" && !Array.isArray(sub)) {
453
+ const subObj = sub as Record<string, unknown>;
454
+ const resolvedSub =
455
+ typeof subObj["$ref"] === "string" && !seenRefs.has(subObj["$ref"])
456
+ ? (resolveRefTarget(subObj["$ref"], rootDefs, effectiveRoot) ?? subObj)
457
+ : subObj;
458
+ if (
459
+ typeof resolvedSub["properties"] === "object" &&
460
+ resolvedSub["properties"] !== null &&
461
+ !Array.isArray(resolvedSub["properties"])
462
+ ) {
463
+ for (const [k, v] of Object.entries(resolvedSub["properties"] as Record<string, unknown>)) {
464
+ if (!(k in rawProperties)) {
465
+ rawProperties[k] = v;
466
+ }
467
+ }
468
+ }
469
+ if (Array.isArray(resolvedSub["required"])) {
470
+ for (const reqKey of resolvedSub["required"]) {
471
+ if (typeof reqKey === "string") {
472
+ required.add(reqKey);
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
175
478
  }
176
479
 
177
480
  const props: Record<string, TSchema> = {};
178
- for (const [key, value] of Object.entries(properties)) {
179
- const converted = convertJsonSchemaToTypebox(value, depth + 1, resolvedDefs);
481
+ for (const [key, value] of Object.entries(rawProperties)) {
482
+ const converted = convertJsonSchemaToTypebox(
483
+ value as Record<string, unknown>,
484
+ rootDefs,
485
+ effectiveRoot,
486
+ seenRefs,
487
+ );
180
488
  props[key] = required.has(key) ? converted : Type.Optional(converted);
181
489
  }
182
490
 
183
491
  const objOpts: Record<string, unknown> = { ...opts };
184
- if (additionalProperties === false) {
185
- objOpts["additionalProperties"] = false;
492
+ if (typeof additionalProperties === "boolean") {
493
+ objOpts["additionalProperties"] = additionalProperties;
494
+ } else if (
495
+ typeof additionalProperties === "object" &&
496
+ additionalProperties !== null &&
497
+ !Array.isArray(additionalProperties)
498
+ ) {
499
+ objOpts["additionalProperties"] = convertJsonSchemaToTypebox(
500
+ additionalProperties as Record<string, unknown>,
501
+ rootDefs,
502
+ effectiveRoot,
503
+ seenRefs,
504
+ );
186
505
  }
187
506
 
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;
507
+ if (required.size > 0) {
508
+ objOpts["required"] = Array.from(required);
509
+ }
510
+
511
+ if (Array.isArray(schema["oneOf"])) {
512
+ objOpts["oneOf"] = orderUnionMembers(
513
+ (schema["oneOf"] as unknown[]).map((sub) =>
514
+ convertJsonSchemaToTypebox(
515
+ sub as Record<string, unknown>,
516
+ rootDefs,
517
+ effectiveRoot,
518
+ seenRefs,
519
+ ),
520
+ ),
521
+ );
522
+ }
523
+ if (Array.isArray(schema["anyOf"])) {
524
+ objOpts["anyOf"] = orderUnionMembers(
525
+ (schema["anyOf"] as unknown[]).map((sub) =>
526
+ convertJsonSchemaToTypebox(
527
+ sub as Record<string, unknown>,
528
+ rootDefs,
529
+ effectiveRoot,
530
+ seenRefs,
531
+ ),
532
+ ),
533
+ );
534
+ }
535
+ if (allOfSchemas) {
536
+ objOpts["allOf"] = allOfSchemas;
537
+ }
538
+
539
+ // WHY: Always emit Type.Object(props, objOpts) even when `props` is empty (`{}`)
540
+ // so empty object schemas serialize as `{ type: "object", properties: {} }`
541
+ // rather than `Type.Record` (`patternProperties`), which Gemini and OpenAI reject.
542
+ return Type.Object(props, objOpts);
195
543
  }
544
+ default:
545
+ return Type.Any(opts);
196
546
  }
197
-
198
- return isNullable ? Type.Union([base, Type.Null()]) : base;
199
547
  }
200
548
 
201
549
  // ─── Tool Name Sanitization ───────────────────────────────────────────────────
@@ -206,19 +554,28 @@ const MAX_TOOL_NAME_LEN = 64;
206
554
  * Build a Pi-compatible tool name.
207
555
  * Format: <prefix>_<server>_<tool>
208
556
  * 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.
557
+ * If truncation or collision disambiguation (`forceHash`) is needed, appends an
558
+ * 8-char SHA-256 hex digest computed over the raw unsanitized input components.
210
559
  */
211
- export function buildToolName(prefix: string, serverName: string, toolName: string): string {
560
+ export function buildToolName(
561
+ prefix: string,
562
+ serverName: string,
563
+ toolName: string,
564
+ forceHash = false,
565
+ ): string {
566
+ // WHY: Hash the raw unsanitized components separated by NUL bytes so distinct
567
+ // server/tool pairs that sanitize to the same "_" sequence (e.g. "my-tool" vs
568
+ // "my_tool", or server "a_b" + tool "c" vs server "a" + tool "b_c") never collide.
569
+ const hash = createHash("sha256")
570
+ .update(`${prefix}\0${serverName}\0${toolName}`)
571
+ .digest("hex")
572
+ .slice(0, 8);
212
573
  const raw = `${prefix}_${serverName}_${toolName}`;
213
574
  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;
575
+ if (!forceHash && safe.length <= MAX_TOOL_NAME_LEN) {
576
+ return safe;
577
+ }
578
+ return `${safe.slice(0, MAX_TOOL_NAME_LEN - 9)}_${hash}`;
222
579
  }
223
580
 
224
581
  // ─── Content Conversion ───────────────────────────────────────────────────────
@@ -227,29 +584,32 @@ type PiTextContent = { type: "text"; text: string };
227
584
  type PiImageContent = { type: "image"; data: string; mimeType: string };
228
585
 
229
586
  export function convertMcpContent(items: unknown[]): (PiTextContent | PiImageContent)[] {
230
- return items.map((item: any) => {
587
+ return items.map((item: unknown) => {
231
588
  if (!item || typeof item !== "object") {
232
589
  return { type: "text", text: String(item) };
233
590
  }
234
- switch (item.type) {
591
+ const obj = item as Record<string, unknown>;
592
+ switch (obj["type"]) {
235
593
  case "text":
236
- return { type: "text", text: String(item.text ?? "") };
594
+ return { type: "text", text: String(obj["text"] ?? "") };
237
595
  case "image":
238
596
  return {
239
597
  type: "image",
240
- data: String(item.data ?? ""),
241
- mimeType: String(item.mimeType ?? "image/png"),
598
+ data: String(obj["data"] ?? ""),
599
+ mimeType: String(obj["mimeType"] ?? "image/png"),
242
600
  };
243
601
  case "audio":
244
602
  return {
245
603
  type: "text",
246
- text: `[Audio: ${item.mimeType ?? "unknown"}, base64 encoded]`,
604
+ text: `[Audio: ${String(obj["mimeType"] ?? "unknown")}, base64 encoded]`,
247
605
  };
248
606
  case "resource": {
249
- const r = item.resource;
250
- if (r?.text) return { type: "text", text: r.text };
251
- if (r?.blob) return { type: "text", text: `[Resource blob: ${r.uri}]` };
252
- return { type: "text", text: `[Resource: ${r?.uri ?? "unknown"}]` };
607
+ const r = obj["resource"] as Record<string, unknown> | undefined;
608
+ // WHY: Check `typeof r?.["text"] === "string"` rather than truthiness so
609
+ // empty text resources (`text: ""`) are preserved as `""` instead of `[Resource: uri]`.
610
+ if (typeof r?.["text"] === "string") return { type: "text", text: r["text"] };
611
+ if (r?.["blob"]) return { type: "text", text: `[Resource blob: ${String(r["uri"])}]` };
612
+ return { type: "text", text: `[Resource: ${String(r?.["uri"] ?? "unknown")}]` };
253
613
  }
254
614
  default:
255
615
  return { type: "text", text: JSON.stringify(item) };
@@ -317,6 +677,10 @@ export class ToolBridge {
317
677
  private readonly pi: PiExtensionAPI;
318
678
  /** Tracks which Pi tool names belong to which MCP server. */
319
679
  private readonly serverToolNames = new Map<string, Set<string>>();
680
+ /** Tracks which MCP server owns each registered Pi tool name. */
681
+ private readonly toolOwners = new Map<string, string>();
682
+ /** Monotonic per-server generation counter to discard out-of-order refreshTools responses. */
683
+ private readonly refreshGenerations = new Map<string, number>();
320
684
 
321
685
  constructor(settings: Settings, pi: PiExtensionAPI) {
322
686
  this.settings = settings;
@@ -335,7 +699,13 @@ export class ToolBridge {
335
699
  * Deactivates tools that are no longer in the server's list.
336
700
  * Note: Pi's registerTool() overwrites by name (Map.set), so re-registration is safe.
337
701
  */
338
- async refreshTools(serverName: string, client: Client, isCurrent: () => boolean = () => true): Promise<void> {
702
+ async refreshTools(
703
+ serverName: string,
704
+ client: Client,
705
+ isCurrent: () => boolean = () => true,
706
+ ): Promise<void> {
707
+ const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
708
+ this.refreshGenerations.set(serverName, gen);
339
709
  const timeoutMs = this.settings.requestTimeoutMs;
340
710
 
341
711
  let tools: McpToolDefinition[];
@@ -350,23 +720,18 @@ export class ToolBridge {
350
720
  );
351
721
  }
352
722
 
353
- if (!isCurrent()) return;
723
+ // WHY: Discard stale tools/list responses if a newer refreshTools(),
724
+ // deactivateServer(), or removeServerTools() occurred while listAllTools() was in flight.
725
+ if (this.refreshGenerations.get(serverName) !== gen || !isCurrent()) return;
354
726
  const registeredForServer = this.serverToolNames.get(serverName) ?? new Set<string>();
355
727
 
356
728
  // Build the set of currently valid Pi tool names for this server
357
729
  const currentToolNames = new Set<string>();
358
730
 
359
731
  for (const tool of tools) {
360
- const piName = buildToolName(this.settings.toolPrefix, serverName, tool.name);
361
- // Detect collision: two different MCP tools mapping to the same Pi name
362
- // (e.g. "my-tool" and "my_tool" both sanitize to "my_tool")
363
- if (currentToolNames.has(piName)) {
364
- console.warn(
365
- `[pi-mcp] Tool name collision: "${tool.name}" maps to "${piName}" which is already taken. ` +
366
- `The later tool definition will overwrite the earlier one.`,
367
- );
368
- }
732
+ const piName = this._resolveUniqueToolName(serverName, tool.name, currentToolNames);
369
733
  currentToolNames.add(piName);
734
+ this.toolOwners.set(piName, serverName);
370
735
  // Always re-register — on reconnect the client reference changes and
371
736
  // Pi's registerTool overwrites by name, so this is idempotent.
372
737
  this._registerTool(piName, serverName, tool, client);
@@ -375,7 +740,10 @@ export class ToolBridge {
375
740
  // Deactivate tools that were removed from the server (no longer in tools/list)
376
741
  for (const existingName of registeredForServer) {
377
742
  if (!currentToolNames.has(existingName)) {
378
- this._deactivateServerTool(existingName);
743
+ this._deactivateServerTool(serverName, existingName);
744
+ if (this.toolOwners.get(existingName) === serverName) {
745
+ this.toolOwners.delete(existingName);
746
+ }
379
747
  }
380
748
  }
381
749
 
@@ -394,13 +762,30 @@ export class ToolBridge {
394
762
 
395
763
  /** Deactivate all Pi tools belonging to a server (called on disconnect). */
396
764
  deactivateServer(serverName: string): void {
765
+ const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
766
+ this.refreshGenerations.set(serverName, gen);
397
767
  this._deactivateServerTools(serverName);
398
768
  }
399
769
 
400
770
  /** Remove all tracking data for a server (called when config changes remove a server). */
401
771
  removeServer(serverName: string): void {
772
+ this.removeServerTools(serverName);
773
+ }
774
+
775
+ /** Remove all tools and ownership tracking for a server. */
776
+ removeServerTools(serverName: string): void {
777
+ const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
778
+ this.refreshGenerations.set(serverName, gen);
402
779
  this._deactivateServerTools(serverName);
403
- this.serverToolNames.delete(serverName);
780
+ const existing = this.serverToolNames.get(serverName);
781
+ if (existing) {
782
+ for (const toolName of existing) {
783
+ if (this.toolOwners.get(toolName) === serverName) {
784
+ this.toolOwners.delete(toolName);
785
+ }
786
+ }
787
+ this.serverToolNames.delete(serverName);
788
+ }
404
789
  }
405
790
 
406
791
  /** Re-activate all Pi tools belonging to a server (called on reconnect). */
@@ -410,6 +795,38 @@ export class ToolBridge {
410
795
 
411
796
  // ─── Internal ───────────────────────────────────────────────────────────────
412
797
 
798
+ private _resolveUniqueToolName(
799
+ serverName: string,
800
+ rawToolName: string,
801
+ seenInBatch: ReadonlySet<string>,
802
+ ): string {
803
+ const isTaken = (name: string): boolean => {
804
+ if (seenInBatch.has(name)) return true;
805
+ const owner = this.toolOwners.get(name);
806
+ return owner !== undefined && owner !== serverName;
807
+ };
808
+
809
+ const baseCandidate = buildToolName(this.settings.toolPrefix, serverName, rawToolName, false);
810
+ if (!isTaken(baseCandidate)) {
811
+ return baseCandidate;
812
+ }
813
+
814
+ const hashedCandidate = buildToolName(this.settings.toolPrefix, serverName, rawToolName, true);
815
+ if (!isTaken(hashedCandidate)) {
816
+ return hashedCandidate;
817
+ }
818
+
819
+ let suffix = 2;
820
+ while (true) {
821
+ const tag = `_${suffix}`;
822
+ const candidate = `${hashedCandidate.slice(0, MAX_TOOL_NAME_LEN - tag.length)}${tag}`;
823
+ if (!isTaken(candidate)) {
824
+ return candidate;
825
+ }
826
+ suffix++;
827
+ }
828
+ }
829
+
413
830
  private _registerTool(
414
831
  piName: string,
415
832
  serverName: string,
@@ -440,7 +857,7 @@ export class ToolBridge {
440
857
 
441
858
  async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
442
859
  if (signal?.aborted) {
443
- return { content: [{ type: "text", text: "Cancelled" }], details: {} };
860
+ throw new McpError("Tool execution cancelled", serverName, "protocol");
444
861
  }
445
862
 
446
863
  try {
@@ -455,7 +872,16 @@ export class ToolBridge {
455
872
  { timeout: timeoutMs, ...(signal ? { signal } : {}) },
456
873
  );
457
874
 
458
- const content = convertMcpContent(result.content as unknown[]);
875
+ const content = convertMcpContent(
876
+ (result.content ?? []) as Array<Record<string, unknown>>,
877
+ );
878
+ if (content.length === 0 && result.structuredContent !== undefined) {
879
+ const serialized =
880
+ typeof result.structuredContent === "string"
881
+ ? result.structuredContent
882
+ : JSON.stringify(result.structuredContent, null, 2);
883
+ content.push({ type: "text", text: serialized });
884
+ }
459
885
 
460
886
  // Tool execution errors (isError: true) — distinct from protocol errors
461
887
  if (result.isError) {
@@ -498,13 +924,24 @@ export class ToolBridge {
498
924
  const serverTools = this.serverToolNames.get(serverName);
499
925
  if (!serverTools || serverTools.size === 0) return;
500
926
 
927
+ const ownedByServer = new Set<string>();
928
+ for (const toolName of serverTools) {
929
+ const owner = this.toolOwners.get(toolName);
930
+ if (owner !== undefined && owner !== serverName) continue;
931
+ ownedByServer.add(toolName);
932
+ }
933
+ if (ownedByServer.size === 0) return;
934
+
501
935
  const currentActive = this.pi.getActiveTools();
502
- const remaining = currentActive.filter((n) => !serverTools.has(n));
936
+ const remaining = currentActive.filter((n) => !ownedByServer.has(n));
503
937
  this.pi.setActiveTools(remaining);
504
938
  }
505
939
 
506
940
  /** Deactivate a single tool by Pi name (used when a tool is removed on list_changed). */
507
- private _deactivateServerTool(piName: string): void {
941
+ private _deactivateServerTool(serverName: string, piName: string): void {
942
+ const owner = this.toolOwners.get(piName);
943
+ if (owner !== undefined && owner !== serverName) return;
944
+
508
945
  const currentActive = this.pi.getActiveTools();
509
946
  const remaining = currentActive.filter((n) => n !== piName);
510
947
  this.pi.setActiveTools(remaining);