fetchWithTimeout | (url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response> | Wraps fetch with AbortController timeout. timeoutMs bounds the whole exchange: on a 2xx carrying a body the returned Response is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's .text()/.json() with the same Timeout error the header phase raises. status, statusText, headers, url, redirected, and type carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. FetchWithTimeoutOptions extends RequestInit (minus signal) and adds rejectPrivateIPs?: boolean, expectedStatuses?: number[] (listed non-2xx statuses logged at debug not error, still thrown), errorBodyLimit?: number (bytes of a non-2xx body kept, default 500), and signal?: AbortSignal (external cancellation — an abort on it throws RequestCancelled (-32011), logged at info and outside withRetry's transient set, since the caller is gone and no retry can reach them). On a non-2xx, error.data carries status/body plus the legacy statusCode/responseBody aliases (identical values; consolidating in a future major); a body over errorBodyLimit is captured from both ends — 40% head, 60% tail, joined by …[N bytes elided]… — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing …. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under nodejs_compat; hostname-only fallback otherwise. Both resolvers are queried — resolve4/resolve6 (c-ares) and lookup (the system resolver, which is what reads /etc/hosts, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved net.connect() on Linux to getaddrinfo while leaving dns.resolve*() on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check. DNS rebinding / TOCTOU gap — the validation lookup and fetch's own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. Error/log redaction: URLs written into thrown errors and log lines are reduced to origin + pathname — the query string (where API keys commonly ride: ?api-key=…, ?api_key=…) never reaches the client or the logs. The actual request still uses the full URL. |
withRetry | <T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T> | Executes fn with exponential backoff. Retries on transient errors (ServiceUnavailable, Timeout, RateLimited); non-transient errors fail immediately. Honors an upstream Retry-After on data.retryAfter (delta-seconds or HTTP-date) over exponential backoff, capped at maxDelayMs; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and data.retryAttempts. Place the retry boundary around the full pipeline (fetch + parse), not just the network call. RetryOptions: maxRetries (default 3), baseDelayMs (default 1000), maxDelayMs (default 30000), jitter (default 0.25), operation (log label), context (RequestContext), signal (AbortSignal), isTransient (custom predicate). |
httpErrorFromResponse | (response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError> | Maps an HTTP Response to a properly classified McpError — full status table including 401/403/408/422/429/5xx, body capture (truncated), retry-after header, optional cause. error.data carries status/body plus the legacy statusCode/responseBody aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling if (status === 429) ... ladders. Reads the response body — clone() first if you need it elsewhere. HttpErrorFromResponseOptions: service? (logical name in message, e.g. 'NCBI'), captureBody? (default true), bodyLimit? (default 500), data? (extra fields merged into error.data), cause?, codeOverride? (per-status mapping override). Pairs naturally with withRetry — both classify codes the same way. A 501 also carries data.retryable: false, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
httpStatusToErrorCode | (status: number) -> JsonRpcErrorCode | undefined | Sync status → code lookup. Returns undefined for 1xx/2xx/3xx. Use when you need just the code without a Response object handy. No status maps to InternalError — that code means this server failed, which a remote status cannot establish; every 5xx is ServiceUnavailable (or Timeout for 504) and so picks up withRetry's default transient policy. |