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))