Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<InputStream> 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))
Expand Down