From dda52ff6e9a4eddc69e5ffc224e67e507a049cff Mon Sep 17 00:00:00 2001 From: Lyin-OTTF <3037869661@qq.com> Date: Sun, 18 Oct 2026 22:23:21 +0800 Subject: [PATCH] fix(server): commit the SSE response before the first event on GET The streamable servlet's GET handler set the SSE headers, started async, and then waited for the session to write something. The container only commits the response when the first event is written, so a client that waits for the response before it starts reading - Node's fetch/undici, mcp-remote - gave up on an idle server with UND_ERR_HEADERS_TIMEOUT, retried twice, and ended up with no listening stream at all. Flush the response right after startAsync so the status line and headers reach the client immediately, the same way HttpServletMcpTransport does for the stateless transport. Refs #1155 --- ...vletStreamableServerTransportProvider.java | 9 ++++ ...HttpServletStreamableIntegrationTests.java | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+) 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..c008efb60 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 @@ -434,6 +434,15 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) AsyncContext asyncContext = request.startAsync(); asyncContext.setTimeout(0); + // Commit the status line and headers now. They were only sent together with + // the + // first SSE event, so a client that waits for the response before it starts + // reading (Node's fetch/undici, mcp-remote) timed out on an idle server: the + // request was accepted but nothing was written until the server had something + // to say. Flushing here makes the stream visible immediately, the same way + // HttpServletMcpTransport does for the stateless transport. + response.flushBuffer(); + HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport( sessionId, asyncContext, response.getWriter()); 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..72abf315b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -18,6 +18,7 @@ import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; @@ -664,6 +665,48 @@ private int postNotification(HttpClient httpClient, String sessionId) { } } + /** + * A client that waits for the response before it starts reading events must not hang + * when the server has nothing to send yet. The GET handler used to leave the response + * uncommitted until the first SSE event was written, so an idle server never answered + * and clients such as Node's fetch/undici or mcp-remote timed out waiting for the + * headers. + */ + @Test + void listeningStreamCommitsResponseBeforeFirstEvent() throws Exception { + // No keep-alive on this provider: nothing will ever be written to the stream, so + // the + // request can only complete if the server commits the response itself. + var provider = HttpServletStreamableServerTransportProvider.builder() + .contextExtractor(TEST_CONTEXT_EXTRACTOR) + .mcpEndpoint(MESSAGE_ENDPOINT) + .build(); + MCP_SERVLET.setDelegate(provider); + var server = McpServer.sync(provider).serverInfo("test-server", "1.0.0").build(); + try { + String sessionId = initializeSession(httpClient); + assertThat(sessionId).isNotNull(); + + 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(); + + HttpResponse response = httpClient.sendAsync(get, HttpResponse.BodyHandlers.ofInputStream()) + .get(5, TimeUnit.SECONDS); + + assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.headers().firstValue("Content-Type").orElse("")).contains("text/event-stream"); + response.body().close(); + } + finally { + server.closeGracefully(); + MCP_SERVLET.setDelegate(mcpServerTransportProvider); + } + } + private String initializeSession(HttpClient httpClient) { var initialize = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))