Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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<string, string>;
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();
});

Expand Down
25 changes: 23 additions & 2 deletions core/mcp/remote/node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
): Record<string, string> {
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.
*
Expand Down Expand Up @@ -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
Expand All @@ -1414,7 +1435,7 @@ export function createRemoteApp(
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers: resHeaders,
headers: withoutBodyFraming(resHeaders),
});
if (
declaresOversizeBody(
Expand Down
Loading