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 +1 -1
- package/src/connection-coordinator.ts +58 -15
- package/src/index.ts +13 -1
- package/src/server-manager.ts +86 -26
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 (
|
|
487
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────
|
package/src/server-manager.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
562
|
-
|
|
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.
|