pi-mcp-extension 1.9.3 → 1.9.4

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.4",
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/index.ts CHANGED
@@ -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 {
@@ -487,6 +487,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
487
487
  });
488
488
  });
489
489
  manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
490
+ bridge.setAuthErrorCallback(async (serverName, err) => {
491
+ await manager.handleUnauthorizedServerError(serverName, err);
492
+ });
490
493
 
491
494
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
492
495
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
@@ -731,6 +734,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
731
734
  host: callbackServerConfig.host,
732
735
  allowPortFallback: callbackServerConfig.allowPortFallback,
733
736
  });
737
+ if (signal.aborted) throw new AuthCancelledError(serverName);
738
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
734
739
  setCallbackPort(port);
735
740
 
736
741
  // 2. Generate a cryptographically secure state parameter for CSRF protection
@@ -771,8 +776,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
771
776
  false,
772
777
  () => signal.aborted,
773
778
  () => unlock!.assertHeld(),
779
+ config.url,
774
780
  );
775
781
 
782
+ if (!reset) {
783
+ // WHY: authorize() is only entered after silent connect failed with AuthRequiredError.
784
+ // If a refresh_token remained on disk (e.g. accepted by the Authorization Server
785
+ // while the Resource Server rejects the refreshed access token), the SDK's auth()
786
+ // would silently refresh and return "AUTHORIZED" without opening the browser.
787
+ // Clear stored tokens while preserving clientInfo and discoveryState.
788
+ try {
789
+ await authProvider.invalidateCredentials("tokens");
790
+ } catch (err) {
791
+ if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
792
+ console.error(`[pi-mcp] ${err.message}`);
793
+ } else {
794
+ throw err;
795
+ }
796
+ }
797
+ if (signal.aborted) throw new AuthCancelledError(serverName);
798
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
799
+ }
800
+
776
801
  // Why set state before calling auth(): the SDK reads provider.state() while
777
802
  // building the authorization URL inside auth(), and omits the state parameter
778
803
  // if empty, which the callback server then rejects as a CSRF error.
@@ -781,23 +806,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
781
806
  // 4. Discover protected-resource challenge metadata from the server.
782
807
  const authChallenge: ManualAuthChallenge = await discoverManualAuthChallenge(config.url, {
783
808
  headers: config.headers,
809
+ signal,
784
810
  }).catch((): ManualAuthChallenge => {
811
+ if (signal.aborted) {
812
+ throw new AuthCancelledError(serverName);
813
+ }
785
814
  console.warn(
786
815
  `[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
787
816
  );
788
817
  return {};
789
818
  });
819
+ if (signal.aborted) throw new AuthCancelledError(serverName);
820
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
790
821
 
791
822
  // 5. Register the callback immediately before auth() can open the browser.
792
823
  // Attach a catch handler so cancellation after immediate authorization
793
824
  // does not produce an unhandled rejection.
794
825
  const callbackPromise = waitForCallback(oauthState);
795
- callbackPromise.catch(() => {});
826
+ callbackPromise.catch(() => {
827
+ // WHY: Suppress unhandled rejection when callbackPromise is cancelled after immediate authorization.
828
+ });
796
829
 
830
+ const customFetch = config.headers ? createHeadersFetch(config.headers) : undefined;
797
831
  const authOptions: {
798
832
  serverUrl: string;
799
833
  resourceMetadataUrl?: URL;
800
834
  scope?: string;
835
+ fetchFn?: typeof fetch;
801
836
  } = { serverUrl: config.url };
802
837
  if (authChallenge.resourceMetadataUrl) {
803
838
  authOptions.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
@@ -805,13 +840,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
805
840
  if (authChallenge.scope) {
806
841
  authOptions.scope = authChallenge.scope;
807
842
  }
843
+ if (customFetch) {
844
+ authOptions.fetchFn = customFetch;
845
+ }
808
846
 
809
847
  // 6. Start the auth flow.
810
848
  // Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
811
849
  // stored or refreshed tokens already succeeded without opening a browser, or
812
850
  // "REDIRECT" after invoking redirectToAuthorization(), in which case we must
813
851
  // wait for the callback code and call auth() a second time with authorizationCode.
814
- const authResult = await auth(authProvider, authOptions);
852
+ let authResult: "AUTHORIZED" | "REDIRECT";
853
+ try {
854
+ authResult = await auth(authProvider, authOptions);
855
+ } catch (err) {
856
+ if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
857
+ // WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
858
+ // Retry auth() once so interactive authorization proceeds cleanly.
859
+ console.error(`[pi-mcp] ${err.message}`);
860
+ authResult = await auth(authProvider, authOptions);
861
+ } else {
862
+ throw err;
863
+ }
864
+ }
815
865
  if (signal.aborted) throw new AuthCancelledError(serverName);
816
866
  if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
817
867
 
@@ -852,6 +902,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
852
902
  authorizationCode: string;
853
903
  resourceMetadataUrl?: URL;
854
904
  scope?: string;
905
+ fetchFn?: typeof fetch;
855
906
  } = {
856
907
  serverUrl: config.url,
857
908
  authorizationCode: code,
@@ -862,8 +913,13 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
862
913
  if (authChallenge.scope) {
863
914
  finishAuthOptions.scope = authChallenge.scope;
864
915
  }
916
+ if (customFetch) {
917
+ finishAuthOptions.fetchFn = customFetch;
918
+ }
865
919
 
866
920
  const finishAuthResult = await auth(authProvider, finishAuthOptions);
921
+ if (signal.aborted) throw new AuthCancelledError(serverName);
922
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
867
923
 
868
924
  if (finishAuthResult !== "AUTHORIZED") {
869
925
  throw new McpError(
@@ -1027,11 +1083,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1027
1083
  parameters: Type.Object({}),
1028
1084
  async execute() {
1029
1085
  const rows = await Promise.all(manager.getAllServers().map(async (server) => {
1030
- const auth = server.config.auth ? await manager.getServerAuthStatus(server.name) : null;
1086
+ let auth: Awaited<ReturnType<typeof manager.getServerAuthStatus>> = null;
1087
+ let authCorrupt = false;
1088
+ if (server.config.auth) {
1089
+ try {
1090
+ auth = await manager.getServerAuthStatus(server.name);
1091
+ } catch (err) {
1092
+ // WHY: Never let a single server's corrupt state file abort mcp_status for all servers.
1093
+ // getAuthStatus() already backed up and deleted the corrupt file before throwing.
1094
+ console.error(`[pi-mcp] ${err instanceof Error ? err.message : String(err)}`);
1095
+ authCorrupt = true;
1096
+ }
1097
+ }
1031
1098
  return {
1032
1099
  name: server.name, lifecycle: server.config.lifecycle, state: server.state,
1033
1100
  tools: bridge.getToolActivity(server.name),
1034
- auth: server.config.auth ? auth?.hasTokens ? "credentials stored" : "authorization may be required" : "not configured",
1101
+ auth: server.config.auth
1102
+ ? authCorrupt
1103
+ ? "corrupt state file backed up (re-auth required)"
1104
+ : auth?.hasTokens
1105
+ ? "credentials stored"
1106
+ : "authorization may be required"
1107
+ : "not configured",
1035
1108
  error: server.lastError
1036
1109
  ? server.lastError instanceof AuthRequiredError
1037
1110
  ? "Authorization required: use mcp_connect in an interactive Pi session"
@@ -1082,6 +1155,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1082
1155
  const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name));
1083
1156
  if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; }
1084
1157
  if (!manager.getServer(name)?.config.auth) { ctx.ui.notify(`No OAuth server named ${name}`, "error"); return; }
1158
+ if (!reset && manager.getServer(name)?.state === "ready") {
1159
+ // WHY: Explicit /mcp:auth on an already-ready server must tear down the existing
1160
+ // connection and force a fresh reconnect/auth check rather than returning a no-op.
1161
+ bridge.deactivateServer(name);
1162
+ await manager.stopServer(name);
1163
+ }
1085
1164
  try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
1086
1165
  catch (err) {
1087
1166
  ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
@@ -68,11 +68,46 @@ export function getCallbackPort(): number {
68
68
  return callbackPort;
69
69
  }
70
70
 
71
+ // ─── URL Canonicalization & Origin-Binding Helpers ────────────────────────────
72
+
73
+ export function canonicalServerUrl(rawUrl: string): string {
74
+ try {
75
+ const u = new URL(rawUrl);
76
+ const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
77
+ return `${u.origin}${path}${u.search}`;
78
+ } catch {
79
+ return rawUrl.trim();
80
+ }
81
+ }
82
+
83
+ export function urlsMatch(storedUrl: string, expectedUrl: string): boolean {
84
+ return canonicalServerUrl(storedUrl) === canonicalServerUrl(expectedUrl);
85
+ }
86
+
87
+ function isResourceCompatibleWithServerUrl(resource: string, expectedServerUrl: string): boolean {
88
+ if (urlsMatch(resource, expectedServerUrl)) return true;
89
+ try {
90
+ const req = new URL(canonicalServerUrl(expectedServerUrl));
91
+ const cfg = new URL(canonicalServerUrl(resource));
92
+ if (req.origin !== cfg.origin) return false;
93
+ if (req.pathname.length < cfg.pathname.length) return false;
94
+ const reqPath = req.pathname.endsWith("/") ? req.pathname : `${req.pathname}/`;
95
+ const cfgPath = cfg.pathname.endsWith("/") ? cfg.pathname : `${cfg.pathname}/`;
96
+ return reqPath.startsWith(cfgPath);
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
71
102
  // ─── Persistent State Types & Schema ──────────────────────────────────────────
72
103
 
73
104
  interface StoredClientInfo {
74
105
  client_id: string;
75
106
  client_secret: string | undefined;
107
+ client_id_issued_at: number | undefined;
108
+ client_secret_expires_at: number | undefined;
109
+ token_endpoint_auth_method: string | undefined;
110
+ redirect_uris: string[] | undefined;
76
111
  }
77
112
 
78
113
  interface StoredTokens {
@@ -86,6 +121,7 @@ interface StoredTokens {
86
121
  }
87
122
 
88
123
  interface StoredState {
124
+ serverUrl?: string | undefined;
89
125
  clientInfo: StoredClientInfo | undefined;
90
126
  tokens: StoredTokens | undefined;
91
127
  discoveryState: OAuthDiscoveryState | undefined;
@@ -94,6 +130,10 @@ interface StoredState {
94
130
  const StoredClientInfoSchema = z.object({
95
131
  client_id: z.string(),
96
132
  client_secret: z.string().optional(),
133
+ client_id_issued_at: z.number().optional(),
134
+ client_secret_expires_at: z.number().optional(),
135
+ token_endpoint_auth_method: z.string().optional(),
136
+ redirect_uris: z.array(z.string()).optional(),
97
137
  });
98
138
 
99
139
  const StoredTokensSchema = z.object({
@@ -106,6 +146,7 @@ const StoredTokensSchema = z.object({
106
146
  });
107
147
 
108
148
  export const StoredStateSchema = z.object({
149
+ serverUrl: z.string().optional(),
109
150
  clientInfo: StoredClientInfoSchema.optional(),
110
151
  tokens: StoredTokensSchema.optional(),
111
152
  discoveryState: z.record(z.unknown()).optional(),
@@ -113,7 +154,11 @@ export const StoredStateSchema = z.object({
113
154
 
114
155
  // ─── File helpers ─────────────────────────────────────────────────────────────
115
156
 
116
- async function loadState(serverName: string, storageDir?: string): Promise<StoredState> {
157
+ async function loadState(
158
+ serverName: string,
159
+ storageDir?: string,
160
+ expectedServerUrl?: string,
161
+ ): Promise<StoredState> {
117
162
  const path = statePath(serverName, storageDir);
118
163
  let raw: string;
119
164
  try {
@@ -121,6 +166,7 @@ async function loadState(serverName: string, storageDir?: string): Promise<Store
121
166
  } catch (err) {
122
167
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
123
168
  return {
169
+ serverUrl: expectedServerUrl ? canonicalServerUrl(expectedServerUrl) : undefined,
124
170
  clientInfo: undefined,
125
171
  tokens: undefined,
126
172
  discoveryState: undefined,
@@ -144,11 +190,47 @@ async function loadState(serverName: string, storageDir?: string): Promise<Store
144
190
  );
145
191
  }
146
192
 
193
+ let discoveryState = parsed.discoveryState as OAuthDiscoveryState | undefined;
194
+
195
+ if (expectedServerUrl !== undefined) {
196
+ // WHY: Bind OAuth state to the canonical server URL so a project config with the same
197
+ // server name cannot inherit global Bearer tokens for a different origin/path.
198
+ if (parsed.serverUrl !== undefined && !urlsMatch(parsed.serverUrl, expectedServerUrl)) {
199
+ return {
200
+ serverUrl: canonicalServerUrl(expectedServerUrl),
201
+ clientInfo: undefined,
202
+ tokens: undefined,
203
+ discoveryState: undefined,
204
+ };
205
+ }
206
+
207
+ const resource = (discoveryState?.resourceMetadata as { resource?: unknown } | undefined)?.resource;
208
+ if (typeof resource === "string" && !isResourceCompatibleWithServerUrl(resource, expectedServerUrl)) {
209
+ if (parsed.serverUrl === undefined) {
210
+ // Legacy state file without top-level serverUrl whose cached resourceMetadata belongs to another server URL
211
+ return {
212
+ serverUrl: canonicalServerUrl(expectedServerUrl),
213
+ clientInfo: undefined,
214
+ tokens: undefined,
215
+ discoveryState: undefined,
216
+ };
217
+ }
218
+ // WHY: Discard mismatched discoveryState so the MCP SDK's selectResourceURL()
219
+ // never throws "Protected resource ... does not match expected".
220
+ discoveryState = undefined;
221
+ }
222
+ }
223
+
147
224
  return {
225
+ serverUrl: parsed.serverUrl ?? (expectedServerUrl ? canonicalServerUrl(expectedServerUrl) : undefined),
148
226
  clientInfo: parsed.clientInfo
149
227
  ? {
150
228
  client_id: parsed.clientInfo.client_id,
151
229
  client_secret: parsed.clientInfo.client_secret,
230
+ client_id_issued_at: parsed.clientInfo.client_id_issued_at,
231
+ client_secret_expires_at: parsed.clientInfo.client_secret_expires_at,
232
+ token_endpoint_auth_method: parsed.clientInfo.token_endpoint_auth_method,
233
+ redirect_uris: parsed.clientInfo.redirect_uris,
152
234
  }
153
235
  : undefined,
154
236
  tokens: parsed.tokens
@@ -161,7 +243,7 @@ async function loadState(serverName: string, storageDir?: string): Promise<Store
161
243
  saved_at: parsed.tokens.saved_at,
162
244
  }
163
245
  : undefined,
164
- discoveryState: parsed.discoveryState as OAuthDiscoveryState | undefined,
246
+ discoveryState,
165
247
  };
166
248
  }
167
249
 
@@ -176,6 +258,7 @@ async function saveState(
176
258
  await chmod(directory, 0o700);
177
259
  // Only write defined fields (never write codeVerifier to shared state)
178
260
  const toWrite: Record<string, unknown> = {};
261
+ if (state.serverUrl !== undefined) toWrite.serverUrl = state.serverUrl;
179
262
  if (state.clientInfo !== undefined) toWrite.clientInfo = state.clientInfo;
180
263
  if (state.tokens !== undefined) toWrite.tokens = state.tokens;
181
264
  if (state.discoveryState !== undefined) toWrite.discoveryState = state.discoveryState;
@@ -186,13 +269,17 @@ async function saveState(
186
269
  await writeFile(tmpPath, JSON.stringify(toWrite, null, 2), { encoding: "utf8", mode: 0o600 });
187
270
  await chmod(tmpPath, 0o600);
188
271
  if (isCancelled()) {
189
- await unlink(tmpPath).catch(() => {});
272
+ await unlink(tmpPath).catch(() => {
273
+ // WHY: Best-effort cleanup of temporary state file when cancelled before rename.
274
+ });
190
275
  throw new AuthRequiredError(serverName, "Authorization cancelled");
191
276
  }
192
277
  await rename(tmpPath, path);
193
278
  await chmod(path, 0o600);
194
279
  } catch (err) {
195
- await unlink(tmpPath).catch(() => {});
280
+ await unlink(tmpPath).catch(() => {
281
+ // WHY: Best-effort cleanup of temporary state file after write or rename failure.
282
+ });
196
283
  throw err;
197
284
  }
198
285
  }
@@ -207,6 +294,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
207
294
  private _oauthState: string | undefined;
208
295
  private _codeVerifier: string | undefined;
209
296
  private _cachedTokens: StoredTokens | undefined;
297
+ private _lastReadTokens: StoredTokens | undefined;
298
+ private _reloadedPeerTokens = false;
210
299
 
211
300
  constructor(
212
301
  serverName: string,
@@ -216,6 +305,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
216
305
  private readonly silent = false,
217
306
  private readonly isCancelled: () => boolean = () => false,
218
307
  private readonly assertLockHeld?: (() => Promise<void>) | undefined,
308
+ private readonly serverUrl?: string | undefined,
219
309
  ) {
220
310
  this.serverName = serverName;
221
311
  this.authConfig = authConfig;
@@ -256,12 +346,45 @@ export class McpOAuthProvider implements OAuthClientProvider {
256
346
  };
257
347
  }
258
348
  // Otherwise load from persisted DCR state
259
- const state = await loadState(this.serverName, this.storageDir);
260
- if (state.clientInfo) {
349
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
350
+ const info = state.clientInfo;
351
+ if (info) {
352
+ if (
353
+ info.client_secret !== undefined &&
354
+ typeof info.client_secret_expires_at === "number" &&
355
+ info.client_secret_expires_at > 0 &&
356
+ info.client_secret_expires_at * 1000 <= Date.now()
357
+ ) {
358
+ if (this.silent) {
359
+ throw new AuthRequiredError(
360
+ this.serverName,
361
+ "Stored OAuth client secret has expired; interactive re-authentication required",
362
+ );
363
+ }
364
+ return undefined;
365
+ }
366
+ if (
367
+ !this.silent &&
368
+ Array.isArray(info.redirect_uris) &&
369
+ info.redirect_uris.length > 0 &&
370
+ !info.redirect_uris.includes(String(this.redirectUrl))
371
+ ) {
372
+ // WHY: If the callback port or configured redirectUrl changed since DCR,
373
+ // return undefined in interactive mode so the SDK re-registers with the new redirect_uri.
374
+ return undefined;
375
+ }
261
376
  return {
262
- client_id: state.clientInfo.client_id,
263
- ...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
264
- };
377
+ client_id: info.client_id,
378
+ ...(info.client_secret !== undefined && { client_secret: info.client_secret }),
379
+ ...(info.client_id_issued_at !== undefined && { client_id_issued_at: info.client_id_issued_at }),
380
+ ...(info.client_secret_expires_at !== undefined && {
381
+ client_secret_expires_at: info.client_secret_expires_at,
382
+ }),
383
+ ...(info.token_endpoint_auth_method !== undefined && {
384
+ token_endpoint_auth_method: info.token_endpoint_auth_method,
385
+ }),
386
+ ...(info.redirect_uris !== undefined && { redirect_uris: info.redirect_uris }),
387
+ } as OAuthClientInformationMixed;
265
388
  }
266
389
  if (this.silent) throw new AuthRequiredError(this.serverName);
267
390
  return undefined;
@@ -272,10 +395,30 @@ export class McpOAuthProvider implements OAuthClientProvider {
272
395
  async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
273
396
  if (this.silent) throw new AuthRequiredError(this.serverName);
274
397
  await this.assertLockHeld?.();
275
- const state = await loadState(this.serverName, this.storageDir);
398
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
399
+ if (this.serverUrl) {
400
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
401
+ }
402
+ const fullInfo = clientInformation as Partial<{
403
+ client_id_issued_at: number;
404
+ client_secret_expires_at: number;
405
+ token_endpoint_auth_method: string;
406
+ redirect_uris: Array<string | URL>;
407
+ }>;
276
408
  state.clientInfo = {
277
409
  client_id: clientInformation.client_id,
278
410
  client_secret: clientInformation.client_secret,
411
+ client_id_issued_at:
412
+ typeof fullInfo.client_id_issued_at === "number" ? fullInfo.client_id_issued_at : undefined,
413
+ client_secret_expires_at:
414
+ typeof fullInfo.client_secret_expires_at === "number" ? fullInfo.client_secret_expires_at : undefined,
415
+ token_endpoint_auth_method:
416
+ typeof fullInfo.token_endpoint_auth_method === "string"
417
+ ? fullInfo.token_endpoint_auth_method
418
+ : undefined,
419
+ redirect_uris: Array.isArray(fullInfo.redirect_uris)
420
+ ? fullInfo.redirect_uris.map((u) => String(u))
421
+ : undefined,
279
422
  };
280
423
  await this.assertLockHeld?.();
281
424
  await saveState(this.serverName, state, this.storageDir, this.isCancelled);
@@ -284,8 +427,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
284
427
  // --- tokens ---
285
428
 
286
429
  async tokens(): Promise<OAuthTokens | undefined> {
287
- const storedTokens = this._cachedTokens ?? (await loadState(this.serverName, this.storageDir)).tokens;
430
+ const storedTokens =
431
+ this._cachedTokens ?? (await loadState(this.serverName, this.storageDir, this.serverUrl)).tokens;
288
432
  if (!storedTokens) return undefined;
433
+ this._lastReadTokens = storedTokens;
289
434
 
290
435
  // Always return stored tokens — even if expired.
291
436
  // The SDK's auth() function checks tokens?.refresh_token and attempts
@@ -302,11 +447,13 @@ export class McpOAuthProvider implements OAuthClientProvider {
302
447
  // → user must re-authenticate in browser
303
448
 
304
449
  // Build OAuthTokens — only include defined fields
305
- const tokens: Record<string, string> = {
450
+ const tokens: Record<string, unknown> = {
306
451
  access_token: storedTokens.access_token,
307
452
  };
308
453
  if (storedTokens.token_type !== undefined) tokens.token_type = storedTokens.token_type;
309
454
  if (storedTokens.refresh_token !== undefined) tokens.refresh_token = storedTokens.refresh_token;
455
+ if (storedTokens.expires_in !== undefined) tokens.expires_in = storedTokens.expires_in;
456
+ if (storedTokens.scope !== undefined) tokens.scope = storedTokens.scope;
310
457
  return tokens as unknown as OAuthTokens;
311
458
  }
312
459
 
@@ -323,26 +470,46 @@ export class McpOAuthProvider implements OAuthClientProvider {
323
470
  };
324
471
 
325
472
  if (this.silent) {
473
+ let handle: AuthLockHandle | undefined;
326
474
  try {
327
- const state = await loadState(this.serverName, this.storageDir);
475
+ handle = await acquireAuthLock(this.serverName, this.storageDir);
476
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
477
+ if (this.serverUrl) {
478
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
479
+ }
328
480
  state.tokens = storedTokens;
329
481
  await saveState(this.serverName, state, this.storageDir, this.isCancelled);
330
482
  this._cachedTokens = undefined;
483
+ this._lastReadTokens = storedTokens;
484
+ this._reloadedPeerTokens = false;
331
485
  } catch (err) {
486
+ // WHY: When another process holds the interactive auth lock (e.g. during --reset),
487
+ // keep the refreshed tokens in memory for this session without clobbering disk state.
332
488
  this._cachedTokens = storedTokens;
333
- console.error(`[pi-mcp] Failed to persist refreshed OAuth tokens for "${this.serverName}":`, err);
489
+ this._lastReadTokens = storedTokens;
490
+ this._reloadedPeerTokens = false;
491
+ if (!(err instanceof AuthLockError)) {
492
+ console.error(`[pi-mcp] Failed to persist refreshed OAuth tokens for "${this.serverName}":`, err);
493
+ }
494
+ } finally {
495
+ await handle?.();
334
496
  }
335
497
  return;
336
498
  }
337
499
 
338
500
  if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
339
501
  await this.assertLockHeld?.();
340
- const state = await loadState(this.serverName, this.storageDir);
502
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
503
+ if (this.serverUrl) {
504
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
505
+ }
341
506
  state.tokens = storedTokens;
342
507
  if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
343
508
  await this.assertLockHeld?.();
344
509
  await saveState(this.serverName, state, this.storageDir, this.isCancelled);
345
510
  this._cachedTokens = undefined;
511
+ this._lastReadTokens = storedTokens;
512
+ this._reloadedPeerTokens = false;
346
513
  }
347
514
 
348
515
  // --- redirectToAuthorization ---
@@ -380,14 +547,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
380
547
  async saveDiscoveryState(discState: OAuthDiscoveryState): Promise<void> {
381
548
  if (this.silent) return; // No shared state writes from background discovery.
382
549
  await this.assertLockHeld?.();
383
- const state = await loadState(this.serverName, this.storageDir);
550
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
551
+ if (this.serverUrl) {
552
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
553
+ }
384
554
  state.discoveryState = discState;
385
555
  await this.assertLockHeld?.();
386
556
  await saveState(this.serverName, state, this.storageDir, this.isCancelled);
387
557
  }
388
558
 
389
559
  async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
390
- const state = await loadState(this.serverName, this.storageDir);
560
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
391
561
  return state.discoveryState;
392
562
  }
393
563
 
@@ -412,12 +582,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
412
582
 
413
583
  // --- Credential invalidation ---
414
584
 
415
- async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
416
- if (this.silent) throw new AuthRequiredError(this.serverName);
417
- await this.assertLockHeld?.();
418
- const state = await loadState(this.serverName, this.storageDir);
585
+ private _applyInvalidation(
586
+ state: StoredState,
587
+ scope: "all" | "client" | "tokens" | "verifier" | "discovery",
588
+ ): boolean {
589
+ let changedDisk = false;
419
590
  switch (scope) {
420
591
  case "all":
592
+ changedDisk =
593
+ state.clientInfo !== undefined ||
594
+ state.tokens !== undefined ||
595
+ state.discoveryState !== undefined;
421
596
  state.clientInfo = undefined;
422
597
  state.tokens = undefined;
423
598
  state.discoveryState = undefined;
@@ -425,9 +600,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
425
600
  this._cachedTokens = undefined;
426
601
  break;
427
602
  case "client":
603
+ changedDisk = state.clientInfo !== undefined;
428
604
  state.clientInfo = undefined;
429
605
  break;
430
606
  case "tokens":
607
+ changedDisk = state.tokens !== undefined;
431
608
  state.tokens = undefined;
432
609
  this._cachedTokens = undefined;
433
610
  break;
@@ -435,27 +612,126 @@ export class McpOAuthProvider implements OAuthClientProvider {
435
612
  this._codeVerifier = undefined;
436
613
  break;
437
614
  case "discovery":
615
+ changedDisk = state.discoveryState !== undefined;
438
616
  state.discoveryState = undefined;
439
617
  break;
440
618
  }
619
+ return changedDisk;
620
+ }
621
+
622
+ async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
623
+ if (this.silent) {
624
+ if (scope === "tokens" && !this._reloadedPeerTokens) {
625
+ let diskState: StoredState | undefined;
626
+ try {
627
+ diskState = await loadState(this.serverName, this.storageDir, this.serverUrl);
628
+ } catch {
629
+ // WHY: If disk state is unreadable, fall through to invalidation and AuthRequiredError.
630
+ diskState = undefined;
631
+ }
632
+ if (
633
+ diskState?.tokens &&
634
+ (diskState.tokens.refresh_token !== this._lastReadTokens?.refresh_token ||
635
+ diskState.tokens.access_token !== this._lastReadTokens?.access_token)
636
+ ) {
637
+ // WHY: Another Pi process rotated the single-use refresh token on disk while
638
+ // our refresh request was in flight. Reload the peer's tokens instead of deleting
639
+ // them or throwing AuthRequiredError so the SDK's auth() retry succeeds.
640
+ this._cachedTokens = diskState.tokens;
641
+ this._lastReadTokens = diskState.tokens;
642
+ this._reloadedPeerTokens = true;
643
+ return;
644
+ }
645
+ }
646
+
647
+ this._cachedTokens = undefined;
648
+ this._reloadedPeerTokens = false;
649
+ if (scope === "verifier") {
650
+ this._codeVerifier = undefined;
651
+ throw new AuthRequiredError(this.serverName);
652
+ }
653
+ let handle: AuthLockHandle | undefined;
654
+ try {
655
+ handle = await acquireAuthLock(this.serverName, this.storageDir);
656
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
657
+ const changedDisk = this._applyInvalidation(state, scope);
658
+ if (changedDisk) {
659
+ if (this.serverUrl) {
660
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
661
+ }
662
+ await saveState(this.serverName, state, this.storageDir, this.isCancelled);
663
+ }
664
+ } catch {
665
+ // WHY: Best-effort cleanup of rejected credentials on disk; if another process
666
+ // holds the lock or disk is unavailable, still throw AuthRequiredError.
667
+ } finally {
668
+ await handle?.();
669
+ }
670
+ throw new AuthRequiredError(this.serverName);
671
+ }
672
+
441
673
  await this.assertLockHeld?.();
442
- await saveState(this.serverName, state, this.storageDir, this.isCancelled);
674
+ if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
675
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
676
+ const changedDisk = this._applyInvalidation(state, scope);
677
+ await this.assertLockHeld?.();
678
+ if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
679
+ if (changedDisk) {
680
+ // WHY: Only write back to disk when stored state for this serverUrl actually had
681
+ // credentials to clear, preventing a cancelled or unauthenticated project override
682
+ // from overwriting another server URL's state file before new credentials are saved.
683
+ if (this.serverUrl) {
684
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
685
+ }
686
+ await saveState(this.serverName, state, this.storageDir, this.isCancelled);
687
+ }
443
688
  }
444
689
  }
445
690
 
446
691
  // ─── Public Helpers ────────────────────────────────────────────────────────────
447
692
 
693
+ /**
694
+ * Best-effort invalidation of stored tokens on disk when a resource server
695
+ * rejects an access token with HTTP 401/403. Preserves clientInfo and discoveryState.
696
+ */
697
+ export async function invalidateStoredTokens(
698
+ serverName: string,
699
+ storageDir?: string,
700
+ serverUrl?: string,
701
+ ): Promise<void> {
702
+ let handle: AuthLockHandle | undefined;
703
+ try {
704
+ handle = await acquireAuthLock(serverName, storageDir);
705
+ const state = await loadState(serverName, storageDir, serverUrl);
706
+ if (state.tokens !== undefined) {
707
+ state.tokens = undefined;
708
+ if (serverUrl) {
709
+ state.serverUrl = canonicalServerUrl(serverUrl);
710
+ }
711
+ await saveState(serverName, state, storageDir);
712
+ }
713
+ } catch {
714
+ // WHY: Best-effort token cleanup when a server rejects credentials; ignore lock contention or I/O errors.
715
+ } finally {
716
+ await handle?.();
717
+ }
718
+ }
719
+
448
720
  /**
449
721
  * Get auth status info for a server — whether tokens exist, when they were saved, etc.
450
722
  * Returns null if no auth state file exists at all.
451
723
  */
452
- export async function getAuthStatus(serverName: string, storageDir?: string): Promise<{
724
+ export async function getAuthStatus(
725
+ serverName: string,
726
+ storageDir?: string,
727
+ serverUrl?: string,
728
+ ): Promise<{
453
729
  hasTokens: boolean;
454
730
  hasClientInfo: boolean;
455
731
  savedAt: string | undefined;
456
732
  scope: string | undefined;
457
733
  } | null> {
458
- const state = await loadState(serverName, storageDir);
734
+ const state = await loadState(serverName, storageDir, serverUrl);
459
735
  if (
460
736
  state.clientInfo === undefined &&
461
737
  state.tokens === undefined &&
@@ -483,3 +759,4 @@ export async function resetAuth(serverName: string, storageDir?: string): Promis
483
759
  throw err;
484
760
  }
485
761
  }
762
+
@@ -24,16 +24,93 @@ import {
24
24
  } from "@modelcontextprotocol/sdk/types.js";
25
25
  import { McpError } from "./errors.js";
26
26
  import type { McpConfig, ServerConfig, Settings } from "./config.js";
27
- import { McpOAuthProvider, AuthRequiredError, getAuthStatus, resetAuth } from "./oauth-provider.js";
27
+ import { McpOAuthProvider, AuthRequiredError, getAuthStatus, resetAuth, invalidateStoredTokens } from "./oauth-provider.js";
28
28
  import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
29
29
 
30
- export function isOAuthUnauthorizedError(error: unknown): boolean {
31
- return (
32
- error instanceof AuthRequiredError ||
33
- error instanceof UnauthorizedError ||
34
- (typeof (error as { code?: unknown })?.code === "number" && (error as { code: number }).code === 401) ||
35
- /\b(401|Unauthorized)\b/i.test(error instanceof Error ? error.message : String(error))
30
+ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>()): boolean {
31
+ if (!error || seen.has(error)) {
32
+ return false;
33
+ }
34
+ if (typeof error === "object") {
35
+ seen.add(error);
36
+ }
37
+
38
+ if (error instanceof AuthRequiredError || error instanceof UnauthorizedError) {
39
+ return true;
40
+ }
41
+
42
+ if (error instanceof McpError) {
43
+ // WHY: Tool-level application errors (`isError: true`) come from tool execution logic
44
+ // (e.g. "found 401 items in database") and must never trigger MCP OAuth re-authentication.
45
+ if (error.code === "tool") {
46
+ return false;
47
+ }
48
+ if (error.cause !== undefined && isOAuthUnauthorizedError(error.cause, seen)) {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ if (typeof error === "object") {
54
+ const rec = error as { code?: unknown; status?: unknown; statusCode?: unknown; cause?: unknown };
55
+ if (rec.cause !== undefined && isOAuthUnauthorizedError(rec.cause, seen)) {
56
+ return true;
57
+ }
58
+ const numericCode =
59
+ typeof rec.code === "number"
60
+ ? rec.code
61
+ : typeof rec.status === "number"
62
+ ? rec.status
63
+ : typeof rec.statusCode === "number"
64
+ ? rec.statusCode
65
+ : undefined;
66
+ // WHY: Only treat 100..599 as HTTP status codes so negative JSON-RPC error codes
67
+ // (e.g. -32000) still inspect the error message for wrapped HTTP 401/403 status text.
68
+ if (numericCode !== undefined && numericCode >= 100 && numericCode <= 599) {
69
+ if (numericCode === 401) return true;
70
+ const msg = error instanceof Error ? error.message : String(error);
71
+ if (numericCode === 403 && /\b(insufficient_scope|trying upscoping)\b/i.test(msg)) {
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ }
77
+
78
+ const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
79
+ if (!msg) return false;
80
+
81
+ // Reject messages that explicitly report a non-401/403 HTTP status code
82
+ const httpStatusMatch = msg.match(
83
+ /\b(?:HTTP\s+|status(?:\s+code)?[:\s]+|(?:POSTing|GETting|connecting)\s+to\s+\S+:\s*|(?:Non-200\s+status\s+code\s*\())(\d{3})\b/i,
36
84
  );
85
+ if (httpStatusMatch) {
86
+ const status = Number(httpStatusMatch[1]);
87
+ if (status !== 401 && status !== 403) {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ if (
93
+ /^\s*401\s*$/.test(msg) ||
94
+ /\bHTTP\s+401\b/i.test(msg) ||
95
+ /\bstatus(?:\s+code)?[:\s(]+401\b/i.test(msg) ||
96
+ /\breturned\s+401\b/i.test(msg) ||
97
+ /\b401\s+Unauthorized\b/i.test(msg) ||
98
+ /:\s*401\b/.test(msg) ||
99
+ /\(401\)/.test(msg) ||
100
+ /\bUnauthorized\b/i.test(msg) ||
101
+ /\binvalid_token\b/i.test(msg)
102
+ ) {
103
+ return true;
104
+ }
105
+
106
+ if (
107
+ /\b(403|Forbidden)\b/i.test(msg) &&
108
+ /\b(insufficient_scope|trying upscoping)\b/i.test(msg)
109
+ ) {
110
+ return true;
111
+ }
112
+
113
+ return false;
37
114
  }
38
115
 
39
116
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -90,7 +167,16 @@ function createTransport(
90
167
  let authProvider: OAuthClientProvider | undefined;
91
168
  if (config.auth && config.transport !== "stdio") {
92
169
  // Silent transport attempts must not write PKCE or launch a browser.
93
- authProvider = new McpOAuthProvider(serverName, config.auth, undefined, storageDir, true);
170
+ authProvider = new McpOAuthProvider(
171
+ serverName,
172
+ config.auth,
173
+ undefined,
174
+ storageDir,
175
+ true,
176
+ undefined,
177
+ undefined,
178
+ config.url,
179
+ );
94
180
  }
95
181
 
96
182
  switch (config.transport) {
@@ -206,7 +292,25 @@ export class ServerManager {
206
292
  savedAt: string | undefined;
207
293
  scope: string | undefined;
208
294
  } | null> {
209
- return getAuthStatus(name, this.storageDir);
295
+ const serverUrl = this.servers.get(name)?.config.url;
296
+ return getAuthStatus(name, this.storageDir, serverUrl);
297
+ }
298
+
299
+ /**
300
+ * Mark an OAuth server as requiring re-authorization when a mid-session
301
+ * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
302
+ */
303
+ async handleUnauthorizedServerError(name: string, _cause?: unknown): Promise<void> {
304
+ const server = this.servers.get(name);
305
+ if (!server || !server.config.auth || server.config.transport === "stdio") {
306
+ return;
307
+ }
308
+ await invalidateStoredTokens(name, this.storageDir, server.config.url);
309
+ const authError = new AuthRequiredError(name);
310
+ if (server.state === "ready") {
311
+ await this._shutdown(server);
312
+ }
313
+ server.lastError = authError;
210
314
  }
211
315
 
212
316
  /** Get recent stderr output for a server. */
@@ -296,7 +400,12 @@ export class ServerManager {
296
400
 
297
401
  // ─── Internal ───────────────────────────────────────────────────────────────
298
402
 
299
- private async _connect(server: ManagedServer, cwd: string, signal: AbortSignal): Promise<void> {
403
+ private async _connect(
404
+ server: ManagedServer,
405
+ cwd: string,
406
+ signal: AbortSignal,
407
+ corruptStateRecovered = false,
408
+ ): Promise<void> {
300
409
  server.lastError = null;
301
410
  // Note: retryCount is NOT reset here — it's only reset after successful connect.
302
411
  // This ensures the retry limit is respected across reconnection attempts.
@@ -409,17 +518,39 @@ export class ServerManager {
409
518
  if (server.client === client) { server.client = null; server.childPid = null; }
410
519
  if (signal.aborted) throw new Error("Connection cancelled");
411
520
  const error = err instanceof Error ? err : new Error(String(err));
521
+ if (
522
+ !corruptStateRecovered &&
523
+ error instanceof McpError &&
524
+ error.code === "config" &&
525
+ /Corrupt OAuth state file/i.test(error.message)
526
+ ) {
527
+ // WHY: McpOAuthProvider._loadState() already backed up and removed the corrupt
528
+ // state file before throwing this error. Log the warning and retry once immediately
529
+ // without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
530
+ console.error(`[pi-mcp] ${error.message}`);
531
+ if (signal.aborted) throw new Error("Connection cancelled");
532
+ return this._connect(server, cwd, signal, true);
533
+ }
412
534
  server.lastError = error;
413
- if (error instanceof AuthRequiredError || (error instanceof McpError && error.code === "protocol")) {
535
+ if (error instanceof AuthRequiredError) {
414
536
  server.state = "stopped";
415
537
  throw error;
416
538
  }
417
539
  if (Boolean(server.config.auth) && server.config.transport !== "stdio" && isOAuthUnauthorizedError(error)) {
540
+ // WHY: Check OAuth unauthorized errors BEFORE the generic `error.code === "protocol"`
541
+ // fast-fail so 401/403 failures during initial tools/list (`onToolRefresh`) are
542
+ // classified as `AuthRequiredError` rather than raw protocol failures, and stale
543
+ // tokens on disk are invalidated before interactive re-authorization runs.
544
+ await invalidateStoredTokens(server.name, this.storageDir, server.config.url);
418
545
  const authError = new AuthRequiredError(server.name);
419
546
  server.lastError = authError;
420
547
  server.state = "stopped";
421
548
  throw authError;
422
549
  }
550
+ if (error instanceof McpError && error.code === "protocol") {
551
+ server.state = "stopped";
552
+ throw error;
553
+ }
423
554
  if (server.retryCount >= this.settings.maxRetries) {
424
555
  server.state = "stopped";
425
556
  throw new McpError(`Connection failed after ${server.retryCount} retries: ${error.message}`, server.name, "connection", err);
@@ -433,7 +564,7 @@ export class ServerManager {
433
564
  }, { once: true });
434
565
  });
435
566
  if (signal.aborted) throw new Error("Connection cancelled");
436
- return this._connect(server, cwd, signal);
567
+ return this._connect(server, cwd, signal, corruptStateRecovered);
437
568
  }
438
569
  }
439
570
 
@@ -20,6 +20,7 @@ import type { TSchema } from "typebox";
20
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
21
  import { McpError } from "./errors.js";
22
22
  import type { Settings } from "./config.js";
23
+ import { isOAuthUnauthorizedError } from "./server-manager.js";
23
24
 
24
25
  // ─── Types ────────────────────────────────────────────────────────────────────
25
26
 
@@ -681,12 +682,18 @@ export class ToolBridge {
681
682
  private readonly toolOwners = new Map<string, string>();
682
683
  /** Monotonic per-server generation counter to discard out-of-order refreshTools responses. */
683
684
  private readonly refreshGenerations = new Map<string, number>();
685
+ private onAuthError?: ((serverName: string, error: unknown) => Promise<void> | void) | undefined;
684
686
 
685
687
  constructor(settings: Settings, pi: PiExtensionAPI) {
686
688
  this.settings = settings;
687
689
  this.pi = pi;
688
690
  }
689
691
 
692
+ /** Register callback invoked when a mid-session tools/list or tools/call fails with 401/403. */
693
+ setAuthErrorCallback(cb: (serverName: string, error: unknown) => Promise<void> | void): void {
694
+ this.onAuthError = cb;
695
+ }
696
+
690
697
  /** Update bridge settings when session config is reloaded on session_start. */
691
698
  updateSettings(settings: Settings): void {
692
699
  this.settings = settings;
@@ -712,6 +719,9 @@ export class ToolBridge {
712
719
  try {
713
720
  tools = await listAllTools(client, timeoutMs);
714
721
  } catch (err) {
722
+ if (isOAuthUnauthorizedError(err)) {
723
+ await this.onAuthError?.(serverName, err);
724
+ }
715
725
  throw new McpError(
716
726
  `Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
717
727
  serverName,
@@ -847,6 +857,7 @@ export class ToolBridge {
847
857
 
848
858
  const schema = convertJsonSchemaToTypebox(tool.inputSchema);
849
859
  const timeoutMs = this.settings.requestTimeoutMs;
860
+ const notifyAuthError = (err: unknown) => this.onAuthError?.(serverName, err);
850
861
 
851
862
  this.pi.registerTool({
852
863
  name: piName,
@@ -899,6 +910,9 @@ export class ToolBridge {
899
910
  return { content, details: {} };
900
911
  } catch (err) {
901
912
  if (err instanceof McpError) throw err;
913
+ if (isOAuthUnauthorizedError(err)) {
914
+ await notifyAuthError(err);
915
+ }
902
916
  // Protocol-level errors (JSON-RPC error response, timeout, etc.)
903
917
  throw new McpError(
904
918
  err instanceof Error ? err.message : String(err),