pi-mcp-extension 1.11.4 → 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.4",
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 =
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
@@ -406,10 +406,15 @@ function formatMcpAuthError(err: unknown, hasUI: boolean): string | undefined {
406
406
  return undefined;
407
407
  }
408
408
 
409
- function formatMcpProtocolOrGenericError(err: unknown): string {
409
+ function formatMcpConnectFailureMessage(err: unknown): string {
410
410
  if (err instanceof McpError && err.code === "protocol") {
411
411
  return "MCP tool discovery failed. Check the server and retry.";
412
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
+ }
413
418
  return "MCP connection failed. Check the server configuration and retry.";
414
419
  }
415
420
 
@@ -420,7 +425,7 @@ function formatMcpConnectError(err: unknown, options: McpConnectErrorOptions): s
420
425
  if (!options.serverExists) {
421
426
  return "Unknown MCP server name. Use mcp_status to list configured servers.";
422
427
  }
423
- return formatMcpAuthError(err, options.hasUI) ?? formatMcpProtocolOrGenericError(err);
428
+ return formatMcpAuthError(err, options.hasUI) ?? formatMcpConnectFailureMessage(err);
424
429
  }
425
430
 
426
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
 
@@ -182,13 +183,6 @@ function createConnectRaceGuard(
182
183
  return { outcome, dispose };
183
184
  }
184
185
 
185
- function isSdkTimeoutError(err: unknown): boolean {
186
- if (err instanceof McpError) return false;
187
- if ((err as { code?: unknown } | null)?.code === -32001) return true;
188
- const message = err instanceof Error ? err.message : String(err);
189
- return /Request timed out|timed out/i.test(message);
190
- }
191
-
192
186
  function rethrowNormalizedConnectError(
193
187
  err: unknown,
194
188
  signal: AbortSignal,
@@ -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