From baff9d359e6b41b9363c808ab4ea56aaa6f72d67 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 18 Oct 2026 19:42:30 -0700 Subject: [PATCH] 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 --- src/server/http/serveStreamableHttp.test.ts | 4 +++- src/server/http/serveStreamableHttp.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/server/http/serveStreamableHttp.test.ts b/src/server/http/serveStreamableHttp.test.ts index 95b6be970d..accba0c190 100644 --- a/src/server/http/serveStreamableHttp.test.ts +++ b/src/server/http/serveStreamableHttp.test.ts @@ -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", diff --git a/src/server/http/serveStreamableHttp.ts b/src/server/http/serveStreamableHttp.ts index d4e311dc84..432c4ea597 100644 --- a/src/server/http/serveStreamableHttp.ts +++ b/src/server/http/serveStreamableHttp.ts @@ -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")) ); };