pi-mcp-adapter 2.25.0 → 2.26.1

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/lifecycle.ts CHANGED
@@ -1,11 +1,26 @@
1
+ import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
1
2
  import { isServerDisabled, type ServerDefinition } from "./types.ts";
2
- import type { McpServerManager } from "./server-manager.ts";
3
+ import type { McpServerManager, ServerConnection } from "./server-manager.ts";
3
4
  import { hasPendingAuth } from "./mcp-auth-flow.ts";
4
5
  import { logger } from "./logger.ts";
5
- import { formatTerminalError, sanitizeTerminalText } from "./utils.ts";
6
+ import { formatTerminalError, parallelLimit, sanitizeTerminalText } from "./utils.ts";
7
+ import { isTerminatedSession } from "./session-recovery.ts";
6
8
 
7
- export type ReconnectCallback = (serverName: string) => void;
9
+ export type ReconnectCallback = (serverName: string) => void | Promise<void>;
8
10
  export type ReconnectFailureCallback = (serverName: string, error: unknown) => void;
11
+ export type HealthRestoredCallback = (serverName: string) => void | Promise<void>;
12
+ export type AuthRequiredCallback = (serverName: string) => void | Promise<void>;
13
+
14
+ const KEEP_ALIVE_RETRY_BASE_MS = 30_000;
15
+ const KEEP_ALIVE_RETRY_MAX_MS = 5 * 60_000;
16
+ const KEEP_ALIVE_CHECK_CONCURRENCY = 10;
17
+
18
+ interface RetryState {
19
+ attempts: number;
20
+ nextAttemptAt: number;
21
+ connection: ServerConnection | undefined;
22
+ status: ServerConnection["status"] | undefined;
23
+ }
9
24
 
10
25
  export class McpLifecycleManager {
11
26
  private keepAliveServers = new Map<string, ServerDefinition>();
@@ -15,9 +30,14 @@ export class McpLifecycleManager {
15
30
  private healthCheckInterval: NodeJS.Timeout | undefined;
16
31
  private onReconnect: ReconnectCallback | undefined;
17
32
  private onReconnectFailure: ReconnectFailureCallback | undefined;
33
+ private onHealthRestored: HealthRestoredCallback | undefined;
34
+ private onAuthRequired: AuthRequiredCallback | undefined;
18
35
  private onIdleShutdown: ((serverName: string) => void) | undefined;
19
36
  private activeHealthCheck: Promise<void> | undefined;
37
+ private activeConvergence: Promise<void> | undefined;
20
38
  private shutdownPromise: Promise<void> | undefined;
39
+ private retryStates = new Map<string, RetryState>();
40
+ private pendingMetadataPublications = new Set<string>();
21
41
  private stopped = false;
22
42
  private removeHealthAbortListener: (() => void) | undefined;
23
43
 
@@ -34,6 +54,14 @@ export class McpLifecycleManager {
34
54
  this.onReconnectFailure = callback;
35
55
  }
36
56
 
57
+ setHealthRestoredCallback(callback: HealthRestoredCallback): void {
58
+ this.onHealthRestored = callback;
59
+ }
60
+
61
+ setAuthRequiredCallback(callback: AuthRequiredCallback): void {
62
+ this.onAuthRequired = callback;
63
+ }
64
+
37
65
  markKeepAlive(name: string, definition: ServerDefinition): void {
38
66
  if (isServerDisabled(definition)) return;
39
67
  this.keepAliveServers.set(name, definition);
@@ -82,29 +110,23 @@ export class McpLifecycleManager {
82
110
  this.healthCheckInterval.unref();
83
111
  }
84
112
 
85
- private async checkConnections(signal?: AbortSignal): Promise<void> {
113
+ async ensureConverged(signal?: AbortSignal): Promise<void> {
86
114
  if (this.stopped || signal?.aborted) return;
87
- for (const [name, definition] of this.keepAliveServers) {
88
- if (isServerDisabled(definition)) continue;
89
- const connection = this.manager.getConnection(name);
90
- if (!connection || connection.status !== "connected") {
91
- if (this.hasPendingAuthForServer(name)) {
92
- logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
93
- continue;
94
- }
95
- try {
96
- await this.manager.connect(name, definition, signal);
97
- if (this.stopped || signal?.aborted) return;
98
- logger.debug(`Reconnected to ${name}`);
99
- this.onReconnect?.(name);
100
- } catch (error) {
101
- if (this.stopped || signal?.aborted) return;
102
- this.onReconnectFailure?.(name, error);
103
- const message = error instanceof Error ? error.message : String(error);
104
- console.error(`MCP: Failed to reconnect to ${name}: ${sanitizeTerminalText(message)}`);
105
- }
106
- }
115
+ if (this.activeConvergence) return this.activeConvergence;
116
+
117
+ const check = this.checkKeepAliveConnections(signal);
118
+ this.activeConvergence = check;
119
+ try {
120
+ await check;
121
+ } finally {
122
+ if (this.activeConvergence === check) this.activeConvergence = undefined;
107
123
  }
124
+ }
125
+
126
+ private async checkConnections(signal?: AbortSignal): Promise<void> {
127
+ if (this.stopped || signal?.aborted) return;
128
+ await this.ensureConverged(signal);
129
+ if (this.stopped || signal?.aborted) return;
108
130
 
109
131
  for (const [name] of this.allServers) {
110
132
  if (this.keepAliveServers.has(name)) continue;
@@ -117,6 +139,205 @@ export class McpLifecycleManager {
117
139
  }
118
140
  }
119
141
 
142
+ private async checkKeepAliveConnections(signal?: AbortSignal): Promise<void> {
143
+ if (this.stopped || signal?.aborted) return;
144
+ await parallelLimit(
145
+ [...this.keepAliveServers],
146
+ KEEP_ALIVE_CHECK_CONCURRENCY,
147
+ ([name, definition]) => this.checkKeepAliveConnection(name, definition, signal),
148
+ );
149
+ }
150
+
151
+ private async checkKeepAliveConnection(
152
+ name: string,
153
+ definition: ServerDefinition,
154
+ signal?: AbortSignal,
155
+ retrySuperseded = true,
156
+ ): Promise<void> {
157
+ if (isServerDisabled(definition) || this.stopped || signal?.aborted) return;
158
+ const connection = this.manager.getConnection(name);
159
+ if (connection?.status === "needs-auth") {
160
+ this.pendingMetadataPublications.delete(name);
161
+ return;
162
+ }
163
+ if (!this.shouldAttemptConnection(name, connection)) return;
164
+ if (!connection || connection.status !== "connected") {
165
+ if (this.hasPendingAuthForServer(name)) {
166
+ logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
167
+ return;
168
+ }
169
+ let freshConnection: ServerConnection;
170
+ try {
171
+ freshConnection = await this.manager.connect(name, definition, signal);
172
+ } catch (error) {
173
+ if (this.stopped || signal?.aborted) return;
174
+ this.reportConnectionFailure(name, error, "reconnect", this.manager.getConnection(name));
175
+ return;
176
+ }
177
+ if (this.stopped || signal?.aborted) return;
178
+ if (freshConnection.status === "needs-auth") {
179
+ await this.notifyAuthRequired(name);
180
+ return;
181
+ }
182
+ if (freshConnection.status !== "connected") {
183
+ this.reportConnectionFailure(
184
+ name,
185
+ new Error(`MCP server ${name} did not return a connected session`),
186
+ "reconnect",
187
+ freshConnection,
188
+ );
189
+ return;
190
+ }
191
+ logger.debug(`Reconnected to ${name}`);
192
+ await this.publishConnectedMetadata(name, freshConnection);
193
+ return;
194
+ }
195
+
196
+ if (this.pendingMetadataPublications.has(name)) {
197
+ await this.publishConnectedMetadata(name, connection);
198
+ return;
199
+ }
200
+ if (!definition.url) return;
201
+ const hadSessionId = (connection.transport as { sessionId?: string } | undefined)?.sessionId != null;
202
+ let refreshResult: Awaited<ReturnType<McpServerManager["refreshTools"]>>;
203
+ try {
204
+ refreshResult = await this.manager.refreshTools(name, connection, signal);
205
+ } catch (error) {
206
+ if (this.stopped || signal?.aborted) return;
207
+ const current = this.manager.getConnection(name);
208
+ if (current !== connection || connection.status !== "connected") {
209
+ await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
210
+ return;
211
+ }
212
+ if (!shouldReconnectAfterRefresh(error, hadSessionId)) {
213
+ this.reportConnectionFailure(name, error, "refresh", connection);
214
+ return;
215
+ }
216
+ if (this.hasPendingAuthForServer(name)) {
217
+ logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
218
+ return;
219
+ }
220
+ let freshConnection: ServerConnection;
221
+ try {
222
+ freshConnection = await this.manager.reconnect(name, definition, connection, signal);
223
+ } catch (reconnectError) {
224
+ if (this.stopped || signal?.aborted) return;
225
+ this.reportConnectionFailure(name, reconnectError, "reconnect", this.manager.getConnection(name));
226
+ return;
227
+ }
228
+ if (this.stopped || signal?.aborted) return;
229
+ if (freshConnection.status === "needs-auth") {
230
+ await this.notifyAuthRequired(name);
231
+ return;
232
+ }
233
+ if (freshConnection.status !== "connected") {
234
+ this.reportConnectionFailure(
235
+ name,
236
+ new Error(`MCP server ${name} did not return a connected session`),
237
+ "reconnect",
238
+ freshConnection,
239
+ );
240
+ return;
241
+ }
242
+ logger.debug(`Reconnected stale MCP session for ${name}`);
243
+ await this.publishConnectedMetadata(name, freshConnection);
244
+ return;
245
+ }
246
+
247
+ if (refreshResult === "superseded") {
248
+ await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
249
+ return;
250
+ }
251
+ if (this.retryStates.delete(name)) {
252
+ await this.onHealthRestored?.(name);
253
+ }
254
+ }
255
+
256
+ private async handleSupersededConnection(
257
+ name: string,
258
+ definition: ServerDefinition,
259
+ staleConnection: ServerConnection,
260
+ signal: AbortSignal | undefined,
261
+ retrySuperseded: boolean,
262
+ ): Promise<void> {
263
+ const current = this.manager.getConnection(name);
264
+ if (current === staleConnection && current.status === "connected") {
265
+ if (this.retryStates.delete(name)) await this.onHealthRestored?.(name);
266
+ return;
267
+ }
268
+ if (current?.status === "connected") {
269
+ await this.publishConnectedMetadata(name, current);
270
+ return;
271
+ }
272
+ if (current?.status === "needs-auth") {
273
+ await this.notifyAuthRequired(name);
274
+ return;
275
+ }
276
+ if (retrySuperseded) {
277
+ await this.checkKeepAliveConnection(name, definition, signal, false);
278
+ }
279
+ }
280
+
281
+ private async publishConnectedMetadata(name: string, connection: ServerConnection): Promise<void> {
282
+ this.pendingMetadataPublications.add(name);
283
+ try {
284
+ await this.onReconnect?.(name);
285
+ this.pendingMetadataPublications.delete(name);
286
+ this.retryStates.delete(name);
287
+ } catch (error) {
288
+ if (this.stopped) return;
289
+ this.reportConnectionFailure(name, error, "publish", connection);
290
+ }
291
+ }
292
+
293
+ private async notifyAuthRequired(name: string): Promise<void> {
294
+ this.pendingMetadataPublications.delete(name);
295
+ this.retryStates.delete(name);
296
+ try {
297
+ await this.onAuthRequired?.(name);
298
+ } catch (error) {
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ logger.debug(`MCP: auth-required callback failed for ${name}: ${sanitizeTerminalText(message)}`);
301
+ }
302
+ }
303
+
304
+ private shouldAttemptConnection(name: string, connection: ServerConnection | undefined): boolean {
305
+ const retry = this.retryStates.get(name);
306
+ if (!retry) return true;
307
+ if (retry.connection !== connection || retry.status !== connection?.status) {
308
+ this.retryStates.delete(name);
309
+ return true;
310
+ }
311
+ return Date.now() >= retry.nextAttemptAt;
312
+ }
313
+
314
+ private reportConnectionFailure(
315
+ name: string,
316
+ error: unknown,
317
+ action: "refresh" | "reconnect" | "publish",
318
+ connection: ServerConnection | undefined,
319
+ ): void {
320
+ const attempts = (this.retryStates.get(name)?.attempts ?? 0) + 1;
321
+ const delay = Math.min(
322
+ KEEP_ALIVE_RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 10),
323
+ KEEP_ALIVE_RETRY_MAX_MS,
324
+ );
325
+ this.retryStates.set(name, {
326
+ attempts,
327
+ nextAttemptAt: Date.now() + delay,
328
+ connection,
329
+ status: connection?.status,
330
+ });
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)}`);
339
+ }
340
+
120
341
  private getIdleTimeout(name: string): number {
121
342
  const perServer = this.serverSettings.get(name)?.idleTimeout;
122
343
  if (perServer !== undefined) return perServer * 60 * 1000;
@@ -137,11 +358,23 @@ export class McpLifecycleManager {
137
358
  this.removeHealthAbortListener = undefined;
138
359
  await this.activeHealthCheck;
139
360
  this.activeHealthCheck = undefined;
361
+ await this.activeConvergence;
362
+ this.activeConvergence = undefined;
140
363
  this.onReconnect = undefined;
141
364
  this.onReconnectFailure = undefined;
365
+ this.onHealthRestored = undefined;
366
+ this.onAuthRequired = undefined;
142
367
  this.onIdleShutdown = undefined;
368
+ this.retryStates.clear();
369
+ this.pendingMetadataPublications.clear();
143
370
  if (typeof this.manager.closeAll === "function") {
144
371
  await this.manager.closeAll();
145
372
  }
146
373
  }
147
374
  }
375
+
376
+ function shouldReconnectAfterRefresh(error: unknown, hadSessionId: boolean): boolean {
377
+ if (isTerminatedSession(error, hadSessionId)) return true;
378
+ return error instanceof SdkError
379
+ && (error.code === SdkErrorCode.NotConnected || error.code === SdkErrorCode.ConnectionClosed);
380
+ }
@@ -1,6 +1,7 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
3
3
  import type { ImportKind } from "./types.ts";
4
+ import { getConfigDirName } from "./agent-dir.ts";
4
5
  import { KNOWN_SERVER_PRESETS, type ConfigWritePreview, type KnownServerPreset, type McpDiscoverySummary } from "./config.ts";
5
6
  import type { McpOnboardingState } from "./onboarding-state.ts";
6
7
 
@@ -556,7 +557,7 @@ export class McpSetupPanel {
556
557
  "3. ~/.agents/mcp/mcp.json",
557
558
  "4. <Pi agent dir>/mcp.json",
558
559
  "5. .mcp.json",
559
- "6. .pi/mcp.json",
560
+ `6. ${getConfigDirName()}/mcp.json`,
560
561
  `Host discovery: ${this.discovery.hostConfigDiscovery}. Conflicts reported: ${this.discovery.conflicts.length}.`,
561
562
  ...this.discovery.conflicts.slice(0, 8).map((conflict) =>
562
563
  `${conflict.serverName}: ${conflict.sources.map((source) => source.path).join(" -> ")} (winner: ${conflict.winner.path})`,
package/metadata-cache.ts CHANGED
@@ -19,11 +19,12 @@ import type {
19
19
  ToolMetadata,
20
20
  PromptMetadata,
21
21
  } from "./types.ts";
22
- import { formatPromptCommandName, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, type ToolPrefix } from "./types.ts";
22
+ import { createToolSelectorCandidateIndex, formatPromptCommandName, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, type ToolPrefix } from "./types.ts";
23
23
  import { resourceNameToToolName } from "./resource-tools.ts";
24
24
  import {
25
25
  extractToolUiStreamMode,
26
26
  interpolateEnvRecord,
27
+ interpolateEnvVars,
27
28
  resolveBearerToken,
28
29
  resolveConfigPath,
29
30
  resolveServerUrl,
@@ -90,6 +91,14 @@ export function computeServerHash(definition: ServerEntry): string {
90
91
  cwd: resolveConfigPath(definition.cwd),
91
92
  url: resolveServerUrl(definition),
92
93
  headers: interpolateEnvRecord(definition.headers),
94
+ requestHeadersCommand: definition.requestHeadersCommand
95
+ ? {
96
+ command: interpolateEnvVars(definition.requestHeadersCommand.command),
97
+ args: definition.requestHeadersCommand.args?.map(interpolateEnvVars),
98
+ env: interpolateEnvRecord(definition.requestHeadersCommand.env),
99
+ timeoutMs: definition.requestHeadersCommand.timeoutMs,
100
+ }
101
+ : undefined,
93
102
  auth: definition.auth,
94
103
  protocolVersion: definition.protocolVersion,
95
104
  bearerToken: resolveBearerToken(definition),
@@ -184,8 +193,10 @@ export function reconstructToolMetadata(
184
193
  const metadata: ToolMetadata[] = [];
185
194
  const seenNames = new Set<string>();
186
195
  const effectivePrefix = resolveToolPrefix(definition, prefix);
187
- const getOtherCurrentCandidates = (toolName: string): Set<string> | undefined => {
188
- if (!configuredServers || !cache) return undefined;
196
+ const hasToolFilters =
197
+ (Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
198
+ (Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
199
+ const selectorCandidateIndex = hasToolFilters && configuredServers && cache ? (() => {
189
200
  const candidates = new Set<string>();
190
201
  for (const [otherServerName, otherDefinition] of Object.entries(configuredServers)) {
191
202
  const otherEntry = cache.servers[otherServerName];
@@ -202,16 +213,15 @@ export function reconstructToolMetadata(
202
213
  }
203
214
  }
204
215
  }
205
- for (const candidate of getToolNameCandidates(toolName, serverName, effectivePrefix, false)) candidates.delete(candidate);
206
- return candidates;
207
- };
216
+ return createToolSelectorCandidateIndex(candidates);
217
+ })() : undefined;
208
218
 
209
219
  for (const tool of entry.tools ?? []) {
210
220
  if (!tool?.name) continue;
211
221
  if (!isUiToolVisibleToModel(tool.uiVisibility)) {
212
222
  continue;
213
223
  }
214
- if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(tool.name))) {
224
+ if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) {
215
225
  continue;
216
226
  }
217
227
 
@@ -236,7 +246,7 @@ export function reconstructToolMetadata(
236
246
  for (const resource of entry.resources ?? []) {
237
247
  if (!resource?.name || !resource?.uri) continue;
238
248
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
239
- if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(baseName))) {
249
+ if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) {
240
250
  continue;
241
251
  }
242
252
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.25.0",
3
+ "version": "2.26.1",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -89,6 +89,7 @@
89
89
  "ui-app-bridge-helpers.ts",
90
90
  "config.ts",
91
91
  "server-manager.ts",
92
+ "request-headers-command.ts",
92
93
  "unix-socket-transport.ts",
93
94
  "json-schema-validator.ts",
94
95
  "session-recovery.ts",