pi-mcp-extension 1.8.0 → 1.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
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": {
package/src/config.ts CHANGED
@@ -40,64 +40,100 @@ const AuthConfigSchema = z.object({
40
40
  clientSecret: z.string().optional(),
41
41
  });
42
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`,
99
- }),
100
- );
43
+ export const ServerConfigSchema = z.preprocess(
44
+ (raw) => {
45
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
46
+ const obj = { ...(raw as Record<string, unknown>) };
47
+ const rawTransport = obj.transport ?? obj.type;
48
+ if (rawTransport === "http") {
49
+ obj.transport = "streamable-http";
50
+ } else if (obj.transport === undefined && rawTransport !== undefined) {
51
+ obj.transport = rawTransport;
52
+ } else if (obj.transport === undefined && obj.url !== undefined && obj.command === undefined) {
53
+ obj.transport = "streamable-http";
54
+ }
55
+ 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
+ };
72
+ }
73
+ if (obj.requestTimeoutMs === undefined && typeof obj.timeout === "number" && obj.timeout > 0) {
74
+ obj.requestTimeoutMs = obj.timeout * 1000;
75
+ }
76
+ return obj;
77
+ },
78
+ z
79
+ .object({
80
+ /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
81
+ command: z.string().optional(),
82
+ /** Arguments passed to command. */
83
+ args: z.array(z.string()).default([]),
84
+ /**
85
+ * Extra environment variables passed to the child process as literals.
86
+ * These merge with process.env; project env overrides parent env.
87
+ * No ${VAR} interpolation — set vars in your shell environment instead.
88
+ */
89
+ env: z.record(z.string()).optional(),
90
+ /** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
91
+ transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
92
+ /**
93
+ * URL for streamable-http or sse transports.
94
+ * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
95
+ */
96
+ url: z.string().url().optional(),
97
+ /**
98
+ * Static HTTP headers to include with every request (streamable-http / sse only).
99
+ * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
100
+ * For OAuth2, use the "auth" field instead.
101
+ */
102
+ headers: z.record(z.string()).optional(),
103
+ /**
104
+ * OAuth2 configuration for servers that require authorization.
105
+ * When set, the transport will use the SDK's OAuth flow (discovery,
106
+ * dynamic client registration, PKCE, token refresh).
107
+ * Only applies to streamable-http and sse transports.
108
+ */
109
+ auth: AuthConfigSchema.optional(),
110
+ /**
111
+ * "eager" — start at session_start.
112
+ * "lazy" — start manually via /mcp:start command.
113
+ */
114
+ lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
115
+ /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
116
+ requestTimeoutMs: z.number().positive().optional(),
117
+ /**
118
+ * Opt-in heartbeat interval (ping) in ms.
119
+ * Only useful for long-lived connections where you want proactive liveness checks.
120
+ * Default: disabled.
121
+ */
122
+ healthCheckIntervalMs: z.number().positive().optional(),
123
+ })
124
+ .refine(
125
+ (cfg) => {
126
+ if (cfg.transport === "stdio") return cfg.command !== undefined;
127
+ return cfg.url !== undefined;
128
+ },
129
+ (cfg) => ({
130
+ message:
131
+ cfg.transport === "stdio"
132
+ ? `"command" is required for stdio transport`
133
+ : `"url" is required for ${cfg.transport} transport`,
134
+ }),
135
+ ),
136
+ );
101
137
 
102
138
  const SettingsSchema = z.object({
103
139
  /**
@@ -145,6 +181,16 @@ export type ServerConfig = z.output<typeof ServerConfigSchema>;
145
181
  export type Settings = z.output<typeof SettingsSchema>;
146
182
  export type McpConfig = z.output<typeof McpConfigSchema>;
147
183
 
184
+ export interface LoadConfigOptions {
185
+ /**
186
+ * Whether to read project-level `<cwd>/.pi/mcp.json`.
187
+ * Set to `false` when the project is untrusted (`ctx.isProjectTrusted() === false`)
188
+ * or during bootstrap before session trust is resolved.
189
+ * Default: `true`.
190
+ */
191
+ includeProjectConfig?: boolean;
192
+ }
193
+
148
194
  // ─── Loader ───────────────────────────────────────────────────────────────────
149
195
 
150
196
  async function readJsonFile(path: string): Promise<unknown | null> {
@@ -181,6 +227,36 @@ function parseWith<S extends z.ZodTypeAny>(
181
227
  return result.data;
182
228
  }
183
229
 
230
+ /**
231
+ * Parse and normalize a server configuration registered via `pi.registerMcpServer()`.
232
+ * Extension-registered servers default to `lifecycle: "eager"` (unless `enabled: false`
233
+ * or `lifecycle` is explicitly specified) so they connect on `session_start` / `mcp_servers_change`.
234
+ */
235
+ export function parseRegisteredServerConfig(
236
+ name: string,
237
+ raw: unknown,
238
+ sourcePath = "<extension>",
239
+ ): ServerConfig {
240
+ const rawObj = raw && typeof raw === "object" && !Array.isArray(raw)
241
+ ? (raw as Record<string, unknown>)
242
+ : undefined;
243
+ const withLifecycle = rawObj && rawObj.lifecycle === undefined
244
+ ? { ...rawObj, lifecycle: rawObj.enabled === false ? "lazy" : "eager" }
245
+ : raw;
246
+ const result = ServerConfigSchema.safeParse(withLifecycle);
247
+ if (!result.success) {
248
+ const issues = result.error.issues
249
+ .map((i) => ` mcpServers.${name}${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`)
250
+ .join("\n");
251
+ throw new McpError(
252
+ `Invalid MCP server "${name}" from ${sourcePath}:\n${issues}`,
253
+ name,
254
+ "config",
255
+ );
256
+ }
257
+ return result.data;
258
+ }
259
+
184
260
  function mergeConfigs(
185
261
  globalCfg: McpConfig,
186
262
  projectCfg: ProjectConfig,
@@ -199,7 +275,7 @@ function mergeConfigs(
199
275
 
200
276
  /**
201
277
  * 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
278
+ * @earendil-works/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
203
279
  * ~/.pi/agent. Pi profiles and alternative agent dirs only work through that variable.
204
280
  */
205
281
  export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
@@ -214,15 +290,20 @@ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
214
290
 
215
291
  /**
216
292
  * Load and merge global (<agent dir>/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
217
- * Project config takes precedence over global config.
293
+ * Project config takes precedence over global config when `options.includeProjectConfig !== false`.
218
294
  * Returns a fully validated, merged config.
219
295
  */
220
- export async function loadConfig(cwd: string, globalPath = join(agentDir(), "mcp.json")): Promise<McpConfig> {
296
+ export async function loadConfig(
297
+ cwd: string,
298
+ globalPath = join(agentDir(), "mcp.json"),
299
+ options?: LoadConfigOptions,
300
+ ): Promise<McpConfig> {
301
+ const includeProjectConfig = options?.includeProjectConfig !== false;
221
302
  const projectPath = join(cwd, ".pi", "mcp.json");
222
303
 
223
304
  const [globalRaw, projectRaw] = await Promise.all([
224
305
  readJsonFile(globalPath),
225
- readJsonFile(projectPath),
306
+ includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(null),
226
307
  ]);
227
308
 
228
309
  // If neither file exists, return an empty valid config
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";
@@ -80,6 +86,13 @@ export class AuthCancelledError extends Error {
80
86
  export const authRetryOption = "Retry: open browser again";
81
87
  export const authPasteOption = "Paste redirect URL";
82
88
  export const authCancelOption = "Cancel authentication";
89
+ export const startupAuthNowOption = "Authorize now";
90
+ export const startupAuthLaterOption = "Not now";
91
+
92
+ function formatErrorMessage(err: unknown): string {
93
+ const msg = err instanceof McpError ? err.userMessage : err instanceof Error ? err.message : String(err);
94
+ return msg.replace(/^AuthRequiredError:\s*/, "");
95
+ }
83
96
 
84
97
  interface OAuthCallbackResult {
85
98
  type: "callback";
@@ -274,33 +287,199 @@ export function serverNameCompletions(
274
287
  return completions.length > 0 ? completions : null;
275
288
  }
276
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
+
277
327
  export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
278
328
  // ── 1. Load and validate config ──────────────────────────────────────────
279
- // cwd is available on the ExtensionContext passed to event handlers.
280
- // We load config lazily on session_start to get the correct per-session cwd.
281
- // For the initial load we use process.cwd() as a bootstrap path to detect
282
- // whether any config exists at all.
283
- 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
+ };
284
336
  try {
285
- config = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath);
337
+ fileConfig = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath, {
338
+ includeProjectConfig: false,
339
+ });
286
340
  } catch (err) {
287
341
  // Can't notify yet (no ctx), so log to stderr. The session_start handler
288
342
  // will re-try with the real cwd and surface errors properly.
289
343
  console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
290
- return;
291
344
  }
292
345
 
293
- if (Object.keys(config.mcpServers).length === 0) {
294
- // No servers configured — silently exit. Users can create mcp.json later.
295
- return;
296
- }
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);
297
357
 
298
358
  // ── 2. Initialize bridge components ──────────────────────────────────────
299
359
  const manager = new ServerManager(config, undefined, paths.authStorageDir);
300
360
  const bridge = new ToolBridge(config.settings, pi);
301
361
  const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
362
+ const declinedStartupAuth = new Set<string>();
363
+ const startupPromptControllers = new Map<string, AbortController>();
302
364
  let authQueue: Promise<void> = Promise.resolve();
303
365
 
366
+ function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
367
+ let release!: () => void;
368
+ const previous = authQueue;
369
+ authQueue = new Promise<void>((resolve) => {
370
+ release = resolve;
371
+ });
372
+ void (async () => {
373
+ try {
374
+ await previous;
375
+ if (
376
+ declinedStartupAuth.has(name) ||
377
+ manager.getServer(name)?.state === "ready" ||
378
+ connectAttempts.has(name)
379
+ ) {
380
+ return;
381
+ }
382
+ const promptController = new AbortController();
383
+ startupPromptControllers.set(name, promptController);
384
+ let choice: string | undefined;
385
+ try {
386
+ choice = await new Promise<string | undefined>((resolve, reject) => {
387
+ if (promptController.signal.aborted) {
388
+ resolve(undefined);
389
+ return;
390
+ }
391
+ const onAbort = () => resolve(undefined);
392
+ promptController.signal.addEventListener("abort", onAbort, { once: true });
393
+ ctx.ui
394
+ .select(
395
+ `${name} needs authorization`,
396
+ [startupAuthNowOption, startupAuthLaterOption],
397
+ { signal: promptController.signal },
398
+ )
399
+ .then(
400
+ (value) => {
401
+ promptController.signal.removeEventListener("abort", onAbort);
402
+ resolve(value);
403
+ },
404
+ (err) => {
405
+ promptController.signal.removeEventListener("abort", onAbort);
406
+ if (promptController.signal.aborted) resolve(undefined);
407
+ else reject(err);
408
+ },
409
+ );
410
+ });
411
+ } finally {
412
+ if (startupPromptControllers.get(name) === promptController) {
413
+ startupPromptControllers.delete(name);
414
+ }
415
+ }
416
+ if (
417
+ promptController.signal.aborted ||
418
+ manager.getServer(name)?.state === "ready" ||
419
+ connectAttempts.has(name)
420
+ ) {
421
+ return;
422
+ }
423
+ if (choice === startupAuthNowOption) {
424
+ try {
425
+ await connectRequested(name, ctx, undefined, false, true);
426
+ } catch (err) {
427
+ if (!(err instanceof AuthCancelledError)) {
428
+ const msg = formatErrorMessage(err);
429
+ ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
430
+ }
431
+ }
432
+ } else {
433
+ declinedStartupAuth.add(name);
434
+ ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
435
+ }
436
+ } catch (err) {
437
+ if (!(err instanceof AuthCancelledError)) {
438
+ const msg = formatErrorMessage(err);
439
+ ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
440
+ }
441
+ } finally {
442
+ release();
443
+ }
444
+ })();
445
+ }
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
+
304
483
  manager.setToolRefreshCallback(async (serverName, client) => {
305
484
  await bridge.refreshTools(serverName, client, () => {
306
485
  const server = manager.getServer(serverName);
@@ -311,19 +490,40 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
311
490
 
312
491
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
313
492
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
314
- // Reload config with the real session cwd (project config may differ)
315
- 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;
316
500
  try {
317
- sessionConfig = await loadConfig(ctx.cwd, paths.globalConfigPath);
501
+ nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
318
502
  } catch (err) {
319
- const msg = err instanceof McpError ? err.userMessage : String(err);
503
+ const msg = formatErrorMessage(err);
320
504
  ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
321
505
  return;
322
506
  }
323
507
 
324
- // 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),
325
520
  // shut down old servers and rebuild the manager's server list
326
521
  if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
522
+ declinedStartupAuth.clear();
523
+ for (const controller of startupPromptControllers.values()) {
524
+ controller.abort();
525
+ }
526
+ startupPromptControllers.clear();
327
527
  // Deactivate and remove all tools from old config
328
528
  for (const server of manager.getAllServers()) {
329
529
  bridge.removeServer(server.name);
@@ -331,6 +531,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
331
531
  // Shut down all running servers
332
532
  await manager.shutdownAll();
333
533
  // Rebuild server entries from new config
534
+ bridge.updateSettings(sessionConfig.settings);
334
535
  manager.rebuildServers(sessionConfig);
335
536
  config = sessionConfig;
336
537
  }
@@ -339,20 +540,62 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
339
540
  ([, cfg]) => cfg.lifecycle === "eager",
340
541
  );
341
542
 
342
- // Start all eager servers concurrently
343
- await Promise.allSettled(
344
- eagerServers.map(async ([name]) => {
345
- try {
346
- await manager.startServer(name, ctx.cwd);
347
- } catch (err) {
348
- const msg = err instanceof McpError ? err.userMessage : String(err);
349
- ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
350
- }
351
- }),
543
+ await startEagerServers(eagerServers, ctx);
544
+ });
545
+
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
+ },
352
559
  );
560
+
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);
566
+ }
567
+ }
568
+
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);
593
+ }
353
594
  });
354
595
 
355
596
  pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
597
+ for (const controller of startupPromptControllers.values()) controller.abort();
598
+ startupPromptControllers.clear();
356
599
  for (const attempt of connectAttempts.values()) attempt.controller.abort();
357
600
  await stopCallbackServer().catch(() => {});
358
601
 
@@ -410,6 +653,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
410
653
  ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
411
654
  return;
412
655
  }
656
+ startupPromptControllers.get(serverName)?.abort();
413
657
  connectAttempts.get(serverName)?.controller.abort();
414
658
  bridge.deactivateServer(serverName);
415
659
  await manager.stopServer(serverName);
@@ -435,7 +679,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
435
679
  await connectRequested(serverName, ctx);
436
680
  ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
437
681
  } catch (err) {
438
- const msg = err instanceof McpError ? err.userMessage : String(err);
682
+ const msg = formatErrorMessage(err);
439
683
  ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
440
684
  }
441
685
  },
@@ -653,12 +897,21 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
653
897
  }
654
898
  }
655
899
 
656
- async function connectRequested(serverName: string, ctx: ExtensionContext, signal?: AbortSignal, reset = false): Promise<void> {
900
+ async function connectRequested(
901
+ serverName: string,
902
+ ctx: ExtensionContext,
903
+ signal?: AbortSignal,
904
+ reset = false,
905
+ alreadyQueued = false,
906
+ ): Promise<void> {
657
907
  const server = manager.getServer(serverName);
658
908
  if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
659
909
  if (signal?.aborted) throw new AuthCancelledError(serverName);
660
910
  if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
661
911
  if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
912
+ if (!alreadyQueued) {
913
+ startupPromptControllers.get(serverName)?.abort();
914
+ }
662
915
 
663
916
  const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
664
917
  entry.waiters++;
@@ -725,24 +978,35 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
725
978
  if (!reset) await manager.startServer(serverName, ctx.cwd);
726
979
  else throw new AuthRequiredError(serverName);
727
980
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
981
+ declinedStartupAuth.delete(serverName);
982
+ if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
728
983
  return;
729
984
  } catch (err) {
730
985
  if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
731
986
  }
732
987
  if (!ctx.hasUI) throw new AuthRequiredError(serverName);
733
- let release!: () => void;
734
- const previous = authQueue;
735
- authQueue = new Promise<void>((resolve) => { release = resolve; });
736
- try {
737
- await previous;
988
+ const runInteractiveAuth = async (): Promise<void> => {
738
989
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
739
990
  await authorize(serverName, ctx, controller.signal, reset);
740
991
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
741
992
  await manager.startServer(serverName, ctx.cwd);
742
993
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
743
- } finally {
744
- release();
994
+ };
995
+ if (alreadyQueued) {
996
+ await runInteractiveAuth();
997
+ } else {
998
+ let release!: () => void;
999
+ const previous = authQueue;
1000
+ authQueue = new Promise<void>((resolve) => { release = resolve; });
1001
+ try {
1002
+ await previous;
1003
+ await runInteractiveAuth();
1004
+ } finally {
1005
+ release();
1006
+ }
745
1007
  }
1008
+ declinedStartupAuth.delete(serverName);
1009
+ if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
746
1010
  })().catch((err: unknown) => {
747
1011
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
748
1012
  throw err;
@@ -263,6 +263,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
263
263
  ...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
264
264
  };
265
265
  }
266
+ if (this.silent) throw new AuthRequiredError(this.serverName);
266
267
  return undefined;
267
268
  }
268
269
 
@@ -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