pi-mcp-extension 1.9.2 → 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.2",
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(
@@ -1023,14 +1079,32 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1023
1079
  name: "mcp_status",
1024
1080
  label: "MCP status",
1025
1081
  description: "List configured MCP servers, lifecycle, connection and OAuth state. Use mcp_connect to connect one server.",
1082
+ promptSnippet: "Inspect configured MCP servers, their connection state, discovered tools, and authentication status.",
1026
1083
  parameters: Type.Object({}),
1027
1084
  async execute() {
1028
1085
  const rows = await Promise.all(manager.getAllServers().map(async (server) => {
1029
- 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
+ }
1030
1098
  return {
1031
1099
  name: server.name, lifecycle: server.config.lifecycle, state: server.state,
1032
1100
  tools: bridge.getToolActivity(server.name),
1033
- 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",
1034
1108
  error: server.lastError
1035
1109
  ? server.lastError instanceof AuthRequiredError
1036
1110
  ? "Authorization required: use mcp_connect in an interactive Pi session"
@@ -1046,6 +1120,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1046
1120
  name: "mcp_connect",
1047
1121
  label: "Connect MCP server",
1048
1122
  description: "Connect one named configured MCP server and activate its tools. May request interactive OAuth authorization.",
1123
+ promptSnippet: "Connect or reconnect to a configured MCP server by name and register its tools.",
1049
1124
  parameters: Type.Object({ name: Type.String({ description: "Exact configured MCP server name" }) }),
1050
1125
  async execute(_id, { name }, signal, _update, ctx) {
1051
1126
  try {
@@ -1080,6 +1155,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1080
1155
  const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name));
1081
1156
  if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; }
1082
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
+ }
1083
1164
  try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
1084
1165
  catch (err) {
1085
1166
  ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");