diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index 7b982142d4e..0d8a879c4f2 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -149,6 +149,33 @@ function errorResponse(message: string, code: number, status = 400): Response { ); } +export function protectMcpSseResponse(request: Request, response: Response): Response { + if ( + request.method !== "GET" || + !response.headers.get("content-type")?.toLowerCase().includes("text/event-stream") + ) { + return response; + } + + const headers = new Headers(response.headers); + headers.set("x-accel-buffering", "no"); + const cacheControl = headers.get("cache-control"); + const missingDirectives = ["no-cache", "no-transform"].filter( + (directive) => !new RegExp(`(?:^|,)\\s*${directive}(?:\\s*(?:,|$))`, "i").test(cacheControl ?? "") + ); + if (missingDirectives.length > 0) { + headers.set( + "cache-control", + [cacheControl, ...missingDirectives].filter(Boolean).join(", ") + ); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + function withSessionHeader(response: Response, sessionId: string): Response { if (response.headers.get("mcp-session-id")) { return response; @@ -244,7 +271,7 @@ async function handleStreamableRequest(request: Request): Promise { * Used by the Next.js route at /api/mcp/stream. */ export async function handleMcpStreamableHTTP(request: Request): Promise { - return handleStreamableRequest(request); + return protectMcpSseResponse(request, await handleStreamableRequest(request)); } /** @@ -256,7 +283,8 @@ export async function handleMcpSSE(request: Request): Promise { const { transport } = ensureSseServer(); try { - return await withMcpHttpAuthContext(request, () => transport.handleRequest(request)); + const response = await withMcpHttpAuthContext(request, () => transport.handleRequest(request)); + return protectMcpSseResponse(request, response); } catch (err) { console.error("[MCP] SSE error:", err); return new Response(JSON.stringify({ error: "MCP SSE transport error" }), { diff --git a/tests/unit/mcp-sse-response-headers-8277.test.ts b/tests/unit/mcp-sse-response-headers-8277.test.ts new file mode 100644 index 00000000000..cb32e4002dc --- /dev/null +++ b/tests/unit/mcp-sse-response-headers-8277.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { protectMcpSseResponse } = await import( + "../../open-sse/mcp-server/httpTransport.ts" +); + +const encodings = ["gzip", "GZip", "gzip;q=0", "*"]; + +for (const acceptEncoding of encodings) { + test(`GET SSE remains identity encoded for Accept-Encoding: ${acceptEncoding}`, async () => { + let sent = false; + const source = new ReadableStream({ + start(controller) { + setTimeout(() => { + sent = true; + controller.enqueue(new TextEncoder().encode("event: ping\ndata: {}\n\n")); + controller.close(); + }, 10); + }, + }); + const response = protectMcpSseResponse( + new Request("http://localhost/api/mcp/stream", { + headers: { "Accept-Encoding": acceptEncoding }, + }), + new Response(source, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Vary: "Origin", + }, + }) + ); + + assert.equal(response.headers.get("content-encoding"), null); + assert.match(response.headers.get("cache-control") ?? "", /(?:^|,\s*)no-transform(?:,|$)/i); + assert.equal(response.headers.get("x-accel-buffering"), "no"); + assert.equal(response.headers.get("vary"), "Origin"); + + const reader = response.body?.getReader(); + assert.ok(reader); + const first = await reader.read(); + assert.equal(sent, true); + assert.equal(new TextDecoder().decode(first.value), "event: ping\ndata: {}\n\n"); + }); +} + +test("non-SSE and already encoded responses remain unchanged", () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { "Accept-Encoding": "gzip" }, + }); + const json = new Response("{}", { + headers: { "Content-Type": "application/json", Vary: "Accept-Encoding" }, + }); + const encoded = new Response("opaque", { + headers: { "Content-Type": "application/json", "Content-Encoding": "br" }, + }); + + assert.strictEqual(protectMcpSseResponse(request, json), json); + assert.strictEqual(protectMcpSseResponse(request, encoded), encoded); + assert.equal(json.headers.get("vary"), "Accept-Encoding"); + assert.equal(encoded.headers.get("content-encoding"), "br"); +}); + +test("non-GET SSE response remains unchanged", () => { + const request = new Request("http://localhost/api/mcp/stream", { method: "POST" }); + const response = new Response("data: {}\n\n", { + headers: { "Content-Type": "text/event-stream", "Content-Encoding": "gzip" }, + }); + + assert.strictEqual(protectMcpSseResponse(request, response), response); +});