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 +1 -1
- package/src/index.ts +2 -0
- package/src/tool-bridge.ts +582 -145
package/package.json
CHANGED
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 {
|
package/src/tool-bridge.ts
CHANGED
|
@@ -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
|
|
38
|
-
* - $ref (
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
const
|
|
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
|
|
59
|
-
|
|
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
|
|
65
|
-
const ref =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
95
|
-
const merged
|
|
96
|
-
if (
|
|
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
|
-
|
|
222
|
+
|
|
223
|
+
const nextSeen = new Set(seenRefs);
|
|
224
|
+
nextSeen.add(ref);
|
|
225
|
+
return convertJsonSchemaToTypebox(merged, rootDefs, effectiveRoot, nextSeen);
|
|
100
226
|
}
|
|
101
227
|
|
|
102
|
-
// ── Handle
|
|
103
|
-
if (
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.
|
|
119
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
break;
|
|
390
|
+
return Type.Integer(opts);
|
|
150
391
|
case "boolean":
|
|
151
|
-
|
|
152
|
-
break;
|
|
392
|
+
return Type.Boolean(opts);
|
|
153
393
|
case "null":
|
|
154
|
-
|
|
155
|
-
break;
|
|
394
|
+
return Type.Null(opts);
|
|
156
395
|
case "array": {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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(
|
|
435
|
+
Array.isArray(schema["required"])
|
|
436
|
+
? (schema["required"] as unknown[]).filter((r): r is string => typeof r === "string")
|
|
437
|
+
: [],
|
|
168
438
|
);
|
|
169
|
-
const additionalProperties =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
179
|
-
const converted = convertJsonSchemaToTypebox(
|
|
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 ===
|
|
185
|
-
objOpts["additionalProperties"] =
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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(
|
|
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)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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:
|
|
587
|
+
return items.map((item: unknown) => {
|
|
231
588
|
if (!item || typeof item !== "object") {
|
|
232
589
|
return { type: "text", text: String(item) };
|
|
233
590
|
}
|
|
234
|
-
|
|
591
|
+
const obj = item as Record<string, unknown>;
|
|
592
|
+
switch (obj["type"]) {
|
|
235
593
|
case "text":
|
|
236
|
-
return { type: "text", text: String(
|
|
594
|
+
return { type: "text", text: String(obj["text"] ?? "") };
|
|
237
595
|
case "image":
|
|
238
596
|
return {
|
|
239
597
|
type: "image",
|
|
240
|
-
data: String(
|
|
241
|
-
mimeType: String(
|
|
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: ${
|
|
604
|
+
text: `[Audio: ${String(obj["mimeType"] ?? "unknown")}, base64 encoded]`,
|
|
247
605
|
};
|
|
248
606
|
case "resource": {
|
|
249
|
-
const r =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return { type: "text", text:
|
|
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(
|
|
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 (
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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) => !
|
|
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);
|