pi-mcp-extension 1.11.3 → 1.11.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.11.3",
3
+ "version": "1.11.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": [
@@ -73,6 +73,24 @@ type InteractiveAuthParams = {
73
73
  reset: boolean;
74
74
  };
75
75
 
76
+ /**
77
+ * Whether a declined server still deserves the `run /mcp:auth <name>` footer hint.
78
+ *
79
+ * WHY: A decline records "this server needed authorization at some point", which stays
80
+ * true until there is positive evidence otherwise. `ready` is that evidence, and so is a
81
+ * `stopped` server whose `lastError` is now a non-auth failure (`ECONNREFUSED`, timeout) —
82
+ * that failure overwrote `AuthRequiredError`, so authorization is no longer the problem
83
+ * and the hint would send the user down the wrong path. An unknown server drops out too.
84
+ * Anything ambiguous (no `lastError` yet, still `starting`) keeps the hint, because
85
+ * clearing it on doubt would hide a pending authorization the user was just told about.
86
+ */
87
+ function retainsAuthHint(server: Pick<ManagedServer, "state" | "lastError"> | undefined): boolean {
88
+ if (!server || server.state === "ready") return false;
89
+ const lastError = server.lastError;
90
+ if (!lastError || lastError instanceof AuthRequiredError) return true;
91
+ return server.state !== "stopped";
92
+ }
93
+
76
94
  export class ConnectionCoordinator {
77
95
  private readonly deps: ConnectionCoordinatorDeps;
78
96
  private readonly connectAttempts = new Map<string, ConnectAttemptEntry>();
@@ -124,7 +142,7 @@ export class ConnectionCoordinator {
124
142
  syncStatus(ctx: ExtensionContext | undefined = this.activeCtx): void {
125
143
  for (const declinedName of Array.from(this.declinedStartupAuth)) {
126
144
  const server = this.deps.manager.getServer(declinedName);
127
- if (!server || server.state === "ready") {
145
+ if (!retainsAuthHint(server)) {
128
146
  this.declinedStartupAuth.delete(declinedName);
129
147
  }
130
148
  }
@@ -147,18 +165,23 @@ export class ConnectionCoordinator {
147
165
 
148
166
  handleRecoveryFailed(serverName: string, error: unknown): void {
149
167
  if (error instanceof AuthRequiredError) {
150
- this.declinedStartupAuth.add(serverName);
151
168
  this.activeCtx?.ui.notify(
152
169
  `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
153
170
  "warning",
154
171
  );
155
- this.syncStatus(this.activeCtx);
156
- } else {
157
- this.activeCtx?.ui.notify(
158
- `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
159
- "error",
160
- );
172
+ // WHY: Route through markAuthRequired so a mid-session 401 (reported by
173
+ // ServerManager.handleUnauthorizedServerError) lands in the declined set and
174
+ // repaints the footer hint exactly like a declined startup prompt.
175
+ this.markAuthRequired(serverName);
176
+ return;
161
177
  }
178
+ this.activeCtx?.ui.notify(
179
+ `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
180
+ "error",
181
+ );
182
+ // WHY: The failure just overwrote lastError, so a hint left over from an earlier
183
+ // decline now points at authorization that is not the problem — clear it.
184
+ this.syncStatus(this.activeCtx);
162
185
  }
163
186
 
164
187
  cancelServer(serverName: string): void {
@@ -325,6 +348,9 @@ export class ConnectionCoordinator {
325
348
  try {
326
349
  await this.deps.manager.startServer(name, ctx.cwd);
327
350
  } catch (err) {
351
+ // WHY: A cancelled start means the user stopped the server (/mcp:stop) or the
352
+ // session is tearing down; reporting it as a start failure would be a bogus error.
353
+ if (err instanceof AuthCancelledError) return;
328
354
  if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
329
355
  authRequiredServers.add(name);
330
356
  return;
@@ -335,12 +361,14 @@ export class ConnectionCoordinator {
335
361
  }),
336
362
  );
337
363
 
364
+ // WHY: Reconcile the footer hint once every eager start has settled — a declined
365
+ // server may have connected cleanly or failed with a non-auth error, and either
366
+ // outcome makes "run /mcp:auth <name>" stale.
367
+ this.syncStatus(ctx);
368
+
338
369
  for (const [name] of serversToStart) {
339
370
  if (!authRequiredServers.has(name)) continue;
340
- if (this.declinedStartupAuth.has(name)) {
341
- this.syncStatus(ctx);
342
- continue;
343
- }
371
+ if (this.declinedStartupAuth.has(name)) continue;
344
372
  this.enqueueStartupAuthPrompt(name, ctx);
345
373
  }
346
374
 
@@ -483,8 +511,13 @@ export class ConnectionCoordinator {
483
511
  await this._startAndGuardAbort(serverName, ctx, controller.signal);
484
512
  return true;
485
513
  } catch (err) {
486
- if (!(err instanceof AuthRequiredError) || !serverConfig.auth) throw err;
487
- return false;
514
+ if (err instanceof AuthRequiredError && serverConfig.auth) return false;
515
+ // WHY: A non-auth connect failure (e.g. ECONNREFUSED) overwrites lastError, so a
516
+ // hint left over from an earlier decline no longer describes this server's problem.
517
+ if (!(err instanceof AuthCancelledError)) {
518
+ this.syncStatus(ctx);
519
+ }
520
+ throw err;
488
521
  }
489
522
  }
490
523
 
@@ -493,7 +526,17 @@ export class ConnectionCoordinator {
493
526
  this._ensureActive(controller.signal, serverName);
494
527
  await this.deps.authorize(serverName, ctx, controller.signal, reset);
495
528
  this._ensureActive(controller.signal, serverName);
496
- await this._startAndGuardAbort(serverName, ctx, controller.signal);
529
+ try {
530
+ await this._startAndGuardAbort(serverName, ctx, controller.signal);
531
+ } catch (err) {
532
+ // WHY: Authorization can succeed and the reconnect still fail for a non-auth reason
533
+ // (ECONNREFUSED, timeout). lastError now names that failure, so a hint left over from
534
+ // the earlier decline would tell the user to authorize a server that just authorized.
535
+ if (!(err instanceof AuthCancelledError)) {
536
+ this.syncStatus(ctx);
537
+ }
538
+ throw err;
539
+ }
497
540
  }
498
541
 
499
542
  private async _startAndGuardAbort(
package/src/index.ts CHANGED
@@ -162,8 +162,20 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
162
162
  manager.setRecoveryFailedCallback((serverName, error) => {
163
163
  coordinator.handleRecoveryFailed(serverName, error);
164
164
  });
165
+ manager.setServerReadyCallback(() => {
166
+ // WHY: Eager starts and background _recover() reconnects never pass through
167
+ // coordinator.connect(), so without this the "run /mcp:auth <name>" footer hint
168
+ // survives a server that has since connected cleanly.
169
+ coordinator.syncStatus();
170
+ });
165
171
  bridge.setAuthErrorCallback(async (serverName) => {
166
- await manager.handleUnauthorizedServerError(serverName);
172
+ const authError = await manager.handleUnauthorizedServerError(serverName);
173
+ if (authError) {
174
+ // WHY: A mid-session 401/403 stops the server synchronously, which cancels the
175
+ // deferred _recover() that would have called onRecoveryFailed — so report the
176
+ // failure here or the re-auth warning and the footer hint never appear.
177
+ coordinator.handleRecoveryFailed(serverName, authError);
178
+ }
167
179
  });
168
180
 
169
181
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
@@ -106,6 +106,17 @@ export type ToolRefreshCallback = (
106
106
  /** Called when background auto-recovery after a disconnect or health check fails. */
107
107
  export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
108
108
 
109
+ /**
110
+ * Called every time a server reaches `ready`, whether the transition came from an
111
+ * explicit connect, an eager startup, or background `_recover()`.
112
+ *
113
+ * WHY: Readiness is the only state change that makes a previously declined
114
+ * `run /mcp:auth <name>` footer hint obsolete, and eager starts plus background
115
+ * recovery bypass `ConnectionCoordinator.connect()`, which is where the hint is
116
+ * otherwise repainted.
117
+ */
118
+ export type ServerReadyCallback = (serverName: string) => void;
119
+
109
120
  interface HandshakeParams {
110
121
  server: ManagedServer;
111
122
  client: Client;
@@ -206,6 +217,7 @@ export class ServerManager {
206
217
  private onToolRefresh: ToolRefreshCallback | null = null;
207
218
  private onServerStopped: ((name: string) => void) | null = null;
208
219
  private onRecoveryFailed: RecoveryFailedCallback | null = null;
220
+ private onServerReady: ServerReadyCallback | null = null;
209
221
 
210
222
  constructor(config: McpConfig, private readonly storageDir?: string) {
211
223
  this.settings = config.settings;
@@ -239,6 +251,11 @@ export class ServerManager {
239
251
  this.onRecoveryFailed = cb;
240
252
  }
241
253
 
254
+ /** Register callback invoked whenever a server reaches the `ready` state. */
255
+ setServerReadyCallback(cb: ServerReadyCallback): void {
256
+ this.onServerReady = cb;
257
+ }
258
+
242
259
  private _resolveTimeoutMs(server: ManagedServer): number {
243
260
  return server.config.requestTimeoutMs ?? this.settings.requestTimeoutMs;
244
261
  }
@@ -335,11 +352,29 @@ export class ServerManager {
335
352
  /**
336
353
  * Mark an OAuth server as requiring re-authorization when a mid-session
337
354
  * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
355
+ *
356
+ * Returns the {@link AuthRequiredError} when a server that was already `ready`
357
+ * lost its credentials mid-session, and `undefined` otherwise (unknown server,
358
+ * non-OAuth server, or a server that is not `ready` — see below).
359
+ *
360
+ * Only a `ready` server is handled here. For any other state this is a no-op and
361
+ * must NOT touch the server: a 401/403 during a connect attempt (`initialize` ok,
362
+ * `tools/list` 401 while `starting`) arrives while that attempt owns the
363
+ * lifecycle, and shutting the server down here would abort the attempt's own
364
+ * signal. `classifyConnectFailure` then sees `aborted` first and reports
365
+ * `AuthCancelledError` instead of `auth-required`, which suppresses the startup
366
+ * auth prompt, the interactive authorization, and the recovery failure alike.
367
+ * Left alone, the attempt's `_raiseAuthRequired` invalidates the tokens, records
368
+ * the `AuthRequiredError`, and reports it exactly once.
338
369
  */
339
- async handleUnauthorizedServerError(name: string): Promise<void> {
370
+ async handleUnauthorizedServerError(name: string): Promise<AuthRequiredError | undefined> {
340
371
  const server = this.servers.get(name);
341
372
  if (!server || !server.config.auth || server.config.transport === "stdio") {
342
- return;
373
+ return undefined;
374
+ }
375
+ if (server.state !== "ready") {
376
+ // WHY: See the JSDoc above — an in-flight connect attempt reports its own 401.
377
+ return undefined;
343
378
  }
344
379
  const authError = new AuthRequiredError(name);
345
380
  // WHY: Shut down immediately before async disk I/O so server.generation increments
@@ -348,6 +383,7 @@ export class ServerManager {
348
383
  server.lastError = authError;
349
384
  await invalidateStoredTokens(name, this.storageDir, server.config.url);
350
385
  server.lastError = authError;
386
+ return authError;
351
387
  }
352
388
 
353
389
  /** Get recent stderr output for a server. */
@@ -557,30 +593,9 @@ export class ServerManager {
557
593
  );
558
594
 
559
595
  // tools/list_changed: re-discover tools and update Pi registrations
560
- client.setNotificationHandler(
561
- ToolListChangedNotificationSchema,
562
- async () => {
563
- if (!signal.aborted && this.onToolRefresh && server.state === "ready" && server.client === client) {
564
- try {
565
- const refreshTimeoutMs = this._resolveTimeoutMs(server);
566
- await this.onToolRefresh(server.name, server.client, {
567
- requestTimeoutMs: refreshTimeoutMs,
568
- signal,
569
- });
570
- } catch (err) {
571
- const { kind, message } = describeError(err);
572
- reportDiagnostic({
573
- level: "error",
574
- event: "server.tools_refresh_failed",
575
- server: server.name,
576
- message: `Failed to refresh tools for ${server.name}: ${message}`,
577
- fields: { kind },
578
- notify: true,
579
- });
580
- }
581
- }
582
- },
583
- );
596
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
597
+ await this._handleToolListChanged(server, client, signal);
598
+ });
584
599
 
585
600
  // notifications/message: structured logging from MCP servers
586
601
  client.setNotificationHandler(
@@ -608,6 +623,48 @@ export class ServerManager {
608
623
  return client;
609
624
  }
610
625
 
626
+ /**
627
+ * Re-discover tools after a `notifications/tools/list_changed` from a ready server.
628
+ *
629
+ * Extracted from the notification handler so the handler registration stays a single
630
+ * guarded call.
631
+ */
632
+ private async _handleToolListChanged(
633
+ server: ManagedServer,
634
+ client: Client,
635
+ signal: AbortSignal,
636
+ ): Promise<void> {
637
+ const onToolRefresh = this.onToolRefresh;
638
+ if (signal.aborted || !onToolRefresh || server.state !== "ready" || server.client !== client) {
639
+ return;
640
+ }
641
+ try {
642
+ const refreshTimeoutMs = this._resolveTimeoutMs(server);
643
+ await onToolRefresh(server.name, server.client, {
644
+ requestTimeoutMs: refreshTimeoutMs,
645
+ signal,
646
+ });
647
+ } catch (err) {
648
+ const { kind, message } = describeError(err);
649
+ // WHY: Two failures here are already accounted for and must not toast: a cancelled
650
+ // refresh (/mcp:stop, session teardown) is expected, and a 401/403 was just reported
651
+ // by handleUnauthorizedServerError as a re-authorization warning plus the footer hint
652
+ // — a second "Failed to refresh tools" error would read as an unrelated problem.
653
+ const alreadyReported =
654
+ err instanceof AuthCancelledError ||
655
+ signal.aborted ||
656
+ server.lastError instanceof AuthRequiredError;
657
+ reportDiagnostic({
658
+ level: alreadyReported ? "info" : "error",
659
+ event: "server.tools_refresh_failed",
660
+ server: server.name,
661
+ message: `Failed to refresh tools for ${server.name}: ${message}`,
662
+ fields: { kind },
663
+ notify: !alreadyReported,
664
+ });
665
+ }
666
+ }
667
+
611
668
  private async _handshakeAndDiscover(params: HandshakeParams): Promise<void> {
612
669
  const { server, client, transport, timeoutMs, signal, currentCwd } = params;
613
670
  await this._connectWithTimeoutAndSignal(client, transport, timeoutMs, signal, server.name);
@@ -626,6 +683,9 @@ export class ServerManager {
626
683
  this._setState(server, "ready");
627
684
  server.retryCount = 0;
628
685
  server.lastError = null;
686
+ // WHY: Fire only after the server is observably ready with a cleared lastError, so a
687
+ // listener that repaints session state never sees a half-transitioned server.
688
+ this.onServerReady?.(server.name);
629
689
  if (server.cwd && server.cwd !== currentCwd) {
630
690
  void client.sendRootsListChanged().catch(() => {
631
691
  // WHY: Best-effort notification if cwd was updated while handshake was in flight.