pi-mcp-extension 1.9.4 → 1.9.5
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/errors.ts +10 -0
- package/src/index.ts +32 -12
- package/src/server-manager.ts +535 -72
- package/src/tool-bridge.ts +115 -20
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
export type McpErrorCode =
|
|
8
8
|
| "config" // Configuration loading or validation failed
|
|
9
9
|
| "connection" // Transport/connection failed
|
|
10
|
+
| "auth" // Authentication/authorization failed
|
|
11
|
+
| "timeout" // Request or connection timed out
|
|
10
12
|
| "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
|
|
11
13
|
| "tool"; // Tool execution error (isError: true from server)
|
|
12
14
|
|
|
@@ -33,3 +35,11 @@ export class McpError extends Error {
|
|
|
33
35
|
return `[${this.server}] ${this.message}`;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
|
|
39
|
+
export class AuthCancelledError extends Error {
|
|
40
|
+
constructor(serverName: string) {
|
|
41
|
+
super(`Authentication cancelled for ${serverName}`);
|
|
42
|
+
this.name = "AuthCancelledError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { ServerManager } from "./server-manager.js";
|
|
|
19
19
|
import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
|
|
20
20
|
import * as Type from "typebox";
|
|
21
21
|
import { ToolBridge } from "./tool-bridge.js";
|
|
22
|
-
import { McpError } from "./errors.js";
|
|
22
|
+
import { McpError, AuthCancelledError } from "./errors.js";
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
24
|
|
|
25
25
|
// OAuth imports
|
|
@@ -76,12 +76,7 @@ function openBrowser(url: string): Promise<void> {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export
|
|
80
|
-
constructor(serverName: string) {
|
|
81
|
-
super(`Authentication cancelled for ${serverName}`);
|
|
82
|
-
this.name = "AuthCancelledError";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
79
|
+
export { AuthCancelledError } from "./errors.js";
|
|
85
80
|
|
|
86
81
|
export const authRetryOption = "Retry: open browser again";
|
|
87
82
|
export const authPasteOption = "Paste redirect URL";
|
|
@@ -480,19 +475,41 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
480
475
|
}
|
|
481
476
|
}
|
|
482
477
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
478
|
+
let activeCtx: ExtensionContext | undefined;
|
|
479
|
+
|
|
480
|
+
manager.setToolRefreshCallback(async (serverName, client, options) => {
|
|
481
|
+
await bridge.refreshTools(
|
|
482
|
+
serverName,
|
|
483
|
+
client,
|
|
484
|
+
() => {
|
|
485
|
+
const server = manager.getServer(serverName);
|
|
486
|
+
return server?.client === client && server.state !== "stopped";
|
|
487
|
+
},
|
|
488
|
+
options,
|
|
489
|
+
);
|
|
488
490
|
});
|
|
489
491
|
manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
|
|
492
|
+
manager.setRecoveryFailedCallback((serverName, error) => {
|
|
493
|
+
if (error instanceof AuthRequiredError) {
|
|
494
|
+
activeCtx?.ui.notify(
|
|
495
|
+
`pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
|
|
496
|
+
"warning",
|
|
497
|
+
);
|
|
498
|
+
activeCtx?.ui.setStatus?.("pi-mcp", `run /mcp:auth ${serverName}`);
|
|
499
|
+
} else {
|
|
500
|
+
activeCtx?.ui.notify(
|
|
501
|
+
`pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
|
|
502
|
+
"error",
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
490
506
|
bridge.setAuthErrorCallback(async (serverName, err) => {
|
|
491
507
|
await manager.handleUnauthorizedServerError(serverName, err);
|
|
492
508
|
});
|
|
493
509
|
|
|
494
510
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
495
511
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
512
|
+
activeCtx = ctx;
|
|
496
513
|
// Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
|
|
497
514
|
const includeProjectConfig =
|
|
498
515
|
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
|
|
@@ -548,6 +565,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
548
565
|
|
|
549
566
|
// Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
|
|
550
567
|
pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
|
|
568
|
+
activeCtx = ctx;
|
|
551
569
|
const entries = Array.isArray((event as { servers?: unknown })?.servers)
|
|
552
570
|
? ((event as { servers: RegisteredMcpServer[] }).servers)
|
|
553
571
|
: getRegisteredEntries();
|
|
@@ -597,6 +615,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
597
615
|
});
|
|
598
616
|
|
|
599
617
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
618
|
+
activeCtx = undefined;
|
|
600
619
|
for (const controller of startupPromptControllers.values()) controller.abort();
|
|
601
620
|
startupPromptControllers.clear();
|
|
602
621
|
for (const attempt of connectAttempts.values()) attempt.controller.abort();
|
|
@@ -960,6 +979,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
960
979
|
reset = false,
|
|
961
980
|
alreadyQueued = false,
|
|
962
981
|
): Promise<void> {
|
|
982
|
+
activeCtx = ctx;
|
|
963
983
|
const server = manager.getServer(serverName);
|
|
964
984
|
if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
|
|
965
985
|
if (signal?.aborted) throw new AuthCancelledError(serverName);
|
package/src/server-manager.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* - PID tracking for safety-net SIGKILL on shutdown failure
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { basename } from "node:path";
|
|
16
|
+
import { StringDecoder } from "node:string_decoder";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
15
18
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
19
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
17
20
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
@@ -22,10 +25,18 @@ import {
|
|
|
22
25
|
ToolListChangedNotificationSchema,
|
|
23
26
|
LoggingMessageNotificationSchema,
|
|
24
27
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
-
import { McpError } from "./errors.js";
|
|
28
|
+
import { McpError, AuthCancelledError } from "./errors.js";
|
|
26
29
|
import type { McpConfig, ServerConfig, Settings } from "./config.js";
|
|
27
30
|
import { McpOAuthProvider, AuthRequiredError, getAuthStatus, resetAuth, invalidateStoredTokens } from "./oauth-provider.js";
|
|
28
31
|
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
32
|
+
import {
|
|
33
|
+
setClientDefaultOptions,
|
|
34
|
+
getClientDefaultOptions,
|
|
35
|
+
type ToolRefreshOptions,
|
|
36
|
+
} from "./tool-bridge.js";
|
|
37
|
+
|
|
38
|
+
export { setClientDefaultOptions, getClientDefaultOptions, type ToolRefreshOptions, AuthCancelledError };
|
|
39
|
+
|
|
29
40
|
|
|
30
41
|
export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>()): boolean {
|
|
31
42
|
if (!error || seen.has(error)) {
|
|
@@ -113,6 +124,33 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
|
|
|
113
124
|
return false;
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Strip carriage returns, ANSI CSI/OSC/DCS/PM/APC escape sequences, and C0/C1
|
|
129
|
+
* control characters from a single server stderr line before storing or logging.
|
|
130
|
+
*/
|
|
131
|
+
export function sanitizeStderrLine(line: string): string {
|
|
132
|
+
return line
|
|
133
|
+
.replace(/\r$/, "")
|
|
134
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
135
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
|
|
136
|
+
.replace(/\x1b[P^_][\s\S]*?\x1b\\/g, "")
|
|
137
|
+
.replace(/\x1b[@-Z\\-_]/g, "")
|
|
138
|
+
.replace(/\x1b|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveEnv(extra?: Record<string, string>): Record<string, string> {
|
|
142
|
+
// Build clean env: process.env may contain undefined values,
|
|
143
|
+
// child_process.spawn silently drops them, but let's be explicit
|
|
144
|
+
const env: Record<string, string> = {};
|
|
145
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
146
|
+
if (value !== undefined) env[key] = value;
|
|
147
|
+
}
|
|
148
|
+
if (extra) {
|
|
149
|
+
Object.assign(env, extra);
|
|
150
|
+
}
|
|
151
|
+
return env;
|
|
152
|
+
}
|
|
153
|
+
|
|
116
154
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
117
155
|
|
|
118
156
|
export type ServerState = "stopped" | "starting" | "ready";
|
|
@@ -128,6 +166,8 @@ export interface ManagedServer {
|
|
|
128
166
|
config: ServerConfig;
|
|
129
167
|
state: ServerState;
|
|
130
168
|
client: Client | null;
|
|
169
|
+
transport?: Transport | undefined;
|
|
170
|
+
cwd?: string | undefined;
|
|
131
171
|
/** PID of the child process (stdio transport only). Used for safety-net cleanup. */
|
|
132
172
|
childPid: number | null;
|
|
133
173
|
retryCount: number;
|
|
@@ -143,7 +183,14 @@ export interface ManagedServer {
|
|
|
143
183
|
}
|
|
144
184
|
|
|
145
185
|
/** Called after tool list is refreshed for a server (e.g. on list_changed notification). */
|
|
146
|
-
export type ToolRefreshCallback = (
|
|
186
|
+
export type ToolRefreshCallback = (
|
|
187
|
+
serverName: string,
|
|
188
|
+
client: Client,
|
|
189
|
+
options?: ToolRefreshOptions,
|
|
190
|
+
) => Promise<void>;
|
|
191
|
+
|
|
192
|
+
/** Called when background auto-recovery after a disconnect or health check fails. */
|
|
193
|
+
export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
|
|
147
194
|
|
|
148
195
|
export interface TransportAuthCallbacks {
|
|
149
196
|
/** Legacy callback type. Background transport attempts never invoke this callback. */
|
|
@@ -152,12 +199,16 @@ export interface TransportAuthCallbacks {
|
|
|
152
199
|
|
|
153
200
|
// ─── Transport Factory ────────────────────────────────────────────────────────
|
|
154
201
|
|
|
155
|
-
function createTransport(
|
|
202
|
+
export function createTransport(
|
|
156
203
|
serverName: string,
|
|
157
204
|
config: ServerConfig,
|
|
158
|
-
|
|
205
|
+
cwdOrOnStderr?: string | ((line: string) => void),
|
|
159
206
|
storageDir?: string,
|
|
207
|
+
cwdArg?: string,
|
|
160
208
|
): Transport {
|
|
209
|
+
const cwd = typeof cwdOrOnStderr === "string" ? cwdOrOnStderr : cwdArg;
|
|
210
|
+
const onStderr = typeof cwdOrOnStderr === "function" ? cwdOrOnStderr : undefined;
|
|
211
|
+
|
|
161
212
|
// Build requestInit for static headers (API keys, etc.)
|
|
162
213
|
const requestInit: RequestInit | undefined = config.headers
|
|
163
214
|
? { headers: config.headers }
|
|
@@ -181,24 +232,42 @@ function createTransport(
|
|
|
181
232
|
|
|
182
233
|
switch (config.transport) {
|
|
183
234
|
case "stdio": {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const env: Record<string, string> = {};
|
|
187
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
188
|
-
if (value !== undefined) env[key] = value;
|
|
235
|
+
if (!config.command) {
|
|
236
|
+
throw new McpError("stdio transport requires 'command'", serverName, "config");
|
|
189
237
|
}
|
|
190
|
-
Object.assign(env, config.env ?? {});
|
|
191
238
|
const transport = new StdioClientTransport({
|
|
192
|
-
command: config.command
|
|
239
|
+
command: config.command,
|
|
193
240
|
args: config.args,
|
|
194
|
-
env,
|
|
241
|
+
env: resolveEnv(config.env),
|
|
242
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
195
243
|
stderr: "pipe",
|
|
196
244
|
});
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
245
|
+
if (onStderr && transport.stderr) {
|
|
246
|
+
const decoder = new StringDecoder("utf8");
|
|
247
|
+
let remainder = "";
|
|
248
|
+
let flushed = false;
|
|
249
|
+
transport.stderr.on("data", (chunk: Buffer) => {
|
|
250
|
+
remainder += decoder.write(chunk);
|
|
251
|
+
const lines = remainder.split("\n");
|
|
252
|
+
remainder = lines.pop() ?? "";
|
|
253
|
+
for (const rawLine of lines) {
|
|
254
|
+
const clean = sanitizeStderrLine(rawLine);
|
|
255
|
+
if (clean) onStderr(clean);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
const flush = (): void => {
|
|
259
|
+
if (flushed) return;
|
|
260
|
+
flushed = true;
|
|
261
|
+
remainder += decoder.end();
|
|
262
|
+
if (remainder) {
|
|
263
|
+
const clean = sanitizeStderrLine(remainder);
|
|
264
|
+
remainder = "";
|
|
265
|
+
if (clean) onStderr(clean);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
transport.stderr.on("end", flush);
|
|
269
|
+
transport.stderr.on("close", flush);
|
|
270
|
+
}
|
|
202
271
|
return transport;
|
|
203
272
|
}
|
|
204
273
|
case "streamable-http":
|
|
@@ -207,16 +276,56 @@ function createTransport(
|
|
|
207
276
|
{
|
|
208
277
|
...(requestInit && { requestInit }),
|
|
209
278
|
...(authProvider && { authProvider }),
|
|
279
|
+
// WHY: Disable internal SDK SSE stream retries so unexpected disconnects
|
|
280
|
+
// immediately surface to ServerManager._recover() for deterministic backoff.
|
|
281
|
+
reconnectionOptions: {
|
|
282
|
+
maxRetries: 0,
|
|
283
|
+
initialReconnectionDelay: 0,
|
|
284
|
+
maxReconnectionDelay: 0,
|
|
285
|
+
reconnectionDelayGrowFactor: 1,
|
|
286
|
+
},
|
|
210
287
|
},
|
|
211
288
|
) as unknown as Transport;
|
|
212
|
-
case "sse":
|
|
213
|
-
|
|
289
|
+
case "sse": {
|
|
290
|
+
const transport = new SSEClientTransport(
|
|
214
291
|
new URL(config.url!),
|
|
215
292
|
{
|
|
216
293
|
...(requestInit && { requestInit }),
|
|
217
294
|
...(authProvider && { authProvider }),
|
|
218
295
|
},
|
|
219
296
|
);
|
|
297
|
+
// WHY: SSEClientTransport.start() awaits the initial `event: endpoint` SSE frame,
|
|
298
|
+
// while SSEClientTransport.close() aborts _abortController and closes _eventSource
|
|
299
|
+
// without firing _eventSource.onerror, leaving start() pending forever (which would
|
|
300
|
+
// deadlock `await client.close()`). Wrap start() and close() so closing the transport
|
|
301
|
+
// immediately rejects any pending start() promise.
|
|
302
|
+
const origStart = transport.start.bind(transport);
|
|
303
|
+
const origClose = transport.close.bind(transport);
|
|
304
|
+
let rejectPendingStart: ((err: Error) => void) | null = null;
|
|
305
|
+
transport.start = () =>
|
|
306
|
+
new Promise<void>((resolve, reject) => {
|
|
307
|
+
rejectPendingStart = reject;
|
|
308
|
+
origStart().then(
|
|
309
|
+
() => {
|
|
310
|
+
rejectPendingStart = null;
|
|
311
|
+
resolve();
|
|
312
|
+
},
|
|
313
|
+
(err) => {
|
|
314
|
+
rejectPendingStart = null;
|
|
315
|
+
reject(err);
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
transport.close = async () => {
|
|
320
|
+
if (rejectPendingStart) {
|
|
321
|
+
const reject = rejectPendingStart;
|
|
322
|
+
rejectPendingStart = null;
|
|
323
|
+
reject(new Error("SSE transport closed before endpoint event"));
|
|
324
|
+
}
|
|
325
|
+
await origClose();
|
|
326
|
+
};
|
|
327
|
+
return transport;
|
|
328
|
+
}
|
|
220
329
|
}
|
|
221
330
|
}
|
|
222
331
|
|
|
@@ -227,6 +336,7 @@ export class ServerManager {
|
|
|
227
336
|
private settings: Settings;
|
|
228
337
|
private onToolRefresh: ToolRefreshCallback | null = null;
|
|
229
338
|
private onServerStopped: ((name: string) => void) | null = null;
|
|
339
|
+
private onRecoveryFailed: RecoveryFailedCallback | null = null;
|
|
230
340
|
|
|
231
341
|
constructor(config: McpConfig, _authCallbacks?: TransportAuthCallbacks, private readonly storageDir?: string) {
|
|
232
342
|
this.settings = config.settings;
|
|
@@ -255,6 +365,18 @@ export class ServerManager {
|
|
|
255
365
|
this.onServerStopped = cb;
|
|
256
366
|
}
|
|
257
367
|
|
|
368
|
+
setRecoveryFailedCallback(cb: RecoveryFailedCallback): void {
|
|
369
|
+
this.onRecoveryFailed = cb;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private _resolveTimeoutMs(server: ManagedServer): number {
|
|
373
|
+
return server.config.requestTimeoutMs ?? this.settings.requestTimeoutMs;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
createTransport(name: string, config: ServerConfig, cwd?: string): Transport {
|
|
377
|
+
return createTransport(name, config, cwd, this.storageDir, cwd);
|
|
378
|
+
}
|
|
379
|
+
|
|
258
380
|
getServer(name: string): ManagedServer | undefined {
|
|
259
381
|
return this.servers.get(name);
|
|
260
382
|
}
|
|
@@ -297,19 +419,20 @@ export class ServerManager {
|
|
|
297
419
|
}
|
|
298
420
|
|
|
299
421
|
/**
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
422
|
+
* Mark an OAuth server as requiring re-authorization when a mid-session
|
|
423
|
+
* request (tools/list or tools/call) fails with 401/403 insufficient_scope.
|
|
424
|
+
*/
|
|
303
425
|
async handleUnauthorizedServerError(name: string, _cause?: unknown): Promise<void> {
|
|
304
426
|
const server = this.servers.get(name);
|
|
305
427
|
if (!server || !server.config.auth || server.config.transport === "stdio") {
|
|
306
428
|
return;
|
|
307
429
|
}
|
|
308
|
-
await invalidateStoredTokens(name, this.storageDir, server.config.url);
|
|
309
430
|
const authError = new AuthRequiredError(name);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
431
|
+
// WHY: Shut down immediately before async disk I/O so server.generation increments
|
|
432
|
+
// synchronously and cancels any concurrent _recover() microtask queued by client.onerror.
|
|
433
|
+
await this._shutdown(server);
|
|
434
|
+
server.lastError = authError;
|
|
435
|
+
await invalidateStoredTokens(name, this.storageDir, server.config.url);
|
|
313
436
|
server.lastError = authError;
|
|
314
437
|
}
|
|
315
438
|
|
|
@@ -332,7 +455,8 @@ export class ServerManager {
|
|
|
332
455
|
if (!server) {
|
|
333
456
|
throw new McpError(`Unknown server "${name}"`, name, "config");
|
|
334
457
|
}
|
|
335
|
-
|
|
458
|
+
server.cwd = cwd;
|
|
459
|
+
if (server.attempt && !server.controller?.signal.aborted) return server.attempt;
|
|
336
460
|
if (server.state === "ready") return;
|
|
337
461
|
server.retryCount = 0;
|
|
338
462
|
const controller = new AbortController();
|
|
@@ -340,10 +464,14 @@ export class ServerManager {
|
|
|
340
464
|
server.state = "starting";
|
|
341
465
|
const attempt = this._connect(server, cwd, controller.signal);
|
|
342
466
|
server.attempt = attempt;
|
|
343
|
-
try {
|
|
467
|
+
try {
|
|
468
|
+
await attempt;
|
|
469
|
+
} finally {
|
|
344
470
|
if (server.attempt === attempt) {
|
|
345
471
|
server.attempt = undefined;
|
|
346
|
-
|
|
472
|
+
}
|
|
473
|
+
if (server.controller === controller && (server.state as ServerState) !== "ready") {
|
|
474
|
+
server.controller = undefined;
|
|
347
475
|
}
|
|
348
476
|
}
|
|
349
477
|
}
|
|
@@ -400,6 +528,132 @@ export class ServerManager {
|
|
|
400
528
|
|
|
401
529
|
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
402
530
|
|
|
531
|
+
private async _connectWithTimeoutAndSignal(
|
|
532
|
+
client: Client,
|
|
533
|
+
transport: Transport,
|
|
534
|
+
timeoutMs: number,
|
|
535
|
+
signal: AbortSignal,
|
|
536
|
+
serverName: string,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
if (signal.aborted) {
|
|
539
|
+
throw new AuthCancelledError(serverName);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const connectPromise = client.connect(transport, { timeout: timeoutMs, signal });
|
|
543
|
+
// WHY: Attach a no-op catch handler so if the outer timeout or abort wins the race
|
|
544
|
+
// while transport.start() is still in flight, a late rejection from connect() never
|
|
545
|
+
// triggers an unhandledRejection.
|
|
546
|
+
connectPromise.catch(() => {
|
|
547
|
+
// WHY: Handled via Promise.race below.
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
551
|
+
let onAbort: (() => void) | undefined;
|
|
552
|
+
|
|
553
|
+
const raceOutcome = new Promise<never>((_resolve, reject) => {
|
|
554
|
+
if (signal.aborted) {
|
|
555
|
+
reject(new AuthCancelledError(serverName));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
onAbort = () => {
|
|
559
|
+
reject(new AuthCancelledError(serverName));
|
|
560
|
+
};
|
|
561
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
562
|
+
|
|
563
|
+
timer = setTimeout(() => {
|
|
564
|
+
reject(
|
|
565
|
+
new McpError(
|
|
566
|
+
`Connection timed out after ${timeoutMs}ms`,
|
|
567
|
+
serverName,
|
|
568
|
+
"timeout",
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
}, timeoutMs);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
await Promise.race([connectPromise, raceOutcome]);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
if (signal.aborted || err instanceof AuthCancelledError) {
|
|
578
|
+
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName);
|
|
579
|
+
}
|
|
580
|
+
if (
|
|
581
|
+
!(err instanceof McpError) &&
|
|
582
|
+
((err as { code?: unknown })?.code === -32001 ||
|
|
583
|
+
/Request timed out|timed out/i.test(err instanceof Error ? err.message : String(err)))
|
|
584
|
+
) {
|
|
585
|
+
throw new McpError(
|
|
586
|
+
`Connection timed out after ${timeoutMs}ms`,
|
|
587
|
+
serverName,
|
|
588
|
+
"timeout",
|
|
589
|
+
err,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
throw err;
|
|
593
|
+
} finally {
|
|
594
|
+
if (timer !== undefined) {
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
}
|
|
597
|
+
if (onAbort) {
|
|
598
|
+
signal.removeEventListener("abort", onAbort);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private _isPidAlive(pid: number): boolean {
|
|
604
|
+
try {
|
|
605
|
+
process.kill(pid, 0);
|
|
606
|
+
return true;
|
|
607
|
+
} catch (err) {
|
|
608
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private async _ensurePidTerminated(pid: number | null | undefined): Promise<void> {
|
|
616
|
+
if (!pid || !this._isPidAlive(pid)) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
process.kill(pid, "SIGTERM");
|
|
622
|
+
} catch (err) {
|
|
623
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const sigtermDeadline = Date.now() + 500;
|
|
629
|
+
while (Date.now() < sigtermDeadline) {
|
|
630
|
+
if (!this._isPidAlive(pid)) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!this._isPidAlive(pid)) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
process.kill(pid, "SIGKILL");
|
|
642
|
+
} catch (err) {
|
|
643
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const sigkillDeadline = Date.now() + 200;
|
|
649
|
+
while (Date.now() < sigkillDeadline) {
|
|
650
|
+
if (!this._isPidAlive(pid)) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
403
657
|
private async _connect(
|
|
404
658
|
server: ManagedServer,
|
|
405
659
|
cwd: string,
|
|
@@ -407,6 +661,8 @@ export class ServerManager {
|
|
|
407
661
|
corruptStateRecovered = false,
|
|
408
662
|
): Promise<void> {
|
|
409
663
|
server.lastError = null;
|
|
664
|
+
server.cwd = cwd;
|
|
665
|
+
const timeoutMs = this._resolveTimeoutMs(server);
|
|
410
666
|
// Note: retryCount is NOT reset here — it's only reset after successful connect.
|
|
411
667
|
// This ensures the retry limit is respected across reconnection attempts.
|
|
412
668
|
// It IS reset when startServer() is called explicitly (e.g. /mcp:start)
|
|
@@ -421,7 +677,7 @@ export class ServerManager {
|
|
|
421
677
|
|
|
422
678
|
let transport: Transport;
|
|
423
679
|
try {
|
|
424
|
-
transport = createTransport(server.name, server.config,
|
|
680
|
+
transport = this.createTransport(server.name, server.config, cwd);
|
|
425
681
|
} catch (err) {
|
|
426
682
|
server.state = "stopped";
|
|
427
683
|
server.lastError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -433,6 +689,72 @@ export class ServerManager {
|
|
|
433
689
|
);
|
|
434
690
|
}
|
|
435
691
|
|
|
692
|
+
server.transport = transport;
|
|
693
|
+
|
|
694
|
+
if (transport instanceof StdioClientTransport) {
|
|
695
|
+
// WHY: Capture child PID immediately when StdioClientTransport spawns the process
|
|
696
|
+
// (before `initialize` is sent), so a timeout or cancellation during `initialize`
|
|
697
|
+
// can still terminate the child process deterministically before retrying.
|
|
698
|
+
const origStart = transport.start.bind(transport);
|
|
699
|
+
transport.start = async () => {
|
|
700
|
+
const startPromise = origStart();
|
|
701
|
+
const earlyProc = (transport as unknown as { _process?: { pid?: number } })._process;
|
|
702
|
+
if (earlyProc?.pid) {
|
|
703
|
+
server.childPid = earlyProc.pid;
|
|
704
|
+
}
|
|
705
|
+
await startPromise;
|
|
706
|
+
const proc = (transport as unknown as { _process?: { pid?: number } })._process;
|
|
707
|
+
if (proc?.pid) {
|
|
708
|
+
server.childPid = proc.pid;
|
|
709
|
+
} else if (transport.pid) {
|
|
710
|
+
server.childPid = transport.pid;
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// WHY: Client.connect's catch block fires `void this.close()` without awaiting it,
|
|
715
|
+
// which clears `this._process = undefined` synchronously on the first call. Memoize
|
|
716
|
+
// transport.close() so our `await client.close()` awaits the actual close completion.
|
|
717
|
+
let closePromise: Promise<void> | null = null;
|
|
718
|
+
const origClose = transport.close.bind(transport);
|
|
719
|
+
transport.close = () => {
|
|
720
|
+
if (!closePromise) {
|
|
721
|
+
closePromise = origClose();
|
|
722
|
+
}
|
|
723
|
+
return closePromise;
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
if (transport.stderr) {
|
|
727
|
+
const decoder = new StringDecoder("utf8");
|
|
728
|
+
let stderrRemainder = "";
|
|
729
|
+
let stderrFlushed = false;
|
|
730
|
+
const emitLine = (rawLine: string): void => {
|
|
731
|
+
const clean = sanitizeStderrLine(rawLine);
|
|
732
|
+
if (!clean) return;
|
|
733
|
+
appendStderr(clean);
|
|
734
|
+
console.error(`[pi-mcp] [${server.name}] ${clean}`);
|
|
735
|
+
};
|
|
736
|
+
transport.stderr.on("data", (chunk: Buffer) => {
|
|
737
|
+
stderrRemainder += decoder.write(chunk);
|
|
738
|
+
const lines = stderrRemainder.split("\n");
|
|
739
|
+
stderrRemainder = lines.pop() ?? "";
|
|
740
|
+
for (const rawLine of lines) {
|
|
741
|
+
emitLine(rawLine);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
const flushStderr = (): void => {
|
|
745
|
+
if (stderrFlushed) return;
|
|
746
|
+
stderrFlushed = true;
|
|
747
|
+
stderrRemainder += decoder.end();
|
|
748
|
+
if (stderrRemainder) {
|
|
749
|
+
emitLine(stderrRemainder);
|
|
750
|
+
stderrRemainder = "";
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
transport.stderr.on("end", flushStderr);
|
|
754
|
+
transport.stderr.on("close", flushStderr);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
436
758
|
const client = new Client(
|
|
437
759
|
{ name: "pi-mcp", version: "1.0.0" },
|
|
438
760
|
{
|
|
@@ -447,12 +769,17 @@ export class ServerManager {
|
|
|
447
769
|
// Handle roots/list requests from the server
|
|
448
770
|
client.setRequestHandler(
|
|
449
771
|
ListRootsRequestSchema,
|
|
450
|
-
async () =>
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
772
|
+
async () => {
|
|
773
|
+
const effectiveCwd = server.cwd ?? cwd;
|
|
774
|
+
return {
|
|
775
|
+
roots: [
|
|
776
|
+
{
|
|
777
|
+
uri: pathToFileURL(effectiveCwd).href,
|
|
778
|
+
name: basename(effectiveCwd) || "workspace",
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
};
|
|
782
|
+
},
|
|
456
783
|
);
|
|
457
784
|
|
|
458
785
|
// tools/list_changed: re-discover tools and update Pi registrations
|
|
@@ -461,7 +788,15 @@ export class ServerManager {
|
|
|
461
788
|
async () => {
|
|
462
789
|
if (!signal.aborted && this.onToolRefresh && server.state === "ready" && server.client === client) {
|
|
463
790
|
try {
|
|
464
|
-
|
|
791
|
+
const refreshTimeoutMs = this._resolveTimeoutMs(server);
|
|
792
|
+
setClientDefaultOptions(server.client, {
|
|
793
|
+
requestTimeoutMs: refreshTimeoutMs,
|
|
794
|
+
signal,
|
|
795
|
+
});
|
|
796
|
+
await this.onToolRefresh(server.name, server.client, {
|
|
797
|
+
requestTimeoutMs: refreshTimeoutMs,
|
|
798
|
+
signal,
|
|
799
|
+
});
|
|
465
800
|
} catch (err) {
|
|
466
801
|
console.error(
|
|
467
802
|
`[pi-mcp] Failed to refresh tools for ${server.name}:`,
|
|
@@ -485,22 +820,70 @@ export class ServerManager {
|
|
|
485
820
|
|
|
486
821
|
server.client = client;
|
|
487
822
|
try {
|
|
488
|
-
await
|
|
489
|
-
if (signal.aborted) throw new
|
|
490
|
-
|
|
823
|
+
await this._connectWithTimeoutAndSignal(client, transport, timeoutMs, signal, server.name);
|
|
824
|
+
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
825
|
+
if (transport instanceof StdioClientTransport && !server.childPid) {
|
|
826
|
+
server.childPid = transport.pid;
|
|
827
|
+
}
|
|
491
828
|
// Readiness includes tool discovery, not just the MCP handshake.
|
|
492
|
-
|
|
493
|
-
if (
|
|
829
|
+
setClientDefaultOptions(client, { requestTimeoutMs: timeoutMs, signal });
|
|
830
|
+
if (this.onToolRefresh) {
|
|
831
|
+
await this.onToolRefresh(server.name, client, {
|
|
832
|
+
requestTimeoutMs: timeoutMs,
|
|
833
|
+
signal,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
494
837
|
server.state = "ready";
|
|
495
838
|
server.retryCount = 0;
|
|
496
839
|
server.lastError = null;
|
|
497
840
|
if (server.config.healthCheckIntervalMs) {
|
|
841
|
+
let pingInFlight = false;
|
|
498
842
|
server.healthCheckTimer = setInterval(async () => {
|
|
499
|
-
if (signal.aborted)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
843
|
+
if (pingInFlight || server.state !== "ready" || !server.client || signal.aborted) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
pingInFlight = true;
|
|
847
|
+
let pingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
848
|
+
try {
|
|
849
|
+
const pingOptions = {
|
|
850
|
+
timeout: timeoutMs,
|
|
851
|
+
signal,
|
|
852
|
+
};
|
|
853
|
+
const pingCall = (
|
|
854
|
+
server.client.ping as (
|
|
855
|
+
opts?: { timeout?: number; signal?: AbortSignal },
|
|
856
|
+
extraOpts?: { timeout?: number; signal?: AbortSignal },
|
|
857
|
+
) => Promise<unknown>
|
|
858
|
+
)(pingOptions, pingOptions);
|
|
859
|
+
pingCall.catch(() => {
|
|
860
|
+
// WHY: Handled via Promise.race below.
|
|
861
|
+
});
|
|
862
|
+
await Promise.race([
|
|
863
|
+
pingCall,
|
|
864
|
+
new Promise<never>((_resolve, reject) => {
|
|
865
|
+
pingTimer = setTimeout(
|
|
866
|
+
() =>
|
|
867
|
+
reject(
|
|
868
|
+
new McpError(
|
|
869
|
+
`Health check ping timed out after ${timeoutMs}ms`,
|
|
870
|
+
server.name,
|
|
871
|
+
"timeout",
|
|
872
|
+
),
|
|
873
|
+
),
|
|
874
|
+
timeoutMs,
|
|
875
|
+
);
|
|
876
|
+
}),
|
|
877
|
+
]);
|
|
878
|
+
} catch {
|
|
879
|
+
if (server.state === "ready" && !signal.aborted) {
|
|
880
|
+
void this._recover(server, server.cwd ?? cwd);
|
|
881
|
+
}
|
|
882
|
+
} finally {
|
|
883
|
+
if (pingTimer !== undefined) {
|
|
884
|
+
clearTimeout(pingTimer);
|
|
885
|
+
}
|
|
886
|
+
pingInFlight = false;
|
|
504
887
|
}
|
|
505
888
|
}, server.config.healthCheckIntervalMs);
|
|
506
889
|
}
|
|
@@ -511,12 +894,85 @@ export class ServerManager {
|
|
|
511
894
|
client.onclose = () => {
|
|
512
895
|
if (signal.aborted || server.client !== client) return;
|
|
513
896
|
console.error(`[pi-mcp] Server ${server.name} closed unexpectedly, reconnecting`);
|
|
514
|
-
void this._recover(server, cwd);
|
|
897
|
+
void this._recover(server, server.cwd ?? cwd);
|
|
898
|
+
};
|
|
899
|
+
// WHY: SSEClientTransport and StreamableHTTPClientTransport report unexpected
|
|
900
|
+
// stream drops via transport.onerror (forwarded to client.onerror) rather than
|
|
901
|
+
// transport.onclose. Crucially, StreamableHTTPClientTransport.send() invokes
|
|
902
|
+
// this.onerror(error) synchronously BEFORE `throw error` rejects the send()
|
|
903
|
+
// promise; deferring _recover() via setImmediate lets Protocol.request() and
|
|
904
|
+
// ToolBridge.execute() observe the original transport/401 error (and run
|
|
905
|
+
// handleUnauthorizedServerError if applicable) before client.close() rejects
|
|
906
|
+
// pending requests with "Connection closed".
|
|
907
|
+
const scheduleErrorRecovery = () => {
|
|
908
|
+
const errorGeneration = server.generation ?? 0;
|
|
909
|
+
setImmediate(() => {
|
|
910
|
+
if (
|
|
911
|
+
(server.generation ?? 0) !== errorGeneration ||
|
|
912
|
+
server.state !== "ready" ||
|
|
913
|
+
signal.aborted ||
|
|
914
|
+
server.client !== client
|
|
915
|
+
) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
void this._recover(server, server.cwd ?? cwd);
|
|
919
|
+
});
|
|
920
|
+
};
|
|
921
|
+
client.onerror = (err) => {
|
|
922
|
+
if (server.state !== "ready" || signal.aborted || server.client !== client) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
926
|
+
if (
|
|
927
|
+
/unknown message ID|Received a response for an unknown message ID|notifications\/progress|unknown token/i.test(
|
|
928
|
+
msg,
|
|
929
|
+
)
|
|
930
|
+
) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (server.config.transport === "sse") {
|
|
934
|
+
console.error(`[pi-mcp] Server ${server.name} SSE stream error, reconnecting: ${msg}`);
|
|
935
|
+
scheduleErrorRecovery();
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (server.config.transport === "streamable-http") {
|
|
939
|
+
if (isOAuthUnauthorizedError(err)) {
|
|
940
|
+
console.error(`[pi-mcp] Server ${server.name} HTTP auth error, recovering: ${msg}`);
|
|
941
|
+
scheduleErrorRecovery();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// WHY: After notifications/initialized, the SDK fires an unawaited optional GET probe
|
|
945
|
+
// (_startOrAuthSse) to open a standalone SSE stream. The SDK already ignores HTTP 405,
|
|
946
|
+
// so a non-auth "Failed to open SSE stream: ..." error here means a POST-only HTTP
|
|
947
|
+
// server returned a non-405 status (e.g. 404 Not Found, 400 Bad Request, 501 Not Implemented)
|
|
948
|
+
// on GET. Ignore it so POST-only servers do not enter a spurious recovery loop; genuine
|
|
949
|
+
// stream drops on established streams report "SSE stream disconnected" or
|
|
950
|
+
// "Maximum reconnection attempts (0) exceeded." instead.
|
|
951
|
+
if (/Failed to open SSE stream:/i.test(msg)) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
console.error(`[pi-mcp] Server ${server.name} HTTP stream error, reconnecting: ${msg}`);
|
|
955
|
+
scheduleErrorRecovery();
|
|
956
|
+
}
|
|
515
957
|
};
|
|
516
958
|
} catch (err) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
959
|
+
const pid =
|
|
960
|
+
server.childPid ??
|
|
961
|
+
(transport as unknown as { _process?: { pid?: number } })?._process?.pid ??
|
|
962
|
+
(transport instanceof StdioClientTransport ? transport.pid : null);
|
|
963
|
+
server.childPid = null;
|
|
964
|
+
await Promise.allSettled([
|
|
965
|
+
client.close(),
|
|
966
|
+
this._ensurePidTerminated(pid),
|
|
967
|
+
]);
|
|
968
|
+
await this._ensurePidTerminated(pid);
|
|
969
|
+
if (server.client === client) {
|
|
970
|
+
server.client = null;
|
|
971
|
+
server.transport = undefined;
|
|
972
|
+
}
|
|
973
|
+
if (signal.aborted || err instanceof AuthCancelledError) {
|
|
974
|
+
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(server.name);
|
|
975
|
+
}
|
|
520
976
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
521
977
|
if (
|
|
522
978
|
!corruptStateRecovered &&
|
|
@@ -528,7 +984,7 @@ export class ServerManager {
|
|
|
528
984
|
// state file before throwing this error. Log the warning and retry once immediately
|
|
529
985
|
// without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
|
|
530
986
|
console.error(`[pi-mcp] ${error.message}`);
|
|
531
|
-
if (signal.aborted) throw new
|
|
987
|
+
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
532
988
|
return this._connect(server, cwd, signal, true);
|
|
533
989
|
}
|
|
534
990
|
server.lastError = error;
|
|
@@ -553,7 +1009,13 @@ export class ServerManager {
|
|
|
553
1009
|
}
|
|
554
1010
|
if (server.retryCount >= this.settings.maxRetries) {
|
|
555
1011
|
server.state = "stopped";
|
|
556
|
-
|
|
1012
|
+
const code = error instanceof McpError && error.code === "timeout" ? "timeout" : "connection";
|
|
1013
|
+
throw new McpError(
|
|
1014
|
+
`Connection failed after ${server.retryCount} retries: ${error.message}`,
|
|
1015
|
+
server.name,
|
|
1016
|
+
code,
|
|
1017
|
+
err,
|
|
1018
|
+
);
|
|
557
1019
|
}
|
|
558
1020
|
const delayMs = RETRY_DELAYS_MS[Math.min(server.retryCount++, RETRY_DELAYS_MS.length - 1)] ?? 30000;
|
|
559
1021
|
await new Promise<void>((resolve) => {
|
|
@@ -563,7 +1025,7 @@ export class ServerManager {
|
|
|
563
1025
|
resolve();
|
|
564
1026
|
}, { once: true });
|
|
565
1027
|
});
|
|
566
|
-
if (signal.aborted) throw new
|
|
1028
|
+
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
567
1029
|
return this._connect(server, cwd, signal, corruptStateRecovered);
|
|
568
1030
|
}
|
|
569
1031
|
}
|
|
@@ -576,8 +1038,12 @@ export class ServerManager {
|
|
|
576
1038
|
// An external stop during cleanup increments the generation again.
|
|
577
1039
|
queueMicrotask(() => {
|
|
578
1040
|
if (server.generation !== recoveryGeneration) return;
|
|
579
|
-
void this.startServer(server.name, cwd).catch((
|
|
580
|
-
|
|
1041
|
+
void this.startServer(server.name, cwd).catch((err) => {
|
|
1042
|
+
if (err instanceof AuthCancelledError) return;
|
|
1043
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1044
|
+
server.lastError = error;
|
|
1045
|
+
console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${error.message}`);
|
|
1046
|
+
this.onRecoveryFailed?.(server.name, error);
|
|
581
1047
|
});
|
|
582
1048
|
});
|
|
583
1049
|
}
|
|
@@ -585,6 +1051,8 @@ export class ServerManager {
|
|
|
585
1051
|
|
|
586
1052
|
private async _shutdown(server: ManagedServer): Promise<void> {
|
|
587
1053
|
server.controller?.abort();
|
|
1054
|
+
server.controller = undefined;
|
|
1055
|
+
server.attempt = undefined;
|
|
588
1056
|
server.generation = (server.generation ?? 0) + 1;
|
|
589
1057
|
|
|
590
1058
|
// Cancel pending retry
|
|
@@ -603,24 +1071,19 @@ export class ServerManager {
|
|
|
603
1071
|
server.lastError = null;
|
|
604
1072
|
this.onServerStopped?.(server.name);
|
|
605
1073
|
const client = server.client;
|
|
606
|
-
const pid =
|
|
1074
|
+
const pid =
|
|
1075
|
+
server.childPid ??
|
|
1076
|
+
(server.transport as unknown as { _process?: { pid?: number } })?._process?.pid ??
|
|
1077
|
+
null;
|
|
607
1078
|
server.client = null;
|
|
1079
|
+
server.transport = undefined;
|
|
608
1080
|
server.childPid = null;
|
|
609
1081
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
} catch {
|
|
616
|
-
// If SDK cleanup fails, force kill the subprocess as a safety net
|
|
617
|
-
if (pid !== null) {
|
|
618
|
-
try {
|
|
619
|
-
process.kill(pid, "SIGKILL");
|
|
620
|
-
} catch {
|
|
621
|
-
// Process may already be dead
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
1082
|
+
await Promise.allSettled([
|
|
1083
|
+
client?.close(),
|
|
1084
|
+
this._ensurePidTerminated(pid),
|
|
1085
|
+
]);
|
|
1086
|
+
await this._ensurePidTerminated(pid);
|
|
625
1087
|
}
|
|
626
1088
|
}
|
|
1089
|
+
|
package/src/tool-bridge.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotoc
|
|
|
18
18
|
import * as Type from "typebox";
|
|
19
19
|
import type { TSchema } from "typebox";
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import { McpError } from "./errors.js";
|
|
21
|
+
import { McpError, AuthCancelledError } from "./errors.js";
|
|
22
22
|
import type { Settings } from "./config.js";
|
|
23
23
|
import { isOAuthUnauthorizedError } from "./server-manager.js";
|
|
24
24
|
|
|
@@ -27,6 +27,30 @@ import { isOAuthUnauthorizedError } from "./server-manager.js";
|
|
|
27
27
|
/** Subset of Pi's ExtensionAPI used by the bridge. */
|
|
28
28
|
export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools" | "setActiveTools">;
|
|
29
29
|
|
|
30
|
+
export type ToolRefreshOptions = {
|
|
31
|
+
requestTimeoutMs?: number | undefined;
|
|
32
|
+
signal?: AbortSignal | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// WHY: Store per-client default { requestTimeoutMs, signal } in a WeakMap so 2-argument
|
|
36
|
+
// onToolRefresh callbacks ((name, client) => bridge.refreshTools(name, client)) still
|
|
37
|
+
// inherit the server's configured requestTimeoutMs and handshake AbortSignal.
|
|
38
|
+
const clientRefreshDefaults = new WeakMap<Client, ToolRefreshOptions>();
|
|
39
|
+
|
|
40
|
+
export function setClientDefaultOptions(client: Client, options: ToolRefreshOptions): void {
|
|
41
|
+
if (client && typeof client === "object") {
|
|
42
|
+
clientRefreshDefaults.set(client, options);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getClientDefaultOptions(client: Client): ToolRefreshOptions | undefined {
|
|
47
|
+
if (client && typeof client === "object") {
|
|
48
|
+
return clientRefreshDefaults.get(client);
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
30
54
|
|
|
31
55
|
// ─── Schema Conversion ────────────────────────────────────────────────────────
|
|
32
56
|
|
|
@@ -641,6 +665,8 @@ export interface McpToolDefinition {
|
|
|
641
665
|
export async function listAllTools(
|
|
642
666
|
client: Client,
|
|
643
667
|
requestTimeoutMs: number,
|
|
668
|
+
signal?: AbortSignal,
|
|
669
|
+
serverName = "<unknown>",
|
|
644
670
|
): Promise<McpToolDefinition[]> {
|
|
645
671
|
const tools: McpToolDefinition[] = [];
|
|
646
672
|
let cursor: string | undefined;
|
|
@@ -648,17 +674,53 @@ export async function listAllTools(
|
|
|
648
674
|
let pageCount = 0;
|
|
649
675
|
|
|
650
676
|
do {
|
|
677
|
+
if (signal?.aborted) {
|
|
678
|
+
throw new AuthCancelledError(serverName);
|
|
679
|
+
}
|
|
651
680
|
if (pageCount >= MAX_PAGES) {
|
|
652
681
|
console.warn(
|
|
653
682
|
`[pi-mcp] tools/list pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`,
|
|
654
683
|
);
|
|
655
684
|
break;
|
|
656
685
|
}
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
686
|
+
const requestOptions = {
|
|
687
|
+
timeout: requestTimeoutMs,
|
|
688
|
+
...(signal ? { signal } : {}),
|
|
689
|
+
};
|
|
690
|
+
const callPromise =
|
|
691
|
+
typeof client.listTools === "function"
|
|
692
|
+
? client.listTools(cursor ? { cursor } : undefined, requestOptions)
|
|
693
|
+
: client.request(
|
|
694
|
+
{ method: "tools/list", params: cursor ? { cursor } : {} },
|
|
695
|
+
ListToolsResultSchema,
|
|
696
|
+
requestOptions,
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
let result: Awaited<typeof callPromise>;
|
|
700
|
+
if (signal) {
|
|
701
|
+
callPromise.catch(() => {
|
|
702
|
+
// WHY: Suppress unhandled rejection if in-flight listTools rejects after abort wins the race.
|
|
703
|
+
});
|
|
704
|
+
let onAbort: (() => void) | undefined;
|
|
705
|
+
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
706
|
+
if (signal.aborted) {
|
|
707
|
+
reject(new AuthCancelledError(serverName));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
onAbort = () => reject(new AuthCancelledError(serverName));
|
|
711
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
712
|
+
});
|
|
713
|
+
try {
|
|
714
|
+
result = await Promise.race([callPromise, abortPromise]);
|
|
715
|
+
} finally {
|
|
716
|
+
if (onAbort) {
|
|
717
|
+
signal.removeEventListener("abort", onAbort);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
} else {
|
|
721
|
+
result = await callPromise;
|
|
722
|
+
}
|
|
723
|
+
|
|
662
724
|
tools.push(...(result.tools as McpToolDefinition[]));
|
|
663
725
|
cursor = result.nextCursor;
|
|
664
726
|
pageCount++;
|
|
@@ -682,6 +744,8 @@ export class ToolBridge {
|
|
|
682
744
|
private readonly toolOwners = new Map<string, string>();
|
|
683
745
|
/** Monotonic per-server generation counter to discard out-of-order refreshTools responses. */
|
|
684
746
|
private readonly refreshGenerations = new Map<string, number>();
|
|
747
|
+
/** Per-server request timeout overrides resolved during refreshTools. */
|
|
748
|
+
private readonly serverTimeouts = new Map<string, number>();
|
|
685
749
|
private onAuthError?: ((serverName: string, error: unknown) => Promise<void> | void) | undefined;
|
|
686
750
|
|
|
687
751
|
constructor(settings: Settings, pi: PiExtensionAPI) {
|
|
@@ -709,16 +773,31 @@ export class ToolBridge {
|
|
|
709
773
|
async refreshTools(
|
|
710
774
|
serverName: string,
|
|
711
775
|
client: Client,
|
|
712
|
-
|
|
776
|
+
isCurrentOrOptions?: (() => boolean) | ToolRefreshOptions,
|
|
777
|
+
maybeOptions?: ToolRefreshOptions,
|
|
713
778
|
): Promise<void> {
|
|
779
|
+
const isCurrent = typeof isCurrentOrOptions === "function" ? isCurrentOrOptions : () => true;
|
|
780
|
+
const options = typeof isCurrentOrOptions === "object" ? isCurrentOrOptions : maybeOptions;
|
|
781
|
+
const defaults = getClientDefaultOptions(client);
|
|
782
|
+
const effectiveTimeoutMs =
|
|
783
|
+
options?.requestTimeoutMs ?? defaults?.requestTimeoutMs ?? this.settings.requestTimeoutMs;
|
|
784
|
+
const effectiveSignal = options?.signal ?? defaults?.signal;
|
|
785
|
+
this.serverTimeouts.set(serverName, effectiveTimeoutMs);
|
|
786
|
+
|
|
714
787
|
const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
|
|
715
788
|
this.refreshGenerations.set(serverName, gen);
|
|
716
|
-
const timeoutMs = this.settings.requestTimeoutMs;
|
|
717
789
|
|
|
718
790
|
let tools: McpToolDefinition[];
|
|
719
791
|
try {
|
|
720
|
-
tools = await listAllTools(client,
|
|
792
|
+
tools = await listAllTools(client, effectiveTimeoutMs, effectiveSignal, serverName);
|
|
721
793
|
} catch (err) {
|
|
794
|
+
if (
|
|
795
|
+
err instanceof AuthCancelledError ||
|
|
796
|
+
effectiveSignal?.aborted ||
|
|
797
|
+
(err instanceof Error && err.name === "AbortError")
|
|
798
|
+
) {
|
|
799
|
+
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName);
|
|
800
|
+
}
|
|
722
801
|
if (isOAuthUnauthorizedError(err)) {
|
|
723
802
|
await this.onAuthError?.(serverName, err);
|
|
724
803
|
}
|
|
@@ -786,6 +865,7 @@ export class ToolBridge {
|
|
|
786
865
|
removeServerTools(serverName: string): void {
|
|
787
866
|
const gen = (this.refreshGenerations.get(serverName) ?? 0) + 1;
|
|
788
867
|
this.refreshGenerations.set(serverName, gen);
|
|
868
|
+
this.serverTimeouts.delete(serverName);
|
|
789
869
|
this._deactivateServerTools(serverName);
|
|
790
870
|
const existing = this.serverToolNames.get(serverName);
|
|
791
871
|
if (existing) {
|
|
@@ -856,7 +936,8 @@ export class ToolBridge {
|
|
|
856
936
|
}
|
|
857
937
|
|
|
858
938
|
const schema = convertJsonSchemaToTypebox(tool.inputSchema);
|
|
859
|
-
const
|
|
939
|
+
const getEffectiveTimeoutMs = () =>
|
|
940
|
+
this.serverTimeouts.get(serverName) ?? this.settings.requestTimeoutMs;
|
|
860
941
|
const notifyAuthError = (err: unknown) => this.onAuthError?.(serverName, err);
|
|
861
942
|
|
|
862
943
|
this.pi.registerTool({
|
|
@@ -871,17 +952,30 @@ export class ToolBridge {
|
|
|
871
952
|
throw new McpError("Tool execution cancelled", serverName, "protocol");
|
|
872
953
|
}
|
|
873
954
|
|
|
955
|
+
const effectiveTimeoutMs = getEffectiveTimeoutMs();
|
|
956
|
+
const requestOptions = {
|
|
957
|
+
timeout: effectiveTimeoutMs,
|
|
958
|
+
...(signal ? { signal } : {}),
|
|
959
|
+
};
|
|
960
|
+
|
|
874
961
|
try {
|
|
875
|
-
const result =
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
962
|
+
const result =
|
|
963
|
+
typeof client.callTool === "function"
|
|
964
|
+
? await client.callTool(
|
|
965
|
+
{ name: tool.name, arguments: params as Record<string, unknown> },
|
|
966
|
+
CallToolResultSchema,
|
|
967
|
+
requestOptions,
|
|
968
|
+
)
|
|
969
|
+
: await client.request(
|
|
970
|
+
{
|
|
971
|
+
method: "tools/call",
|
|
972
|
+
params: { name: tool.name, arguments: params },
|
|
973
|
+
},
|
|
974
|
+
CallToolResultSchema,
|
|
975
|
+
// Pass AbortSignal to SDK — it will automatically send
|
|
976
|
+
// notifications/cancelled when the signal fires
|
|
977
|
+
requestOptions,
|
|
978
|
+
);
|
|
885
979
|
|
|
886
980
|
const content = convertMcpContent(
|
|
887
981
|
(result.content ?? []) as Array<Record<string, unknown>>,
|
|
@@ -925,6 +1019,7 @@ export class ToolBridge {
|
|
|
925
1019
|
});
|
|
926
1020
|
}
|
|
927
1021
|
|
|
1022
|
+
|
|
928
1023
|
private _activateServerTools(serverName: string): void {
|
|
929
1024
|
const serverTools = this.serverToolNames.get(serverName);
|
|
930
1025
|
if (!serverTools || serverTools.size === 0) return;
|