fix(mcp): apply the SDK's own Accept and Content-Type checks before a session takeover

Media types are case-insensitive in HTTP, so the takeover guard compared
them lower-cased, but the SDK's transport matches Accept as a
case-sensitive substring. A header such as Application/JSON passed the
guard, closed the live session, and then got 406 from the SDK. The guard
now also requires the SDK's Accept substrings and uses its exported
isJsonContentType, so it only replaces a session the SDK will accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-18 19:42:30 -07:00
1 parent 34b11444ba
commit baff9d359e
2 files changed
+11 -3

No files matched your search

+3 -1
View File
@@ -791,14 +791,16 @@ it("closes the connection when a route read only part of the body and keeps it a
}
});
it("does not let a zero-quality or quoted Accept range qualify a session takeover", async () => {
it("does not let a zero-quality, quoted or SDK-refused Accept qualify a session takeover", async () => {
const { served } = await start();
const { client } = await connect(served.url);
const url = new URL(served.url);
// The second header holds one range: the comma and `text/event-stream` sit inside a quoted parameter.
// The third is valid HTTP, but the SDK matches media types with case and would answer 406.
for (const accept of [
"application/json;q=0, text/event-stream",
'application/json;note=", text/event-stream;x=1"',
"Application/JSON, Text/Event-Stream",
]) {
const refused = await rawHttpRequest(url, {
method: "POST",
+8 -2
View File
@@ -1,6 +1,7 @@
import {
hostHeaderValidationResponse,
isInitializeRequest,
isJsonContentType,
localhostAllowedHostnames,
originValidationResponse,
readRequestBody,
@@ -145,17 +146,22 @@ const quality = (range: string): number => {
};
/** The headers the SDK requires of a streamable POST; a request without them is left to the transport's refusal. */
const hasStreamablePostHeaders = (request: Request): boolean => {
const accept = request.headers.get("accept") ?? "";
// Exact media ranges with a positive weight: `application/json-patch+json`, `text/event-streaming`
// and `application/json;q=0` do not count.
const accepted = new Set(
splitUnquoted(request.headers.get("accept") ?? "", ",")
splitUnquoted(accept, ",")
.filter((range) => quality(range) > 0)
.map(mediaType),
);
// The SDK's own checks too: its Accept test is a case-sensitive substring match, so a header it
// would refuse with 406 (such as `Application/JSON`) must not close the live session first.
return (
accepted.has("application/json") &&
accepted.has("text/event-stream") &&
mediaType(request.headers.get("content-type") ?? "") === "application/json"
accept.includes("application/json") &&
accept.includes("text/event-stream") &&
isJsonContentType(request.headers.get("content-type"))
);
};