99import java .time .Duration ;
1010import java .util .ArrayList ;
1111import java .util .List ;
12+ import java .util .Set ;
1213import java .util .concurrent .ConcurrentHashMap ;
14+ import java .util .concurrent .atomic .AtomicReference ;
1315import java .util .concurrent .locks .ReentrantLock ;
1416
1517import io .modelcontextprotocol .common .McpTransportContext ;
2830import io .modelcontextprotocol .util .Assert ;
2931import io .modelcontextprotocol .util .KeepAliveScheduler ;
3032import jakarta .servlet .AsyncContext ;
33+ import jakarta .servlet .AsyncEvent ;
34+ import jakarta .servlet .AsyncListener ;
3135import jakarta .servlet .ServletException ;
3236import jakarta .servlet .annotation .WebServlet ;
3337import jakarta .servlet .http .HttpServlet ;
3438import jakarta .servlet .http .HttpServletRequest ;
3539import jakarta .servlet .http .HttpServletResponse ;
3640import org .slf4j .Logger ;
3741import org .slf4j .LoggerFactory ;
42+ import reactor .core .Disposable ;
3843import reactor .core .publisher .Flux ;
3944import reactor .core .publisher .Mono ;
45+ import reactor .core .scheduler .Schedulers ;
4046
4147/**
4248 * Server-side implementation of the Model Context Protocol (MCP) streamable transport
@@ -115,6 +121,13 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
115121 */
116122 private final ConcurrentHashMap <String , McpStreamableServerSession > sessions = new ConcurrentHashMap <>();
117123
124+ /**
125+ * IDs of the sessions which received a request since the last sweep. The set is
126+ * swapped for an empty one on every sweep, so it only ever holds the activity of the
127+ * current interval.
128+ */
129+ private final AtomicReference <Set <String >> activeSessions = new AtomicReference <>(ConcurrentHashMap .newKeySet ());
130+
118131 private McpTransportContextExtractor <HttpServletRequest > contextExtractor ;
119132
120133 /**
@@ -128,6 +141,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
128141 */
129142 private KeepAliveScheduler keepAliveScheduler ;
130143
144+ /**
145+ * Periodic eviction of the sessions no client came back to. Activated if
146+ * sessionSweepInterval is set.
147+ */
148+ private Disposable sessionSweeper ;
149+
131150 /**
132151 * Security validator for validating HTTP requests.
133152 */
@@ -146,11 +165,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
146165 * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
147166 * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
148167 * positive.
168+ * @param sessionSweepInterval The interval at which idle sessions are evicted. If
169+ * null, no sweeping will be scheduled.
149170 * @throws IllegalArgumentException if any parameter is null
150171 */
151172 private HttpServletStreamableServerTransportProvider (McpJsonMapper jsonMapper , String mcpEndpoint ,
152173 boolean disallowDelete , McpTransportContextExtractor <HttpServletRequest > contextExtractor ,
153- Duration keepAliveInterval , ServerHttpHeaderValidator httpHeaderValidator , int requestMaxSize ) {
174+ Duration keepAliveInterval , ServerHttpHeaderValidator httpHeaderValidator , int requestMaxSize ,
175+ Duration sessionSweepInterval ) {
154176 Assert .notNull (jsonMapper , "JsonMapper must not be null" );
155177 Assert .notNull (mcpEndpoint , "MCP endpoint must not be null" );
156178 Assert .notNull (contextExtractor , "Context extractor must not be null" );
@@ -169,11 +191,72 @@ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, S
169191 this .keepAliveScheduler = KeepAliveScheduler .builder (this ::sessionsToPing )
170192 .initialDelay (keepAliveInterval )
171193 .interval (keepAliveInterval )
194+ .onPingFailure (session -> {
195+ // The stream the ping was written to is dead. The session survives:
196+ // the client may reconnect to it, and the idle timeout reclaims it if
197+ // it never does.
198+ if (session instanceof McpStreamableServerSession streamableSession ) {
199+ streamableSession .releaseListeningStream ();
200+ }
201+ })
172202 .build ();
173203
174204 this .keepAliveScheduler .start ();
175205 }
176206
207+ if (sessionSweepInterval != null ) {
208+ this .sessionSweeper = Flux .interval (sessionSweepInterval , sessionSweepInterval , Schedulers .boundedElastic ())
209+ .doOnNext (tick -> {
210+ // Each sweep runs in its own subscription, so that a sweep failing
211+ // does not terminate the interval and disable sweeping altogether
212+ Mono .fromRunnable (this ::sweepSessions )
213+ .doOnError (e -> logger .error ("Session sweep failed" , e ))
214+ .onErrorComplete ()
215+ .subscribe ();
216+ })
217+ .onErrorComplete (error -> {
218+ logger .error ("Session sweeper error" , error );
219+ return true ;
220+ })
221+ .subscribe ();
222+ }
223+
224+ }
225+
226+ /**
227+ * Evicts the sessions no client is using anymore. A session is kept if it holds an
228+ * open stream, which a client can legitimately sit on without ever writing to it, or
229+ * if it received a request during the interval which just elapsed. Anything else is a
230+ * session whose client went away without deleting it: the protocol lets a client
231+ * reconnect to a session, so nothing else ever reclaims it.
232+ */
233+ private void sweepSessions () {
234+ if (this .isClosing ) {
235+ return ;
236+ }
237+ Set <String > active = this .activeSessions .getAndSet (ConcurrentHashMap .newKeySet ());
238+ this .sessions .values ().removeIf (session -> {
239+ if (session .hasOpenStream () || active .contains (session .getId ())) {
240+ return false ;
241+ }
242+ logger .debug ("Evicting idle session {}" , session .getId ());
243+ try {
244+ session .closeGracefully ().block ();
245+ }
246+ catch (Exception e ) {
247+ logger .warn ("Failed to close idle session {}: {}" , session .getId (), e .getMessage ());
248+ }
249+ return true ;
250+ });
251+ }
252+
253+ /**
254+ * Records that the given session is being used, so that the next sweep does not
255+ * mistake it for a session whose client is gone.
256+ * @param sessionId the session the current request belongs to
257+ */
258+ private void markSessionActive (String sessionId ) {
259+ this .activeSessions .get ().add (sessionId );
177260 }
178261
179262 /**
@@ -264,6 +347,9 @@ public Mono<Void> closeGracefully() {
264347 if (this .keepAliveScheduler != null ) {
265348 this .keepAliveScheduler .shutdown ();
266349 }
350+ if (this .sessionSweeper != null ) {
351+ this .sessionSweeper .dispose ();
352+ }
267353 });
268354 }
269355
@@ -323,6 +409,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
323409 response .sendError (HttpServletResponse .SC_NOT_FOUND );
324410 return ;
325411 }
412+ this .markSessionActive (sessionId );
326413
327414 logger .debug ("Handling GET request for session: {}" , sessionId );
328415
@@ -354,65 +441,14 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
354441 McpStreamableServerSession .McpStreamableServerSessionStream listeningStream = session
355442 .listeningStream (sessionTransport );
356443
357- asyncContext .addListener (new jakarta .servlet .AsyncListener () {
358- @ Override
359- public void onComplete (jakarta .servlet .AsyncEvent event ) throws IOException {
360- logger .debug ("SSE connection completed for session: {}" , sessionId );
361- listeningStream .close ();
362- }
363-
364- @ Override
365- public void onTimeout (jakarta .servlet .AsyncEvent event ) throws IOException {
366- logger .debug ("SSE connection timed out for session: {}" , sessionId );
367- listeningStream .close ();
368- }
369-
370- @ Override
371- public void onError (jakarta .servlet .AsyncEvent event ) throws IOException {
372- logger .debug ("SSE connection error for session: {}" , sessionId );
373- listeningStream .close ();
374- }
375-
376- @ Override
377- public void onStartAsync (jakarta .servlet .AsyncEvent event ) throws IOException {
378- // No action needed
379- }
380- });
444+ registerAsyncLifecycle (asyncContext , sessionId , listeningStream ::releaseTransport );
381445 }
382446 catch (Exception e ) {
383447 logger .error ("Failed to handle GET request for session {}: {}" , sessionId , e .getMessage ());
384448 response .sendError (HttpServletResponse .SC_INTERNAL_SERVER_ERROR );
385449 }
386450 }
387451
388- /**
389- * Replays the messages the client missed while its SSE stream was broken.
390- * @param session the session the client is resuming
391- * @param lastEventId the ID of the last event received by the client
392- * @param sessionTransport the transport of the resumed SSE stream
393- * @param transportContext the context extracted from the request
394- * @return {@code true} if the replay completed, {@code false} if it failed, in which
395- * case the transport has been closed
396- */
397- private boolean tryReplayMissedMessages (McpStreamableServerSession session , String lastEventId ,
398- McpStreamableServerTransport sessionTransport , McpTransportContext transportContext ) {
399- try {
400- for (McpSchema .JSONRPCMessage message : session .replay (lastEventId )
401- .contextWrite (ctx -> ctx .put (McpTransportContext .KEY , transportContext ))
402- .toIterable ()) {
403- sessionTransport .sendMessage (message )
404- .contextWrite (ctx -> ctx .put (McpTransportContext .KEY , transportContext ))
405- .block ();
406- }
407- return true ;
408- }
409- catch (Exception e ) {
410- logger .error ("Failed to replay messages for session {}: {}" , session .getId (), e .getMessage ());
411- sessionTransport .close ();
412- return false ;
413- }
414- }
415-
416452 /**
417453 * Handles POST requests for incoming JSON-RPC messages from clients.
418454 * @param request The HTTP servlet request containing the JSON-RPC message
@@ -479,6 +515,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
479515 });
480516 McpStreamableServerSession .McpStreamableServerSessionInit init = this .sessionFactory
481517 .startSession (initializeRequest );
518+ this .markSessionActive (init .session ().getId ());
482519 this .sessions .put (init .session ().getId (), init .session ());
483520
484521 try {
@@ -530,6 +567,8 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
530567 return ;
531568 }
532569
570+ this .markSessionActive (sessionId );
571+
533572 if (message instanceof McpSchema .JSONRPCResponse jsonrpcResponse ) {
534573 session .accept (jsonrpcResponse )
535574 .contextWrite (ctx -> ctx .put (McpTransportContext .KEY , transportContext ))
@@ -554,6 +593,7 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
554593
555594 HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport (
556595 sessionId , asyncContext , response .getWriter ());
596+ registerAsyncLifecycle (asyncContext , sessionId , sessionTransport ::close );
557597
558598 try {
559599 session .responseStream (jsonrpcRequest , sessionTransport )
@@ -710,6 +750,70 @@ public void destroy() {
710750 super .destroy ();
711751 }
712752
753+ /**
754+ * Replays the messages the client missed while its SSE stream was broken.
755+ * @param session the session the client is resuming
756+ * @param lastEventId the ID of the last event received by the client
757+ * @param sessionTransport the transport of the resumed SSE stream
758+ * @param transportContext the context extracted from the request
759+ * @return {@code true} if the replay completed, {@code false} if it failed, in which
760+ * case the transport has been closed
761+ */
762+ private static boolean tryReplayMissedMessages (McpStreamableServerSession session , String lastEventId ,
763+ McpStreamableServerTransport sessionTransport , McpTransportContext transportContext ) {
764+ try {
765+ for (McpSchema .JSONRPCMessage message : session .replay (lastEventId )
766+ .contextWrite (ctx -> ctx .put (McpTransportContext .KEY , transportContext ))
767+ .toIterable ()) {
768+ sessionTransport .sendMessage (message )
769+ .contextWrite (ctx -> ctx .put (McpTransportContext .KEY , transportContext ))
770+ .block ();
771+ }
772+ return true ;
773+ }
774+ catch (Exception e ) {
775+ logger .error ("Failed to replay messages for session {}: {}" , session .getId (), e .getMessage ());
776+ sessionTransport .close ();
777+ return false ;
778+ }
779+ }
780+
781+ /**
782+ * Registers a listener releasing the SSE stream carried by the given asynchronous
783+ * request once the container is done with it, whether the client went away, the
784+ * request timed out or it errored. Without this, the connection is left open, holding
785+ * on to a socket and a container thread, until the process restarts.
786+ * @param asyncContext the asynchronous context of the SSE request
787+ * @param sessionId the session the stream belongs to
788+ * @param onConnectionEnd the action releasing the stream
789+ */
790+ private static void registerAsyncLifecycle (AsyncContext asyncContext , String sessionId , Runnable onConnectionEnd ) {
791+ asyncContext .addListener (new AsyncListener () {
792+ @ Override
793+ public void onComplete (AsyncEvent event ) throws IOException {
794+ logger .debug ("SSE connection completed for session: {}" , sessionId );
795+ onConnectionEnd .run ();
796+ }
797+
798+ @ Override
799+ public void onTimeout (AsyncEvent event ) throws IOException {
800+ logger .debug ("SSE connection timed out for session: {}" , sessionId );
801+ onConnectionEnd .run ();
802+ }
803+
804+ @ Override
805+ public void onError (AsyncEvent event ) throws IOException {
806+ logger .debug ("SSE connection error for session: {}" , sessionId );
807+ onConnectionEnd .run ();
808+ }
809+
810+ @ Override
811+ public void onStartAsync (AsyncEvent event ) throws IOException {
812+ // No action needed
813+ }
814+ });
815+ }
816+
713817 /**
714818 * Implementation of McpStreamableServerTransport for HttpServlet SSE sessions. This
715819 * class handles the transport-level communication for a specific client session.
@@ -719,7 +823,6 @@ public void destroy() {
719823 * underlying PrintWriter to prevent race conditions when multiple threads attempt to
720824 * send messages concurrently.
721825 */
722-
723826 private class HttpServletStreamableMcpSessionTransport implements McpStreamableServerTransport {
724827
725828 private final String sessionId ;
@@ -783,9 +886,10 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId
783886 logger .debug ("Message sent to session {} with ID {}" , this .sessionId , messageId );
784887 }
785888 catch (Exception e ) {
889+ // 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
786891 logger .error ("Failed to send message to session {}: {}" , this .sessionId , e .getMessage ());
787- HttpServletStreamableServerTransportProvider .this .sessions .remove (this .sessionId );
788- this .asyncContext .complete ();
892+ this .close ();
789893 }
790894 finally {
791895 lock .unlock ();
@@ -829,8 +933,6 @@ public void close() {
829933 }
830934
831935 this .closed = true ;
832-
833- // HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
834936 this .asyncContext .complete ();
835937 logger .debug ("Successfully completed async context for session {}" , sessionId );
836938 }
@@ -875,6 +977,8 @@ public static class Builder {
875977
876978 private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE ;
877979
980+ private Duration sessionSweepInterval ;
981+
878982 /**
879983 * Sets the JsonMapper to use for JSON serialization/deserialization of MCP
880984 * messages.
@@ -984,7 +1088,23 @@ public HttpServletStreamableServerTransportProvider build() {
9841088 Assert .notNull (this .mcpEndpoint , "MCP endpoint must be set" );
9851089 return new HttpServletStreamableServerTransportProvider (
9861090 jsonMapper == null ? McpJsonDefaults .getMapper () : jsonMapper , mcpEndpoint , disallowDelete ,
987- contextExtractor , keepAliveInterval , httpHeaderValidator , requestMaxSize );
1091+ contextExtractor , keepAliveInterval , httpHeaderValidator , requestMaxSize , sessionSweepInterval );
1092+ }
1093+
1094+ /**
1095+ * Sets the interval at which idle sessions are evicted. A session is idle once it
1096+ * holds no open stream and has received no request for a full interval, which is
1097+ * how a session whose client went away without deleting it looks. Nothing else
1098+ * reclaims those sessions, as the protocol lets a client reconnect to a session
1099+ * it has been disconnected from.
1100+ * @param sessionSweepInterval The interval between two sweeps. If null, no
1101+ * sweeping will be scheduled and sessions are kept until they are deleted or the
1102+ * server shuts down.
1103+ * @return this builder instance
1104+ */
1105+ public Builder sessionSweepInterval (Duration sessionSweepInterval ) {
1106+ this .sessionSweepInterval = sessionSweepInterval ;
1107+ return this ;
9881108 }
9891109
9901110 }
0 commit comments