pi-mcp-extension 1.11.3 → 1.11.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.11.3",
3
+ "version": "1.11.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": [
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Owns:
5
5
  * - Pure OAuth 401/403 connect and tool error detection (`isOAuthUnauthorizedError`)
6
+ * - Pure SDK request-timeout detection (`isSdkTimeoutError`) and cancellation detection
7
+ * (`isCancellationError`)
6
8
  * - Fixed retry backoff schedule (`RETRY_DELAYS_MS`)
7
9
  * - Connect-failure classification into retry, auth-required, fast-fail, give-up, or cancelled (`classifyConnectFailure`)
8
10
  */
@@ -138,6 +140,59 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
138
140
  /** Fixed retry delay schedule — predictable, no jitter math needed. */
139
141
  export const RETRY_DELAYS_MS = [1000, 3000, 5000, 10000, 30000] as const;
140
142
 
143
+ /** Message text of an error-ish value, preferring a real `message` field over `String()`. */
144
+ function errorMessageOf(err: unknown): string {
145
+ // WHY: transports reject with plain `{ code, message, data }` objects too, where
146
+ // `String(err)` is "[object Object]" and the message would be lost.
147
+ const message = (err as { message?: unknown } | null)?.message;
148
+ return typeof message === "string" ? message : String(err);
149
+ }
150
+
151
+ /**
152
+ * Whether `err` is the MCP SDK's own request-timeout rejection.
153
+ *
154
+ * The SDK rejects a timed-out `initialize`, `tools/list`, or `tools/call` with **its**
155
+ * `McpError` carrying the numeric JSON-RPC code `ErrorCode.RequestTimeout` (-32001) —
156
+ * a different class from pi-mcp's own {@link McpError}, so an `instanceof McpError`
157
+ * check here only excludes codes this extension already decided on. Any layer that
158
+ * re-wraps an SDK error must keep the timeout distinction: `classifyConnectFailure`
159
+ * fast-fails an error coded `"protocol"`, which would turn a transient discovery
160
+ * timeout into a first-attempt failure with no retries.
161
+ */
162
+ export function isSdkTimeoutError(err: unknown): boolean {
163
+ // WHY: our own McpError already carries a deliberate code; never re-derive it from
164
+ // the message (e.g. "Connection timed out after 5000ms" is already code "timeout").
165
+ if (err instanceof McpError) return false;
166
+ const code = (err as { code?: unknown } | null)?.code;
167
+ // WHY: a negative numeric code is a JSON-RPC error code, and a server error response
168
+ // arrives with its own (e.g. ErrorCode.InternalError = -32603) plus a message that may
169
+ // well say "timed out". Only the SDK's own RequestTimeout means the client gave up on
170
+ // the server, so no other negative code may fall through to the message heuristic.
171
+ if (typeof code === "number" && code < 0) return code === -32001;
172
+ return /Request timed out|timed out/i.test(errorMessageOf(err));
173
+ }
174
+
175
+ /**
176
+ * Whether `err`/`signal` are our own cancellation rather than a failure of the server.
177
+ *
178
+ * The SDK's `Protocol.request` rejects an in-flight request on abort as
179
+ * `McpError(ErrorCode.RequestTimeout, String(signal.reason))` — carrying the *timeout* code
180
+ * for a caller cancellation (`cancel()` in the SDK is shared by the timeout handler and the
181
+ * abort listener). Anything that classifies an SDK error must therefore test cancellation
182
+ * first, or a cancelled tool call is reported as a server timeout.
183
+ */
184
+ export function isCancellationError(err: unknown, signal?: AbortSignal | undefined): boolean {
185
+ // WHY: the caller's own abort is authoritative — it survives the SDK rewrapping the reason.
186
+ if (signal?.aborted || err instanceof AuthCancelledError) return true;
187
+ if (!(err instanceof Error)) return false;
188
+ // WHY: fallback for an abort-shaped rejection that arrives without our signal — the SDK's
189
+ // abort listener sets `signal.aborted` before rejecting, so this leg only matters when the
190
+ // rejection came from a signal we were never handed (or a transport-level abort). Still
191
+ // narrow on purpose: only a -32001 carrier is inspected, so a JSON-RPC error response whose
192
+ // message merely mentions "aborted" keeps its own class.
193
+ return err.name === "AbortError" || ((err as { code?: unknown }).code === -32001 && /abort/i.test(err.message));
194
+ }
195
+
141
196
  export type TransportType = ServerConfig["transport"];
142
197
 
143
198
  export type ConnectFailureAction =
@@ -73,6 +73,24 @@ type InteractiveAuthParams = {
73
73
  reset: boolean;
74
74
  };
75
75
 
76
+ /**
77
+ * Whether a declined server still deserves the `run /mcp:auth <name>` footer hint.
78
+ *
79
+ * WHY: A decline records "this server needed authorization at some point", which stays
80
+ * true until there is positive evidence otherwise. `ready` is that evidence, and so is a
81
+ * `stopped` server whose `lastError` is now a non-auth failure (`ECONNREFUSED`, timeout) —
82
+ * that failure overwrote `AuthRequiredError`, so authorization is no longer the problem
83
+ * and the hint would send the user down the wrong path. An unknown server drops out too.
84
+ * Anything ambiguous (no `lastError` yet, still `starting`) keeps the hint, because
85
+ * clearing it on doubt would hide a pending authorization the user was just told about.
86
+ */
87
+ function retainsAuthHint(server: Pick<ManagedServer, "state" | "lastError"> | undefined): boolean {
88
+ if (!server || server.state === "ready") return false;
89
+ const lastError = server.lastError;
90
+ if (!lastError || lastError instanceof AuthRequiredError) return true;
91
+ return server.state !== "stopped";
92
+ }
93
+
76
94
  export class ConnectionCoordinator {
77
95
  private readonly deps: ConnectionCoordinatorDeps;
78
96
  private readonly connectAttempts = new Map<string, ConnectAttemptEntry>();
@@ -124,7 +142,7 @@ export class ConnectionCoordinator {
124
142
  syncStatus(ctx: ExtensionContext | undefined = this.activeCtx): void {
125
143
  for (const declinedName of Array.from(this.declinedStartupAuth)) {
126
144
  const server = this.deps.manager.getServer(declinedName);
127
- if (!server || server.state === "ready") {
145
+ if (!retainsAuthHint(server)) {
128
146
  this.declinedStartupAuth.delete(declinedName);
129
147
  }
130
148
  }
@@ -147,18 +165,23 @@ export class ConnectionCoordinator {
147
165
 
148
166
  handleRecoveryFailed(serverName: string, error: unknown): void {
149
167
  if (error instanceof AuthRequiredError) {
150
- this.declinedStartupAuth.add(serverName);
151
168
  this.activeCtx?.ui.notify(
152
169
  `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
153
170
  "warning",
154
171
  );
155
- this.syncStatus(this.activeCtx);
156
- } else {
157
- this.activeCtx?.ui.notify(
158
- `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
159
- "error",
160
- );
172
+ // WHY: Route through markAuthRequired so a mid-session 401 (reported by
173
+ // ServerManager.handleUnauthorizedServerError) lands in the declined set and
174
+ // repaints the footer hint exactly like a declined startup prompt.
175
+ this.markAuthRequired(serverName);
176
+ return;
161
177
  }
178
+ this.activeCtx?.ui.notify(
179
+ `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
180
+ "error",
181
+ );
182
+ // WHY: The failure just overwrote lastError, so a hint left over from an earlier
183
+ // decline now points at authorization that is not the problem — clear it.
184
+ this.syncStatus(this.activeCtx);
162
185
  }
163
186
 
164
187
  cancelServer(serverName: string): void {
@@ -325,6 +348,9 @@ export class ConnectionCoordinator {
325
348
  try {
326
349
  await this.deps.manager.startServer(name, ctx.cwd);
327
350
  } catch (err) {
351
+ // WHY: A cancelled start means the user stopped the server (/mcp:stop) or the
352
+ // session is tearing down; reporting it as a start failure would be a bogus error.
353
+ if (err instanceof AuthCancelledError) return;
328
354
  if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
329
355
  authRequiredServers.add(name);
330
356
  return;
@@ -335,12 +361,14 @@ export class ConnectionCoordinator {
335
361
  }),
336
362
  );
337
363
 
364
+ // WHY: Reconcile the footer hint once every eager start has settled — a declined
365
+ // server may have connected cleanly or failed with a non-auth error, and either
366
+ // outcome makes "run /mcp:auth <name>" stale.
367
+ this.syncStatus(ctx);
368
+
338
369
  for (const [name] of serversToStart) {
339
370
  if (!authRequiredServers.has(name)) continue;
340
- if (this.declinedStartupAuth.has(name)) {
341
- this.syncStatus(ctx);
342
- continue;
343
- }
371
+ if (this.declinedStartupAuth.has(name)) continue;
344
372
  this.enqueueStartupAuthPrompt(name, ctx);
345
373
  }
346
374
 
@@ -483,8 +511,13 @@ export class ConnectionCoordinator {
483
511
  await this._startAndGuardAbort(serverName, ctx, controller.signal);
484
512
  return true;
485
513
  } catch (err) {
486
- if (!(err instanceof AuthRequiredError) || !serverConfig.auth) throw err;
487
- return false;
514
+ if (err instanceof AuthRequiredError && serverConfig.auth) return false;
515
+ // WHY: A non-auth connect failure (e.g. ECONNREFUSED) overwrites lastError, so a
516
+ // hint left over from an earlier decline no longer describes this server's problem.
517
+ if (!(err instanceof AuthCancelledError)) {
518
+ this.syncStatus(ctx);
519
+ }
520
+ throw err;
488
521
  }
489
522
  }
490
523
 
@@ -493,7 +526,17 @@ export class ConnectionCoordinator {
493
526
  this._ensureActive(controller.signal, serverName);
494
527
  await this.deps.authorize(serverName, ctx, controller.signal, reset);
495
528
  this._ensureActive(controller.signal, serverName);
496
- await this._startAndGuardAbort(serverName, ctx, controller.signal);
529
+ try {
530
+ await this._startAndGuardAbort(serverName, ctx, controller.signal);
531
+ } catch (err) {
532
+ // WHY: Authorization can succeed and the reconnect still fail for a non-auth reason
533
+ // (ECONNREFUSED, timeout). lastError now names that failure, so a hint left over from
534
+ // the earlier decline would tell the user to authorize a server that just authorized.
535
+ if (!(err instanceof AuthCancelledError)) {
536
+ this.syncStatus(ctx);
537
+ }
538
+ throw err;
539
+ }
497
540
  }
498
541
 
499
542
  private async _startAndGuardAbort(
package/src/errors.ts CHANGED
@@ -9,7 +9,7 @@ export type McpErrorCode =
9
9
  | "connection" // Transport/connection failed
10
10
  | "auth" // Authentication/authorization failed
11
11
  | "timeout" // Request or connection timed out
12
- | "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
12
+ | "protocol" // JSON-RPC protocol violation (a server error response, a malformed payload, or a call the caller cancelled)
13
13
  | "tool"; // Tool execution error (isError: true from server)
14
14
 
15
15
  export class McpError extends Error {
package/src/index.ts CHANGED
@@ -162,8 +162,20 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
162
162
  manager.setRecoveryFailedCallback((serverName, error) => {
163
163
  coordinator.handleRecoveryFailed(serverName, error);
164
164
  });
165
+ manager.setServerReadyCallback(() => {
166
+ // WHY: Eager starts and background _recover() reconnects never pass through
167
+ // coordinator.connect(), so without this the "run /mcp:auth <name>" footer hint
168
+ // survives a server that has since connected cleanly.
169
+ coordinator.syncStatus();
170
+ });
165
171
  bridge.setAuthErrorCallback(async (serverName) => {
166
- await manager.handleUnauthorizedServerError(serverName);
172
+ const authError = await manager.handleUnauthorizedServerError(serverName);
173
+ if (authError) {
174
+ // WHY: A mid-session 401/403 stops the server synchronously, which cancels the
175
+ // deferred _recover() that would have called onRecoveryFailed — so report the
176
+ // failure here or the re-auth warning and the footer hint never appear.
177
+ coordinator.handleRecoveryFailed(serverName, authError);
178
+ }
167
179
  });
168
180
 
169
181
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
@@ -394,10 +406,15 @@ function formatMcpAuthError(err: unknown, hasUI: boolean): string | undefined {
394
406
  return undefined;
395
407
  }
396
408
 
397
- function formatMcpProtocolOrGenericError(err: unknown): string {
409
+ function formatMcpConnectFailureMessage(err: unknown): string {
398
410
  if (err instanceof McpError && err.code === "protocol") {
399
411
  return "MCP tool discovery failed. Check the server and retry.";
400
412
  }
413
+ // WHY: a request timeout means the server was reachable but too slow, so pointing at the
414
+ // configuration would send the user down the wrong path (#209).
415
+ if (err instanceof McpError && err.code === "timeout") {
416
+ return "MCP server did not respond in time. Check that it is running and retry.";
417
+ }
401
418
  return "MCP connection failed. Check the server configuration and retry.";
402
419
  }
403
420
 
@@ -408,7 +425,7 @@ function formatMcpConnectError(err: unknown, options: McpConnectErrorOptions): s
408
425
  if (!options.serverExists) {
409
426
  return "Unknown MCP server name. Use mcp_status to list configured servers.";
410
427
  }
411
- return formatMcpAuthError(err, options.hasUI) ?? formatMcpProtocolOrGenericError(err);
428
+ return formatMcpAuthError(err, options.hasUI) ?? formatMcpConnectFailureMessage(err);
412
429
  }
413
430
 
414
431
  function registerManagementTools(
@@ -38,6 +38,7 @@ import { createTransport } from "./transport-factory.js";
38
38
  import {
39
39
  classifyConnectFailure,
40
40
  isOAuthUnauthorizedError,
41
+ isSdkTimeoutError,
41
42
  type ConnectFailureAction,
42
43
  } from "./connect-policy.js";
43
44
 
@@ -106,6 +107,17 @@ export type ToolRefreshCallback = (
106
107
  /** Called when background auto-recovery after a disconnect or health check fails. */
107
108
  export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
108
109
 
110
+ /**
111
+ * Called every time a server reaches `ready`, whether the transition came from an
112
+ * explicit connect, an eager startup, or background `_recover()`.
113
+ *
114
+ * WHY: Readiness is the only state change that makes a previously declined
115
+ * `run /mcp:auth <name>` footer hint obsolete, and eager starts plus background
116
+ * recovery bypass `ConnectionCoordinator.connect()`, which is where the hint is
117
+ * otherwise repainted.
118
+ */
119
+ export type ServerReadyCallback = (serverName: string) => void;
120
+
109
121
  interface HandshakeParams {
110
122
  server: ManagedServer;
111
123
  client: Client;
@@ -171,13 +183,6 @@ function createConnectRaceGuard(
171
183
  return { outcome, dispose };
172
184
  }
173
185
 
174
- function isSdkTimeoutError(err: unknown): boolean {
175
- if (err instanceof McpError) return false;
176
- if ((err as { code?: unknown } | null)?.code === -32001) return true;
177
- const message = err instanceof Error ? err.message : String(err);
178
- return /Request timed out|timed out/i.test(message);
179
- }
180
-
181
186
  function rethrowNormalizedConnectError(
182
187
  err: unknown,
183
188
  signal: AbortSignal,
@@ -206,6 +211,7 @@ export class ServerManager {
206
211
  private onToolRefresh: ToolRefreshCallback | null = null;
207
212
  private onServerStopped: ((name: string) => void) | null = null;
208
213
  private onRecoveryFailed: RecoveryFailedCallback | null = null;
214
+ private onServerReady: ServerReadyCallback | null = null;
209
215
 
210
216
  constructor(config: McpConfig, private readonly storageDir?: string) {
211
217
  this.settings = config.settings;
@@ -239,6 +245,11 @@ export class ServerManager {
239
245
  this.onRecoveryFailed = cb;
240
246
  }
241
247
 
248
+ /** Register callback invoked whenever a server reaches the `ready` state. */
249
+ setServerReadyCallback(cb: ServerReadyCallback): void {
250
+ this.onServerReady = cb;
251
+ }
252
+
242
253
  private _resolveTimeoutMs(server: ManagedServer): number {
243
254
  return server.config.requestTimeoutMs ?? this.settings.requestTimeoutMs;
244
255
  }
@@ -335,11 +346,29 @@ export class ServerManager {
335
346
  /**
336
347
  * Mark an OAuth server as requiring re-authorization when a mid-session
337
348
  * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
349
+ *
350
+ * Returns the {@link AuthRequiredError} when a server that was already `ready`
351
+ * lost its credentials mid-session, and `undefined` otherwise (unknown server,
352
+ * non-OAuth server, or a server that is not `ready` — see below).
353
+ *
354
+ * Only a `ready` server is handled here. For any other state this is a no-op and
355
+ * must NOT touch the server: a 401/403 during a connect attempt (`initialize` ok,
356
+ * `tools/list` 401 while `starting`) arrives while that attempt owns the
357
+ * lifecycle, and shutting the server down here would abort the attempt's own
358
+ * signal. `classifyConnectFailure` then sees `aborted` first and reports
359
+ * `AuthCancelledError` instead of `auth-required`, which suppresses the startup
360
+ * auth prompt, the interactive authorization, and the recovery failure alike.
361
+ * Left alone, the attempt's `_raiseAuthRequired` invalidates the tokens, records
362
+ * the `AuthRequiredError`, and reports it exactly once.
338
363
  */
339
- async handleUnauthorizedServerError(name: string): Promise<void> {
364
+ async handleUnauthorizedServerError(name: string): Promise<AuthRequiredError | undefined> {
340
365
  const server = this.servers.get(name);
341
366
  if (!server || !server.config.auth || server.config.transport === "stdio") {
342
- return;
367
+ return undefined;
368
+ }
369
+ if (server.state !== "ready") {
370
+ // WHY: See the JSDoc above — an in-flight connect attempt reports its own 401.
371
+ return undefined;
343
372
  }
344
373
  const authError = new AuthRequiredError(name);
345
374
  // WHY: Shut down immediately before async disk I/O so server.generation increments
@@ -348,6 +377,7 @@ export class ServerManager {
348
377
  server.lastError = authError;
349
378
  await invalidateStoredTokens(name, this.storageDir, server.config.url);
350
379
  server.lastError = authError;
380
+ return authError;
351
381
  }
352
382
 
353
383
  /** Get recent stderr output for a server. */
@@ -557,30 +587,9 @@ export class ServerManager {
557
587
  );
558
588
 
559
589
  // tools/list_changed: re-discover tools and update Pi registrations
560
- client.setNotificationHandler(
561
- ToolListChangedNotificationSchema,
562
- async () => {
563
- if (!signal.aborted && this.onToolRefresh && server.state === "ready" && server.client === client) {
564
- try {
565
- const refreshTimeoutMs = this._resolveTimeoutMs(server);
566
- await this.onToolRefresh(server.name, server.client, {
567
- requestTimeoutMs: refreshTimeoutMs,
568
- signal,
569
- });
570
- } catch (err) {
571
- const { kind, message } = describeError(err);
572
- reportDiagnostic({
573
- level: "error",
574
- event: "server.tools_refresh_failed",
575
- server: server.name,
576
- message: `Failed to refresh tools for ${server.name}: ${message}`,
577
- fields: { kind },
578
- notify: true,
579
- });
580
- }
581
- }
582
- },
583
- );
590
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
591
+ await this._handleToolListChanged(server, client, signal);
592
+ });
584
593
 
585
594
  // notifications/message: structured logging from MCP servers
586
595
  client.setNotificationHandler(
@@ -608,6 +617,48 @@ export class ServerManager {
608
617
  return client;
609
618
  }
610
619
 
620
+ /**
621
+ * Re-discover tools after a `notifications/tools/list_changed` from a ready server.
622
+ *
623
+ * Extracted from the notification handler so the handler registration stays a single
624
+ * guarded call.
625
+ */
626
+ private async _handleToolListChanged(
627
+ server: ManagedServer,
628
+ client: Client,
629
+ signal: AbortSignal,
630
+ ): Promise<void> {
631
+ const onToolRefresh = this.onToolRefresh;
632
+ if (signal.aborted || !onToolRefresh || server.state !== "ready" || server.client !== client) {
633
+ return;
634
+ }
635
+ try {
636
+ const refreshTimeoutMs = this._resolveTimeoutMs(server);
637
+ await onToolRefresh(server.name, server.client, {
638
+ requestTimeoutMs: refreshTimeoutMs,
639
+ signal,
640
+ });
641
+ } catch (err) {
642
+ const { kind, message } = describeError(err);
643
+ // WHY: Two failures here are already accounted for and must not toast: a cancelled
644
+ // refresh (/mcp:stop, session teardown) is expected, and a 401/403 was just reported
645
+ // by handleUnauthorizedServerError as a re-authorization warning plus the footer hint
646
+ // — a second "Failed to refresh tools" error would read as an unrelated problem.
647
+ const alreadyReported =
648
+ err instanceof AuthCancelledError ||
649
+ signal.aborted ||
650
+ server.lastError instanceof AuthRequiredError;
651
+ reportDiagnostic({
652
+ level: alreadyReported ? "info" : "error",
653
+ event: "server.tools_refresh_failed",
654
+ server: server.name,
655
+ message: `Failed to refresh tools for ${server.name}: ${message}`,
656
+ fields: { kind },
657
+ notify: !alreadyReported,
658
+ });
659
+ }
660
+ }
661
+
611
662
  private async _handshakeAndDiscover(params: HandshakeParams): Promise<void> {
612
663
  const { server, client, transport, timeoutMs, signal, currentCwd } = params;
613
664
  await this._connectWithTimeoutAndSignal(client, transport, timeoutMs, signal, server.name);
@@ -626,6 +677,9 @@ export class ServerManager {
626
677
  this._setState(server, "ready");
627
678
  server.retryCount = 0;
628
679
  server.lastError = null;
680
+ // WHY: Fire only after the server is observably ready with a cleared lastError, so a
681
+ // listener that repaints session state never sees a half-transitioned server.
682
+ this.onServerReady?.(server.name);
629
683
  if (server.cwd && server.cwd !== currentCwd) {
630
684
  void client.sendRootsListChanged().catch(() => {
631
685
  // WHY: Best-effort notification if cwd was updated while handshake was in flight.
@@ -18,7 +18,7 @@ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
19
  import { McpError, AuthCancelledError } from "./errors.js";
20
20
  import type { Settings } from "./config.js";
21
- import { isOAuthUnauthorizedError } from "./server-manager.js";
21
+ import { isCancellationError, isOAuthUnauthorizedError, isSdkTimeoutError } from "./connect-policy.js";
22
22
  import { convertJsonSchemaToTypebox } from "./schema-convert.js";
23
23
  import { listAllTools, type McpToolDefinition } from "./tool-discovery.js";
24
24
 
@@ -353,8 +353,9 @@ export class ToolBridge {
353
353
  }
354
354
 
355
355
  /**
356
- * Discover a server's tools, normalizing aborts into {@link AuthCancelledError}
357
- * and every other failure into an `McpError("protocol")`.
356
+ * Discover a server's tools, normalizing aborts into {@link AuthCancelledError},
357
+ * SDK request timeouts into `McpError("timeout")`, and every other failure into
358
+ * `McpError("protocol")`.
358
359
  */
359
360
  private async _fetchToolsForServer(
360
361
  serverName: string,
@@ -365,20 +366,20 @@ export class ToolBridge {
365
366
  try {
366
367
  return await listAllTools(client, effectiveTimeoutMs, effectiveSignal, serverName);
367
368
  } catch (err) {
368
- if (
369
- err instanceof AuthCancelledError ||
370
- effectiveSignal?.aborted ||
371
- (err instanceof Error && err.name === "AbortError")
372
- ) {
369
+ if (isCancellationError(err, effectiveSignal)) {
373
370
  throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName);
374
371
  }
375
372
  if (isOAuthUnauthorizedError(err)) {
376
373
  await this.onAuthError?.(serverName, err);
377
374
  }
375
+ // WHY: an SDK request timeout is retryable — `classifyConnectFailure` fast-fails on
376
+ // "protocol", so mislabelling it would skip `maxRetries` and report a discovery
377
+ // timeout as a protocol violation (#209).
378
+ const code = isSdkTimeoutError(err) ? "timeout" : "protocol";
378
379
  throw new McpError(
379
380
  `Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
380
381
  serverName,
381
- "protocol",
382
+ code,
382
383
  err,
383
384
  );
384
385
  }
@@ -482,11 +483,13 @@ export class ToolBridge {
482
483
  if (isOAuthUnauthorizedError(err)) {
483
484
  await notifyAuthError(err);
484
485
  }
485
- // Protocol-level errors (JSON-RPC error response, timeout, etc.)
486
+ // Protocol-level errors (JSON-RPC error response, malformed payload) keep
487
+ // "protocol"; an SDK request timeout keeps its own code so callers can tell
488
+ // "the server answered badly" from "the server did not answer in time" (#209).
486
489
  throw new McpError(
487
490
  err instanceof Error ? err.message : String(err),
488
491
  serverName,
489
- "protocol",
492
+ isSdkTimeoutError(err) ? "timeout" : "protocol",
490
493
  err,
491
494
  );
492
495
  }
@@ -509,13 +512,24 @@ export class ToolBridge {
509
512
  throw new McpError("Tool execution cancelled", serverName, "protocol");
510
513
  }
511
514
 
512
- const result = await callToolWithClient(
513
- client,
514
- toolName,
515
- params,
516
- this.serverTimeouts.get(serverName) ?? this.settings.requestTimeoutMs,
517
- signal,
518
- );
515
+ let result: ToolCallResponse;
516
+ try {
517
+ result = await callToolWithClient(
518
+ client,
519
+ toolName,
520
+ params,
521
+ this.serverTimeouts.get(serverName) ?? this.settings.requestTimeoutMs,
522
+ signal,
523
+ );
524
+ } catch (err) {
525
+ // WHY: an abort mid-call is rejected by the SDK with its RequestTimeout error, so the
526
+ // cancellation has to be recognised here — otherwise a caller cancellation is reported
527
+ // as a server timeout, and blamed on the server (#209).
528
+ if (isCancellationError(err, signal)) {
529
+ throw new McpError("Tool execution cancelled", serverName, "protocol");
530
+ }
531
+ throw err;
532
+ }
519
533
  const content = buildToolCallContent(result);
520
534
 
521
535
  // Tool execution errors (isError: true) — distinct from protocol errors