fix: record an intercepted 401's status, headers and body (#2297)

The network tracker wrapped the auth-challenge interceptor, so a 401/403
never reached it as a response: the interceptor cancelled the body and
threw, and the tracker logged only the thrown error. NetworkEntry renders
its response section only when a status is present, so the body — and the
WWW-Authenticate resource_metadata and scope — were never shown.

Move the tracker below the interceptor so it records the real response and
clones its body before the interceptor cancels it, and stop awaiting that
cancel: with the tracker's clone as the other tee branch, the cancel settles
only once the clone reads to the end, which would hang on an unending body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 00:25:19 -04:00
commit af4b574234
2 changed files with 105 additions and 28 deletions

View file

@ -24,8 +24,11 @@ export function createAuthChallengeInterceptFetch(
}
// Release the connection before throwing so the SDK transport is not left
// with a half-read 401/403 body on streamable HTTP.
await response.body?.cancel().catch(() => {});
// with a half-read 401/403 body on streamable HTTP. Not awaited: when the
// network tracker sits below this wrapper (#2297) the body is one branch of
// a tee, and cancelling a branch settles only once the tracker's clone has
// read to the end — so a 401 whose body never ends would hang the throw.
void response.body?.cancel().catch(() => {});
throw new AuthChallengeError(
challenge,

View file

@ -67,12 +67,27 @@ export function createTransportNode(
onAuthChallengeObserved
? createAuthChallengeObserverFetch(inner, onAuthChallengeObserved)
: inner;
const withTracking = (inner: typeof fetch): typeof fetch =>
onFetchRequest
? createFetchTracker(inner, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: inner;
// The observer sits *under* the interceptor so it still reports the
// challenge on the path where interception throws — and, more to the point,
// on the legacy first-auth path where interception is off entirely.
//
// The network tracker sits under the interceptor too (#2297). Above it, the
// tracker never saw the 401/403 at all: the interceptor cancels the body and
// throws, so the entry was recorded from the thrown error alone — no status,
// no `WWW-Authenticate`, and no response body, which is exactly the detail
// someone debugging an OAuth challenge needs. Below it, the tracker records
// the real response and clones its body before the interceptor cancels it.
const trackedBase = withTracking(withChallengeObserver(baseFetch));
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
? createAuthChallengeInterceptFetch(withChallengeObserver(baseFetch))
: withChallengeObserver(baseFetch);
? createAuthChallengeInterceptFetch(trackedBase)
: trackedBase;
if (serverType === "stdio") {
const stdioConfig = config as StdioServerConfig;
@ -111,15 +126,9 @@ export function createTransportNode(
const configuredSseFetch = sseConfig.eventSourceInit?.fetch as
| typeof fetch
| undefined;
const sseFetch = configuredSseFetch
? withChallengeObserver(configuredSseFetch)
const trackedFetch = configuredSseFetch
? withTracking(withChallengeObserver(configuredSseFetch))
: fetchWithOptionalAuthIntercept;
const trackedFetch = onFetchRequest
? createFetchTracker(sseFetch, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: sseFetch;
const headers = headersFromSettings(settings);
@ -134,18 +143,11 @@ export function createTransportNode(
...(headers && { headers }),
};
const postFetch = onFetchRequest
? createFetchTracker(fetchWithOptionalAuthIntercept, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: fetchWithOptionalAuthIntercept;
const transport = new SSEClientTransport(url, {
authProvider,
eventSourceInit,
requestInit,
fetch: postFetch,
fetch: fetchWithOptionalAuthIntercept,
});
return { transport };
@ -161,17 +163,10 @@ export function createTransportNode(
...(headers && { headers }),
};
const transportFetch = onFetchRequest
? createFetchTracker(fetchWithOptionalAuthIntercept, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: fetchWithOptionalAuthIntercept;
const transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit,
fetch: transportFetch,
fetch: fetchWithOptionalAuthIntercept,
// SEP-2350: how the transport reacts to a `403 insufficient_scope`
// challenge. Defaults to the SDK's `reauthorize` when unset.
...(settings?.oauthOnInsufficientScope && {