55package io .modelcontextprotocol .spec ;
66
77import java .time .Duration ;
8+ import java .util .List ;
89import java .util .Map ;
10+ import java .util .Set ;
911import java .util .UUID ;
1012import java .util .concurrent .ConcurrentHashMap ;
1113import java .util .concurrent .atomic .AtomicLong ;
1214import java .util .concurrent .atomic .AtomicReference ;
1315import java .util .function .Supplier ;
1416
15- import org .slf4j .Logger ;
16- import org .slf4j .LoggerFactory ;
17-
18- import io .modelcontextprotocol .json .TypeRef ;
19-
2017import io .modelcontextprotocol .common .McpTransportContext ;
18+ import io .modelcontextprotocol .json .TypeRef ;
2119import io .modelcontextprotocol .json .schema .JsonSchemaValidator ;
2220import io .modelcontextprotocol .server .McpAsyncServerExchange ;
2321import io .modelcontextprotocol .server .McpNotificationHandler ;
2422import io .modelcontextprotocol .server .McpRequestHandler ;
2523import io .modelcontextprotocol .spec .McpSchema .ErrorCodes ;
2624import io .modelcontextprotocol .util .Assert ;
25+ import org .slf4j .Logger ;
26+ import org .slf4j .LoggerFactory ;
2727import reactor .core .publisher .Flux ;
2828import reactor .core .publisher .Mono ;
2929import reactor .core .publisher .MonoSink ;
@@ -43,6 +43,12 @@ public class McpStreamableServerSession implements McpLoggableSession {
4343
4444 private final ConcurrentHashMap <Object , McpStreamableServerSessionStream > requestIdToStream = new ConcurrentHashMap <>();
4545
46+ /**
47+ * Every stream with a connection currently attached, whether the listening stream or
48+ * a POST response stream, so that they can all be released when the session ends.
49+ */
50+ private final Set <McpStreamableServerSessionStream > openStreams = ConcurrentHashMap .newKeySet ();
51+
4652 private final String id ;
4753
4854 private final Duration requestTimeout ;
@@ -201,12 +207,24 @@ public McpStreamableServerSessionStream listeningStream(McpStreamableServerTrans
201207 McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream (transport );
202208 McpLoggableSession replaced = this .listeningStreamRef .getAndSet (listeningStream );
203209 if (replaced instanceof McpStreamableServerSessionStream replacedStream ) {
204- logger .debug ("Closing the listening stream replaced in session {}" , this .id );
205- replacedStream .close ();
210+ logger .debug ("Releasing the connection of the listening stream replaced in session {}" , this .id );
211+ replacedStream .releaseTransport ();
206212 }
207213 return listeningStream ;
208214 }
209215
216+ /**
217+ * Releases the connection of the listening stream, if one is attached, leaving the
218+ * session without one until the client establishes a new stream. Used when the
219+ * connection turns out to be dead, typically because a keep-alive ping went
220+ * unanswered, so that the socket behind it is not held on to for nothing.
221+ */
222+ public void releaseListeningStream () {
223+ if (this .listeningStreamRef .get () instanceof McpStreamableServerSessionStream stream ) {
224+ stream .releaseTransport ();
225+ }
226+ }
227+
210228 // TODO: keep track of history by keeping a map from eventId to stream and then
211229 // iterate over the events using the lastEventId
212230 public Flux <McpSchema .JSONRPCMessage > replay (Object lastEventId ) {
@@ -226,9 +244,6 @@ public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
226244 McpStreamableServerSessionStream stream = new McpStreamableServerSessionStream (transport );
227245 McpRequestHandler <?> requestHandler = McpStreamableServerSession .this .requestHandlers
228246 .get (jsonrpcRequest .method ());
229- // TODO: delegate to stream, which upon successful response should close
230- // remove itself from the registry and also close the underlying transport
231- // (sink)
232247 if (requestHandler == null ) {
233248 MethodNotFoundError error = getMethodNotFoundError (jsonrpcRequest .method ());
234249 return transport
@@ -237,7 +252,7 @@ public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
237252 .error (jsonrpcRequest .id (),
238253 new McpSchema .JSONRPCResponse .JSONRPCError (
239254 McpSchema .ErrorCodes .METHOD_NOT_FOUND , error .message (), error .data ())))
240- .then (transport .closeGracefully ());
255+ .then (stream .closeGracefully ());
241256 }
242257 return requestHandler
243258 .handle (new McpAsyncServerExchange (this .id , stream , clientCapabilities .get (), clientInfo .get (),
@@ -253,7 +268,7 @@ public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
253268 return Mono .just (errorResponse );
254269 })
255270 .flatMap (transport ::sendMessage )
256- .then (transport .closeGracefully ());
271+ .then (stream .closeGracefully ());
257272 });
258273 }
259274
@@ -324,20 +339,18 @@ private MethodNotFoundError getMethodNotFoundError(String method) {
324339 @ Override
325340 public Mono <Void > closeGracefully () {
326341 return this .onClose .get ().onErrorComplete ().then (Mono .defer (() -> {
327- McpLoggableSession listeningStream = this .listeningStreamRef .getAndSet (missingMcpTransportSession );
328- return listeningStream .closeGracefully ();
329- // TODO: Also close all the open streams
342+ this .listeningStreamRef .set (this .missingMcpTransportSession );
343+ return Flux .fromIterable (List .copyOf (this .openStreams ))
344+ .flatMap (McpStreamableServerSessionStream ::closeGracefully )
345+ .then ();
330346 }));
331347 }
332348
333349 @ Override
334350 public void close () {
335351 this .onClose .get ().onErrorComplete ().subscribe ();
336- McpLoggableSession listeningStream = this .listeningStreamRef .getAndSet (missingMcpTransportSession );
337- if (listeningStream != null ) {
338- listeningStream .close ();
339- }
340- // TODO: Also close all open streams
352+ this .listeningStreamRef .set (this .missingMcpTransportSession );
353+ List .copyOf (this .openStreams ).forEach (McpStreamableServerSessionStream ::close );
341354 }
342355
343356 /**
@@ -387,18 +400,19 @@ public final class McpStreamableServerSessionStream implements McpLoggableSessio
387400
388401 private final ConcurrentHashMap <Object , MonoSink <McpSchema .JSONRPCResponse >> pendingResponses = new ConcurrentHashMap <>();
389402
390- private final McpStreamableServerTransport transport ;
403+ private final McpStreamableServerTransport connection ;
391404
392405 private final String transportId ;
393406
394407 private final Supplier <String > uuidGenerator ;
395408
396409 /**
397410 * Constructor accepting the dedicated transport representing the SSE stream.
398- * @param transport request-specific SSE transport stream
411+ * @param connection request-specific SSE transport stream
399412 */
400- public McpStreamableServerSessionStream (McpStreamableServerTransport transport ) {
401- this .transport = transport ;
413+ public McpStreamableServerSessionStream (McpStreamableServerTransport connection ) {
414+ this .connection = connection ;
415+ McpStreamableServerSession .this .openStreams .add (this );
402416 this .transportId = UUID .randomUUID ().toString ();
403417 // This ID design allows for a constant-time extraction of the history by
404418 // precisely identifying the SSE stream using the first component
@@ -428,9 +442,11 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
428442 requestParams );
429443 String messageId = this .uuidGenerator .get ();
430444 // TODO: store message in history
431- this .transport .sendMessage (jsonrpcRequest , messageId ).subscribe (v -> {
445+ this .connection .sendMessage (jsonrpcRequest , messageId ).subscribe (v -> {
432446 }, sink ::error );
433- }).timeout (requestTimeout ).doOnError (e -> {
447+ }).timeout (requestTimeout ).doFinally (signal -> {
448+ // Also on completion and cancellation: a resolved request keeps no state,
449+ // and a deadline imposed by the caller cancels rather than errors
434450 this .pendingResponses .remove (requestId );
435451 McpStreamableServerSession .this .requestIdToStream .remove (requestId );
436452 }).handle ((jsonRpcResponse , sink ) -> {
@@ -442,7 +458,7 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
442458 sink .complete ();
443459 }
444460 else {
445- sink .next (this .transport .unmarshalFrom (jsonRpcResponse .result (), typeRef ));
461+ sink .next (this .connection .unmarshalFrom (jsonRpcResponse .result (), typeRef ));
446462 }
447463 }
448464 });
@@ -453,31 +469,51 @@ public Mono<Void> sendNotification(String method, Object params) {
453469 McpSchema .JSONRPCNotification jsonrpcNotification = new McpSchema .JSONRPCNotification (method , params );
454470 String messageId = this .uuidGenerator .get ();
455471 // TODO: store message in history
456- return this .transport .sendMessage (jsonrpcNotification , messageId );
472+ return this .connection .sendMessage (jsonrpcNotification , messageId );
457473 }
458474
459475 @ Override
460476 public Mono <Void > closeGracefully () {
461477 return Mono .defer (() -> {
478+ McpStreamableServerSession .this .openStreams .remove (this );
462479 this .pendingResponses .values ().forEach (s -> s .error (new RuntimeException ("Stream closed" )));
463480 this .pendingResponses .clear ();
464481 // If this was the generic stream, reset it
465482 McpStreamableServerSession .this .listeningStreamRef .compareAndExchange (this ,
466483 McpStreamableServerSession .this .missingMcpTransportSession );
467484 McpStreamableServerSession .this .requestIdToStream .values ().removeIf (this ::equals );
468- return this .transport .closeGracefully ();
485+ return this .connection .closeGracefully ();
469486 });
470487 }
471488
472489 @ Override
473490 public void close () {
491+ McpStreamableServerSession .this .openStreams .remove (this );
474492 this .pendingResponses .values ().forEach (s -> s .error (new RuntimeException ("Stream closed" )));
475493 this .pendingResponses .clear ();
476494 // If this was the generic stream, reset it
477495 McpStreamableServerSession .this .listeningStreamRef .compareAndExchange (this ,
478496 McpStreamableServerSession .this .missingMcpTransportSession );
479497 McpStreamableServerSession .this .requestIdToStream .values ().removeIf (this ::equals );
480- this .transport .close ();
498+ this .connection .close ();
499+ }
500+
501+ /**
502+ * Releases the connection carrying this stream, detaching the stream from the
503+ * session, but keeps its pending server-initiated requests resolvable: the client
504+ * answers those with a separate HTTP POST request, which outlives the SSE stream
505+ * the request was sent on.
506+ * <p>
507+ * This is the counterpart of {@link #close()} for the end of a connection rather
508+ * than the end of the session: an SSE stream going away, whether replaced,
509+ * disconnected or timed out, does not invalidate the requests sent on it.
510+ */
511+ public void releaseTransport () {
512+ McpStreamableServerSession .this .openStreams .remove (this );
513+ // If this was the generic stream, reset it
514+ McpStreamableServerSession .this .listeningStreamRef .compareAndExchange (this ,
515+ McpStreamableServerSession .this .missingMcpTransportSession );
516+ this .connection .close ();
481517 }
482518
483519 }
0 commit comments