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 index 40add938c..f52737c7d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletResponseUtils.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletResponseUtils.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.server.transport; +import java.io.IOException; + import jakarta.servlet.http.HttpServletResponse; /** @@ -15,22 +17,23 @@ 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. + * Prepares the response for streaming Server-Sent Events: sets the content type and + * the headers that keep intermediaries from caching, transforming or buffering the + * stream. + *
+ * The headers are flushed immediately so the client receives something even when the
+ * server delays sending data.
* @param response The HTTP servlet response
*/
- static void prepareSseResponse(HttpServletResponse response) {
+ static void prepareSseResponse(HttpServletResponse response) throws IOException {
response.setContentType(TEXT_EVENT_STREAM);
- response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache, no-transform");
response.setHeader("X-Accel-Buffering", "no");
+ response.flushBuffer();
}
}
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 4bf2f0f47..a80d694be 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,13 +300,13 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
return;
}
- HttpServletResponseUtils.prepareSseResponse(response);
-
String sessionId = UUID.randomUUID().toString();
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);
+ response.setCharacterEncoding(UTF_8);
PrintWriter writer = response.getWriter();
+ HttpServletResponseUtils.prepareSseResponse(response);
// Create a new session transport
HttpServletMcpSessionTransport sessionTransport = new HttpServletMcpSessionTransport(sessionId, asyncContext,
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 7ce830499..ffc9cbdf4 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
@@ -425,14 +425,27 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
McpTransportContext transportContext = this.contextExtractor.extract(request);
+ PrintWriter writer;
+ AsyncContext asyncContext;
try {
- HttpServletResponseUtils.prepareSseResponse(response);
-
- AsyncContext asyncContext = request.startAsync();
+ response.setCharacterEncoding(UTF_8);
+ writer = response.getWriter();
+ asyncContext = request.startAsync();
asyncContext.setTimeout(0);
+ }
+ catch (Exception e) {
+ // Nothing has been written yet, so an error status can still be sent
+ logger.error("Failed to start the GET stream for session {}: {}", sessionId, e.getMessage());
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ return;
+ }
- HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
- sessionId, asyncContext, response.getWriter());
+ HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
+ sessionId, asyncContext, writer);
+ try {
+ // Send headers as soon as the stream is open: even if the server doesn't send
+ // data, the client will receive the headers
+ HttpServletResponseUtils.prepareSseResponse(response);
// Replay the messages the client missed while its stream was broken
String lastEventId = request.getHeader(HttpHeaders.LAST_EVENT_ID);
@@ -450,9 +463,14 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport);
}
+ catch (IOException e) {
+ // Only flushing the headers throws IOException
+ logger.debug("Failed to send the GET stream headers for session {}: {}", sessionId, e.getMessage());
+ sessionTransport.close();
+ }
catch (Exception e) {
logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage());
- response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ sessionTransport.close();
}
}
@@ -598,27 +616,48 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
}
else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
// For streaming responses, we need to return SSE
- HttpServletResponseUtils.prepareSseResponse(response);
-
- AsyncContext asyncContext = request.startAsync();
- asyncContext.setTimeout(0);
+ PrintWriter writer;
+ AsyncContext asyncContext;
+ try {
+ response.setCharacterEncoding(UTF_8);
+ writer = response.getWriter();
+ asyncContext = request.startAsync();
+ asyncContext.setTimeout(0);
+ }
+ catch (Exception e) {
+ // Nothing has been written yet, so an error status can still be sent
+ logger.error("Failed to start the request stream for session {}: {}", sessionId, e.getMessage());
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ return;
+ }
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
- sessionId, asyncContext, response.getWriter());
-
- // The listener is given the stream rather than its transport, so that the
- // end of the connection detaches the stream from the session instead of
- // only dropping the socket: a stream outliving its connection keeps the
- // session looking busy and spares it from the sweeper
- McpStreamableServerSession.McpStreamableServerSessionStream responseStream = session
- .responseStream(sessionTransport);
- registerAsyncLifecycle(asyncContext, sessionId, responseStream::releaseTransport);
-
+ sessionId, asyncContext, writer);
try {
+ // Send headers as soon as the stream is open: even if the server
+ // doesn't send
+ // data, the client will receive the headers
+ HttpServletResponseUtils.prepareSseResponse(response);
+
+ // The listener is given the stream rather than its transport, so that
+ // the end of the connection detaches the stream from the session
+ // instead of only dropping the socket: a stream outliving its
+ // connection keeps the session looking busy and spares it from the
+ // sweeper
+ McpStreamableServerSession.McpStreamableServerSessionStream responseStream = session
+ .responseStream(sessionTransport);
+ registerAsyncLifecycle(asyncContext, sessionId, responseStream::releaseTransport);
+
responseStream.handle(jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
}
+ catch (IOException e) {
+ // Only flushing the headers throws IOException
+ logger.debug("Failed to send the request stream headers for session {}: {}", sessionId,
+ e.getMessage());
+ sessionTransport.close();
+ }
catch (Exception e) {
logger.error("Failed to handle request stream: {}", e.getMessage());
// The transport may already have completed the async context, when
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 ad2a215fe..135e7ced5 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
@@ -19,6 +19,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;
@@ -39,6 +40,7 @@
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.util.KeepAliveScheduler;
+import io.modelcontextprotocol.util.ToolsUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.LifecycleException;
@@ -55,6 +57,7 @@
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import reactor.test.StepVerifier;
import reactor.test.scheduler.VirtualTimeScheduler;
@@ -205,8 +208,9 @@ void testMissingHandlerReturnsMethodNotFoundError() {
}
@Test
- void listeningStreamHasStreamingHeaders() throws Exception {
- prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
+ void listeningStreamSendsHeadersBeforeFirstEvent() throws Exception {
+ // Without keep-alive nothing is written, so only an early flush sends the headers
+ withTransportProvider(null, null);
var sessionId = initializeSession(httpClient);
var get = HttpRequest.newBuilder()
@@ -216,8 +220,10 @@ void listeningStreamHasStreamingHeaders() throws Exception {
.GET()
.build();
- var response = httpClient.send(get, HttpResponse.BodyHandlers.ofInputStream());
+ // Completes once the headers arrive, before any event
+ var response = httpClient.sendAsync(get, HttpResponse.BodyHandlers.ofInputStream()).get(5, TimeUnit.SECONDS);
try {
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK);
assertSseHeaders(response);
}
finally {
@@ -226,17 +232,28 @@ void listeningStreamHasStreamingHeaders() throws Exception {
}
@Test
- void toolCallSseResponseHasStreamingHeaders() throws Exception {
- prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
- .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
+ void toolCallSseResponseSendsHeadersBeforeResult() throws Exception {
+ mcpServerTransportProvider.closeGracefully().block();
+ mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder()
+ .contextExtractor(TEST_CONTEXT_EXTRACTOR)
+ .mcpEndpoint(MESSAGE_ENDPOINT)
+ // Without keep-alive nothing is written until the tool returns
+ .keepAliveInterval(null)
+ .sessionSweepInterval(null)
+ .build();
+
+ mcpServlet.setDelegate(mcpServerTransportProvider);
+
+ // Returns once the test completes toolResult
+ Sinks.One