@ikuma.cloud/pix-mcp 0.0.1 → 0.0.3

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:
12
-
13
- ```sh
14
- mise run mcp:dev
15
- mise run test --project pix-mcp
16
- mise run check
17
- ```
18
-
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:
9
+ To load this package in an existing Pi installation:
22
10
 
23
11
  ```sh
24
- mise run mcp:dev --mcp-config /absolute/path/to/mcp.json
12
+ pi -e /absolute/path/to/pix/packages/mcp
25
13
  ```
26
14
 
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
 
@@ -59,42 +48,113 @@ contact remote services. Configuration trust is not an OS sandbox.
59
48
  "url": "https://mcp.example.com/mcp",
60
49
  "headers": { "Authorization": "Bearer ${SERVICE_TOKEN}" },
61
50
  "description": "Issue tracking tools",
62
- "timeoutMs": 30000,
63
- "approve": true
51
+ "timeout": 960000,
52
+ "startupTimeoutMs": 30000,
53
+ "catalogTimeoutMs": 30000
64
54
  }
65
55
  }
66
56
  }
67
57
  ```
68
58
 
69
- Supported fields:
59
+ ### Supported configuration
60
+
61
+ MCP standardizes the protocol, not a universal configuration file. This adapter
62
+ supports a [Claude Code-style](https://code.claude.com/docs/en/mcp) connection
63
+ subset, not every client-specific field or transport. OpenCode configuration,
64
+ Cursor's `${env:VAR}` syntax, and other clients' approval policies are not imported
65
+ or translated.
66
+
67
+ The published [JSON Schema](mcp.schema.json) provides editor validation. An
68
+ optional root `$schema` string can reference your installed copy; the adapter
69
+ never fetches that URL. Runtime validation additionally checks expanded strings,
70
+ URLs, and HTTP headers.
70
71
 
71
72
  | Field | Behavior |
72
73
  | --- | --- |
73
- | `type` | `stdio` or `http`; inferred from `command` or `url` when omitted |
74
- | `command`, `args`, `env`, `cwd` | Stdio only; executable and argument array, not a shell command |
75
- | `url`, `headers` | Streamable HTTP only; no SSE fallback or redirect following |
76
- | `description` | Optional short capability summary for discovery |
77
- | `timeoutMs` | Request/discovery timeout, default 30000; allowed range 100–120000 |
78
- | `approve` | Require confirmation for every invocation, default `true` |
79
- | `disabled` | Skip this server when `true` |
80
-
81
- Only `env` and `headers` values expand `${VARIABLE}` references. Missing
82
- variables fail without echoing their values. Stdio inherits the SDK's minimal
83
- platform environment plus explicit `env`, not the entire Pi environment.
84
- Credentials in HTTP URLs are rejected; use headers instead. Unknown fields fail
85
- rather than silently accepting unsupported configuration.
86
-
87
- Tool invocation requires confirmation independently of config trust. In headless
88
- mode, calls fail closed unless the reviewed configuration explicitly sets
89
- `"approve": false` for that server. Native calls still pass through Pi's normal
90
- tool hooks. Server annotations never grant permission. Child stderr and raw SDK
91
- errors are not printed because they can contain credentials; debug a failing
92
- server separately in a trusted environment.
74
+ | `type` | `stdio`, `http`, or `streamable-http` (alias of `http`); only stdio is inferred, when `command` is present |
75
+ | `command`, `args`, `env` | Stdio only; executable and argument array, not a shell command |
76
+ | `url`, `headers` | Streamable HTTP only; explicit `type` required; no legacy SSE fallback or redirect following |
77
+ | `timeout` | Hard deadline for each tool invocation, in milliseconds; default 30000 |
78
+ | `cwd` | Stdio extension: working directory relative to the config directory |
79
+ | `description` | Discovery extension: optional summary, truncated to 500 characters |
80
+ | `startupTimeoutMs` | pix extension: complete connection/initialization handshake deadline; default 30000 |
81
+ | `catalogTimeoutMs` | pix extension: complete catalog snapshot deadline, including all pages; default 30000 |
82
+ | `disabled` | Skip the server's value validation and environment expansion when `true`; unknown fields are still errors |
83
+
84
+ `command`, `args`, `cwd`, `env` values, `url`, and `headers` values expand `${VAR}`
85
+ and `${VAR:-default}`. A default applies only when the variable is unset, not when
86
+ it is an empty string. Expansion is single-pass against Pi's environment; `env`
87
+ entries do not define variables for other entries. Missing variables and invalid
88
+ expanded values fail without echoing credentials. Stdio inherits the SDK's
89
+ minimal platform environment plus explicit `env`, not the entire Pi environment.
90
+ Credentials and fragments in HTTP URLs are rejected; use headers for credentials.
91
+ Unknown fields invalidate their server entry rather than being silently ignored.
92
+
93
+ ### Configuration errors
94
+
95
+ An invalid server entry is skipped without hiding healthy servers. Discovery's
96
+ `servers` list includes a safe diagnostic for each skipped entry; a valid server
97
+ name can still be used with `mcp({ action: "list", server: "name" })` to inspect
98
+ its status. Invalid names are replaced with their one-based entry positions.
99
+ Diagnostics identify supported fields or migration steps without echoing URLs,
100
+ commands, headers, argument values, or environment-variable names.
101
+
102
+ Malformed JSON, invalid root structure, unknown root fields, and the file/server
103
+ count limits remain fatal for the whole file. An invalid-only configuration
104
+ reports that no valid servers remain. Disabled entries are omitted, not reported
105
+ as failed connections. Correct the file and reload Pi to retry.
106
+
107
+ Configuration trust still applies before any valid server is started or its
108
+ metadata exposed.
109
+
110
+ ### Deadlines and migration
111
+
112
+ All three deadline fields accept integers from 1 through 2,147,483,647 milliseconds
113
+ (Node's timer-safe maximum). Each defaults independently to 30 seconds. Setting
114
+ `"timeout": 960000` permits a 16-minute tool call without lengthening startup or
115
+ discovery. The call clock starts after connection startup;
116
+ progress does not reset it. Catalog deadlines cover all pages of one snapshot;
117
+ a subsequent list-change refresh starts a new deadline.
118
+
119
+ HTTP deadlines cover response headers and bodies, including JSON and SSE. MCP
120
+ requests borrow the host's HTTP/proxy routing but override its header/body idle
121
+ limits per request; Pi's global settings and unrelated requests are unchanged.
122
+ Notification GET streams have a bounded header wait but no body-idle deadline.
123
+ Upstream proxies and servers may still impose their own limits.
124
+
125
+ **Breaking changes from 0.0.1:**
126
+
127
+ - Replace `timeoutMs` with `timeout` for tool calls. Set `startupTimeoutMs` and
128
+ `catalogTimeoutMs` separately if their defaults are unsuitable. The removed
129
+ field produces a migration diagnostic; it is not an alias.
130
+ - Add `"type": "http"` to remote entries that previously specified only `url`.
131
+ - Connection strings now expand environment references beyond `env` and `headers`.
132
+
133
+ The 120-second call ceiling is removed. This is a configuration migration, not a
134
+ promise to finish a remote operation within its deadline.
135
+
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.
149
+
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.
93
152
 
94
153
  ## Discovery and execution
95
154
 
96
155
  At session startup, the adapter connects to trusted servers and fetches their
97
- paginated tool catalogs. One failure does not hide tools from other servers.
156
+ paginated tool catalogs. A server's configuration, connection, or discovery
157
+ failure does not hide tools from other servers.
98
158
  Full schemas stay out of model context until selected. **Schema exposure is lazy;
99
159
  initial connections and metadata discovery are not.**
100
160
 
@@ -115,10 +175,9 @@ Selection is tied to the current schema fingerprint: use `search`/`load`, not
115
175
  Pi's generic tool-name toggles, to enable a native MCP tool.
116
176
 
117
177
  Native names include a readable server/tool prefix and a deterministic hash to
118
- avoid normalization collisions. Independent, preapproved native calls can run
119
- concurrently. Calls requiring confirmation run sequentially to avoid overlapping
120
- approval dialogs. Discovered tools stay active until session shutdown or a server
121
- 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.
122
181
  Changed and removed definitions are withdrawn; changed tools require loading
123
182
  again. Unsupported input schemas or metadata, name collisions, and task-only
124
183
  tools are counted as `unsupportedTools` in discovery results. An unsupported
@@ -156,12 +215,13 @@ Schemas are syntax-checked before compilation. Only the default MCP dialect,
156
215
  JSON Schema 2020-12, is supported; explicit legacy dialects (including embedded
157
216
  resources) are rejected rather than interpreted with incorrect reference
158
217
  semantics. External schema references are unsupported, but literal `$ref` fields
159
- inside instance data are allowed. Requests and the entire initialization
160
- handshake use fixed deadlines, not progress-extended timeouts.
218
+ inside instance data are allowed.
161
219
 
162
220
  The adapter never automatically retries `tools/call`: a timeout or lost response
163
- may occur after a mutating operation took effect. Successful output is validated
164
- against the schema captured when the call began; MCP error results are exempt
221
+ may occur after a mutating operation took effect. Cancellation or expiration
222
+ aborts only the affected HTTP request and sends a best-effort MCP cancellation
223
+ notification; concurrent sibling calls remain usable. Successful output is
224
+ validated against the schema captured when the call began; MCP error results are exempt
165
225
  from that success schema. Cancellation is best-effort at the server and does not
166
226
  roll back effects.
167
227
 
@@ -171,5 +231,6 @@ OAuth, legacy SSE transport, MCP prompts/resources APIs, sampling, elicitation,
171
231
  MCP apps, task execution, semantic search, scripting, config UI, and persistent
172
232
  catalog caching. Use a fuller adapter when those capabilities are required.
173
233
 
174
- Tests use local stdio/HTTP fixture servers and isolated Pi configuration, without
175
- model requests or personal credentials.
234
+ ## Contributing
235
+
236
+ Read [CONTRIBUTING.md](CONTRIBUTING.md) before making changes.
@@ -0,0 +1,120 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "pix-mcp configuration",
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
+ "type": "object",
6
+ "required": ["mcpServers"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "$ref": "#/$defs/text",
11
+ "description": "Editor hint only; never fetched by pix-mcp."
12
+ },
13
+ "mcpServers": {
14
+ "type": "object",
15
+ "maxProperties": 32,
16
+ "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,48}$" },
17
+ "additionalProperties": { "$ref": "#/$defs/server" }
18
+ }
19
+ },
20
+ "$defs": {
21
+ "text": { "type": "string", "minLength": 1, "pattern": "^[^\u0000]*$" },
22
+ "string": { "type": "string", "pattern": "^[^\u0000]*$" },
23
+ "timeout": {
24
+ "type": "integer",
25
+ "minimum": 1,
26
+ "maximum": 2147483647,
27
+ "default": 30000,
28
+ "description": "Fixed deadline in milliseconds; progress does not extend it."
29
+ },
30
+ "common": {
31
+ "type": "object",
32
+ "properties": {
33
+ "description": { "$ref": "#/$defs/text" },
34
+ "timeout": {
35
+ "$ref": "#/$defs/timeout",
36
+ "description": "Hard deadline for one tool invocation."
37
+ },
38
+ "startupTimeoutMs": {
39
+ "$ref": "#/$defs/timeout",
40
+ "description": "pix extension: complete transport connection and initialization handshake."
41
+ },
42
+ "catalogTimeoutMs": {
43
+ "$ref": "#/$defs/timeout",
44
+ "description": "pix extension: one complete catalog snapshot, including all pages."
45
+ },
46
+ "disabled": { "const": false }
47
+ }
48
+ },
49
+ "stdio": {
50
+ "allOf": [{ "$ref": "#/$defs/common" }],
51
+ "type": "object",
52
+ "required": ["command"],
53
+ "properties": {
54
+ "type": { "const": "stdio" },
55
+ "command": { "$ref": "#/$defs/text" },
56
+ "args": { "type": "array", "items": { "$ref": "#/$defs/string" } },
57
+ "cwd": {
58
+ "$ref": "#/$defs/text",
59
+ "description": "Resolved relative to the configuration directory."
60
+ },
61
+ "env": {
62
+ "type": "object",
63
+ "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_-]*$" },
64
+ "additionalProperties": { "$ref": "#/$defs/string" }
65
+ }
66
+ },
67
+ "not": { "anyOf": [{ "required": ["url"] }, { "required": ["headers"] }] }
68
+ },
69
+ "http": {
70
+ "allOf": [{ "$ref": "#/$defs/common" }],
71
+ "type": "object",
72
+ "required": ["type", "url"],
73
+ "properties": {
74
+ "type": { "enum": ["http", "streamable-http"] },
75
+ "url": { "$ref": "#/$defs/text" },
76
+ "headers": {
77
+ "type": "object",
78
+ "propertyNames": { "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" },
79
+ "additionalProperties": { "$ref": "#/$defs/string" }
80
+ }
81
+ },
82
+ "not": {
83
+ "anyOf": [
84
+ { "required": ["command"] },
85
+ { "required": ["args"] },
86
+ { "required": ["env"] },
87
+ { "required": ["cwd"] }
88
+ ]
89
+ }
90
+ },
91
+ "server": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "type": true,
96
+ "command": true,
97
+ "args": true,
98
+ "env": true,
99
+ "cwd": true,
100
+ "url": true,
101
+ "headers": true,
102
+ "description": true,
103
+ "timeout": true,
104
+ "startupTimeoutMs": true,
105
+ "catalogTimeoutMs": true,
106
+ "disabled": { "type": "boolean" }
107
+ },
108
+ "if": {
109
+ "required": ["disabled"],
110
+ "properties": { "disabled": { "const": true } }
111
+ },
112
+ "then": {
113
+ "description": "Disabled entries skip value validation and environment expansion, but unknown fields remain errors."
114
+ },
115
+ "else": {
116
+ "oneOf": [{ "$ref": "#/$defs/stdio" }, { "$ref": "#/$defs/http" }]
117
+ }
118
+ }
119
+ }
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikuma.cloud/pix-mcp",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -11,6 +11,7 @@
11
11
  ],
12
12
  "files": [
13
13
  "src",
14
+ "mcp.schema.json",
14
15
  "!src/**/*.test.ts",
15
16
  "!src/fixtures"
16
17
  ],
@@ -21,7 +22,8 @@
21
22
  },
22
23
  "dependencies": {
23
24
  "@modelcontextprotocol/sdk": "1.30.0",
24
- "ajv": "8.20.0"
25
+ "ajv": "8.20.0",
26
+ "undici": "8.10.2"
25
27
  },
26
28
  "peerDependencies": {
27
29
  "@earendil-works/pi-ai": "*",
package/src/client.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { fetch as undiciFetch, getGlobalDispatcher } from "undici";
1
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
4
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
5
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -40,6 +42,7 @@ export class Connection {
40
42
  #stopped = false;
41
43
  #changed: (tools: Tool[]) => void;
42
44
  #outputValidators = new Map<string, ReturnType<typeof compileSchema>>();
45
+ #operation = new AsyncLocalStorage<AbortSignal>();
43
46
 
44
47
  constructor(config: ServerConfig, changed: (tools: Tool[]) => void) {
45
48
  this.config = config;
@@ -96,44 +99,98 @@ export class Connection {
96
99
  void this.client.close().catch(() => {});
97
100
  }
98
101
 
102
+ async #deadline<T>(
103
+ timeout: number,
104
+ signal: AbortSignal | undefined,
105
+ action: (signal: AbortSignal) => Promise<T>,
106
+ ): Promise<T> {
107
+ const outer = signal
108
+ ? AbortSignal.any([signal, this.#lifetime.signal])
109
+ : this.#lifetime.signal;
110
+ outer.throwIfAborted();
111
+ const deadline = new AbortController();
112
+ const transport = new AbortController();
113
+ const abort = () => deadline.abort(outer.reason);
114
+ outer.addEventListener("abort", abort, { once: true });
115
+ const timer = setTimeout(
116
+ () => deadline.abort(new Error("MCP operation timed out.")),
117
+ timeout,
118
+ );
119
+ try {
120
+ // SDK 1.30 does not forward request signals to HTTP fetch. Async context
121
+ // keeps concurrent requests isolated without relying on private SDK IDs.
122
+ return await this.#operation.run(
123
+ AbortSignal.any([deadline.signal, transport.signal]),
124
+ () => action(deadline.signal),
125
+ );
126
+ } finally {
127
+ clearTimeout(timer);
128
+ outer.removeEventListener("abort", abort);
129
+ // Use a separate signal: aborting the SDK signal after success would send
130
+ // a spurious cancellation for an already-completed request in SDK 1.30.
131
+ transport.abort();
132
+ }
133
+ }
134
+
99
135
  async #fetch(
100
136
  url: Parameters<typeof fetch>[0],
101
137
  init: Parameters<typeof fetch>[1],
102
138
  ) {
103
- // Teardown must still send DELETE after the connection lifetime is aborted.
104
- if (init?.method === "DELETE")
105
- return globalThis.fetch(url, {
139
+ // Borrow the host's routing/proxy dispatcher, but override idle limits only
140
+ // for this request. Our AbortSignals bound headers AND bodies. Use the same
141
+ // Undici implementation as the dispatcher to preserve decompression behavior.
142
+ const dispatcher = getGlobalDispatcher().compose(
143
+ (dispatch) => (options, handler) =>
144
+ dispatch({ ...options, headersTimeout: 0, bodyTimeout: 0 }, handler),
145
+ );
146
+ const fetch = (signal: AbortSignal) => {
147
+ const request = {
106
148
  ...init,
107
- redirect: "error",
108
- signal: AbortSignal.timeout(2000),
109
- });
149
+ redirect: "error" as const,
150
+ signal,
151
+ dispatcher,
152
+ };
153
+ // SDK/global fetch and npm Undici expose different DOM type declarations;
154
+ // the SDK sends a URL and a serialized JSON body, compatible with both.
155
+ return undiciFetch(
156
+ url as Parameters<typeof undiciFetch>[0],
157
+ request as Parameters<typeof undiciFetch>[1],
158
+ ) as unknown as Promise<Response>;
159
+ };
160
+ // Teardown must still send DELETE after the connection lifetime is aborted.
161
+ if (init?.method === "DELETE") return fetch(AbortSignal.timeout(2000));
110
162
  const signals = [
111
163
  this.#lifetime.signal,
112
164
  ...(init?.signal ? [init.signal] : []),
113
165
  ];
114
166
  if (init?.method !== "GET") {
115
- return globalThis.fetch(url, {
116
- ...init,
117
- redirect: "error",
118
- signal: AbortSignal.any([
119
- ...signals,
120
- AbortSignal.timeout(this.config.timeoutMs),
121
- ]),
122
- });
167
+ // This body is generated by the SDK. Only our requests and the initialized
168
+ // handshake notification belong to an operation. Incoming stream callbacks
169
+ // retain their creator's async context: replies to server pings must not
170
+ // inherit the already-finished handshake's abort signal. Cancellation and
171
+ // other control messages also need their own short, lifetime-bound budget.
172
+ const message =
173
+ typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
174
+ const scoped =
175
+ message &&
176
+ (("method" in message && "id" in message) ||
177
+ message.method === "notifications/initialized");
178
+ const operation = scoped ? this.#operation.getStore() : undefined;
179
+ return fetch(
180
+ AbortSignal.any([...signals, operation ?? AbortSignal.timeout(2000)]),
181
+ );
123
182
  }
124
183
  // GET is optional (405 is valid), but an established notification stream
125
184
  // cannot disappear silently: with retries disabled its catalog is stale.
126
185
  const headersDeadline = new AbortController();
127
186
  const timer = setTimeout(
128
187
  () => headersDeadline.abort(),
129
- this.config.timeoutMs,
188
+ this.config.startupTimeoutMs,
130
189
  );
131
190
  try {
132
- const response = await globalThis.fetch(url, {
133
- ...init,
134
- redirect: "error",
135
- signal: AbortSignal.any([...signals, headersDeadline.signal]),
136
- });
191
+ const response = await fetch(
192
+ AbortSignal.any([...signals, headersDeadline.signal]),
193
+ );
137
194
  if (response.status === 405) return response;
138
195
  if (
139
196
  !response.ok ||
@@ -177,13 +234,18 @@ export class Connection {
177
234
  // send. Bound the whole handshake and close the transport to release it.
178
235
  const handshakeTimer = setTimeout(
179
236
  () => this.#disconnect(),
180
- this.config.timeoutMs,
237
+ this.config.startupTimeoutMs,
181
238
  );
182
239
  try {
183
- await this.client.connect(this.#transport as Transport, {
184
- signal: this.#lifetime.signal,
185
- timeout: this.config.timeoutMs,
186
- });
240
+ await this.#deadline(
241
+ this.config.startupTimeoutMs,
242
+ undefined,
243
+ (signal) =>
244
+ this.client.connect(this.#transport as Transport, {
245
+ signal,
246
+ timeout: this.config.startupTimeoutMs,
247
+ }),
248
+ );
187
249
  } finally {
188
250
  clearTimeout(handshakeTimer);
189
251
  }
@@ -220,55 +282,66 @@ export class Connection {
220
282
  const names = new Set<string>();
221
283
  let cursor: string | undefined;
222
284
  let bytes = 0;
223
- const signal = AbortSignal.any([
224
- this.#lifetime.signal,
225
- AbortSignal.timeout(this.config.timeoutMs),
226
- ]);
227
- if (this.client.getServerCapabilities()?.tools) {
228
- do {
229
- const page = await this.client.request(
230
- {
231
- method: "tools/list",
232
- params: cursor === undefined ? {} : { cursor },
233
- },
234
- catalogResultSchema,
235
- {
236
- signal,
237
- timeout: this.config.timeoutMs,
238
- },
239
- );
240
- bytes += Buffer.byteLength(JSON.stringify(page));
241
- if (
242
- bytes > 2 * 1024 * 1024 ||
243
- tools.length + page.tools.length > 1000
244
- )
245
- throw new Error("Catalog limit");
246
- for (const tool of page.tools) {
247
- if (names.has(tool.name)) throw new Error("Duplicate tool name");
248
- names.add(tool.name);
249
- tools.push(tool);
285
+ await this.#deadline(
286
+ this.config.catalogTimeoutMs,
287
+ undefined,
288
+ async (signal) => {
289
+ if (this.client.getServerCapabilities()?.tools) {
290
+ do {
291
+ const page = await this.#deadline(
292
+ this.config.catalogTimeoutMs,
293
+ signal,
294
+ (requestSignal) =>
295
+ this.client.request(
296
+ {
297
+ method: "tools/list",
298
+ params: cursor === undefined ? {} : { cursor },
299
+ },
300
+ catalogResultSchema,
301
+ {
302
+ signal: requestSignal,
303
+ timeout: this.config.catalogTimeoutMs,
304
+ },
305
+ ),
306
+ );
307
+ bytes += Buffer.byteLength(JSON.stringify(page));
308
+ if (
309
+ bytes > 2 * 1024 * 1024 ||
310
+ tools.length + page.tools.length > 1000
311
+ )
312
+ throw new Error("Catalog limit");
313
+ for (const tool of page.tools) {
314
+ if (names.has(tool.name))
315
+ throw new Error("Duplicate tool name");
316
+ names.add(tool.name);
317
+ tools.push(tool);
318
+ }
319
+ cursor = page.nextCursor;
320
+ if (cursor !== undefined) {
321
+ if (cursors.has(cursor) || cursors.size >= 100)
322
+ throw new Error("Invalid pagination");
323
+ cursors.add(cursor);
324
+ }
325
+ } while (cursor !== undefined);
250
326
  }
251
- cursor = page.nextCursor;
252
- if (cursor !== undefined) {
253
- if (cursors.has(cursor) || cursors.size >= 100)
254
- throw new Error("Invalid pagination");
255
- cursors.add(cursor);
327
+ // Keep a complete, atomic validator snapshot rather than the SDK's
328
+ // per-page mutable cache, including when a catalog changes during a call.
329
+ const validators = new Map<
330
+ string,
331
+ ReturnType<typeof compileSchema>
332
+ >();
333
+ for (const tool of tools) {
334
+ if (tool.outputSchema)
335
+ validators.set(tool.name, compileSchema(tool.outputSchema));
256
336
  }
257
- } while (cursor !== undefined);
258
- }
259
- // Keep a complete, atomic validator snapshot rather than the SDK's
260
- // per-page mutable cache, including when a catalog changes during a call.
261
- const validators = new Map<string, ReturnType<typeof compileSchema>>();
262
- for (const tool of tools) {
263
- if (tool.outputSchema)
264
- validators.set(tool.name, compileSchema(tool.outputSchema));
265
- }
266
- signal.throwIfAborted();
267
- if (!this.#stopped) {
268
- this.#outputValidators = validators;
269
- this.status = "Connected";
270
- this.#changed(tools);
271
- }
337
+ signal.throwIfAborted();
338
+ if (!this.#stopped) {
339
+ this.#outputValidators = validators;
340
+ this.status = "Connected";
341
+ this.#changed(tools);
342
+ }
343
+ },
344
+ );
272
345
  }
273
346
  } catch {
274
347
  if (!this.#stopped) {
@@ -295,14 +368,19 @@ export class Connection {
295
368
  // validates error payloads against success schemas. Use one typed request,
296
369
  // then our captured validator; task-only tools are excluded during discovery.
297
370
  // Never retry: a lost response does not prove the operation did not run.
298
- const result = await this.client.request(
299
- { method: "tools/call", params: { name, arguments: args } },
300
- CallToolResultSchema,
301
- {
302
- signal: combined,
303
- timeout: this.config.timeoutMs,
304
- resetTimeoutOnProgress: false,
305
- },
371
+ const result = await this.#deadline(
372
+ this.config.timeout,
373
+ combined,
374
+ (requestSignal) =>
375
+ this.client.request(
376
+ { method: "tools/call", params: { name, arguments: args } },
377
+ CallToolResultSchema,
378
+ {
379
+ signal: requestSignal,
380
+ timeout: this.config.timeout,
381
+ resetTimeoutOnProgress: false,
382
+ },
383
+ ),
306
384
  );
307
385
  const parsed = CallToolResultSchema.parse(result);
308
386
  if (
package/src/config.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
3
 
4
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
5
+
4
6
  export interface CommonServer {
5
7
  name: string;
6
8
  description: string;
7
- timeoutMs: number;
8
- approve: boolean;
9
+ timeout: number;
10
+ startupTimeoutMs: number;
11
+ catalogTimeoutMs: number;
9
12
  }
10
13
  export type ServerConfig = CommonServer &
11
14
  (
@@ -18,51 +21,218 @@ export type ServerConfig = CommonServer &
18
21
  }
19
22
  | { type: "http"; url: string; headers: Record<string, string> }
20
23
  );
24
+ export interface ConfigIssue {
25
+ name: string;
26
+ field?: string;
27
+ message: string;
28
+ }
21
29
  export interface Config {
22
30
  path: string;
23
31
  servers: ServerConfig[];
32
+ issues: ConfigIssue[];
24
33
  }
25
34
 
26
- function invalid(): never {
27
- // Never echo config values: URLs, headers, arguments, and even parse errors can contain secrets.
28
- throw new Error(
29
- "Invalid MCP configuration. See the pix-mcp README for supported fields.",
30
- );
35
+ class ConfigError extends Error {
36
+ readonly field: string | undefined;
37
+ constructor(message: string, field?: string) {
38
+ // Only fixed messages and known field names belong here, never input values.
39
+ super(`Invalid MCP configuration. ${field ? `${field}: ` : ""}${message}`);
40
+ this.field = field;
41
+ }
42
+ }
43
+ function invalid(
44
+ field?: string,
45
+ message = "See the pix-mcp README for supported fields.",
46
+ ): never {
47
+ throw new ConfigError(message, field);
31
48
  }
32
- function object(value: unknown): Record<string, unknown> {
33
- if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
49
+ function object(value: unknown, field?: string): Record<string, unknown> {
50
+ if (!value || typeof value !== "object" || Array.isArray(value))
51
+ invalid(field, "Expected an object.");
34
52
  return value as Record<string, unknown>;
35
53
  }
36
- function text(value: unknown): string {
37
- if (typeof value !== "string" || value.length === 0 || value.includes("\0"))
38
- invalid();
54
+ function text(value: unknown, field: string, empty = false): string {
55
+ if (
56
+ typeof value !== "string" ||
57
+ (!empty && value.length === 0) ||
58
+ value.includes("\0")
59
+ )
60
+ invalid(field, "Expected a valid string.");
39
61
  return value;
40
62
  }
63
+ function expand(
64
+ value: unknown,
65
+ env: NodeJS.ProcessEnv,
66
+ field: string,
67
+ empty = false,
68
+ ): string {
69
+ const expanded = text(value, field, empty).replace(
70
+ /\$\{([^}]*)\}|\$\{/g,
71
+ (_match, expression: string | undefined) => {
72
+ const match = expression?.match(
73
+ /^([A-Za-z_][A-Za-z0-9_]*)(?::-([\s\S]*))?$/,
74
+ );
75
+ if (!match?.[1])
76
+ invalid(
77
+ field,
78
+ `Use \${VAR} or \${VAR:-default} environment references.`,
79
+ );
80
+ const replacement =
81
+ (Object.hasOwn(env, match[1]) ? env[match[1]] : undefined) ?? match[2];
82
+ if (replacement === undefined)
83
+ invalid(field, "References an unset environment variable.");
84
+ return replacement;
85
+ },
86
+ );
87
+ return text(expanded, field, empty);
88
+ }
41
89
  function strings(
42
90
  value: unknown,
43
91
  env: NodeJS.ProcessEnv,
44
- kind: "env" | "headers" = "env",
92
+ field: "env" | "headers",
45
93
  ): Record<string, string> {
46
94
  const result: Record<string, string> = Object.create(null);
47
- for (const [key, entry] of Object.entries(object(value))) {
48
- // HTTP field names use token grammar, not environment-variable grammar;
49
- // the Headers constructor below validates them without leaking their values.
50
- if (kind === "env" && !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) invalid();
51
- if (typeof entry !== "string" || entry.includes("\0")) invalid();
52
- result[key] = entry.replace(
53
- /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
54
- (_match, name: string) => {
55
- const replacement = env[name];
56
- if (replacement === undefined)
57
- throw new Error(
58
- "MCP configuration references an unset environment variable.",
59
- );
60
- return replacement;
61
- },
62
- );
95
+ for (const [key, entry] of Object.entries(object(value, field))) {
96
+ if (field === "env" && !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key))
97
+ invalid(field, "Invalid environment variable name.");
98
+ result[key] = expand(entry, env, field, true);
63
99
  }
64
100
  return result;
65
101
  }
102
+ function timeout(value: unknown, field: string): number {
103
+ if (value === undefined) return 30_000;
104
+ // Node timers overflow above this bound and can fire almost immediately.
105
+ if (
106
+ typeof value !== "number" ||
107
+ !Number.isInteger(value) ||
108
+ value < 1 ||
109
+ value > MAX_TIMEOUT_MS
110
+ )
111
+ invalid(
112
+ field,
113
+ `Expected an integer from 1 through ${MAX_TIMEOUT_MS} milliseconds.`,
114
+ );
115
+ return value;
116
+ }
117
+
118
+ function parseServer(
119
+ name: string,
120
+ raw: unknown,
121
+ path: string,
122
+ env: NodeJS.ProcessEnv,
123
+ ): ServerConfig | undefined {
124
+ if (!/^[A-Za-z0-9_-]{1,48}$/.test(name))
125
+ invalid(
126
+ undefined,
127
+ "Server names must contain 1–48 ASCII letters, digits, underscores, or hyphens.",
128
+ );
129
+ const server = object(raw);
130
+ if (Object.hasOwn(server, "timeoutMs"))
131
+ invalid(
132
+ "timeoutMs",
133
+ "Removed; use timeout for tool calls, startupTimeoutMs for initialization, and catalogTimeoutMs for discovery (milliseconds).",
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
+ );
140
+ const allowed = new Set([
141
+ "type",
142
+ "command",
143
+ "args",
144
+ "env",
145
+ "cwd",
146
+ "url",
147
+ "headers",
148
+ "description",
149
+ "timeout",
150
+ "startupTimeoutMs",
151
+ "catalogTimeoutMs",
152
+ "disabled",
153
+ ]);
154
+ if (Object.keys(server).some((key) => !allowed.has(key)))
155
+ invalid(
156
+ undefined,
157
+ "Unsupported server field. See the pix-mcp README for supported fields.",
158
+ );
159
+ if (server.disabled !== undefined && typeof server.disabled !== "boolean")
160
+ invalid("disabled", "Expected a boolean.");
161
+ if (server.disabled === true) return undefined;
162
+ const common: CommonServer = {
163
+ name,
164
+ description:
165
+ server.description === undefined
166
+ ? ""
167
+ : text(server.description, "description").slice(0, 500),
168
+ timeout: timeout(server.timeout, "timeout"),
169
+ startupTimeoutMs: timeout(server.startupTimeoutMs, "startupTimeoutMs"),
170
+ catalogTimeoutMs: timeout(server.catalogTimeoutMs, "catalogTimeoutMs"),
171
+ };
172
+ const type =
173
+ server.type === undefined && server.command !== undefined
174
+ ? "stdio"
175
+ : server.type;
176
+ if (type === "stdio") {
177
+ if (server.url !== undefined || server.headers !== undefined)
178
+ invalid("type", "Stdio servers cannot use url or headers.");
179
+ const args = server.args === undefined ? [] : server.args;
180
+ if (!Array.isArray(args)) invalid("args", "Expected an array of strings.");
181
+ return {
182
+ ...common,
183
+ type,
184
+ command: expand(server.command, env, "command"),
185
+ args: args.map((arg) => expand(arg, env, "args", true)),
186
+ env: strings(server.env === undefined ? {} : server.env, env, "env"),
187
+ cwd:
188
+ server.cwd === undefined
189
+ ? dirname(path)
190
+ : resolve(dirname(path), expand(server.cwd, env, "cwd")),
191
+ };
192
+ }
193
+ if (type !== "http" && type !== "streamable-http")
194
+ invalid(
195
+ "type",
196
+ "Use stdio, http, or streamable-http. Remote servers require an explicit type.",
197
+ );
198
+ if (
199
+ [server.command, server.args, server.env, server.cwd].some(
200
+ (value) => value !== undefined,
201
+ )
202
+ )
203
+ invalid("type", "HTTP servers cannot use command, args, env, or cwd.");
204
+ const url = expand(server.url, env, "url");
205
+ let parsed: URL;
206
+ try {
207
+ parsed = new URL(url);
208
+ } catch {
209
+ invalid(
210
+ "url",
211
+ "Expected an HTTP(S) URL without credentials or a fragment.",
212
+ );
213
+ }
214
+ if (
215
+ !["http:", "https:"].includes(parsed.protocol) ||
216
+ parsed.username ||
217
+ parsed.password ||
218
+ parsed.hash
219
+ )
220
+ invalid(
221
+ "url",
222
+ "Expected an HTTP(S) URL without credentials or a fragment.",
223
+ );
224
+ const headers = strings(
225
+ server.headers === undefined ? {} : server.headers,
226
+ env,
227
+ "headers",
228
+ );
229
+ try {
230
+ new Headers(headers);
231
+ } catch {
232
+ invalid("headers", "Invalid HTTP header name or value.");
233
+ }
234
+ return { ...common, type: "http", url, headers };
235
+ }
66
236
 
67
237
  export async function readConfig(
68
238
  path: string,
@@ -75,107 +245,49 @@ export async function readConfig(
75
245
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
76
246
  throw new Error("Cannot read MCP configuration.");
77
247
  }
78
- if (Buffer.byteLength(source) > 256 * 1024) invalid();
248
+ if (Buffer.byteLength(source) > 256 * 1024)
249
+ invalid(undefined, "Configuration exceeds 256 KiB.");
79
250
  let value: unknown;
80
251
  try {
81
252
  value = JSON.parse(source);
82
253
  } catch {
83
- invalid();
254
+ invalid(undefined, "Malformed JSON.");
84
255
  }
85
256
  const root = object(value);
86
- if (Object.keys(root).some((key) => key !== "mcpServers")) invalid();
87
- const entries = Object.entries(object(root.mcpServers));
88
- if (entries.length > 32) invalid();
257
+ if (
258
+ Object.keys(root).some((key) => key !== "mcpServers" && key !== "$schema")
259
+ )
260
+ invalid(
261
+ undefined,
262
+ "Unsupported root field; use mcpServers and optional $schema.",
263
+ );
264
+ // Editor metadata only: never fetch a configuration-provided schema URL.
265
+ if (root.$schema !== undefined) text(root.$schema, "$schema");
266
+ const entries = Object.entries(object(root.mcpServers, "mcpServers"));
267
+ if (entries.length > 32)
268
+ invalid("mcpServers", "At most 32 servers are supported.");
89
269
  const servers: ServerConfig[] = [];
90
- for (const [name, raw] of entries) {
91
- if (!/^[A-Za-z0-9_-]{1,48}$/.test(name)) invalid();
92
- const server = object(raw);
93
- const allowed = new Set([
94
- "type",
95
- "command",
96
- "args",
97
- "env",
98
- "cwd",
99
- "url",
100
- "headers",
101
- "description",
102
- "timeoutMs",
103
- "approve",
104
- "disabled",
105
- ]);
106
- if (Object.keys(server).some((key) => !allowed.has(key))) invalid();
107
- if (server.disabled !== undefined && typeof server.disabled !== "boolean")
108
- invalid();
109
- if (server.disabled === true) continue;
110
- const type =
111
- server.type ?? (server.command !== undefined ? "stdio" : "http");
112
- const timeoutMs = server.timeoutMs ?? 30_000;
113
- if (
114
- !Number.isInteger(timeoutMs) ||
115
- typeof timeoutMs !== "number" ||
116
- timeoutMs < 100 ||
117
- timeoutMs > 120_000
118
- )
119
- invalid();
120
- if (server.approve !== undefined && typeof server.approve !== "boolean")
121
- invalid();
122
- const common: CommonServer = {
123
- name,
124
- description:
125
- server.description === undefined
126
- ? ""
127
- : text(server.description).slice(0, 500),
128
- timeoutMs,
129
- approve: server.approve !== false,
130
- };
131
- if (type === "stdio") {
132
- if (server.url !== undefined || server.headers !== undefined) invalid();
133
- const args = server.args ?? [];
134
- if (
135
- !Array.isArray(args) ||
136
- args.some((arg) => typeof arg !== "string" || arg.includes("\0"))
137
- )
138
- invalid();
139
- servers.push({
140
- ...common,
141
- type,
142
- command: text(server.command),
143
- args,
144
- env: strings(server.env ?? {}, env),
145
- cwd:
146
- server.cwd === undefined
147
- ? dirname(path)
148
- : resolve(dirname(path), text(server.cwd)),
270
+ const issues: ConfigIssue[] = [];
271
+ for (const [index, [name, raw]] of entries.entries()) {
272
+ try {
273
+ const server = parseServer(name, raw, path, env);
274
+ if (server) servers.push(server);
275
+ } catch (error) {
276
+ // Invalid names and unknown keys can themselves contain credentials. Only
277
+ // validated names and our fixed validation messages reach discovery.
278
+ issues.push({
279
+ name: /^[A-Za-z0-9_-]{1,48}$/.test(name)
280
+ ? name
281
+ : `Invalid server #${index + 1}`,
282
+ ...(error instanceof ConfigError && error.field
283
+ ? { field: error.field }
284
+ : {}),
285
+ message:
286
+ error instanceof ConfigError
287
+ ? error.message
288
+ : "Invalid MCP configuration. Server could not be validated.",
149
289
  });
150
- } else if (type === "http") {
151
- if (
152
- [server.command, server.args, server.env, server.cwd].some(
153
- (value) => value !== undefined,
154
- )
155
- )
156
- invalid();
157
- const url = text(server.url);
158
- let parsed: URL;
159
- try {
160
- parsed = new URL(url);
161
- } catch {
162
- invalid();
163
- }
164
- if (
165
- !["http:", "https:"].includes(parsed.protocol) ||
166
- parsed.username ||
167
- parsed.password ||
168
- parsed.hash
169
- )
170
- invalid();
171
- const headers = strings(server.headers ?? {}, env, "headers");
172
- try {
173
- new Headers(headers);
174
- } catch {
175
- invalid();
176
- }
177
- servers.push({ ...common, type, url, headers });
178
- } else invalid();
290
+ }
179
291
  }
180
- return { path, servers };
292
+ return { path, servers, issues };
181
293
  }
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  import type { Tool } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
9
9
  import { Connection } from "./client.ts";
10
- import { readConfig, type ServerConfig } from "./config.ts";
10
+ import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
11
11
  import { formatResult } from "./output.ts";
12
12
  import { compileSchema } from "./schema.ts";
13
13
 
@@ -18,6 +18,7 @@ interface State {
18
18
  loaded: Map<string, string>;
19
19
  connections: Map<string, Connection>;
20
20
  rejected: Map<string, number>;
21
+ configIssues: ConfigIssue[];
21
22
  }
22
23
 
23
24
  const discoveryParameters = Type.Object(
@@ -81,6 +82,8 @@ export default function mcp(pi: ExtensionAPI) {
81
82
  function catalog(owner: State | undefined): string {
82
83
  if (!owner) return "Catalog is initialized when the Pi session starts.";
83
84
  const lines = [owner.status];
85
+ for (const issue of owner.configIssues)
86
+ lines.push(`${issue.name}: ${issue.message}`);
84
87
  for (const connection of owner.connections.values()) {
85
88
  const config = connection.config;
86
89
  const tools = [...owner.entries.values()].filter(
@@ -124,7 +127,11 @@ export default function mcp(pi: ExtensionAPI) {
124
127
  if (!owner) throw new Error("MCP session has not started.");
125
128
  current(owner);
126
129
  deactivate(hiddenTools());
127
- if (args.server && !owner.connections.has(args.server))
130
+ if (
131
+ args.server &&
132
+ !owner.connections.has(args.server) &&
133
+ !owner.configIssues.some((issue) => issue.name === args.server)
134
+ )
128
135
  throw new Error("Unknown MCP server.");
129
136
  const all = [...owner.entries.values()].sort((a, b) =>
130
137
  a.name.localeCompare(b.name, "en"),
@@ -178,11 +185,18 @@ export default function mcp(pi: ExtensionAPI) {
178
185
  }
179
186
  const data = {
180
187
  status: owner.status,
181
- servers: [...owner.connections.values()].map((connection) => ({
182
- name: connection.config.name,
183
- status: connection.status,
184
- unsupportedTools: owner.rejected.get(connection.config.name) ?? 0,
185
- })),
188
+ servers: [
189
+ ...owner.configIssues.map((issue) => ({
190
+ name: issue.name,
191
+ status: issue.message,
192
+ unsupportedTools: 0,
193
+ })),
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
+ })),
199
+ ],
186
200
  items: selected.map((item) => ({
187
201
  ...summary(item),
188
202
  active: active.has(item.name),
@@ -236,46 +250,22 @@ export default function mcp(pi: ExtensionAPI) {
236
250
  description:
237
251
  tool.description || `Call ${tool.name} on ${config.name}.`,
238
252
  parameters,
239
- // Approval dialogs share Pi's UI. Only preapproved calls may overlap.
240
- executionMode: config.approve ? "sequential" : "parallel",
241
- async execute(_id, params, signal, _onUpdate, ctx) {
253
+ executionMode: "parallel",
254
+ async execute(_id, params, signal) {
242
255
  current(owner);
243
256
  signal?.throwIfAborted();
244
- const verify = () => {
245
- current(owner);
246
- if (
247
- owner.entries.get(item.name)?.fingerprint !==
248
- item.fingerprint ||
249
- owner.loaded.get(item.name) !== item.fingerprint ||
250
- !pi.getActiveTools().includes(item.name)
251
- ) {
252
- throw new Error(
253
- "MCP tool is unavailable or changed. Discover it again.",
254
- );
255
- }
256
- };
257
- verify();
258
- if (!validator.Check(params))
259
- throw new Error("Arguments do not match the MCP input schema.");
260
- if (config.approve) {
261
- if (!ctx.hasUI)
262
- throw new Error(
263
- "MCP approval required. Use an interactive session or explicitly set approve:false for this trusted server.",
264
- );
265
- const input = JSON.stringify(params, null, 2);
266
- if (input.length > 8000)
267
- throw new Error(
268
- "MCP arguments are too large for the approval dialog.",
269
- );
270
- const allowed = await ctx.ui.confirm(
271
- `Call ${config.name}/${tool.name}?`,
272
- input,
273
- signal ? { signal } : undefined,
257
+ if (
258
+ owner.entries.get(item.name)?.fingerprint !==
259
+ item.fingerprint ||
260
+ owner.loaded.get(item.name) !== item.fingerprint ||
261
+ !pi.getActiveTools().includes(item.name)
262
+ ) {
263
+ throw new Error(
264
+ "MCP tool is unavailable or changed. Discover it again.",
274
265
  );
275
- if (!allowed) throw new Error("MCP tool call denied.");
276
266
  }
277
- signal?.throwIfAborted();
278
- verify();
267
+ if (!validator.Check(params))
268
+ throw new Error("Arguments do not match the MCP input schema.");
279
269
  const connection = owner.connections.get(config.name);
280
270
  if (!connection) throw new Error("MCP connection unavailable.");
281
271
  const result = await connection.call(
@@ -335,6 +325,7 @@ export default function mcp(pi: ExtensionAPI) {
335
325
  loaded: new Map(),
336
326
  connections: new Map(),
337
327
  rejected: new Map(),
328
+ configIssues: [],
338
329
  };
339
330
  state = owner;
340
331
  try {
@@ -363,7 +354,15 @@ export default function mcp(pi: ExtensionAPI) {
363
354
  "MCP configuration not trusted. Review it, then restart with --mcp-trust-config or --mcp-config <path>.";
364
355
  return;
365
356
  }
366
- owner.status = "Use list/search/load to discover and activate tools.";
357
+ owner.configIssues = config.issues;
358
+ owner.status =
359
+ config.issues.length > 0
360
+ ? config.servers.length > 0
361
+ ? "Some MCP servers have invalid configuration; valid servers remain available. Use list/search/load to discover and activate tools."
362
+ : "No valid MCP servers configured. Correct configuration errors and reload Pi."
363
+ : config.servers.length > 0
364
+ ? "Use list/search/load to discover and activate tools."
365
+ : "No enabled MCP servers configured.";
367
366
  for (const server of config.servers) {
368
367
  owner.connections.set(
369
368
  server.name,