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