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

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