Skip to content
Closed
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
32 changes: 30 additions & 2 deletions open-sse/mcp-server/httpTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -244,7 +271,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
* Used by the Next.js route at /api/mcp/stream.
*/
export async function handleMcpStreamableHTTP(request: Request): Promise<Response> {
return handleStreamableRequest(request);
return protectMcpSseResponse(request, await handleStreamableRequest(request));
}

/**
Expand All @@ -256,7 +283,8 @@ export async function handleMcpSSE(request: Request): Promise<Response> {
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" }), {
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/mcp-sse-response-headers-8277.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>({
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);
});
Loading