Skip to content

Commit 48bc578

Browse files
committed
fix: flush GET stream headers as soon as the stream opens
The streamable HTTP GET stream only committed the response with the first event written to it, so clients waiting for the status line and SSE headers timed out (e.g. undici's default 300s headersTimeout) when the server had nothing to send yet. Commit the response buffer right after the async stream opens, so a 200 with text/event-stream headers is sent immediately. Fixes #1155
1 parent 1cf7903 commit 48bc578

2 files changed

Lines changed: 80 additions & 0 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,13 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
437437
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
438438
sessionId, asyncContext, response.getWriter());
439439

440+
// Commit the status line and SSE headers right away. Without an explicit
441+
// flush the
442+
// response is only sent together with the first event, so clients waiting for
443+
// the
444+
// stream headers time out when no event arrives.
445+
response.flushBuffer();
446+
440447
// Replay the messages the client missed while its stream was broken
441448
String lastEventId = request.getHeader(HttpHeaders.LAST_EVENT_ID);
442449
if (lastEventId != null
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.modelcontextprotocol.server.transport;
2+
3+
import java.io.PrintWriter;
4+
import java.util.Map;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
7+
import jakarta.servlet.AsyncContext;
8+
import jakarta.servlet.http.HttpServletRequest;
9+
import jakarta.servlet.http.HttpServletResponse;
10+
11+
import io.modelcontextprotocol.common.McpTransportContext;
12+
import io.modelcontextprotocol.spec.HttpHeaders;
13+
import io.modelcontextprotocol.spec.McpStreamableServerSession;
14+
import org.junit.jupiter.api.BeforeEach;
15+
import org.junit.jupiter.api.Test;
16+
17+
import static org.mockito.ArgumentMatchers.any;
18+
import static org.mockito.Mockito.mock;
19+
import static org.mockito.Mockito.verify;
20+
import static org.mockito.Mockito.when;
21+
22+
/**
23+
* Unit tests for the GET stream of {@link HttpServletStreamableServerTransportProvider}.
24+
*/
25+
class HttpServletStreamableServerTransportProviderTests {
26+
27+
private HttpServletStreamableServerTransportProvider provider;
28+
29+
private final Map<String, McpStreamableServerSession> sessions = new ConcurrentHashMap<>();
30+
31+
@BeforeEach
32+
void setUp() throws Exception {
33+
this.provider = HttpServletStreamableServerTransportProvider.builder()
34+
.jsonMapper(mock(io.modelcontextprotocol.json.McpJsonMapper.class))
35+
.mcpEndpoint("/mcp")
36+
.contextExtractor(request -> McpTransportContext.EMPTY)
37+
.build();
38+
39+
McpStreamableServerSession session = mock(McpStreamableServerSession.class);
40+
McpStreamableServerSession.McpStreamableServerSessionStream stream = mock(
41+
McpStreamableServerSession.McpStreamableServerSessionStream.class);
42+
when(session.listeningStream(any())).thenReturn(stream);
43+
44+
var sessionsField = HttpServletStreamableServerTransportProvider.class.getDeclaredField("sessions");
45+
sessionsField.setAccessible(true);
46+
@SuppressWarnings("unchecked")
47+
Map<String, McpStreamableServerSession> sessions = (Map<String, McpStreamableServerSession>) sessionsField
48+
.get(this.provider);
49+
sessions.put("session-1", session);
50+
}
51+
52+
@Test
53+
void getStreamCommitsResponseHeadersImmediately() throws Exception {
54+
HttpServletRequest request = mock(HttpServletRequest.class);
55+
when(request.getRequestURI()).thenReturn("/mcp");
56+
when(request.getHeader("Accept")).thenReturn("text/event-stream");
57+
when(request.getHeader(HttpHeaders.MCP_SESSION_ID)).thenReturn("session-1");
58+
when(request.getHeader("Last-Event-ID")).thenReturn(null);
59+
AsyncContext asyncContext = mock(AsyncContext.class);
60+
when(request.startAsync()).thenReturn(asyncContext);
61+
62+
HttpServletResponse response = mock(HttpServletResponse.class);
63+
when(response.getWriter()).thenReturn(mock(PrintWriter.class));
64+
65+
this.provider.doGet(request, response);
66+
67+
// The status line and the SSE headers must be committed as soon as the stream
68+
// opens,
69+
// before any event is written (see gh-1155)
70+
verify(response).flushBuffer();
71+
}
72+
73+
}

0 commit comments

Comments
 (0)