pi-mcp-extension 1.9.0 → 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.0",
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": [
@@ -37,8 +37,8 @@
37
37
  "zod": "^3.25.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@mariozechner/pi-ai": "*",
41
- "@mariozechner/pi-coding-agent": "*",
40
+ "@earendil-works/pi-ai": "*",
41
+ "@earendil-works/pi-coding-agent": "*",
42
42
  "typebox": "*"
43
43
  },
44
44
  "devDependencies": {
@@ -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,99 +14,160 @@ 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
- });
42
-
43
- const ServerConfigSchema = z
44
- .object({
45
- /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
46
- command: z.string().optional(),
47
- /** Arguments passed to command. */
48
- args: z.array(z.string()).default([]),
49
- /**
50
- * Extra environment variables passed to the child process as literals.
51
- * These merge with process.env; project env overrides parent env.
52
- * No ${VAR} interpolation — set vars in your shell environment instead.
53
- */
54
- env: z.record(z.string()).optional(),
55
- /** Transport protocol. Default: "stdio". */
56
- transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
57
- /**
58
- * URL for streamable-http or sse transports.
59
- * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
60
- */
61
- url: z.string().url().optional(),
62
- /**
63
- * Static HTTP headers to include with every request (streamable-http / sse only).
64
- * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
65
- * For OAuth2, use the "auth" field instead.
66
- */
67
- headers: z.record(z.string()).optional(),
68
- /**
69
- * OAuth2 configuration for servers that require authorization.
70
- * When set, the transport will use the SDK's OAuth flow (discovery,
71
- * dynamic client registration, PKCE, token refresh).
72
- * Only applies to streamable-http and sse transports.
73
- */
74
- auth: AuthConfigSchema.optional(),
75
- /**
76
- * "eager" — start at session_start.
77
- * "lazy" — start manually via /mcp:start command.
78
- */
79
- lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
80
- /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
81
- requestTimeoutMs: z.number().positive().optional(),
82
- /**
83
- * Opt-in heartbeat interval (ping) in ms.
84
- * Only useful for long-lived connections where you want proactive liveness checks.
85
- * Default: disabled.
86
- */
87
- healthCheckIntervalMs: z.number().positive().optional(),
88
- })
89
- .refine(
90
- (cfg) => {
91
- if (cfg.transport === "stdio") return cfg.command !== undefined;
92
- return cfg.url !== undefined;
93
- },
94
- (cfg) => ({
95
- message:
96
- cfg.transport === "stdio"
97
- ? `"command" is required for stdio transport`
98
- : `"url" is required for ${cfg.transport} transport`,
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
+ }
99
73
  }),
100
- );
74
+ );
75
+
76
+ export const ServerConfigSchema = z.preprocess(
77
+ (raw) => {
78
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
79
+ const obj = { ...(raw as Record<string, unknown>) };
80
+ const rawTransport = obj.transport ?? obj.type;
81
+ if (rawTransport === "http") {
82
+ obj.transport = "streamable-http";
83
+ } else if (obj.transport === undefined && rawTransport !== undefined) {
84
+ obj.transport = rawTransport;
85
+ } else if (obj.transport === undefined && obj.url !== undefined && obj.command === undefined) {
86
+ obj.transport = "streamable-http";
87
+ }
88
+ if (obj.auth === undefined && obj.oauth && typeof obj.oauth === "object" && !Array.isArray(obj.oauth)) {
89
+ obj.auth = obj.oauth;
90
+ }
91
+ if (obj.requestTimeoutMs === undefined && typeof obj.timeout === "number" && obj.timeout > 0) {
92
+ obj.requestTimeoutMs = obj.timeout * 1000;
93
+ }
94
+ return obj;
95
+ },
96
+ z
97
+ .object({
98
+ /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
99
+ command: z.string().optional(),
100
+ /** Arguments passed to command. */
101
+ args: z.array(z.string()).default([]),
102
+ /**
103
+ * Extra environment variables passed to the child process as literals.
104
+ * These merge with process.env; project env overrides parent env.
105
+ * No ${VAR} interpolation — set vars in your shell environment instead.
106
+ */
107
+ env: z.record(z.string()).optional(),
108
+ /** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
109
+ transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
110
+ /**
111
+ * URL for streamable-http or sse transports.
112
+ * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
113
+ */
114
+ url: z.string().url().optional(),
115
+ /**
116
+ * Static HTTP headers to include with every request (streamable-http / sse only).
117
+ * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
118
+ * For OAuth2, use the "auth" field instead.
119
+ */
120
+ headers: z.record(z.string()).optional(),
121
+ /**
122
+ * OAuth2 configuration for servers that require authorization.
123
+ * When set, the transport will use the SDK's OAuth flow (discovery,
124
+ * dynamic client registration, PKCE, token refresh).
125
+ * Only applies to streamable-http and sse transports.
126
+ */
127
+ auth: AuthConfigSchema.optional(),
128
+ /**
129
+ * "eager" — start at session_start.
130
+ * "lazy" — start manually via /mcp:start command.
131
+ */
132
+ lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
133
+ /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
134
+ requestTimeoutMs: z.number().positive().optional(),
135
+ /**
136
+ * Opt-in heartbeat interval (ping) in ms.
137
+ * Only useful for long-lived connections where you want proactive liveness checks.
138
+ * Default: disabled.
139
+ */
140
+ healthCheckIntervalMs: z.number().positive().optional(),
141
+ })
142
+ .refine(
143
+ (cfg) => {
144
+ if (cfg.transport === "stdio") return cfg.command !== undefined;
145
+ return cfg.url !== undefined;
146
+ },
147
+ (cfg) => ({
148
+ message:
149
+ cfg.transport === "stdio"
150
+ ? `"command" is required for stdio transport`
151
+ : `"url" is required for ${cfg.transport} transport`,
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
+ },
160
+ ),
161
+ );
101
162
 
102
163
  const SettingsSchema = z.object({
103
164
  /**
104
165
  * Prefix used in Pi tool names: <prefix>_<server>_<tool>.
105
- * Must match [a-zA-Z0-9_]. Default: "mcp".
166
+ * Must match ^[a-zA-Z][a-zA-Z0-9_]*$. Default: "mcp".
106
167
  */
107
168
  toolPrefix: z
108
169
  .string()
109
- .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")
110
171
  .default("mcp"),
111
172
  /** Default per-request timeout in ms for all servers. Default: 30000. */
112
173
  requestTimeoutMs: z.number().positive().default(30000),
@@ -127,7 +188,7 @@ const McpConfigSchema = z.object({
127
188
  const ProjectSettingsSchema = z.object({
128
189
  toolPrefix: z
129
190
  .string()
130
- .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")
131
192
  .optional(),
132
193
  requestTimeoutMs: z.number().positive().optional(),
133
194
  maxRetries: z.number().int().min(0).max(10).optional(),
@@ -145,16 +206,117 @@ export type ServerConfig = z.output<typeof ServerConfigSchema>;
145
206
  export type Settings = z.output<typeof SettingsSchema>;
146
207
  export type McpConfig = z.output<typeof McpConfigSchema>;
147
208
 
209
+ export interface LoadConfigOptions {
210
+ /**
211
+ * Whether to read project-level `<cwd>/.pi/mcp.json`.
212
+ * Set to `false` when the project is untrusted (`ctx.isProjectTrusted() === false`)
213
+ * or during bootstrap before session trust is resolved.
214
+ * Default: `true`.
215
+ */
216
+ includeProjectConfig?: boolean;
217
+ }
218
+
148
219
  // ─── Loader ───────────────────────────────────────────────────────────────────
149
220
 
150
- 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;
151
297
  try {
152
- const text = await readFile(path, "utf8");
153
- return JSON.parse(text) as unknown;
298
+ text = await readFile(path, "utf-8");
154
299
  } catch (err) {
155
- // ENOENT → file doesn't exist, silently skip
156
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
157
- throw 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
+
310
+ try {
311
+ return JSON.parse(stripJsonComments(text));
312
+ } catch (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
+ );
158
320
  }
159
321
  }
160
322
 
@@ -181,6 +343,36 @@ function parseWith<S extends z.ZodTypeAny>(
181
343
  return result.data;
182
344
  }
183
345
 
346
+ /**
347
+ * Parse and normalize a server configuration registered via `pi.registerMcpServer()`.
348
+ * Extension-registered servers default to `lifecycle: "eager"` (unless `enabled: false`
349
+ * or `lifecycle` is explicitly specified) so they connect on `session_start` / `mcp_servers_change`.
350
+ */
351
+ export function parseRegisteredServerConfig(
352
+ name: string,
353
+ raw: unknown,
354
+ sourcePath = "<extension>",
355
+ ): ServerConfig {
356
+ const rawObj = raw && typeof raw === "object" && !Array.isArray(raw)
357
+ ? (raw as Record<string, unknown>)
358
+ : undefined;
359
+ const withLifecycle = rawObj && rawObj.lifecycle === undefined
360
+ ? { ...rawObj, lifecycle: rawObj.enabled === false ? "lazy" : "eager" }
361
+ : raw;
362
+ const result = ServerConfigSchema.safeParse(withLifecycle);
363
+ if (!result.success) {
364
+ const issues = result.error.issues
365
+ .map((i) => ` mcpServers.${name}${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`)
366
+ .join("\n");
367
+ throw new McpError(
368
+ `Invalid MCP server "${name}" from ${sourcePath}:\n${issues}`,
369
+ name,
370
+ "config",
371
+ );
372
+ }
373
+ return result.data;
374
+ }
375
+
184
376
  function mergeConfigs(
185
377
  globalCfg: McpConfig,
186
378
  projectCfg: ProjectConfig,
@@ -199,7 +391,7 @@ function mergeConfigs(
199
391
 
200
392
  /**
201
393
  * Pi's agent directory, resolved the way Pi resolves it (`getAgentDir()` in
202
- * @mariozechner/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
394
+ * @earendil-works/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
203
395
  * ~/.pi/agent. Pi profiles and alternative agent dirs only work through that variable.
204
396
  */
205
397
  export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
@@ -214,27 +406,32 @@ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
214
406
 
215
407
  /**
216
408
  * Load and merge global (<agent dir>/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
217
- * Project config takes precedence over global config.
409
+ * Project config takes precedence over global config when `options.includeProjectConfig !== false`.
218
410
  * Returns a fully validated, merged config.
219
411
  */
220
- export async function loadConfig(cwd: string, globalPath = join(agentDir(), "mcp.json")): Promise<McpConfig> {
412
+ export async function loadConfig(
413
+ cwd: string,
414
+ globalPath = join(agentDir(), "mcp.json"),
415
+ options?: LoadConfigOptions,
416
+ ): Promise<McpConfig> {
417
+ const includeProjectConfig = options?.includeProjectConfig !== false;
221
418
  const projectPath = join(cwd, ".pi", "mcp.json");
222
419
 
223
420
  const [globalRaw, projectRaw] = await Promise.all([
224
421
  readJsonFile(globalPath),
225
- readJsonFile(projectPath),
422
+ includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(undefined),
226
423
  ]);
227
424
 
228
425
  // If neither file exists, return an empty valid config
229
- if (globalRaw === null && projectRaw === null) {
426
+ if (globalRaw === undefined && projectRaw === undefined) {
230
427
  return McpConfigSchema.parse({});
231
428
  }
232
429
 
233
- const globalCfg = globalRaw !== null
430
+ const globalCfg = globalRaw !== undefined
234
431
  ? parseConfig(globalRaw, globalPath)
235
432
  : McpConfigSchema.parse({});
236
433
 
237
- if (projectRaw === null) return globalCfg;
434
+ if (projectRaw === undefined) return globalCfg;
238
435
 
239
436
  const projectCfg = parseWith(ProjectConfigSchema, projectRaw, projectPath);
240
437
  return mergeConfigs(globalCfg, projectCfg);
package/src/index.ts CHANGED
@@ -7,8 +7,14 @@
7
7
  * Wires together: config → server manager → tool bridge → Pi API.
8
8
  */
9
9
 
10
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, ExtensionUIContext } from "@mariozechner/pi-coding-agent";
11
- import { loadConfig, type McpConfig } from "./config.js";
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ ExtensionCommandContext,
14
+ ExtensionUIContext,
15
+ RegisteredMcpServer,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import { loadConfig, parseRegisteredServerConfig, type McpConfig, type ServerConfig } from "./config.js";
12
18
  import { ServerManager } from "./server-manager.js";
13
19
  import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
14
20
  import * as Type from "typebox";
@@ -281,26 +287,73 @@ export function serverNameCompletions(
281
287
  return completions.length > 0 ? completions : null;
282
288
  }
283
289
 
290
+ function collectRegisteredServers(
291
+ entries: readonly RegisteredMcpServer[],
292
+ fileServers: Record<string, ServerConfig>,
293
+ onError?: (err: unknown) => void,
294
+ ): Record<string, ServerConfig> {
295
+ const registered: Record<string, ServerConfig> = {};
296
+ for (const entry of entries) {
297
+ // Servers configured in mcp.json take precedence over extension-registered servers.
298
+ if (Object.prototype.hasOwnProperty.call(fileServers, entry.name)) {
299
+ continue;
300
+ }
301
+ try {
302
+ registered[entry.name] = parseRegisteredServerConfig(
303
+ entry.name,
304
+ entry.config,
305
+ entry.extensionPath || "<extension>",
306
+ );
307
+ } catch (err) {
308
+ onError?.(err);
309
+ }
310
+ }
311
+ return registered;
312
+ }
313
+
314
+ function mergeWithRegisteredServers(
315
+ fileConfig: McpConfig,
316
+ registeredServers: Record<string, ServerConfig>,
317
+ ): McpConfig {
318
+ return {
319
+ settings: fileConfig.settings,
320
+ mcpServers: {
321
+ ...registeredServers,
322
+ ...fileConfig.mcpServers,
323
+ },
324
+ };
325
+ }
326
+
284
327
  export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
285
328
  // ── 1. Load and validate config ──────────────────────────────────────────
286
- // cwd is available on the ExtensionContext passed to event handlers.
287
- // We load config lazily on session_start to get the correct per-session cwd.
288
- // For the initial load we use process.cwd() as a bootstrap path to detect
289
- // whether any config exists at all.
290
- let config: McpConfig;
329
+ // cwd and project trust are available on the ExtensionContext passed to event handlers.
330
+ // At bootstrap (before session_start) we load ONLY the global config so untrusted
331
+ // <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
332
+ let fileConfig: McpConfig = {
333
+ settings: { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 },
334
+ mcpServers: {},
335
+ };
291
336
  try {
292
- config = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath);
337
+ fileConfig = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath, {
338
+ includeProjectConfig: false,
339
+ });
293
340
  } catch (err) {
294
341
  // Can't notify yet (no ctx), so log to stderr. The session_start handler
295
342
  // will re-try with the real cwd and surface errors properly.
296
343
  console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
297
- return;
298
344
  }
299
345
 
300
- if (Object.keys(config.mcpServers).length === 0) {
301
- // No servers configured — silently exit. Users can create mcp.json later.
302
- return;
303
- }
346
+ const getRegisteredEntries = (): RegisteredMcpServer[] =>
347
+ typeof (pi as { getMcpServers?: () => RegisteredMcpServer[] }).getMcpServers === "function"
348
+ ? pi.getMcpServers()
349
+ : [];
350
+
351
+ let extensionServers = collectRegisteredServers(
352
+ getRegisteredEntries(),
353
+ fileConfig.mcpServers,
354
+ (err) => console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`),
355
+ );
356
+ let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
304
357
 
305
358
  // ── 2. Initialize bridge components ──────────────────────────────────────
306
359
  const manager = new ServerManager(config, undefined, paths.authStorageDir);
@@ -391,6 +444,42 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
391
444
  })();
392
445
  }
393
446
 
447
+ async function startEagerServers(
448
+ serversToStart: Array<[string, ServerConfig]>,
449
+ ctx: ExtensionContext,
450
+ ): Promise<void> {
451
+ const authRequiredServers = new Set<string>();
452
+
453
+ // Start all eager servers concurrently
454
+ await Promise.allSettled(
455
+ serversToStart.map(async ([name, cfg]) => {
456
+ try {
457
+ await manager.startServer(name, ctx.cwd);
458
+ } catch (err) {
459
+ if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
460
+ authRequiredServers.add(name);
461
+ return;
462
+ }
463
+ const msg = formatErrorMessage(err);
464
+ ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
465
+ }
466
+ }),
467
+ );
468
+
469
+ for (const [name] of serversToStart) {
470
+ if (!authRequiredServers.has(name)) continue;
471
+ if (declinedStartupAuth.has(name)) {
472
+ ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
473
+ continue;
474
+ }
475
+ enqueueStartupAuthPrompt(name, ctx);
476
+ }
477
+
478
+ for (let i = 0; i < 4 * authRequiredServers.size; i++) {
479
+ await Promise.resolve();
480
+ }
481
+ }
482
+
394
483
  manager.setToolRefreshCallback(async (serverName, client) => {
395
484
  await bridge.refreshTools(serverName, client, () => {
396
485
  const server = manager.getServer(serverName);
@@ -401,17 +490,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
401
490
 
402
491
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
403
492
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
404
- // Reload config with the real session cwd (project config may differ)
405
- let sessionConfig = config;
493
+ // Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
494
+ const includeProjectConfig =
495
+ typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
496
+ ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
497
+ : true;
498
+
499
+ let nextFileConfig = fileConfig;
406
500
  try {
407
- sessionConfig = await loadConfig(ctx.cwd, paths.globalConfigPath);
501
+ nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
408
502
  } catch (err) {
409
503
  const msg = formatErrorMessage(err);
410
504
  ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
411
505
  return;
412
506
  }
413
507
 
414
- // If config changed (different cwd with project-level overrides),
508
+ fileConfig = nextFileConfig;
509
+ extensionServers = collectRegisteredServers(
510
+ getRegisteredEntries(),
511
+ fileConfig.mcpServers,
512
+ (err) => {
513
+ const msg = formatErrorMessage(err);
514
+ ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
515
+ },
516
+ );
517
+ const sessionConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
518
+
519
+ // If config changed (different cwd with project-level overrides or registered servers),
415
520
  // shut down old servers and rebuild the manager's server list
416
521
  if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
417
522
  declinedStartupAuth.clear();
@@ -426,6 +531,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
426
531
  // Shut down all running servers
427
532
  await manager.shutdownAll();
428
533
  // Rebuild server entries from new config
534
+ bridge.updateSettings(sessionConfig.settings);
429
535
  manager.rebuildServers(sessionConfig);
430
536
  config = sessionConfig;
431
537
  }
@@ -434,35 +540,56 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
434
540
  ([, cfg]) => cfg.lifecycle === "eager",
435
541
  );
436
542
 
437
- const authRequiredServers = new Set<string>();
543
+ await startEagerServers(eagerServers, ctx);
544
+ });
438
545
 
439
- // Start all eager servers concurrently
440
- await Promise.allSettled(
441
- eagerServers.map(async ([name, cfg]) => {
442
- try {
443
- await manager.startServer(name, ctx.cwd);
444
- } catch (err) {
445
- if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
446
- authRequiredServers.add(name);
447
- return;
448
- }
449
- const msg = formatErrorMessage(err);
450
- ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
451
- }
452
- }),
546
+ // Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
547
+ pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
548
+ const entries = Array.isArray((event as { servers?: unknown })?.servers)
549
+ ? ((event as { servers: RegisteredMcpServer[] }).servers)
550
+ : getRegisteredEntries();
551
+
552
+ const nextExtensionServers = collectRegisteredServers(
553
+ entries,
554
+ fileConfig.mcpServers,
555
+ (err) => {
556
+ const msg = formatErrorMessage(err);
557
+ ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
558
+ },
453
559
  );
454
560
 
455
- for (const [name] of eagerServers) {
456
- if (!authRequiredServers.has(name)) continue;
457
- if (declinedStartupAuth.has(name)) {
458
- ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
459
- continue;
561
+ const removedOrChanged: string[] = [];
562
+ for (const [name, prevCfg] of Object.entries(extensionServers)) {
563
+ const nextCfg = nextExtensionServers[name];
564
+ if (!nextCfg || JSON.stringify(prevCfg) !== JSON.stringify(nextCfg)) {
565
+ removedOrChanged.push(name);
460
566
  }
461
- enqueueStartupAuthPrompt(name, ctx);
462
567
  }
463
568
 
464
- for (let i = 0; i < 4 * authRequiredServers.size; i++) {
465
- await Promise.resolve();
569
+ for (const name of removedOrChanged) {
570
+ declinedStartupAuth.delete(name);
571
+ startupPromptControllers.get(name)?.abort();
572
+ startupPromptControllers.delete(name);
573
+ connectAttempts.get(name)?.controller.abort();
574
+ bridge.removeServer(name);
575
+ await manager.removeServer(name);
576
+ }
577
+
578
+ const addedEager: Array<[string, ServerConfig]> = [];
579
+ for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
580
+ if (!manager.getServer(name)) {
581
+ manager.setServer(name, nextCfg);
582
+ if (nextCfg.lifecycle === "eager") {
583
+ addedEager.push([name, nextCfg]);
584
+ }
585
+ }
586
+ }
587
+
588
+ extensionServers = nextExtensionServers;
589
+ config = mergeWithRegisteredServers(fileConfig, extensionServers);
590
+
591
+ if (addedEager.length > 0) {
592
+ await startEagerServers(addedEager, ctx);
466
593
  }
467
594
  });
468
595
 
@@ -256,6 +256,30 @@ export class ServerManager {
256
256
  );
257
257
  }
258
258
 
259
+ /** Add or replace a stopped server entry in the manager. */
260
+ setServer(name: string, serverConfig: ServerConfig): void {
261
+ this.servers.set(name, {
262
+ name,
263
+ config: serverConfig,
264
+ state: "stopped",
265
+ client: null,
266
+ childPid: null,
267
+ retryCount: 0,
268
+ lastError: null,
269
+ stderrLog: [],
270
+ healthCheckTimer: null,
271
+ retryTimer: null,
272
+ });
273
+ }
274
+
275
+ /** Shut down and remove a server entry from the manager. */
276
+ async removeServer(name: string): Promise<void> {
277
+ const server = this.servers.get(name);
278
+ if (!server) return;
279
+ await this._shutdown(server);
280
+ this.servers.delete(name);
281
+ }
282
+
259
283
  /**
260
284
  * Rebuild the server map from a new config.
261
285
  * Must only be called after shutdownAll() — old servers are discarded.
@@ -266,18 +290,7 @@ export class ServerManager {
266
290
  this.settings = config.settings;
267
291
  this.servers.clear();
268
292
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
269
- this.servers.set(name, {
270
- name,
271
- config: serverConfig,
272
- state: "stopped",
273
- client: null,
274
- childPid: null,
275
- retryCount: 0,
276
- lastError: null,
277
- stderrLog: [],
278
- healthCheckTimer: null,
279
- retryTimer: null,
280
- });
293
+ this.setServer(name, serverConfig);
281
294
  }
282
295
  }
283
296
 
@@ -16,7 +16,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
16
  import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
17
17
  import * as Type from "typebox";
18
18
  import type { TSchema } from "typebox";
19
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
20
  import { McpError } from "./errors.js";
21
21
  import type { Settings } from "./config.js";
22
22
 
@@ -313,7 +313,7 @@ export async function listAllTools(
313
313
  * Tools are registered once and activated/deactivated as servers connect/disconnect.
314
314
  */
315
315
  export class ToolBridge {
316
- private readonly settings: Settings;
316
+ private settings: Settings;
317
317
  private readonly pi: PiExtensionAPI;
318
318
  /** Tracks which Pi tool names belong to which MCP server. */
319
319
  private readonly serverToolNames = new Map<string, Set<string>>();
@@ -323,6 +323,11 @@ export class ToolBridge {
323
323
  this.pi = pi;
324
324
  }
325
325
 
326
+ /** Update bridge settings when session config is reloaded on session_start. */
327
+ updateSettings(settings: Settings): void {
328
+ this.settings = settings;
329
+ }
330
+
326
331
  /**
327
332
  * Refresh tools for a server — called on initial connect and on list_changed.
328
333
  * Always re-registers tools with the current client reference so that