/** * A bound on every network call the Inspector makes on the OAuth path (#2319). * * ## Why this exists * * Protected-resource-metadata discovery, authorization-server metadata * discovery, dynamic client registration, the token exchange and the refresh * all went out with no `AbortSignal` at all. Token revocation was the single * exception — `revocation.ts` has carried its own deadline since #2144, and it * is the pattern this module generalizes. * * An unbounded fetch matters more here than it usually would, because of where * this work runs and what the UI does while it runs: * * - It runs **server-side**, in the Inspector's own Node process, proxied * through the backend. A stall is therefore invisible in browser devtools — * #2188's reporter correctly observed that no request left the browser at * all while a Node-side socket sat established and idle. * - Connect-time auth errors deliberately hold the connection status at * `"connecting"` rather than moving it to `"error"` * (`isConnectAuthRecoveryError`, `challenge.ts`), on the theory that a * redirect is about to end the attempt. A stalled fetch inside that window is * a silent, unbounded spinner rather than a surfaced failure. * * The deadline covers the **whole exchange**, not just the headers: `fetch` * resolves as soon as response headers arrive, so a server that sends headers * — or half a JSON document — and then stalls would leave the caller's * `response.json()` hanging with nothing watching it. Every response on this * path is a small finite document, so the body is buffered under the same race * and the response rebuilt around it. * * Today the common case is bounded, but only incidentally: a discovery stall * that happens to sit inside an SDK `initialize` rides that request's own * timeout and surfaces after 60s as `Request timed out`. This module makes the * bound deliberate, applies it to the stalls that sit *outside* an SDK request * too, and — the point of naming the URL in the error — says *which* endpoint * stalled instead of blaming the handshake. * * ## The signal, the proxy hop, and why the race is still here * * The signal is the primary mechanism and it now reaches all the way out. On a * direct fetch (CLI, TUI, backend) it cancels the request outright. In the * browser the OAuth fetch is `createRemoteFetch`, which re-issues the call as a * POST to `/api/fetch` — and as of #2319 it forwards the signal onto that hop, * where the route composes `c.req.raw.signal` into its own outbound fetch. So * an abort here tears down the backend's request to the authorization server * rather than leaving it running detached, which is what it used to do. * * The race is kept anyway, and it is not redundant. It bounds what the signal * cannot: a `fetchFn` that ignores `AbortSignal` altogether — an injected or * test double, or any future transport that drops it the way the proxy used to * — and a backend that is itself wedged, where the outbound request is * cancelled but the hop back never completes. A deadline that depends on every * layer beneath it honouring a signal is not a deadline. Same reasoning, same * shape, as `revokeToken`. */ /** * 30 seconds. Deliberately generous: discovery against a slow or cold-starting * authorization server is legitimate, and a bound that fires on a server that * was going to answer is worse than the unbounded wait it replaced. It still * halves the 60s the SDK handshake incidentally provides, and it is the only * bound at all on the legs that sit outside an SDK request. */ export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000; /** * Raised when an OAuth-path request outlives its budget. * * Carries the URL because "discovery timed out" without one is only marginally * better than a spinner: the OAuth path makes several requests to several * hosts, and which of them stalled is the whole diagnostic. */ export class OAuthRequestTimeoutError extends Error { readonly url: string; readonly timeoutMs: number; constructor(url: string, timeoutMs: number) { super(`OAuth request to ${url} timed out after ${timeoutMs}ms`); this.name = "OAuthRequestTimeoutError"; this.url = url; this.timeoutMs = timeoutMs; } } /** * How a proxy-side deadline is carried back to the browser (#2319). * * `/api/fetch` serializes its failures as a JSON error response, and * `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a * timeout enforced by the *backend* would arrive as an untyped error and the * `instanceof OAuthRequestTimeoutError` checks downstream would all be false * (Copilot). That matters most exactly where there is no client-side wrapper to * have fired first: the discovery the SDK runs from inside the transport. So * the route stamps this marker and `createRemoteFetch` reconstructs the typed * error from it. */ export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout"; /** The body `/api/fetch` returns when its own deadline fired. */ export interface OAuthRequestTimeoutWire { code: typeof OAUTH_TIMEOUT_WIRE_CODE; url: string; timeoutMs: number; } /** Whether a parsed `/api/fetch` error body is a proxy-side deadline. */ export function isOAuthRequestTimeoutWire( value: unknown, ): value is OAuthRequestTimeoutWire { if (typeof value !== "object" || value === null) return false; const candidate = value as Record; return ( candidate.code === OAUTH_TIMEOUT_WIRE_CODE && typeof candidate.url === "string" && typeof candidate.timeoutMs === "number" ); } /** * Build the `isExempt` predicate for a chain that carries MCP traffic as well as * OAuth work: it returns true for the MCP endpoint itself. * * ⚠️ **Fails open, deliberately.** The two errors are not symmetric. Bounding a * request that should not be bounded severs a long-running tool call or an SSE * stream — a severe, user-visible regression. Failing to bound one leaves the * SDK's own per-request timeout as the backstop it has always been. So anything * uncertain — no server URL yet, a URL that will not parse, either side — is * treated as the MCP endpoint and exempted. * * Compared on origin + pathname: the SDK varies method and query across its MCP * requests but not the endpoint, while OAuth discovery goes to a different path * (`/.well-known/…`) and usually a different origin entirely. */ export function exemptMcpEndpoint( getServerUrl: () => string | undefined, ): (url: string) => boolean { return (url) => { const serverUrl = getServerUrl(); if (!serverUrl) return true; try { const a = new URL(url); const b = new URL(serverUrl); return a.origin === b.origin && a.pathname === b.pathname; } catch { return true; } }; } /** * The deadline a wrapped call is running under, keyed by the exact `RequestInit` * the wrapper handed down (#2319). * * This is how the budget crosses the proxy hop without travelling *upstream*. * `/api/fetch` must not time every request it serves — `createRemoteFetch` also * carries MCP traffic, and a Streamable HTTP tool call can legitimately withhold * its response headers for minutes — so the route needs to know which requests * are bounded and which are not (Copilot). A header would be the obvious * carrier and is the wrong one: `serializeRequest` copies request headers * verbatim into the payload and the route re-sends them to the authorization * server, so a marker header would leak to a third party. * * A `WeakMap` keyed on the init object has neither problem. It is invisible to * serialization, it needs no cast onto `RequestInit`, and it is collected with * the object — the wrapper builds a fresh init per call, so there is one entry * per in-flight request and nothing accumulates. */ const REQUEST_DEADLINES = new WeakMap(); /** * The deadline `withOAuthRequestTimeout` stamped on this init, if any. * * `undefined` means the request is not bounded by this wrapper and must not be * bounded by anything downstream either — an exempt MCP request reads as * `undefined` because the exempt path never builds an init of its own. */ export function deadlineForRequestInit( init: RequestInit | undefined, ): number | undefined { return init ? REQUEST_DEADLINES.get(init) : undefined; } /** The request URL, whatever form `fetch`'s first argument took. */ function requestUrlOf(input: RequestInfo | URL): string { if (typeof input === "string") return input; if (input instanceof URL) return input.href; return input.url; } /** * The caller's own `AbortSignal`, if it supplied one. * * A `Request` carries its signal on the *input*, not in `init`, and this * wrapper always passes a `signal` of its own in `init` — so reading `init` * alone would silently override the embedded one and change ordinary `fetch` * cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))` * (Copilot). * * An `init.signal` still wins, but only when it is not `undefined`. `RequestInit` * is a WebIDL dictionary, where a member present with the value `undefined` is * converted as *absent* — so `{ ...base, signal: undefined }`, which is what a * spread over an options object that had no signal produces, must inherit the * `Request`'s signal rather than clear it (Copilot). An explicit `null` is the * genuine "no signal" override and is honoured as one. */ function callerSignalOf( input: RequestInfo | URL, init: RequestInit | undefined, ): AbortSignal | undefined { const explicit = init?.signal; if (explicit !== undefined) return explicit ?? undefined; if (typeof input !== "string" && !(input instanceof URL)) return input.signal; return undefined; } /** * Statuses the Fetch Standard defines as null-body. Handing the `Response` * constructor a body with one of these throws a `TypeError`, so the rebuilt * response below must pass `null` instead. */ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); /** * Rebuild a `Response` around an already-buffered body. * * Two things have to be carried across by hand. * * `url`, `redirected` and `type` are getters with no constructor option, and * callers do read them — the RFC 8414/OIDC compat wrapper stamps a header * naming the URL a body came from, and the SDK's discovery reports the * responding URL in its errors. So they are copied explicitly rather than * silently reset to `""` / `false` / `"default"`. * * `content-encoding` and `content-length`, by contrast, must be *dropped*. * `arrayBuffer()` hands back the **decoded** entity body, so a gzipped metadata * document arrives here as plain JSON bytes: an inherited `content-encoding` * would describe an encoding the body no longer has, and the inherited * `content-length` would be the compressed size — both of them wrong, and both * visible in the Network capture (Copilot). `responseWithBody` in * `endpointOverrides.ts` deletes the same two headers for the same reason. */ function rebuildResponse(response: Response, body: ArrayBuffer): Response { const headers = new Headers(response.headers); headers.delete("content-encoding"); headers.delete("content-length"); const rebuilt = new Response( NULL_BODY_STATUSES.has(response.status) ? null : body, { status: response.status, statusText: response.statusText, headers, }, ); for (const key of ["url", "redirected", "type"] as const) { // Enumerability is taken from whatever the freshly built `Response` already // has for this key, rather than asserted. Hard-coding either answer makes // the rebuilt response observably different from a native one — and which // answer is right is the host's business: under the Fetch standard (and // undici) these are prototype getters with no own enumerable key at all, // while happy-dom defines them as own enumerable data properties. Copying // the descriptor keeps `Object.keys`, object spread and `JSON.stringify` // identical to a response that never passed through here, in both // (Copilot). `false` for the getter case, where there is no own descriptor. const existing = Object.getOwnPropertyDescriptor(rebuilt, key); Object.defineProperty(rebuilt, key, { value: response[key], enumerable: existing?.enumerable ?? false, configurable: true, }); } return rebuilt; } /** * Wrap a `fetch` so every call through it is bounded by `timeoutMs`. * * Every request that reaches it unexempted is bounded — so on a chain that * carries anything other than OAuth work, `isExempt` is not optional. A * Streamable HTTP or SSE response is a long-lived stream that is *supposed* to * stay open, and a Streamable HTTP POST for a long-running tool call * legitimately withholds its response headers for minutes; a deadline over * either severs a working connection. `InspectorClient` uses the wrapper on * both of its chains and the two differ only in this: the OAuth chain passes no * predicate because every request on it is OAuth work, while the transport * chain passes `exemptMcpEndpoint`. * * `isExempt` lets one wrapped fetch serve a mixed chain. It is how the * *transport* fetch can be bounded at all: that chain carries both the SDK's * OAuth work and the MCP traffic itself, and the MCP traffic must not be * timed — a Streamable HTTP POST for a long-running tool call legitimately * withholds its response headers for minutes, and an SSE body stays open * indefinitely. So `InspectorClient` exempts the MCP endpoint by URL and bounds * everything else on that chain, and **fails open**: if the server URL cannot * be determined or parsed, everything is exempt. Bounding a request that should * not be bounded breaks a working tool call; failing to bound one leaves the * SDK's own per-request timeout as the backstop it already was. * * A caller's own signal is preserved — whether it arrived in `init` or embedded * in a `Request` — and is both forwarded to the inner fetch (composed with * `AbortSignal.any`) and raced here, so an outer cancellation still wins even on * a fetch that drops the signal. This wrapper only ever *adds* a reason to give * up; it never removes the caller's. * * The response **body** is buffered under the same deadline, because `fetch` * resolves on headers and a stalled body would otherwise be unbounded. */ export function withOAuthRequestTimeout( fetchFn: typeof fetch, timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, isExempt?: (url: string) => boolean, ): typeof fetch { // Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one // calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on // a fractional delay (verified on Node 26) and so would fail before the // request was sent. This wrapper drives its deadline with `setTimeout` and an // `AbortController`, and `setTimeout` accepts a fractional delay and // truncates it, so nothing here would throw (Copilot). // // The real reason is that the budget is *reported*: it is interpolated into // the timeout message and exposed as `OAuthRequestTimeoutError.timeoutMs`. A // caller whose budget came from a `performance.now()` subtraction would // otherwise produce "timed out after 1000.4000000953674ms" and a non-integer // public field. Rounding rather than flooring so a caller's own // whole-millisecond timeout survives the trip through that clock and is still // the number the message names. const budget = Math.max(0, Math.round(timeoutMs)); return async (input, init) => { const url = requestUrlOf(input); // An exempt request is passed through completely untouched — no deadline, // no signal of ours, and crucially no body buffering, since the responses // this exists for are streams that must stay open (see `isExempt` above). if (isExempt?.(url)) return fetchFn(input, init); const controller = new AbortController(); const callerSignal = callerSignalOf(input, init); // Before anything is constructed or sent. Racing an already-aborted signal // would still evaluate `fetchFn(...)`, and a `fetchFn` that does not check // an already-aborted signal — the proxy hop was one until #2319 — would // send the OAuth request even though the caller cancelled before the call // (Copilot). if (callerSignal?.aborted) throw callerSignal.reason; const signal = callerSignal ? AbortSignal.any([controller.signal, callerSignal]) : controller.signal; let timer: ReturnType | undefined; let timedOut = false; let onCallerAbort: (() => void) | undefined; // The losing side of every race below: it rejects when the budget runs out, // and when the caller cancels. const abandoned = new Promise((_resolve, reject) => { timer = setTimeout(() => { timedOut = true; // Abort first, which is what actually cancels the request — through // the proxy hop too, since #2319 — then reject, so a `fetchFn` that // ignores the signal is still abandoned on schedule. controller.abort(new OAuthRequestTimeoutError(url, budget)); reject(new OAuthRequestTimeoutError(url, budget)); }, budget); // The caller's cancellation is raced too, not merely forwarded. Forwarding // it is enough wherever the signal is honoured, but a `fetchFn` that // ignores it would otherwise leave this promise pending for the whole // budget instead of the outer cancellation winning as documented // (Copilot). The caller's own `reason` is preserved, so it still sees its // abort rather than a substituted error. // Not aborted — the short-circuit above returned for that case. if (callerSignal) { onCallerAbort = () => reject(callerSignal.reason); callerSignal.addEventListener("abort", onCallerAbort, { once: true }); } }); // Built once and stamped, so `createRemoteFetch` can read the budget off it // and tell `/api/fetch` this particular request is bounded. const nextInit: RequestInit = { ...init, signal }; REQUEST_DEADLINES.set(nextInit, budget); try { const response = await Promise.race([ fetchFn(input, nextInit), abandoned, ]); // `fetch` resolves once the response *headers* arrive, so stopping here // would leave the body unbounded: an authorization server can send // headers, or half a JSON document, and then stall, and the caller's // `response.json()` would hang with nothing watching it (Copilot). Every // response on this path is a small, finite document — metadata, // a registration, a token — so buffering it under the same deadline // bounds the whole exchange. This is the other reason the wrapper must // never be applied to the transport fetch, whose bodies are streams that // are supposed to stay open. const body = await Promise.race([response.arrayBuffer(), abandoned]); return rebuildResponse(response, body); } catch (err) { // `controller.abort()` above can reject the underlying fetch *before* // `reject` runs, in which case the race settles with undici's // `AbortError` instead of ours. Which of the two wins is an ordering // detail of the fetch implementation, so normalize on the flag rather // than on the error that surfaced: a timeout must always be reported as // one, naming the endpoint. A caller-driven abort leaves the flag false // and so passes through with its own reason intact. if (timedOut) throw new OAuthRequestTimeoutError(url, budget); throw err; } finally { /* v8 ignore next -- the Promise executor runs synchronously, so `timer` is always assigned by the time this runs; the guard exists only because TypeScript cannot see that. */ if (timer !== undefined) clearTimeout(timer); // Drop the listener even though it is `once`: a caller signal can outlive // this request (one `AbortController` per connect attempt, several // requests through it), and a listener per request would accumulate on it. if (onCallerAbort && callerSignal) { callerSignal.removeEventListener("abort", onCallerAbort); } } }; }