From cbb3c656698049c353e1c23c6b6da8a38482a29c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 21 Oct 2026 12:29:10 -0400 Subject: [PATCH] fix(web): drop the dropped body's framing headers from an oversize error relay When /api/fetch refuses an oversized body for an error status, it relays the status and headers with no body. The relayed headers still carried content-length / transfer-encoding / content-encoding for the dropped body, so the browser rebuilt a null-body Response claiming a 5 MiB length. Those three are now omitted on that path only. Closes #2771 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .../mcp/remote/server-extra-coverage.test.ts | 13 +++++++++- core/mcp/remote/node/server.ts | 25 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 95f9dbe45..71d2a7342 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -879,6 +879,9 @@ describe("server.ts supplemental coverage", () => { expect(body.ok).toBe(false); expect(body.status).toBe(404); expect(body.headers["x-probe"]).toBe("relayed"); + expect(body.headers["content-type"]).toBe("text/html"); + // The chunked framing described the dropped body, not this one (#2771). + expect(body.headers["transfer-encoding"]).toBeUndefined(); expect(body.body).toBeUndefined(); }); @@ -889,8 +892,16 @@ describe("server.ts supplemental coverage", () => { body: JSON.stringify({ url: `${targetUrl}/not-found-declared` }), }); expect(res.status).toBe(200); - const body = (await res.json()) as { status: number; body?: string }; + const body = (await res.json()) as { + status: number; + headers: Record; + body?: string; + }; expect(body.status).toBe(404); + expect(body.headers["content-type"]).toBe("text/html"); + // A declared length for a body that is not relayed would misdescribe the + // rebuilt Response (#2771). + expect(body.headers["content-length"]).toBeUndefined(); expect(body.body).toBeUndefined(); }); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 56b40c3bb..e9cdfbbec 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -376,6 +376,26 @@ const MAX_STORAGE_BODY_BYTES = 4 * 1024 * 1024; */ export const MAX_PROXIED_RESPONSE_BYTES = 4 * 1024 * 1024; +/** + * Headers that describe a response body's framing rather than the response. + * An oversize error is relayed with its status and headers but no body, and + * the browser rebuilds it as a `Response` with a null body, so these would + * claim a length or an encoding for a body that is not there (#2771). + */ +const BODY_FRAMING_HEADERS = new Set([ + "content-length", + "transfer-encoding", + "content-encoding", +]); + +function withoutBodyFraming( + headers: Record, +): Record { + return Object.fromEntries( + Object.entries(headers).filter(([k]) => !BODY_FRAMING_HEADERS.has(k)), + ); +} + /** * Whether a string is safe to emit as an HTTP header VALUE. * @@ -1397,7 +1417,8 @@ export function createRemoteApp( // faithful way to hand it on. Distinct from the 500 an outbound // failure gets and the 504 a deadline gets; the browser surfaces it // through `createRemoteFetch` as a failed fetch carrying this message. - // - An error status is relayed as itself, with its headers and no body. + // - An error status is relayed as itself, with its headers and no body + // (less the dropped body's framing headers, #2771). // The status is what an OAuth client acts on there — a 404 from a // path-aware protected-resource metadata URL is what sends SDK // discovery on to the root well-known URL — and a 502 in its place @@ -1414,7 +1435,7 @@ export function createRemoteApp( ok: res.ok, status: res.status, statusText: res.statusText, - headers: resHeaders, + headers: withoutBodyFraming(resHeaders), }); if ( declaresOversizeBody(