pi-mcp-extension 1.9.5 → 1.9.7

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.9.5",
3
+ "version": "1.9.7",
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": [
@@ -115,6 +115,11 @@ export function parseCallbackUrl(
115
115
  "invalid_state",
116
116
  "Invalid or expired state parameter - potential CSRF attack",
117
117
  );
118
+ } else if (typeof options.expectedState === "function" && !options.expectedState(state)) {
119
+ throw new CallbackUrlValidationError(
120
+ "invalid_state",
121
+ "Invalid or expired state parameter - potential CSRF attack",
122
+ );
118
123
  }
119
124
 
120
125
  if (error) {
@@ -125,12 +130,5 @@ export function parseCallbackUrl(
125
130
  throw new CallbackUrlValidationError("missing_code", "No authorization code provided");
126
131
  }
127
132
 
128
- if (typeof options.expectedState === "function" && !options.expectedState(state)) {
129
- throw new CallbackUrlValidationError(
130
- "invalid_state",
131
- "Invalid or expired state parameter - potential CSRF attack",
132
- );
133
- }
134
-
135
133
  return { state, code };
136
134
  }
package/src/index.ts CHANGED
@@ -238,12 +238,21 @@ export async function waitForOAuthCallback(
238
238
  }
239
239
  }
240
240
 
241
- /** Resolve exact server names before interpreting the optional reset suffix. */
242
- export function parseAuthArguments(args: string, serverNames: string[]): { name: string; reset: boolean } {
241
+ /** Resolve exact server names before interpreting the optional reset flag. */
242
+ export function parseAuthArguments(args = "", serverNames: string[] = []): { name: string; reset: boolean } {
243
243
  const name = args.trim();
244
+ if (!name) return { name: "", reset: false };
244
245
  if (serverNames.includes(name)) return { name, reset: false };
245
- const reset = /\s+--reset$/.test(name);
246
- return { name: reset ? name.replace(/\s+--reset$/, "").trimEnd() : name, reset };
246
+ // WHY: Support standalone `--reset` as well as leading (`--reset <name>`) and trailing (`<name> --reset`)
247
+ // flag positions so `/mcp:auth --reset` prints the usage message rather than looking up a server named "--reset".
248
+ if (name === "--reset") return { name: "", reset: true };
249
+ if (/\s+--reset$/.test(name)) {
250
+ return { name: name.replace(/\s+--reset$/, "").trimEnd(), reset: true };
251
+ }
252
+ if (/^--reset\s+/.test(name)) {
253
+ return { name: name.replace(/^--reset\s+/, "").trimStart(), reset: true };
254
+ }
255
+ return { name, reset: false };
247
256
  }
248
257
 
249
258
  interface ServerCompletionEntry {
@@ -319,13 +328,15 @@ function mergeWithRegisteredServers(
319
328
  };
320
329
  }
321
330
 
331
+ const DEFAULT_SETTINGS = { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 } as const;
332
+
322
333
  export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
323
334
  // ── 1. Load and validate config ──────────────────────────────────────────
324
335
  // cwd and project trust are available on the ExtensionContext passed to event handlers.
325
336
  // At bootstrap (before session_start) we load ONLY the global config so untrusted
326
337
  // <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
327
338
  let fileConfig: McpConfig = {
328
- settings: { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 },
339
+ settings: { ...DEFAULT_SETTINGS },
329
340
  mcpServers: {},
330
341
  };
331
342
  try {
@@ -357,25 +368,131 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
357
368
  const declinedStartupAuth = new Set<string>();
358
369
  const startupPromptControllers = new Map<string, AbortController>();
359
370
  let authQueue: Promise<void> = Promise.resolve();
371
+ let activeStartupTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
372
+ let activeCtx: ExtensionContext | undefined;
360
373
 
361
- function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
374
+ // WHY: Allow an aborted waiter on authQueue to wake up and reject immediately without breaking
375
+ // the sequential promise chain for subsequent non-aborted waiters.
376
+ function waitForQueueTurn(previous: Promise<void>, signal: AbortSignal, serverName: string): Promise<void> {
377
+ if (signal.aborted) {
378
+ return Promise.reject(new AuthCancelledError(serverName));
379
+ }
380
+ return new Promise<void>((resolve, reject) => {
381
+ const onAbort = () => {
382
+ reject(new AuthCancelledError(serverName));
383
+ };
384
+ signal.addEventListener("abort", onAbort, { once: true });
385
+ previous.then(
386
+ () => {
387
+ signal.removeEventListener("abort", onAbort);
388
+ if (signal.aborted) {
389
+ reject(new AuthCancelledError(serverName));
390
+ } else {
391
+ resolve();
392
+ }
393
+ },
394
+ (err) => {
395
+ signal.removeEventListener("abort", onAbort);
396
+ reject(err);
397
+ },
398
+ );
399
+ });
400
+ }
401
+
402
+ async function runSerializedAuth(
403
+ serverName: string,
404
+ signal: AbortSignal,
405
+ fn: () => Promise<void>,
406
+ ): Promise<void> {
407
+ const previous = authQueue;
362
408
  let release!: () => void;
409
+ const gate = new Promise<void>((resolve) => {
410
+ release = resolve;
411
+ });
412
+ authQueue = previous.then(() => gate);
413
+ try {
414
+ await waitForQueueTurn(previous, signal, serverName);
415
+ await fn();
416
+ } finally {
417
+ release();
418
+ }
419
+ }
420
+
421
+ function syncDeclinedAuthStatus(ctx: ExtensionContext | undefined = activeCtx): void {
422
+ for (const declinedName of Array.from(declinedStartupAuth)) {
423
+ const server = manager.getServer(declinedName);
424
+ if (!server || server.state === "ready") {
425
+ declinedStartupAuth.delete(declinedName);
426
+ }
427
+ }
428
+ if (!ctx) return;
429
+ if (declinedStartupAuth.size === 0) {
430
+ ctx.ui.setStatus?.("pi-mcp", undefined);
431
+ return;
432
+ }
433
+ // WHY: Display the most recent remaining server that still needs authorization so resolving
434
+ // one server does not clear the footer hint while other declined servers are still pending.
435
+ const remaining = Array.from(declinedStartupAuth);
436
+ const displayServer = remaining[remaining.length - 1]!;
437
+ ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${displayServer}`);
438
+ }
439
+
440
+ async function teardownSessionState(ctx?: ExtensionContext): Promise<void> {
441
+ declinedStartupAuth.clear();
442
+ ctx?.ui.setStatus?.("pi-mcp", undefined);
443
+ for (const controller of startupPromptControllers.values()) {
444
+ controller.abort();
445
+ }
446
+ startupPromptControllers.clear();
447
+ const inFlightPromises = Array.from(connectAttempts.values(), (attempt) => attempt.promise);
448
+ for (const attempt of connectAttempts.values()) {
449
+ attempt.controller.abort();
450
+ }
451
+ connectAttempts.clear();
452
+ if (inFlightPromises.length > 0) {
453
+ await Promise.allSettled(inFlightPromises);
454
+ }
455
+ await stopCallbackServer().catch(() => {
456
+ // WHY: Ignore callback server shutdown errors during session teardown.
457
+ });
458
+ for (const server of manager.getAllServers()) {
459
+ bridge.removeServer(server.name);
460
+ }
461
+ await manager.shutdownAll();
462
+ }
463
+
464
+ function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
465
+ // WHY: Deduplicate startup prompts when session_start fires multiple times or while a prompt
466
+ // for the same server is already queued or active.
467
+ if (startupPromptControllers.has(name)) {
468
+ return;
469
+ }
470
+ const promptController = new AbortController();
471
+ // WHY: Register the AbortController immediately when queued (before awaiting authQueue) so
472
+ // /mcp:stop, /mcp:auth, or session teardown can cancel prompts that are still waiting in line.
473
+ startupPromptControllers.set(name, promptController);
474
+
363
475
  const previous = authQueue;
364
- authQueue = new Promise<void>((resolve) => {
476
+ let release!: () => void;
477
+ const gate = new Promise<void>((resolve) => {
365
478
  release = resolve;
366
479
  });
480
+ authQueue = previous.then(() => gate);
481
+
367
482
  void (async () => {
483
+ let currentTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
368
484
  try {
369
- await previous;
485
+ await waitForQueueTurn(previous, promptController.signal, name);
370
486
  if (
487
+ promptController.signal.aborted ||
371
488
  declinedStartupAuth.has(name) ||
372
489
  manager.getServer(name)?.state === "ready" ||
373
490
  connectAttempts.has(name)
374
491
  ) {
375
492
  return;
376
493
  }
377
- const promptController = new AbortController();
378
- startupPromptControllers.set(name, promptController);
494
+ currentTurn = { serverName: name };
495
+ activeStartupTurn = currentTurn;
379
496
  let choice: string | undefined;
380
497
  try {
381
498
  choice = await new Promise<string | undefined>((resolve, reject) => {
@@ -408,6 +525,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
408
525
  startupPromptControllers.delete(name);
409
526
  }
410
527
  }
528
+ if (currentTurn.handoffPromise) {
529
+ // WHY: An explicit connectRequested (/mcp:auth, /mcp:start, or mcp_connect) superseded
530
+ // this server's startup prompt while it held the active queue turn. Hold the queue slot
531
+ // until that connection/auth finishes before letting the next server's prompt appear.
532
+ await currentTurn.handoffPromise.catch(() => {
533
+ // WHY: Errors from an explicit connectRequested handoff are surfaced to its own caller.
534
+ });
535
+ return;
536
+ }
411
537
  if (
412
538
  promptController.signal.aborted ||
413
539
  manager.getServer(name)?.state === "ready" ||
@@ -426,7 +552,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
426
552
  }
427
553
  } else {
428
554
  declinedStartupAuth.add(name);
429
- ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
555
+ syncDeclinedAuthStatus(ctx);
430
556
  }
431
557
  } catch (err) {
432
558
  if (!(err instanceof AuthCancelledError)) {
@@ -434,6 +560,17 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
434
560
  ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
435
561
  }
436
562
  } finally {
563
+ if (startupPromptControllers.get(name) === promptController) {
564
+ startupPromptControllers.delete(name);
565
+ }
566
+ if (currentTurn && activeStartupTurn === currentTurn) {
567
+ if (currentTurn.handoffPromise) {
568
+ await currentTurn.handoffPromise.catch(() => {
569
+ // WHY: Suppress rejection here because connectRequested caller handles it.
570
+ });
571
+ }
572
+ activeStartupTurn = null;
573
+ }
437
574
  release();
438
575
  }
439
576
  })();
@@ -464,7 +601,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
464
601
  for (const [name] of serversToStart) {
465
602
  if (!authRequiredServers.has(name)) continue;
466
603
  if (declinedStartupAuth.has(name)) {
467
- ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
604
+ syncDeclinedAuthStatus(ctx);
468
605
  continue;
469
606
  }
470
607
  enqueueStartupAuthPrompt(name, ctx);
@@ -475,8 +612,6 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
475
612
  }
476
613
  }
477
614
 
478
- let activeCtx: ExtensionContext | undefined;
479
-
480
615
  manager.setToolRefreshCallback(async (serverName, client, options) => {
481
616
  await bridge.refreshTools(
482
617
  serverName,
@@ -491,11 +626,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
491
626
  manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
492
627
  manager.setRecoveryFailedCallback((serverName, error) => {
493
628
  if (error instanceof AuthRequiredError) {
629
+ declinedStartupAuth.add(serverName);
494
630
  activeCtx?.ui.notify(
495
631
  `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
496
632
  "warning",
497
633
  );
498
- activeCtx?.ui.setStatus?.("pi-mcp", `run /mcp:auth ${serverName}`);
634
+ syncDeclinedAuthStatus(activeCtx);
499
635
  } else {
500
636
  activeCtx?.ui.notify(
501
637
  `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
@@ -516,12 +652,36 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
516
652
  ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
517
653
  : true;
518
654
 
519
- let nextFileConfig = fileConfig;
655
+ let nextFileConfig: McpConfig;
520
656
  try {
521
657
  nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
522
658
  } catch (err) {
523
659
  const msg = formatErrorMessage(err);
524
660
  ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
661
+ // WHY: Switching sessions into a directory with an invalid config must tear down stale
662
+ // servers, tools, prompts, and status from the previous session rather than leaving them active.
663
+ await teardownSessionState(ctx);
664
+ fileConfig = {
665
+ settings: { ...DEFAULT_SETTINGS },
666
+ mcpServers: {},
667
+ };
668
+ extensionServers = collectRegisteredServers(
669
+ getRegisteredEntries(),
670
+ fileConfig.mcpServers,
671
+ (regErr) => {
672
+ const regMsg = formatErrorMessage(regErr);
673
+ ctx.ui.notify(`pi-mcp: Config error — ${regMsg}`, "error");
674
+ },
675
+ );
676
+ config = mergeWithRegisteredServers(fileConfig, extensionServers);
677
+ bridge.updateSettings(config.settings);
678
+ manager.rebuildServers(config);
679
+ if (Object.keys(extensionServers).length > 0) {
680
+ const eagerServers = Object.entries(config.mcpServers).filter(
681
+ ([, cfg]) => cfg.lifecycle === "eager",
682
+ );
683
+ await startEagerServers(eagerServers, ctx);
684
+ }
525
685
  return;
526
686
  }
527
687
 
@@ -539,21 +699,16 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
539
699
  // If config changed (different cwd with project-level overrides or registered servers),
540
700
  // shut down old servers and rebuild the manager's server list
541
701
  if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
542
- declinedStartupAuth.clear();
543
- for (const controller of startupPromptControllers.values()) {
544
- controller.abort();
545
- }
546
- startupPromptControllers.clear();
547
- // Deactivate and remove all tools from old config
548
- for (const server of manager.getAllServers()) {
549
- bridge.removeServer(server.name);
550
- }
551
- // Shut down all running servers
552
- await manager.shutdownAll();
702
+ await teardownSessionState(ctx);
553
703
  // Rebuild server entries from new config
554
704
  bridge.updateSettings(sessionConfig.settings);
555
705
  manager.rebuildServers(sessionConfig);
556
706
  config = sessionConfig;
707
+ } else {
708
+ // WHY: Even when config is identical across session switches, update running servers'
709
+ // cwd and emit notifications/roots/list_changed if the workspace directory changed.
710
+ manager.updateCwd(ctx.cwd);
711
+ syncDeclinedAuthStatus(ctx);
557
712
  }
558
713
 
559
714
  const eagerServers = Object.entries(sessionConfig.mcpServers).filter(
@@ -595,6 +750,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
595
750
  bridge.removeServer(name);
596
751
  await manager.removeServer(name);
597
752
  }
753
+ if (removedOrChanged.length > 0) {
754
+ syncDeclinedAuthStatus(ctx);
755
+ }
598
756
 
599
757
  const addedEager: Array<[string, ServerConfig]> = [];
600
758
  for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
@@ -616,10 +774,14 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
616
774
 
617
775
  pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
618
776
  activeCtx = undefined;
777
+ declinedStartupAuth.clear();
619
778
  for (const controller of startupPromptControllers.values()) controller.abort();
620
779
  startupPromptControllers.clear();
621
780
  for (const attempt of connectAttempts.values()) attempt.controller.abort();
622
- await stopCallbackServer().catch(() => {});
781
+ connectAttempts.clear();
782
+ await stopCallbackServer().catch(() => {
783
+ // WHY: Ignore callback server shutdown errors during session_shutdown.
784
+ });
623
785
 
624
786
  // Deactivate all tools before shutting down servers
625
787
  for (const server of manager.getAllServers()) {
@@ -675,7 +837,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
675
837
  ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
676
838
  return;
677
839
  }
840
+ declinedStartupAuth.delete(serverName);
841
+ syncDeclinedAuthStatus(ctx);
678
842
  startupPromptControllers.get(serverName)?.abort();
843
+ startupPromptControllers.delete(serverName);
679
844
  connectAttempts.get(serverName)?.controller.abort();
680
845
  bridge.deactivateServer(serverName);
681
846
  await manager.stopServer(serverName);
@@ -701,6 +866,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
701
866
  await connectRequested(serverName, ctx);
702
867
  ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
703
868
  } catch (err) {
869
+ if (err instanceof AuthCancelledError) {
870
+ ctx.ui.notify(`pi-mcp: ${err.message}`, "info");
871
+ return;
872
+ }
704
873
  const msg = formatErrorMessage(err);
705
874
  ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
706
875
  }
@@ -967,7 +1136,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
967
1136
  } finally {
968
1137
  signal.removeEventListener("abort", cancel);
969
1138
  cancel();
970
- await stopCallbackServer().catch(() => {});
1139
+ await stopCallbackServer().catch(() => {
1140
+ // WHY: Ignore callback server stop errors during authorize cleanup.
1141
+ });
971
1142
  await unlock?.();
972
1143
  }
973
1144
  }
@@ -985,9 +1156,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
985
1156
  if (signal?.aborted) throw new AuthCancelledError(serverName);
986
1157
  if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
987
1158
  if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
988
- if (!alreadyQueued) {
989
- startupPromptControllers.get(serverName)?.abort();
990
- }
1159
+
1160
+ const inheritedStartupTurn =
1161
+ !alreadyQueued && activeStartupTurn?.serverName === serverName ? activeStartupTurn : null;
991
1162
 
992
1163
  const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
993
1164
  entry.waiters++;
@@ -1011,6 +1182,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1011
1182
  onAbort = () => {
1012
1183
  releaseWaiter();
1013
1184
  if (entry.waiters === 0) {
1185
+ if (connectAttempts.get(serverName) === entry) {
1186
+ connectAttempts.delete(serverName);
1187
+ }
1014
1188
  entry.controller.abort();
1015
1189
  stopPromise = manager.stopServer(serverName);
1016
1190
  void stopPromise.finally(() => reject(new AuthCancelledError(serverName)));
@@ -1031,7 +1205,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1031
1205
 
1032
1206
  const existing = connectAttempts.get(serverName);
1033
1207
  // All requests join an in-flight reset. A reset supersedes a normal connect.
1034
- if (existing && (!reset || existing.reset)) {
1208
+ if (existing && !existing.controller.signal.aborted && (!reset || existing.reset)) {
1035
1209
  return await waitForAttempt(existing);
1036
1210
  }
1037
1211
 
@@ -1043,7 +1217,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1043
1217
  let entry!: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number };
1044
1218
  const task = (async () => {
1045
1219
  // Wait for a superseded connect to release its callback listener and auth lock.
1046
- if (existing) await existing.promise.catch(() => {});
1220
+ if (existing) {
1221
+ await existing.promise.catch(() => {
1222
+ // WHY: Ignore errors from the superseded connect attempt while waiting for cleanup.
1223
+ });
1224
+ }
1047
1225
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
1048
1226
  if (reset) {
1049
1227
  bridge.deactivateServer(serverName);
@@ -1055,7 +1233,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1055
1233
  else throw new AuthRequiredError(serverName);
1056
1234
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
1057
1235
  declinedStartupAuth.delete(serverName);
1058
- if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
1236
+ syncDeclinedAuthStatus(ctx);
1059
1237
  return;
1060
1238
  } catch (err) {
1061
1239
  if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
@@ -1068,28 +1246,31 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1068
1246
  await manager.startServer(serverName, ctx.cwd);
1069
1247
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
1070
1248
  };
1071
- if (alreadyQueued) {
1249
+ if (alreadyQueued || inheritedStartupTurn) {
1072
1250
  await runInteractiveAuth();
1073
1251
  } else {
1074
- let release!: () => void;
1075
- const previous = authQueue;
1076
- authQueue = new Promise<void>((resolve) => { release = resolve; });
1077
- try {
1078
- await previous;
1079
- await runInteractiveAuth();
1080
- } finally {
1081
- release();
1082
- }
1252
+ await runSerializedAuth(serverName, controller.signal, runInteractiveAuth);
1083
1253
  }
1084
1254
  declinedStartupAuth.delete(serverName);
1085
- if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
1255
+ syncDeclinedAuthStatus(ctx);
1086
1256
  })().catch((err: unknown) => {
1087
1257
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
1088
1258
  throw err;
1089
1259
  }).finally(() => {
1090
1260
  if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
1091
1261
  });
1092
- void task.catch(() => {});
1262
+ void task.catch(() => {
1263
+ // WHY: Prevent unhandled rejection when task settles after an aborted waiter detaches.
1264
+ });
1265
+ if (inheritedStartupTurn) {
1266
+ // WHY: Attach handoffPromise before aborting the active startup prompt so the prompt turn
1267
+ // stays held for this server's interactive auth instead of advancing authQueue to the next server.
1268
+ inheritedStartupTurn.handoffPromise = task;
1269
+ }
1270
+ if (!alreadyQueued) {
1271
+ startupPromptControllers.get(serverName)?.abort();
1272
+ startupPromptControllers.delete(serverName);
1273
+ }
1093
1274
  entry = { promise: task, controller, reset, waiters: 0 };
1094
1275
  connectAttempts.set(serverName, entry);
1095
1276
  return await waitForAttempt(entry);
@@ -381,6 +381,10 @@ export class ServerManager {
381
381
  return this.servers.get(name);
382
382
  }
383
383
 
384
+ getServerStatus(name: string): ManagedServer | undefined {
385
+ return this.servers.get(name);
386
+ }
387
+
384
388
  getAllServers(): ManagedServer[] {
385
389
  return Array.from(this.servers.values());
386
390
  }
@@ -389,6 +393,23 @@ export class ServerManager {
389
393
  return this.getAllServers().filter((s) => s.state === "ready");
390
394
  }
391
395
 
396
+ private _updateServerCwd(server: ManagedServer, cwd: string): void {
397
+ const prevCwd = server.cwd;
398
+ server.cwd = cwd;
399
+ if (prevCwd !== undefined && prevCwd !== cwd && server.state === "ready" && server.client) {
400
+ void server.client.sendRootsListChanged().catch(() => {
401
+ // WHY: Best-effort notification; some MCP servers ignore or do not support roots/list_changed.
402
+ });
403
+ }
404
+ }
405
+
406
+ /** Update workspace cwd for all managed servers and notify ready servers if cwd changed. */
407
+ updateCwd(cwd: string): void {
408
+ for (const server of this.servers.values()) {
409
+ this._updateServerCwd(server, cwd);
410
+ }
411
+ }
412
+
392
413
  /** Status summary for /mcp command. */
393
414
  getStatusSummary(): string {
394
415
  const all = this.getAllServers();
@@ -455,7 +476,7 @@ export class ServerManager {
455
476
  if (!server) {
456
477
  throw new McpError(`Unknown server "${name}"`, name, "config");
457
478
  }
458
- server.cwd = cwd;
479
+ this._updateServerCwd(server, cwd);
459
480
  if (server.attempt && !server.controller?.signal.aborted) return server.attempt;
460
481
  if (server.state === "ready") return;
461
482
  server.retryCount = 0;
@@ -526,6 +547,11 @@ export class ServerManager {
526
547
  }
527
548
  }
528
549
 
550
+ /** Alias for rebuildServers when replacing configuration on session_start. */
551
+ replaceConfig(config: McpConfig): void {
552
+ this.rebuildServers(config);
553
+ }
554
+
529
555
  // ─── Internal ───────────────────────────────────────────────────────────────
530
556
 
531
557
  private async _connectWithTimeoutAndSignal(
@@ -661,7 +687,8 @@ export class ServerManager {
661
687
  corruptStateRecovered = false,
662
688
  ): Promise<void> {
663
689
  server.lastError = null;
664
- server.cwd = cwd;
690
+ const currentCwd = server.cwd ?? cwd;
691
+ server.cwd = currentCwd;
665
692
  const timeoutMs = this._resolveTimeoutMs(server);
666
693
  // Note: retryCount is NOT reset here — it's only reset after successful connect.
667
694
  // This ensures the retry limit is respected across reconnection attempts.
@@ -677,7 +704,7 @@ export class ServerManager {
677
704
 
678
705
  let transport: Transport;
679
706
  try {
680
- transport = this.createTransport(server.name, server.config, cwd);
707
+ transport = this.createTransport(server.name, server.config, currentCwd);
681
708
  } catch (err) {
682
709
  server.state = "stopped";
683
710
  server.lastError = err instanceof Error ? err : new Error(String(err));
@@ -837,6 +864,11 @@ export class ServerManager {
837
864
  server.state = "ready";
838
865
  server.retryCount = 0;
839
866
  server.lastError = null;
867
+ if (server.cwd && server.cwd !== currentCwd) {
868
+ void client.sendRootsListChanged().catch(() => {
869
+ // WHY: Best-effort notification if cwd was updated while handshake was in flight.
870
+ });
871
+ }
840
872
  if (server.config.healthCheckIntervalMs) {
841
873
  let pingInFlight = false;
842
874
  server.healthCheckTimer = setInterval(async () => {
@@ -985,7 +1017,7 @@ export class ServerManager {
985
1017
  // without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
986
1018
  console.error(`[pi-mcp] ${error.message}`);
987
1019
  if (signal.aborted) throw new AuthCancelledError(server.name);
988
- return this._connect(server, cwd, signal, true);
1020
+ return this._connect(server, server.cwd ?? cwd, signal, true);
989
1021
  }
990
1022
  server.lastError = error;
991
1023
  if (error instanceof AuthRequiredError) {
@@ -1019,14 +1051,27 @@ export class ServerManager {
1019
1051
  }
1020
1052
  const delayMs = RETRY_DELAYS_MS[Math.min(server.retryCount++, RETRY_DELAYS_MS.length - 1)] ?? 30000;
1021
1053
  await new Promise<void>((resolve) => {
1022
- server.retryTimer = setTimeout(() => { server.retryTimer = null; resolve(); }, delayMs);
1023
- signal.addEventListener("abort", () => {
1024
- if (server.retryTimer) { clearTimeout(server.retryTimer); server.retryTimer = null; }
1054
+ if (signal.aborted) {
1055
+ resolve();
1056
+ return;
1057
+ }
1058
+ const onAbort = () => {
1059
+ if (server.retryTimer) {
1060
+ clearTimeout(server.retryTimer);
1061
+ server.retryTimer = null;
1062
+ }
1063
+ resolve();
1064
+ };
1065
+ const timer = setTimeout(() => {
1066
+ signal.removeEventListener("abort", onAbort);
1067
+ server.retryTimer = null;
1025
1068
  resolve();
1026
- }, { once: true });
1069
+ }, delayMs);
1070
+ server.retryTimer = timer;
1071
+ signal.addEventListener("abort", onAbort, { once: true });
1027
1072
  });
1028
1073
  if (signal.aborted) throw new AuthCancelledError(server.name);
1029
- return this._connect(server, cwd, signal, corruptStateRecovered);
1074
+ return this._connect(server, server.cwd ?? cwd, signal, corruptStateRecovered);
1030
1075
  }
1031
1076
  }
1032
1077
 
@@ -760,7 +760,13 @@ export class ToolBridge {
760
760
 
761
761
  /** Update bridge settings when session config is reloaded on session_start. */
762
762
  updateSettings(settings: Settings): void {
763
+ const prevDefaultTimeoutMs = this.settings.requestTimeoutMs;
763
764
  this.settings = settings;
765
+ for (const [serverName, timeoutMs] of this.serverTimeouts.entries()) {
766
+ if (timeoutMs === prevDefaultTimeoutMs) {
767
+ this.serverTimeouts.delete(serverName);
768
+ }
769
+ }
764
770
  }
765
771
 
766
772
  /**
@@ -779,10 +785,14 @@ export class ToolBridge {
779
785
  const isCurrent = typeof isCurrentOrOptions === "function" ? isCurrentOrOptions : () => true;
780
786
  const options = typeof isCurrentOrOptions === "object" ? isCurrentOrOptions : maybeOptions;
781
787
  const defaults = getClientDefaultOptions(client);
782
- const effectiveTimeoutMs =
783
- options?.requestTimeoutMs ?? defaults?.requestTimeoutMs ?? this.settings.requestTimeoutMs;
788
+ const explicitTimeoutMs = options?.requestTimeoutMs ?? defaults?.requestTimeoutMs;
789
+ const effectiveTimeoutMs = explicitTimeoutMs ?? this.settings.requestTimeoutMs;
784
790
  const effectiveSignal = options?.signal ?? defaults?.signal;
785
- this.serverTimeouts.set(serverName, effectiveTimeoutMs);
791
+ if (explicitTimeoutMs !== undefined) {
792
+ this.serverTimeouts.set(serverName, explicitTimeoutMs);
793
+ } else {
794
+ this.serverTimeouts.delete(serverName);
795
+ }
786
796
 
787
797
  const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
788
798
  this.refreshGenerations.set(serverName, gen);