pi-mcp-extension 1.8.0 → 1.9.0

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.8.0",
3
+ "version": "1.9.0",
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": [
package/src/index.ts CHANGED
@@ -80,6 +80,13 @@ export class AuthCancelledError extends Error {
80
80
  export const authRetryOption = "Retry: open browser again";
81
81
  export const authPasteOption = "Paste redirect URL";
82
82
  export const authCancelOption = "Cancel authentication";
83
+ export const startupAuthNowOption = "Authorize now";
84
+ export const startupAuthLaterOption = "Not now";
85
+
86
+ function formatErrorMessage(err: unknown): string {
87
+ const msg = err instanceof McpError ? err.userMessage : err instanceof Error ? err.message : String(err);
88
+ return msg.replace(/^AuthRequiredError:\s*/, "");
89
+ }
83
90
 
84
91
  interface OAuthCallbackResult {
85
92
  type: "callback";
@@ -299,8 +306,91 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
299
306
  const manager = new ServerManager(config, undefined, paths.authStorageDir);
300
307
  const bridge = new ToolBridge(config.settings, pi);
301
308
  const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
309
+ const declinedStartupAuth = new Set<string>();
310
+ const startupPromptControllers = new Map<string, AbortController>();
302
311
  let authQueue: Promise<void> = Promise.resolve();
303
312
 
313
+ function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
314
+ let release!: () => void;
315
+ const previous = authQueue;
316
+ authQueue = new Promise<void>((resolve) => {
317
+ release = resolve;
318
+ });
319
+ void (async () => {
320
+ try {
321
+ await previous;
322
+ if (
323
+ declinedStartupAuth.has(name) ||
324
+ manager.getServer(name)?.state === "ready" ||
325
+ connectAttempts.has(name)
326
+ ) {
327
+ return;
328
+ }
329
+ const promptController = new AbortController();
330
+ startupPromptControllers.set(name, promptController);
331
+ let choice: string | undefined;
332
+ try {
333
+ choice = await new Promise<string | undefined>((resolve, reject) => {
334
+ if (promptController.signal.aborted) {
335
+ resolve(undefined);
336
+ return;
337
+ }
338
+ const onAbort = () => resolve(undefined);
339
+ promptController.signal.addEventListener("abort", onAbort, { once: true });
340
+ ctx.ui
341
+ .select(
342
+ `${name} needs authorization`,
343
+ [startupAuthNowOption, startupAuthLaterOption],
344
+ { signal: promptController.signal },
345
+ )
346
+ .then(
347
+ (value) => {
348
+ promptController.signal.removeEventListener("abort", onAbort);
349
+ resolve(value);
350
+ },
351
+ (err) => {
352
+ promptController.signal.removeEventListener("abort", onAbort);
353
+ if (promptController.signal.aborted) resolve(undefined);
354
+ else reject(err);
355
+ },
356
+ );
357
+ });
358
+ } finally {
359
+ if (startupPromptControllers.get(name) === promptController) {
360
+ startupPromptControllers.delete(name);
361
+ }
362
+ }
363
+ if (
364
+ promptController.signal.aborted ||
365
+ manager.getServer(name)?.state === "ready" ||
366
+ connectAttempts.has(name)
367
+ ) {
368
+ return;
369
+ }
370
+ if (choice === startupAuthNowOption) {
371
+ try {
372
+ await connectRequested(name, ctx, undefined, false, true);
373
+ } catch (err) {
374
+ if (!(err instanceof AuthCancelledError)) {
375
+ const msg = formatErrorMessage(err);
376
+ ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
377
+ }
378
+ }
379
+ } else {
380
+ declinedStartupAuth.add(name);
381
+ ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
382
+ }
383
+ } catch (err) {
384
+ if (!(err instanceof AuthCancelledError)) {
385
+ const msg = formatErrorMessage(err);
386
+ ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
387
+ }
388
+ } finally {
389
+ release();
390
+ }
391
+ })();
392
+ }
393
+
304
394
  manager.setToolRefreshCallback(async (serverName, client) => {
305
395
  await bridge.refreshTools(serverName, client, () => {
306
396
  const server = manager.getServer(serverName);
@@ -316,7 +406,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
316
406
  try {
317
407
  sessionConfig = await loadConfig(ctx.cwd, paths.globalConfigPath);
318
408
  } catch (err) {
319
- const msg = err instanceof McpError ? err.userMessage : String(err);
409
+ const msg = formatErrorMessage(err);
320
410
  ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
321
411
  return;
322
412
  }
@@ -324,6 +414,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
324
414
  // If config changed (different cwd with project-level overrides),
325
415
  // shut down old servers and rebuild the manager's server list
326
416
  if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
417
+ declinedStartupAuth.clear();
418
+ for (const controller of startupPromptControllers.values()) {
419
+ controller.abort();
420
+ }
421
+ startupPromptControllers.clear();
327
422
  // Deactivate and remove all tools from old config
328
423
  for (const server of manager.getAllServers()) {
329
424
  bridge.removeServer(server.name);
@@ -339,20 +434,41 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
339
434
  ([, cfg]) => cfg.lifecycle === "eager",
340
435
  );
341
436
 
437
+ const authRequiredServers = new Set<string>();
438
+
342
439
  // Start all eager servers concurrently
343
440
  await Promise.allSettled(
344
- eagerServers.map(async ([name]) => {
441
+ eagerServers.map(async ([name, cfg]) => {
345
442
  try {
346
443
  await manager.startServer(name, ctx.cwd);
347
444
  } catch (err) {
348
- const msg = err instanceof McpError ? err.userMessage : String(err);
445
+ if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
446
+ authRequiredServers.add(name);
447
+ return;
448
+ }
449
+ const msg = formatErrorMessage(err);
349
450
  ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
350
451
  }
351
452
  }),
352
453
  );
454
+
455
+ for (const [name] of eagerServers) {
456
+ if (!authRequiredServers.has(name)) continue;
457
+ if (declinedStartupAuth.has(name)) {
458
+ ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
459
+ continue;
460
+ }
461
+ enqueueStartupAuthPrompt(name, ctx);
462
+ }
463
+
464
+ for (let i = 0; i < 4 * authRequiredServers.size; i++) {
465
+ await Promise.resolve();
466
+ }
353
467
  });
354
468
 
355
469
  pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
470
+ for (const controller of startupPromptControllers.values()) controller.abort();
471
+ startupPromptControllers.clear();
356
472
  for (const attempt of connectAttempts.values()) attempt.controller.abort();
357
473
  await stopCallbackServer().catch(() => {});
358
474
 
@@ -410,6 +526,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
410
526
  ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
411
527
  return;
412
528
  }
529
+ startupPromptControllers.get(serverName)?.abort();
413
530
  connectAttempts.get(serverName)?.controller.abort();
414
531
  bridge.deactivateServer(serverName);
415
532
  await manager.stopServer(serverName);
@@ -435,7 +552,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
435
552
  await connectRequested(serverName, ctx);
436
553
  ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
437
554
  } catch (err) {
438
- const msg = err instanceof McpError ? err.userMessage : String(err);
555
+ const msg = formatErrorMessage(err);
439
556
  ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
440
557
  }
441
558
  },
@@ -653,12 +770,21 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
653
770
  }
654
771
  }
655
772
 
656
- async function connectRequested(serverName: string, ctx: ExtensionContext, signal?: AbortSignal, reset = false): Promise<void> {
773
+ async function connectRequested(
774
+ serverName: string,
775
+ ctx: ExtensionContext,
776
+ signal?: AbortSignal,
777
+ reset = false,
778
+ alreadyQueued = false,
779
+ ): Promise<void> {
657
780
  const server = manager.getServer(serverName);
658
781
  if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
659
782
  if (signal?.aborted) throw new AuthCancelledError(serverName);
660
783
  if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
661
784
  if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
785
+ if (!alreadyQueued) {
786
+ startupPromptControllers.get(serverName)?.abort();
787
+ }
662
788
 
663
789
  const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
664
790
  entry.waiters++;
@@ -725,24 +851,35 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
725
851
  if (!reset) await manager.startServer(serverName, ctx.cwd);
726
852
  else throw new AuthRequiredError(serverName);
727
853
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
854
+ declinedStartupAuth.delete(serverName);
855
+ if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
728
856
  return;
729
857
  } catch (err) {
730
858
  if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
731
859
  }
732
860
  if (!ctx.hasUI) throw new AuthRequiredError(serverName);
733
- let release!: () => void;
734
- const previous = authQueue;
735
- authQueue = new Promise<void>((resolve) => { release = resolve; });
736
- try {
737
- await previous;
861
+ const runInteractiveAuth = async (): Promise<void> => {
738
862
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
739
863
  await authorize(serverName, ctx, controller.signal, reset);
740
864
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
741
865
  await manager.startServer(serverName, ctx.cwd);
742
866
  if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
743
- } finally {
744
- release();
867
+ };
868
+ if (alreadyQueued) {
869
+ await runInteractiveAuth();
870
+ } else {
871
+ let release!: () => void;
872
+ const previous = authQueue;
873
+ authQueue = new Promise<void>((resolve) => { release = resolve; });
874
+ try {
875
+ await previous;
876
+ await runInteractiveAuth();
877
+ } finally {
878
+ release();
879
+ }
745
880
  }
881
+ declinedStartupAuth.delete(serverName);
882
+ if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
746
883
  })().catch((err: unknown) => {
747
884
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
748
885
  throw err;
@@ -263,6 +263,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
263
263
  ...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
264
264
  };
265
265
  }
266
+ if (this.silent) throw new AuthRequiredError(this.serverName);
266
267
  return undefined;
267
268
  }
268
269