pi-mcp-extension 1.9.1 → 1.9.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
@@ -9,19 +9,20 @@
9
9
 
10
10
  ## Features
11
11
 
12
- - **Multi-transport** — `stdio` subprocesses, `streamable-http`, and legacy `sse` out of the box
12
+ - **Multi-transport** — `stdio` subprocesses, `streamable-http` (including `"http"` and URL-only shorthand), and legacy `sse` out of the box
13
13
  - **Auto-discovery** — Paginated `tools/list` with cursor following (MCP 2025-03-26 spec)
14
14
  - **Live tool refresh** — `notifications/tools/list_changed` triggers re-discovery without restart
15
15
  - **Clean cancellation** — `AbortSignal` propagation via `notifications/cancelled`
16
- - **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries
16
+ - **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries, and automatic recovery when a server transport closes unexpectedly
17
17
  - **Health checks** — Opt-in periodic pings to detect stale connections
18
- - **Safe process lifecycle** — PID tracking with safety-net `SIGKILL` for orphaned subprocesses
18
+ - **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with safety-net `SIGKILL` for orphaned subprocesses
19
19
  - **Stable tool names** — Sanitized, collision-avoiding names (max 64 chars) that stay constant across reconnects
20
20
  - **No tool churn** — Activate/deactivate pattern ensures tools don't flicker during reconnection
21
- - **Global + project config** — Layered config (project overrides global) with per-server tuning
22
- - **Tool annotations** — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
21
+ - **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()`), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
22
+ - **Rich tool results & annotations** — MCP `image` content passthrough (`ImageContent`), plus `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
23
+ - **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
23
24
  - **Structured error handling** — Distinct error codes for config, connection, protocol, and tool-level failures
24
- - **OAuth 2.1** — Browser authorization with PKCE, protected-resource discovery, token refresh, and secure local persistence
25
+ - **OAuth 2.1** — Browser and headless/SSH authorization with PKCE, protected-resource discovery, Dynamic Client Registration, startup prompts for eager servers, cross-process lock recovery, token refresh, and secure local persistence (see [OAUTH.md](./OAUTH.md))
25
26
 
26
27
  ## Installation
27
28
 
@@ -42,7 +43,16 @@ pi -e npm:pi-mcp-extension
42
43
  pi install npm:pi-mcp-extension
43
44
  ```
44
45
 
45
- 2. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
46
+ 2. *(Pi 1.x)* Disable Pi's built-in `mcp` extension so Pi does not warn that both extensions are enabled at the same time — either run `pi config` and uncheck `mcp` under **Built-in extensions**, or add `"extensions": ["-builtin:mcp"]` to `~/.pi/agent/settings.json`:
47
+ ```jsonc
48
+ {
49
+ "extensions": [
50
+ "-builtin:mcp"
51
+ ]
52
+ }
53
+ ```
54
+
55
+ 3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
46
56
  ```jsonc
47
57
  {
48
58
  "mcpServers": {
@@ -55,16 +65,17 @@ pi -e npm:pi-mcp-extension
55
65
  }
56
66
  ```
57
67
 
58
- 3. Start Pi — your MCP tools are ready. Use `/mcp` to check server status.
68
+ 4. Start Pi — your MCP tools are ready. Use `/mcp` to check server status.
59
69
 
60
70
  ## Configuration
61
71
 
62
- Config files are loaded from two locations. **Project config overrides global config** via shallow merge (server-level replacement).
72
+ Servers can be configured via JSON files or registered programmatically by other Pi 1.x extensions:
63
73
 
64
- | Location | Scope |
74
+ | Source | Precedence & Scope |
65
75
  |---|---|
66
- | `~/.pi/agent/mcp.json` | Global — applies to all projects (honors `$PI_CODING_AGENT_DIR` when set: `<agent dir>/mcp.json`) |
67
- | `.pi/mcp.json` | Project — overrides global per-server |
76
+ | `.pi/mcp.json` | **Highest** — Project-level overrides per server and per explicitly stated setting. Loaded on `session_start` **only when the project is trusted** (`ctx.isProjectTrusted()`); never read at bootstrap or in untrusted projects. |
77
+ | `~/.pi/agent/mcp.json` | **Global** — Applies to all projects (honors `$PI_CODING_AGENT_DIR` with `~` expansion when set: `<agent dir>/mcp.json`). |
78
+ | `pi.registerMcpServer()` | **Extension-registered** — Discovered via `pi.getMcpServers()` and updated live on `mcp_servers_change` events when replacing `builtin:mcp`. Any server with the same name in `mcp.json` takes precedence. |
68
79
 
69
80
  ### Full Example
70
81
 
@@ -87,8 +98,7 @@ Config files are loaded from two locations. **Project config overrides global co
87
98
  "lifecycle": "eager"
88
99
  },
89
100
  "deepsource": {
90
- "transport": "streamable-http",
91
- "url": "https://mcp.deepsource.io/mcp",
101
+ "url": "https://mcp.deepsource.io/mcp", // "transport" defaults to "streamable-http" when "url" is set
92
102
  "lifecycle": "eager"
93
103
  },
94
104
 
@@ -126,6 +136,8 @@ Config files are loaded from two locations. **Project config overrides global co
126
136
 
127
137
  ### Global Settings
128
138
 
139
+ In project-level `.pi/mcp.json`, `settings` keys carry no defaults — only the keys explicitly stated in the project file override global settings.
140
+
129
141
  | Field | Type | Default | Description |
130
142
  |---|---|---|---|
131
143
  | `toolPrefix` | `string` | `"mcp"` | Prefix for registered tools: `<prefix>_<server>_<tool>`. Must match `^[a-zA-Z][a-zA-Z0-9_]*$`. |
@@ -134,28 +146,32 @@ Config files are loaded from two locations. **Project config overrides global co
134
146
 
135
147
  ### Per-Server Config
136
148
 
149
+ For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`, and `timeout` (in seconds) as an alias for `requestTimeoutMs`.
150
+
137
151
  | Field | Type | Default | Description |
138
152
  |---|---|---|---|
139
- | `transport` | `"stdio" \| "streamable-http" \| "sse"` | `"stdio"` | Transport protocol |
140
- | `command` | `string` | — | Executable to spawn (**required** for stdio) |
153
+ | `transport` (or `type`) | `"stdio" \| "streamable-http" \| "http" \| "sse"` | `"stdio"` (`"streamable-http"` when `url` is set without `command`) | Transport protocol (`"http"` is normalized to `"streamable-http"`) |
154
+ | `command` | `string` | — | Executable to spawn (**required** for `stdio`) |
141
155
  | `args` | `string[]` | `[]` | Arguments for the command |
142
- | `env` | `Record<string, string>` | — | Extra environment variables for the child process |
143
- | `url` | `string` | — | Server URL (**required** for streamable-http/sse) |
144
- | `headers` | `Record<string, string>` | — | Static headers sent with HTTP and SSE requests |
145
- | `auth` | `object` | — | OAuth configuration described below |
146
- | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = connect on request via `mcp_connect` or `/mcp:start` |
147
- | `requestTimeoutMs` | `number` | global setting | Per-server timeout override |
148
- | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval for connection health monitoring |
156
+ | `env` | `Record<string, string>` | — | Extra environment variables for the child process (literal values, no `${VAR}` interpolation) |
157
+ | `url` | `string` | — | Server URL (**required** for `streamable-http` and `sse`) |
158
+ | `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests |
159
+ | `auth` (or `oauth`) | `object` | — | OAuth 2.1 configuration described below and in [OAUTH.md](./OAUTH.md) (`streamable-http` / `sse` only) |
160
+ | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` (`"eager"` for `pi.registerMcpServer()` unless `enabled: false`) | `eager` = auto-start on `session_start`, `lazy` = connect on request via `mcp_connect`, `/mcp:start`, or `/mcp:auth` |
161
+ | `requestTimeoutMs` (or `timeout`) | `number` | global setting | Per-server timeout override in ms (`timeout` is specified in seconds and multiplied by `1000`) |
162
+ | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval in ms for proactive connection health monitoring |
149
163
 
150
164
  ### OAuth Config
151
165
 
166
+ Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
167
+
152
168
  | Field | Type | Default | Description |
153
169
  |---|---|---|---|
154
170
  | `type` | `"oauth"` | `"oauth"` | Authentication type |
155
- | `scope` | `string` | discovered | Requested scope when the server does not advertise one |
156
- | `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID |
171
+ | `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
172
+ | `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
157
173
  | `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
158
- | `redirectUrl` | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback used by `/mcp:auth` |
174
+ | `redirectUrl` (or `oauth.callbackUrl` / `oauth.callbackPort`) | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback (`localhost`, `127.0.0.1`, or `::1` with path `/callback` and an explicit non-zero port; `callbackPort` maps to `http://127.0.0.1:<port>/callback`). Scans forward from `19876` when omitted; binds the exact host and port when configured. |
159
175
 
160
176
  ## Commands
161
177
 
@@ -166,11 +182,18 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
166
182
  | `/mcp` | Show status summary of all configured servers |
167
183
  | `/mcp <name>` | Show detailed status and stderr log for a specific server |
168
184
  | `/mcp:start <name>` | Connect a server and wait for tool discovery |
169
- | `/mcp:stop <name>` | Cancel a pending connection or authorization, or stop a running server and deactivate its tools |
185
+ | `/mcp:stop <name>` | Cancel a pending connection, startup prompt, or authorization, or stop a running server and deactivate its tools |
170
186
  | `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
171
187
  | `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
172
188
 
173
- Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text. Repeated credential resets share one pending authorization; stop or shutdown cancels it. Lazy servers remain stopped until requested. An interactive Pi session is required when browser authorization is needed. Eager startup never opens a browser. Retry opens the browser again only when selected in the OAuth prompt.
189
+ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, discovered and active tool counts, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
190
+
191
+ ### OAuth startup, headless/SSH, and concurrency behavior
192
+
193
+ - **Interactive sessions required for browser auth**: Print and RPC sessions fail promptly when interactive authorization is required.
194
+ - **Startup prompt for eager OAuth servers**: Silent startup and background reconnects never open a browser unprompted or trigger Dynamic Client Registration when no client credentials are stored. When an `eager` OAuth server needs interactive authorization at startup in an interactive Pi session, Pi presents a sequential `<name> needs authorization` prompt (`Authorize now` / `Not now`). Choosing `Authorize now` runs the interactive OAuth flow; choosing `Not now` leaves the server stopped, shows a `run /mcp:auth <name>` footer hint, and remembers the decline for the rest of the session.
195
+ - **Headless and SSH support**: While authorization is pending, Pi displays the authorization URL and offers `Retry: open browser again`, `Paste redirect URL`, and `Cancel authentication`. On remote/SSH or headless machines, open the URL locally and either forward the callback port (`ssh -L <port>:127.0.0.1:<port>`) or paste the browser's final `http://127.0.0.1:<port>/callback?code=...&state=...` redirect URL into `Paste redirect URL`. Only `https:` (or loopback `http:`) authorization URLs are passed to the system browser opener.
196
+ - **Coordinated connects and cross-process locks**: Concurrent connect requests join an in-flight attempt, while `--reset` supersedes a normal connect and shares one pending reset. Cross-process interactive flows are serialized per server via `<agent dir>/mcp-auth/<hash>.json.lock` with a 30s heartbeat and automatic reclamation of locks from dead local PIDs or stale heartbeats (>90s), while allowing silent token refreshes to persist rotated tokens.
174
197
 
175
198
  ### Connection readiness and active tools
176
199
 
@@ -199,20 +222,20 @@ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and
199
222
  ▼ ▼
200
223
  Global/Project ┌──────────────┐
201
224
  mcp.json files │ MCP Servers │
202
- │ (stdio/http) │
225
+ + Extensions │ (stdio/http) │
203
226
  └──────────────┘
204
227
  ```
205
228
 
206
- 1. **Config is loaded** from global and project files (project overrides global by server name)
207
- 2. **Eager servers connect** at session start without opening browsers; lazy servers wait for `mcp_connect` or `/mcp:start`
208
- 3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
229
+ 1. **Config is loaded** from global `<agent dir>/mcp.json`, trusted project `.pi/mcp.json` (checked via `ctx.isProjectTrusted()` on `session_start`), and extension-registered servers (`pi.getMcpServers()` / `mcp_servers_change`), with `mcp.json` taking precedence over extension-registered servers and project entries overriding global ones
230
+ 2. **Eager servers connect** at `session_start` (offering a sequential `Authorize now` / `Not now` prompt when an eager OAuth server needs authorization in an interactive session); lazy servers wait for `mcp_connect`, `/mcp:start`, or `/mcp:auth`
231
+ 3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages) before a server enters `ready`
209
232
  4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
210
- 5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix)
233
+ 5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix) and pass through text and `image` content blocks (`ImageContent`)
211
234
  6. **Tools activate/deactivate** automatically as servers connect/disconnect
212
235
  7. **AbortSignal** is wired through to `notifications/cancelled` for clean cancellation
213
- 8. **Reconnection** uses a fixed delay schedule with configurable max retries
236
+ 8. **Reconnection & recovery** uses a fixed delay schedule with configurable max retries on initial failure, health check failure, or unexpected transport close (`client.onclose`)
214
237
  9. **`list_changed` notifications** trigger re-discovery, deactivating stale tools automatically
215
- 10. **Session shutdown** cleanly stops all servers and deactivates all tools
238
+ 10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
216
239
 
217
240
  ## MCP Spec Compliance
218
241
 
@@ -222,15 +245,16 @@ Implements the **MCP 2025-03-26** specification for tool clients:
222
245
  |---|---|
223
246
  | Protocol version `2025-03-26` (+ `2024-11-05` fallback) | ✅ |
224
247
  | `stdio` transport | ✅ |
225
- | `streamable-http` transport | ✅ |
248
+ | `streamable-http` transport (including `"http"` / URL-only shorthand) | ✅ |
226
249
  | `sse` transport (legacy backwards compat) | ✅ |
250
+ | OAuth 2.1 authorization (PKCE S256, RFC 9728 Protected Resource Metadata, RFC 7591 DCR) | ✅ |
227
251
  | Cursor-based `tools/list` pagination | ✅ |
228
- | `tools/call` with parameters | ✅ |
252
+ | `tools/call` with parameters & `image` content (`ImageContent`) passthrough | ✅ |
229
253
  | `roots/list` capability (workspace root) | ✅ |
230
254
  | `notifications/tools/list_changed` (live refresh) | ✅ |
231
255
  | `notifications/cancelled` (AbortSignal) | ✅ |
232
256
  | `notifications/message` (structured logging) | ✅ |
233
- | `isError: true` distinction (protocol vs tool errors) | ✅ |
257
+ | `isError: true` distinction (protocol vs tool errors, non-text filtered on error) | ✅ |
234
258
  | Tool annotations (`readOnlyHint`, `destructiveHint`, etc.) | ✅ |
235
259
  | Resources bridge | ⏳ v2 |
236
260
  | Prompts bridge | ⏳ v2 |
@@ -255,10 +279,11 @@ npm run test:integration
255
279
  ### Testing
256
280
 
257
281
  The test suite includes:
258
- - **Config validation** — Zod schema, global/project merge, defaults, error cases
259
- - **Server lifecycle** — Connect, retry, shutdown, health check, race conditions
260
- - **Tool bridge** — JSON Schema → TypeBox conversion, name sanitization, activate/deactivate
261
- - **End-to-end integration** — Real mock MCP server over stdio with tool discovery and execution
282
+ - **Config & Pi 1.x compatibility** — Zod schema, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
283
+ - **Server lifecycle & coordination** — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
284
+ - **OAuth 2.1 & auth locks** — Challenge discovery, PKCE, Dynamic Client Registration, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat and stale lock reclamation
285
+ - **Tool bridge & management tools** — JSON Schema → TypeBox conversion, name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, and slash command completions
286
+ - **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
262
287
 
263
288
  ## License
264
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -149,7 +149,9 @@ async function closeCurrentServer(): Promise<void> {
149
149
  * scans forward from the default port if needed.
150
150
  */
151
151
  export function callbackServerConfigFromRedirectUrl(redirectUrl?: string): CallbackServerConfig {
152
- if (!redirectUrl) {
152
+ // WHY: Only treat `undefined` (omitted) as default; an explicit empty string `""`
153
+ // must fall through to `new URL("")` and throw a validation error.
154
+ if (redirectUrl === undefined) {
153
155
  return {
154
156
  preferredPort: DEFAULT_PORT,
155
157
  host: DEFAULT_HOST,
@@ -236,7 +238,7 @@ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT,
236
238
  });
237
239
 
238
240
  await new Promise<void>((resolve, reject) => {
239
- candidateServer.once("error", (err: any) => {
241
+ candidateServer.once("error", (err: Error) => {
240
242
  reject(err);
241
243
  });
242
244
 
package/src/config.ts CHANGED
@@ -14,31 +14,64 @@ import { readFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { z } from "zod";
17
+ import { callbackServerConfigFromRedirectUrl } from "./callback-server.js";
17
18
  import { McpError } from "./errors.js";
18
19
 
19
20
  // ─── Zod Schemas ──────────────────────────────────────────────────────────────
20
21
 
21
- const AuthConfigSchema = z.object({
22
- /** Auth type. Currently only "oauth" is supported. Default: "oauth". */
23
- type: z.enum(["oauth"]).default("oauth"),
24
- /**
25
- * Callback URL for the OAuth redirect.
26
- * Default: auto-detected local callback server.
27
- */
28
- redirectUrl: z.string().optional(),
29
- /**
30
- * Optional scope to request during authorization.
31
- */
32
- scope: z.string().optional(),
33
- /**
34
- * Pre-registered client_id (skip dynamic client registration).
35
- */
36
- clientId: z.string().optional(),
37
- /**
38
- * Pre-registered client_secret.
39
- */
40
- clientSecret: z.string().optional(),
41
- });
22
+ const AuthConfigSchema = z.preprocess(
23
+ (raw) => {
24
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
25
+ const obj = { ...(raw as Record<string, unknown>) };
26
+ if (obj.redirectUrl === undefined) {
27
+ if (typeof obj.callbackUrl === "string") {
28
+ obj.redirectUrl = obj.callbackUrl;
29
+ } else if (typeof obj.callbackPort === "number") {
30
+ obj.redirectUrl = `http://127.0.0.1:${obj.callbackPort}/callback`;
31
+ } else if (obj.callbackUrl !== undefined) {
32
+ obj.redirectUrl = obj.callbackUrl;
33
+ }
34
+ }
35
+ delete obj.callbackUrl;
36
+ delete obj.callbackPort;
37
+ return obj;
38
+ },
39
+ z
40
+ .object({
41
+ /** Auth type. Currently only "oauth" is supported. Default: "oauth". */
42
+ type: z.enum(["oauth"]).default("oauth"),
43
+ /**
44
+ * Callback URL for the OAuth redirect.
45
+ * Default: auto-detected local callback server.
46
+ */
47
+ redirectUrl: z.string().optional(),
48
+ /**
49
+ * Optional scope to request during authorization.
50
+ */
51
+ scope: z.string().optional(),
52
+ /**
53
+ * Pre-registered client_id (skip dynamic client registration).
54
+ */
55
+ clientId: z.string().optional(),
56
+ /**
57
+ * Pre-registered client_secret.
58
+ */
59
+ clientSecret: z.string().optional(),
60
+ })
61
+ .superRefine((auth, ctx) => {
62
+ if (auth.redirectUrl !== undefined) {
63
+ try {
64
+ callbackServerConfigFromRedirectUrl(auth.redirectUrl);
65
+ } catch (err) {
66
+ ctx.addIssue({
67
+ code: z.ZodIssueCode.custom,
68
+ path: ["redirectUrl"],
69
+ message: err instanceof Error ? err.message : String(err),
70
+ });
71
+ }
72
+ }
73
+ }),
74
+ );
42
75
 
43
76
  export const ServerConfigSchema = z.preprocess(
44
77
  (raw) => {
@@ -53,22 +86,7 @@ export const ServerConfigSchema = z.preprocess(
53
86
  obj.transport = "streamable-http";
54
87
  }
55
88
  if (obj.auth === undefined && obj.oauth && typeof obj.oauth === "object" && !Array.isArray(obj.oauth)) {
56
- const oauth = obj.oauth as Record<string, unknown>;
57
- const redirectUrl =
58
- typeof oauth.redirectUrl === "string"
59
- ? oauth.redirectUrl
60
- : typeof oauth.callbackUrl === "string"
61
- ? oauth.callbackUrl
62
- : typeof oauth.callbackPort === "number"
63
- ? `http://127.0.0.1:${oauth.callbackPort}/callback`
64
- : undefined;
65
- obj.auth = {
66
- type: "oauth",
67
- ...(redirectUrl !== undefined ? { redirectUrl } : {}),
68
- ...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
69
- ...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
70
- ...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
71
- };
89
+ obj.auth = obj.oauth;
72
90
  }
73
91
  if (obj.requestTimeoutMs === undefined && typeof obj.timeout === "number" && obj.timeout > 0) {
74
92
  obj.requestTimeoutMs = obj.timeout * 1000;
@@ -132,17 +150,24 @@ export const ServerConfigSchema = z.preprocess(
132
150
  ? `"command" is required for stdio transport`
133
151
  : `"url" is required for ${cfg.transport} transport`,
134
152
  }),
153
+ )
154
+ .refine(
155
+ (cfg) => !(cfg.transport === "stdio" && cfg.auth !== undefined),
156
+ {
157
+ message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
158
+ path: ["auth"],
159
+ },
135
160
  ),
136
161
  );
137
162
 
138
163
  const SettingsSchema = z.object({
139
164
  /**
140
165
  * Prefix used in Pi tool names: <prefix>_<server>_<tool>.
141
- * Must match [a-zA-Z0-9_]. Default: "mcp".
166
+ * Must match ^[a-zA-Z][a-zA-Z0-9_]*$. Default: "mcp".
142
167
  */
143
168
  toolPrefix: z
144
169
  .string()
145
- .regex(/^[a-zA-Z0-9_]+$/, "toolPrefix must match [a-zA-Z0-9_]")
170
+ .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
146
171
  .default("mcp"),
147
172
  /** Default per-request timeout in ms for all servers. Default: 30000. */
148
173
  requestTimeoutMs: z.number().positive().default(30000),
@@ -163,7 +188,7 @@ const McpConfigSchema = z.object({
163
188
  const ProjectSettingsSchema = z.object({
164
189
  toolPrefix: z
165
190
  .string()
166
- .regex(/^[a-zA-Z0-9_]+$/, "toolPrefix must match [a-zA-Z0-9_]")
191
+ .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
167
192
  .optional(),
168
193
  requestTimeoutMs: z.number().positive().optional(),
169
194
  maxRetries: z.number().int().min(0).max(10).optional(),
@@ -193,14 +218,105 @@ export interface LoadConfigOptions {
193
218
 
194
219
  // ─── Loader ───────────────────────────────────────────────────────────────────
195
220
 
196
- async function readJsonFile(path: string): Promise<unknown | null> {
221
+ /**
222
+ * Strip `//` line comments and `/* ... *\/` block comments outside of double-quoted
223
+ * JSON strings in a single character-by-character pass.
224
+ *
225
+ * Preserves `\n` and `\r` characters so line numbers in `JSON.parse` error messages
226
+ * remain accurate. Unterminated `/*` block comments are left intact so `JSON.parse`
227
+ * fails with a syntax error rather than silently truncating the document.
228
+ */
229
+ export function stripJsonComments(text: string): string {
230
+ let out = "";
231
+ let inString = false;
232
+ let i = 0;
233
+
234
+ while (i < text.length) {
235
+ const ch = text[i]!;
236
+
237
+ if (inString) {
238
+ if (ch === "\\") {
239
+ out += ch;
240
+ if (i + 1 < text.length) {
241
+ out += text[i + 1]!;
242
+ i += 2;
243
+ } else {
244
+ i += 1;
245
+ }
246
+ continue;
247
+ }
248
+ if (ch === '"') {
249
+ inString = false;
250
+ }
251
+ out += ch;
252
+ i += 1;
253
+ continue;
254
+ }
255
+
256
+ if (ch === '"') {
257
+ inString = true;
258
+ out += ch;
259
+ i += 1;
260
+ continue;
261
+ }
262
+
263
+ if (ch === "/" && text[i + 1] === "/") {
264
+ i += 2;
265
+ while (i < text.length && text[i] !== "\n") {
266
+ if (text[i] === "\r" && text[i + 1] === "\n") break;
267
+ i += 1;
268
+ }
269
+ continue;
270
+ }
271
+
272
+ if (ch === "/" && text[i + 1] === "*") {
273
+ const closeIdx = text.indexOf("*/", i + 2);
274
+ if (closeIdx === -1) {
275
+ // WHY: Leave unterminated block comments intact so JSON.parse fails with a
276
+ // SyntaxError rather than silently truncating the rest of the file.
277
+ out += text.slice(i);
278
+ break;
279
+ }
280
+ for (let j = i; j < closeIdx + 2; j += 1) {
281
+ const commentCh = text[j]!;
282
+ out += commentCh === "\n" || commentCh === "\r" ? commentCh : " ";
283
+ }
284
+ i = closeIdx + 2;
285
+ continue;
286
+ }
287
+
288
+ out += ch;
289
+ i += 1;
290
+ }
291
+
292
+ return out;
293
+ }
294
+
295
+ async function readJsonFile(path: string): Promise<unknown> {
296
+ let text: string;
297
+ try {
298
+ text = await readFile(path, "utf-8");
299
+ } catch (err) {
300
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
301
+ const detail = err instanceof Error ? err.message : String(err);
302
+ throw new McpError(
303
+ `Failed to read mcp.json at ${path}: ${detail}`,
304
+ "<config>",
305
+ "config",
306
+ err,
307
+ );
308
+ }
309
+
197
310
  try {
198
- const text = await readFile(path, "utf8");
199
- return JSON.parse(text) as unknown;
311
+ return JSON.parse(stripJsonComments(text));
200
312
  } catch (err) {
201
- // ENOENT → file doesn't exist, silently skip
202
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
203
- throw err;
313
+ const detail = err instanceof Error ? err.message : String(err);
314
+ throw new McpError(
315
+ `Invalid JSON in mcp.json at ${path}: ${detail}`,
316
+ "<config>",
317
+ "config",
318
+ err,
319
+ );
204
320
  }
205
321
  }
206
322
 
@@ -303,19 +419,19 @@ export async function loadConfig(
303
419
 
304
420
  const [globalRaw, projectRaw] = await Promise.all([
305
421
  readJsonFile(globalPath),
306
- includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(null),
422
+ includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(undefined),
307
423
  ]);
308
424
 
309
425
  // If neither file exists, return an empty valid config
310
- if (globalRaw === null && projectRaw === null) {
426
+ if (globalRaw === undefined && projectRaw === undefined) {
311
427
  return McpConfigSchema.parse({});
312
428
  }
313
429
 
314
- const globalCfg = globalRaw !== null
430
+ const globalCfg = globalRaw !== undefined
315
431
  ? parseConfig(globalRaw, globalPath)
316
432
  : McpConfigSchema.parse({});
317
433
 
318
- if (projectRaw === null) return globalCfg;
434
+ if (projectRaw === undefined) return globalCfg;
319
435
 
320
436
  const projectCfg = parseWith(ProjectConfigSchema, projectRaw, projectPath);
321
437
  return mergeConfigs(globalCfg, projectCfg);