pi-provider-litellm 3.3.0 → 4.0.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/src/mcp-tools.ts DELETED
@@ -1,1536 +0,0 @@
1
- import { createHash, createHmac, randomBytes } from "node:crypto";
2
- import type { JsonValue, Static, TSchema } from "@earendil-works/pi-ai";
3
- import { Type } from "@earendil-works/pi-ai";
4
- import {
5
- defineTool,
6
- type ExtensionContext,
7
- type ToolDefinition,
8
- type ToolExposure,
9
- } from "@earendil-works/pi-coding-agent";
10
- import { normalizeBaseUrl } from "./discover.js";
11
- import type { LiteLLMMcpTool, LiteLLMRuntimeAuth, McpToolAnnotations } from "./types.js";
12
-
13
- const LIST_TIMEOUT_MS = 10_000;
14
- const CALL_TIMEOUT_MS = 30_000;
15
- const MAX_DISCOVERY_BODY_BYTES = 5 * 1024 * 1024;
16
- const MAX_DISCOVERY_ENTRIES = 10_000;
17
- const MAX_CALL_BODY_BYTES = 5 * 1024 * 1024;
18
- const MAX_REGISTERED_TOOLS = 512;
19
- const MAX_DESCRIPTION_BYTES = 4 * 1024;
20
- const MAX_SCHEMA_BYTES = 64 * 1024;
21
- const MAX_SCHEMA_DEPTH = 16;
22
- const MAX_RESULT_BYTES = 64 * 1024;
23
- // Pi 0.99 caps its own structured bash results at the same size.
24
- const MAX_STRUCTURED_RESULT_BYTES = 1024 * 1024;
25
- const MAX_TOOL_NAME_LENGTH = 64;
26
- const TOOL_NAME_HASH_LENGTH = 10;
27
- const MAX_LABEL_BYTES = 256;
28
- const MAX_DETAIL_BYTES = 256;
29
- const MAX_ERROR_WALK_DEPTH = 16;
30
- const MAX_DIAGNOSTIC_SAMPLES = 5;
31
- const MAX_DIAGNOSTIC_CAUSE_BYTES = 200;
32
- const INVALID_ENTRY_IDENTITY_BUFFER_BYTES = 64 * 1024;
33
- const TRUNCATION_MARKER = "\n[truncated by pi-provider-litellm]";
34
- const DESCRIPTION_TRUNCATION_MARKER = "… [truncated]";
35
- const SHORT_TRUNCATION_MARKER = "…";
36
-
37
- // `incidentScope` separates the suppression state of catalogs from different providers, so one
38
- // provider's clean pass cannot clear, and its differing report cannot re-trigger, another's incident.
39
- type DiagnosticSink = ((message: string) => void) & { readonly incidentScope?: string };
40
-
41
- interface RawLiteLLMMcpTool {
42
- name?: unknown;
43
- description?: unknown;
44
- inputSchema?: unknown;
45
- input_schema?: unknown;
46
- annotations?: unknown;
47
- server_id?: unknown;
48
- server_name?: unknown;
49
- mcp_info?: {
50
- server_id?: unknown;
51
- server_name?: unknown;
52
- description?: unknown;
53
- };
54
- }
55
-
56
- // The exposure values of a Pi `mcp.json` server entry, so `litellm.mcp.exposure` and
57
- // `litellm.mcp.toolExposure` mean what Pi's own MCP configuration means.
58
- export type McpExposure = "direct" | "codemode" | "codemode-deferred" | "deferred" | "hidden";
59
- const MCP_EXPOSURES: readonly McpExposure[] = ["direct", "codemode", "codemode-deferred", "deferred", "hidden"];
60
-
61
- export interface McpExposurePolicy {
62
- // Exposure of every tool without a `toolExposure` match.
63
- exposure: McpExposure;
64
- // `[key, exposure]` in settings order. A key without `/` matches the tool name as the server offers
65
- // it; a key with `/` matches `<server>/<tool>`. `*` matches any characters.
66
- toolExposure: ReadonlyArray<readonly [string, McpExposure]>;
67
- }
68
-
69
- export const DEFAULT_MCP_EXPOSURE_POLICY: McpExposurePolicy = { exposure: "direct", toolExposure: [] };
70
-
71
- function isMcpExposure(value: unknown): value is McpExposure {
72
- return typeof value === "string" && (MCP_EXPOSURES as readonly string[]).includes(value);
73
- }
74
-
75
- // Reads `exposure` and `toolExposure` from the `litellm.mcp` settings block. Unusable values fall back
76
- // to `direct`, today's behavior, and are reported in extension-owned text.
77
- export function parseMcpExposurePolicy(raw: unknown): { policy: McpExposurePolicy; warnings: string[] } {
78
- const settings = asRecord(raw);
79
- const warnings: string[] = [];
80
- const expected = MCP_EXPOSURES.map((value) => JSON.stringify(value)).join(", ");
81
- let exposure: McpExposure = "direct";
82
- if (settings?.exposure !== undefined) {
83
- if (isMcpExposure(settings.exposure)) exposure = settings.exposure;
84
- else warnings.push(`litellm.mcp.exposure must be one of ${expected}; using "direct".`);
85
- }
86
- const toolExposure: Array<readonly [string, McpExposure]> = [];
87
- if (settings?.toolExposure !== undefined) {
88
- const overrides = asRecord(settings.toolExposure);
89
- if (!overrides)
90
- warnings.push("litellm.mcp.toolExposure must be an object mapping tool name patterns to exposures; ignoring it.");
91
- else
92
- for (const [key, value] of Object.entries(overrides)) {
93
- if (isMcpExposure(value)) toolExposure.push([key, value]);
94
- else warnings.push(`litellm.mcp.toolExposure[${JSON.stringify(key)}] must be one of ${expected}; ignoring it.`);
95
- }
96
- }
97
- return { policy: { exposure, toolExposure }, warnings };
98
- }
99
-
100
- // Glob match where `*` matches any characters; segments must appear in order without overlapping.
101
- function wildcardMatches(pattern: string, subject: string): boolean {
102
- const segments = pattern.split("*");
103
- if (segments.length === 1) return pattern === subject;
104
- const first = segments[0] ?? "";
105
- const last = segments[segments.length - 1] ?? "";
106
- if (!subject.startsWith(first) || !subject.endsWith(last)) return false;
107
- const end = subject.length - last.length;
108
- let position = first.length;
109
- for (const segment of segments.slice(1, -1)) {
110
- const index = subject.indexOf(segment, position);
111
- if (index === -1 || index + segment.length > end) return false;
112
- position = index + segment.length;
113
- }
114
- return position <= end;
115
- }
116
-
117
- // As in Pi's mcp.json: an exact key wins over patterns, and among patterns the first in settings
118
- // order wins. A `<server>/<tool>` exact key is more specific than a bare tool name.
119
- export function resolveMcpExposure(policy: McpExposurePolicy, serverName: string, toolName: string): McpExposure {
120
- const qualified = `${serverName}/${toolName}`;
121
- let qualifiedExact: McpExposure | undefined;
122
- let bareExact: McpExposure | undefined;
123
- let firstPattern: McpExposure | undefined;
124
- for (const [key, exposure] of policy.toolExposure) {
125
- const isQualified = key.includes("/");
126
- const subject = isQualified ? qualified : toolName;
127
- if (!key.includes("*")) {
128
- if (key !== subject) continue;
129
- if (isQualified) qualifiedExact ??= exposure;
130
- else bareExact ??= exposure;
131
- } else if (firstPattern === undefined && wildcardMatches(key, subject)) {
132
- firstPattern = exposure;
133
- }
134
- }
135
- return qualifiedExact ?? bareExact ?? firstPattern ?? policy.exposure;
136
- }
137
-
138
- // Pi's tool API has one `deferred`; the two deferred MCP exposures differ only in which discovery
139
- // tool (codemode or tool_search) is activated to reach them.
140
- export function toToolExposure(exposure: McpExposure): ToolExposure {
141
- return exposure === "codemode-deferred" ? "deferred" : exposure;
142
- }
143
-
144
- interface PreparedTool {
145
- tool: LiteLLMMcpTool;
146
- name: string;
147
- parameters: TSchema;
148
- syntheticArgsEnvelope: boolean;
149
- exposure: McpExposure;
150
- }
151
-
152
- interface InvalidMcpTool {
153
- label: string;
154
- identity: string;
155
- }
156
-
157
- export class McpAccessDeniedError extends Error {
158
- constructor() {
159
- super("MCP access denied");
160
- this.name = "McpAccessDeniedError";
161
- }
162
- }
163
-
164
- // Why a discovered MCP tool was not registered as the proxy described it.
165
- // `schema-envelope` is a degradation, not a loss: the tool still registers, with the
166
- // extension-owned envelope in place of a schema that could not be proven safe.
167
- export type McpDropReason = "invalid-tool" | "duplicate-identity" | "invalid-schema" | "name-collision";
168
- export type McpDegradeReason = "schema-envelope";
169
- export type McpIncidentReason = McpDropReason | McpDegradeReason;
170
-
171
- const DROP_REASON_TEXT: Readonly<Record<McpDropReason, string>> = {
172
- "invalid-tool": "a missing name or server identity",
173
- "duplicate-identity": "a repeated valid identity (the first valid occurrence of each is the one registered)",
174
- "invalid-schema": "an invalid or oversized input schema",
175
- "name-collision": "a colliding generated Pi name",
176
- };
177
-
178
- const DEGRADE_REASON_TEXT: Readonly<Record<McpDegradeReason, string>> = {
179
- "schema-envelope": "a schema that could not be proven free of proxy-supplied regexes or references",
180
- };
181
-
182
- const INCIDENT_REASON_TEXT: Readonly<Record<McpIncidentReason, string>> = {
183
- ...DROP_REASON_TEXT,
184
- ...DEGRADE_REASON_TEXT,
185
- };
186
-
187
- export interface McpPreparationReport {
188
- // Raw tool entries the proxy returned, before any normalization.
189
- discovered: number;
190
- // LiteLLM returned the usable subset from servers that answered while reporting another failed.
191
- partialFailure: boolean;
192
- // Tools that will be registered, whatever parameter shape they ended up with.
193
- prepared: number;
194
- // Of `prepared`, how many carry the extension-owned envelope rather than a proxy-supplied schema.
195
- // Includes both the schemaless path and hazard degradations; `degraded` counts only the latter.
196
- enveloped: number;
197
- // Tools discarded because the registered-tool cap was reached.
198
- overflow: number;
199
- // Bounded generated names for the overflow membership, used to detect catalog changes.
200
- overflowTools: string[];
201
- dropped: Array<{ reason: McpDropReason; tools: string[] }>;
202
- // Non-printed identities paired with `dropped`, used to detect membership changes safely.
203
- dropMemberships: Array<{ reason: McpDropReason; identities: string[] }>;
204
- degraded: Array<{ reason: McpDegradeReason; tools: string[] }>;
205
- // Distinct exposures of the prepared tools, so the caller can activate codemode or tool_search.
206
- exposures: McpExposure[];
207
- }
208
-
209
- // Bounded discovery result: `raw` is what the proxy returned, so losses can be reconciled.
210
- export interface McpDiscovery {
211
- raw: number;
212
- tools: LiteLLMMcpTool[];
213
- partialFailure: boolean;
214
- // Entries dropped by normalization, with safe display labels and non-displayed identities.
215
- invalid: InvalidMcpTool[];
216
- }
217
-
218
- // Incident-aware suppression: an incident class is re-reported whenever its message changes
219
- // (different count or different tools), so a stable proxy stays quiet across refreshes while a
220
- // changed one is always surfaced. Keys come from a fixed set of classes, so this cannot grow
221
- // without bound.
222
- const lastEmittedIncident = new Map<string, string>();
223
-
224
- // Reports an incident unless its identity is unchanged since the last report of that class.
225
- // `identity` must capture everything an operator would want to hear about again — for tool
226
- // incidents that is the full sorted membership, not just the bounded sample that gets printed.
227
- function emitSafetyDiagnostic(
228
- incident: string,
229
- message: string,
230
- identity: string = message,
231
- onDiagnostic: DiagnosticSink = (line) => {
232
- process.stderr.write(line);
233
- },
234
- ): void {
235
- const scope = onDiagnostic.incidentScope;
236
- const key = scope === undefined ? incident : `${scope}\0${incident}`;
237
- if (lastEmittedIncident.get(key) === identity) return;
238
- lastEmittedIncident.set(key, identity);
239
- onDiagnostic(`LiteLLM MCP${scope === undefined ? "" : ` (${JSON.stringify(scope)})`}: ${message}\n`);
240
- }
241
-
242
- function clearIncident(incident: string, onDiagnostic?: DiagnosticSink): void {
243
- const scope = onDiagnostic?.incidentScope;
244
- lastEmittedIncident.delete(scope === undefined ? incident : `${scope}\0${incident}`);
245
- }
246
-
247
- function membershipIdentity(tools: readonly string[]): string {
248
- return createHash("sha256")
249
- .update([...tools].sort().join("\0"))
250
- .digest("hex")
251
- .slice(0, 16);
252
- }
253
-
254
- function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
255
- return `${count} ${count === 1 ? singular : pluralForm}`;
256
- }
257
-
258
- // Renders a bounded sample list. Names are already sanitized to `[a-z0-9_]`, so they are safe for stderr.
259
- function sampleList(names: readonly string[]): string {
260
- const shown = names.slice(0, MAX_DIAGNOSTIC_SAMPLES);
261
- const remainder = names.length - shown.length;
262
- return remainder > 0 ? `${shown.join(", ")} (+${remainder} more)` : shown.join(", ");
263
- }
264
-
265
- // Reports a registration pass that Pi aborted.
266
- //
267
- // Pi's `registerTool` is a synchronous replace-by-name whose only throw comes from a staleness
268
- // check that is never reset, so a throw means the pass is over, not that one tool was rejected.
269
- // `cause` is Pi-authored and bounded; no proxy schema, description, or body reaches stderr.
270
- export function reportMcpRegistrationFatal(
271
- registered: number,
272
- attempted: number,
273
- cause: unknown,
274
- onDiagnostic?: DiagnosticSink,
275
- ): void {
276
- const causeText = truncateUtf8(
277
- cause instanceof Error ? cause.message : String(cause),
278
- MAX_DIAGNOSTIC_CAUSE_BYTES,
279
- SHORT_TRUNCATION_MARKER,
280
- );
281
- emitSafetyDiagnostic(
282
- "registration-fatal",
283
- `registration stopped after ${registered} of ${plural(attempted, "MCP tool")}; ` +
284
- `no further attempts will be made by this extension instance (${causeText}).`,
285
- undefined,
286
- onDiagnostic,
287
- );
288
- }
289
-
290
- // A successful pass clears any fatal incident left by an earlier extension instance, so the same
291
- // failure is reported if it later recurs.
292
- export function reportMcpRegistrationSuccess(onDiagnostic?: DiagnosticSink): void {
293
- clearIncident("registration-fatal", onDiagnostic);
294
- }
295
-
296
- // `registered` is the generated names of the tools that survived, so a failure that changes
297
- // which servers answered is reported again even when the count happens to stay the same.
298
- export function reportMcpPartialDiscovery(
299
- partialFailure: boolean,
300
- registered: readonly string[],
301
- onDiagnostic?: DiagnosticSink,
302
- ): void {
303
- if (!partialFailure) {
304
- clearIncident("discovery-partial-failure", onDiagnostic);
305
- return;
306
- }
307
- emitSafetyDiagnostic(
308
- "discovery-partial-failure",
309
- `proxy reported a partial server failure; ${plural(registered.length, "tool")} registered.`,
310
- `discovery-partial-failure:${membershipIdentity(registered)}`,
311
- onDiagnostic,
312
- );
313
- }
314
-
315
- // Reports a discovery that yielded no registrable tool, so the silence is explained. A pass that did
316
- // register something clears the incident, so a later recurrence is reported rather than suppressed as
317
- // an unchanged message.
318
- export function reportMcpCatalogOutcome(raw: number, registered: number, onDiagnostic?: DiagnosticSink): void {
319
- if (registered > 0) {
320
- clearIncident("empty-catalog", onDiagnostic);
321
- return;
322
- }
323
- emitSafetyDiagnostic(
324
- "empty-catalog",
325
- `no MCP tools were registered from ${plural(raw, "raw entry", "raw entries")} returned by the proxy.`,
326
- `empty-catalog:${raw}`,
327
- onDiagnostic,
328
- );
329
- }
330
-
331
- // Random per process, never logged and never persisted. The catalog identity it feeds is only ever
332
- // compared with other identities computed in the same process, so it does not need to be stable
333
- // across runs — and a random salt means even a leaked identity cannot be dictionary-attacked back to
334
- // the credential, which a bare digest of a short key would not prevent.
335
- const credentialIdentitySalt = randomBytes(32);
336
-
337
- // A non-reversible stand-in for the credential material behind an MCP catalog.
338
- //
339
- // Covers the headers as well as the key: `LITELLM_HEADERS` can carry its own authorization material,
340
- // so fingerprinting the key alone would leave secrets in the identity. Any change to either yields a
341
- // different fingerprint, which is what the catalog needs in order to notice a credential change.
342
- // Durable pause records supply a private agent-directory salt; ordinary catalog identities keep
343
- // using the per-process default above.
344
- export function credentialFingerprint(
345
- apiKey: string,
346
- headers?: Record<string, string>,
347
- salt: Uint8Array = credentialIdentitySalt,
348
- ): string {
349
- const material = JSON.stringify([
350
- apiKey,
351
- Object.entries(headers ?? {}).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
352
- ]);
353
- return createHmac("sha256", salt).update(material).digest("hex").slice(0, 32);
354
- }
355
-
356
- function asRecord(value: unknown): Record<string, unknown> | undefined {
357
- return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
358
- }
359
-
360
- function boundedSignal(timeoutMs: number, parent?: AbortSignal): AbortSignal {
361
- const timeout = AbortSignal.timeout(timeoutMs);
362
- return parent ? AbortSignal.any([parent, timeout]) : timeout;
363
- }
364
-
365
- function stringValue(value: unknown): string | undefined {
366
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
367
- }
368
-
369
- function byteLength(value: string): number {
370
- return Buffer.byteLength(value, "utf8");
371
- }
372
-
373
- function truncateUtf8(value: string, maxBytes: number, marker: string): string {
374
- if (byteLength(value) <= maxBytes) return value;
375
- const markerBytes = byteLength(marker);
376
- const source = Buffer.from(value, "utf8");
377
- let end = Math.max(0, maxBytes - markerBytes);
378
- while (end > 0 && (source[end] & 0xc0) === 0x80) end -= 1;
379
- return `${source.subarray(0, end).toString("utf8")}${marker}`;
380
- }
381
-
382
- async function readBoundedText(
383
- response: Response,
384
- limit: number,
385
- surface: string,
386
- onDiagnostic?: DiagnosticSink,
387
- ): Promise<string> {
388
- if (!response.body) {
389
- clearIncident(`${surface}-body-cap`, onDiagnostic);
390
- return "";
391
- }
392
- const reader = response.body.getReader();
393
- const chunks: Uint8Array[] = [];
394
- let size = 0;
395
- try {
396
- while (true) {
397
- const { done, value } = await reader.read();
398
- if (done) break;
399
- size += value.byteLength;
400
- if (size > limit) {
401
- // Build the diagnostic and the error before cancelling: `cancel()` rejects with the stream's
402
- // stored error if the body errored concurrently, which would otherwise discard both.
403
- emitSafetyDiagnostic(
404
- `${surface}-body-cap`,
405
- `${surface} response exceeded its ${limit}-byte limit.`,
406
- undefined,
407
- onDiagnostic,
408
- );
409
- const capError = new Error(`${surface} response exceeds its ${limit}-byte limit`);
410
- await reader.cancel().catch(() => undefined);
411
- throw capError;
412
- }
413
- chunks.push(value);
414
- }
415
- } finally {
416
- reader.releaseLock();
417
- }
418
- // A response that stayed within the cap clears the incident, so a later breach on this surface
419
- // is reported again instead of being suppressed as an unchanged message.
420
- clearIncident(`${surface}-body-cap`, onDiagnostic);
421
- return Buffer.concat(
422
- chunks.map((chunk) => Buffer.from(chunk)),
423
- size,
424
- ).toString("utf8");
425
- }
426
-
427
- function parseJson(text: string, surface: string): unknown {
428
- try {
429
- return JSON.parse(text) as unknown;
430
- } catch {
431
- throw new Error(`${surface} returned invalid JSON`);
432
- }
433
- }
434
-
435
- function skipJsonWhitespace(text: string, start: number): number {
436
- let index = start;
437
- while (index < text.length && /\s/.test(text[index] ?? "")) index += 1;
438
- return index;
439
- }
440
-
441
- function jsonStringEnd(text: string, start: number): number | undefined {
442
- if (text[start] !== '"') return undefined;
443
- for (let index = start + 1; index < text.length; index += 1) {
444
- if (text[index] === "\\") {
445
- index += 1;
446
- continue;
447
- }
448
- if (text[index] === '"') return index + 1;
449
- }
450
- return undefined;
451
- }
452
-
453
- // Advances over one JSON value without materializing it. Full syntax validation remains JSON.parse's
454
- // job; this scanner only needs enough structure to locate a top-level `tools` array before parsing.
455
- function jsonValueEnd(text: string, start: number): number {
456
- const first = text[start];
457
- if (first === '"') return jsonStringEnd(text, start) ?? text.length;
458
- if (first !== "[" && first !== "{") {
459
- let index = start;
460
- while (index < text.length && !/[\s,}\]]/.test(text[index] ?? "")) index += 1;
461
- return index;
462
- }
463
-
464
- let depth = 1;
465
- for (let index = start + 1; index < text.length; index += 1) {
466
- const character = text[index];
467
- if (character === '"') {
468
- index = (jsonStringEnd(text, index) ?? text.length) - 1;
469
- continue;
470
- }
471
- if (character === "[" || character === "{") depth += 1;
472
- else if (character === "]" || character === "}") {
473
- depth -= 1;
474
- if (depth === 0) return index + 1;
475
- }
476
- }
477
- return text.length;
478
- }
479
-
480
- function assertJsonArrayEntryLimit(text: string, start: number): void {
481
- let depth = 1;
482
- let expectingEntry = true;
483
- let entries = 0;
484
- const recordEntry = (): void => {
485
- entries += 1;
486
- expectingEntry = false;
487
- if (entries > MAX_DISCOVERY_ENTRIES) {
488
- throw new Error(`MCP discovery exceeds its ${MAX_DISCOVERY_ENTRIES}-entry limit`);
489
- }
490
- };
491
-
492
- for (let index = start + 1; index < text.length; index += 1) {
493
- const character = text[index];
494
- if (character === '"') {
495
- if (depth === 1 && expectingEntry) recordEntry();
496
- index = (jsonStringEnd(text, index) ?? text.length) - 1;
497
- continue;
498
- }
499
- if (character === "[" || character === "{") {
500
- if (depth === 1 && expectingEntry) recordEntry();
501
- depth += 1;
502
- continue;
503
- }
504
- if (character === "]" || character === "}") {
505
- depth -= 1;
506
- if (depth === 0) return;
507
- continue;
508
- }
509
- if (depth !== 1) continue;
510
- if (character === ",") {
511
- expectingEntry = true;
512
- continue;
513
- }
514
- if (/\s/.test(character ?? "") || !expectingEntry) continue;
515
- recordEntry();
516
- }
517
- }
518
-
519
- // Rejects an excessive catalog before JSON.parse can allocate an object for every raw entry. Both
520
- // documented response shapes are handled, including duplicate or escaped top-level `tools` keys.
521
- // The response byte cap bounds this lexical pass, and the entry cap makes the hostile-array path
522
- // stop after the first 10,001 primitive values rather than traversing the complete payload.
523
- function assertDiscoveryEntryLimit(text: string): void {
524
- let index = skipJsonWhitespace(text, 0);
525
- if (text[index] === "[") {
526
- assertJsonArrayEntryLimit(text, index);
527
- return;
528
- }
529
- if (text[index] !== "{") return;
530
- index += 1;
531
-
532
- while (index < text.length) {
533
- index = skipJsonWhitespace(text, index);
534
- if (text[index] === "}") return;
535
- const keyStart = index;
536
- const keyEnd = jsonStringEnd(text, keyStart);
537
- if (keyEnd === undefined) return;
538
- index = skipJsonWhitespace(text, keyEnd);
539
- if (text[index] !== ":") return;
540
- index = skipJsonWhitespace(text, index + 1);
541
-
542
- let key: unknown;
543
- try {
544
- key = JSON.parse(text.slice(keyStart, keyEnd));
545
- } catch {
546
- return;
547
- }
548
- if (key === "tools" && text[index] === "[") assertJsonArrayEntryLimit(text, index);
549
- index = skipJsonWhitespace(text, jsonValueEnd(text, index));
550
- if (text[index] === ",") {
551
- index += 1;
552
- continue;
553
- }
554
- return;
555
- }
556
- }
557
-
558
- function parseDiscoveryJson(text: string): unknown {
559
- assertDiscoveryEntryLimit(text);
560
- return parseJson(text, "MCP discovery");
561
- }
562
-
563
- const ANNOTATION_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const;
564
-
565
- // Only the boolean hints, as Pi's own MCP client keeps them; other proxy-supplied annotation fields
566
- // (such as `title`) never reach Pi.
567
- function toolAnnotations(value: unknown): McpToolAnnotations | undefined {
568
- const raw = asRecord(value);
569
- if (!raw) return undefined;
570
- const hints: McpToolAnnotations = {};
571
- for (const hint of ANNOTATION_HINTS) {
572
- const flag = raw[hint];
573
- if (typeof flag === "boolean") hints[hint] = flag;
574
- }
575
- return Object.keys(hints).length > 0 ? hints : undefined;
576
- }
577
-
578
- function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
579
- const raw = asRecord(value) as RawLiteLLMMcpTool | undefined;
580
- if (!raw) return undefined;
581
- const name = stringValue(raw.name);
582
- const serverName =
583
- stringValue(raw.mcp_info?.server_name) ??
584
- stringValue(raw.server_name) ??
585
- stringValue(raw.mcp_info?.server_id) ??
586
- stringValue(raw.server_id);
587
- if (!name || !serverName) return undefined;
588
- const hasInputSchema = Object.hasOwn(raw, "inputSchema");
589
- const hasSnakeInputSchema = Object.hasOwn(raw, "input_schema");
590
- const suppliedSchema = hasInputSchema ? raw.inputSchema : hasSnakeInputSchema ? raw.input_schema : undefined;
591
- const inputSchema = asRecord(suppliedSchema);
592
- const annotations = toolAnnotations(raw.annotations);
593
- const serverDescription = stringValue(raw.mcp_info?.description);
594
- return {
595
- name,
596
- server_name: serverName,
597
- server_id: stringValue(raw.mcp_info?.server_id) ?? stringValue(raw.server_id) ?? serverName,
598
- description: stringValue(raw.description) ?? name,
599
- input_schema: inputSchema ?? {},
600
- // Present but not an object: malformed, as distinct from absent.
601
- ...(inputSchema === undefined && (hasInputSchema || hasSnakeInputSchema) ? { input_schema_malformed: true } : {}),
602
- ...(annotations ? { annotations } : {}),
603
- ...(serverDescription ? { server_description: serverDescription } : {}),
604
- };
605
- }
606
-
607
- // Rejected entries have no registered name; positional labels keep stderr free of proxy text.
608
- function invalidToolLabel(index: number): string {
609
- return `entry_${index}`;
610
- }
611
-
612
- // This structural encoding never retains or prints raw entry text. Length prefixes preserve
613
- // boundaries, while the discovery body cap bounds the amount of input hashed. Cursor frames keep
614
- // queued work proportional to nesting depth; each open object retains one key list rather than one
615
- // frame per field. The per-process salt keeps low-entropy malformed entries from being recoverable
616
- // by guessing.
617
- const invalidEntryIdentitySalt = randomBytes(32);
618
- const invalidEntryIdentityEncoder = new TextEncoder();
619
- const invalidEntryIdentityBuffer = new Uint8Array(INVALID_ENTRY_IDENTITY_BUFFER_BYTES);
620
- function invalidToolIdentity(value: unknown): string {
621
- const digest = createHmac("sha256", invalidEntryIdentitySalt);
622
- const active = new WeakSet<object>();
623
-
624
- const writeText = (text: string): void => {
625
- let remaining = text;
626
- while (remaining.length > 0) {
627
- const { read, written } = invalidEntryIdentityEncoder.encodeInto(remaining, invalidEntryIdentityBuffer);
628
- if (written > 0) digest.update(invalidEntryIdentityBuffer.subarray(0, written));
629
- if (read === 0) throw new Error("Failed to encode MCP entry identity");
630
- remaining = remaining.slice(read);
631
- }
632
- };
633
- const writeString = (text: string): void => {
634
- writeText(`${byteLength(text)}:`);
635
- writeText(text);
636
- };
637
- type IdentityPart =
638
- | { kind: "value"; value: unknown }
639
- | { kind: "array"; value: unknown[]; index: number }
640
- | { kind: "object"; value: Record<string, unknown>; keys: string[]; index: number };
641
- const pending: IdentityPart[] = [{ kind: "value", value }];
642
- while (pending.length > 0) {
643
- const part = pending.pop();
644
- if (!part) break;
645
- if (part.kind === "array") {
646
- if (part.index >= part.value.length) {
647
- writeText("]");
648
- active.delete(part.value);
649
- } else {
650
- pending.push({ ...part, index: part.index + 1 });
651
- pending.push({ kind: "value", value: part.value[part.index] });
652
- }
653
- continue;
654
- }
655
- if (part.kind === "object") {
656
- if (part.index >= part.keys.length) {
657
- writeText("}");
658
- active.delete(part.value);
659
- } else {
660
- const key = part.keys[part.index] as string;
661
- writeString(key);
662
- pending.push({ ...part, index: part.index + 1 });
663
- pending.push({ kind: "value", value: part.value[key] });
664
- }
665
- continue;
666
- }
667
-
668
- const entry = part.value;
669
- if (entry === null) {
670
- writeText("null;");
671
- } else if (typeof entry === "string") {
672
- writeText("string:");
673
- writeString(entry);
674
- } else if (typeof entry === "number") {
675
- writeText(`number:${entry};`);
676
- } else if (typeof entry === "boolean") {
677
- writeText(entry ? "true;" : "false;");
678
- } else if (typeof entry !== "object") {
679
- writeText(`${typeof entry};`);
680
- } else if (active.has(entry)) {
681
- writeText("cycle;");
682
- } else if (Array.isArray(entry)) {
683
- active.add(entry);
684
- writeText(`array:${entry.length}[`);
685
- pending.push({ kind: "array", value: entry, index: 0 });
686
- } else {
687
- active.add(entry);
688
- const value = entry as Record<string, unknown>;
689
- const keys = Object.keys(value);
690
- writeText(`object:${keys.length}{`);
691
- pending.push({ kind: "object", value, keys, index: 0 });
692
- }
693
- }
694
- return digest.digest("hex").slice(0, 32);
695
- }
696
-
697
- export async function discoverMcpTools(
698
- baseUrl: string,
699
- apiKey: string,
700
- headers?: Record<string, string>,
701
- onProgress?: (message: string) => void,
702
- parentSignal?: AbortSignal,
703
- allowInsecureHttp = false,
704
- onDiagnostic?: DiagnosticSink,
705
- ): Promise<McpDiscovery> {
706
- parentSignal?.throwIfAborted();
707
- onProgress?.("Discovering MCP tools from server...");
708
- const signal = boundedSignal(LIST_TIMEOUT_MS, parentSignal);
709
- try {
710
- onProgress?.("Querying MCP tools/list endpoint...");
711
- const response = await fetch(`${normalizeBaseUrl(baseUrl, allowInsecureHttp)}/mcp-rest/tools/list`, {
712
- headers: {
713
- ...headers,
714
- Authorization: `Bearer ${apiKey}`,
715
- "Content-Type": "application/json",
716
- },
717
- signal,
718
- });
719
- if (!response.ok) {
720
- onProgress?.(`MCP tools discovery failed with status ${response.status}`);
721
- if (response.status === 401 || response.status === 403) throw new McpAccessDeniedError();
722
- throw new Error(`HTTP ${response.status}`);
723
- }
724
- const body = parseDiscoveryJson(
725
- await readBoundedText(response, MAX_DISCOVERY_BODY_BYTES, "MCP discovery", onDiagnostic),
726
- );
727
- const bodyRecord = asRecord(body);
728
- const errorTag = bodyRecord?.error;
729
- // Older proxies catch their own access denial and wrap it in an HTTP 200 unexpected_error.
730
- // Recognize only the fixed denial sentence; never display the proxy's message.
731
- if (
732
- errorTag === "access_denied" ||
733
- (errorTag === "unexpected_error" &&
734
- typeof bodyRecord?.message === "string" &&
735
- bodyRecord.message.includes("The key is not allowed to access any MCP servers."))
736
- )
737
- throw new McpAccessDeniedError();
738
- const partialFailure = errorTag === "partial_failure";
739
- // LiteLLM reports total MCP discovery failure in an HTTP 200 envelope. Use only
740
- // extension-owned text: neither its machine-readable tag nor its message is safe for stderr.
741
- if (errorTag != null && !partialFailure) {
742
- throw new Error(
743
- errorTag === "unexpected_error"
744
- ? "MCP discovery reported an unexpected proxy error"
745
- : "MCP discovery reported an error",
746
- );
747
- }
748
- // An unrecognized body shape is not the same as an empty catalog, and reporting it as "0 raw
749
- // entries" would send an operator looking at the wrong thing. This check also applies to a
750
- // partial-failure envelope, because LiteLLM's successful subset must still be an array.
751
- const rawTools = Array.isArray(body) ? body : Array.isArray(bodyRecord?.tools) ? bodyRecord.tools : undefined;
752
- if (rawTools === undefined) throw new Error("MCP discovery returned an unexpected body shape");
753
- if (rawTools.length > MAX_DISCOVERY_ENTRIES) {
754
- throw new Error(`MCP discovery exceeds its ${MAX_DISCOVERY_ENTRIES}-entry limit`);
755
- }
756
- onProgress?.(`Found ${rawTools.length} raw MCP tools, normalizing...`);
757
- const tools: LiteLLMMcpTool[] = [];
758
- const invalid: InvalidMcpTool[] = [];
759
- // Every raw entry lands in exactly one bucket, so later counts reconcile back to `raw`.
760
- rawTools.forEach((raw, index) => {
761
- const tool = normalizeMcpTool(raw);
762
- if (tool) tools.push(tool);
763
- else invalid.push({ label: invalidToolLabel(index), identity: invalidToolIdentity(raw) });
764
- });
765
- onProgress?.(`Normalized ${tools.length} of ${rawTools.length} raw MCP tools`);
766
- return { raw: rawTools.length, tools, invalid, partialFailure };
767
- } catch (error) {
768
- onProgress?.("MCP tools discovery encountered an error");
769
- if (parentSignal?.aborted) throw parentSignal.reason;
770
- throw error;
771
- }
772
- }
773
-
774
- function mcpErrorMessage(value: unknown, depth = 0): string | undefined {
775
- if (depth > MAX_ERROR_WALK_DEPTH) return undefined;
776
- if (typeof value === "string") return value;
777
- if (Array.isArray(value)) {
778
- for (const entry of value) {
779
- const message = mcpErrorMessage(entry, depth + 1);
780
- if (message !== undefined) return message;
781
- }
782
- return undefined;
783
- }
784
- const record = asRecord(value);
785
- if (!record) return undefined;
786
- return (
787
- stringValue(record.message) ??
788
- (record.error !== value ? mcpErrorMessage(record.error, depth + 1) : undefined) ??
789
- stringValue(record.text)
790
- );
791
- }
792
-
793
- function serializeResult(value: unknown): string {
794
- const serialized = JSON.stringify(value, null, 2) ?? "null";
795
- return truncateUtf8(serialized, MAX_RESULT_BYTES, TRUNCATION_MARKER);
796
- }
797
-
798
- function mcpCallError(serverId: string, toolName: string, detail: string): Error {
799
- return new Error(
800
- truncateUtf8(`Error calling ${toolName} on ${serverId}: ${detail}`, MAX_RESULT_BYTES, TRUNCATION_MARKER),
801
- );
802
- }
803
-
804
- export interface McpToolOutcome {
805
- text: string;
806
- structuredContent: Record<string, unknown>;
807
- isError: boolean;
808
- }
809
-
810
- // Same shape as Pi's own MCP `createMcpResultSchema()`, which codemode detects to render
811
- // `CallToolResult`. Extension-owned: a proxy-supplied `outputSchema` is never forwarded.
812
- const MCP_RESULT_SCHEMA = {
813
- type: "object",
814
- properties: {
815
- content: { type: "array", items: { type: "object" } },
816
- isError: { type: "boolean" },
817
- _meta: { type: "object" },
818
- },
819
- required: ["content"],
820
- } as unknown as TSchema;
821
-
822
- function structuredResult(result: unknown, text: string, isError: boolean): Record<string, unknown> {
823
- const record = asRecord(result);
824
- if (record && Array.isArray(record.content)) {
825
- const { _meta: _ignored, ...rest } = record;
826
- const structured = isError ? { ...rest, isError: true } : rest;
827
- if (Buffer.byteLength(JSON.stringify(structured)) <= MAX_STRUCTURED_RESULT_BYTES) return structured;
828
- }
829
- return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
830
- }
831
-
832
- export async function executeMcpTool(
833
- baseUrl: string,
834
- apiKey: string,
835
- serverId: string,
836
- toolName: string,
837
- args: Record<string, unknown>,
838
- headers?: Record<string, string>,
839
- parentSignal?: AbortSignal,
840
- allowInsecureHttp = false,
841
- onDiagnostic?: DiagnosticSink,
842
- ): Promise<McpToolOutcome> {
843
- parentSignal?.throwIfAborted();
844
- const signal = boundedSignal(CALL_TIMEOUT_MS, parentSignal);
845
- let response: Response;
846
- try {
847
- response = await fetch(`${normalizeBaseUrl(baseUrl, allowInsecureHttp)}/mcp-rest/tools/call`, {
848
- method: "POST",
849
- headers: {
850
- ...headers,
851
- Authorization: `Bearer ${apiKey}`,
852
- "Content-Type": "application/json",
853
- },
854
- body: JSON.stringify({ server_id: serverId, name: toolName, arguments: args }),
855
- signal,
856
- });
857
- if (!response.ok) throw mcpCallError(serverId, toolName, `HTTP ${response.status}`);
858
- const body = parseJson(
859
- await readBoundedText(response, MAX_CALL_BODY_BYTES, "MCP tool call", onDiagnostic),
860
- "MCP tool call",
861
- );
862
- const bodyRecord = asRecord(body);
863
- if (bodyRecord?.error != null) {
864
- throw mcpCallError(serverId, toolName, mcpErrorMessage(bodyRecord.error) ?? "MCP error");
865
- }
866
- const result = bodyRecord && "result" in bodyRecord ? bodyRecord.result : body;
867
- const resultRecord = asRecord(result);
868
- if (resultRecord?.isError === true || bodyRecord?.isError === true) {
869
- const message =
870
- mcpErrorMessage(resultRecord?.content ?? bodyRecord?.content ?? result) ?? serializeResult(result);
871
- const text = mcpCallError(serverId, toolName, message).message;
872
- return { text, structuredContent: structuredResult(result, text, true), isError: true };
873
- }
874
- const text = serializeResult(result);
875
- return { text, structuredContent: structuredResult(result, text, false), isError: false };
876
- } catch (error) {
877
- if (parentSignal?.aborted) throw parentSignal.reason;
878
- throw error;
879
- }
880
- }
881
-
882
- function sanitizeName(name: string): string {
883
- const sanitized = name
884
- .toLowerCase()
885
- .replace(/[^a-z0-9]+/g, "_")
886
- .replace(/^_+|_+$/g, "");
887
- return sanitized || "tool";
888
- }
889
-
890
- function toolIdentity(tool: LiteLLMMcpTool): string {
891
- return JSON.stringify([tool.server_id ?? tool.server_name, tool.server_name, tool.name]);
892
- }
893
-
894
- // Derives a Pi tool name from the tool's identity alone.
895
- //
896
- // The hash is unconditional so the name is a pure function of `server_id`/`server_name`/`name`.
897
- // A conditional hash would make a survivor's name depend on which *other* tools happened to be in
898
- // the same catalog, so adding or removing a sibling would rename it — and because Pi cannot
899
- // unregister, the old name would linger and the model would see one tool twice.
900
- // An alias provider's catalog is namespaced by the provider name, in both the prefix and the hash,
901
- // so two providers exposing the same server never replace each other's tools.
902
- function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
903
- const identity = namespace === undefined ? toolIdentity(tool) : JSON.stringify([namespace, toolIdentity(tool)]);
904
- const hash = createHash("sha256").update(identity).digest("hex").slice(0, TOOL_NAME_HASH_LENGTH);
905
- const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
906
- const base = `${prefix}_${sanitizeName(tool.server_name)}_${sanitizeName(tool.name)}`;
907
- return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
908
- }
909
-
910
- // A namespace groups one server's tools, so its name is a pure function of server identity too:
911
- // `My-Server` and `My_Server` sanitize alike, and long names truncate alike, so a hash keeps them apart.
912
- function buildNamespaceName(tool: LiteLLMMcpTool, namespace?: string): string {
913
- const identity = JSON.stringify([namespace ?? null, tool.server_id ?? tool.server_name, tool.server_name]);
914
- const hash = createHash("sha256").update(identity).digest("hex").slice(0, 6);
915
- const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
916
- const base = `${prefix}_${sanitizeName(tool.server_name)}`;
917
- return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
918
- }
919
-
920
- function schemaDepth(value: unknown, depth = 0): number {
921
- if (!value || typeof value !== "object") return depth;
922
- if (depth > MAX_SCHEMA_DEPTH) return depth;
923
- const values = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>);
924
- return values.reduce((maximum, child) => Math.max(maximum, schemaDepth(child, depth + 1)), depth);
925
- }
926
-
927
- const DIRECT_SCHEMA_KEYWORDS = [
928
- "additionalItems",
929
- "additionalProperties",
930
- "contains",
931
- "contentSchema",
932
- "else",
933
- "if",
934
- "not",
935
- "propertyNames",
936
- "then",
937
- "unevaluatedItems",
938
- "unevaluatedProperties",
939
- ] as const;
940
- const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
941
- const SCHEMA_MAP_KEYWORDS = ["$defs", "definitions", "dependentSchemas", "patternProperties", "properties"] as const;
942
-
943
- // TypeBox compiles a schema's `pattern` and the keys of `patternProperties` into a backtracking
944
- // RegExp and executes them with no time limit, so a proxy-supplied expression is an unbounded CPU
945
- // sink. A `$ref` resolves an arbitrary JSON pointer, which means a regex can be parked under any key
946
- // at all — including data-only positions such as `default` or `examples`. Enumerating "positions
947
- // where a subschema may appear" is therefore not a sound bound. We instead walk the entire supplied
948
- // document, every key and every value, and refuse to vouch for anything we could not fully inspect.
949
- const UNSAFE_REGEX_KEY = "pattern";
950
- const UNSAFE_REGEX_MAP_KEY = "patternProperties";
951
- const NONLOCAL_REF_KEYS = ["$dynamicRef", "$recursiveRef"] as const;
952
- // These keywords change TypeBox's reference base or introduce aliases. Rather than duplicate its
953
- // URI and dynamic-scope resolver, fail closed whenever an untrusted schema defines one.
954
- const REF_SCOPE_KEYS = new Set(["$id", "$anchor", "$dynamicAnchor", "$recursiveAnchor"]);
955
- const UNSAFE_POINTER_TOKENS = new Set(["__proto__", "constructor", "prototype"]);
956
- const MAX_SCAN_NODES = 20_000;
957
- const MAX_REF_HOPS = 32;
958
- const POINTER_MISSING = Symbol("pointer-missing");
959
- // Keywords whose immediate children are keyed by *name* rather than by keyword. Inside one of these
960
- // a key like `patternProperties` is the name of a tool argument, not a constraint, so the keyword
961
- // checks must not fire on it. This is the only context the scan needs; it does not restrict where
962
- // the scan looks, only how a key is interpreted.
963
- const NAME_KEYED_KEYWORDS = new Set([
964
- "properties",
965
- "patternProperties",
966
- "$defs",
967
- "definitions",
968
- "dependentSchemas",
969
- "dependentRequired",
970
- "dependencies",
971
- ]);
972
-
973
- // Why a supplied schema could not be proven safe to hand to the validator.
974
- export type SchemaHazard =
975
- | "regex"
976
- | "malformed-ref"
977
- | "nonlocal-ref"
978
- | "unresolvable-ref"
979
- | "resolver-scope"
980
- | "ref-cycle"
981
- | "budget"
982
- | "cycle";
983
-
984
- // Resolves a JSON pointer fragment against the supplied document. Returns POINTER_MISSING rather
985
- // than undefined so that a target whose value genuinely is `undefined` cannot be mistaken for a hit.
986
- function resolvePointer(root: unknown, fragment: string): unknown {
987
- if (fragment === "") return root;
988
- // A plain `#name` is an $anchor reference, which cannot be resolved without an anchor index.
989
- if (!fragment.startsWith("/")) return POINTER_MISSING;
990
- let current: unknown = root;
991
- for (const rawToken of fragment.slice(1).split("/")) {
992
- const token = rawToken.replaceAll("~1", "/").replaceAll("~0", "~");
993
- // TypeBox's pointer lookup refuses these keys even when they are own properties.
994
- if (UNSAFE_POINTER_TOKENS.has(token)) return POINTER_MISSING;
995
- if (Array.isArray(current)) {
996
- // TypeBox indexes arrays with the pointer token itself. Accept only the canonical decimal
997
- // spellings that can name JSON array elements; Number("01") would incorrectly alias index 1.
998
- if (!/^(0|[1-9]\d*)$/.test(token)) return POINTER_MISSING;
999
- const index = Number(token);
1000
- if (!Number.isSafeInteger(index) || index >= current.length) return POINTER_MISSING;
1001
- current = current[index];
1002
- continue;
1003
- }
1004
- const record = asRecord(current);
1005
- if (!record || !Object.hasOwn(record, token)) return POINTER_MISSING;
1006
- current = record[token];
1007
- }
1008
- return current;
1009
- }
1010
-
1011
- // Follows a local `#` reference chain and reports whether the validator could actually use it.
1012
- //
1013
- // Covering the whole document proves no regex hides behind a pointer, but it does not prove the
1014
- // pointer names a subschema at all. TypeBox resolves the pointer and then treats whatever it finds
1015
- // as a schema, so `#/description` makes it throw on a string, a mutual `$defs` pair makes it recurse
1016
- // until the stack is gone, and a missing target silently resolves to `false` — a tool that can never
1017
- // validate any argument. Object identity cannot see any of these, because each pointer hop is a
1018
- // different object.
1019
- function findLocalRefHazard(
1020
- root: Record<string, unknown>,
1021
- ref: string,
1022
- schemaPositionNodes: WeakSet<object>,
1023
- ): SchemaHazard | undefined {
1024
- const activePointers = new Set<string>();
1025
- const checkedPointers = new Set<string>();
1026
- // This is intentionally shared across the complete traversal from one root ref so a broad
1027
- // combinator graph cannot evade the same bound by branching.
1028
- let referenceCount = 0;
1029
-
1030
- const visitRef = (pointer: string): SchemaHazard | undefined => {
1031
- if (activePointers.has(pointer)) return "ref-cycle";
1032
- if (checkedPointers.has(pointer)) return undefined;
1033
- referenceCount += 1;
1034
- if (referenceCount > MAX_REF_HOPS) return "budget";
1035
- // TypeBox percent-decodes URI fragments before resolving their JSON pointer. Keeping encoded
1036
- // refs would require exactly mirroring that decoding (including failures), so refuse them.
1037
- if (pointer.includes("%")) return "unresolvable-ref";
1038
- const target = resolvePointer(root, pointer.slice(1));
1039
- if (target === POINTER_MISSING) return "unresolvable-ref";
1040
- // A boolean is always a complete schema, independent of where it appears in the document.
1041
- if (typeof target === "boolean") return undefined;
1042
- const record = asRecord(target);
1043
- // Name-keyed containers are not schemas. Requiring an object target to have been visited where
1044
- // keys are interpreted as schema keywords prevents a ref from turning one into a schema behind
1045
- // the scan's back (for example, `#/dependencies`).
1046
- if (!record || !schemaPositionNodes.has(record) || !hasValidSchemaShape(record)) return "unresolvable-ref";
1047
-
1048
- activePointers.add(pointer);
1049
- const hazard = visitSameInstanceSchemas(record);
1050
- activePointers.delete(pointer);
1051
- if (!hazard) checkedPointers.add(pointer);
1052
- return hazard;
1053
- };
1054
-
1055
- // TypeBox evaluates these applicators against the same instance as their containing schema.
1056
- // Following only those edges catches unproductive reference cycles without rejecting ordinary
1057
- // recursive object/array schemas, whose property or item edge advances to a nested value.
1058
- const visitSameInstanceSchemas = (schema: Record<string, unknown>): SchemaHazard | undefined => {
1059
- if (typeof schema.$ref === "string") {
1060
- const hazard = schema.$ref.startsWith("#") ? visitRef(schema.$ref) : "nonlocal-ref";
1061
- if (hazard) return hazard;
1062
- }
1063
- for (const keyword of ["not", "if", "then", "else"] as const) {
1064
- const child = asRecord(schema[keyword]);
1065
- if (!child) continue;
1066
- const hazard = visitSameInstanceSchemas(child);
1067
- if (hazard) return hazard;
1068
- }
1069
- for (const keyword of ["allOf", "anyOf", "oneOf"] as const) {
1070
- const schemas = schema[keyword];
1071
- if (!Array.isArray(schemas)) continue;
1072
- for (const child of schemas) {
1073
- const record = asRecord(child);
1074
- if (!record) continue;
1075
- const hazard = visitSameInstanceSchemas(record);
1076
- if (hazard) return hazard;
1077
- }
1078
- }
1079
- for (const keyword of ["dependentSchemas", "dependencies"] as const) {
1080
- const map = asRecord(schema[keyword]);
1081
- if (!map) continue;
1082
- for (const child of Object.values(map)) {
1083
- const record = asRecord(child);
1084
- if (!record) continue;
1085
- const hazard = visitSameInstanceSchemas(record);
1086
- if (hazard) return hazard;
1087
- }
1088
- }
1089
- return undefined;
1090
- };
1091
-
1092
- return visitRef(ref);
1093
- }
1094
-
1095
- function schemaPositionNodes(root: Record<string, unknown>): WeakSet<object> {
1096
- const positions = new WeakSet<object>();
1097
- const seen = new WeakSet<object>();
1098
-
1099
- const visitSchema = (value: unknown): void => {
1100
- if (typeof value === "boolean") return;
1101
- const schema = asRecord(value);
1102
- if (!schema || seen.has(schema)) return;
1103
- seen.add(schema);
1104
- positions.add(schema);
1105
-
1106
- for (const keyword of DIRECT_SCHEMA_KEYWORDS) visitSchema(schema[keyword]);
1107
- for (const keyword of SCHEMA_MAP_KEYWORDS) {
1108
- const map = asRecord(schema[keyword]);
1109
- if (map) for (const child of Object.values(map)) visitSchema(child);
1110
- }
1111
- const dependencies = asRecord(schema.dependencies);
1112
- if (dependencies) {
1113
- for (const child of Object.values(dependencies)) {
1114
- // Draft-07 dependencies may also be arrays of property names; only schema-valued entries
1115
- // establish independently reachable schema positions.
1116
- if (typeof child === "boolean" || asRecord(child)) visitSchema(child);
1117
- }
1118
- }
1119
- for (const keyword of SCHEMA_ARRAY_KEYWORDS) {
1120
- const schemas = schema[keyword];
1121
- if (Array.isArray(schemas)) for (const child of schemas) visitSchema(child);
1122
- }
1123
- const items = schema.items;
1124
- if (Array.isArray(items)) for (const child of items) visitSchema(child);
1125
- else visitSchema(items);
1126
- };
1127
-
1128
- visitSchema(root);
1129
- return positions;
1130
- }
1131
-
1132
- // Walks the whole untrusted schema graph looking for anything the validator could turn into a
1133
- // regex, plus references we cannot resolve within the document we were given.
1134
- //
1135
- // Bounded three ways so a hostile document cannot exhaust us while we inspect it: a depth cap, a
1136
- // node budget, and identity-based cycle detection. Hitting any bound returns a hazard rather than
1137
- // `undefined`, because an unfinished scan proves nothing.
1138
- //
1139
- // A local `#/...` ref is only accepted when the walk covers its target *and* the target resolves to
1140
- // a usable subschema, with the reference chain proven acyclic. Any other ref form is a hazard.
1141
- export function findSchemaHazard(root: Record<string, unknown>): SchemaHazard | undefined {
1142
- const seenKeywordObjects = new WeakSet<object>();
1143
- const seenNameMaps = new WeakSet<object>();
1144
- const active = new WeakSet<object>();
1145
- const localRefs: string[] = [];
1146
- let budget = MAX_SCAN_NODES;
1147
-
1148
- const walk = (value: unknown, depth: number, keysAreNames: boolean): SchemaHazard | undefined => {
1149
- if (budget-- <= 0) return "budget";
1150
- if (depth > MAX_SCHEMA_DEPTH) return "budget";
1151
- if (value === null || typeof value !== "object") return undefined;
1152
- if (active.has(value)) return "cycle";
1153
- // Shared identities need inspection once per interpretation context: a `pattern` key is an
1154
- // argument name under `properties`, but the same object is hazardous if also used as a schema.
1155
- const seen = keysAreNames ? seenNameMaps : seenKeywordObjects;
1156
- if (seen.has(value)) return undefined;
1157
- seen.add(value);
1158
- active.add(value);
1159
-
1160
- if (Array.isArray(value)) {
1161
- for (const child of value) {
1162
- const hazard = walk(child, depth + 1, false);
1163
- if (hazard) return hazard;
1164
- }
1165
- active.delete(value);
1166
- return undefined;
1167
- }
1168
-
1169
- for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
1170
- if (!keysAreNames) {
1171
- // `$id` changes the base against which fragment-only refs resolve, while the anchor
1172
- // keywords introduce aliases and dynamic scope. TypeBox applies those semantics while
1173
- // compiling, so the pointer-only safety resolver must not accept schemas that define them.
1174
- if (REF_SCOPE_KEYS.has(key)) return "resolver-scope";
1175
- // Any `pattern` or `patternProperties` at a keyword position, whatever its value type. This
1176
- // TypeBox only compiles a string-valued `pattern`, so a non-string one is not a CPU risk —
1177
- // but the expression text would still land in the schema Pi holds and forward to the
1178
- // provider, so refusing to vouch for it keeps the stated invariant literally true and does
1179
- // not depend on an upstream type guard staying as it is. A key that is an argument *name*
1180
- // is excluded by `keysAreNames` before reaching here.
1181
- if (key === UNSAFE_REGEX_KEY || key === UNSAFE_REGEX_MAP_KEY) return "regex";
1182
- // A pointer outside the supplied document cannot be inspected at all. A local one is only
1183
- // safe if it also resolves to something the validator can use as a schema.
1184
- if (key === "$ref") {
1185
- if (typeof child !== "string") return "malformed-ref";
1186
- if (!child.startsWith("#")) return "nonlocal-ref";
1187
- // TypeBox decodes percent escapes before pointer lookup. Reject encoded local refs rather
1188
- // than risk validating a different target from the one TypeBox will compile.
1189
- if (child.includes("%")) return "unresolvable-ref";
1190
- // Resolve after the complete walk so acceptance cannot depend on whether the referenced
1191
- // schema appeared before or after this `$ref` in object insertion order.
1192
- localRefs.push(child);
1193
- }
1194
- if ((NONLOCAL_REF_KEYS as readonly string[]).includes(key)) {
1195
- if (typeof child !== "string") return "malformed-ref";
1196
- return "nonlocal-ref";
1197
- }
1198
- }
1199
- const hazard = walk(child, depth + 1, !keysAreNames && NAME_KEYED_KEYWORDS.has(key));
1200
- if (hazard) return hazard;
1201
- }
1202
- active.delete(value);
1203
- return undefined;
1204
- };
1205
-
1206
- const walkHazard = walk(root, 0, false);
1207
- if (walkHazard) return walkHazard;
1208
- const validPositions = schemaPositionNodes(root);
1209
- for (const ref of localRefs) {
1210
- const refHazard = findLocalRefHazard(root, ref, validPositions);
1211
- if (refHazard) return refHazard;
1212
- }
1213
- return undefined;
1214
- }
1215
-
1216
- function isValidSchema(value: unknown): boolean {
1217
- if (typeof value === "boolean") return true;
1218
- const schema = asRecord(value);
1219
- return schema !== undefined && hasValidSchemaShape(schema);
1220
- }
1221
-
1222
- function isValidSchemaMap(value: unknown): boolean {
1223
- const schemas = asRecord(value);
1224
- return schemas !== undefined && Object.values(schemas).every(isValidSchema);
1225
- }
1226
-
1227
- function isValidSchemaArray(value: unknown): boolean {
1228
- return Array.isArray(value) && value.every(isValidSchema);
1229
- }
1230
-
1231
- function isStringArray(value: unknown): boolean {
1232
- return Array.isArray(value) && value.every((item) => typeof item === "string");
1233
- }
1234
-
1235
- function isValidDependencies(value: unknown): boolean {
1236
- const dependencies = asRecord(value);
1237
- return (
1238
- dependencies !== undefined &&
1239
- Object.values(dependencies).every((entry) => isValidSchema(entry) || isStringArray(entry))
1240
- );
1241
- }
1242
-
1243
- function isValidDependentRequired(value: unknown): boolean {
1244
- const dependentRequired = asRecord(value);
1245
- return dependentRequired !== undefined && Object.values(dependentRequired).every(isStringArray);
1246
- }
1247
-
1248
- // Shape validation is a separate concern from the hazard scan above: it exists to avoid handing the
1249
- // validator a structurally broken document, and it deliberately only looks where a subschema may
1250
- // legally appear so that data positions are not misread as constraints.
1251
- function hasValidSchemaShape(record: Record<string, unknown>): boolean {
1252
- if ("required" in record && !isStringArray(record.required)) return false;
1253
- if ("dependentRequired" in record && !isValidDependentRequired(record.dependentRequired)) return false;
1254
- if ("dependencies" in record && !isValidDependencies(record.dependencies)) return false;
1255
- if (SCHEMA_MAP_KEYWORDS.some((keyword) => keyword in record && !isValidSchemaMap(record[keyword]))) return false;
1256
- if (SCHEMA_ARRAY_KEYWORDS.some((keyword) => keyword in record && !isValidSchemaArray(record[keyword]))) return false;
1257
- if (DIRECT_SCHEMA_KEYWORDS.some((keyword) => keyword in record && !isValidSchema(record[keyword]))) return false;
1258
- const items = record.items;
1259
- return !("items" in record) || isValidSchema(items) || isValidSchemaArray(items);
1260
- }
1261
-
1262
- // The extension-owned parameter shape. Nothing in it originates from the proxy.
1263
- function trustedEnvelope(): TSchema {
1264
- return Type.Object({
1265
- args: Type.Record(Type.String(), Type.Unknown(), { description: "Tool arguments as key-value pairs" }),
1266
- });
1267
- }
1268
-
1269
- type BuiltParameters = {
1270
- parameters: TSchema;
1271
- syntheticArgsEnvelope: boolean;
1272
- // Set when the proxy supplied a usable schema that we replaced with the trusted envelope.
1273
- degraded?: McpDegradeReason;
1274
- };
1275
-
1276
- function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason {
1277
- // An `inputSchema` that was present but not a JSON object is a malformed tool, not a schemaless
1278
- // one, and must not be silently upgraded to the permissive envelope.
1279
- if (tool.input_schema_malformed) return "invalid-schema";
1280
- const inputSchema = tool.input_schema;
1281
- // Absent or empty schema: the proxy asked for no parameters, which the trusted envelope models
1282
- // exactly. This is the normal schemaless path, not a degradation.
1283
- if (Object.keys(inputSchema).length === 0) {
1284
- return { parameters: trustedEnvelope(), syntheticArgsEnvelope: true };
1285
- }
1286
- if (inputSchema.type !== "object") return "invalid-schema";
1287
- let serialized: string;
1288
- try {
1289
- serialized = JSON.stringify(inputSchema);
1290
- } catch {
1291
- return "invalid-schema";
1292
- }
1293
- if (byteLength(serialized) > MAX_SCHEMA_BYTES || schemaDepth(inputSchema) > MAX_SCHEMA_DEPTH) {
1294
- return "invalid-schema";
1295
- }
1296
- if (!hasValidSchemaShape(inputSchema)) return "invalid-schema";
1297
- // Structurally fine, but we will only forward it if the whole graph is provably free of
1298
- // proxy-supplied regexes and unresolvable references. Otherwise the tool still registers, with
1299
- // the trusted envelope standing in for its schema.
1300
- if (findSchemaHazard(inputSchema) !== undefined) {
1301
- return { parameters: trustedEnvelope(), syntheticArgsEnvelope: true, degraded: "schema-envelope" };
1302
- }
1303
- return { parameters: Type.Unsafe(inputSchema), syntheticArgsEnvelope: false };
1304
- }
1305
-
1306
- export function prepareTools(
1307
- discovery: McpDiscovery,
1308
- namespace?: string,
1309
- exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
1310
- ): {
1311
- prepared: PreparedTool[];
1312
- report: McpPreparationReport;
1313
- } {
1314
- const drops = new Map<McpDropReason, string[]>();
1315
- const dropMemberships = new Map<McpDropReason, string[]>();
1316
- const degrades = new Map<McpDegradeReason, string[]>();
1317
- const recordDrop = (reason: McpDropReason, label: string, identity = label): void => {
1318
- const labels = drops.get(reason);
1319
- if (labels) labels.push(label);
1320
- else drops.set(reason, [label]);
1321
- const identities = dropMemberships.get(reason);
1322
- if (identities) identities.push(identity);
1323
- else dropMemberships.set(reason, [identity]);
1324
- };
1325
-
1326
- for (const invalid of discovery.invalid) {
1327
- recordDrop("invalid-tool", invalid.label, `${invalid.label}\0${invalid.identity}`);
1328
- }
1329
-
1330
- const accepted: Array<Omit<PreparedTool, "name"> & { degraded?: McpDegradeReason }> = [];
1331
- const acceptedIdentities = new Set<string>();
1332
- for (const tool of discovery.tools) {
1333
- const built = buildParameters(tool);
1334
- if (typeof built === "string") {
1335
- recordDrop(built, buildPiToolName(tool, namespace));
1336
- continue;
1337
- }
1338
- const identity = toolIdentity(tool);
1339
- if (acceptedIdentities.has(identity)) {
1340
- recordDrop("duplicate-identity", buildPiToolName(tool, namespace));
1341
- continue;
1342
- }
1343
- acceptedIdentities.add(identity);
1344
- accepted.push({
1345
- tool,
1346
- parameters: built.parameters,
1347
- syntheticArgsEnvelope: built.syntheticArgsEnvelope,
1348
- exposure: resolveMcpExposure(exposurePolicy, tool.server_name, tool.name),
1349
- degraded: built.degraded,
1350
- });
1351
- }
1352
-
1353
- const candidates = accepted.slice(0, MAX_REGISTERED_TOOLS);
1354
- const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool, namespace));
1355
- const finalNames = new Set<string>();
1356
- const prepared: PreparedTool[] = [];
1357
- for (const candidate of candidates) {
1358
- // Membership-independent: the name depends only on this tool's identity.
1359
- const name = buildPiToolName(candidate.tool, namespace);
1360
- if (finalNames.has(name)) {
1361
- recordDrop("name-collision", name);
1362
- continue;
1363
- }
1364
- finalNames.add(name);
1365
- const { degraded, ...preparedCandidate } = candidate;
1366
- prepared.push({ ...preparedCandidate, name });
1367
- if (degraded) {
1368
- const labels = degrades.get(degraded);
1369
- if (labels) labels.push(name);
1370
- else degrades.set(degraded, [name]);
1371
- }
1372
- }
1373
-
1374
- const report: McpPreparationReport = {
1375
- discovered: discovery.raw,
1376
- partialFailure: discovery.partialFailure,
1377
- prepared: prepared.length,
1378
- // Counting only hazard degradations would tell an operator that schemaless tools retain a
1379
- // proxy-specific argument contract; they have only the extension-owned args-envelope shape.
1380
- enveloped: prepared.filter((tool) => tool.syntheticArgsEnvelope).length,
1381
- overflow: overflowTools.length,
1382
- overflowTools,
1383
- dropped: [...drops.entries()].map(([reason, tools]) => ({ reason, tools })),
1384
- dropMemberships: [...dropMemberships.entries()].map(([reason, identities]) => ({ reason, identities })),
1385
- degraded: [...degrades.entries()].map(([reason, tools]) => ({ reason, tools })),
1386
- exposures: [...new Set(prepared.map((tool) => tool.exposure))].sort(),
1387
- };
1388
- return { prepared, report };
1389
- }
1390
-
1391
- function emitPreparationDiagnostics(report: McpPreparationReport, onDiagnostic?: DiagnosticSink): void {
1392
- const seen = new Set<string>();
1393
- const emitClass = (reason: McpIncidentReason, message: string, tools: string[]): void => {
1394
- seen.add(reason);
1395
- // Identity covers the full membership, so a change beyond the printed sample still re-reports.
1396
- emitSafetyDiagnostic(reason, message, `${tools.length}:${membershipIdentity(tools)}`, onDiagnostic);
1397
- };
1398
- const dropMemberships = new Map(report.dropMemberships.map(({ reason, identities }) => [reason, identities]));
1399
- for (const { reason, tools } of report.dropped) {
1400
- const identities = dropMemberships.get(reason);
1401
- if (!identities) throw new Error(`Missing MCP diagnostic membership for ${reason}`);
1402
- emitClass(
1403
- reason,
1404
- `dropped ${plural(tools.length, "MCP tool")} with ${DROP_REASON_TEXT[reason]}: ${sampleList(tools)}.`,
1405
- identities,
1406
- );
1407
- }
1408
- for (const { reason, tools } of report.degraded) {
1409
- emitClass(
1410
- reason,
1411
- `kept ${plural(tools.length, "MCP tool")} but replaced ${tools.length === 1 ? "its" : "their"} schema with a ` +
1412
- `safe args envelope, because of ${DEGRADE_REASON_TEXT[reason]}: ${sampleList(tools)}.`,
1413
- tools,
1414
- );
1415
- }
1416
- if (report.overflow > 0) {
1417
- seen.add("tool-cap");
1418
- emitSafetyDiagnostic(
1419
- "tool-cap",
1420
- `ignoring ${plural(report.overflow, "MCP tool")} beyond the ${MAX_REGISTERED_TOOLS}-tool limit.`,
1421
- `${report.overflow}:${membershipIdentity(report.overflowTools)}`,
1422
- onDiagnostic,
1423
- );
1424
- }
1425
- // Clear classes that did not recur, so the same incident is reported again if it comes back
1426
- // after a clean refresh instead of being suppressed as an unchanged identity.
1427
- for (const reason of [...Object.keys(INCIDENT_REASON_TEXT), "tool-cap"]) {
1428
- if (!seen.has(reason)) clearIncident(reason, onDiagnostic);
1429
- }
1430
- }
1431
-
1432
- export async function createMcpToolDefinitions(
1433
- getAuth: (ctx?: ExtensionContext) => Promise<LiteLLMRuntimeAuth>,
1434
- onProgress?: (message: string) => void,
1435
- signal?: AbortSignal,
1436
- onDiagnostic?: DiagnosticSink,
1437
- namespace?: string,
1438
- exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
1439
- ): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
1440
- const discoveryAuth = await getAuth();
1441
- const discovery = await discoverMcpTools(
1442
- discoveryAuth.baseUrl,
1443
- discoveryAuth.apiKey,
1444
- discoveryAuth.headers,
1445
- onProgress,
1446
- signal,
1447
- discoveryAuth.allowInsecureHttp,
1448
- onDiagnostic,
1449
- );
1450
- const { prepared, report } = prepareTools(discovery, namespace, exposurePolicy);
1451
- emitPreparationDiagnostics(report, onDiagnostic);
1452
- const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
1453
- const lostDetail = [
1454
- ...report.dropped.map((entry) => `${entry.reason}=${entry.tools.length}`),
1455
- ...(report.overflow > 0 ? [`tool-cap=${report.overflow}`] : []),
1456
- ].join(", ");
1457
- // raw = prepared + lost, so an operator can always account for every entry the proxy returned.
1458
- onProgress?.(
1459
- `Prepared ${report.prepared} of ${report.discovered} raw MCP tools ` +
1460
- `(${report.enveloped} kept with a safe args envelope); lost ${lostTotal}` +
1461
- `${lostDetail ? ` (${lostDetail})` : ""}`,
1462
- );
1463
-
1464
- const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope, exposure }) => {
1465
- const description = truncateUtf8(
1466
- `${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
1467
- MAX_DESCRIPTION_BYTES,
1468
- DESCRIPTION_TRUNCATION_MARKER,
1469
- );
1470
- const promptSnippet = truncateUtf8(
1471
- `${mcpTool.description} via ${mcpTool.server_name} MCP server`,
1472
- MAX_DESCRIPTION_BYTES,
1473
- DESCRIPTION_TRUNCATION_MARKER,
1474
- );
1475
-
1476
- // `label` and the `details` fields below are proxy-supplied, so they are bounded like every other
1477
- // untrusted string that reaches Pi's UI or the model.
1478
- const label = truncateUtf8(`${mcpTool.server_name}: ${mcpTool.name}`, MAX_LABEL_BYTES, SHORT_TRUNCATION_MARKER);
1479
- const boundedDetail = (value: string): string => truncateUtf8(value, MAX_DETAIL_BYTES, SHORT_TRUNCATION_MARKER);
1480
- // Pi 0.99 groups tools by namespace, as its own MCP client does per server. The prefix matches the
1481
- // generated tool names, so a group is recognisable in codemode listings; older Pi ignores it.
1482
- const toolNamespace = {
1483
- name: buildNamespaceName(mcpTool, namespace),
1484
- description: truncateUtf8(
1485
- mcpTool.server_description ?? `LiteLLM MCP server ${mcpTool.server_name}`,
1486
- MAX_LABEL_BYTES,
1487
- SHORT_TRUNCATION_MARKER,
1488
- ),
1489
- };
1490
-
1491
- return defineTool({
1492
- name,
1493
- label,
1494
- description,
1495
- promptSnippet,
1496
- executionMode: "parallel",
1497
- exposure: toToolExposure(exposure),
1498
- namespace: toolNamespace,
1499
- ...(mcpTool.annotations ? { annotations: mcpTool.annotations } : {}),
1500
- parameters,
1501
- outputSchema: MCP_RESULT_SCHEMA,
1502
- async execute(_toolCallId, params: Static<typeof parameters>, toolSignal, _onUpdate, ctx) {
1503
- const auth = await getAuth(ctx);
1504
- const rawParams = params as Record<string, unknown>;
1505
- const args = syntheticArgsEnvelope ? asRecord(rawParams.args) : rawParams;
1506
- if (!args) throw new Error("Synthetic MCP tool arguments must contain an object-valued args property");
1507
- const { text, structuredContent, isError } = await executeMcpTool(
1508
- auth.baseUrl,
1509
- auth.apiKey,
1510
- mcpTool.server_id ?? mcpTool.server_name,
1511
- mcpTool.name,
1512
- args,
1513
- auth.headers,
1514
- toolSignal,
1515
- auth.allowInsecureHttp,
1516
- onDiagnostic,
1517
- );
1518
- // Pi before 0.99 ignores a returned `isError`; `ctx.executeTool` exists only from 0.99.
1519
- if (isError && typeof (ctx as { executeTool?: unknown }).executeTool !== "function") throw new Error(text);
1520
- return {
1521
- content: [{ type: "text", text }],
1522
- // The body came from JSON.parse, so it is a JsonValue; Pi's type cannot see that.
1523
- structuredContent: structuredContent as JsonValue,
1524
- ...(isError ? { isError: true } : {}),
1525
- details: {
1526
- server: boundedDetail(mcpTool.server_name),
1527
- serverId: boundedDetail(mcpTool.server_id ?? mcpTool.server_name),
1528
- tool: boundedDetail(mcpTool.name),
1529
- },
1530
- };
1531
- },
1532
- });
1533
- });
1534
-
1535
- return { definitions, report };
1536
- }