pi-mcp-extension 1.9.3 → 1.9.5

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.3",
3
+ "version": "1.9.5",
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": [
@@ -17,6 +17,31 @@ export interface ManualAuthChallengeOptions {
17
17
  fetchFn?: AuthChallengeFetch | undefined;
18
18
  timeoutMs?: number | undefined;
19
19
  headers?: Record<string, string> | undefined;
20
+ signal?: AbortSignal | undefined;
21
+ }
22
+
23
+ /**
24
+ * Wrap a fetch implementation so configured server headers are attached as
25
+ * defaults on every request (e.g. during SDK OAuth metadata discovery and
26
+ * token exchange) without overriding explicit per-request headers.
27
+ */
28
+ export function createHeadersFetch(
29
+ baseHeaders?: Record<string, string>,
30
+ baseFetch: typeof fetch = fetch,
31
+ ): typeof fetch {
32
+ if (!baseHeaders || Object.keys(baseHeaders).length === 0) {
33
+ return baseFetch;
34
+ }
35
+ return async (input, init) => {
36
+ const merged = new Headers(baseHeaders);
37
+ if (init?.headers) {
38
+ const incoming = new Headers(init.headers);
39
+ incoming.forEach((value, key) => {
40
+ merged.set(key, value);
41
+ });
42
+ }
43
+ return baseFetch(input, { ...init, headers: merged });
44
+ };
20
45
  }
21
46
 
22
47
  /**
@@ -32,14 +57,33 @@ export async function discoverManualAuthChallenge(
32
57
  options: ManualAuthChallengeOptions = {},
33
58
  ): Promise<ManualAuthChallenge> {
34
59
  const fetchFn = options.fetchFn ?? fetch;
60
+ const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_CHALLENGE_TIMEOUT_MS);
61
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
35
62
  const headers = new Headers(options.headers);
36
63
  headers.set("Accept", "application/json, text/event-stream");
37
- const response = await fetchFn(serverUrl, {
64
+ let response = await fetchFn(serverUrl, {
38
65
  method: "GET",
39
66
  headers,
40
- signal: AbortSignal.timeout(options.timeoutMs ?? AUTH_CHALLENGE_TIMEOUT_MS),
67
+ signal,
41
68
  });
42
69
 
70
+ if (response.status === 405) {
71
+ // WHY: Streamable HTTP MCP servers may only accept POST and return 405 on GET without
72
+ // a WWW-Authenticate challenge. Probe with a minimal JSON-RPC ping POST to elicit 401/403.
73
+ await response.body?.cancel().catch(() => {
74
+ // WHY: Body cancel errors during challenge probe cleanup are non-fatal.
75
+ });
76
+ const postHeaders = new Headers(options.headers);
77
+ postHeaders.set("Accept", "application/json, text/event-stream");
78
+ postHeaders.set("Content-Type", "application/json");
79
+ response = await fetchFn(serverUrl, {
80
+ method: "POST",
81
+ headers: postHeaders,
82
+ body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 0 }),
83
+ signal,
84
+ });
85
+ }
86
+
43
87
  try {
44
88
  if (response.status !== 401 && response.status !== 403) {
45
89
  return {};
@@ -55,6 +99,8 @@ export async function discoverManualAuthChallenge(
55
99
  ...(scope ? { scope } : {}),
56
100
  };
57
101
  } finally {
58
- await response.body?.cancel().catch(() => {});
102
+ await response.body?.cancel().catch(() => {
103
+ // WHY: Body cancel errors during challenge probe cleanup are non-fatal.
104
+ });
59
105
  }
60
106
  }
package/src/auth-lock.ts CHANGED
@@ -91,7 +91,7 @@ export interface AuthLockTimer {
91
91
  export interface AuthLockClock {
92
92
  now(): number;
93
93
  setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval> | AuthLockTimer;
94
- clearInterval(timer: any): void;
94
+ clearInterval(timer: ReturnType<typeof setInterval> | AuthLockTimer): void;
95
95
  }
96
96
 
97
97
  export interface AuthLockDeps {
@@ -108,6 +108,7 @@ export interface AcquireAuthLockOptions {
108
108
  }
109
109
 
110
110
  export type AuthLockHandle = (() => Promise<void>) & {
111
+ unlock: () => Promise<void>;
111
112
  assertHeld: () => Promise<void>;
112
113
  isLost: () => boolean;
113
114
  };
@@ -153,7 +154,7 @@ export function createDefaultAuthLockDeps(): AuthLockDeps {
153
154
  clock: {
154
155
  now: () => Date.now(),
155
156
  setInterval: (cb, ms) => setInterval(cb, ms),
156
- clearInterval: (timer) => clearInterval(timer),
157
+ clearInterval: (timer) => clearInterval(timer as ReturnType<typeof setInterval>),
157
158
  },
158
159
  pid: process.pid,
159
160
  hostname: osHostname(),
@@ -204,6 +205,33 @@ function formatHolderDetails(record: LockRecord | null): string | undefined {
204
205
 
205
206
  const MAX_ACQUIRE_ATTEMPTS = 3;
206
207
 
208
+ async function readLockRecordWithRetry(
209
+ fs: AuthLockFs,
210
+ path: string,
211
+ usingRealFs = false,
212
+ ): Promise<LockRecord | null> {
213
+ for (let i = 0; i < 3; i++) {
214
+ try {
215
+ const raw = await fs.readFile(path, "utf8");
216
+ return parseLockRecord(raw);
217
+ } catch (err) {
218
+ if ((err as NodeJS.ErrnoException).code === "ENOENT" && i < 2) {
219
+ // WHY: During stale-lock inspection, a contender briefly renames `path` to
220
+ // `movedPath` and restores it via `link(movedPath, path)` on nonce mismatch.
221
+ // Yield across microtasks (and a brief macrotask on real fs) before treating ENOENT as genuine lock loss.
222
+ await Promise.resolve();
223
+ await Promise.resolve();
224
+ if (usingRealFs && i === 1) {
225
+ await new Promise<void>((resolve) => setTimeout(resolve, 5));
226
+ }
227
+ continue;
228
+ }
229
+ return null;
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+
207
235
  export async function acquireAuthLock(
208
236
  serverName: string,
209
237
  storageDir?: string,
@@ -239,8 +267,12 @@ export async function acquireAuthLock(
239
267
  await file.writeFile(JSON.stringify(myRecord));
240
268
  await file.sync();
241
269
  } catch (writeErr) {
242
- await file.close().catch(() => {});
243
- await deps.fs.unlink(path).catch(() => {});
270
+ await file.close().catch(() => {
271
+ // WHY: Best-effort file handle close after write failure.
272
+ });
273
+ await deps.fs.unlink(path).catch(() => {
274
+ // WHY: Best-effort cleanup of partially written lock file before rethrowing writeErr.
275
+ });
244
276
  throw writeErr;
245
277
  }
246
278
  await file.close();
@@ -257,7 +289,9 @@ export async function acquireAuthLock(
257
289
  };
258
290
 
259
291
  const onExit = () => {
260
- if (released || lost || !usingRealFs) return;
292
+ // WHY: Do not skip cleanup when `lost` is true — if a contender restored our
293
+ // lock file via `link()` after a transient rename, we must still unlink our own nonce.
294
+ if (released || !usingRealFs) return;
261
295
  try {
262
296
  const raw = readFileSync(path, "utf8");
263
297
  const current = parseLockRecord(raw);
@@ -265,7 +299,7 @@ export async function acquireAuthLock(
265
299
  unlinkSync(path);
266
300
  }
267
301
  } catch {
268
- // Best-effort cleanup on process exit
302
+ // WHY: Best-effort synchronous cleanup on process exit; lock file may already be unlinked.
269
303
  }
270
304
  };
271
305
 
@@ -273,7 +307,6 @@ export async function acquireAuthLock(
273
307
  if (lost || released) return;
274
308
  lost = true;
275
309
  stopHeartbeat();
276
- process.removeListener("exit", onExit);
277
310
  options?.onLost?.();
278
311
  };
279
312
 
@@ -281,13 +314,7 @@ export async function acquireAuthLock(
281
314
 
282
315
  heartbeatTimer = deps.clock.setInterval(async () => {
283
316
  if (released || lost) return;
284
- let current: LockRecord | null = null;
285
- try {
286
- const raw = await deps.fs.readFile(path, "utf8");
287
- current = parseLockRecord(raw);
288
- } catch {
289
- current = null;
290
- }
317
+ const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
291
318
  if (released || lost) return;
292
319
  if (!current || current.nonce !== myRecord.nonce) {
293
320
  markLost();
@@ -298,7 +325,15 @@ export async function acquireAuthLock(
298
325
  await deps.fs.utimes(path, nowSec, nowSec);
299
326
  } catch {
300
327
  if (!released && !lost) {
301
- markLost();
328
+ const after = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
329
+ if (!after || after.nonce !== myRecord.nonce) {
330
+ markLost();
331
+ } else {
332
+ await deps.fs.utimes(path, nowSec, nowSec).catch(() => {
333
+ // WHY: If utimes still fails after verifying the restored lock nonce, mark lock lost.
334
+ if (!released && !lost) markLost();
335
+ });
336
+ }
302
337
  }
303
338
  }
304
339
  }, HEARTBEAT_MS);
@@ -308,13 +343,7 @@ export async function acquireAuthLock(
308
343
  if (lost || released) {
309
344
  throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
310
345
  }
311
- let current: LockRecord | null = null;
312
- try {
313
- const raw = await deps.fs.readFile(path, "utf8");
314
- current = parseLockRecord(raw);
315
- } catch {
316
- current = null;
317
- }
346
+ const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
318
347
  if (!current || current.nonce !== myRecord.nonce) {
319
348
  markLost();
320
349
  throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
@@ -326,20 +355,18 @@ export async function acquireAuthLock(
326
355
  released = true;
327
356
  stopHeartbeat();
328
357
  process.removeListener("exit", onExit);
329
- if (lost) return;
330
- let current: LockRecord | null = null;
331
- try {
332
- const raw = await deps.fs.readFile(path, "utf8");
333
- current = parseLockRecord(raw);
334
- } catch {
335
- current = null;
336
- }
358
+ // WHY: Always check and unlink if the file on disk still carries our nonce, even if
359
+ // `lost` was set during a contender's temporary stale-check rename+link window.
360
+ const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
337
361
  if (current && current.nonce === myRecord.nonce) {
338
- await deps.fs.unlink(path).catch(() => {});
362
+ await deps.fs.unlink(path).catch(() => {
363
+ // WHY: Best-effort lock removal on unlock; ignore ENOENT if concurrently removed.
364
+ });
339
365
  }
340
366
  };
341
367
 
342
368
  return Object.assign(unlock, {
369
+ unlock,
343
370
  assertHeld,
344
371
  isLost: () => lost,
345
372
  });
@@ -410,12 +437,18 @@ export async function acquireAuthLock(
410
437
  }
411
438
 
412
439
  if (matchesStale) {
413
- await deps.fs.unlink(movedPath).catch(() => {});
440
+ await deps.fs.unlink(movedPath).catch(() => {
441
+ // WHY: Best-effort removal of reclaimed stale lock file.
442
+ });
414
443
  continue;
415
444
  }
416
445
 
417
- await deps.fs.link(movedPath, path).catch(() => {});
418
- await deps.fs.unlink(movedPath).catch(() => {});
446
+ await deps.fs.link(movedPath, path).catch(() => {
447
+ // WHY: Best-effort restore of contender's fresh lock file if path was not already recreated.
448
+ });
449
+ await deps.fs.unlink(movedPath).catch(() => {
450
+ // WHY: Best-effort cleanup of temporary stale-check file.
451
+ });
419
452
  throw new AuthLockError(serverName, formatHolderDetails(movedRecord));
420
453
  }
421
454
  }
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
@@ -33,7 +33,7 @@ import {
33
33
  import { CallbackUrlValidationError, isSafeAuthorizationUrl, parseCallbackUrl } from "./callback-url.js";
34
34
  import { setCallbackPort, McpOAuthProvider, acquireAuthLock, type AuthLockHandle } from "./oauth-provider.js";
35
35
  import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
36
- import { discoverManualAuthChallenge } from "./auth-challenge.js";
36
+ import { discoverManualAuthChallenge, createHeadersFetch } from "./auth-challenge.js";
37
37
  import type { ManualAuthChallenge } from "./auth-challenge.js";
38
38
 
39
39
  export interface BrowserOpenCommand {
@@ -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";
@@ -480,16 +475,41 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
480
475
  }
481
476
  }
482
477
 
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
- });
478
+ let activeCtx: ExtensionContext | undefined;
479
+
480
+ manager.setToolRefreshCallback(async (serverName, client, options) => {
481
+ await bridge.refreshTools(
482
+ serverName,
483
+ client,
484
+ () => {
485
+ const server = manager.getServer(serverName);
486
+ return server?.client === client && server.state !== "stopped";
487
+ },
488
+ options,
489
+ );
488
490
  });
489
491
  manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
492
+ manager.setRecoveryFailedCallback((serverName, error) => {
493
+ if (error instanceof AuthRequiredError) {
494
+ activeCtx?.ui.notify(
495
+ `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
496
+ "warning",
497
+ );
498
+ activeCtx?.ui.setStatus?.("pi-mcp", `run /mcp:auth ${serverName}`);
499
+ } else {
500
+ activeCtx?.ui.notify(
501
+ `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
502
+ "error",
503
+ );
504
+ }
505
+ });
506
+ bridge.setAuthErrorCallback(async (serverName, err) => {
507
+ await manager.handleUnauthorizedServerError(serverName, err);
508
+ });
490
509
 
491
510
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
492
511
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
512
+ activeCtx = ctx;
493
513
  // Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
494
514
  const includeProjectConfig =
495
515
  typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
@@ -545,6 +565,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
545
565
 
546
566
  // Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
547
567
  pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
568
+ activeCtx = ctx;
548
569
  const entries = Array.isArray((event as { servers?: unknown })?.servers)
549
570
  ? ((event as { servers: RegisteredMcpServer[] }).servers)
550
571
  : getRegisteredEntries();
@@ -594,6 +615,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
594
615
  });
595
616
 
596
617
  pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
618
+ activeCtx = undefined;
597
619
  for (const controller of startupPromptControllers.values()) controller.abort();
598
620
  startupPromptControllers.clear();
599
621
  for (const attempt of connectAttempts.values()) attempt.controller.abort();
@@ -731,6 +753,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
731
753
  host: callbackServerConfig.host,
732
754
  allowPortFallback: callbackServerConfig.allowPortFallback,
733
755
  });
756
+ if (signal.aborted) throw new AuthCancelledError(serverName);
757
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
734
758
  setCallbackPort(port);
735
759
 
736
760
  // 2. Generate a cryptographically secure state parameter for CSRF protection
@@ -771,8 +795,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
771
795
  false,
772
796
  () => signal.aborted,
773
797
  () => unlock!.assertHeld(),
798
+ config.url,
774
799
  );
775
800
 
801
+ if (!reset) {
802
+ // WHY: authorize() is only entered after silent connect failed with AuthRequiredError.
803
+ // If a refresh_token remained on disk (e.g. accepted by the Authorization Server
804
+ // while the Resource Server rejects the refreshed access token), the SDK's auth()
805
+ // would silently refresh and return "AUTHORIZED" without opening the browser.
806
+ // Clear stored tokens while preserving clientInfo and discoveryState.
807
+ try {
808
+ await authProvider.invalidateCredentials("tokens");
809
+ } catch (err) {
810
+ if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
811
+ console.error(`[pi-mcp] ${err.message}`);
812
+ } else {
813
+ throw err;
814
+ }
815
+ }
816
+ if (signal.aborted) throw new AuthCancelledError(serverName);
817
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
818
+ }
819
+
776
820
  // Why set state before calling auth(): the SDK reads provider.state() while
777
821
  // building the authorization URL inside auth(), and omits the state parameter
778
822
  // if empty, which the callback server then rejects as a CSRF error.
@@ -781,23 +825,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
781
825
  // 4. Discover protected-resource challenge metadata from the server.
782
826
  const authChallenge: ManualAuthChallenge = await discoverManualAuthChallenge(config.url, {
783
827
  headers: config.headers,
828
+ signal,
784
829
  }).catch((): ManualAuthChallenge => {
830
+ if (signal.aborted) {
831
+ throw new AuthCancelledError(serverName);
832
+ }
785
833
  console.warn(
786
834
  `[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
787
835
  );
788
836
  return {};
789
837
  });
838
+ if (signal.aborted) throw new AuthCancelledError(serverName);
839
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
790
840
 
791
841
  // 5. Register the callback immediately before auth() can open the browser.
792
842
  // Attach a catch handler so cancellation after immediate authorization
793
843
  // does not produce an unhandled rejection.
794
844
  const callbackPromise = waitForCallback(oauthState);
795
- callbackPromise.catch(() => {});
845
+ callbackPromise.catch(() => {
846
+ // WHY: Suppress unhandled rejection when callbackPromise is cancelled after immediate authorization.
847
+ });
796
848
 
849
+ const customFetch = config.headers ? createHeadersFetch(config.headers) : undefined;
797
850
  const authOptions: {
798
851
  serverUrl: string;
799
852
  resourceMetadataUrl?: URL;
800
853
  scope?: string;
854
+ fetchFn?: typeof fetch;
801
855
  } = { serverUrl: config.url };
802
856
  if (authChallenge.resourceMetadataUrl) {
803
857
  authOptions.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
@@ -805,13 +859,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
805
859
  if (authChallenge.scope) {
806
860
  authOptions.scope = authChallenge.scope;
807
861
  }
862
+ if (customFetch) {
863
+ authOptions.fetchFn = customFetch;
864
+ }
808
865
 
809
866
  // 6. Start the auth flow.
810
867
  // Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
811
868
  // stored or refreshed tokens already succeeded without opening a browser, or
812
869
  // "REDIRECT" after invoking redirectToAuthorization(), in which case we must
813
870
  // wait for the callback code and call auth() a second time with authorizationCode.
814
- const authResult = await auth(authProvider, authOptions);
871
+ let authResult: "AUTHORIZED" | "REDIRECT";
872
+ try {
873
+ authResult = await auth(authProvider, authOptions);
874
+ } catch (err) {
875
+ if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
876
+ // WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
877
+ // Retry auth() once so interactive authorization proceeds cleanly.
878
+ console.error(`[pi-mcp] ${err.message}`);
879
+ authResult = await auth(authProvider, authOptions);
880
+ } else {
881
+ throw err;
882
+ }
883
+ }
815
884
  if (signal.aborted) throw new AuthCancelledError(serverName);
816
885
  if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
817
886
 
@@ -852,6 +921,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
852
921
  authorizationCode: string;
853
922
  resourceMetadataUrl?: URL;
854
923
  scope?: string;
924
+ fetchFn?: typeof fetch;
855
925
  } = {
856
926
  serverUrl: config.url,
857
927
  authorizationCode: code,
@@ -862,8 +932,13 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
862
932
  if (authChallenge.scope) {
863
933
  finishAuthOptions.scope = authChallenge.scope;
864
934
  }
935
+ if (customFetch) {
936
+ finishAuthOptions.fetchFn = customFetch;
937
+ }
865
938
 
866
939
  const finishAuthResult = await auth(authProvider, finishAuthOptions);
940
+ if (signal.aborted) throw new AuthCancelledError(serverName);
941
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
867
942
 
868
943
  if (finishAuthResult !== "AUTHORIZED") {
869
944
  throw new McpError(
@@ -904,6 +979,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
904
979
  reset = false,
905
980
  alreadyQueued = false,
906
981
  ): Promise<void> {
982
+ activeCtx = ctx;
907
983
  const server = manager.getServer(serverName);
908
984
  if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
909
985
  if (signal?.aborted) throw new AuthCancelledError(serverName);
@@ -1027,11 +1103,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1027
1103
  parameters: Type.Object({}),
1028
1104
  async execute() {
1029
1105
  const rows = await Promise.all(manager.getAllServers().map(async (server) => {
1030
- const auth = server.config.auth ? await manager.getServerAuthStatus(server.name) : null;
1106
+ let auth: Awaited<ReturnType<typeof manager.getServerAuthStatus>> = null;
1107
+ let authCorrupt = false;
1108
+ if (server.config.auth) {
1109
+ try {
1110
+ auth = await manager.getServerAuthStatus(server.name);
1111
+ } catch (err) {
1112
+ // WHY: Never let a single server's corrupt state file abort mcp_status for all servers.
1113
+ // getAuthStatus() already backed up and deleted the corrupt file before throwing.
1114
+ console.error(`[pi-mcp] ${err instanceof Error ? err.message : String(err)}`);
1115
+ authCorrupt = true;
1116
+ }
1117
+ }
1031
1118
  return {
1032
1119
  name: server.name, lifecycle: server.config.lifecycle, state: server.state,
1033
1120
  tools: bridge.getToolActivity(server.name),
1034
- auth: server.config.auth ? auth?.hasTokens ? "credentials stored" : "authorization may be required" : "not configured",
1121
+ auth: server.config.auth
1122
+ ? authCorrupt
1123
+ ? "corrupt state file backed up (re-auth required)"
1124
+ : auth?.hasTokens
1125
+ ? "credentials stored"
1126
+ : "authorization may be required"
1127
+ : "not configured",
1035
1128
  error: server.lastError
1036
1129
  ? server.lastError instanceof AuthRequiredError
1037
1130
  ? "Authorization required: use mcp_connect in an interactive Pi session"
@@ -1082,6 +1175,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1082
1175
  const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name));
1083
1176
  if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; }
1084
1177
  if (!manager.getServer(name)?.config.auth) { ctx.ui.notify(`No OAuth server named ${name}`, "error"); return; }
1178
+ if (!reset && manager.getServer(name)?.state === "ready") {
1179
+ // WHY: Explicit /mcp:auth on an already-ready server must tear down the existing
1180
+ // connection and force a fresh reconnect/auth check rather than returning a no-op.
1181
+ bridge.deactivateServer(name);
1182
+ await manager.stopServer(name);
1183
+ }
1085
1184
  try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
1086
1185
  catch (err) {
1087
1186
  ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");