55package io .modelcontextprotocol .spec ;
66
77import java .time .Duration ;
8+ import java .util .ArrayList ;
89import java .util .List ;
910import java .util .Map ;
1011import java .util .Set ;
1112import java .util .UUID ;
1213import java .util .concurrent .ConcurrentHashMap ;
1314import java .util .concurrent .atomic .AtomicLong ;
1415import java .util .concurrent .atomic .AtomicReference ;
16+ import java .util .function .Predicate ;
1517import java .util .function .Supplier ;
1618
1719import io .modelcontextprotocol .common .McpTransportContext ;
@@ -41,7 +43,14 @@ public class McpStreamableServerSession implements McpLoggableSession {
4143
4244 private static final Logger logger = LoggerFactory .getLogger (McpStreamableServerSession .class );
4345
44- private final ConcurrentHashMap <String , McpStreamableServerSessionStream > requestIdToStream = new ConcurrentHashMap <>();
46+ /**
47+ * Every server-initiated request still awaiting its response, keyed by request ID.
48+ * Tracked per session rather than per stream, because the client answers over a
49+ * separate HTTP POST which outlives the stream the request was sent on: a stream
50+ * going away must not lose the requests it carried, but the end of the session must
51+ * resolve all of them.
52+ */
53+ private final ConcurrentHashMap <String , PendingRequest > pendingRequests = new ConcurrentHashMap <>();
4554
4655 /**
4756 * Every stream with a connection currently attached, whether the listening stream or
@@ -313,22 +322,13 @@ public Mono<Void> accept(McpSchema.JSONRPCResponse response) {
313322 logger .debug ("Received response: {}" , response );
314323
315324 if (response .id () != null ) {
316- var stream = this .requestIdToStream .get (response .id ());
317- if (stream == null ) {
318- return Mono .error (McpError .builder (ErrorCodes .INTERNAL_ERROR )
319- .message ("Unexpected response for unknown id " + response .id ())
320- .build ());
321- }
322- // TODO: encapsulate this inside the stream itself
323- var sink = stream .pendingResponses .remove (response .id ());
324- if (sink == null ) {
325+ var pendingRequest = this .pendingRequests .remove (response .id ());
326+ if (pendingRequest == null ) {
325327 return Mono .error (McpError .builder (ErrorCodes .INTERNAL_ERROR )
326328 .message ("Unexpected response for unknown id " + response .id ())
327329 .build ());
328330 }
329- else {
330- sink .success (response );
331- }
331+ pendingRequest .sink ().success (response );
332332 }
333333 else {
334334 logger .error ("Discarded MCP request response without session id. "
@@ -346,13 +346,38 @@ private MethodNotFoundError getMethodNotFoundError(String method) {
346346 return new MethodNotFoundError (method , "Method not found: " + method , null );
347347 }
348348
349+ /**
350+ * Fails the matching pending requests with the given reason. Each request is removed
351+ * before its sink is failed, so that the cleanup the failure triggers on the
352+ * requesting side finds nothing left to remove.
353+ * @param match selects the requests to fail
354+ * @param reason the message of the error the sinks are failed with
355+ */
356+ private void failPendingRequests (Predicate <PendingRequest > match , String reason ) {
357+ List <String > requestIds = new ArrayList <>();
358+ this .pendingRequests .forEach ((requestId , pendingRequest ) -> {
359+ if (match .test (pendingRequest )) {
360+ requestIds .add (requestId );
361+ }
362+ });
363+ for (String requestId : requestIds ) {
364+ PendingRequest pendingRequest = this .pendingRequests .remove (requestId );
365+ if (pendingRequest != null ) {
366+ pendingRequest .sink ().error (new RuntimeException (reason ));
367+ }
368+ }
369+ }
370+
349371 @ Override
350372 public Mono <Void > closeGracefully () {
351373 return this .onClose .get ().onErrorComplete ().then (Mono .defer (() -> {
352374 this .listeningStreamRef .set (this .missingMcpTransportSession );
353375 return Flux .fromIterable (List .copyOf (this .openStreams ))
354376 .flatMap (McpStreamableServerSessionStream ::closeGracefully )
355- .then ();
377+ .then ()
378+ // Also the requests of the streams already released: the session ending
379+ // is the point at which no client can answer them anymore
380+ .then (Mono .fromRunnable (() -> this .failPendingRequests (request -> true , "Session closed" )));
356381 }));
357382 }
358383
@@ -361,6 +386,9 @@ public void close() {
361386 this .onClose .get ().onErrorComplete ().subscribe ();
362387 this .listeningStreamRef .set (this .missingMcpTransportSession );
363388 List .copyOf (this .openStreams ).forEach (McpStreamableServerSessionStream ::close );
389+ // Also the requests of the streams already released: the session ending is the
390+ // point at which no client can answer them anymore
391+ this .failPendingRequests (request -> true , "Session closed" );
364392 }
365393
366394 /**
@@ -402,14 +430,22 @@ public record McpStreamableServerSessionInit(McpStreamableServerSession session,
402430 Mono <McpSchema .InitializeResult > initResult ) {
403431 }
404432
433+ /**
434+ * A server-initiated request awaiting its response, along with the stream it was sent
435+ * on.
436+ *
437+ * @param stream the stream the request was sent on
438+ * @param sink the sink to resolve once the response arrives
439+ */
440+ private record PendingRequest (McpStreamableServerSessionStream stream , MonoSink <McpSchema .JSONRPCResponse > sink ) {
441+ }
442+
405443 /**
406444 * An individual SSE stream within a Streamable HTTP context. Can be either the
407445 * listening GET SSE stream or a request-specific POST SSE stream.
408446 */
409447 public final class McpStreamableServerSessionStream implements McpLoggableSession {
410448
411- private final ConcurrentHashMap <String , MonoSink <McpSchema .JSONRPCResponse >> pendingResponses = new ConcurrentHashMap <>();
412-
413449 private final McpStreamableServerTransport connection ;
414450
415451 private final String transportId ;
@@ -444,10 +480,10 @@ public boolean isNotificationForLevelAllowed(McpSchema.LoggingLevel loggingLevel
444480 public <T > Mono <T > sendRequest (String method , Object requestParams , TypeRef <T > typeRef ) {
445481 String requestId = McpStreamableServerSession .this .generateRequestId ();
446482
447- McpStreamableServerSession .this .requestIdToStream .put (requestId , this );
448-
449483 return Mono .<McpSchema .JSONRPCResponse >create (sink -> {
450- this .pendingResponses .put (requestId , sink );
484+ // Registered on subscription rather than on assembly, so that a request
485+ // which is never sent does not leave the session tracking it forever
486+ McpStreamableServerSession .this .pendingRequests .put (requestId , new PendingRequest (this , sink ));
451487 McpSchema .JSONRPCRequest jsonrpcRequest = new McpSchema .JSONRPCRequest (method , requestId ,
452488 requestParams );
453489 String messageId = this .uuidGenerator .get ();
@@ -457,8 +493,7 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
457493 }).timeout (requestTimeout ).doFinally (signal -> {
458494 // Also on completion and cancellation: a resolved request keeps no state,
459495 // and a deadline imposed by the caller cancels rather than errors
460- this .pendingResponses .remove (requestId );
461- McpStreamableServerSession .this .requestIdToStream .remove (requestId );
496+ McpStreamableServerSession .this .pendingRequests .remove (requestId );
462497 }).handle ((jsonRpcResponse , sink ) -> {
463498 if (jsonRpcResponse .error () != null ) {
464499 sink .error (new McpError (jsonRpcResponse .error ()));
@@ -486,25 +521,23 @@ public Mono<Void> sendNotification(String method, Object params) {
486521 public Mono <Void > closeGracefully () {
487522 return Mono .defer (() -> {
488523 McpStreamableServerSession .this .openStreams .remove (this );
489- this .pendingResponses . values (). forEach ( s -> s . error ( new RuntimeException ( "Stream closed" )));
490- this . pendingResponses . clear ( );
524+ McpStreamableServerSession . this .failPendingRequests ( request -> this . equals ( request . stream ()),
525+ "Stream closed" );
491526 // If this was the generic stream, reset it
492527 McpStreamableServerSession .this .listeningStreamRef .compareAndExchange (this ,
493528 McpStreamableServerSession .this .missingMcpTransportSession );
494- McpStreamableServerSession .this .requestIdToStream .values ().removeIf (this ::equals );
495529 return this .connection .closeGracefully ();
496530 });
497531 }
498532
499533 @ Override
500534 public void close () {
501535 McpStreamableServerSession .this .openStreams .remove (this );
502- this .pendingResponses . values (). forEach ( s -> s . error ( new RuntimeException ( "Stream closed" )));
503- this . pendingResponses . clear ( );
536+ McpStreamableServerSession . this .failPendingRequests ( request -> this . equals ( request . stream ()),
537+ "Stream closed" );
504538 // If this was the generic stream, reset it
505539 McpStreamableServerSession .this .listeningStreamRef .compareAndExchange (this ,
506540 McpStreamableServerSession .this .missingMcpTransportSession );
507- McpStreamableServerSession .this .requestIdToStream .values ().removeIf (this ::equals );
508541 this .connection .close ();
509542 }
510543
0 commit comments