pi-mcp-extension 1.9.4 → 1.9.6
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/callback-url.ts +5 -7
- package/src/errors.ts +10 -0
- package/src/index.ts +257 -56
- package/src/server-manager.ts +569 -74
- package/src/tool-bridge.ts +125 -20
package/package.json
CHANGED
package/src/callback-url.ts
CHANGED
|
@@ -115,6 +115,11 @@ export function parseCallbackUrl(
|
|
|
115
115
|
"invalid_state",
|
|
116
116
|
"Invalid or expired state parameter - potential CSRF attack",
|
|
117
117
|
);
|
|
118
|
+
} else if (typeof options.expectedState === "function" && !options.expectedState(state)) {
|
|
119
|
+
throw new CallbackUrlValidationError(
|
|
120
|
+
"invalid_state",
|
|
121
|
+
"Invalid or expired state parameter - potential CSRF attack",
|
|
122
|
+
);
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
if (error) {
|
|
@@ -125,12 +130,5 @@ export function parseCallbackUrl(
|
|
|
125
130
|
throw new CallbackUrlValidationError("missing_code", "No authorization code provided");
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
if (typeof options.expectedState === "function" && !options.expectedState(state)) {
|
|
129
|
-
throw new CallbackUrlValidationError(
|
|
130
|
-
"invalid_state",
|
|
131
|
-
"Invalid or expired state parameter - potential CSRF attack",
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
133
|
return { state, code };
|
|
136
134
|
}
|
package/src/errors.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
export type McpErrorCode =
|
|
8
8
|
| "config" // Configuration loading or validation failed
|
|
9
9
|
| "connection" // Transport/connection failed
|
|
10
|
+
| "auth" // Authentication/authorization failed
|
|
11
|
+
| "timeout" // Request or connection timed out
|
|
10
12
|
| "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
|
|
11
13
|
| "tool"; // Tool execution error (isError: true from server)
|
|
12
14
|
|
|
@@ -33,3 +35,11 @@ export class McpError extends Error {
|
|
|
33
35
|
return `[${this.server}] ${this.message}`;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
|
|
39
|
+
export class AuthCancelledError extends Error {
|
|
40
|
+
constructor(serverName: string) {
|
|
41
|
+
super(`Authentication cancelled for ${serverName}`);
|
|
42
|
+
this.name = "AuthCancelledError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { ServerManager } from "./server-manager.js";
|
|
|
19
19
|
import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
|
|
20
20
|
import * as Type from "typebox";
|
|
21
21
|
import { ToolBridge } from "./tool-bridge.js";
|
|
22
|
-
import { McpError } from "./errors.js";
|
|
22
|
+
import { McpError, AuthCancelledError } from "./errors.js";
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
24
|
|
|
25
25
|
// OAuth imports
|
|
@@ -76,12 +76,7 @@ function openBrowser(url: string): Promise<void> {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export
|
|
80
|
-
constructor(serverName: string) {
|
|
81
|
-
super(`Authentication cancelled for ${serverName}`);
|
|
82
|
-
this.name = "AuthCancelledError";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
79
|
+
export { AuthCancelledError } from "./errors.js";
|
|
85
80
|
|
|
86
81
|
export const authRetryOption = "Retry: open browser again";
|
|
87
82
|
export const authPasteOption = "Paste redirect URL";
|
|
@@ -243,12 +238,21 @@ export async function waitForOAuthCallback(
|
|
|
243
238
|
}
|
|
244
239
|
}
|
|
245
240
|
|
|
246
|
-
/** Resolve exact server names before interpreting the optional reset
|
|
247
|
-
export function parseAuthArguments(args
|
|
241
|
+
/** Resolve exact server names before interpreting the optional reset flag. */
|
|
242
|
+
export function parseAuthArguments(args = "", serverNames: string[] = []): { name: string; reset: boolean } {
|
|
248
243
|
const name = args.trim();
|
|
244
|
+
if (!name) return { name: "", reset: false };
|
|
249
245
|
if (serverNames.includes(name)) return { name, reset: false };
|
|
250
|
-
|
|
251
|
-
|
|
246
|
+
// WHY: Support standalone `--reset` as well as leading (`--reset <name>`) and trailing (`<name> --reset`)
|
|
247
|
+
// flag positions so `/mcp:auth --reset` prints the usage message rather than looking up a server named "--reset".
|
|
248
|
+
if (name === "--reset") return { name: "", reset: true };
|
|
249
|
+
if (/\s+--reset$/.test(name)) {
|
|
250
|
+
return { name: name.replace(/\s+--reset$/, "").trimEnd(), reset: true };
|
|
251
|
+
}
|
|
252
|
+
if (/^--reset\s+/.test(name)) {
|
|
253
|
+
return { name: name.replace(/^--reset\s+/, "").trimStart(), reset: true };
|
|
254
|
+
}
|
|
255
|
+
return { name, reset: false };
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
interface ServerCompletionEntry {
|
|
@@ -324,13 +328,15 @@ function mergeWithRegisteredServers(
|
|
|
324
328
|
};
|
|
325
329
|
}
|
|
326
330
|
|
|
331
|
+
const DEFAULT_SETTINGS = { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 } as const;
|
|
332
|
+
|
|
327
333
|
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
|
|
328
334
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
329
335
|
// cwd and project trust are available on the ExtensionContext passed to event handlers.
|
|
330
336
|
// At bootstrap (before session_start) we load ONLY the global config so untrusted
|
|
331
337
|
// <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
|
|
332
338
|
let fileConfig: McpConfig = {
|
|
333
|
-
settings: {
|
|
339
|
+
settings: { ...DEFAULT_SETTINGS },
|
|
334
340
|
mcpServers: {},
|
|
335
341
|
};
|
|
336
342
|
try {
|
|
@@ -362,25 +368,131 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
362
368
|
const declinedStartupAuth = new Set<string>();
|
|
363
369
|
const startupPromptControllers = new Map<string, AbortController>();
|
|
364
370
|
let authQueue: Promise<void> = Promise.resolve();
|
|
371
|
+
let activeStartupTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
|
|
372
|
+
let activeCtx: ExtensionContext | undefined;
|
|
373
|
+
|
|
374
|
+
// WHY: Allow an aborted waiter on authQueue to wake up and reject immediately without breaking
|
|
375
|
+
// the sequential promise chain for subsequent non-aborted waiters.
|
|
376
|
+
function waitForQueueTurn(previous: Promise<void>, signal: AbortSignal, serverName: string): Promise<void> {
|
|
377
|
+
if (signal.aborted) {
|
|
378
|
+
return Promise.reject(new AuthCancelledError(serverName));
|
|
379
|
+
}
|
|
380
|
+
return new Promise<void>((resolve, reject) => {
|
|
381
|
+
const onAbort = () => {
|
|
382
|
+
reject(new AuthCancelledError(serverName));
|
|
383
|
+
};
|
|
384
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
385
|
+
previous.then(
|
|
386
|
+
() => {
|
|
387
|
+
signal.removeEventListener("abort", onAbort);
|
|
388
|
+
if (signal.aborted) {
|
|
389
|
+
reject(new AuthCancelledError(serverName));
|
|
390
|
+
} else {
|
|
391
|
+
resolve();
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
(err) => {
|
|
395
|
+
signal.removeEventListener("abort", onAbort);
|
|
396
|
+
reject(err);
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
365
401
|
|
|
366
|
-
function
|
|
402
|
+
async function runSerializedAuth(
|
|
403
|
+
serverName: string,
|
|
404
|
+
signal: AbortSignal,
|
|
405
|
+
fn: () => Promise<void>,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
const previous = authQueue;
|
|
367
408
|
let release!: () => void;
|
|
409
|
+
const gate = new Promise<void>((resolve) => {
|
|
410
|
+
release = resolve;
|
|
411
|
+
});
|
|
412
|
+
authQueue = previous.then(() => gate);
|
|
413
|
+
try {
|
|
414
|
+
await waitForQueueTurn(previous, signal, serverName);
|
|
415
|
+
await fn();
|
|
416
|
+
} finally {
|
|
417
|
+
release();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function syncDeclinedAuthStatus(ctx: ExtensionContext | undefined = activeCtx): void {
|
|
422
|
+
for (const declinedName of Array.from(declinedStartupAuth)) {
|
|
423
|
+
const server = manager.getServer(declinedName);
|
|
424
|
+
if (!server || server.state === "ready") {
|
|
425
|
+
declinedStartupAuth.delete(declinedName);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (!ctx) return;
|
|
429
|
+
if (declinedStartupAuth.size === 0) {
|
|
430
|
+
ctx.ui.setStatus?.("pi-mcp", undefined);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// WHY: Display the most recent remaining server that still needs authorization so resolving
|
|
434
|
+
// one server does not clear the footer hint while other declined servers are still pending.
|
|
435
|
+
const remaining = Array.from(declinedStartupAuth);
|
|
436
|
+
const displayServer = remaining[remaining.length - 1]!;
|
|
437
|
+
ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${displayServer}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function teardownSessionState(ctx?: ExtensionContext): Promise<void> {
|
|
441
|
+
declinedStartupAuth.clear();
|
|
442
|
+
ctx?.ui.setStatus?.("pi-mcp", undefined);
|
|
443
|
+
for (const controller of startupPromptControllers.values()) {
|
|
444
|
+
controller.abort();
|
|
445
|
+
}
|
|
446
|
+
startupPromptControllers.clear();
|
|
447
|
+
const inFlightPromises = Array.from(connectAttempts.values(), (attempt) => attempt.promise);
|
|
448
|
+
for (const attempt of connectAttempts.values()) {
|
|
449
|
+
attempt.controller.abort();
|
|
450
|
+
}
|
|
451
|
+
connectAttempts.clear();
|
|
452
|
+
if (inFlightPromises.length > 0) {
|
|
453
|
+
await Promise.allSettled(inFlightPromises);
|
|
454
|
+
}
|
|
455
|
+
await stopCallbackServer().catch(() => {
|
|
456
|
+
// WHY: Ignore callback server shutdown errors during session teardown.
|
|
457
|
+
});
|
|
458
|
+
for (const server of manager.getAllServers()) {
|
|
459
|
+
bridge.removeServer(server.name);
|
|
460
|
+
}
|
|
461
|
+
await manager.shutdownAll();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
|
|
465
|
+
// WHY: Deduplicate startup prompts when session_start fires multiple times or while a prompt
|
|
466
|
+
// for the same server is already queued or active.
|
|
467
|
+
if (startupPromptControllers.has(name)) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const promptController = new AbortController();
|
|
471
|
+
// WHY: Register the AbortController immediately when queued (before awaiting authQueue) so
|
|
472
|
+
// /mcp:stop, /mcp:auth, or session teardown can cancel prompts that are still waiting in line.
|
|
473
|
+
startupPromptControllers.set(name, promptController);
|
|
474
|
+
|
|
368
475
|
const previous = authQueue;
|
|
369
|
-
|
|
476
|
+
let release!: () => void;
|
|
477
|
+
const gate = new Promise<void>((resolve) => {
|
|
370
478
|
release = resolve;
|
|
371
479
|
});
|
|
480
|
+
authQueue = previous.then(() => gate);
|
|
481
|
+
|
|
372
482
|
void (async () => {
|
|
483
|
+
let currentTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
|
|
373
484
|
try {
|
|
374
|
-
await previous;
|
|
485
|
+
await waitForQueueTurn(previous, promptController.signal, name);
|
|
375
486
|
if (
|
|
487
|
+
promptController.signal.aborted ||
|
|
376
488
|
declinedStartupAuth.has(name) ||
|
|
377
489
|
manager.getServer(name)?.state === "ready" ||
|
|
378
490
|
connectAttempts.has(name)
|
|
379
491
|
) {
|
|
380
492
|
return;
|
|
381
493
|
}
|
|
382
|
-
|
|
383
|
-
|
|
494
|
+
currentTurn = { serverName: name };
|
|
495
|
+
activeStartupTurn = currentTurn;
|
|
384
496
|
let choice: string | undefined;
|
|
385
497
|
try {
|
|
386
498
|
choice = await new Promise<string | undefined>((resolve, reject) => {
|
|
@@ -413,6 +525,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
413
525
|
startupPromptControllers.delete(name);
|
|
414
526
|
}
|
|
415
527
|
}
|
|
528
|
+
if (currentTurn.handoffPromise) {
|
|
529
|
+
// WHY: An explicit connectRequested (/mcp:auth, /mcp:start, or mcp_connect) superseded
|
|
530
|
+
// this server's startup prompt while it held the active queue turn. Hold the queue slot
|
|
531
|
+
// until that connection/auth finishes before letting the next server's prompt appear.
|
|
532
|
+
await currentTurn.handoffPromise.catch(() => {
|
|
533
|
+
// WHY: Errors from an explicit connectRequested handoff are surfaced to its own caller.
|
|
534
|
+
});
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
416
537
|
if (
|
|
417
538
|
promptController.signal.aborted ||
|
|
418
539
|
manager.getServer(name)?.state === "ready" ||
|
|
@@ -431,7 +552,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
431
552
|
}
|
|
432
553
|
} else {
|
|
433
554
|
declinedStartupAuth.add(name);
|
|
434
|
-
ctx
|
|
555
|
+
syncDeclinedAuthStatus(ctx);
|
|
435
556
|
}
|
|
436
557
|
} catch (err) {
|
|
437
558
|
if (!(err instanceof AuthCancelledError)) {
|
|
@@ -439,6 +560,17 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
439
560
|
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
440
561
|
}
|
|
441
562
|
} finally {
|
|
563
|
+
if (startupPromptControllers.get(name) === promptController) {
|
|
564
|
+
startupPromptControllers.delete(name);
|
|
565
|
+
}
|
|
566
|
+
if (currentTurn && activeStartupTurn === currentTurn) {
|
|
567
|
+
if (currentTurn.handoffPromise) {
|
|
568
|
+
await currentTurn.handoffPromise.catch(() => {
|
|
569
|
+
// WHY: Suppress rejection here because connectRequested caller handles it.
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
activeStartupTurn = null;
|
|
573
|
+
}
|
|
442
574
|
release();
|
|
443
575
|
}
|
|
444
576
|
})();
|
|
@@ -469,7 +601,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
469
601
|
for (const [name] of serversToStart) {
|
|
470
602
|
if (!authRequiredServers.has(name)) continue;
|
|
471
603
|
if (declinedStartupAuth.has(name)) {
|
|
472
|
-
ctx
|
|
604
|
+
syncDeclinedAuthStatus(ctx);
|
|
473
605
|
continue;
|
|
474
606
|
}
|
|
475
607
|
enqueueStartupAuthPrompt(name, ctx);
|
|
@@ -480,31 +612,76 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
480
612
|
}
|
|
481
613
|
}
|
|
482
614
|
|
|
483
|
-
manager.setToolRefreshCallback(async (serverName, client) => {
|
|
484
|
-
await bridge.refreshTools(
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
615
|
+
manager.setToolRefreshCallback(async (serverName, client, options) => {
|
|
616
|
+
await bridge.refreshTools(
|
|
617
|
+
serverName,
|
|
618
|
+
client,
|
|
619
|
+
() => {
|
|
620
|
+
const server = manager.getServer(serverName);
|
|
621
|
+
return server?.client === client && server.state !== "stopped";
|
|
622
|
+
},
|
|
623
|
+
options,
|
|
624
|
+
);
|
|
488
625
|
});
|
|
489
626
|
manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
|
|
627
|
+
manager.setRecoveryFailedCallback((serverName, error) => {
|
|
628
|
+
if (error instanceof AuthRequiredError) {
|
|
629
|
+
declinedStartupAuth.add(serverName);
|
|
630
|
+
activeCtx?.ui.notify(
|
|
631
|
+
`pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
|
|
632
|
+
"warning",
|
|
633
|
+
);
|
|
634
|
+
syncDeclinedAuthStatus(activeCtx);
|
|
635
|
+
} else {
|
|
636
|
+
activeCtx?.ui.notify(
|
|
637
|
+
`pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
|
|
638
|
+
"error",
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
490
642
|
bridge.setAuthErrorCallback(async (serverName, err) => {
|
|
491
643
|
await manager.handleUnauthorizedServerError(serverName, err);
|
|
492
644
|
});
|
|
493
645
|
|
|
494
646
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
495
647
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
648
|
+
activeCtx = ctx;
|
|
496
649
|
// Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
|
|
497
650
|
const includeProjectConfig =
|
|
498
651
|
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
|
|
499
652
|
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
500
653
|
: true;
|
|
501
654
|
|
|
502
|
-
let nextFileConfig
|
|
655
|
+
let nextFileConfig: McpConfig;
|
|
503
656
|
try {
|
|
504
657
|
nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
|
|
505
658
|
} catch (err) {
|
|
506
659
|
const msg = formatErrorMessage(err);
|
|
507
660
|
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
661
|
+
// WHY: Switching sessions into a directory with an invalid config must tear down stale
|
|
662
|
+
// servers, tools, prompts, and status from the previous session rather than leaving them active.
|
|
663
|
+
await teardownSessionState(ctx);
|
|
664
|
+
fileConfig = {
|
|
665
|
+
settings: { ...DEFAULT_SETTINGS },
|
|
666
|
+
mcpServers: {},
|
|
667
|
+
};
|
|
668
|
+
extensionServers = collectRegisteredServers(
|
|
669
|
+
getRegisteredEntries(),
|
|
670
|
+
fileConfig.mcpServers,
|
|
671
|
+
(regErr) => {
|
|
672
|
+
const regMsg = formatErrorMessage(regErr);
|
|
673
|
+
ctx.ui.notify(`pi-mcp: Config error — ${regMsg}`, "error");
|
|
674
|
+
},
|
|
675
|
+
);
|
|
676
|
+
config = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
677
|
+
bridge.updateSettings(config.settings);
|
|
678
|
+
manager.rebuildServers(config);
|
|
679
|
+
if (Object.keys(extensionServers).length > 0) {
|
|
680
|
+
const eagerServers = Object.entries(config.mcpServers).filter(
|
|
681
|
+
([, cfg]) => cfg.lifecycle === "eager",
|
|
682
|
+
);
|
|
683
|
+
await startEagerServers(eagerServers, ctx);
|
|
684
|
+
}
|
|
508
685
|
return;
|
|
509
686
|
}
|
|
510
687
|
|
|
@@ -522,21 +699,16 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
522
699
|
// If config changed (different cwd with project-level overrides or registered servers),
|
|
523
700
|
// shut down old servers and rebuild the manager's server list
|
|
524
701
|
if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
|
|
525
|
-
|
|
526
|
-
for (const controller of startupPromptControllers.values()) {
|
|
527
|
-
controller.abort();
|
|
528
|
-
}
|
|
529
|
-
startupPromptControllers.clear();
|
|
530
|
-
// Deactivate and remove all tools from old config
|
|
531
|
-
for (const server of manager.getAllServers()) {
|
|
532
|
-
bridge.removeServer(server.name);
|
|
533
|
-
}
|
|
534
|
-
// Shut down all running servers
|
|
535
|
-
await manager.shutdownAll();
|
|
702
|
+
await teardownSessionState(ctx);
|
|
536
703
|
// Rebuild server entries from new config
|
|
537
704
|
bridge.updateSettings(sessionConfig.settings);
|
|
538
705
|
manager.rebuildServers(sessionConfig);
|
|
539
706
|
config = sessionConfig;
|
|
707
|
+
} else {
|
|
708
|
+
// WHY: Even when config is identical across session switches, update running servers'
|
|
709
|
+
// cwd and emit notifications/roots/list_changed if the workspace directory changed.
|
|
710
|
+
manager.updateCwd(ctx.cwd);
|
|
711
|
+
syncDeclinedAuthStatus(ctx);
|
|
540
712
|
}
|
|
541
713
|
|
|
542
714
|
const eagerServers = Object.entries(sessionConfig.mcpServers).filter(
|
|
@@ -548,6 +720,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
548
720
|
|
|
549
721
|
// Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
|
|
550
722
|
pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
|
|
723
|
+
activeCtx = ctx;
|
|
551
724
|
const entries = Array.isArray((event as { servers?: unknown })?.servers)
|
|
552
725
|
? ((event as { servers: RegisteredMcpServer[] }).servers)
|
|
553
726
|
: getRegisteredEntries();
|
|
@@ -577,6 +750,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
577
750
|
bridge.removeServer(name);
|
|
578
751
|
await manager.removeServer(name);
|
|
579
752
|
}
|
|
753
|
+
if (removedOrChanged.length > 0) {
|
|
754
|
+
syncDeclinedAuthStatus(ctx);
|
|
755
|
+
}
|
|
580
756
|
|
|
581
757
|
const addedEager: Array<[string, ServerConfig]> = [];
|
|
582
758
|
for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
|
|
@@ -597,10 +773,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
597
773
|
});
|
|
598
774
|
|
|
599
775
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
776
|
+
activeCtx = undefined;
|
|
777
|
+
declinedStartupAuth.clear();
|
|
600
778
|
for (const controller of startupPromptControllers.values()) controller.abort();
|
|
601
779
|
startupPromptControllers.clear();
|
|
602
780
|
for (const attempt of connectAttempts.values()) attempt.controller.abort();
|
|
603
|
-
|
|
781
|
+
connectAttempts.clear();
|
|
782
|
+
await stopCallbackServer().catch(() => {
|
|
783
|
+
// WHY: Ignore callback server shutdown errors during session_shutdown.
|
|
784
|
+
});
|
|
604
785
|
|
|
605
786
|
// Deactivate all tools before shutting down servers
|
|
606
787
|
for (const server of manager.getAllServers()) {
|
|
@@ -656,7 +837,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
656
837
|
ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
|
|
657
838
|
return;
|
|
658
839
|
}
|
|
840
|
+
declinedStartupAuth.delete(serverName);
|
|
841
|
+
syncDeclinedAuthStatus(ctx);
|
|
659
842
|
startupPromptControllers.get(serverName)?.abort();
|
|
843
|
+
startupPromptControllers.delete(serverName);
|
|
660
844
|
connectAttempts.get(serverName)?.controller.abort();
|
|
661
845
|
bridge.deactivateServer(serverName);
|
|
662
846
|
await manager.stopServer(serverName);
|
|
@@ -682,6 +866,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
682
866
|
await connectRequested(serverName, ctx);
|
|
683
867
|
ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
|
|
684
868
|
} catch (err) {
|
|
869
|
+
if (err instanceof AuthCancelledError) {
|
|
870
|
+
ctx.ui.notify(`pi-mcp: ${err.message}`, "info");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
685
873
|
const msg = formatErrorMessage(err);
|
|
686
874
|
ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
|
|
687
875
|
}
|
|
@@ -948,7 +1136,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
948
1136
|
} finally {
|
|
949
1137
|
signal.removeEventListener("abort", cancel);
|
|
950
1138
|
cancel();
|
|
951
|
-
await stopCallbackServer().catch(() => {
|
|
1139
|
+
await stopCallbackServer().catch(() => {
|
|
1140
|
+
// WHY: Ignore callback server stop errors during authorize cleanup.
|
|
1141
|
+
});
|
|
952
1142
|
await unlock?.();
|
|
953
1143
|
}
|
|
954
1144
|
}
|
|
@@ -960,14 +1150,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
960
1150
|
reset = false,
|
|
961
1151
|
alreadyQueued = false,
|
|
962
1152
|
): Promise<void> {
|
|
1153
|
+
activeCtx = ctx;
|
|
963
1154
|
const server = manager.getServer(serverName);
|
|
964
1155
|
if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
|
|
965
1156
|
if (signal?.aborted) throw new AuthCancelledError(serverName);
|
|
966
1157
|
if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
|
|
967
1158
|
if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1159
|
+
|
|
1160
|
+
const inheritedStartupTurn =
|
|
1161
|
+
!alreadyQueued && activeStartupTurn?.serverName === serverName ? activeStartupTurn : null;
|
|
971
1162
|
|
|
972
1163
|
const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
|
|
973
1164
|
entry.waiters++;
|
|
@@ -991,6 +1182,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
991
1182
|
onAbort = () => {
|
|
992
1183
|
releaseWaiter();
|
|
993
1184
|
if (entry.waiters === 0) {
|
|
1185
|
+
if (connectAttempts.get(serverName) === entry) {
|
|
1186
|
+
connectAttempts.delete(serverName);
|
|
1187
|
+
}
|
|
994
1188
|
entry.controller.abort();
|
|
995
1189
|
stopPromise = manager.stopServer(serverName);
|
|
996
1190
|
void stopPromise.finally(() => reject(new AuthCancelledError(serverName)));
|
|
@@ -1011,7 +1205,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1011
1205
|
|
|
1012
1206
|
const existing = connectAttempts.get(serverName);
|
|
1013
1207
|
// All requests join an in-flight reset. A reset supersedes a normal connect.
|
|
1014
|
-
if (existing && (!reset || existing.reset)) {
|
|
1208
|
+
if (existing && !existing.controller.signal.aborted && (!reset || existing.reset)) {
|
|
1015
1209
|
return await waitForAttempt(existing);
|
|
1016
1210
|
}
|
|
1017
1211
|
|
|
@@ -1023,7 +1217,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1023
1217
|
let entry!: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number };
|
|
1024
1218
|
const task = (async () => {
|
|
1025
1219
|
// Wait for a superseded connect to release its callback listener and auth lock.
|
|
1026
|
-
if (existing)
|
|
1220
|
+
if (existing) {
|
|
1221
|
+
await existing.promise.catch(() => {
|
|
1222
|
+
// WHY: Ignore errors from the superseded connect attempt while waiting for cleanup.
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1027
1225
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
1028
1226
|
if (reset) {
|
|
1029
1227
|
bridge.deactivateServer(serverName);
|
|
@@ -1035,7 +1233,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1035
1233
|
else throw new AuthRequiredError(serverName);
|
|
1036
1234
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
1037
1235
|
declinedStartupAuth.delete(serverName);
|
|
1038
|
-
|
|
1236
|
+
syncDeclinedAuthStatus(ctx);
|
|
1039
1237
|
return;
|
|
1040
1238
|
} catch (err) {
|
|
1041
1239
|
if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
|
|
@@ -1048,28 +1246,31 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1048
1246
|
await manager.startServer(serverName, ctx.cwd);
|
|
1049
1247
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
1050
1248
|
};
|
|
1051
|
-
if (alreadyQueued) {
|
|
1249
|
+
if (alreadyQueued || inheritedStartupTurn) {
|
|
1052
1250
|
await runInteractiveAuth();
|
|
1053
1251
|
} else {
|
|
1054
|
-
|
|
1055
|
-
const previous = authQueue;
|
|
1056
|
-
authQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
1057
|
-
try {
|
|
1058
|
-
await previous;
|
|
1059
|
-
await runInteractiveAuth();
|
|
1060
|
-
} finally {
|
|
1061
|
-
release();
|
|
1062
|
-
}
|
|
1252
|
+
await runSerializedAuth(serverName, controller.signal, runInteractiveAuth);
|
|
1063
1253
|
}
|
|
1064
1254
|
declinedStartupAuth.delete(serverName);
|
|
1065
|
-
|
|
1255
|
+
syncDeclinedAuthStatus(ctx);
|
|
1066
1256
|
})().catch((err: unknown) => {
|
|
1067
1257
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
1068
1258
|
throw err;
|
|
1069
1259
|
}).finally(() => {
|
|
1070
1260
|
if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
|
|
1071
1261
|
});
|
|
1072
|
-
void task.catch(() => {
|
|
1262
|
+
void task.catch(() => {
|
|
1263
|
+
// WHY: Prevent unhandled rejection when task settles after an aborted waiter detaches.
|
|
1264
|
+
});
|
|
1265
|
+
if (inheritedStartupTurn) {
|
|
1266
|
+
// WHY: Attach handoffPromise before aborting the active startup prompt so the prompt turn
|
|
1267
|
+
// stays held for this server's interactive auth instead of advancing authQueue to the next server.
|
|
1268
|
+
inheritedStartupTurn.handoffPromise = task;
|
|
1269
|
+
}
|
|
1270
|
+
if (!alreadyQueued) {
|
|
1271
|
+
startupPromptControllers.get(serverName)?.abort();
|
|
1272
|
+
startupPromptControllers.delete(serverName);
|
|
1273
|
+
}
|
|
1073
1274
|
entry = { promise: task, controller, reset, waiters: 0 };
|
|
1074
1275
|
connectAttempts.set(serverName, entry);
|
|
1075
1276
|
return await waitForAttempt(entry);
|