@ikuma.cloud/pix-mcp 0.0.3 → 0.0.4
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 +71 -10
- package/package.json +1 -1
- package/src/index.ts +53 -18
- package/src/rejection.ts +53 -0
- package/src/schema.ts +224 -23
package/README.md
CHANGED
|
@@ -180,17 +180,78 @@ permission extensions must coordinate any shared approval UI. Discovered tools
|
|
|
180
180
|
stay active until session shutdown or a server catalog change.
|
|
181
181
|
Changed and removed definitions are withdrawn; changed tools require loading
|
|
182
182
|
again. Unsupported input schemas or metadata, name collisions, and task-only
|
|
183
|
-
tools are counted as `unsupportedTools` in discovery results.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
tools are counted as `unsupportedTools` in discovery results. Each server with
|
|
184
|
+
rejections also includes up to five `rejections` and an `omittedRejections`
|
|
185
|
+
count. Each rejection has a one-based catalog `index`, an adapter-owned `code`
|
|
186
|
+
and `message`, and the original `tool` name only if it passes name validation.
|
|
187
|
+
For example, `draft07-reference` identifies unsupported draft-07 references;
|
|
188
|
+
`unsupported-dialect` identifies an unrecognized dialect. These diagnostics are
|
|
189
|
+
returned by list, search, and load, and replaced on each catalog refresh. Raw
|
|
190
|
+
exceptions, schema contents, invalid tool names, and dialect URLs are not exposed.
|
|
191
|
+
|
|
192
|
+
An unsupported output schema still fails that server's discovery rather than
|
|
193
|
+
producing a per-tool rejection. Losing an established HTTP notification stream
|
|
194
|
+
also withdraws tools rather than silently keeping a stale catalog; servers that
|
|
195
|
+
decline the optional stream with HTTP 405 remain usable. Reload Pi to reconnect
|
|
196
|
+
a failed server or reread configuration.
|
|
188
197
|
|
|
189
198
|
Pi handles provider compatibility. Some providers support transcript-anchored
|
|
190
199
|
schema additions; others rebuild the tool set and may invalidate prompt caches.
|
|
191
200
|
For very small catalogs, eager loading would avoid a discovery round trip, but
|
|
192
201
|
v1 intentionally offers only the deferred mode.
|
|
193
202
|
|
|
203
|
+
### Schema compatibility
|
|
204
|
+
|
|
205
|
+
Schemas without `$schema` use MCP's default JSON Schema 2020-12 dialect. Explicit
|
|
206
|
+
2020-12 schemas and a conservative draft-07 subset are supported. Draft-07
|
|
207
|
+
accepts `http://json-schema.org/draft-07/schema` and its HTTPS spelling, with or
|
|
208
|
+
without a trailing `#`.
|
|
209
|
+
|
|
210
|
+
Draft-07 schemas are syntax-checked against their own bundled meta-schema, then
|
|
211
|
+
checked against this keyword allowlist:
|
|
212
|
+
|
|
213
|
+
| Category | Supported draft-07 keywords |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| Types and values | `type` (including unions), `enum`, `const`, boolean subschemas |
|
|
216
|
+
| Objects | `properties`, `patternProperties`, `additionalProperties`, `required`, `propertyNames`, `minProperties`, `maxProperties` |
|
|
217
|
+
| Arrays | Schema-valued `items`, `contains`, `minItems`, `maxItems`, `uniqueItems: false` |
|
|
218
|
+
| Numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` |
|
|
219
|
+
| Strings | `minLength`, `maxLength`, `pattern` |
|
|
220
|
+
| Composition | `allOf`, `anyOf`, `oneOf`, `not`, `if`, `then`, `else` |
|
|
221
|
+
| Metadata and storage | `$comment`, `title`, `description`, `default`, `examples`, `readOnly`, `writeOnly`, `definitions` |
|
|
222
|
+
|
|
223
|
+
After these checks, the schema is normalized to equivalent 2020-12 constraints.
|
|
224
|
+
String length bounds must be safe integers and become Unicode patterns that
|
|
225
|
+
count code points rather than Pi's native grapheme-cluster lengths. Object
|
|
226
|
+
normalization also avoids an incorrect native property-count optimization.
|
|
227
|
+
The normalized schema is both exposed to Pi and used for adapter validation;
|
|
228
|
+
output schemas use the same compatibility policy. Literal data in annotations,
|
|
229
|
+
`const`, and `enum` is preserved without interpreting it as a schema. Pi's normal
|
|
230
|
+
argument coercion still applies before adapter execution.
|
|
231
|
+
|
|
232
|
+
`multipleOf`, `uniqueItems: true`, and `properties`/`required` names inherited from
|
|
233
|
+
`Object.prototype` (for example, `toString`, `constructor`, and `__proto__`) are
|
|
234
|
+
rejected with specific diagnostics. Pi's current validator uses inexact numeric
|
|
235
|
+
comparisons, lossy uniqueness hashes, and inherited-property checks for these
|
|
236
|
+
constructs; accepting them would not preserve JSON Schema semantics. These
|
|
237
|
+
compatibility restrictions and normalizations apply to draft-07 admission;
|
|
238
|
+
existing default/2020-12 validation behavior is unchanged.
|
|
239
|
+
|
|
240
|
+
`const` and `enum` literals cannot contain arrays, even nested inside objects:
|
|
241
|
+
native equality can conflate array literals with unequal objects. Arrays in
|
|
242
|
+
annotation data such as `default` and `examples` remain legal. Numeric
|
|
243
|
+
backreferences in `patternProperties` are also rejected, regardless of
|
|
244
|
+
`additionalProperties`, because native pattern combination changes their capture
|
|
245
|
+
indices. Escaped literal backslashes and named backreferences remain supported.
|
|
246
|
+
|
|
247
|
+
Draft-07 references (including local references), `$id`, nested `$schema`
|
|
248
|
+
declarations, tuple `items`, `additionalItems`, `dependencies`, `format`, content
|
|
249
|
+
keywords, and other unlisted keywords are rejected. In particular, newer keywords
|
|
250
|
+
such as `dependentRequired` cannot silently become constraints. This is not a
|
|
251
|
+
complete draft-07 converter. For unsupported dialect constructs, the server must
|
|
252
|
+
supply an equivalent supported schema—not merely remove or change `$schema`.
|
|
253
|
+
Embedded draft-07 declarations inside a 2020-12 document also remain unsupported.
|
|
254
|
+
|
|
194
255
|
## Output and limits
|
|
195
256
|
|
|
196
257
|
Text, supported images, and structured content are retained. Long text gets a
|
|
@@ -211,11 +272,11 @@ servers concurrently. Each catalog is limited to 1000 tools, 100 pagination
|
|
|
211
272
|
cursors, and 2 MiB of metadata; individual input/output schemas are limited to
|
|
212
273
|
64 KiB. Tool names must use 1–128 ASCII letters, digits, underscores, hyphens, or
|
|
213
274
|
periods; descriptions are limited to 16 KiB. Stdio messages are limited to 16 MiB.
|
|
214
|
-
Schemas are syntax-checked before compilation
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
275
|
+
Schemas are syntax-checked before compilation; see [schema compatibility](#schema-compatibility)
|
|
276
|
+
for supported dialects and the draft-07 subset. Schema nesting is limited to 64
|
|
277
|
+
levels, including literal data. External schema references are unsupported, but
|
|
278
|
+
literal `$ref` fields inside instance data are allowed. Meta-schema validation
|
|
279
|
+
never fetches a server-provided URL.
|
|
219
280
|
|
|
220
281
|
The adapter never automatically retries `tools/call`: a timeout or lost response
|
|
221
282
|
may occur after a mutating operation took effect. Cancellation or expiration
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import { Type
|
|
2
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
3
3
|
import type {
|
|
4
4
|
ExtensionAPI,
|
|
5
5
|
ExtensionContext,
|
|
@@ -9,7 +9,18 @@ import { compact, entry, search, summary, type Entry } from "./catalog.ts";
|
|
|
9
9
|
import { Connection } from "./client.ts";
|
|
10
10
|
import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
|
|
11
11
|
import { formatResult } from "./output.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { prepareSchema } from "./schema.ts";
|
|
13
|
+
import { ToolRejectionError, type RejectionCode } from "./rejection.ts";
|
|
14
|
+
|
|
15
|
+
interface Rejections {
|
|
16
|
+
count: number;
|
|
17
|
+
samples: {
|
|
18
|
+
tool?: string;
|
|
19
|
+
index: number;
|
|
20
|
+
code: RejectionCode;
|
|
21
|
+
message: string;
|
|
22
|
+
}[];
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
interface State {
|
|
15
26
|
alive: boolean;
|
|
@@ -17,7 +28,7 @@ interface State {
|
|
|
17
28
|
entries: Map<string, Entry>;
|
|
18
29
|
loaded: Map<string, string>;
|
|
19
30
|
connections: Map<string, Connection>;
|
|
20
|
-
rejected: Map<string,
|
|
31
|
+
rejected: Map<string, Rejections>;
|
|
21
32
|
configIssues: ConfigIssue[];
|
|
22
33
|
}
|
|
23
34
|
|
|
@@ -191,11 +202,21 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
191
202
|
status: issue.message,
|
|
192
203
|
unsupportedTools: 0,
|
|
193
204
|
})),
|
|
194
|
-
...[...owner.connections.values()].map((connection) =>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
...[...owner.connections.values()].map((connection) => {
|
|
206
|
+
const rejected = owner.rejected.get(connection.config.name);
|
|
207
|
+
return {
|
|
208
|
+
name: connection.config.name,
|
|
209
|
+
status: connection.status,
|
|
210
|
+
unsupportedTools: rejected?.count ?? 0,
|
|
211
|
+
...(rejected?.count
|
|
212
|
+
? {
|
|
213
|
+
rejections: rejected.samples,
|
|
214
|
+
omittedRejections:
|
|
215
|
+
rejected.count - rejected.samples.length,
|
|
216
|
+
}
|
|
217
|
+
: {}),
|
|
218
|
+
};
|
|
219
|
+
}),
|
|
199
220
|
],
|
|
200
221
|
items: selected.map((item) => ({
|
|
201
222
|
...summary(item),
|
|
@@ -223,27 +244,27 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
223
244
|
const active = new Set(pi.getActiveTools());
|
|
224
245
|
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
225
246
|
const next = new Map<string, Entry>();
|
|
226
|
-
|
|
227
|
-
for (const tool of tools) {
|
|
247
|
+
const rejected: Rejections = { count: 0, samples: [] };
|
|
248
|
+
for (const [index, tool] of tools.entries()) {
|
|
228
249
|
const item = entry(config.name, tool);
|
|
250
|
+
const validName = /^[A-Za-z0-9_.-]{1,128}$/.test(tool.name);
|
|
229
251
|
try {
|
|
230
252
|
if (
|
|
231
|
-
|
|
253
|
+
!validName ||
|
|
232
254
|
Buffer.byteLength(tool.description ?? "") > 16 * 1024
|
|
233
255
|
) {
|
|
234
|
-
throw new
|
|
256
|
+
throw new ToolRejectionError("invalid-tool-metadata");
|
|
235
257
|
}
|
|
236
258
|
if (tool.execution?.taskSupport === "required")
|
|
237
|
-
throw new
|
|
259
|
+
throw new ToolRejectionError("tasks-required");
|
|
238
260
|
if (
|
|
239
261
|
next.has(item.name) ||
|
|
240
262
|
(registered.has(item.name) && !owned.has(item.name))
|
|
241
263
|
)
|
|
242
|
-
throw new
|
|
243
|
-
const validator =
|
|
264
|
+
throw new ToolRejectionError("name-collision");
|
|
265
|
+
const { parameters, validator } = prepareSchema(tool.inputSchema);
|
|
244
266
|
const old = previous.get(item.name);
|
|
245
267
|
if (old?.fingerprint !== item.fingerprint) {
|
|
246
|
-
const parameters = tool.inputSchema as TSchema;
|
|
247
268
|
pi.registerTool({
|
|
248
269
|
name: item.name,
|
|
249
270
|
label: `MCP: ${config.name}/${tool.name}`,
|
|
@@ -287,8 +308,22 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
287
308
|
owned.add(item.name);
|
|
288
309
|
}
|
|
289
310
|
next.set(item.name, item);
|
|
290
|
-
} catch {
|
|
291
|
-
rejected++;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
rejected.count++;
|
|
313
|
+
// Bound discovery independently of catalog size. Invalid names and raw
|
|
314
|
+
// compiler/SDK errors are never reflected back into model context.
|
|
315
|
+
if (rejected.samples.length < 5) {
|
|
316
|
+
const reason =
|
|
317
|
+
error instanceof ToolRejectionError
|
|
318
|
+
? error
|
|
319
|
+
: new ToolRejectionError("registration-failed");
|
|
320
|
+
rejected.samples.push({
|
|
321
|
+
...(validName ? { tool: tool.name } : {}),
|
|
322
|
+
index: index + 1,
|
|
323
|
+
code: reason.code,
|
|
324
|
+
message: reason.message,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
292
327
|
}
|
|
293
328
|
}
|
|
294
329
|
for (const [name, old] of previous) {
|
package/src/rejection.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Discovery may expose only these adapter-owned messages, never compiler/SDK
|
|
2
|
+
// exceptions: schemas, dialect URLs, and exception payloads can contain secrets.
|
|
3
|
+
const messages = {
|
|
4
|
+
"invalid-schema": "Invalid or unsupported MCP JSON Schema.",
|
|
5
|
+
"unsupported-dialect":
|
|
6
|
+
"Invalid or unsupported MCP JSON Schema dialect. Use 2020-12 or the documented draft-07 subset.",
|
|
7
|
+
"embedded-dialect":
|
|
8
|
+
"Embedded dialect declarations are unsupported in the draft-07 subset.",
|
|
9
|
+
"draft07-reference":
|
|
10
|
+
"Draft-07 references are unsupported. Supply an equivalent reference-free schema or a 2020-12 schema.",
|
|
11
|
+
"draft07-tuple":
|
|
12
|
+
"Draft-07 tuple items are unsupported. Supply an equivalent 2020-12 schema using prefixItems.",
|
|
13
|
+
"draft07-dependencies":
|
|
14
|
+
"Draft-07 dependencies are unsupported. Supply an equivalent 2020-12 schema using dependentRequired/dependentSchemas.",
|
|
15
|
+
"draft07-resource":
|
|
16
|
+
"Draft-07 resource identifiers ($id) are unsupported in the compatible subset.",
|
|
17
|
+
"draft07-format":
|
|
18
|
+
"Draft-07 format validation is unsupported in the compatible subset.",
|
|
19
|
+
"draft07-keyword":
|
|
20
|
+
"An unsupported draft-07 keyword is present. Use the documented subset or supply an equivalent 2020-12 schema.",
|
|
21
|
+
"draft07-length":
|
|
22
|
+
"Draft-07 string length bounds outside the safe integer range are unsupported.",
|
|
23
|
+
"draft07-property-name":
|
|
24
|
+
"Draft-07 properties/required names inherited from Object.prototype are unsupported by Pi's native validator.",
|
|
25
|
+
"draft07-unique-items":
|
|
26
|
+
"Draft-07 uniqueItems:true is unsupported because Pi's native validator can conflate distinct values.",
|
|
27
|
+
"draft07-multiple-of":
|
|
28
|
+
"Draft-07 multipleOf is unsupported because Pi's native validator uses inexact numeric comparisons.",
|
|
29
|
+
"draft07-literal-array":
|
|
30
|
+
"Draft-07 const/enum literals containing arrays are unsupported because Pi's native equality check can conflate arrays and objects.",
|
|
31
|
+
"draft07-pattern-backreference":
|
|
32
|
+
"Draft-07 patternProperties with numeric backreferences are unsupported because Pi's native validator changes their capture indices.",
|
|
33
|
+
"external-reference": "External MCP schema references are unsupported.",
|
|
34
|
+
"schema-too-large": "MCP schema exceeds 64 KiB.",
|
|
35
|
+
"schema-too-deep": "MCP schema is too deeply nested.",
|
|
36
|
+
"invalid-tool-metadata":
|
|
37
|
+
"Unsupported tool metadata: names require 1–128 ASCII letters, digits, underscores, hyphens, or periods; descriptions must not exceed 16 KiB.",
|
|
38
|
+
"tasks-required": "Tools requiring MCP task execution are unsupported.",
|
|
39
|
+
"name-collision": "The native tool name conflicts with another tool.",
|
|
40
|
+
"registration-failed": "Native tool registration failed.",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type RejectionCode = keyof typeof messages;
|
|
44
|
+
|
|
45
|
+
export class ToolRejectionError extends Error {
|
|
46
|
+
readonly code: RejectionCode;
|
|
47
|
+
|
|
48
|
+
constructor(code: RejectionCode) {
|
|
49
|
+
super(messages[code]);
|
|
50
|
+
this.name = "ToolRejectionError";
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { TSchema } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { jsonSchemaValidator } from "@modelcontextprotocol/sdk/validation";
|
|
3
|
+
import { Ajv } from "ajv";
|
|
3
4
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4
5
|
import { Compile } from "typebox/compile";
|
|
6
|
+
import { ToolRejectionError } from "./rejection.ts";
|
|
5
7
|
|
|
6
8
|
const syntax = new Ajv2020({ strict: false, validateFormats: false });
|
|
9
|
+
const legacySyntax = new Ajv({ strict: false, validateFormats: false });
|
|
7
10
|
const dialect = "https://json-schema.org/draft/2020-12/schema";
|
|
11
|
+
const legacyDialect = "http://json-schema.org/draft-07/schema";
|
|
8
12
|
const schemaMaps = new Set([
|
|
9
13
|
"$defs",
|
|
10
14
|
"definitions",
|
|
@@ -27,28 +31,202 @@ const schemaValues = new Set([
|
|
|
27
31
|
"contentSchema",
|
|
28
32
|
]);
|
|
29
33
|
|
|
34
|
+
// An allowlist, not a blacklist: a newer or custom keyword could acquire a
|
|
35
|
+
// constraint when interpreted by Pi's TypeBox validator. Instance/annotation
|
|
36
|
+
// data is copied verbatim, never traversed as a schema.
|
|
37
|
+
const legacyKeywords = new Set([
|
|
38
|
+
"$comment",
|
|
39
|
+
"title",
|
|
40
|
+
"description",
|
|
41
|
+
"default",
|
|
42
|
+
"examples",
|
|
43
|
+
"readOnly",
|
|
44
|
+
"writeOnly",
|
|
45
|
+
"type",
|
|
46
|
+
"enum",
|
|
47
|
+
"const",
|
|
48
|
+
"minimum",
|
|
49
|
+
"maximum",
|
|
50
|
+
"exclusiveMinimum",
|
|
51
|
+
"exclusiveMaximum",
|
|
52
|
+
"minLength",
|
|
53
|
+
"maxLength",
|
|
54
|
+
"pattern",
|
|
55
|
+
"items",
|
|
56
|
+
"minItems",
|
|
57
|
+
"maxItems",
|
|
58
|
+
"uniqueItems",
|
|
59
|
+
"contains",
|
|
60
|
+
"properties",
|
|
61
|
+
"patternProperties",
|
|
62
|
+
"additionalProperties",
|
|
63
|
+
"required",
|
|
64
|
+
"propertyNames",
|
|
65
|
+
"minProperties",
|
|
66
|
+
"maxProperties",
|
|
67
|
+
"allOf",
|
|
68
|
+
"anyOf",
|
|
69
|
+
"oneOf",
|
|
70
|
+
"not",
|
|
71
|
+
"if",
|
|
72
|
+
"then",
|
|
73
|
+
"else",
|
|
74
|
+
"definitions",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
function containsArray(value: unknown): boolean {
|
|
78
|
+
return (
|
|
79
|
+
Array.isArray(value) ||
|
|
80
|
+
(value !== null &&
|
|
81
|
+
typeof value === "object" &&
|
|
82
|
+
Object.values(value).some(containsArray))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasNumericBackreference(pattern: string): boolean {
|
|
87
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
88
|
+
if (pattern[index] === "\\" && /[1-9]/.test(pattern[++index] ?? ""))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeLegacy(value: unknown, root = false): unknown {
|
|
95
|
+
if (typeof value === "boolean") return value;
|
|
96
|
+
const schema = value as Record<string, unknown>;
|
|
97
|
+
const normalized = Object.fromEntries(
|
|
98
|
+
Object.entries(schema).map(([keyword, child]) => {
|
|
99
|
+
if (keyword === "$schema") {
|
|
100
|
+
if (!root) throw new ToolRejectionError("embedded-dialect");
|
|
101
|
+
return [keyword, dialect];
|
|
102
|
+
}
|
|
103
|
+
if (["$ref", "$dynamicRef", "$recursiveRef"].includes(keyword))
|
|
104
|
+
throw new ToolRejectionError("draft07-reference");
|
|
105
|
+
if (keyword === "items" && Array.isArray(child))
|
|
106
|
+
throw new ToolRejectionError("draft07-tuple");
|
|
107
|
+
if (keyword === "dependencies")
|
|
108
|
+
throw new ToolRejectionError("draft07-dependencies");
|
|
109
|
+
if (keyword === "$id") throw new ToolRejectionError("draft07-resource");
|
|
110
|
+
if (keyword === "format") throw new ToolRejectionError("draft07-format");
|
|
111
|
+
// TypeBox 1.3 uses a numeric tolerance for multiples and lossy hashes for
|
|
112
|
+
// uniqueness. A private validator cannot fix Pi's native acceptance set.
|
|
113
|
+
if (keyword === "multipleOf")
|
|
114
|
+
throw new ToolRejectionError("draft07-multiple-of");
|
|
115
|
+
if (keyword === "uniqueItems" && child === true)
|
|
116
|
+
throw new ToolRejectionError("draft07-unique-items");
|
|
117
|
+
// TypeBox and Pi's preprocessing use `in` for properties/required. Reject
|
|
118
|
+
// prototype-sensitive names even when optional: inherited values can fail
|
|
119
|
+
// a property constraint or satisfy a requirement on an absent JSON key.
|
|
120
|
+
if (
|
|
121
|
+
(keyword === "required" &&
|
|
122
|
+
(child as string[]).some((name) => name in Object.prototype)) ||
|
|
123
|
+
(keyword === "properties" &&
|
|
124
|
+
Object.keys(child as object).some((name) => name in Object.prototype))
|
|
125
|
+
)
|
|
126
|
+
throw new ToolRejectionError("draft07-property-name");
|
|
127
|
+
// TypeBox's deep equality can equate array literals with objects such as
|
|
128
|
+
// {length:0}. Inspect literal structure only for that defect, not for schema
|
|
129
|
+
// keywords; default/examples remain unrestricted instance data.
|
|
130
|
+
if (
|
|
131
|
+
(keyword === "const" && containsArray(child)) ||
|
|
132
|
+
(keyword === "enum" && (child as unknown[]).some(containsArray))
|
|
133
|
+
)
|
|
134
|
+
throw new ToolRejectionError("draft07-literal-array");
|
|
135
|
+
// additionalProperties combines these patterns inside a capturing group,
|
|
136
|
+
// changing numeric backreference indices. Reject rather than rewrite regex
|
|
137
|
+
// syntax; escaped backslashes and named backreferences keep their meaning.
|
|
138
|
+
if (
|
|
139
|
+
keyword === "patternProperties" &&
|
|
140
|
+
Object.keys(child as object).some(hasNumericBackreference)
|
|
141
|
+
)
|
|
142
|
+
throw new ToolRejectionError("draft07-pattern-backreference");
|
|
143
|
+
if (!legacyKeywords.has(keyword))
|
|
144
|
+
throw new ToolRejectionError("draft07-keyword");
|
|
145
|
+
if (schemaMaps.has(keyword)) {
|
|
146
|
+
return [
|
|
147
|
+
keyword,
|
|
148
|
+
Object.fromEntries(
|
|
149
|
+
Object.entries(child as Record<string, unknown>).map(
|
|
150
|
+
([name, nested]) => [name, normalizeLegacy(nested)],
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
if (schemaArrays.has(keyword))
|
|
156
|
+
return [
|
|
157
|
+
keyword,
|
|
158
|
+
(child as unknown[]).map((item) => normalizeLegacy(item)),
|
|
159
|
+
];
|
|
160
|
+
if (schemaValues.has(keyword) || keyword === "items")
|
|
161
|
+
return [keyword, normalizeLegacy(child)];
|
|
162
|
+
return [keyword, child];
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
if (
|
|
166
|
+
normalized.minLength !== undefined ||
|
|
167
|
+
normalized.maxLength !== undefined
|
|
168
|
+
) {
|
|
169
|
+
const minimum = (normalized.minLength ?? 0) as number;
|
|
170
|
+
const maximum = normalized.maxLength as number | undefined;
|
|
171
|
+
if (
|
|
172
|
+
!Number.isSafeInteger(minimum) ||
|
|
173
|
+
(maximum !== undefined && !Number.isSafeInteger(maximum))
|
|
174
|
+
)
|
|
175
|
+
throw new ToolRejectionError("draft07-length");
|
|
176
|
+
// TypeBox counts grapheme clusters for min/maxLength, not JSON Schema code
|
|
177
|
+
// points. Its Unicode regex engine does count code points, including astral
|
|
178
|
+
// characters. Match all characters (also newlines), and preserve any existing
|
|
179
|
+
// pattern/allOf rather than overwriting a constraint. Bounds without type
|
|
180
|
+
// must still accept non-strings, including when the bounds contradict.
|
|
181
|
+
const pattern =
|
|
182
|
+
maximum !== undefined && minimum > maximum
|
|
183
|
+
? "(?!)"
|
|
184
|
+
: `^[\\s\\S]{${minimum},${maximum ?? ""}}(?![\\s\\S])`;
|
|
185
|
+
delete normalized.minLength;
|
|
186
|
+
delete normalized.maxLength;
|
|
187
|
+
if (normalized.pattern === undefined) normalized.pattern = pattern;
|
|
188
|
+
else
|
|
189
|
+
normalized.allOf = [
|
|
190
|
+
...((normalized.allOf as unknown[] | undefined) ?? []),
|
|
191
|
+
{ pattern },
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
const properties = normalized.properties as
|
|
195
|
+
| Record<string, unknown>
|
|
196
|
+
| undefined;
|
|
197
|
+
if (
|
|
198
|
+
normalized.additionalProperties === false &&
|
|
199
|
+
properties !== undefined &&
|
|
200
|
+
normalized.patternProperties === undefined &&
|
|
201
|
+
Array.isArray(normalized.required) &&
|
|
202
|
+
Object.keys(properties).length === normalized.required.length &&
|
|
203
|
+
normalized.required.some((name) => !Object.hasOwn(properties, name))
|
|
204
|
+
) {
|
|
205
|
+
// TypeBox's fast path compares key counts, not sets. An empty pattern map
|
|
206
|
+
// changes no constraints but selects its correct standard property check.
|
|
207
|
+
normalized.patternProperties = {};
|
|
208
|
+
}
|
|
209
|
+
return normalized;
|
|
210
|
+
}
|
|
211
|
+
|
|
30
212
|
function checkSchemaSupport(value: unknown): void {
|
|
31
213
|
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
32
214
|
const schema = value as Record<string, unknown>;
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// ignore $ref siblings). Reject legacy dialects, including embedded resources,
|
|
36
|
-
// rather than misrepresent them as supported or silently relabeling the schema.
|
|
215
|
+
// A 2020-12 document cannot silently opt an embedded resource into legacy
|
|
216
|
+
// semantics. Draft-07 normalization is intentionally limited to whole schemas.
|
|
37
217
|
if (
|
|
38
218
|
schema.$schema !== undefined &&
|
|
39
219
|
(typeof schema.$schema !== "string" ||
|
|
40
220
|
schema.$schema.replace(/#$/, "") !== dialect)
|
|
41
221
|
) {
|
|
42
|
-
throw new
|
|
222
|
+
throw new ToolRejectionError("unsupported-dialect");
|
|
43
223
|
}
|
|
44
224
|
for (const keyword of ["$ref", "$dynamicRef", "$recursiveRef"]) {
|
|
45
225
|
const reference = schema[keyword];
|
|
46
226
|
if (typeof reference === "string" && !reference.startsWith("#")) {
|
|
47
|
-
throw new
|
|
227
|
+
throw new ToolRejectionError("external-reference");
|
|
48
228
|
}
|
|
49
229
|
}
|
|
50
|
-
// const/enum/default/examples and unknown annotation keywords contain instance
|
|
51
|
-
// data, not schemas: a literal {$ref: 'https://...'} there must remain legal.
|
|
52
230
|
for (const [keyword, child] of Object.entries(schema)) {
|
|
53
231
|
if (schemaMaps.has(keyword) || keyword === "dependencies") {
|
|
54
232
|
for (const nested of Object.values(child as Record<string, unknown>))
|
|
@@ -60,28 +238,51 @@ function checkSchemaSupport(value: unknown): void {
|
|
|
60
238
|
}
|
|
61
239
|
}
|
|
62
240
|
|
|
63
|
-
|
|
64
|
-
const serialized = JSON.stringify(schema);
|
|
65
|
-
if (Buffer.byteLength(serialized) > 64 * 1024)
|
|
66
|
-
throw new Error("MCP schema exceeds 64 KiB.");
|
|
241
|
+
function checkLimits(schema: unknown): void {
|
|
67
242
|
const checkDepth = (value: unknown, depth: number): void => {
|
|
68
|
-
if (depth > 64) throw new
|
|
243
|
+
if (depth > 64) throw new ToolRejectionError("schema-too-deep");
|
|
69
244
|
if (!value || typeof value !== "object") return;
|
|
70
245
|
for (const child of Object.values(value)) checkDepth(child, depth + 1);
|
|
71
246
|
};
|
|
72
247
|
checkDepth(schema, 0);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
248
|
+
if (Buffer.byteLength(JSON.stringify(schema)) > 64 * 1024)
|
|
249
|
+
throw new ToolRejectionError("schema-too-large");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function prepareSchema(schema: Record<string, unknown>) {
|
|
253
|
+
try {
|
|
254
|
+
checkLimits(schema);
|
|
255
|
+
const declared = schema.$schema;
|
|
256
|
+
const uri =
|
|
257
|
+
typeof declared === "string" ? declared.replace(/#$/, "") : declared;
|
|
258
|
+
const legacy =
|
|
259
|
+
uri === legacyDialect || uri === legacyDialect.replace("http:", "https:");
|
|
260
|
+
if (declared !== undefined && !legacy && uri !== dialect)
|
|
261
|
+
throw new ToolRejectionError("unsupported-dialect");
|
|
262
|
+
if (legacy && legacySyntax.validate(legacyDialect, schema) !== true)
|
|
263
|
+
throw new ToolRejectionError("invalid-schema");
|
|
264
|
+
const parameters = (
|
|
265
|
+
legacy ? normalizeLegacy(schema, true) : schema
|
|
266
|
+
) as TSchema;
|
|
267
|
+
if (legacy) checkLimits(parameters);
|
|
268
|
+
// TypeBox compilation is not syntax validation. Use only bundled meta-schemas,
|
|
269
|
+
// never an untrusted $schema URI, both before and after normalization.
|
|
270
|
+
if (syntax.validate(dialect, parameters) !== true)
|
|
271
|
+
throw new ToolRejectionError("invalid-schema");
|
|
272
|
+
checkSchemaSupport(parameters);
|
|
273
|
+
return { parameters, validator: Compile(parameters) };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof ToolRejectionError) throw error;
|
|
276
|
+
throw new ToolRejectionError("invalid-schema");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function compileSchema(schema: Record<string, unknown>) {
|
|
281
|
+
return prepareSchema(schema).validator;
|
|
81
282
|
}
|
|
82
283
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
284
|
+
// Input exposure and SDK/output validation share normalization and the same
|
|
285
|
+
// validator family. A private legacy validator cannot repair Pi's native schema.
|
|
85
286
|
export const schemaValidator: jsonSchemaValidator = {
|
|
86
287
|
getValidator<T>(schema: Record<string, unknown>) {
|
|
87
288
|
const validator = compileSchema(schema);
|