pi-mcp-extension 1.9.5 → 1.9.7
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/index.ts +228 -47
- package/src/server-manager.ts +54 -9
- package/src/tool-bridge.ts +13 -3
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/index.ts
CHANGED
|
@@ -238,12 +238,21 @@ export async function waitForOAuthCallback(
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Resolve exact server names before interpreting the optional reset
|
|
242
|
-
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 } {
|
|
243
243
|
const name = args.trim();
|
|
244
|
+
if (!name) return { name: "", reset: false };
|
|
244
245
|
if (serverNames.includes(name)) return { name, reset: false };
|
|
245
|
-
|
|
246
|
-
|
|
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 };
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
interface ServerCompletionEntry {
|
|
@@ -319,13 +328,15 @@ function mergeWithRegisteredServers(
|
|
|
319
328
|
};
|
|
320
329
|
}
|
|
321
330
|
|
|
331
|
+
const DEFAULT_SETTINGS = { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 } as const;
|
|
332
|
+
|
|
322
333
|
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
|
|
323
334
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
324
335
|
// cwd and project trust are available on the ExtensionContext passed to event handlers.
|
|
325
336
|
// At bootstrap (before session_start) we load ONLY the global config so untrusted
|
|
326
337
|
// <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
|
|
327
338
|
let fileConfig: McpConfig = {
|
|
328
|
-
settings: {
|
|
339
|
+
settings: { ...DEFAULT_SETTINGS },
|
|
329
340
|
mcpServers: {},
|
|
330
341
|
};
|
|
331
342
|
try {
|
|
@@ -357,25 +368,131 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
357
368
|
const declinedStartupAuth = new Set<string>();
|
|
358
369
|
const startupPromptControllers = new Map<string, AbortController>();
|
|
359
370
|
let authQueue: Promise<void> = Promise.resolve();
|
|
371
|
+
let activeStartupTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
|
|
372
|
+
let activeCtx: ExtensionContext | undefined;
|
|
360
373
|
|
|
361
|
-
|
|
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
|
+
}
|
|
401
|
+
|
|
402
|
+
async function runSerializedAuth(
|
|
403
|
+
serverName: string,
|
|
404
|
+
signal: AbortSignal,
|
|
405
|
+
fn: () => Promise<void>,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
const previous = authQueue;
|
|
362
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
|
+
|
|
363
475
|
const previous = authQueue;
|
|
364
|
-
|
|
476
|
+
let release!: () => void;
|
|
477
|
+
const gate = new Promise<void>((resolve) => {
|
|
365
478
|
release = resolve;
|
|
366
479
|
});
|
|
480
|
+
authQueue = previous.then(() => gate);
|
|
481
|
+
|
|
367
482
|
void (async () => {
|
|
483
|
+
let currentTurn: { serverName: string; handoffPromise?: Promise<void> } | null = null;
|
|
368
484
|
try {
|
|
369
|
-
await previous;
|
|
485
|
+
await waitForQueueTurn(previous, promptController.signal, name);
|
|
370
486
|
if (
|
|
487
|
+
promptController.signal.aborted ||
|
|
371
488
|
declinedStartupAuth.has(name) ||
|
|
372
489
|
manager.getServer(name)?.state === "ready" ||
|
|
373
490
|
connectAttempts.has(name)
|
|
374
491
|
) {
|
|
375
492
|
return;
|
|
376
493
|
}
|
|
377
|
-
|
|
378
|
-
|
|
494
|
+
currentTurn = { serverName: name };
|
|
495
|
+
activeStartupTurn = currentTurn;
|
|
379
496
|
let choice: string | undefined;
|
|
380
497
|
try {
|
|
381
498
|
choice = await new Promise<string | undefined>((resolve, reject) => {
|
|
@@ -408,6 +525,15 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
408
525
|
startupPromptControllers.delete(name);
|
|
409
526
|
}
|
|
410
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
|
+
}
|
|
411
537
|
if (
|
|
412
538
|
promptController.signal.aborted ||
|
|
413
539
|
manager.getServer(name)?.state === "ready" ||
|
|
@@ -426,7 +552,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
426
552
|
}
|
|
427
553
|
} else {
|
|
428
554
|
declinedStartupAuth.add(name);
|
|
429
|
-
ctx
|
|
555
|
+
syncDeclinedAuthStatus(ctx);
|
|
430
556
|
}
|
|
431
557
|
} catch (err) {
|
|
432
558
|
if (!(err instanceof AuthCancelledError)) {
|
|
@@ -434,6 +560,17 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
434
560
|
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
435
561
|
}
|
|
436
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
|
+
}
|
|
437
574
|
release();
|
|
438
575
|
}
|
|
439
576
|
})();
|
|
@@ -464,7 +601,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
464
601
|
for (const [name] of serversToStart) {
|
|
465
602
|
if (!authRequiredServers.has(name)) continue;
|
|
466
603
|
if (declinedStartupAuth.has(name)) {
|
|
467
|
-
ctx
|
|
604
|
+
syncDeclinedAuthStatus(ctx);
|
|
468
605
|
continue;
|
|
469
606
|
}
|
|
470
607
|
enqueueStartupAuthPrompt(name, ctx);
|
|
@@ -475,8 +612,6 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
475
612
|
}
|
|
476
613
|
}
|
|
477
614
|
|
|
478
|
-
let activeCtx: ExtensionContext | undefined;
|
|
479
|
-
|
|
480
615
|
manager.setToolRefreshCallback(async (serverName, client, options) => {
|
|
481
616
|
await bridge.refreshTools(
|
|
482
617
|
serverName,
|
|
@@ -491,11 +626,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
491
626
|
manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
|
|
492
627
|
manager.setRecoveryFailedCallback((serverName, error) => {
|
|
493
628
|
if (error instanceof AuthRequiredError) {
|
|
629
|
+
declinedStartupAuth.add(serverName);
|
|
494
630
|
activeCtx?.ui.notify(
|
|
495
631
|
`pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
|
|
496
632
|
"warning",
|
|
497
633
|
);
|
|
498
|
-
activeCtx
|
|
634
|
+
syncDeclinedAuthStatus(activeCtx);
|
|
499
635
|
} else {
|
|
500
636
|
activeCtx?.ui.notify(
|
|
501
637
|
`pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
|
|
@@ -516,12 +652,36 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
516
652
|
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
517
653
|
: true;
|
|
518
654
|
|
|
519
|
-
let nextFileConfig
|
|
655
|
+
let nextFileConfig: McpConfig;
|
|
520
656
|
try {
|
|
521
657
|
nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
|
|
522
658
|
} catch (err) {
|
|
523
659
|
const msg = formatErrorMessage(err);
|
|
524
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
|
+
}
|
|
525
685
|
return;
|
|
526
686
|
}
|
|
527
687
|
|
|
@@ -539,21 +699,16 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
539
699
|
// If config changed (different cwd with project-level overrides or registered servers),
|
|
540
700
|
// shut down old servers and rebuild the manager's server list
|
|
541
701
|
if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
|
|
542
|
-
|
|
543
|
-
for (const controller of startupPromptControllers.values()) {
|
|
544
|
-
controller.abort();
|
|
545
|
-
}
|
|
546
|
-
startupPromptControllers.clear();
|
|
547
|
-
// Deactivate and remove all tools from old config
|
|
548
|
-
for (const server of manager.getAllServers()) {
|
|
549
|
-
bridge.removeServer(server.name);
|
|
550
|
-
}
|
|
551
|
-
// Shut down all running servers
|
|
552
|
-
await manager.shutdownAll();
|
|
702
|
+
await teardownSessionState(ctx);
|
|
553
703
|
// Rebuild server entries from new config
|
|
554
704
|
bridge.updateSettings(sessionConfig.settings);
|
|
555
705
|
manager.rebuildServers(sessionConfig);
|
|
556
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);
|
|
557
712
|
}
|
|
558
713
|
|
|
559
714
|
const eagerServers = Object.entries(sessionConfig.mcpServers).filter(
|
|
@@ -595,6 +750,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
595
750
|
bridge.removeServer(name);
|
|
596
751
|
await manager.removeServer(name);
|
|
597
752
|
}
|
|
753
|
+
if (removedOrChanged.length > 0) {
|
|
754
|
+
syncDeclinedAuthStatus(ctx);
|
|
755
|
+
}
|
|
598
756
|
|
|
599
757
|
const addedEager: Array<[string, ServerConfig]> = [];
|
|
600
758
|
for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
|
|
@@ -616,10 +774,14 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
616
774
|
|
|
617
775
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
618
776
|
activeCtx = undefined;
|
|
777
|
+
declinedStartupAuth.clear();
|
|
619
778
|
for (const controller of startupPromptControllers.values()) controller.abort();
|
|
620
779
|
startupPromptControllers.clear();
|
|
621
780
|
for (const attempt of connectAttempts.values()) attempt.controller.abort();
|
|
622
|
-
|
|
781
|
+
connectAttempts.clear();
|
|
782
|
+
await stopCallbackServer().catch(() => {
|
|
783
|
+
// WHY: Ignore callback server shutdown errors during session_shutdown.
|
|
784
|
+
});
|
|
623
785
|
|
|
624
786
|
// Deactivate all tools before shutting down servers
|
|
625
787
|
for (const server of manager.getAllServers()) {
|
|
@@ -675,7 +837,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
675
837
|
ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
|
|
676
838
|
return;
|
|
677
839
|
}
|
|
840
|
+
declinedStartupAuth.delete(serverName);
|
|
841
|
+
syncDeclinedAuthStatus(ctx);
|
|
678
842
|
startupPromptControllers.get(serverName)?.abort();
|
|
843
|
+
startupPromptControllers.delete(serverName);
|
|
679
844
|
connectAttempts.get(serverName)?.controller.abort();
|
|
680
845
|
bridge.deactivateServer(serverName);
|
|
681
846
|
await manager.stopServer(serverName);
|
|
@@ -701,6 +866,10 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
701
866
|
await connectRequested(serverName, ctx);
|
|
702
867
|
ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
|
|
703
868
|
} catch (err) {
|
|
869
|
+
if (err instanceof AuthCancelledError) {
|
|
870
|
+
ctx.ui.notify(`pi-mcp: ${err.message}`, "info");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
704
873
|
const msg = formatErrorMessage(err);
|
|
705
874
|
ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
|
|
706
875
|
}
|
|
@@ -967,7 +1136,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
967
1136
|
} finally {
|
|
968
1137
|
signal.removeEventListener("abort", cancel);
|
|
969
1138
|
cancel();
|
|
970
|
-
await stopCallbackServer().catch(() => {
|
|
1139
|
+
await stopCallbackServer().catch(() => {
|
|
1140
|
+
// WHY: Ignore callback server stop errors during authorize cleanup.
|
|
1141
|
+
});
|
|
971
1142
|
await unlock?.();
|
|
972
1143
|
}
|
|
973
1144
|
}
|
|
@@ -985,9 +1156,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
985
1156
|
if (signal?.aborted) throw new AuthCancelledError(serverName);
|
|
986
1157
|
if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
|
|
987
1158
|
if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1159
|
+
|
|
1160
|
+
const inheritedStartupTurn =
|
|
1161
|
+
!alreadyQueued && activeStartupTurn?.serverName === serverName ? activeStartupTurn : null;
|
|
991
1162
|
|
|
992
1163
|
const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
|
|
993
1164
|
entry.waiters++;
|
|
@@ -1011,6 +1182,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1011
1182
|
onAbort = () => {
|
|
1012
1183
|
releaseWaiter();
|
|
1013
1184
|
if (entry.waiters === 0) {
|
|
1185
|
+
if (connectAttempts.get(serverName) === entry) {
|
|
1186
|
+
connectAttempts.delete(serverName);
|
|
1187
|
+
}
|
|
1014
1188
|
entry.controller.abort();
|
|
1015
1189
|
stopPromise = manager.stopServer(serverName);
|
|
1016
1190
|
void stopPromise.finally(() => reject(new AuthCancelledError(serverName)));
|
|
@@ -1031,7 +1205,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1031
1205
|
|
|
1032
1206
|
const existing = connectAttempts.get(serverName);
|
|
1033
1207
|
// All requests join an in-flight reset. A reset supersedes a normal connect.
|
|
1034
|
-
if (existing && (!reset || existing.reset)) {
|
|
1208
|
+
if (existing && !existing.controller.signal.aborted && (!reset || existing.reset)) {
|
|
1035
1209
|
return await waitForAttempt(existing);
|
|
1036
1210
|
}
|
|
1037
1211
|
|
|
@@ -1043,7 +1217,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1043
1217
|
let entry!: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number };
|
|
1044
1218
|
const task = (async () => {
|
|
1045
1219
|
// Wait for a superseded connect to release its callback listener and auth lock.
|
|
1046
|
-
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
|
+
}
|
|
1047
1225
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
1048
1226
|
if (reset) {
|
|
1049
1227
|
bridge.deactivateServer(serverName);
|
|
@@ -1055,7 +1233,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1055
1233
|
else throw new AuthRequiredError(serverName);
|
|
1056
1234
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
1057
1235
|
declinedStartupAuth.delete(serverName);
|
|
1058
|
-
|
|
1236
|
+
syncDeclinedAuthStatus(ctx);
|
|
1059
1237
|
return;
|
|
1060
1238
|
} catch (err) {
|
|
1061
1239
|
if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
|
|
@@ -1068,28 +1246,31 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1068
1246
|
await manager.startServer(serverName, ctx.cwd);
|
|
1069
1247
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
1070
1248
|
};
|
|
1071
|
-
if (alreadyQueued) {
|
|
1249
|
+
if (alreadyQueued || inheritedStartupTurn) {
|
|
1072
1250
|
await runInteractiveAuth();
|
|
1073
1251
|
} else {
|
|
1074
|
-
|
|
1075
|
-
const previous = authQueue;
|
|
1076
|
-
authQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
1077
|
-
try {
|
|
1078
|
-
await previous;
|
|
1079
|
-
await runInteractiveAuth();
|
|
1080
|
-
} finally {
|
|
1081
|
-
release();
|
|
1082
|
-
}
|
|
1252
|
+
await runSerializedAuth(serverName, controller.signal, runInteractiveAuth);
|
|
1083
1253
|
}
|
|
1084
1254
|
declinedStartupAuth.delete(serverName);
|
|
1085
|
-
|
|
1255
|
+
syncDeclinedAuthStatus(ctx);
|
|
1086
1256
|
})().catch((err: unknown) => {
|
|
1087
1257
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
1088
1258
|
throw err;
|
|
1089
1259
|
}).finally(() => {
|
|
1090
1260
|
if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
|
|
1091
1261
|
});
|
|
1092
|
-
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
|
+
}
|
|
1093
1274
|
entry = { promise: task, controller, reset, waiters: 0 };
|
|
1094
1275
|
connectAttempts.set(serverName, entry);
|
|
1095
1276
|
return await waitForAttempt(entry);
|
package/src/server-manager.ts
CHANGED
|
@@ -381,6 +381,10 @@ export class ServerManager {
|
|
|
381
381
|
return this.servers.get(name);
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
getServerStatus(name: string): ManagedServer | undefined {
|
|
385
|
+
return this.servers.get(name);
|
|
386
|
+
}
|
|
387
|
+
|
|
384
388
|
getAllServers(): ManagedServer[] {
|
|
385
389
|
return Array.from(this.servers.values());
|
|
386
390
|
}
|
|
@@ -389,6 +393,23 @@ export class ServerManager {
|
|
|
389
393
|
return this.getAllServers().filter((s) => s.state === "ready");
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
private _updateServerCwd(server: ManagedServer, cwd: string): void {
|
|
397
|
+
const prevCwd = server.cwd;
|
|
398
|
+
server.cwd = cwd;
|
|
399
|
+
if (prevCwd !== undefined && prevCwd !== cwd && server.state === "ready" && server.client) {
|
|
400
|
+
void server.client.sendRootsListChanged().catch(() => {
|
|
401
|
+
// WHY: Best-effort notification; some MCP servers ignore or do not support roots/list_changed.
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Update workspace cwd for all managed servers and notify ready servers if cwd changed. */
|
|
407
|
+
updateCwd(cwd: string): void {
|
|
408
|
+
for (const server of this.servers.values()) {
|
|
409
|
+
this._updateServerCwd(server, cwd);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
392
413
|
/** Status summary for /mcp command. */
|
|
393
414
|
getStatusSummary(): string {
|
|
394
415
|
const all = this.getAllServers();
|
|
@@ -455,7 +476,7 @@ export class ServerManager {
|
|
|
455
476
|
if (!server) {
|
|
456
477
|
throw new McpError(`Unknown server "${name}"`, name, "config");
|
|
457
478
|
}
|
|
458
|
-
server
|
|
479
|
+
this._updateServerCwd(server, cwd);
|
|
459
480
|
if (server.attempt && !server.controller?.signal.aborted) return server.attempt;
|
|
460
481
|
if (server.state === "ready") return;
|
|
461
482
|
server.retryCount = 0;
|
|
@@ -526,6 +547,11 @@ export class ServerManager {
|
|
|
526
547
|
}
|
|
527
548
|
}
|
|
528
549
|
|
|
550
|
+
/** Alias for rebuildServers when replacing configuration on session_start. */
|
|
551
|
+
replaceConfig(config: McpConfig): void {
|
|
552
|
+
this.rebuildServers(config);
|
|
553
|
+
}
|
|
554
|
+
|
|
529
555
|
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
530
556
|
|
|
531
557
|
private async _connectWithTimeoutAndSignal(
|
|
@@ -661,7 +687,8 @@ export class ServerManager {
|
|
|
661
687
|
corruptStateRecovered = false,
|
|
662
688
|
): Promise<void> {
|
|
663
689
|
server.lastError = null;
|
|
664
|
-
server.cwd
|
|
690
|
+
const currentCwd = server.cwd ?? cwd;
|
|
691
|
+
server.cwd = currentCwd;
|
|
665
692
|
const timeoutMs = this._resolveTimeoutMs(server);
|
|
666
693
|
// Note: retryCount is NOT reset here — it's only reset after successful connect.
|
|
667
694
|
// This ensures the retry limit is respected across reconnection attempts.
|
|
@@ -677,7 +704,7 @@ export class ServerManager {
|
|
|
677
704
|
|
|
678
705
|
let transport: Transport;
|
|
679
706
|
try {
|
|
680
|
-
transport = this.createTransport(server.name, server.config,
|
|
707
|
+
transport = this.createTransport(server.name, server.config, currentCwd);
|
|
681
708
|
} catch (err) {
|
|
682
709
|
server.state = "stopped";
|
|
683
710
|
server.lastError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -837,6 +864,11 @@ export class ServerManager {
|
|
|
837
864
|
server.state = "ready";
|
|
838
865
|
server.retryCount = 0;
|
|
839
866
|
server.lastError = null;
|
|
867
|
+
if (server.cwd && server.cwd !== currentCwd) {
|
|
868
|
+
void client.sendRootsListChanged().catch(() => {
|
|
869
|
+
// WHY: Best-effort notification if cwd was updated while handshake was in flight.
|
|
870
|
+
});
|
|
871
|
+
}
|
|
840
872
|
if (server.config.healthCheckIntervalMs) {
|
|
841
873
|
let pingInFlight = false;
|
|
842
874
|
server.healthCheckTimer = setInterval(async () => {
|
|
@@ -985,7 +1017,7 @@ export class ServerManager {
|
|
|
985
1017
|
// without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
|
|
986
1018
|
console.error(`[pi-mcp] ${error.message}`);
|
|
987
1019
|
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
988
|
-
return this._connect(server, cwd, signal, true);
|
|
1020
|
+
return this._connect(server, server.cwd ?? cwd, signal, true);
|
|
989
1021
|
}
|
|
990
1022
|
server.lastError = error;
|
|
991
1023
|
if (error instanceof AuthRequiredError) {
|
|
@@ -1019,14 +1051,27 @@ export class ServerManager {
|
|
|
1019
1051
|
}
|
|
1020
1052
|
const delayMs = RETRY_DELAYS_MS[Math.min(server.retryCount++, RETRY_DELAYS_MS.length - 1)] ?? 30000;
|
|
1021
1053
|
await new Promise<void>((resolve) => {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1054
|
+
if (signal.aborted) {
|
|
1055
|
+
resolve();
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const onAbort = () => {
|
|
1059
|
+
if (server.retryTimer) {
|
|
1060
|
+
clearTimeout(server.retryTimer);
|
|
1061
|
+
server.retryTimer = null;
|
|
1062
|
+
}
|
|
1063
|
+
resolve();
|
|
1064
|
+
};
|
|
1065
|
+
const timer = setTimeout(() => {
|
|
1066
|
+
signal.removeEventListener("abort", onAbort);
|
|
1067
|
+
server.retryTimer = null;
|
|
1025
1068
|
resolve();
|
|
1026
|
-
},
|
|
1069
|
+
}, delayMs);
|
|
1070
|
+
server.retryTimer = timer;
|
|
1071
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1027
1072
|
});
|
|
1028
1073
|
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
1029
|
-
return this._connect(server, cwd, signal, corruptStateRecovered);
|
|
1074
|
+
return this._connect(server, server.cwd ?? cwd, signal, corruptStateRecovered);
|
|
1030
1075
|
}
|
|
1031
1076
|
}
|
|
1032
1077
|
|
package/src/tool-bridge.ts
CHANGED
|
@@ -760,7 +760,13 @@ export class ToolBridge {
|
|
|
760
760
|
|
|
761
761
|
/** Update bridge settings when session config is reloaded on session_start. */
|
|
762
762
|
updateSettings(settings: Settings): void {
|
|
763
|
+
const prevDefaultTimeoutMs = this.settings.requestTimeoutMs;
|
|
763
764
|
this.settings = settings;
|
|
765
|
+
for (const [serverName, timeoutMs] of this.serverTimeouts.entries()) {
|
|
766
|
+
if (timeoutMs === prevDefaultTimeoutMs) {
|
|
767
|
+
this.serverTimeouts.delete(serverName);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
764
770
|
}
|
|
765
771
|
|
|
766
772
|
/**
|
|
@@ -779,10 +785,14 @@ export class ToolBridge {
|
|
|
779
785
|
const isCurrent = typeof isCurrentOrOptions === "function" ? isCurrentOrOptions : () => true;
|
|
780
786
|
const options = typeof isCurrentOrOptions === "object" ? isCurrentOrOptions : maybeOptions;
|
|
781
787
|
const defaults = getClientDefaultOptions(client);
|
|
782
|
-
const
|
|
783
|
-
|
|
788
|
+
const explicitTimeoutMs = options?.requestTimeoutMs ?? defaults?.requestTimeoutMs;
|
|
789
|
+
const effectiveTimeoutMs = explicitTimeoutMs ?? this.settings.requestTimeoutMs;
|
|
784
790
|
const effectiveSignal = options?.signal ?? defaults?.signal;
|
|
785
|
-
|
|
791
|
+
if (explicitTimeoutMs !== undefined) {
|
|
792
|
+
this.serverTimeouts.set(serverName, explicitTimeoutMs);
|
|
793
|
+
} else {
|
|
794
|
+
this.serverTimeouts.delete(serverName);
|
|
795
|
+
}
|
|
786
796
|
|
|
787
797
|
const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
|
|
788
798
|
this.refreshGenerations.set(serverName, gen);
|