pi-mcp-extension 1.9.4 → 1.9.6

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.4",
3
+ "version": "1.9.6",
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/errors.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  export type McpErrorCode =
8
8
  | "config" // Configuration loading or validation failed
9
9
  | "connection" // Transport/connection failed
10
+ | "auth" // Authentication/authorization failed
11
+ | "timeout" // Request or connection timed out
10
12
  | "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
11
13
  | "tool"; // Tool execution error (isError: true from server)
12
14
 
@@ -33,3 +35,11 @@ export class McpError extends Error {
33
35
  return `[${this.server}] ${this.message}`;
34
36
  }
35
37
  }
38
+
39
+ export class AuthCancelledError extends Error {
40
+ constructor(serverName: string) {
41
+ super(`Authentication cancelled for ${serverName}`);
42
+ this.name = "AuthCancelledError";
43
+ }
44
+ }
45
+
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import { ServerManager } from "./server-manager.js";
19
19
  import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
20
20
  import * as Type from "typebox";
21
21
  import { ToolBridge } from "./tool-bridge.js";
22
- import { McpError } from "./errors.js";
22
+ import { McpError, AuthCancelledError } from "./errors.js";
23
23
  import { spawn } from "node:child_process";
24
24
 
25
25
  // OAuth imports
@@ -76,12 +76,7 @@ function openBrowser(url: string): Promise<void> {
76
76
  });
77
77
  }
78
78
 
79
- export class AuthCancelledError extends Error {
80
- constructor(serverName: string) {
81
- super(`Authentication cancelled for ${serverName}`);
82
- this.name = "AuthCancelledError";
83
- }
84
- }
79
+ export { AuthCancelledError } from "./errors.js";
85
80
 
86
81
  export const authRetryOption = "Retry: open browser again";
87
82
  export const authPasteOption = "Paste redirect URL";
@@ -243,12 +238,21 @@ export async function waitForOAuthCallback(
243
238
  }
244
239
  }
245
240
 
246
- /** Resolve exact server names before interpreting the optional reset suffix. */
247
- 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 } {
248
243
  const name = args.trim();
244
+ if (!name) return { name: "", reset: false };
249
245
  if (serverNames.includes(name)) return { name, reset: false };
250
- const reset = /\s+--reset$/.test(name);
251
- 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 };
252
256
  }
253
257
 
254
258
  interface ServerCompletionEntry {
@@ -324,13 +328,15 @@ function mergeWithRegisteredServers(
324
328
  };
325
329
  }
326
330
 
331
+ const DEFAULT_SETTINGS = { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 } as const;
332
+
327
333
  export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
328
334
  // ── 1. Load and validate config ──────────────────────────────────────────
329
335
  // cwd and project trust are available on the ExtensionContext passed to event handlers.
330
336
  // At bootstrap (before session_start) we load ONLY the global config so untrusted
331
337
  // <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
332
338
  let fileConfig: McpConfig = {
333
- settings: { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 },
339
+ settings: { ...DEFAULT_SETTINGS },
334
340
  mcpServers: {},
335
341
  };
336
342
  try {
@@ -362,25 +368,131 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
362
368
  const declinedStartupAuth = new Set<string>();
363
369
  const startupPromptControllers = new Map<string, AbortController>();
364
370
  let authQueue: Promise<void> = Promise.resolve();
371
+ let activeStartupTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
372
+ let activeCtx: ExtensionContext | undefined;
373
+
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
+ }
365
401
 
366
- function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
402
+ async function runSerializedAuth(
403
+ serverName: string,
404
+ signal: AbortSignal,
405
+ fn: () => Promise<void>,
406
+ ): Promise<void> {
407
+ const previous = authQueue;
367
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
+
368
475
  const previous = authQueue;
369
- authQueue = new Promise<void>((resolve) => {
476
+ let release!: () => void;
477
+ const gate = new Promise<void>((resolve) => {
370
478
  release = resolve;
371
479
  });
480
+ authQueue = previous.then(() => gate);
481
+
372
482
  void (async () => {
483
+ let currentTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
373
484
  try {
374
- await previous;
485
+ await waitForQueueTurn(previous, promptController.signal, name);
375
486
  if (
487
+ promptController.signal.aborted ||
376
488
  declinedStartupAuth.has(name) ||
377
489
  manager.getServer(name)?.state === "ready" ||
378
490
  connectAttempts.has(name)
379
491
  ) {
380
492
  return;
381
493
  }
382
- const promptController = new AbortController();
383
- startupPromptControllers.set(name, promptController);
494
+ currentTurn = { serverName: name };
495
+ activeStartupTurn = currentTurn;
384
496
  let choice: string | undefined;
385
497
  try {
386
498
  choice = await new Promise<string | undefined>((resolve, reject) => {
@@ -413,6 +525,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
413
525
  startupPromptControllers.delete(name);
414
526
  }
415
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
+ }
416
537
  if (
417
538
  promptController.signal.aborted ||
418
539
  manager.getServer(name)?.state === "ready" ||
@@ -431,7 +552,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
431
552
  }
432
553
  } else {
433
554
  declinedStartupAuth.add(name);
434
- ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
555
+ syncDeclinedAuthStatus(ctx);
435
556
  }
436
557
  } catch (err) {
437
558
  if (!(err instanceof AuthCancelledError)) {
@@ -439,6 +560,17 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
439
560
  ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
440
561
  }
441
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
+ }
442
574
  release();
443
575
  }
444
576
  })();
@@ -469,7 +601,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
469
601
  for (const [name] of serversToStart) {
470
602
  if (!authRequiredServers.has(name)) continue;
471
603
  if (declinedStartupAuth.has(name)) {
472
- ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
604
+ syncDeclinedAuthStatus(ctx);
473
605
  continue;
474
606
  }
475
607
  enqueueStartupAuthPrompt(name, ctx);
@@ -480,31 +612,76 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
480
612
  }
481
613
  }
482
614
 
483
- manager.setToolRefreshCallback(async (serverName, client) => {
484
- await bridge.refreshTools(serverName, client, () => {
485
- const server = manager.getServer(serverName);
486
- return server?.client === client && server.state !== "stopped";
487
- });
615
+ manager.setToolRefreshCallback(async (serverName, client, options) => {
616
+ await bridge.refreshTools(
617
+ serverName,
618
+ client,
619
+ () => {
620
+ const server = manager.getServer(serverName);
621
+ return server?.client === client && server.state !== "stopped";
622
+ },
623
+ options,
624
+ );
488
625
  });
489
626
  manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
627
+ manager.setRecoveryFailedCallback((serverName, error) => {
628
+ if (error instanceof AuthRequiredError) {
629
+ declinedStartupAuth.add(serverName);
630
+ activeCtx?.ui.notify(
631
+ `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
632
+ "warning",
633
+ );
634
+ syncDeclinedAuthStatus(activeCtx);
635
+ } else {
636
+ activeCtx?.ui.notify(
637
+ `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
638
+ "error",
639
+ );
640
+ }
641
+ });
490
642
  bridge.setAuthErrorCallback(async (serverName, err) => {
491
643
  await manager.handleUnauthorizedServerError(serverName, err);
492
644
  });
493
645
 
494
646
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
495
647
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
648
+ activeCtx = ctx;
496
649
  // Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
497
650
  const includeProjectConfig =
498
651
  typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
499
652
  ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
500
653
  : true;
501
654
 
502
- let nextFileConfig = fileConfig;
655
+ let nextFileConfig: McpConfig;
503
656
  try {
504
657
  nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
505
658
  } catch (err) {
506
659
  const msg = formatErrorMessage(err);
507
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
+ }
508
685
  return;
509
686
  }
510
687
 
@@ -522,21 +699,16 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
522
699
  // If config changed (different cwd with project-level overrides or registered servers),
523
700
  // shut down old servers and rebuild the manager's server list
524
701
  if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
525
- declinedStartupAuth.clear();
526
- for (const controller of startupPromptControllers.values()) {
527
- controller.abort();
528
- }
529
- startupPromptControllers.clear();
530
- // Deactivate and remove all tools from old config
531
- for (const server of manager.getAllServers()) {
532
- bridge.removeServer(server.name);
533
- }
534
- // Shut down all running servers
535
- await manager.shutdownAll();
702
+ await teardownSessionState(ctx);
536
703
  // Rebuild server entries from new config
537
704
  bridge.updateSettings(sessionConfig.settings);
538
705
  manager.rebuildServers(sessionConfig);
539
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);
540
712
  }
541
713
 
542
714
  const eagerServers = Object.entries(sessionConfig.mcpServers).filter(
@@ -548,6 +720,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
548
720
 
549
721
  // Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
550
722
  pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
723
+ activeCtx = ctx;
551
724
  const entries = Array.isArray((event as { servers?: unknown })?.servers)
552
725
  ? ((event as { servers: RegisteredMcpServer[] }).servers)
553
726
  : getRegisteredEntries();
@@ -577,6 +750,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
577
750
  bridge.removeServer(name);
578
751
  await manager.removeServer(name);
579
752
  }
753
+ if (removedOrChanged.length > 0) {
754
+ syncDeclinedAuthStatus(ctx);
755
+ }
580
756
 
581
757
  const addedEager: Array<[string, ServerConfig]> = [];
582
758
  for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
@@ -597,10 +773,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
597
773
  });
598
774
 
599
775
  pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
776
+ activeCtx = undefined;
777
+ declinedStartupAuth.clear();
600
778
  for (const controller of startupPromptControllers.values()) controller.abort();
601
779
  startupPromptControllers.clear();
602
780
  for (const attempt of connectAttempts.values()) attempt.controller.abort();
603
- await stopCallbackServer().catch(() => {});
781
+ connectAttempts.clear();
782
+ await stopCallbackServer().catch(() => {
783
+ // WHY: Ignore callback server shutdown errors during session_shutdown.
784
+ });
604
785
 
605
786
  // Deactivate all tools before shutting down servers
606
787
  for (const server of manager.getAllServers()) {
@@ -656,7 +837,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
656
837
  ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
657
838
  return;
658
839
  }
840
+ declinedStartupAuth.delete(serverName);
841
+ syncDeclinedAuthStatus(ctx);
659
842
  startupPromptControllers.get(serverName)?.abort();
843
+ startupPromptControllers.delete(serverName);
660
844
  connectAttempts.get(serverName)?.controller.abort();
661
845
  bridge.deactivateServer(serverName);
662
846
  await manager.stopServer(serverName);
@@ -682,6 +866,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
682
866
  await connectRequested(serverName, ctx);
683
867
  ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
684
868
  } catch (err) {
869
+ if (err instanceof AuthCancelledError) {
870
+ ctx.ui.notify(`pi-mcp: ${err.message}`, "info");
871
+ return;
872
+ }
685
873
  const msg = formatErrorMessage(err);
686
874
  ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
687
875
  }
@@ -948,7 +1136,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
948
1136
  } finally {
949
1137
  signal.removeEventListener("abort", cancel);
950
1138
  cancel();
951
- await stopCallbackServer().catch(() => {});
1139
+ await stopCallbackServer().catch(() => {
1140
+ // WHY: Ignore callback server stop errors during authorize cleanup.
1141
+ });
952
1142
  await unlock?.();
953
1143
  }
954
1144
  }
@@ -960,14 +1150,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
960
1150
  reset = false,
961
1151
  alreadyQueued = false,
962
1152
  ): Promise<void> {
1153
+ activeCtx = ctx;
963
1154
  const server = manager.getServer(serverName);
964
1155
  if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
965
1156
  if (signal?.aborted) throw new AuthCancelledError(serverName);
966
1157
  if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
967
1158
  if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
968
- if (!alreadyQueued) {
969
- startupPromptControllers.get(serverName)?.abort();
970
- }
1159
+
1160
+ const inheritedStartupTurn =
1161
+ !alreadyQueued && activeStartupTurn?.serverName === serverName ? activeStartupTurn : null;
971
1162
 
972
1163
  const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
973
1164
  entry.waiters++;
@@ -991,6 +1182,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
991
1182
  onAbort = () => {
992
1183
  releaseWaiter();
993
1184
  if (entry.waiters === 0) {
1185
+ if (connectAttempts.get(serverName) === entry) {
1186
+ connectAttempts.delete(serverName);
1187
+ }
994
1188
  entry.controller.abort();
995
1189
  stopPromise = manager.stopServer(serverName);
996
1190
  void stopPromise.finally(() => reject(new AuthCancelledError(serverName)));
@@ -1011,7 +1205,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1011
1205
 
1012
1206
  const existing = connectAttempts.get(serverName);
1013
1207
  // All requests join an in-flight reset. A reset supersedes a normal connect.
1014
- if (existing && (!reset || existing.reset)) {
1208
+ if (existing && !existing.controller.signal.aborted && (!reset || existing.reset)) {
1015
1209
  return await waitForAttempt(existing);
1016
1210
  }
1017
1211
 
@@ -1023,7 +1217,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1023
1217
  let entry!: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number };
1024
1218
  const task = (async () => {
1025
1219
  // Wait for a superseded connect to release its callback listener and auth lock.
1026
- 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
+ }
1027
1225
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
1028
1226
  if (reset) {
1029
1227
  bridge.deactivateServer(serverName);
@@ -1035,7 +1233,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1035
1233
  else throw new AuthRequiredError(serverName);
1036
1234
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
1037
1235
  declinedStartupAuth.delete(serverName);
1038
- if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
1236
+ syncDeclinedAuthStatus(ctx);
1039
1237
  return;
1040
1238
  } catch (err) {
1041
1239
  if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
@@ -1048,28 +1246,31 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1048
1246
  await manager.startServer(serverName, ctx.cwd);
1049
1247
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
1050
1248
  };
1051
- if (alreadyQueued) {
1249
+ if (alreadyQueued || inheritedStartupTurn) {
1052
1250
  await runInteractiveAuth();
1053
1251
  } else {
1054
- let release!: () => void;
1055
- const previous = authQueue;
1056
- authQueue = new Promise<void>((resolve) => { release = resolve; });
1057
- try {
1058
- await previous;
1059
- await runInteractiveAuth();
1060
- } finally {
1061
- release();
1062
- }
1252
+ await runSerializedAuth(serverName, controller.signal, runInteractiveAuth);
1063
1253
  }
1064
1254
  declinedStartupAuth.delete(serverName);
1065
- if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
1255
+ syncDeclinedAuthStatus(ctx);
1066
1256
  })().catch((err: unknown) => {
1067
1257
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
1068
1258
  throw err;
1069
1259
  }).finally(() => {
1070
1260
  if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
1071
1261
  });
1072
- 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
+ }
1073
1274
  entry = { promise: task, controller, reset, waiters: 0 };
1074
1275
  connectAttempts.set(serverName, entry);
1075
1276
  return await waitForAttempt(entry);