@ikuma.cloud/pix-mcp 0.0.2 → 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 CHANGED
@@ -4,28 +4,17 @@ A small Pi MCP adapter: discover tools, load their schemas on demand, then call
4
4
  those tools natively. No scripting engine or model-provider-specific API is
5
5
  required.
6
6
 
7
- **Read [CONTRIBUTING.md](../../CONTRIBUTING.md) before making changes.**
7
+ ## Usage
8
8
 
9
- ## Development
10
-
11
- From the repository root:
9
+ To load this package in an existing Pi installation:
12
10
 
13
11
  ```sh
14
- mise run mcp:dev
15
- mise run test --project pix-mcp
16
- mise run check
12
+ pi -e /absolute/path/to/pix/packages/mcp
17
13
  ```
18
14
 
19
- The development task runs from `packages/mcp` and disables other extensions so
20
- another MCP adapter cannot collide with the `mcp` tool or flags. Pass arguments
21
- through the task, for example:
22
-
23
- ```sh
24
- mise run mcp:dev --mcp-config /absolute/path/to/mcp.json
25
- ```
26
-
27
- For an existing Pi installation, load this package with
28
- `pi --no-extensions -e /absolute/path/to/pix/packages/mcp`.
15
+ Disable any other MCP adapter that would collide with the `mcp` tool or flags,
16
+ but keep any permission-control extensions enabled. Review the configuration
17
+ and trust requirements below before connecting servers.
29
18
 
30
19
  ## Configuration and trust
31
20
 
@@ -61,8 +50,7 @@ contact remote services. Configuration trust is not an OS sandbox.
61
50
  "description": "Issue tracking tools",
62
51
  "timeout": 960000,
63
52
  "startupTimeoutMs": 30000,
64
- "catalogTimeoutMs": 30000,
65
- "approve": true
53
+ "catalogTimeoutMs": 30000
66
54
  }
67
55
  }
68
56
  }
@@ -91,7 +79,6 @@ URLs, and HTTP headers.
91
79
  | `description` | Discovery extension: optional summary, truncated to 500 characters |
92
80
  | `startupTimeoutMs` | pix extension: complete connection/initialization handshake deadline; default 30000 |
93
81
  | `catalogTimeoutMs` | pix extension: complete catalog snapshot deadline, including all pages; default 30000 |
94
- | `approve` | pix policy: require confirmation for every invocation, default `true` |
95
82
  | `disabled` | Skip the server's value validation and environment expansion when `true`; unknown fields are still errors |
96
83
 
97
84
  `command`, `args`, `cwd`, `env` values, `url`, and `headers` values expand `${VAR}`
@@ -118,14 +105,14 @@ reports that no valid servers remain. Disabled entries are omitted, not reported
118
105
  as failed connections. Correct the file and reload Pi to retry.
119
106
 
120
107
  Configuration trust still applies before any valid server is started or its
121
- metadata exposed. Per-call approval remains independent of configuration errors.
108
+ metadata exposed.
122
109
 
123
110
  ### Deadlines and migration
124
111
 
125
112
  All three deadline fields accept integers from 1 through 2,147,483,647 milliseconds
126
113
  (Node's timer-safe maximum). Each defaults independently to 30 seconds. Setting
127
114
  `"timeout": 960000` permits a 16-minute tool call without lengthening startup or
128
- discovery. The call clock starts after invocation approval and connection startup;
115
+ discovery. The call clock starts after connection startup;
129
116
  progress does not reset it. Catalog deadlines cover all pages of one snapshot;
130
117
  a subsequent list-change refresh starts a new deadline.
131
118
 
@@ -146,14 +133,22 @@ Upstream proxies and servers may still impose their own limits.
146
133
  The 120-second call ceiling is removed. This is a configuration migration, not a
147
134
  promise to finish a remote operation within its deadline.
148
135
 
149
- ### Invocation approval
136
+ ### Tool-call control
137
+
138
+ Like Pi's built-in tools, loaded MCP tools execute without adapter-specific
139
+ permission prompts in both interactive and headless sessions. Native calls pass
140
+ through Pi's normal `tool_call` and `tool_result` hooks. For approvals or access
141
+ policies, install a Pi extension that handles `tool_call` so it can manage MCP
142
+ and other tools together. Server annotations do not bypass those hooks.
143
+ Configuration trust above is separate: it authorizes startup, not individual calls.
144
+
145
+ **Migration from 0.0.2:** Remove the server-level `approve` field. Entries that
146
+ still contain it are rejected with migration guidance rather than silently
147
+ ignoring an existing policy. If you relied on `approve: true`, configure an
148
+ external permission extension before removing it.
150
149
 
151
- Tool invocation requires confirmation independently of config trust. In headless
152
- mode, calls fail closed unless the reviewed configuration explicitly sets
153
- `"approve": false` for that server. Native calls still pass through Pi's normal
154
- tool hooks. Server annotations never grant permission. Child stderr and raw SDK
155
- errors are not printed because they can contain credentials; debug a failing
156
- server separately in a trusted environment.
150
+ Child stderr and raw SDK errors are not printed because they can contain
151
+ credentials; debug a failing server separately in a trusted environment.
157
152
 
158
153
  ## Discovery and execution
159
154
 
@@ -180,23 +175,83 @@ Selection is tied to the current schema fingerprint: use `search`/`load`, not
180
175
  Pi's generic tool-name toggles, to enable a native MCP tool.
181
176
 
182
177
  Native names include a readable server/tool prefix and a deterministic hash to
183
- avoid normalization collisions. Independent, preapproved native calls can run
184
- concurrently. Calls requiring confirmation run sequentially to avoid overlapping
185
- approval dialogs. Discovered tools stay active until session shutdown or a server
186
- catalog change.
178
+ avoid normalization collisions. Independent native calls can run concurrently;
179
+ permission extensions must coordinate any shared approval UI. Discovered tools
180
+ stay active until session shutdown or a server catalog change.
187
181
  Changed and removed definitions are withdrawn; changed tools require loading
188
182
  again. Unsupported input schemas or metadata, name collisions, and task-only
189
- tools are counted as `unsupportedTools` in discovery results. An unsupported
190
- output schema fails that server's discovery. Losing an established HTTP
191
- notification stream also withdraws tools rather than silently keeping a stale
192
- catalog; servers that decline the optional stream with HTTP 405 remain usable.
193
- Reload Pi to reconnect a failed server or reread configuration.
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.
194
197
 
195
198
  Pi handles provider compatibility. Some providers support transcript-anchored
196
199
  schema additions; others rebuild the tool set and may invalidate prompt caches.
197
200
  For very small catalogs, eager loading would avoid a discovery round trip, but
198
201
  v1 intentionally offers only the deferred mode.
199
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
+
200
255
  ## Output and limits
201
256
 
202
257
  Text, supported images, and structured content are retained. Long text gets a
@@ -217,11 +272,11 @@ servers concurrently. Each catalog is limited to 1000 tools, 100 pagination
217
272
  cursors, and 2 MiB of metadata; individual input/output schemas are limited to
218
273
  64 KiB. Tool names must use 1–128 ASCII letters, digits, underscores, hyphens, or
219
274
  periods; descriptions are limited to 16 KiB. Stdio messages are limited to 16 MiB.
220
- Schemas are syntax-checked before compilation. Only the default MCP dialect,
221
- JSON Schema 2020-12, is supported; explicit legacy dialects (including embedded
222
- resources) are rejected rather than interpreted with incorrect reference
223
- semantics. External schema references are unsupported, but literal `$ref` fields
224
- inside instance data are allowed.
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.
225
280
 
226
281
  The adapter never automatically retries `tools/call`: a timeout or lost response
227
282
  may occur after a mutating operation took effect. Cancellation or expiration
@@ -237,5 +292,6 @@ OAuth, legacy SSE transport, MCP prompts/resources APIs, sampling, elicitation,
237
292
  MCP apps, task execution, semantic search, scripting, config UI, and persistent
238
293
  catalog caching. Use a fuller adapter when those capabilities are required.
239
294
 
240
- Tests use local stdio/HTTP fixture servers and isolated Pi configuration, without
241
- model requests or personal credentials.
295
+ ## Contributing
296
+
297
+ Read [CONTRIBUTING.md](CONTRIBUTING.md) before making changes.
package/mcp.schema.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "pix-mcp configuration",
4
- "description": "A Claude-style connection subset with pix-specific approval and lifecycle options. Environment expansion and HTTP URL/header validation also occur at runtime.",
4
+ "description": "A Claude-style connection subset with pix-specific discovery and lifecycle options. Environment expansion and HTTP URL/header validation also occur at runtime.",
5
5
  "type": "object",
6
6
  "required": ["mcpServers"],
7
7
  "additionalProperties": false,
@@ -43,11 +43,6 @@
43
43
  "$ref": "#/$defs/timeout",
44
44
  "description": "pix extension: one complete catalog snapshot, including all pages."
45
45
  },
46
- "approve": {
47
- "type": "boolean",
48
- "default": true,
49
- "description": "pix extension: require confirmation for every tool invocation."
50
- },
51
46
  "disabled": { "const": false }
52
47
  }
53
48
  },
@@ -108,7 +103,6 @@
108
103
  "timeout": true,
109
104
  "startupTimeoutMs": true,
110
105
  "catalogTimeoutMs": true,
111
- "approve": true,
112
106
  "disabled": { "type": "boolean" }
113
107
  },
114
108
  "if": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikuma.cloud/pix-mcp",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/config.ts CHANGED
@@ -9,7 +9,6 @@ export interface CommonServer {
9
9
  timeout: number;
10
10
  startupTimeoutMs: number;
11
11
  catalogTimeoutMs: number;
12
- approve: boolean;
13
12
  }
14
13
  export type ServerConfig = CommonServer &
15
14
  (
@@ -133,6 +132,11 @@ function parseServer(
133
132
  "timeoutMs",
134
133
  "Removed; use timeout for tool calls, startupTimeoutMs for initialization, and catalogTimeoutMs for discovery (milliseconds).",
135
134
  );
135
+ if (Object.hasOwn(server, "approve"))
136
+ invalid(
137
+ "approve",
138
+ "Removed; delete approve and use a Pi tool_call extension for permission controls.",
139
+ );
136
140
  const allowed = new Set([
137
141
  "type",
138
142
  "command",
@@ -145,7 +149,6 @@ function parseServer(
145
149
  "timeout",
146
150
  "startupTimeoutMs",
147
151
  "catalogTimeoutMs",
148
- "approve",
149
152
  "disabled",
150
153
  ]);
151
154
  if (Object.keys(server).some((key) => !allowed.has(key)))
@@ -156,8 +159,6 @@ function parseServer(
156
159
  if (server.disabled !== undefined && typeof server.disabled !== "boolean")
157
160
  invalid("disabled", "Expected a boolean.");
158
161
  if (server.disabled === true) return undefined;
159
- if (server.approve !== undefined && typeof server.approve !== "boolean")
160
- invalid("approve", "Expected a boolean.");
161
162
  const common: CommonServer = {
162
163
  name,
163
164
  description:
@@ -167,7 +168,6 @@ function parseServer(
167
168
  timeout: timeout(server.timeout, "timeout"),
168
169
  startupTimeoutMs: timeout(server.startupTimeoutMs, "startupTimeoutMs"),
169
170
  catalogTimeoutMs: timeout(server.catalogTimeoutMs, "catalogTimeoutMs"),
170
- approve: server.approve !== false,
171
171
  };
172
172
  const type =
173
173
  server.type === undefined && server.command !== undefined
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import { Type, type TSchema } from "@earendil-works/pi-ai";
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 { compileSchema } from "./schema.ts";
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, number>;
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
- name: connection.config.name,
196
- status: connection.status,
197
- unsupportedTools: owner.rejected.get(connection.config.name) ?? 0,
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,73 +244,49 @@ 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
- let rejected = 0;
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
- !/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name) ||
253
+ !validName ||
232
254
  Buffer.byteLength(tool.description ?? "") > 16 * 1024
233
255
  ) {
234
- throw new Error("Unsupported tool metadata");
256
+ throw new ToolRejectionError("invalid-tool-metadata");
235
257
  }
236
258
  if (tool.execution?.taskSupport === "required")
237
- throw new Error("Tasks unsupported");
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 Error("Tool name collision");
243
- const validator = compileSchema(tool.inputSchema);
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}`,
250
271
  description:
251
272
  tool.description || `Call ${tool.name} on ${config.name}.`,
252
273
  parameters,
253
- // Approval dialogs share Pi's UI. Only preapproved calls may overlap.
254
- executionMode: config.approve ? "sequential" : "parallel",
255
- async execute(_id, params, signal, _onUpdate, ctx) {
274
+ executionMode: "parallel",
275
+ async execute(_id, params, signal) {
256
276
  current(owner);
257
277
  signal?.throwIfAborted();
258
- const verify = () => {
259
- current(owner);
260
- if (
261
- owner.entries.get(item.name)?.fingerprint !==
262
- item.fingerprint ||
263
- owner.loaded.get(item.name) !== item.fingerprint ||
264
- !pi.getActiveTools().includes(item.name)
265
- ) {
266
- throw new Error(
267
- "MCP tool is unavailable or changed. Discover it again.",
268
- );
269
- }
270
- };
271
- verify();
272
- if (!validator.Check(params))
273
- throw new Error("Arguments do not match the MCP input schema.");
274
- if (config.approve) {
275
- if (!ctx.hasUI)
276
- throw new Error(
277
- "MCP approval required. Use an interactive session or explicitly set approve:false for this trusted server.",
278
- );
279
- const input = JSON.stringify(params, null, 2);
280
- if (input.length > 8000)
281
- throw new Error(
282
- "MCP arguments are too large for the approval dialog.",
283
- );
284
- const allowed = await ctx.ui.confirm(
285
- `Call ${config.name}/${tool.name}?`,
286
- input,
287
- signal ? { signal } : undefined,
278
+ if (
279
+ owner.entries.get(item.name)?.fingerprint !==
280
+ item.fingerprint ||
281
+ owner.loaded.get(item.name) !== item.fingerprint ||
282
+ !pi.getActiveTools().includes(item.name)
283
+ ) {
284
+ throw new Error(
285
+ "MCP tool is unavailable or changed. Discover it again.",
288
286
  );
289
- if (!allowed) throw new Error("MCP tool call denied.");
290
287
  }
291
- signal?.throwIfAborted();
292
- verify();
288
+ if (!validator.Check(params))
289
+ throw new Error("Arguments do not match the MCP input schema.");
293
290
  const connection = owner.connections.get(config.name);
294
291
  if (!connection) throw new Error("MCP connection unavailable.");
295
292
  const result = await connection.call(
@@ -311,8 +308,22 @@ export default function mcp(pi: ExtensionAPI) {
311
308
  owned.add(item.name);
312
309
  }
313
310
  next.set(item.name, item);
314
- } catch {
315
- 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
+ }
316
327
  }
317
328
  }
318
329
  for (const [name, old] of previous) {
@@ -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
- // Pi validates the exposed schema with TypeBox before execute. A different
34
- // private validator cannot repair legacy semantics there (e.g. draft-07 must
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 Error("Invalid or unsupported MCP JSON Schema dialect.");
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 Error("External MCP schema references are unsupported.");
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
- export function compileSchema(schema: Record<string, unknown>) {
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 Error("MCP schema is too deeply nested.");
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
- // TypeBox code generation is not schema syntax validation: unknown type names
74
- // can compile into always-true checks. Validate the dialect's meta-schema first.
75
- // Validate against the supported meta-schema directly, without consulting an
76
- // untrusted $schema URI or attempting to load a remote meta-schema.
77
- if (syntax.validate(dialect, schema) !== true)
78
- throw new Error("Invalid or unsupported MCP JSON Schema.");
79
- checkSchemaSupport(schema);
80
- return Compile(schema as TSchema);
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
- // Use Pi's validator family for SDK output validation too, rather than silently
84
- // downgrading MCP's default 2020-12 schemas to the SDK's default draft-07 validator.
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);