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(