Skip to content

Commit dbde226

Browse files
committed
Fix hanging disconnected client during POST
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent fe4e821 commit dbde226

4 files changed

Lines changed: 258 additions & 39 deletions

File tree

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import io.modelcontextprotocol.spec.McpStreamableServerSession;
2727
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
2828
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
29+
import io.modelcontextprotocol.spec.McpTransportException;
2930
import io.modelcontextprotocol.spec.ProtocolVersions;
3031
import io.modelcontextprotocol.util.Assert;
3132
import io.modelcontextprotocol.util.KeepAliveScheduler;
@@ -593,10 +594,17 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
593594

594595
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
595596
sessionId, asyncContext, response.getWriter());
596-
registerAsyncLifecycle(asyncContext, sessionId, sessionTransport::close);
597+
598+
// The listener is given the stream rather than its transport, so that the
599+
// end of the connection detaches the stream from the session instead of
600+
// only dropping the socket: a stream outliving its connection keeps the
601+
// session looking busy and spares it from the sweeper
602+
McpStreamableServerSession.McpStreamableServerSessionStream responseStream = session
603+
.responseStream(sessionTransport);
604+
registerAsyncLifecycle(asyncContext, sessionId, responseStream::releaseTransport);
597605

598606
try {
599-
session.responseStream(jsonrpcRequest, sessionTransport)
607+
responseStream.handle(jsonrpcRequest)
600608
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
601609
.block();
602610
}
@@ -887,9 +895,16 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId
887895
}
888896
catch (Exception e) {
889897
// The connection is gone, the session is not: the client may come
890-
// back for it, and the idle timeout reclaims it if it never does
898+
// back for it, and the sweeper reclaims it if it never does
891899
logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage());
892900
this.close();
901+
// Surfaced to the caller rather than swallowed: whoever is writing to
902+
// this stream has to learn that it no longer leads anywhere, or it
903+
// keeps producing messages for a client which is gone. A request
904+
// being streamed a response would never finish, holding on to the
905+
// container thread which has to be given back before the end of the
906+
// connection can be acted upon.
907+
throw new McpTransportException("Failed to send message to session " + this.sessionId, e);
893908
}
894909
finally {
895910
lock.unlock();

mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -250,45 +250,35 @@ public Flux<McpSchema.JSONRPCMessage> replay(Object lastEventId) {
250250
return Flux.empty();
251251
}
252252

253+
/**
254+
* Create a response stream (the SSE stream of a single HTTP POST request, finalized
255+
* with the response to the request it carries). The caller owns the returned stream
256+
* and is responsible for releasing it once the connection behind it ends, the same
257+
* way it does for a {@link #listeningStream(McpStreamableServerTransport)}: a stream
258+
* outliving its connection keeps the session looking busy, see
259+
* {@link #hasOpenStream()}.
260+
* @param transport the SSE transport stream to send messages to
261+
* @return a stream representation, on which
262+
* {@link McpStreamableServerSessionStream#handle(McpSchema.JSONRPCRequest)} runs the
263+
* request
264+
*/
265+
public McpStreamableServerSessionStream responseStream(McpStreamableServerTransport transport) {
266+
return new McpStreamableServerSessionStream(transport);
267+
}
268+
253269
/**
254270
* Provide the SSE stream of MCP messages finalized with a Response.
255271
* @param jsonrpcRequest the MCP request triggering the stream creation
256272
* @param transport the SSE transport stream to send messages to
257273
* @return Mono which completes once the processing is done
274+
* @deprecated the stream created for the request is not exposed, which leaves the
275+
* caller unable to release it when the connection carrying it ends. Use
276+
* {@link #responseStream(McpStreamableServerTransport)} and
277+
* {@link McpStreamableServerSessionStream#handle(McpSchema.JSONRPCRequest)} instead.
258278
*/
279+
@Deprecated
259280
public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStreamableServerTransport transport) {
260-
return Mono.deferContextual(ctx -> {
261-
McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
262-
263-
McpStreamableServerSessionStream stream = new McpStreamableServerSessionStream(transport);
264-
McpRequestHandler<?> requestHandler = McpStreamableServerSession.this.requestHandlers
265-
.get(jsonrpcRequest.method());
266-
if (requestHandler == null) {
267-
MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method());
268-
return transport
269-
.sendMessage(
270-
McpSchema.JSONRPCResponse
271-
.error(jsonrpcRequest.id(),
272-
new McpSchema.JSONRPCResponse.JSONRPCError(
273-
McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data())))
274-
.then(stream.closeGracefully());
275-
}
276-
return requestHandler
277-
.handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(),
278-
transportContext, this.jsonSchemaValidator), jsonrpcRequest.params())
279-
.map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result))
280-
.onErrorResume(e -> {
281-
McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError
282-
&& mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError()
283-
: new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
284-
e.getMessage(), McpError.aggregateExceptionMessages(e));
285-
286-
var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError);
287-
return Mono.just(errorResponse);
288-
})
289-
.flatMap(transport::sendMessage)
290-
.then(stream.closeGracefully());
291-
});
281+
return Mono.defer(() -> this.responseStream(transport).handle(jsonrpcRequest));
292282
}
293283

294284
/**
@@ -509,6 +499,50 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
509499
});
510500
}
511501

502+
/**
503+
* Runs the request this stream was created for, sending its messages to the
504+
* client and finalizing the stream with the response.
505+
* <p>
506+
* The stream is detached from the session once the request is done with it,
507+
* whatever the outcome: a response, an error, or the caller giving up.
508+
* @param jsonrpcRequest the MCP request this stream carries the response of
509+
* @return Mono which completes once the processing is done
510+
*/
511+
public Mono<Void> handle(McpSchema.JSONRPCRequest jsonrpcRequest) {
512+
// The stream is released whichever way the request ends: with a response, on
513+
// an error, or by the caller giving up on it
514+
return Mono.usingWhen(Mono.just(this), stream -> Mono.deferContextual(ctx -> {
515+
McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY,
516+
McpTransportContext.EMPTY);
517+
518+
McpRequestHandler<?> requestHandler = McpStreamableServerSession.this.requestHandlers
519+
.get(jsonrpcRequest.method());
520+
if (requestHandler == null) {
521+
MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method());
522+
return this.connection.sendMessage(McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(),
523+
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
524+
error.message(), error.data())));
525+
}
526+
return requestHandler
527+
.handle(new McpAsyncServerExchange(McpStreamableServerSession.this.id, this,
528+
clientCapabilities.get(), clientInfo.get(), transportContext,
529+
McpStreamableServerSession.this.jsonSchemaValidator), jsonrpcRequest.params())
530+
.map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result))
531+
.onErrorResume(e -> {
532+
McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError
533+
&& mcpError.getJsonRpcError() != null)
534+
? mcpError.getJsonRpcError()
535+
: new McpSchema.JSONRPCResponse.JSONRPCError(
536+
McpSchema.ErrorCodes.INTERNAL_ERROR, e.getMessage(),
537+
McpError.aggregateExceptionMessages(e));
538+
539+
var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError);
540+
return Mono.just(errorResponse);
541+
})
542+
.flatMap(this.connection::sendMessage);
543+
}), McpStreamableServerSessionStream::closeGracefully);
544+
}
545+
512546
@Override
513547
public Mono<Void> sendNotification(String method, Object params) {
514548
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params);

mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.concurrent.ConcurrentLinkedQueue;
1111

1212
import io.modelcontextprotocol.json.TypeRef;
13+
import io.modelcontextprotocol.server.McpRequestHandler;
1314
import org.junit.jupiter.api.Test;
1415
import reactor.core.publisher.Mono;
1516

@@ -23,8 +24,12 @@ class McpStreamableServerSessionTests {
2324
private static final Duration TIMEOUT = Duration.ofSeconds(5);
2425

2526
private McpStreamableServerSession session() {
27+
return session(Map.of());
28+
}
29+
30+
private McpStreamableServerSession session(Map<String, McpRequestHandler<?>> requestHandlers) {
2631
return new McpStreamableServerSession("session-1", McpSchema.ClientCapabilities.builder().build(),
27-
new McpSchema.Implementation("test-client", "1.0.0"), TIMEOUT, Map.of(), Map.of());
32+
new McpSchema.Implementation("test-client", "1.0.0"), TIMEOUT, requestHandlers, Map.of());
2833
}
2934

3035
@Test
@@ -139,6 +144,81 @@ void closingAStreamFailsOnlyItsOwnPendingRequests() {
139144
assertThat(onListeningStream).succeedsWithin(TIMEOUT).isEqualTo("response-value");
140145
}
141146

147+
@Test
148+
void endOfTheConnectionCarryingAResponseStreamDetachesItFromTheSession() {
149+
// A request whose handler never completes, as seen when a client gives up and
150+
// disconnects while the server is still working on its tool call
151+
var session = session(Map.of("tools/call", (exchange, params) -> Mono.never()));
152+
var transport = new RecordingTransport();
153+
154+
// The caller owns the stream, so it can detach it from the session once the
155+
// container tells it the connection carrying it is gone
156+
var stream = session.responseStream(transport);
157+
stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).subscribe();
158+
assertThat(session.hasOpenStream()).isTrue();
159+
160+
stream.releaseTransport();
161+
162+
// The session must stop believing it holds a live connection: hasOpenStream() is
163+
// what tells the session sweeper that a client is still around, so a stream which
164+
// outlives its connection makes the session impossible to reclaim
165+
assertThat(session.hasOpenStream()).isFalse();
166+
assertThat(transport.closed).isTrue();
167+
}
168+
169+
@Test
170+
void responseStreamIsDetachedFromTheSessionOnceItsRequestIsAnswered() {
171+
var session = session(Map.of("tools/call", (exchange, params) -> Mono.just("result")));
172+
var transport = new RecordingTransport();
173+
174+
var stream = session.responseStream(transport);
175+
assertThat(session.hasOpenStream()).isTrue();
176+
177+
stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).block(TIMEOUT);
178+
179+
assertThat(session.hasOpenStream()).isFalse();
180+
}
181+
182+
@Test
183+
void responseStreamIsDetachedFromTheSessionWhenItsResponseCannotBeSent() {
184+
var session = session(Map.of("tools/call", (exchange, params) -> Mono.just("result")));
185+
186+
var stream = session.responseStream(new FailingTransport());
187+
var handling = stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).toFuture();
188+
189+
// The connection which was to carry the response failed. The caller gets to see
190+
// it, and the stream must not be left attached to the session.
191+
assertThat(handling).failsWithin(TIMEOUT).withThrowableThat().havingCause().withMessage("connection gone");
192+
assertThat(session.hasOpenStream()).isFalse();
193+
}
194+
195+
@Test
196+
void abandonedResponseStreamIsDetachedFromTheSession() {
197+
var session = session(Map.of("tools/call", (exchange, params) -> Mono.never()));
198+
199+
var stream = session.responseStream(new RecordingTransport());
200+
var subscription = stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).subscribe();
201+
assertThat(session.hasOpenStream()).isTrue();
202+
203+
// The caller gives up on a request which would never terminate on its own, so
204+
// nothing sends the response the stream was created to carry
205+
subscription.dispose();
206+
207+
assertThat(session.hasOpenStream()).isFalse();
208+
}
209+
210+
/**
211+
* A transport whose connection is gone, so that nothing can be written to it.
212+
*/
213+
static class FailingTransport extends RecordingTransport {
214+
215+
@Override
216+
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId) {
217+
return Mono.error(new RuntimeException("connection gone"));
218+
}
219+
220+
}
221+
142222
static class RecordingTransport implements McpStreamableServerTransport {
143223

144224
final Queue<McpSchema.JSONRPCMessage> sent = new ConcurrentLinkedQueue<>();

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

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@
4949
import org.junit.jupiter.api.Timeout;
5050
import org.junit.jupiter.params.provider.Arguments;
5151
import org.slf4j.LoggerFactory;
52+
import reactor.core.publisher.Flux;
5253
import reactor.core.publisher.Mono;
5354
import reactor.test.StepVerifier;
5455

56+
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
5557
import static java.nio.charset.StandardCharsets.UTF_8;
5658
import static org.assertj.core.api.Assertions.assertThat;
5759
import static org.awaitility.Awaitility.await;
@@ -70,9 +72,13 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg
7072

7173
private HttpServletStreamableServerTransportProvider mcpServerTransportProvider;
7274

73-
private final Duration KEEP_ALIVE_INTERVAL = Duration.ofMillis(200);
75+
// Keep alive is fast. A ping failure releases the stream, so listening
76+
// steams are released quickly.
77+
private final Duration KEEP_ALIVE_INTERVAL = Duration.ofMillis(150);
7478

75-
private final Duration SESSION_SWEEP_INTERVAL = Duration.ofMillis(200);
79+
// Sweeping is slower than keep-alive, so that a failed ping doesn't immediately
80+
// result in a session sweep
81+
private final Duration SESSION_SWEEP_INTERVAL = KEEP_ALIVE_INTERVAL.multipliedBy(2);
7682

7783
@Override
7884
protected void awaitClientStreamEstablished() {
@@ -463,6 +469,90 @@ void sessionIsNotEvictedWithoutSweepInterval() throws Exception {
463469
assertThat(postNotification(httpClient, sessionId)).isEqualTo(HttpServletResponse.SC_ACCEPTED);
464470
}
465471

472+
/**
473+
* A client which aborts a tool call must not leave its session behind.
474+
*/
475+
@Test
476+
void sessionIsEvictedWhenTheClientAbortsAResponseStream() throws Exception {
477+
mcpServerTransportProvider.closeGracefully().block();
478+
mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder()
479+
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
480+
.mcpEndpoint(MESSAGE_ENDPOINT)
481+
// remove keepalive, so only the response stream can keep the session alive
482+
.keepAliveInterval(null)
483+
.sessionSweepInterval(SESSION_SWEEP_INTERVAL)
484+
.build();
485+
MCP_SERVLET.setDelegate(mcpServerTransportProvider);
486+
487+
// A tool which never returns but keeps writing to its response stream. The
488+
// payloads are large and frequent on purpose: writes to a connection whose peer
489+
// is gone keep succeeding until the socket buffer fills up, and that is the only
490+
// thing which can surface the disconnect here.
491+
prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
492+
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
493+
.tools(McpServerFeatures.AsyncToolSpecification.builder()
494+
.tool(McpSchema.Tool.builder("hangs", EMPTY_JSON_SCHEMA).description("never returns").build())
495+
.callHandler((exchange, request) -> Flux.interval(Duration.ofMillis(10))
496+
.flatMap(tick -> exchange.loggingNotification(McpSchema.LoggingMessageNotification.builder()
497+
.level(McpSchema.LoggingLevel.INFO)
498+
.data("x".repeat(64 * 1024))
499+
.build()))
500+
.then(Mono.<McpSchema.CallToolResult>never()))
501+
.build())
502+
.build();
503+
504+
var httpClient = HttpClient.newHttpClient();
505+
var sessionId = initializeSession(httpClient);
506+
507+
// The POST opens a response SSE stream, which the session counts as an open
508+
// stream for as long as the call is in flight
509+
var responseStream = postToolCall(httpClient, sessionId, "hangs");
510+
await().atMost(Duration.ofSeconds(5))
511+
.untilAsserted(
512+
() -> assertThat(responseStream.events()).anyMatch(line -> line.contains("notifications/message")));
513+
assertThat(postNotification(httpClient, sessionId)).isEqualTo(HttpServletResponse.SC_ACCEPTED);
514+
515+
// The client gives up on the call and disconnects
516+
responseStream.closeStream();
517+
518+
// Nothing is connected to the session anymore, so the sweeper must reclaim it.
519+
// Probing only once: any request would count as activity and reset the clock.
520+
Thread.sleep(SESSION_SWEEP_INTERVAL.multipliedBy(2).toMillis());
521+
522+
assertThat(postNotification(httpClient, sessionId)).isEqualTo(HttpServletResponse.SC_NOT_FOUND);
523+
}
524+
525+
/**
526+
* Calls a tool with a POST request, returning a handle on the SSE response stream it
527+
* opens.
528+
*/
529+
private StreamResponse postToolCall(HttpClient httpClient, String sessionId, String toolName) {
530+
var post = HttpRequest.newBuilder()
531+
.uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
532+
.header("Content-Type", "application/json")
533+
.header("Accept", "text/event-stream, application/json")
534+
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
535+
.POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\","
536+
+ "\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\",\"arguments\":{}}}"))
537+
.build();
538+
Queue<String> events = new ConcurrentLinkedQueue<>();
539+
var streamRef = new AtomicReference<InputStream>();
540+
var clientFuture = httpClient.sendAsync(post, HttpResponse.BodyHandlers.ofInputStream())
541+
.thenAccept(response -> {
542+
streamRef.set(response.body());
543+
try (var r = new BufferedReader(new InputStreamReader(response.body(), UTF_8))) {
544+
String l;
545+
while ((l = r.readLine()) != null) {
546+
events.add(l);
547+
}
548+
}
549+
catch (IOException e) {
550+
// "closed" here is our own closeStream(), not a failure
551+
}
552+
});
553+
return new StreamResponse(clientFuture, events, streamRef);
554+
}
555+
466556
private int postNotification(HttpClient httpClient, String sessionId) throws Exception {
467557
var notification = HttpRequest.newBuilder()
468558
.uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
@@ -533,7 +623,7 @@ record StreamResponse(CompletableFuture<Void> streamFuture, Queue<String> events
533623
void closeStream() {
534624
// Close listening stream. We retry a few times in case the stream was not
535625
// established on the first try
536-
await().atMost(Duration.ofSeconds(1)).until(() -> {
626+
await().pollDelay(Duration.ZERO).atMost(Duration.ofSeconds(1)).until(() -> {
537627
var stream = streamRef.get();
538628
if (stream != null) {
539629
stream.close();

0 commit comments

Comments
 (0)