pi-mcp-adapter 2.26.1 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -7
- package/README.md +41 -1
- package/cli.js +163 -1
- package/commands.ts +102 -22
- package/config.ts +25 -7
- package/direct-tools.ts +5 -4
- package/index.ts +149 -6
- package/init.ts +10 -2
- package/lifecycle.ts +79 -21
- package/mcp-auth-flow.ts +39 -6
- package/mcp-bearer-store.ts +387 -0
- package/mcp-callback-server.ts +27 -3
- package/mcp-code.ts +4 -0
- package/mcp-panel.ts +42 -13
- package/metadata-cache.ts +32 -21
- package/package-mcp-loader.ts +168 -0
- package/package.json +3 -1
- package/proxy-modes.ts +5 -4
- package/request-headers-command.ts +31 -37
- package/search-ranking.ts +92 -22
- package/server-manager.ts +25 -7
- package/types.ts +2 -0
- package/ui-server.ts +18 -16
- package/utils.ts +70 -0
package/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { McpExtensionState } from "./state.ts";
|
|
3
|
-
import type { DirectToolSpec, McpAdapterOptions, McpConfig, PromptMetadata } from "./types.ts";
|
|
3
|
+
import type { DirectToolSpec, McpAdapterOptions, McpConfig, PromptMetadata, ServerEntry } from "./types.ts";
|
|
4
4
|
import type { McpOAuthRuntime } from "./mcp-auth-flow.ts";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import type { TSchema } from "typebox";
|
|
7
|
-
import { showStatus, showTools, showPrompts, reconnectServer, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
|
|
7
|
+
import { showStatus, showTools, showPrompts, reconnectServer, reconnectServers, authenticateServer, logoutServer, manageBearerToken, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
|
|
8
8
|
import { cloneMcpConfig, loadMcpConfig, writeProjectServerDisabledOverride } from "./config.ts";
|
|
9
9
|
import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.ts";
|
|
10
10
|
import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
|
|
@@ -22,6 +22,7 @@ import { runMcpScript } from "./mcp-code.ts";
|
|
|
22
22
|
import { cleanupMaterializedBinaryResources } from "./tool-registrar.ts";
|
|
23
23
|
|
|
24
24
|
export type { McpAdapterOptions } from "./types.ts";
|
|
25
|
+
export type { ServerEntry } from "./types.ts";
|
|
25
26
|
export {
|
|
26
27
|
MCP_STATUS_EVENT,
|
|
27
28
|
MCP_STATUS_SNAPSHOT_VERSION,
|
|
@@ -38,6 +39,14 @@ export {
|
|
|
38
39
|
const INIT_WAIT_TIMEOUT_MS = 30_000;
|
|
39
40
|
const INIT_WAIT_TIMED_OUT: unique symbol = Symbol("init-wait-timed-out");
|
|
40
41
|
|
|
42
|
+
export interface McpServerRegistration {
|
|
43
|
+
dispose(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Routes runtime registrations to the adapter installed for a specific Pi
|
|
47
|
+
// instance, so a process with several adapters cannot cross-register.
|
|
48
|
+
const runtimeRegistrars = new WeakMap<ExtensionAPI, (name: string, definition: ServerEntry) => McpServerRegistration>();
|
|
49
|
+
|
|
41
50
|
async function awaitWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | typeof INIT_WAIT_TIMED_OUT> {
|
|
42
51
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
52
|
try {
|
|
@@ -130,6 +139,19 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
130
139
|
let proxyToolRegistered = false;
|
|
131
140
|
let proxyToolDescription: string | null = null;
|
|
132
141
|
let directToolsFrozen = false;
|
|
142
|
+
// Session/runtime scoped server registrations from other extensions. They
|
|
143
|
+
// survive session restarts within this install and die with the process.
|
|
144
|
+
const runtimeServers = new Map<string, ServerEntry>();
|
|
145
|
+
|
|
146
|
+
// Mirrors init's per-server lifecycle registration so runtime servers get
|
|
147
|
+
// idle cleanup and keep-alive health recovery like configured servers.
|
|
148
|
+
function attachRuntimeServerLifecycle(targetState: McpExtensionState, name: string, definition: ServerEntry): void {
|
|
149
|
+
const lifecycleMode = definition.lifecycle ?? "lazy";
|
|
150
|
+
const persistsAfterFirstSpawn = lifecycleMode === "eager" || lifecycleMode === "lazy-keep-alive";
|
|
151
|
+
const idleOverride = definition.idleTimeout ?? (persistsAfterFirstSpawn ? 0 : undefined);
|
|
152
|
+
targetState.lifecycle.registerServer(name, definition, idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined);
|
|
153
|
+
if (lifecycleMode === "keep-alive") targetState.lifecycle.markKeepAlive(name, definition);
|
|
154
|
+
}
|
|
133
155
|
|
|
134
156
|
// OMP remaps `typebox` to a host shim that historically lacked Type.Unsafe.
|
|
135
157
|
// Prefer Unsafe when present (real TypeBox / fixed OMP shim); otherwise pass
|
|
@@ -282,6 +304,45 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
282
304
|
|
|
283
305
|
registerPromptCommands(resolveCachedPrompts(earlyConfig));
|
|
284
306
|
|
|
307
|
+
runtimeRegistrars.set(pi, (name: string, definition: ServerEntry): McpServerRegistration => {
|
|
308
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
309
|
+
throw new Error("MCP server name must be a non-empty string");
|
|
310
|
+
}
|
|
311
|
+
if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
|
|
312
|
+
throw new Error(`MCP server definition for "${name}" must be an object`);
|
|
313
|
+
}
|
|
314
|
+
const effective = state?.config ?? earlyConfig;
|
|
315
|
+
if (runtimeServers.has(name) || Object.hasOwn(effective.mcpServers, name)) {
|
|
316
|
+
throw new Error(`MCP server "${name}" is already registered`);
|
|
317
|
+
}
|
|
318
|
+
// Runtime-registered servers are proxy-tool-only: direct tools are frozen
|
|
319
|
+
// at startup and must not be rebuilt for late registrations.
|
|
320
|
+
const entry: ServerEntry = { ...structuredClone(definition), directTools: false };
|
|
321
|
+
runtimeServers.set(name, entry);
|
|
322
|
+
const registeredState = state;
|
|
323
|
+
if (registeredState) {
|
|
324
|
+
registeredState.config.mcpServers[name] = entry;
|
|
325
|
+
attachRuntimeServerLifecycle(registeredState, name, entry);
|
|
326
|
+
syncToolSurface();
|
|
327
|
+
updateStatusBar(registeredState);
|
|
328
|
+
}
|
|
329
|
+
let disposed = false;
|
|
330
|
+
return {
|
|
331
|
+
dispose: async (): Promise<void> => {
|
|
332
|
+
if (disposed) return;
|
|
333
|
+
disposed = true;
|
|
334
|
+
runtimeServers.delete(name);
|
|
335
|
+
const currentState = state;
|
|
336
|
+
if (!currentState || currentState.config.mcpServers[name] !== entry) return;
|
|
337
|
+
delete currentState.config.mcpServers[name];
|
|
338
|
+
currentState.lifecycle.unregisterServer(name);
|
|
339
|
+
await currentState.manager.close(name);
|
|
340
|
+
syncToolSurface();
|
|
341
|
+
updateStatusBar(currentState);
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
|
|
285
346
|
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
|
286
347
|
|
|
287
348
|
pi.registerFlag("mcp-config", {
|
|
@@ -299,7 +360,6 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
299
360
|
}
|
|
300
361
|
: {}),
|
|
301
362
|
oauthRuntime,
|
|
302
|
-
statusEvents: pi.events,
|
|
303
363
|
});
|
|
304
364
|
initPromise = promise;
|
|
305
365
|
|
|
@@ -314,6 +374,14 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
314
374
|
}
|
|
315
375
|
|
|
316
376
|
state = nextState;
|
|
377
|
+
for (const [name, definition] of runtimeServers) {
|
|
378
|
+
if (Object.hasOwn(nextState.config.mcpServers, name)) {
|
|
379
|
+
console.error(`MCP: runtime-registered server "${name}" now collides with a configured server; keeping the configured server`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
nextState.config.mcpServers[name] = definition;
|
|
383
|
+
attachRuntimeServerLifecycle(nextState, name, definition);
|
|
384
|
+
}
|
|
317
385
|
nextState.onToolMetadataUpdated = (_serverName, _reason) => {
|
|
318
386
|
if (state !== nextState || !owner.isActive()) return;
|
|
319
387
|
syncPromptCommands();
|
|
@@ -325,6 +393,9 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
325
393
|
};
|
|
326
394
|
syncPromptCommands();
|
|
327
395
|
syncToolSurface(ctx);
|
|
396
|
+
// A connected snapshot is readiness-like external state. Publish it only
|
|
397
|
+
// after Pi's model-facing direct-tool surface reflects live metadata.
|
|
398
|
+
nextState.statusEvents = pi.events;
|
|
328
399
|
updateStatusBar(nextState);
|
|
329
400
|
initPromise = null;
|
|
330
401
|
if (earlyConfig.settings?.freezeDirectTools === true) {
|
|
@@ -467,7 +538,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
467
538
|
// Re-flag returned MCP tool failures so pi registers them as errors (see toolErrorOverride).
|
|
468
539
|
pi.on("tool_result", (event) => toolErrorOverride(event.details));
|
|
469
540
|
|
|
470
|
-
pi.registerCommand(
|
|
541
|
+
const registerMcpCommand = (commandName: string) => pi.registerCommand(commandName, {
|
|
471
542
|
description: "Show MCP server status",
|
|
472
543
|
getArgumentCompletions: (prefix: string) => {
|
|
473
544
|
const normalized = prefix.trimStart();
|
|
@@ -479,6 +550,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
479
550
|
{ value: "prompts", label: "prompts — List all MCP prompts" },
|
|
480
551
|
{ value: "setup", label: "setup — Configure MCP servers" },
|
|
481
552
|
{ value: "logout", label: "logout — Clear server credentials" },
|
|
553
|
+
{ value: "token", label: "token — Manage stored bearer tokens" },
|
|
482
554
|
{ value: "disable", label: "disable — Disable a server" },
|
|
483
555
|
{ value: "enable", label: "enable — Enable a server" },
|
|
484
556
|
{ value: "status", label: "status — Show server status" },
|
|
@@ -488,11 +560,27 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
488
560
|
|
|
489
561
|
const [, subcommand, argumentPrefix] = argumentMatch;
|
|
490
562
|
if (
|
|
491
|
-
(subcommand !== "reconnect" && subcommand !== "logout" && subcommand !== "disable" && subcommand !== "enable")
|
|
563
|
+
(subcommand !== "reconnect" && subcommand !== "logout" && subcommand !== "disable" && subcommand !== "enable" && subcommand !== "token")
|
|
492
564
|
|| argumentPrefix === undefined
|
|
493
565
|
|| !state
|
|
494
566
|
) return null;
|
|
495
567
|
|
|
568
|
+
if (subcommand === "token") {
|
|
569
|
+
const tokenMatch = argumentPrefix.trimStart().match(/^(set|remove|status)\s+(.*)$/);
|
|
570
|
+
if (!tokenMatch) {
|
|
571
|
+
const actions = ["set", "remove", "status"]
|
|
572
|
+
.filter(action => action.startsWith(argumentPrefix.trimStart()))
|
|
573
|
+
.map(action => ({ value: `token ${action} `, label: `${action} — Bearer token ${action}` }));
|
|
574
|
+
return actions.length > 0 ? actions : null;
|
|
575
|
+
}
|
|
576
|
+
const action = tokenMatch[1] ?? "";
|
|
577
|
+
const serverPrefix = tokenMatch[2] ?? "";
|
|
578
|
+
const servers = Object.keys(state.config.mcpServers)
|
|
579
|
+
.filter(serverName => serverName.startsWith(serverPrefix.trimStart()))
|
|
580
|
+
.map(serverName => ({ value: `token ${action} ${serverName}`, label: serverName }));
|
|
581
|
+
return servers.length > 0 ? servers : null;
|
|
582
|
+
}
|
|
583
|
+
|
|
496
584
|
const servers = Object.keys(state.config.mcpServers)
|
|
497
585
|
.filter((serverName) => serverName.startsWith(argumentPrefix.trimStart()))
|
|
498
586
|
.map((serverName) => ({ value: `${subcommand} ${serverName}`, label: serverName }));
|
|
@@ -568,6 +656,21 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
568
656
|
await logoutServer(serverName, state, commandCtx);
|
|
569
657
|
break;
|
|
570
658
|
}
|
|
659
|
+
case "token": {
|
|
660
|
+
const action = parts[1];
|
|
661
|
+
const serverName = parts.slice(2).join(" ");
|
|
662
|
+
if (action !== "set" && action !== "remove" && action !== "status") {
|
|
663
|
+
if (commandCtx.hasUI) commandCtx.ui?.notify("Usage: /mcp token set|remove|status <server>", "error");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (!serverName) {
|
|
667
|
+
if (commandCtx.hasUI) commandCtx.ui?.notify("Usage: /mcp token set|remove|status <server>", "error");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
commandOwner?.throwIfInactive();
|
|
671
|
+
await manageBearerToken(action, serverName, state, commandCtx);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
571
674
|
case "disable":
|
|
572
675
|
case "enable": {
|
|
573
676
|
const serverName = rest;
|
|
@@ -618,6 +721,8 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
618
721
|
}
|
|
619
722
|
},
|
|
620
723
|
});
|
|
724
|
+
registerMcpCommand("mcp");
|
|
725
|
+
registerMcpCommand("pi-mcp");
|
|
621
726
|
|
|
622
727
|
pi.registerCommand("mcp-auth", {
|
|
623
728
|
description: "Authenticate with an MCP server (OAuth)",
|
|
@@ -782,6 +887,28 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
782
887
|
}
|
|
783
888
|
return args as Record<string, unknown>;
|
|
784
889
|
};
|
|
890
|
+
const validateNestedGatewayParams = (value: Record<string, unknown>): typeof params => {
|
|
891
|
+
for (const key of ["tool", "connect", "describe", "instructions", "search", "server", "action"] as const) {
|
|
892
|
+
if (value[key] !== undefined && typeof value[key] !== "string") {
|
|
893
|
+
throw new Error(`Invalid nested gateway param \`${key}\`: expected string, got ${Array.isArray(value[key]) ? "array" : typeof value[key]}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
for (const key of ["regex", "includeSchemas"] as const) {
|
|
897
|
+
if (value[key] !== undefined && typeof value[key] !== "boolean") {
|
|
898
|
+
throw new Error(`Invalid nested gateway param \`${key}\`: expected boolean, got ${Array.isArray(value[key]) ? "array" : typeof value[key]}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
for (const key of ["limit", "offset"] as const) {
|
|
902
|
+
if (value[key] !== undefined && typeof value[key] !== "number") {
|
|
903
|
+
throw new Error(`Invalid nested gateway param \`${key}\`: expected number, got ${Array.isArray(value[key]) ? "array" : typeof value[key]}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
const args = value.args;
|
|
907
|
+
if (args !== undefined && typeof args !== "string" && (typeof args !== "object" || args === null || Array.isArray(args))) {
|
|
908
|
+
throw new Error(`Invalid nested gateway param \`args\`: expected JSON object or JSON string, got ${Array.isArray(args) ? "array" : args === null ? "null" : typeof args}`);
|
|
909
|
+
}
|
|
910
|
+
return value as typeof params;
|
|
911
|
+
};
|
|
785
912
|
let parsedArgs = parseArgs(params.args);
|
|
786
913
|
let dispatchParams = params;
|
|
787
914
|
const hasGatewayMode = (value: typeof params): boolean =>
|
|
@@ -793,7 +920,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
793
920
|
|| value.server !== undefined
|
|
794
921
|
|| value.action !== undefined;
|
|
795
922
|
if (!hasGatewayMode(params) && parsedArgs) {
|
|
796
|
-
const nestedParams = parsedArgs
|
|
923
|
+
const nestedParams = validateNestedGatewayParams(parsedArgs);
|
|
797
924
|
if (hasGatewayMode(nestedParams)) {
|
|
798
925
|
dispatchParams = nestedParams;
|
|
799
926
|
parsedArgs = parseArgs(nestedParams.args);
|
|
@@ -939,4 +1066,20 @@ export function createMcpAdapter(options: McpAdapterOptions = {}) {
|
|
|
939
1066
|
};
|
|
940
1067
|
}
|
|
941
1068
|
|
|
1069
|
+
/**
|
|
1070
|
+
* Register an MCP server with the adapter installed for this Pi instance.
|
|
1071
|
+
* Registrations are session/runtime scoped and never persisted. Duplicate
|
|
1072
|
+
* names fail closed. Registered servers are proxy-tool-only; their tools
|
|
1073
|
+
* become visible at the next tool sync. To change a definition, dispose the
|
|
1074
|
+
* registration and register again.
|
|
1075
|
+
*/
|
|
1076
|
+
export function registerMcpServer(options: { pi: ExtensionAPI; name: string; definition: ServerEntry }): McpServerRegistration {
|
|
1077
|
+
const { pi, name, definition } = options;
|
|
1078
|
+
const register = runtimeRegistrars.get(pi);
|
|
1079
|
+
if (!register) {
|
|
1080
|
+
throw new Error("pi-mcp-adapter is not installed for this Pi instance");
|
|
1081
|
+
}
|
|
1082
|
+
return register(name, definition);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
942
1085
|
export default createMcpAdapter();
|
package/init.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { McpExtensionState } from "./state.ts";
|
|
3
|
-
import { formatToolName, isServerDisabled, resolveToolPrefix, type McpAdapterOptions, type PromptMetadata, type ToolMetadata } from "./types.ts";
|
|
3
|
+
import { formatToolName, isServerDisabled, resolveToolPrefix, type McpAdapterOptions, type PromptMetadata, type ToolMetadata, type ToolSelectorCandidateIndex } from "./types.ts";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { cloneMcpConfig, loadMcpConfig } from "./config.ts";
|
|
6
6
|
import { ConsentManager } from "./consent-manager.ts";
|
|
7
7
|
import { McpLifecycleManager } from "./lifecycle.ts";
|
|
8
8
|
import {
|
|
9
9
|
computeServerHash,
|
|
10
|
+
createCachedToolSelectorCandidateIndex,
|
|
10
11
|
getMetadataCachePath,
|
|
11
12
|
getMissingConfiguredDirectToolServers,
|
|
12
13
|
isServerCacheValid,
|
|
@@ -239,6 +240,7 @@ export async function initializeMcp(
|
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
const prefix = config.settings?.toolPrefix ?? "server";
|
|
243
|
+
let cachedSelectorCandidateIndex: ToolSelectorCandidateIndex | undefined;
|
|
242
244
|
|
|
243
245
|
for (const [name, definition] of serverEntries) {
|
|
244
246
|
const lifecycleMode = definition.lifecycle ?? "lazy";
|
|
@@ -255,7 +257,13 @@ export async function initializeMcp(
|
|
|
255
257
|
|
|
256
258
|
const cachedEntry = cache?.servers?.[name];
|
|
257
259
|
if (cachedEntry && isServerCacheValid(cachedEntry, definition)) {
|
|
258
|
-
const
|
|
260
|
+
const hasToolFilters =
|
|
261
|
+
(Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
|
|
262
|
+
(Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
|
|
263
|
+
if (hasToolFilters && !cachedSelectorCandidateIndex && cache) {
|
|
264
|
+
cachedSelectorCandidateIndex = createCachedToolSelectorCandidateIndex(config.mcpServers, cache, prefix);
|
|
265
|
+
}
|
|
266
|
+
const metadata = reconstructToolMetadata(name, cachedEntry, prefix, definition, config.mcpServers, cache ?? undefined, cachedSelectorCandidateIndex);
|
|
259
267
|
toolMetadata.set(name, metadata);
|
|
260
268
|
if (Array.isArray(cachedEntry.resources)) {
|
|
261
269
|
resourceCounts.set(name, cachedEntry.resources.length);
|
package/lifecycle.ts
CHANGED
|
@@ -73,6 +73,14 @@ export class McpLifecycleManager {
|
|
|
73
73
|
if (settings?.idleTimeout !== undefined) this.serverSettings.set(name, settings);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
unregisterServer(name: string): void {
|
|
77
|
+
this.allServers.delete(name);
|
|
78
|
+
this.keepAliveServers.delete(name);
|
|
79
|
+
this.serverSettings.delete(name);
|
|
80
|
+
this.retryStates.delete(name);
|
|
81
|
+
this.pendingMetadataPublications.delete(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
setGlobalIdleTimeout(minutes: number): void {
|
|
77
85
|
this.globalIdleTimeout = minutes * 60 * 1000;
|
|
78
86
|
}
|
|
@@ -155,6 +163,10 @@ export class McpLifecycleManager {
|
|
|
155
163
|
retrySuperseded = true,
|
|
156
164
|
): Promise<void> {
|
|
157
165
|
if (isServerDisabled(definition) || this.stopped || signal?.aborted) return;
|
|
166
|
+
// Fence against unregisterServer racing an in-flight convergence pass.
|
|
167
|
+
// Identity comparison also rejects stale passes after a same-name
|
|
168
|
+
// replacement registration, which a name check would wrongly accept.
|
|
169
|
+
if (this.keepAliveServers.get(name) !== definition) return;
|
|
158
170
|
const connection = this.manager.getConnection(name);
|
|
159
171
|
if (connection?.status === "needs-auth") {
|
|
160
172
|
this.pendingMetadataPublications.delete(name);
|
|
@@ -171,17 +183,18 @@ export class McpLifecycleManager {
|
|
|
171
183
|
freshConnection = await this.manager.connect(name, definition, signal);
|
|
172
184
|
} catch (error) {
|
|
173
185
|
if (this.stopped || signal?.aborted) return;
|
|
174
|
-
this.reportConnectionFailure(name, error, "reconnect", this.manager.getConnection(name));
|
|
186
|
+
this.reportConnectionFailure(name, definition, error, "reconnect", this.manager.getConnection(name));
|
|
175
187
|
return;
|
|
176
188
|
}
|
|
177
189
|
if (this.stopped || signal?.aborted) return;
|
|
178
190
|
if (freshConnection.status === "needs-auth") {
|
|
179
|
-
await this.notifyAuthRequired(name);
|
|
191
|
+
await this.notifyAuthRequired(name, definition, freshConnection);
|
|
180
192
|
return;
|
|
181
193
|
}
|
|
182
194
|
if (freshConnection.status !== "connected") {
|
|
183
195
|
this.reportConnectionFailure(
|
|
184
196
|
name,
|
|
197
|
+
definition,
|
|
185
198
|
new Error(`MCP server ${name} did not return a connected session`),
|
|
186
199
|
"reconnect",
|
|
187
200
|
freshConnection,
|
|
@@ -189,12 +202,12 @@ export class McpLifecycleManager {
|
|
|
189
202
|
return;
|
|
190
203
|
}
|
|
191
204
|
logger.debug(`Reconnected to ${name}`);
|
|
192
|
-
await this.publishConnectedMetadata(name, freshConnection);
|
|
205
|
+
await this.publishConnectedMetadata(name, definition, freshConnection);
|
|
193
206
|
return;
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
if (this.pendingMetadataPublications.has(name)) {
|
|
197
|
-
await this.publishConnectedMetadata(name, connection);
|
|
210
|
+
await this.publishConnectedMetadata(name, definition, connection);
|
|
198
211
|
return;
|
|
199
212
|
}
|
|
200
213
|
if (!definition.url) return;
|
|
@@ -210,7 +223,7 @@ export class McpLifecycleManager {
|
|
|
210
223
|
return;
|
|
211
224
|
}
|
|
212
225
|
if (!shouldReconnectAfterRefresh(error, hadSessionId)) {
|
|
213
|
-
this.reportConnectionFailure(name, error, "refresh", connection);
|
|
226
|
+
this.reportConnectionFailure(name, definition, error, "refresh", connection);
|
|
214
227
|
return;
|
|
215
228
|
}
|
|
216
229
|
if (this.hasPendingAuthForServer(name)) {
|
|
@@ -222,17 +235,18 @@ export class McpLifecycleManager {
|
|
|
222
235
|
freshConnection = await this.manager.reconnect(name, definition, connection, signal);
|
|
223
236
|
} catch (reconnectError) {
|
|
224
237
|
if (this.stopped || signal?.aborted) return;
|
|
225
|
-
this.reportConnectionFailure(name, reconnectError, "reconnect", this.manager.getConnection(name));
|
|
238
|
+
this.reportConnectionFailure(name, definition, reconnectError, "reconnect", this.manager.getConnection(name));
|
|
226
239
|
return;
|
|
227
240
|
}
|
|
228
241
|
if (this.stopped || signal?.aborted) return;
|
|
229
242
|
if (freshConnection.status === "needs-auth") {
|
|
230
|
-
await this.notifyAuthRequired(name);
|
|
243
|
+
await this.notifyAuthRequired(name, definition, freshConnection);
|
|
231
244
|
return;
|
|
232
245
|
}
|
|
233
246
|
if (freshConnection.status !== "connected") {
|
|
234
247
|
this.reportConnectionFailure(
|
|
235
248
|
name,
|
|
249
|
+
definition,
|
|
236
250
|
new Error(`MCP server ${name} did not return a connected session`),
|
|
237
251
|
"reconnect",
|
|
238
252
|
freshConnection,
|
|
@@ -240,7 +254,7 @@ export class McpLifecycleManager {
|
|
|
240
254
|
return;
|
|
241
255
|
}
|
|
242
256
|
logger.debug(`Reconnected stale MCP session for ${name}`);
|
|
243
|
-
await this.publishConnectedMetadata(name, freshConnection);
|
|
257
|
+
await this.publishConnectedMetadata(name, definition, freshConnection);
|
|
244
258
|
return;
|
|
245
259
|
}
|
|
246
260
|
|
|
@@ -248,6 +262,11 @@ export class McpLifecycleManager {
|
|
|
248
262
|
await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
|
|
249
263
|
return;
|
|
250
264
|
}
|
|
265
|
+
if (refreshResult === "refresh-timeout") {
|
|
266
|
+
this.deferRefreshTimeout(name, definition, connection);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (this.keepAliveServers.get(name) !== definition) return;
|
|
251
270
|
if (this.retryStates.delete(name)) {
|
|
252
271
|
await this.onHealthRestored?.(name);
|
|
253
272
|
}
|
|
@@ -261,16 +280,17 @@ export class McpLifecycleManager {
|
|
|
261
280
|
retrySuperseded: boolean,
|
|
262
281
|
): Promise<void> {
|
|
263
282
|
const current = this.manager.getConnection(name);
|
|
283
|
+
if (this.keepAliveServers.get(name) !== definition) return;
|
|
264
284
|
if (current === staleConnection && current.status === "connected") {
|
|
265
285
|
if (this.retryStates.delete(name)) await this.onHealthRestored?.(name);
|
|
266
286
|
return;
|
|
267
287
|
}
|
|
268
288
|
if (current?.status === "connected") {
|
|
269
|
-
await this.publishConnectedMetadata(name, current);
|
|
289
|
+
await this.publishConnectedMetadata(name, definition, current);
|
|
270
290
|
return;
|
|
271
291
|
}
|
|
272
292
|
if (current?.status === "needs-auth") {
|
|
273
|
-
await this.notifyAuthRequired(name);
|
|
293
|
+
await this.notifyAuthRequired(name, definition, current);
|
|
274
294
|
return;
|
|
275
295
|
}
|
|
276
296
|
if (retrySuperseded) {
|
|
@@ -278,7 +298,15 @@ export class McpLifecycleManager {
|
|
|
278
298
|
}
|
|
279
299
|
}
|
|
280
300
|
|
|
281
|
-
private async publishConnectedMetadata(name: string, connection: ServerConnection): Promise<void> {
|
|
301
|
+
private async publishConnectedMetadata(name: string, definition: ServerDefinition, connection: ServerConnection): Promise<void> {
|
|
302
|
+
// Fence stale convergence passes by definition identity so disposal, and
|
|
303
|
+
// disposal followed by a same-name replacement, never re-track this
|
|
304
|
+
// server. Close the pass's own connection only when it is still current,
|
|
305
|
+
// so a replacement's connection is never touched.
|
|
306
|
+
if (this.keepAliveServers.get(name) !== definition) {
|
|
307
|
+
if (this.manager.getConnection(name) === connection) await this.manager.close(name);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
282
310
|
this.pendingMetadataPublications.add(name);
|
|
283
311
|
try {
|
|
284
312
|
await this.onReconnect?.(name);
|
|
@@ -286,11 +314,19 @@ export class McpLifecycleManager {
|
|
|
286
314
|
this.retryStates.delete(name);
|
|
287
315
|
} catch (error) {
|
|
288
316
|
if (this.stopped) return;
|
|
289
|
-
this.reportConnectionFailure(name, error, "publish", connection);
|
|
317
|
+
this.reportConnectionFailure(name, definition, error, "publish", connection);
|
|
290
318
|
}
|
|
291
319
|
}
|
|
292
320
|
|
|
293
|
-
private async notifyAuthRequired(name: string): Promise<void> {
|
|
321
|
+
private async notifyAuthRequired(name: string, definition: ServerDefinition, connection: ServerConnection): Promise<void> {
|
|
322
|
+
// Fence stale convergence passes by definition identity. Close the
|
|
323
|
+
// leftover needs-auth connection only while it is still the manager's
|
|
324
|
+
// current entry, so a replacement's connection is never touched and the
|
|
325
|
+
// stale client/transport cannot leak when a replacement connects later.
|
|
326
|
+
if (this.keepAliveServers.get(name) !== definition) {
|
|
327
|
+
if (this.manager.getConnection(name) === connection) await this.manager.close(name);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
294
330
|
this.pendingMetadataPublications.delete(name);
|
|
295
331
|
this.retryStates.delete(name);
|
|
296
332
|
try {
|
|
@@ -313,10 +349,39 @@ export class McpLifecycleManager {
|
|
|
313
349
|
|
|
314
350
|
private reportConnectionFailure(
|
|
315
351
|
name: string,
|
|
352
|
+
definition: ServerDefinition,
|
|
316
353
|
error: unknown,
|
|
317
354
|
action: "refresh" | "reconnect" | "publish",
|
|
318
355
|
connection: ServerConnection | undefined,
|
|
319
356
|
): void {
|
|
357
|
+
if (!this.recordRetry(name, definition, connection)) return;
|
|
358
|
+
this.onReconnectFailure?.(name, error);
|
|
359
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
360
|
+
const target = action === "reconnect"
|
|
361
|
+
? `reconnect to ${name}`
|
|
362
|
+
: action === "publish"
|
|
363
|
+
? `publish metadata for ${name}`
|
|
364
|
+
: `refresh ${name}`;
|
|
365
|
+
console.error(`MCP: Failed to ${target}: ${sanitizeTerminalText(message)}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private deferRefreshTimeout(
|
|
369
|
+
name: string,
|
|
370
|
+
definition: ServerDefinition,
|
|
371
|
+
connection: ServerConnection | undefined,
|
|
372
|
+
): void {
|
|
373
|
+
if (!this.recordRetry(name, definition, connection)) return;
|
|
374
|
+
logger.debug(`MCP: keep-alive tools/list refresh timed out for ${name}; retrying after backoff`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private recordRetry(
|
|
378
|
+
name: string,
|
|
379
|
+
definition: ServerDefinition,
|
|
380
|
+
connection: ServerConnection | undefined,
|
|
381
|
+
): boolean {
|
|
382
|
+
// Do not recreate retry/failure state from a stale convergence pass after
|
|
383
|
+
// disposal or a same-name replacement registration.
|
|
384
|
+
if (this.keepAliveServers.get(name) !== definition) return false;
|
|
320
385
|
const attempts = (this.retryStates.get(name)?.attempts ?? 0) + 1;
|
|
321
386
|
const delay = Math.min(
|
|
322
387
|
KEEP_ALIVE_RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 10),
|
|
@@ -328,14 +393,7 @@ export class McpLifecycleManager {
|
|
|
328
393
|
connection,
|
|
329
394
|
status: connection?.status,
|
|
330
395
|
});
|
|
331
|
-
|
|
332
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
333
|
-
const target = action === "reconnect"
|
|
334
|
-
? `reconnect to ${name}`
|
|
335
|
-
: action === "publish"
|
|
336
|
-
? `publish metadata for ${name}`
|
|
337
|
-
: `refresh ${name}`;
|
|
338
|
-
console.error(`MCP: Failed to ${target}: ${sanitizeTerminalText(message)}`);
|
|
396
|
+
return true;
|
|
339
397
|
}
|
|
340
398
|
|
|
341
399
|
private getIdleTimeout(name: string): number {
|
package/mcp-auth-flow.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
waitForCallback,
|
|
19
19
|
cancelPendingCallback,
|
|
20
20
|
stopCallbackServer,
|
|
21
|
+
stopCallbackServerIfIdle,
|
|
21
22
|
releaseCallbackServer,
|
|
22
23
|
} from "./mcp-callback-server.ts"
|
|
23
24
|
import {
|
|
@@ -387,7 +388,7 @@ export async function startAuth(
|
|
|
387
388
|
} catch (error) {
|
|
388
389
|
releaseCallbackServer(oauthState)
|
|
389
390
|
try {
|
|
390
|
-
await clearOAuthState(serverName, authStorageOptions)
|
|
391
|
+
await cleanupAndReleaseCallbackServerIfIdle(() => clearOAuthState(serverName, authStorageOptions))
|
|
391
392
|
} catch (cleanupError) {
|
|
392
393
|
throw new AggregateError([error, cleanupError], "OAuth startup cleanup failed")
|
|
393
394
|
}
|
|
@@ -429,6 +430,7 @@ export async function startAuth(
|
|
|
429
430
|
authProvider.deactivate()
|
|
430
431
|
releaseCallbackServer(oauthState)
|
|
431
432
|
await clearOAuthState(serverName, authStorageOptions)
|
|
433
|
+
await stopCallbackServerIfIdle()
|
|
432
434
|
return { authorizationUrl: "" }
|
|
433
435
|
}
|
|
434
436
|
if (!capturedUrl) {
|
|
@@ -439,7 +441,7 @@ export async function startAuth(
|
|
|
439
441
|
} catch (error) {
|
|
440
442
|
authProvider.deactivate()
|
|
441
443
|
try {
|
|
442
|
-
await
|
|
444
|
+
await clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, authStorageOptions)
|
|
443
445
|
} catch (cleanupError) {
|
|
444
446
|
throw new AggregateError([error, cleanupError], "OAuth startup cleanup failed")
|
|
445
447
|
}
|
|
@@ -463,7 +465,7 @@ async function setPendingAuth(
|
|
|
463
465
|
state.pendingAuths.set(key, pendingAuth)
|
|
464
466
|
state.pendingAuthStates.set(key, oauthState)
|
|
465
467
|
const cleanupTimer = setTimeout(() => {
|
|
466
|
-
void
|
|
468
|
+
void clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, pendingAuth.authStorageOptions).catch(error => {
|
|
467
469
|
console.error(`MCP Auth: Timed-out flow cleanup failed: ${formatTerminalError(error)}`)
|
|
468
470
|
})
|
|
469
471
|
}, MANUAL_AUTH_TIMEOUT_MS)
|
|
@@ -498,6 +500,37 @@ async function clearPendingAuth(runtime: McpOAuthRuntime, serverName: string, oa
|
|
|
498
500
|
}
|
|
499
501
|
}
|
|
500
502
|
|
|
503
|
+
async function clearPendingAuthAndReleaseIfIdle(
|
|
504
|
+
runtime: McpOAuthRuntime,
|
|
505
|
+
serverName: string,
|
|
506
|
+
oauthState: string | undefined,
|
|
507
|
+
fallbackStorageOptions: AuthStorageOptions = {},
|
|
508
|
+
): Promise<void> {
|
|
509
|
+
await cleanupAndReleaseCallbackServerIfIdle(
|
|
510
|
+
() => clearPendingAuth(runtime, serverName, oauthState, fallbackStorageOptions),
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function cleanupAndReleaseCallbackServerIfIdle(cleanup: () => void | Promise<void>): Promise<void> {
|
|
515
|
+
let cleanupFailure: { error: unknown } | undefined
|
|
516
|
+
try {
|
|
517
|
+
await cleanup()
|
|
518
|
+
} catch (error) {
|
|
519
|
+
cleanupFailure = { error }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
await stopCallbackServerIfIdle()
|
|
524
|
+
} catch (releaseError) {
|
|
525
|
+
if (cleanupFailure) {
|
|
526
|
+
throw new AggregateError([cleanupFailure.error, releaseError], "OAuth callback cleanup failed")
|
|
527
|
+
}
|
|
528
|
+
throw releaseError
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (cleanupFailure) throw cleanupFailure.error
|
|
532
|
+
}
|
|
533
|
+
|
|
501
534
|
function getSearchParamsFromInput(input: string): URLSearchParams | undefined {
|
|
502
535
|
try {
|
|
503
536
|
const url = new URL(input)
|
|
@@ -701,7 +734,7 @@ export async function completeAuth(
|
|
|
701
734
|
} finally {
|
|
702
735
|
if (!keepPendingForRetry) {
|
|
703
736
|
try {
|
|
704
|
-
await
|
|
737
|
+
await clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, authStorageOptions)
|
|
705
738
|
} catch (cleanupError) {
|
|
706
739
|
if (caughtError !== undefined) {
|
|
707
740
|
throw new AggregateError([caughtError, cleanupError], "OAuth completion cleanup failed")
|
|
@@ -801,7 +834,7 @@ export async function authenticate(
|
|
|
801
834
|
} catch (error) {
|
|
802
835
|
if (oauthState) cancelPendingCallback(oauthState)
|
|
803
836
|
try {
|
|
804
|
-
await
|
|
837
|
+
await clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, authStorageOptions)
|
|
805
838
|
} catch (cleanupError) {
|
|
806
839
|
throw new AggregateError([error, cleanupError], "OAuth cancellation cleanup failed")
|
|
807
840
|
}
|
|
@@ -922,7 +955,7 @@ export async function removeAuth(serverName: string, options: AuthenticateOption
|
|
|
922
955
|
if (oauthState) {
|
|
923
956
|
cancelPendingCallback(oauthState)
|
|
924
957
|
}
|
|
925
|
-
await
|
|
958
|
+
await clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, authStorageOptions)
|
|
926
959
|
throwIfAborted(signal)
|
|
927
960
|
clearAllCredentials(serverName, authStorageOptions)
|
|
928
961
|
await clearOAuthState(serverName, authStorageOptions)
|