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

@ -1,5 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import {
CHALLENGE_BODY_MAX_BYTES,
CHALLENGE_BODY_READ_MS,
TRUNCATED_BODY_SUFFIX,
createFetchTracker,
redactSensitiveHeaders,
redactBody,
@ -539,6 +542,123 @@ describe("createFetchTracker", () => {
});
});
describe("createFetchTracker — 401/403 challenge bodies (#2297)", () => {
function track(response: Response) {
const tracked: FetchRequestEntryBase[] = [];
const updateResponseBody = vi.fn();
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
updateResponseBody,
});
return { fetcher, tracked, updateResponseBody };
}
it("records the body on the entry itself, before returning", async () => {
const body = JSON.stringify({ error: "invalid_token" });
const { fetcher, tracked, updateResponseBody } = track(
new Response(body, {
status: 401,
headers: { "content-type": "application/json" },
}),
);
const res = await fetcher("https://mcp.example/mcp", { method: "POST" });
expect(tracked).toHaveLength(1);
expect(tracked[0]?.responseStatus).toBe(401);
expect(tracked[0]?.responseBody).toBe(body);
expect(updateResponseBody).not.toHaveBeenCalled();
// The caller still gets an unread body.
expect(await res.text()).toBe(body);
});
it("does the same for a 403", async () => {
const { fetcher, tracked } = track(
new Response("insufficient_scope", { status: 403 }),
);
await fetcher("https://mcp.example/mcp", { method: "POST" });
expect(tracked[0]?.responseBody).toBe("insufficient_scope");
});
it("stops at the byte cap and marks the body truncated", async () => {
const chunk = new Uint8Array(CHALLENGE_BODY_MAX_BYTES / 2).fill(97);
let pulls = 0;
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
cancel,
});
const { fetcher, tracked } = track(new Response(stream, { status: 401 }));
const res = await fetcher("https://mcp.example/mcp", { method: "POST" });
const recorded = tracked[0]?.responseBody ?? "";
expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true);
expect(recorded.length - TRUNCATED_BODY_SUFFIX.length).toBe(
CHALLENGE_BODY_MAX_BYTES,
);
// With the clone released, cancelling the caller's branch reaches the source.
await res.body?.cancel();
expect(cancel).toHaveBeenCalled();
expect(pulls).toBeLessThan(10);
});
it("gives up on a stalled body at the deadline and releases the source", async () => {
vi.useFakeTimers();
try {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("partial"));
},
cancel,
});
const { fetcher, tracked } = track(new Response(stream, { status: 401 }));
const pending = fetcher("https://mcp.example/mcp", { method: "POST" });
await vi.advanceTimersByTimeAsync(CHALLENGE_BODY_READ_MS);
const res = await pending;
expect(tracked[0]?.responseBody).toBe(`partial${TRUNCATED_BODY_SUFFIX}`);
await res.body?.cancel();
expect(cancel).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("records the entry with no body when the clone cannot be made", async () => {
const response = new Response("nope", { status: 401 });
vi.spyOn(response, "clone").mockImplementation(() => {
throw new Error("clone failed");
});
const { fetcher, tracked } = track(response);
await fetcher("https://mcp.example/mcp", { method: "POST" });
expect(tracked).toHaveLength(1);
expect(tracked[0]?.responseStatus).toBe(401);
expect(tracked[0]?.responseBody).toBeUndefined();
});
it("records the entry with no body when the read errors", async () => {
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
controller.error(new Error("socket reset"));
},
});
const { fetcher, tracked } = track(new Response(stream, { status: 401 }));
await fetcher("https://mcp.example/mcp", { method: "POST" });
expect(tracked[0]?.responseStatus).toBe(401);
expect(tracked[0]?.responseBody).toBeUndefined();
});
});
describe("redactUrlQuery", () => {
it("redacts sensitive params and keeps the path + other params", () => {
expect(

View file

@ -260,14 +260,14 @@ describe("Transport", () => {
});
const fetchRequests: FetchRequestEntryBase[] = [];
const bodies = new Map<string, string>();
const onFetchResponseBody = vi.fn();
const result = createTransportNode(
{ type: "streamable-http", url: "https://mcp.example/mcp" },
{
fetchFn,
interceptAuthChallenges: true,
onFetchRequest: (entry) => fetchRequests.push(entry),
onFetchResponseBody: (id, body) => bodies.set(id, body),
onFetchResponseBody,
},
);
@ -279,12 +279,15 @@ describe("Transport", () => {
"MCP auth challenge (401)",
);
// The body is on the entry by the time the challenge is thrown — not a
// later update — so a caller that stops listening at the throw has it.
expect(fetchRequests).toHaveLength(1);
const [entry] = fetchRequests;
expect(entry.error).toBeUndefined();
expect(entry.responseStatus).toBe(401);
expect(entry.responseHeaders?.["www-authenticate"]).toBe(wwwAuthenticate);
await vi.waitFor(() => expect(bodies.get(entry.id)).toBe(challengeBody));
expect(entry.responseBody).toBe(challengeBody);
expect(onFetchResponseBody).not.toHaveBeenCalled();
});
it("still throws an intercepted 401 whose body never ends (#2297)", async () => {
@ -319,6 +322,8 @@ describe("Transport", () => {
"MCP auth challenge (401)",
);
expect(fetchRequests[0]?.responseStatus).toBe(401);
// Cut off at the tracker's read deadline rather than buffered forever.
expect(fetchRequests[0]?.responseBody).toBe("partial\n[truncated]");
});
it("applies settings.headers to the outgoing streamable-http request", async () => {

View file

@ -193,6 +193,69 @@ describe("server.ts /api/mcp/* branch coverage", () => {
await new Promise<void>((r) => upstream.server.close(() => r()));
}
});
it("relays a streamable-http 401's status and body over the event stream (#2297)", async () => {
const upstream = await startUnauthorizedUpstream();
const events = new AbortController();
try {
const connected = (await (
await connect(h, { type: "streamable-http", url: upstream.url })
).json()) as { sessionId?: string };
expect(connected.sessionId).toBeDefined();
const stream = await fetch(
`${h.baseUrl}/api/mcp/events?sessionId=${connected.sessionId}`,
{ signal: events.signal },
);
const sent = await fetch(`${h.baseUrl}/api/mcp/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: connected.sessionId,
message: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
},
}),
});
expect(await sent.json()).toMatchObject({
ok: false,
kind: "auth_challenge",
});
const reader = stream.body!.getReader();
const decoder = new TextDecoder();
const pattern = /data: (\{"type":"fetch_request",.*?\})\n/;
let text = "";
let match: RegExpMatchArray | null = null;
while (!(match = text.match(pattern))) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
const event = JSON.parse(match![1]!) as {
data: {
responseStatus?: number;
responseBody?: string;
error?: string;
};
};
expect(event.data.error).toBeUndefined();
expect(event.data.responseStatus).toBe(401);
expect(event.data.responseBody).toBe(
JSON.stringify({ error: "unauthorized" }),
);
} finally {
events.abort();
await new Promise<void>((r) => upstream.server.close(() => r()));
}
});
});
describe("POST /api/mcp/send", () => {