4949import org .junit .jupiter .api .Timeout ;
5050import org .junit .jupiter .params .provider .Arguments ;
5151import org .slf4j .LoggerFactory ;
52+ import reactor .core .publisher .Flux ;
5253import reactor .core .publisher .Mono ;
5354import reactor .test .StepVerifier ;
5455
56+ import static io .modelcontextprotocol .util .ToolsUtils .EMPTY_JSON_SCHEMA ;
5557import static java .nio .charset .StandardCharsets .UTF_8 ;
5658import static org .assertj .core .api .Assertions .assertThat ;
5759import 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