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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.4",
3
+ "version": "1.9.5",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
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 class AuthCancelledError extends Error {
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
- manager.setToolRefreshCallback(async (serverName, client) => {
484
- await bridge.refreshTools(serverName, client, () => {
485
- const server = manager.getServer(serverName);
486
- return server?.client === client && server.state !== "stopped";
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);
@@ -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 = (serverName: string, client: Client) => Promise<void>;
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
- onStderr: (line: string) => void,
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
- // Build clean env: process.env may contain undefined values,
185
- // child_process.spawn silently drops them, but let's be explicit
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
- // Capture stderr lines into the circular buffer
198
- transport.stderr?.on("data", (chunk: Buffer) => {
199
- const lines = chunk.toString().split("\n").filter(Boolean);
200
- for (const line of lines) onStderr(line);
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
- return new SSEClientTransport(
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
- * Mark an OAuth server as requiring re-authorization when a mid-session
301
- * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
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
- if (server.state === "ready") {
311
- await this._shutdown(server);
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
- if (server.attempt) return server.attempt;
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 { await attempt; } finally {
467
+ try {
468
+ await attempt;
469
+ } finally {
344
470
  if (server.attempt === attempt) {
345
471
  server.attempt = undefined;
346
- if (this.getServer(name)?.state !== "ready") server.controller = undefined;
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, appendStderr, this.storageDir);
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
- // file:// URI for the workspace root. On Windows this produces
452
- // file://C:\... which is technically non-standard but functional
453
- // for the common case (MCP servers use roots as hints, not strict paths).
454
- roots: [{ uri: `file://${cwd}`, name: "workspace" }],
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
- await this.onToolRefresh(server.name, server.client);
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 client.connect(transport);
489
- if (signal.aborted) throw new Error("Connection cancelled");
490
- server.childPid = transport instanceof StdioClientTransport ? transport.pid : null;
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
- if (this.onToolRefresh) await this.onToolRefresh(server.name, client);
493
- if (signal.aborted) throw new Error("Connection cancelled");
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) return;
500
- try { await client.ping(); }
501
- catch {
502
- if (signal.aborted) return;
503
- await this._recover(server, cwd);
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
- try { await client.close(); } catch { /* best effort */ }
518
- if (server.client === client) { server.client = null; server.childPid = null; }
519
- if (signal.aborted) throw new Error("Connection cancelled");
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 Error("Connection cancelled");
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
- throw new McpError(`Connection failed after ${server.retryCount} retries: ${error.message}`, server.name, "connection", err);
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 Error("Connection cancelled");
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((error) => {
580
- server.lastError = error instanceof Error ? error : new Error(String(error));
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 = server.childPid;
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
- try {
611
- // SDK handles transport-specific cleanup:
612
- // - stdio: closes stdin, waits for process exit, sends SIGTERM/SIGKILL
613
- // - streamable-http/sse: closes HTTP connections
614
- await client?.close();
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
+
@@ -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 result = await client.request(
658
- { method: "tools/list", params: cursor ? { cursor } : {} },
659
- ListToolsResultSchema,
660
- { timeout: requestTimeoutMs },
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
- isCurrent: () => boolean = () => true,
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, timeoutMs);
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 timeoutMs = this.settings.requestTimeoutMs;
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 = await client.request(
876
- {
877
- method: "tools/call",
878
- params: { name: tool.name, arguments: params },
879
- },
880
- CallToolResultSchema,
881
- // Pass AbortSignal to SDK — it will automatically send
882
- // notifications/cancelled when the signal fires
883
- { timeout: timeoutMs, ...(signal ? { signal } : {}) },
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;