pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
425 lines (383 loc) • 17.8 kB
text/typescript
/**
* Pure connection and stream error classification and retry policy for pi-mcp.
*
* Owns:
* - Pure OAuth 401/403 connect and tool error detection (`isOAuthUnauthorizedError`)
* - Pure SDK request-timeout detection (`isSdkTimeoutError`) and cancellation detection
* (`isCancellationError`)
* - Pure `client.onerror` stream/JSON-RPC error classification (`classifyClientError`,
* `isNonFatalClientError`, `isNotificationHandlerError`)
* - Fixed retry backoff schedule (`RETRY_DELAYS_MS`)
* - Connect-failure classification into retry, auth-required, fast-fail, give-up, or cancelled (`classifyConnectFailure`)
*/
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { McpError, AuthCancelledError, AuthRequiredError } from "./errors.js";
import type { ServerConfig } from "./config.js";
function isHttpStatusCodeUnauthorized(numericCode: number, error: unknown): boolean {
if (numericCode === 401) return true;
const msg = error instanceof Error ? error.message : String(error);
if (numericCode === 403 && /\b(insufficient_scope|trying upscoping)\b/i.test(msg)) {
return true;
}
return false;
}
function extractNumericStatusCode(rec: {
code?: unknown;
status?: unknown;
statusCode?: unknown;
}): number | undefined {
if (typeof rec.code === "number") return rec.code;
if (typeof rec.status === "number") return rec.status;
if (typeof rec.statusCode === "number") return rec.statusCode;
return undefined;
}
function isObjectRecordUnauthorized(
error: object,
seen: Set<unknown>,
): boolean | undefined {
if (error instanceof McpError && error.code === "tool") {
// WHY: Tool-level application errors (`isError: true`) come from tool execution logic
// (e.g. "found 401 items in database") and must never trigger MCP OAuth re-authentication.
return false;
}
const rec = error as { code?: unknown; status?: unknown; statusCode?: unknown; cause?: unknown };
if (rec.cause !== undefined && isOAuthUnauthorizedError(rec.cause, seen)) {
return true;
}
const numericCode = extractNumericStatusCode(rec);
// WHY: Only treat 100..599 as HTTP status codes so negative JSON-RPC error codes
// (e.g. -32000) still inspect the error message for wrapped HTTP 401/403 status text.
if (numericCode !== undefined && numericCode >= 100 && numericCode <= 599) {
return isHttpStatusCodeUnauthorized(numericCode, error);
}
return undefined;
}
function hasBare401StatusMarker(msg: string): boolean {
return (
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/^\s*401\s*$/.test(msg) ||
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/\bHTTP(?:\/\d(?:\.\d)?)?[:\s]+401\b/i.test(msg) ||
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/\bstatus(?:\s+code)?[:\s(]+401\b/i.test(msg)
);
}
function hasWrapped401StatusText(msg: string): boolean {
return (
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/\b(?:POSTing|GETting|connecting)\s+to\s+\S+:\s*401\b/i.test(msg) ||
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/\breturned\s+401\b/i.test(msg) ||
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/\b401\s+Unauthorized\b/i.test(msg)
);
}
function has401MessageMarker(msg: string): boolean {
return (
// nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
/\(401\)/.test(msg) ||
/\binvalid_token\b/i.test(msg)
);
}
function hasUnauthorizedMessageMarker(msg: string): boolean {
return hasBare401StatusMarker(msg) || hasWrapped401StatusText(msg) || has401MessageMarker(msg);
}
function isUnauthorizedErrorMessage(msg: string): boolean {
// Reject messages that explicitly report a non-401/403 HTTP status code
const httpStatusMatch = msg.match(
/\b(?:HTTP(?:\/\d(?:\.\d)?)?\s+|status(?:\s+code)?[:\s]+|(?:POSTing|GETting|connecting)\s+to\s+\S+:\s*|(?:Non-200\s+status\s+code\s*\())(\d{3})\b/i,
);
if (httpStatusMatch) {
const status = Number(httpStatusMatch[1]);
if (status !== 401 && status !== 403) {
return false;
}
}
if (hasUnauthorizedMessageMarker(msg)) {
return true;
}
return (
/\b(403|Forbidden)\b/i.test(msg) &&
/\b(insufficient_scope|trying upscoping)\b/i.test(msg)
);
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return typeof error === "string" ? error : "";
}
export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>()): boolean {
if (!error || seen.has(error)) {
return false;
}
if (typeof error === "object") {
seen.add(error);
if (error instanceof AuthRequiredError || error instanceof UnauthorizedError) {
return true;
}
const objectResult = isObjectRecordUnauthorized(error, seen);
if (objectResult !== undefined) {
return objectResult;
}
}
const msg = extractErrorMessage(error);
return msg ? isUnauthorizedErrorMessage(msg) : false;
}
/** Fixed retry delay schedule — predictable, no jitter math needed. */
export const RETRY_DELAYS_MS = [1000, 3000, 5000, 10000, 30000] as const;
/** Message text of an error-ish value, preferring a real `message` field over `String()`. */
function errorMessageOf(err: unknown): string {
// WHY: transports reject with plain `{ code, message, data }` objects too, where
// `String(err)` is "[object Object]" and the message would be lost.
const message = (err as { message?: unknown } | null)?.message;
return typeof message === "string" ? message : String(err);
}
/**
* Whether `err` is the MCP SDK's own request-timeout rejection.
*
* The SDK rejects a timed-out `initialize`, `tools/list`, or `tools/call` with **its**
* `McpError` carrying the numeric JSON-RPC code `ErrorCode.RequestTimeout` (-32001) —
* a different class from pi-mcp's own {@link McpError}, so an `instanceof McpError`
* check here only excludes codes this extension already decided on. Any layer that
* re-wraps an SDK error must keep the timeout distinction: `classifyConnectFailure`
* fast-fails an error coded `"protocol"`, which would turn a transient discovery
* timeout into a first-attempt failure with no retries.
*/
export function isSdkTimeoutError(err: unknown): boolean {
// WHY: our own McpError already carries a deliberate code; never re-derive it from
// the message (e.g. "Connection timed out after 5000ms" is already code "timeout").
if (err instanceof McpError) return false;
const code = (err as { code?: unknown } | null)?.code;
// WHY: a negative numeric code is a JSON-RPC error code, and a server error response
// arrives with its own (e.g. ErrorCode.InternalError = -32603) plus a message that may
// well say "timed out". Only the SDK's own RequestTimeout means the client gave up on
// the server, so no other negative code may fall through to the message heuristic.
if (typeof code === "number" && code < 0) return code === -32001;
return /Request timed out|timed out/i.test(errorMessageOf(err));
}
/**
* Whether `err`/`signal` are our own cancellation rather than a failure of the server.
*
* The SDK's `Protocol.request` rejects an in-flight request on abort as
* `McpError(ErrorCode.RequestTimeout, String(signal.reason))` — carrying the *timeout* code
* for a caller cancellation (`cancel()` in the SDK is shared by the timeout handler and the
* abort listener). Anything that classifies an SDK error must therefore test cancellation
* first, or a cancelled tool call is reported as a server timeout.
*/
export function isCancellationError(err: unknown, signal?: AbortSignal | undefined): boolean {
// WHY: the caller's own abort is authoritative — it survives the SDK rewrapping the reason.
if (signal?.aborted || err instanceof AuthCancelledError) return true;
if (!(err instanceof Error)) return false;
// WHY: fallback for an abort-shaped rejection that arrives without our signal — the SDK's
// abort listener sets `signal.aborted` before rejecting, so this leg only matters when the
// rejection came from a signal we were never handed (or a transport-level abort). Still
// narrow on purpose: only a -32001 carrier is inspected, so a JSON-RPC error response whose
// message merely mentions "aborted" keeps its own class.
return err.name === "AbortError" || ((err as { code?: unknown }).code === -32001 && /abort/i.test(err.message));
}
/**
* Whether a `client.onerror` message came from `Protocol._onnotification` rejecting an
* incoming notification (for example `notifications/progress` carrying `total: null` once
* `tools/call` attaches `_meta.progressToken`, or a throwing notification callback).
*/
export function isNotificationHandlerError(rawMsg: string): boolean {
return /^Uncaught error in notification handler:/i.test(rawMsg);
}
/**
* Whether a `client.onerror` message is non-fatal protocol noise rather than a broken
* SSE / Streamable HTTP transport stream.
*
* Late responses or `notifications/progress` events for an already-settled or cancelled
* request report `unknown message ID` / `unknown token`, and malformed notifications or
* throwing notification callbacks report `Uncaught error in notification handler: …`.
* Reconnecting on any of these would close a healthy HTTP/SSE session and abort in-flight
* `tools/call` requests with `Connection closed`.
*/
export function isNonFatalClientError(rawMsg: string): boolean {
return (
isNotificationHandlerError(rawMsg) ||
/unknown message ID|Received a response for an unknown message ID|notifications\/progress|unknown token/i.test(
rawMsg,
)
);
}
export type TransportType = ServerConfig["transport"];
/**
* What the lifecycle manager should do about an error reported on `client.onerror`.
*
* `ignore` covers three distinct non-failures: benign JSON-RPC races (the SDK and some servers
* disagree about which ids are live), stdio transports (a subprocess that dies is reported
* through `onclose`), and the SDK's POST-only SSE probe answering with a non-405 status.
* `diagnostic` names the one ignorable error worth an info-level log line.
*/
export type ClientErrorAction =
| { kind: "ignore"; diagnostic?: "server.notification_error" }
| {
kind: "recover";
event: "server.stream_error" | "server.auth_error";
transport: "sse" | "streamable-http";
/** Message prefix for the diagnostic, e.g. "SSE stream error, reconnecting". */
reason: string;
};
/** The SDK's optional standalone-SSE GET probe answering `Failed to open SSE stream: …`. */
const POST_ONLY_SSE_PROBE = /Failed to open SSE stream:/i;
/**
* Message text of a `client.onerror` payload, matching the pre-extraction branch exactly.
*
* WHY: only an `Error`'s `message` is inspected. Reading `.message` off a plain-object rejection
* would widen the benign-JSON-RPC and POST-only-probe matches to payloads the old code
* recovered from, and an ignored real stream drop is worse than a spurious reconnect.
*/
function clientErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function classifyNonFatalClientError(rawMsg: string): ClientErrorAction {
// WHY: a throwing notification callback is benign for the stream, but it is the only
// ignorable error a user can act on (a server sending malformed progress), so it keeps an
// info-level log line instead of disappearing.
return isNotificationHandlerError(rawMsg)
? { kind: "ignore", diagnostic: "server.notification_error" }
: { kind: "ignore" };
}
function classifyStreamableHttpError(err: unknown): ClientErrorAction {
if (isOAuthUnauthorizedError(err)) {
return {
kind: "recover",
event: "server.auth_error",
transport: "streamable-http",
reason: "HTTP auth error, recovering",
};
}
// WHY: After notifications/initialized, the SDK fires an unawaited optional GET probe
// (_startOrAuthSse) to open a standalone SSE stream. The SDK already ignores HTTP 405,
// so a non-auth "Failed to open SSE stream: ..." error here means a POST-only HTTP
// server returned a non-405 status (e.g. 404 Not Found, 400 Bad Request, 501 Not Implemented)
// on GET. Ignore it so POST-only servers do not enter a spurious recovery loop; genuine
// stream drops on established streams report "SSE stream disconnected" or
// "Maximum reconnection attempts (0) exceeded." instead.
if (POST_ONLY_SSE_PROBE.test(clientErrorMessage(err))) {
return { kind: "ignore" };
}
return {
kind: "recover",
event: "server.stream_error",
transport: "streamable-http",
reason: "HTTP stream error, reconnecting",
};
}
/**
* Pure `client.onerror` classifier for the transports that report stream drops there.
*
* SSEClientTransport and StreamableHTTPClientTransport report an unexpected drop through
* `onerror` rather than `onclose`, so this decides between "reconnect" and "this is not a
* failure" without touching lifecycle state; the caller owns the actual recovery.
*/
export function classifyClientError(transport: TransportType, err: unknown): ClientErrorAction {
const rawMsg = clientErrorMessage(err);
if (isNonFatalClientError(rawMsg)) {
return classifyNonFatalClientError(rawMsg);
}
if (transport === "sse") {
return {
kind: "recover",
event: "server.stream_error",
transport: "sse",
reason: "SSE stream error, reconnecting",
};
}
if (transport === "streamable-http") {
return classifyStreamableHttpError(err);
}
// WHY: stdio subprocess failure is reported through onclose, not onerror.
return { kind: "ignore" };
}
export type ConnectFailureAction =
| { kind: "cancelled" }
| { kind: "retry-corrupt-state" }
| { kind: "auth-required"; invalidateTokens: boolean }
| { kind: "fail"; error: Error }
| { kind: "give-up"; error: McpError }
| { kind: "retry"; delayMs: number };
export interface ClassifyConnectFailureInput {
error: unknown;
aborted: boolean;
corruptStateRecovered: boolean;
hasAuth: boolean;
transport: TransportType;
retryCount: number;
maxRetries: number;
serverName?: string;
}
function classifyOAuthFailure(
input: ClassifyConnectFailureInput,
error: Error,
): ConnectFailureAction | null {
if (error instanceof AuthRequiredError) {
return { kind: "auth-required", invalidateTokens: false };
}
if (input.hasAuth && input.transport !== "stdio" && isOAuthUnauthorizedError(input.error)) {
// WHY: Check OAuth unauthorized errors BEFORE the generic `error.code === "protocol"`
// fast-fail so 401/403 failures during initial tools/list (`onToolRefresh`) are
// classified as `AuthRequiredError` rather than raw protocol failures, and stale
// tokens on disk are invalidated before interactive re-authorization runs.
return { kind: "auth-required", invalidateTokens: true };
}
return null;
}
function classifyPreRetryFailure(
input: ClassifyConnectFailureInput,
error: Error,
): ConnectFailureAction | null {
if (
!input.corruptStateRecovered &&
error instanceof McpError &&
error.code === "config" &&
/Corrupt OAuth state file/i.test(error.message)
) {
// WHY: McpOAuthProvider._loadState() already backed up and removed the corrupt
// state file before throwing this error. Log the warning and retry once immediately
// without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
return { kind: "retry-corrupt-state" };
}
const oauthAction = classifyOAuthFailure(input, error);
if (oauthAction) return oauthAction;
if (error instanceof McpError && (error.code === "protocol" || error.code === "config")) {
return { kind: "fail", error };
}
return null;
}
function classifyRetryOrGiveUp(
input: ClassifyConnectFailureInput,
error: Error,
): ConnectFailureAction {
if (input.retryCount >= input.maxRetries) {
const serverName = input.serverName ?? (error instanceof McpError ? error.server : "");
const code = error instanceof McpError && error.code === "timeout" ? "timeout" : "connection";
return {
kind: "give-up",
error: new McpError(
`Connection failed after ${input.retryCount} retries: ${error.message}`,
serverName,
code,
input.error,
),
};
}
const delayMs = RETRY_DELAYS_MS[Math.min(input.retryCount, RETRY_DELAYS_MS.length - 1)] ?? 30000;
return { kind: "retry", delayMs };
}
/**
* Pure failure classifier for connection attempts.
* Classifies failure into cancelled, retry-corrupt-state, auth-required, fail (fast-fail),
* give-up (retries exhausted), or retry with a specific delay.
*/
export function classifyConnectFailure(input: ClassifyConnectFailureInput): ConnectFailureAction {
if (input.aborted || input.error instanceof AuthCancelledError) {
return { kind: "cancelled" };
}
const error = input.error instanceof Error ? input.error : new Error(String(input.error));
const preRetryAction = classifyPreRetryFailure(input, error);
if (preRetryAction) {
return preRetryAction;
}
return classifyRetryOrGiveUp(input, error);
}