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/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("mcp", {
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 as typeof params;
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 metadata = reconstructToolMetadata(name, cachedEntry, prefix, definition, config.mcpServers, cache ?? undefined);
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
- this.onReconnectFailure?.(name, error);
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 clearPendingAuth(runtime, serverName, oauthState, authStorageOptions)
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 clearPendingAuth(runtime, serverName, oauthState, pendingAuth.authStorageOptions).catch(error => {
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 clearPendingAuth(runtime, serverName, oauthState, authStorageOptions)
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 clearPendingAuth(runtime, serverName, oauthState, authStorageOptions)
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 clearPendingAuth(runtime, serverName, oauthState, authStorageOptions)
958
+ await clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, authStorageOptions)
926
959
  throwIfAborted(signal)
927
960
  clearAllCredentials(serverName, authStorageOptions)
928
961
  await clearOAuthState(serverName, authStorageOptions)