feat: bound SDK-internal OAuth on the transport chain too (#2319 round 11)

The transport chain carried the SDK's own OAuth work — the discovery it
runs from inside the transport on the 401/refresh path — and that leg
was left to the SDK's incidental per-request timeout, reporting a bare
`Request timed out` naming no endpoint. On a direct (non-proxied) path
the /api/fetch backstop cannot reach it either.

`withOAuthRequestTimeout` takes an optional `isExempt` predicate; an
exempt request passes through completely untouched — no deadline, no
signal, and no body buffering, which is what a stream needs.
`exemptMcpEndpoint` exempts the MCP endpoint by origin + pathname, and
FAILS OPEN: no server URL, or either side unparseable, means exempt.
The errors are not symmetric. Bounding what should not be bounded
severs a long-running tool call withholding its headers, or an SSE
body; failing to bound leaves the SDK's timeout as the backstop it
already was.

Two test corrections, both real:

- The metadata-preservation case asserted url/redirected/type against a
  synthetic Response, whose values are the rebuild's own defaults — so
  it stayed green with the defineProperty loop deleted. It now seeds
  non-default values first; verified by mutation that removing the loop
  makes it fail.
- Two signal assertions compared against the controller rather than
  `request.signal`. The Fetch standard gives a Request a dependent
  signal, so those encoded non-standard identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-11 04:49:05 -04:00
commit f491c7fc9a
3 changed files with 181 additions and 14 deletions

View file

@ -56,6 +56,7 @@ export {
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
OAUTH_TIMEOUT_WIRE_CODE,
OAuthRequestTimeoutError,
exemptMcpEndpoint,
isOAuthRequestTimeoutWire,
withOAuthRequestTimeout,
} from "./requestTimeout.js";

View file

@ -117,6 +117,37 @@ export function isOAuthRequestTimeoutWire(
);
}
/**
* 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 request URL, whatever form `fetch`'s first argument took. */
function requestUrlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
@ -208,6 +239,17 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response {
* `buildEffectiveAuthFetch` rather than wrapping `this.fetchFn`, which the
* transport is handed directly.
*
* `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
@ -220,6 +262,7 @@ function rebuildResponse(response: Response, body: ArrayBuffer): Response {
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
@ -239,6 +282,11 @@ export function withOAuthRequestTimeout(
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

View file

@ -256,7 +256,11 @@ import {
} from "../auth/challenge.js";
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js";
import { withOAuthRequestTimeout } from "../auth/requestTimeout.js";
import {
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
exemptMcpEndpoint,
withOAuthRequestTimeout,
} from "../auth/requestTimeout.js";
import type { TokenRevocationOutcome } from "../auth/revocation.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
@ -824,7 +828,21 @@ export class InspectorClient extends InspectorClientEventTarget {
// still hit the upstream failure. The substituted response is stamped with
// `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came
// from rather than appearing to be a 200 from the RFC 8414 path.
this.fetchFn = withRfc8414OidcCompat(withOverrides);
// The transport chain is bounded too, but only for the requests on it that
// are *not* MCP traffic — the OAuth work the SDK runs from inside the
// transport on the 401/refresh path, which otherwise waits out the SDK's
// incidental per-request timeout and reports a bare `Request timed out`
// naming no endpoint (Copilot). The MCP endpoint itself is exempt, and the
// predicate fails open: a long-running tool call withholding its response
// headers for minutes, or an SSE body that stays open, must never be timed
// here. Deadline innermost, for the same reason as the auth chain below.
this.fetchFn = withRfc8414OidcCompat(
withOAuthRequestTimeout(
withOverrides,
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
exemptMcpEndpoint(() => this.getServerUrl()),
),
);
// #2319: the auth chain is composed separately so the deadline sits
// *inside* the compat wrapper. Reusing `this.fetchFn` as the base would put
// it outside, and then a stalled OIDC probe would be reported under the