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
2 changed files with 269 additions and 6 deletions

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)