@ikuma.cloud/pix-mcp 0.0.1 → 0.0.2

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
@@ -59,30 +59,94 @@ contact remote services. Configuration trust is not an OS sandbox.
59
59
  "url": "https://mcp.example.com/mcp",
60
60
  "headers": { "Authorization": "Bearer ${SERVICE_TOKEN}" },
61
61
  "description": "Issue tracking tools",
62
- "timeoutMs": 30000,
62
+ "timeout": 960000,
63
+ "startupTimeoutMs": 30000,
64
+ "catalogTimeoutMs": 30000,
63
65
  "approve": true
64
66
  }
65
67
  }
66
68
  }
67
69
  ```
68
70
 
69
- Supported fields:
71
+ ### Supported configuration
72
+
73
+ MCP standardizes the protocol, not a universal configuration file. This adapter
74
+ supports a [Claude Code-style](https://code.claude.com/docs/en/mcp) connection
75
+ subset, not every client-specific field or transport. OpenCode configuration,
76
+ Cursor's `${env:VAR}` syntax, and other clients' approval policies are not imported
77
+ or translated.
78
+
79
+ The published [JSON Schema](mcp.schema.json) provides editor validation. An
80
+ optional root `$schema` string can reference your installed copy; the adapter
81
+ never fetches that URL. Runtime validation additionally checks expanded strings,
82
+ URLs, and HTTP headers.
70
83
 
71
84
  | Field | Behavior |
72
85
  | --- | --- |
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
+ | `type` | `stdio`, `http`, or `streamable-http` (alias of `http`); only stdio is inferred, when `command` is present |
87
+ | `command`, `args`, `env` | Stdio only; executable and argument array, not a shell command |
88
+ | `url`, `headers` | Streamable HTTP only; explicit `type` required; no legacy SSE fallback or redirect following |
89
+ | `timeout` | Hard deadline for each tool invocation, in milliseconds; default 30000 |
90
+ | `cwd` | Stdio extension: working directory relative to the config directory |
91
+ | `description` | Discovery extension: optional summary, truncated to 500 characters |
92
+ | `startupTimeoutMs` | pix extension: complete connection/initialization handshake deadline; default 30000 |
93
+ | `catalogTimeoutMs` | pix extension: complete catalog snapshot deadline, including all pages; default 30000 |
94
+ | `approve` | pix policy: require confirmation for every invocation, default `true` |
95
+ | `disabled` | Skip the server's value validation and environment expansion when `true`; unknown fields are still errors |
96
+
97
+ `command`, `args`, `cwd`, `env` values, `url`, and `headers` values expand `${VAR}`
98
+ and `${VAR:-default}`. A default applies only when the variable is unset, not when
99
+ it is an empty string. Expansion is single-pass against Pi's environment; `env`
100
+ entries do not define variables for other entries. Missing variables and invalid
101
+ expanded values fail without echoing credentials. Stdio inherits the SDK's
102
+ minimal platform environment plus explicit `env`, not the entire Pi environment.
103
+ Credentials and fragments in HTTP URLs are rejected; use headers for credentials.
104
+ Unknown fields invalidate their server entry rather than being silently ignored.
105
+
106
+ ### Configuration errors
107
+
108
+ An invalid server entry is skipped without hiding healthy servers. Discovery's
109
+ `servers` list includes a safe diagnostic for each skipped entry; a valid server
110
+ name can still be used with `mcp({ action: "list", server: "name" })` to inspect
111
+ its status. Invalid names are replaced with their one-based entry positions.
112
+ Diagnostics identify supported fields or migration steps without echoing URLs,
113
+ commands, headers, argument values, or environment-variable names.
114
+
115
+ Malformed JSON, invalid root structure, unknown root fields, and the file/server
116
+ count limits remain fatal for the whole file. An invalid-only configuration
117
+ reports that no valid servers remain. Disabled entries are omitted, not reported
118
+ as failed connections. Correct the file and reload Pi to retry.
119
+
120
+ Configuration trust still applies before any valid server is started or its
121
+ metadata exposed. Per-call approval remains independent of configuration errors.
122
+
123
+ ### Deadlines and migration
124
+
125
+ All three deadline fields accept integers from 1 through 2,147,483,647 milliseconds
126
+ (Node's timer-safe maximum). Each defaults independently to 30 seconds. Setting
127
+ `"timeout": 960000` permits a 16-minute tool call without lengthening startup or
128
+ discovery. The call clock starts after invocation approval and connection startup;
129
+ progress does not reset it. Catalog deadlines cover all pages of one snapshot;
130
+ a subsequent list-change refresh starts a new deadline.
131
+
132
+ HTTP deadlines cover response headers and bodies, including JSON and SSE. MCP
133
+ requests borrow the host's HTTP/proxy routing but override its header/body idle
134
+ limits per request; Pi's global settings and unrelated requests are unchanged.
135
+ Notification GET streams have a bounded header wait but no body-idle deadline.
136
+ Upstream proxies and servers may still impose their own limits.
137
+
138
+ **Breaking changes from 0.0.1:**
139
+
140
+ - Replace `timeoutMs` with `timeout` for tool calls. Set `startupTimeoutMs` and
141
+ `catalogTimeoutMs` separately if their defaults are unsuitable. The removed
142
+ field produces a migration diagnostic; it is not an alias.
143
+ - Add `"type": "http"` to remote entries that previously specified only `url`.
144
+ - Connection strings now expand environment references beyond `env` and `headers`.
145
+
146
+ The 120-second call ceiling is removed. This is a configuration migration, not a
147
+ promise to finish a remote operation within its deadline.
148
+
149
+ ### Invocation approval
86
150
 
87
151
  Tool invocation requires confirmation independently of config trust. In headless
88
152
  mode, calls fail closed unless the reviewed configuration explicitly sets
@@ -94,7 +158,8 @@ server separately in a trusted environment.
94
158
  ## Discovery and execution
95
159
 
96
160
  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.
161
+ paginated tool catalogs. A server's configuration, connection, or discovery
162
+ failure does not hide tools from other servers.
98
163
  Full schemas stay out of model context until selected. **Schema exposure is lazy;
99
164
  initial connections and metadata discovery are not.**
100
165
 
@@ -156,12 +221,13 @@ Schemas are syntax-checked before compilation. Only the default MCP dialect,
156
221
  JSON Schema 2020-12, is supported; explicit legacy dialects (including embedded
157
222
  resources) are rejected rather than interpreted with incorrect reference
158
223
  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.
224
+ inside instance data are allowed.
161
225
 
162
226
  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
227
+ may occur after a mutating operation took effect. Cancellation or expiration
228
+ aborts only the affected HTTP request and sends a best-effort MCP cancellation
229
+ notification; concurrent sibling calls remain usable. Successful output is
230
+ validated against the schema captured when the call began; MCP error results are exempt
165
231
  from that success schema. Cancellation is best-effort at the server and does not
166
232
  roll back effects.
167
233
 
@@ -0,0 +1,126 @@
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 approval 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
+ "approve": {
47
+ "type": "boolean",
48
+ "default": true,
49
+ "description": "pix extension: require confirmation for every tool invocation."
50
+ },
51
+ "disabled": { "const": false }
52
+ }
53
+ },
54
+ "stdio": {
55
+ "allOf": [{ "$ref": "#/$defs/common" }],
56
+ "type": "object",
57
+ "required": ["command"],
58
+ "properties": {
59
+ "type": { "const": "stdio" },
60
+ "command": { "$ref": "#/$defs/text" },
61
+ "args": { "type": "array", "items": { "$ref": "#/$defs/string" } },
62
+ "cwd": {
63
+ "$ref": "#/$defs/text",
64
+ "description": "Resolved relative to the configuration directory."
65
+ },
66
+ "env": {
67
+ "type": "object",
68
+ "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_-]*$" },
69
+ "additionalProperties": { "$ref": "#/$defs/string" }
70
+ }
71
+ },
72
+ "not": { "anyOf": [{ "required": ["url"] }, { "required": ["headers"] }] }
73
+ },
74
+ "http": {
75
+ "allOf": [{ "$ref": "#/$defs/common" }],
76
+ "type": "object",
77
+ "required": ["type", "url"],
78
+ "properties": {
79
+ "type": { "enum": ["http", "streamable-http"] },
80
+ "url": { "$ref": "#/$defs/text" },
81
+ "headers": {
82
+ "type": "object",
83
+ "propertyNames": { "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" },
84
+ "additionalProperties": { "$ref": "#/$defs/string" }
85
+ }
86
+ },
87
+ "not": {
88
+ "anyOf": [
89
+ { "required": ["command"] },
90
+ { "required": ["args"] },
91
+ { "required": ["env"] },
92
+ { "required": ["cwd"] }
93
+ ]
94
+ }
95
+ },
96
+ "server": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "properties": {
100
+ "type": true,
101
+ "command": true,
102
+ "args": true,
103
+ "env": true,
104
+ "cwd": true,
105
+ "url": true,
106
+ "headers": true,
107
+ "description": true,
108
+ "timeout": true,
109
+ "startupTimeoutMs": true,
110
+ "catalogTimeoutMs": true,
111
+ "approve": true,
112
+ "disabled": { "type": "boolean" }
113
+ },
114
+ "if": {
115
+ "required": ["disabled"],
116
+ "properties": { "disabled": { "const": true } }
117
+ },
118
+ "then": {
119
+ "description": "Disabled entries skip value validation and environment expansion, but unknown fields remain errors."
120
+ },
121
+ "else": {
122
+ "oneOf": [{ "$ref": "#/$defs/stdio" }, { "$ref": "#/$defs/http" }]
123
+ }
124
+ }
125
+ }
126
+ }
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.2",
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,10 +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;
9
+ timeout: number;
10
+ startupTimeoutMs: number;
11
+ catalogTimeoutMs: number;
8
12
  approve: boolean;
9
13
  }
10
14
  export type ServerConfig = CommonServer &
@@ -18,51 +22,217 @@ export type ServerConfig = CommonServer &
18
22
  }
19
23
  | { type: "http"; url: string; headers: Record<string, string> }
20
24
  );
25
+ export interface ConfigIssue {
26
+ name: string;
27
+ field?: string;
28
+ message: string;
29
+ }
21
30
  export interface Config {
22
31
  path: string;
23
32
  servers: ServerConfig[];
33
+ issues: ConfigIssue[];
24
34
  }
25
35
 
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
- );
36
+ class ConfigError extends Error {
37
+ readonly field: string | undefined;
38
+ constructor(message: string, field?: string) {
39
+ // Only fixed messages and known field names belong here, never input values.
40
+ super(`Invalid MCP configuration. ${field ? `${field}: ` : ""}${message}`);
41
+ this.field = field;
42
+ }
43
+ }
44
+ function invalid(
45
+ field?: string,
46
+ message = "See the pix-mcp README for supported fields.",
47
+ ): never {
48
+ throw new ConfigError(message, field);
31
49
  }
32
- function object(value: unknown): Record<string, unknown> {
33
- if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
50
+ function object(value: unknown, field?: string): Record<string, unknown> {
51
+ if (!value || typeof value !== "object" || Array.isArray(value))
52
+ invalid(field, "Expected an object.");
34
53
  return value as Record<string, unknown>;
35
54
  }
36
- function text(value: unknown): string {
37
- if (typeof value !== "string" || value.length === 0 || value.includes("\0"))
38
- invalid();
55
+ function text(value: unknown, field: string, empty = false): string {
56
+ if (
57
+ typeof value !== "string" ||
58
+ (!empty && value.length === 0) ||
59
+ value.includes("\0")
60
+ )
61
+ invalid(field, "Expected a valid string.");
39
62
  return value;
40
63
  }
64
+ function expand(
65
+ value: unknown,
66
+ env: NodeJS.ProcessEnv,
67
+ field: string,
68
+ empty = false,
69
+ ): string {
70
+ const expanded = text(value, field, empty).replace(
71
+ /\$\{([^}]*)\}|\$\{/g,
72
+ (_match, expression: string | undefined) => {
73
+ const match = expression?.match(
74
+ /^([A-Za-z_][A-Za-z0-9_]*)(?::-([\s\S]*))?$/,
75
+ );
76
+ if (!match?.[1])
77
+ invalid(
78
+ field,
79
+ `Use \${VAR} or \${VAR:-default} environment references.`,
80
+ );
81
+ const replacement =
82
+ (Object.hasOwn(env, match[1]) ? env[match[1]] : undefined) ?? match[2];
83
+ if (replacement === undefined)
84
+ invalid(field, "References an unset environment variable.");
85
+ return replacement;
86
+ },
87
+ );
88
+ return text(expanded, field, empty);
89
+ }
41
90
  function strings(
42
91
  value: unknown,
43
92
  env: NodeJS.ProcessEnv,
44
- kind: "env" | "headers" = "env",
93
+ field: "env" | "headers",
45
94
  ): Record<string, string> {
46
95
  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
- );
96
+ for (const [key, entry] of Object.entries(object(value, field))) {
97
+ if (field === "env" && !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key))
98
+ invalid(field, "Invalid environment variable name.");
99
+ result[key] = expand(entry, env, field, true);
63
100
  }
64
101
  return result;
65
102
  }
103
+ function timeout(value: unknown, field: string): number {
104
+ if (value === undefined) return 30_000;
105
+ // Node timers overflow above this bound and can fire almost immediately.
106
+ if (
107
+ typeof value !== "number" ||
108
+ !Number.isInteger(value) ||
109
+ value < 1 ||
110
+ value > MAX_TIMEOUT_MS
111
+ )
112
+ invalid(
113
+ field,
114
+ `Expected an integer from 1 through ${MAX_TIMEOUT_MS} milliseconds.`,
115
+ );
116
+ return value;
117
+ }
118
+
119
+ function parseServer(
120
+ name: string,
121
+ raw: unknown,
122
+ path: string,
123
+ env: NodeJS.ProcessEnv,
124
+ ): ServerConfig | undefined {
125
+ if (!/^[A-Za-z0-9_-]{1,48}$/.test(name))
126
+ invalid(
127
+ undefined,
128
+ "Server names must contain 1–48 ASCII letters, digits, underscores, or hyphens.",
129
+ );
130
+ const server = object(raw);
131
+ if (Object.hasOwn(server, "timeoutMs"))
132
+ invalid(
133
+ "timeoutMs",
134
+ "Removed; use timeout for tool calls, startupTimeoutMs for initialization, and catalogTimeoutMs for discovery (milliseconds).",
135
+ );
136
+ const allowed = new Set([
137
+ "type",
138
+ "command",
139
+ "args",
140
+ "env",
141
+ "cwd",
142
+ "url",
143
+ "headers",
144
+ "description",
145
+ "timeout",
146
+ "startupTimeoutMs",
147
+ "catalogTimeoutMs",
148
+ "approve",
149
+ "disabled",
150
+ ]);
151
+ if (Object.keys(server).some((key) => !allowed.has(key)))
152
+ invalid(
153
+ undefined,
154
+ "Unsupported server field. See the pix-mcp README for supported fields.",
155
+ );
156
+ if (server.disabled !== undefined && typeof server.disabled !== "boolean")
157
+ invalid("disabled", "Expected a boolean.");
158
+ if (server.disabled === true) return undefined;
159
+ if (server.approve !== undefined && typeof server.approve !== "boolean")
160
+ invalid("approve", "Expected a boolean.");
161
+ const common: CommonServer = {
162
+ name,
163
+ description:
164
+ server.description === undefined
165
+ ? ""
166
+ : text(server.description, "description").slice(0, 500),
167
+ timeout: timeout(server.timeout, "timeout"),
168
+ startupTimeoutMs: timeout(server.startupTimeoutMs, "startupTimeoutMs"),
169
+ catalogTimeoutMs: timeout(server.catalogTimeoutMs, "catalogTimeoutMs"),
170
+ approve: server.approve !== false,
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),
@@ -335,6 +349,7 @@ export default function mcp(pi: ExtensionAPI) {
335
349
  loaded: new Map(),
336
350
  connections: new Map(),
337
351
  rejected: new Map(),
352
+ configIssues: [],
338
353
  };
339
354
  state = owner;
340
355
  try {
@@ -363,7 +378,15 @@ export default function mcp(pi: ExtensionAPI) {
363
378
  "MCP configuration not trusted. Review it, then restart with --mcp-trust-config or --mcp-config <path>.";
364
379
  return;
365
380
  }
366
- owner.status = "Use list/search/load to discover and activate tools.";
381
+ owner.configIssues = config.issues;
382
+ owner.status =
383
+ config.issues.length > 0
384
+ ? config.servers.length > 0
385
+ ? "Some MCP servers have invalid configuration; valid servers remain available. Use list/search/load to discover and activate tools."
386
+ : "No valid MCP servers configured. Correct configuration errors and reload Pi."
387
+ : config.servers.length > 0
388
+ ? "Use list/search/load to discover and activate tools."
389
+ : "No enabled MCP servers configured.";
367
390
  for (const server of config.servers) {
368
391
  owner.connections.set(
369
392
  server.name,