From d2487ae24584d6b14c003c79d05ad7edbc0e209f Mon Sep 17 00:00:00 2001 From: Debasish Padhy Date: Sun, 20 Sep 2026 12:26:10 +0530 Subject: [PATCH] Standardize SSE response headers - Extract SSE response preparation into HttpServletResponseUtils - Add X-Accel-Buffering: no header to SSE responses - Add no-transform to the SSE Cache-Control header - Drop Connection: keep-alive from SSE responses (prohibited in HTTP/2) Fixes #293 Signed-off-by: Daniel Garnier-Moiroux --- .../transport/HttpServletMcpTransport.java | 2 +- .../transport/HttpServletResponseUtils.java | 36 +++++++++++ ...HttpServletSseServerTransportProvider.java | 5 +- ...vletStreamableServerTransportProvider.java | 10 +-- ...tpServletMcpTransportIntegrationTests.java | 3 + .../HttpServletSseIntegrationTests.java | 23 +++++++ ...HttpServletStreamableIntegrationTests.java | 63 +++++++++++++++++++ 7 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletResponseUtils.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java index 002009708..03be3a163 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java @@ -345,7 +345,7 @@ private String eventFrame(JSONRPCMessage message) { private static PrintWriter startEventStream(HttpServletResponse response) throws IOException { response.setContentType(TEXT_EVENT_STREAM); response.setCharacterEncoding(UTF_8); - response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Cache-Control", "no-cache, no-transform"); response.setHeader("X-Accel-Buffering", "no"); response.setStatus(HttpServletResponse.SC_OK); response.flushBuffer(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletResponseUtils.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletResponseUtils.java new file mode 100644 index 000000000..40add938c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletResponseUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import jakarta.servlet.http.HttpServletResponse; + +/** + * Utility methods for working with {@link HttpServletResponse}. For internal use only. + * + * @author Daniel Garnier-Moiroux + */ +final class HttpServletResponseUtils { + + private static final String TEXT_EVENT_STREAM = "text/event-stream"; + + private static final String UTF_8 = "UTF-8"; + + private HttpServletResponseUtils() { + } + + /** + * Prepares the response for streaming Server-Sent Events: sets the content type, + * character encoding and the headers that keep intermediaries from caching, + * transforming or buffering the stream. + * @param response The HTTP servlet response + */ + static void prepareSseResponse(HttpServletResponse response) { + response.setContentType(TEXT_EVENT_STREAM); + response.setCharacterEncoding(UTF_8); + response.setHeader("Cache-Control", "no-cache, no-transform"); + response.setHeader("X-Accel-Buffering", "no"); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index 18ac4147c..4bf2f0f47 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -300,10 +300,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } - response.setContentType("text/event-stream"); - response.setCharacterEncoding(UTF_8); - response.setHeader("Cache-Control", "no-cache"); - response.setHeader("Connection", "keep-alive"); + HttpServletResponseUtils.prepareSseResponse(response); String sessionId = UUID.randomUUID().toString(); AsyncContext asyncContext = request.startAsync(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index 63e149ac9..29765a2eb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -426,10 +426,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) McpTransportContext transportContext = this.contextExtractor.extract(request); try { - response.setContentType(TEXT_EVENT_STREAM); - response.setCharacterEncoding(UTF_8); - response.setHeader("Cache-Control", "no-cache"); - response.setHeader("Connection", "keep-alive"); + HttpServletResponseUtils.prepareSseResponse(response); AsyncContext asyncContext = request.startAsync(); asyncContext.setTimeout(0); @@ -601,10 +598,7 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { // For streaming responses, we need to return SSE - response.setContentType(TEXT_EVENT_STREAM); - response.setCharacterEncoding(UTF_8); - response.setHeader("Cache-Control", "no-cache"); - response.setHeader("Connection", "keep-alive"); + HttpServletResponseUtils.prepareSseResponse(response); AsyncContext asyncContext = request.startAsync(); asyncContext.setTimeout(0); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java index 9bd943125..4dc8e6990 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java @@ -211,6 +211,9 @@ void streamingToolCallReturnsSse() throws Exception { assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type")) .hasValueSatisfying(v -> assertThat(v).contains("text/event-stream")); + assertThat(response.headers().firstValue("Cache-Control")).contains("no-cache, no-transform"); + assertThat(response.headers().firstValue("X-Accel-Buffering")).contains("no"); + assertThat(response.headers().firstValue("Connection")).isEmpty(); assertThat(response.body()).contains("notifications/progress"); assertThat(response.body()).contains("\"result\""); } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java index 6f6517adc..a12f6c8da 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java @@ -182,6 +182,29 @@ void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) { } } + @Test + void sseResponseHasStreamingHeaders() throws Exception { + prepareAsyncServerBuilder().build(); + + var httpClient = HttpClient.newHttpClient(); + var sseRequest = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + CUSTOM_SSE_ENDPOINT)) + .header("Accept", "text/event-stream") + .GET() + .build(); + + var response = httpClient.send(sseRequest, HttpResponse.BodyHandlers.ofInputStream()); + try { + assertThat(response.headers().firstValue("Content-Type")).contains("text/event-stream;charset=UTF-8"); + assertThat(response.headers().firstValue("Cache-Control")).contains("no-cache, no-transform"); + assertThat(response.headers().firstValue("X-Accel-Buffering")).contains("no"); + assertThat(response.headers().firstValue("Connection")).isEmpty(); + } + finally { + response.body().close(); + } + } + @Test void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception { var httpClient = HttpClient.newHttpClient(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java index 99f693156..c8949f11d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -14,6 +14,7 @@ import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.time.Duration; +import java.util.List; import java.util.Map; import java.util.Queue; import java.util.concurrent.CompletableFuture; @@ -212,10 +213,72 @@ void testMissingHandlerReturnsMethodNotFoundError() { } } + @Test + void listeningStreamHasStreamingHeaders() throws Exception { + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); + var sessionId = initializeSession(httpClient); + + var get = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT)) + .header("Accept", "text/event-stream") + .header(HttpHeaders.MCP_SESSION_ID, sessionId) + .GET() + .build(); + + var response = httpClient.send(get, HttpResponse.BodyHandlers.ofInputStream()); + try { + assertSseHeaders(response); + } + finally { + response.body().close(); + } + } + + @Test + void toolCallSseResponseHasStreamingHeaders() throws Exception { + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .tools(McpServerFeatures.AsyncToolSpecification.builder() + .tool(McpSchema.Tool.builder("echo", EMPTY_JSON_SCHEMA).description("returns immediately").build()) + .callHandler((exchange, + request) -> Mono.just(McpSchema.CallToolResult.builder() + .content(List.of(McpSchema.TextContent.builder("ok").build())) + .isError(false) + .build())) + .build()) + .build(); + var sessionId = initializeSession(httpClient); + + var post = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json") + .header(HttpHeaders.MCP_SESSION_ID, sessionId) + .POST(HttpRequest.BodyPublishers + .ofString("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\",\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"echo\",\"arguments\":{}}}")) + .build(); + + var response = httpClient.send(post, HttpResponse.BodyHandlers.ofInputStream()); + try { + assertSseHeaders(response); + } + finally { + response.body().close(); + } + } + @Override protected void prepareClients(int port, String mcpEndpoint) { } + private static void assertSseHeaders(HttpResponse response) { + assertThat(response.headers().firstValue("Content-Type")).contains("text/event-stream;charset=UTF-8"); + assertThat(response.headers().firstValue("Cache-Control")).contains("no-cache, no-transform"); + assertThat(response.headers().firstValue("X-Accel-Buffering")).contains("no"); + assertThat(response.headers().firstValue("Connection")).isEmpty(); + } + static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext .create(Map.of("important", "value"));