fix: bound the 401/403 body read so an unending body is released (#2297)

Copilot review round 1. With the tracker below the auth-challenge
interceptor, its background clone kept draining a 401 body after the
interceptor cancelled its own branch — so a body that never ended left the
connection live. Read a 401/403 body up front instead, capped at 64 KiB and
2s, and cancel the clone's reader when either bound is hit so the
interceptor's cancel releases the source. The body now lands on the entry
itself before the challenge is thrown.

Also cover the web path end to end: a streamable-http 401 on
/api/mcp/send reaches the event stream with its status and 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 01:01:15 -04:00
commit a7c4c5260e
3 changed files with 269 additions and 6 deletions

View file

@ -223,6 +223,65 @@ export function isLongLivedStreamResponse(
);
}
/** Most of a 401/403 body the tracker will buffer before it stops reading. */
export const CHALLENGE_BODY_MAX_BYTES = 64 * 1024;
/** How long the tracker waits for a 401/403 body before giving up on the rest. */
export const CHALLENGE_BODY_READ_MS = 2_000;
/** Appended to a 401/403 body the tracker stopped reading early. */
export const TRUNCATED_BODY_SUFFIX = "\n[truncated]";
/**
* Read a 401/403 response body from a clone, bounded by
* {@link CHALLENGE_BODY_MAX_BYTES} and {@link CHALLENGE_BODY_READ_MS}.
*
* Unlike every other status, a challenge body is read *before* the entry is
* recorded (#2297). An auth-challenge interceptor above the tracker cancels the
* response and throws as soon as the tracker returns, and on the web backend
* that ends the connect request — so a body delivered later, as a separate
* update, arrives after anything could still carry it to the log. The bound is
* what makes waiting safe: a body that never ends is cut off, and cancelling the
* clone's reader lets the interceptor's cancel of the original release the
* connection instead of leaving the clone draining it.
*/
async function readChallengeBody(
response: Response,
): Promise<string | undefined> {
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
const body = response.clone().body;
/* v8 ignore next -- the caller checked response.body, and a clone of a response with a body has one */
if (!body) return undefined;
reader = body.getReader();
} catch {
return undefined;
}
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<"timeout">((resolve) => {
timer = setTimeout(() => resolve("timeout"), CHALLENGE_BODY_READ_MS);
});
try {
while (bytes < CHALLENGE_BODY_MAX_BYTES) {
const next = await Promise.race([reader.read(), deadline]);
if (next === "timeout") break;
if (next.done) return text + decoder.decode();
bytes += next.value.byteLength;
text += decoder.decode(next.value, { stream: true });
}
// Stopped early. Not awaited: cancel() adopts the source's promise, which a
// stalled body may never settle.
reader.cancel().catch(() => {});
return text + decoder.decode() + TRUNCATED_BODY_SUFFIX;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
export interface FetchTrackingCallbacks {
trackRequest?: (entry: FetchRequestEntryBase) => void;
/**
@ -376,6 +435,21 @@ export function createFetchTracker(
duration,
};
if (
(responseStatus === 401 || responseStatus === 403) &&
!isLongLivedStream &&
response.body &&
!response.bodyUsed
) {
const body = await readChallengeBody(response);
entry.responseBody =
body === undefined
? undefined
: (redactBody(body, response.headers.get("content-type")) ?? body);
callbacks.trackRequest?.(entry);
return response;
}
callbacks.trackRequest?.(entry);
// Kick off a fire-and-forget read of the cloned body. The clone is an

View file

@ -26,8 +26,8 @@ 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. 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.
// a tee, and cancel() adopts the source's promise, which a stalled body may
// never settle — so awaiting it could hang the throw.
void response.body?.cancel().catch(() => {});
throw new AuthChallengeError(

View file

@ -83,7 +83,8 @@ export function createTransportNode(
// 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.
// the real response and reads its body (bounded) before the interceptor
// cancels it.
const trackedBase = withTracking(withChallengeObserver(baseFetch));
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
? createAuthChallengeInterceptFetch(trackedBase)