Skip to content

Commit dda52ff

Browse files
committed
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
1 parent 7b9b000 commit dda52ff

2 files changed

Lines changed: 52 additions & 0 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,15 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
434434
AsyncContext asyncContext = request.startAsync();
435435
asyncContext.setTimeout(0);
436436

437+
// Commit the status line and headers now. They were only sent together with
438+
// the
439+
// first SSE event, so a client that waits for the response before it starts
440+
// reading (Node's fetch/undici, mcp-remote) timed out on an idle server: the
441+
// request was accepted but nothing was written until the server had something
442+
// to say. Flushing here makes the stream visible immediately, the same way
443+
// HttpServletMcpTransport does for the stateless transport.
444+
response.flushBuffer();
445+
437446
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
438447
sessionId, asyncContext, response.getWriter());
439448

‎mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.Queue;
1919
import java.util.concurrent.CompletableFuture;
2020
import java.util.concurrent.ConcurrentLinkedQueue;
21+
import java.util.concurrent.TimeUnit;
2122
import java.util.concurrent.atomic.AtomicBoolean;
2223
import java.util.concurrent.atomic.AtomicReference;
2324
import java.util.function.Function;
@@ -664,6 +665,48 @@ private int postNotification(HttpClient httpClient, String sessionId) {
664665
}
665666
}
666667

668+
/**
669+
* A client that waits for the response before it starts reading events must not hang
670+
* when the server has nothing to send yet. The GET handler used to leave the response
671+
* uncommitted until the first SSE event was written, so an idle server never answered
672+
* and clients such as Node's fetch/undici or mcp-remote timed out waiting for the
673+
* headers.
674+
*/
675+
@Test
676+
void listeningStreamCommitsResponseBeforeFirstEvent() throws Exception {
677+
// No keep-alive on this provider: nothing will ever be written to the stream, so
678+
// the
679+
// request can only complete if the server commits the response itself.
680+
var provider = HttpServletStreamableServerTransportProvider.builder()
681+
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
682+
.mcpEndpoint(MESSAGE_ENDPOINT)
683+
.build();
684+
MCP_SERVLET.setDelegate(provider);
685+
var server = McpServer.sync(provider).serverInfo("test-server", "1.0.0").build();
686+
try {
687+
String sessionId = initializeSession(httpClient);
688+
assertThat(sessionId).isNotNull();
689+
690+
var get = HttpRequest.newBuilder()
691+
.uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
692+
.header("Accept", "text/event-stream")
693+
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
694+
.GET()
695+
.build();
696+
697+
HttpResponse<InputStream> response = httpClient.sendAsync(get, HttpResponse.BodyHandlers.ofInputStream())
698+
.get(5, TimeUnit.SECONDS);
699+
700+
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK);
701+
assertThat(response.headers().firstValue("Content-Type").orElse("")).contains("text/event-stream");
702+
response.body().close();
703+
}
704+
finally {
705+
server.closeGracefully();
706+
MCP_SERVLET.setDelegate(mcpServerTransportProvider);
707+
}
708+
}
709+
667710
private String initializeSession(HttpClient httpClient) {
668711
var initialize = HttpRequest.newBuilder()
669712
.uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))

0 commit comments

Comments
 (0)