@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 +101 -45
- package/mcp.schema.json +1 -7
- package/package.json +1 -1
- package/src/config.ts +5 -5
- package/src/index.ts +65 -54
- package/src/rejection.ts +53 -0
- package/src/schema.ts +224 -23
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
|
-
|
|
7
|
+
## Usage
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
From the repository root:
|
|
9
|
+
To load this package in an existing Pi installation:
|
|
12
10
|
|
|
13
11
|
```sh
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
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
|
|
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,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
|
-
|
|
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}`,
|
|
250
271
|
description:
|
|
251
272
|
tool.description || `Call ${tool.name} on ${config.name}.`,
|
|
252
273
|
parameters,
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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) {
|
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);
|