3636
3737import java .io .IOException ;
3838import java .time .Duration ;
39+ import java .time .Instant ;
3940import java .util .List ;
4041import java .util .Map ;
4142import java .util .concurrent .ConcurrentHashMap ;
4445 * Implementation of a WebFlux based {@link McpStreamableServerTransportProvider}.
4546 *
4647 * @author Dariusz Jędrzejczyk
48+ * @author Dimitar Proynov
4749 */
4850public class WebFluxStreamableServerTransportProvider implements McpStreamableServerTransportProvider {
4951
@@ -69,14 +71,34 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
6971
7072 private KeepAliveScheduler keepAliveScheduler ;
7173
74+ /**
75+ * Duration of inactivity after which an idle session is evicted. If {@code null},
76+ * idle sessions are never evicted.
77+ */
78+ private final Duration sessionIdleTimeout ;
79+
80+ /**
81+ * Tracks the last access time per session id, used to evict idle sessions.
82+ */
83+ private final ConcurrentHashMap <String , Instant > sessionLastAccessTimes = new ConcurrentHashMap <>();
84+
85+ private Disposable idleSessionScheduler ;
86+
87+ /**
88+ * Max number of session this provider supports before discarding new initialization
89+ * requests.
90+ */
91+ private final long maxSessions ;
92+
7293 /**
7394 * Security validator for validating HTTP requests.
7495 */
7596 private final ServerTransportSecurityValidator securityValidator ;
7697
7798 private WebFluxStreamableServerTransportProvider (McpJsonMapper jsonMapper , String mcpEndpoint ,
7899 McpTransportContextExtractor <ServerRequest > contextExtractor , boolean disallowDelete ,
79- Duration keepAliveInterval , ServerTransportSecurityValidator securityValidator ) {
100+ Duration keepAliveInterval , Duration sessionIdleTimeout , ServerTransportSecurityValidator securityValidator ,
101+ long maxSessions ) {
80102 Assert .notNull (jsonMapper , "JsonMapper must not be null" );
81103 Assert .notNull (mcpEndpoint , "Message endpoint must not be null" );
82104 Assert .notNull (contextExtractor , "Context extractor must not be null" );
@@ -87,6 +109,8 @@ private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, Strin
87109 this .contextExtractor = contextExtractor ;
88110 this .disallowDelete = disallowDelete ;
89111 this .securityValidator = securityValidator ;
112+ this .maxSessions = maxSessions ;
113+ this .sessionIdleTimeout = sessionIdleTimeout ;
90114 this .routerFunction = RouterFunctions .route ()
91115 .GET (this .mcpEndpoint , this ::handleGet )
92116 .POST (this .mcpEndpoint , this ::handlePost )
@@ -102,6 +126,11 @@ private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, Strin
102126
103127 this .keepAliveScheduler .start ();
104128 }
129+
130+ if (sessionIdleTimeout != null ) {
131+ this .idleSessionScheduler = Flux .interval (sessionIdleTimeout , sessionIdleTimeout )
132+ .subscribe (tick -> this .evictIdleSessions ());
133+ }
105134 }
106135
107136 @ Override
@@ -142,9 +171,13 @@ public Mono<Void> closeGracefully() {
142171 .then ();
143172 }).then ().doOnSuccess (v -> {
144173 sessions .clear ();
174+ this .sessionLastAccessTimes .clear ();
145175 if (this .keepAliveScheduler != null ) {
146176 this .keepAliveScheduler .shutdown ();
147177 }
178+ if (this .idleSessionScheduler != null ) {
179+ this .idleSessionScheduler .dispose ();
180+ }
148181 });
149182 }
150183
@@ -191,7 +224,7 @@ private Mono<ServerResponse> handleGet(ServerRequest request) {
191224 return ServerResponse .badRequest ().build ();
192225 }
193226
194- if (request . headers (). header ( HttpHeaders . MCP_SESSION_ID ). isEmpty ( )) {
227+ if (! hasMcpSessionIdHeader ( request )) {
195228 return ServerResponse .badRequest ().build (); // TODO: say we need a session
196229 // id
197230 }
@@ -204,6 +237,8 @@ private Mono<ServerResponse> handleGet(ServerRequest request) {
204237 return ServerResponse .notFound ().build ();
205238 }
206239
240+ touchSession (sessionId );
241+
207242 if (!request .headers ().header (HttpHeaders .LAST_EVENT_ID ).isEmpty ()) {
208243 String lastId = request .headers ().asHttpHeaders ().getFirst (HttpHeaders .LAST_EVENT_ID );
209244 return ServerResponse .ok ()
@@ -266,31 +301,10 @@ private Mono<ServerResponse> handlePost(ServerRequest request) {
266301 McpSchema .JSONRPCMessage message = McpSchema .deserializeJsonRpcMessage (jsonMapper , body );
267302 if (message instanceof McpSchema .JSONRPCRequest jsonrpcRequest
268303 && jsonrpcRequest .method ().equals (McpSchema .METHOD_INITIALIZE )) {
269- var typeReference = new TypeRef <McpSchema .InitializeRequest >() {
270- };
271- McpSchema .InitializeRequest initializeRequest = jsonMapper .convertValue (jsonrpcRequest .params (),
272- typeReference );
273- McpStreamableServerSession .McpStreamableServerSessionInit init = this .sessionFactory
274- .startSession (initializeRequest );
275- sessions .put (init .session ().getId (), init .session ());
276- return init .initResult ().map (initializeResult -> {
277- McpSchema .JSONRPCResponse jsonrpcResponse = new McpSchema .JSONRPCResponse (
278- McpSchema .JSONRPC_VERSION , jsonrpcRequest .id (), initializeResult , null );
279- try {
280- return this .jsonMapper .writeValueAsString (jsonrpcResponse );
281- }
282- catch (IOException e ) {
283- logger .warn ("Failed to serialize initResponse" , e );
284- throw Exceptions .propagate (e );
285- }
286- })
287- .flatMap (initResult -> ServerResponse .ok ()
288- .contentType (MediaType .APPLICATION_JSON )
289- .header (HttpHeaders .MCP_SESSION_ID , init .session ().getId ())
290- .bodyValue (initResult ));
304+ return handleInitRequest (jsonrpcRequest , hasMcpSessionIdHeader (request ));
291305 }
292306
293- if (request . headers (). header ( HttpHeaders . MCP_SESSION_ID ). isEmpty ( )) {
307+ if (! hasMcpSessionIdHeader ( request )) {
294308 return ServerResponse .badRequest ().bodyValue (new McpError ("Session ID missing" ));
295309 }
296310
@@ -302,6 +316,8 @@ private Mono<ServerResponse> handlePost(ServerRequest request) {
302316 .bodyValue (new McpError ("Session not found: " + sessionId ));
303317 }
304318
319+ touchSession (sessionId );
320+
305321 if (message instanceof McpSchema .JSONRPCResponse jsonrpcResponse ) {
306322 return session .accept (jsonrpcResponse ).then (ServerResponse .accepted ().build ());
307323 }
@@ -353,7 +369,7 @@ private Mono<ServerResponse> handleDelete(ServerRequest request) {
353369 McpTransportContext transportContext = this .contextExtractor .extract (request );
354370
355371 return Mono .defer (() -> {
356- if (request . headers (). header ( HttpHeaders . MCP_SESSION_ID ). isEmpty ( )) {
372+ if (! hasMcpSessionIdHeader ( request )) {
357373 return ServerResponse .badRequest ().build (); // TODO: say we need a session
358374 // id
359375 }
@@ -370,10 +386,99 @@ private Mono<ServerResponse> handleDelete(ServerRequest request) {
370386 return ServerResponse .notFound ().build ();
371387 }
372388
373- return session .delete ().then (ServerResponse .ok ().build ());
389+ return session .delete ().doOnSuccess ( unused -> removeSession ( sessionId )). then (ServerResponse .ok ().build ());
374390 }).contextWrite (ctx -> ctx .put (McpTransportContext .KEY , transportContext ));
375391 }
376392
393+ private Mono <ServerResponse > handleInitRequest (McpSchema .JSONRPCRequest jsonrpcRequest ,
394+ boolean hasMcpSessionIdHeader ) {
395+ if (hasMcpSessionIdHeader ) {
396+ return ServerResponse .status (HttpStatus .BAD_REQUEST )
397+ .bodyValue (McpError .builder (McpSchema .ErrorCodes .INTERNAL_ERROR )
398+ .message ("Session already initialized" )
399+ .build ());
400+ }
401+ if (this .sessions .size () >= this .maxSessions ) {
402+ return ServerResponse .status (HttpStatus .SERVICE_UNAVAILABLE )
403+ .bodyValue (McpError .builder (McpSchema .ErrorCodes .INTERNAL_ERROR )
404+ .message ("Max number of sessions reached" )
405+ .build ());
406+ }
407+ var typeReference = new TypeRef <McpSchema .InitializeRequest >() {
408+ };
409+ McpSchema .InitializeRequest initializeRequest = jsonMapper .convertValue (jsonrpcRequest .params (), typeReference );
410+ McpStreamableServerSession .McpStreamableServerSessionInit init = this .sessionFactory
411+ .startSession (initializeRequest );
412+ sessions .put (init .session ().getId (), init .session ());
413+ if (this .sessionIdleTimeout != null ) {
414+ this .sessionLastAccessTimes .put (init .session ().getId (), Instant .now ());
415+ }
416+ return init .initResult ().map (initializeResult -> {
417+ McpSchema .JSONRPCResponse jsonrpcResponse = new McpSchema .JSONRPCResponse (McpSchema .JSONRPC_VERSION ,
418+ jsonrpcRequest .id (), initializeResult , null );
419+ try {
420+ return this .jsonMapper .writeValueAsString (jsonrpcResponse );
421+ }
422+ catch (IOException e ) {
423+ logger .warn ("Failed to serialize initResponse" , e );
424+ throw Exceptions .propagate (e );
425+ }
426+ })
427+ .flatMap (initResult -> ServerResponse .ok ()
428+ .contentType (MediaType .APPLICATION_JSON )
429+ .header (HttpHeaders .MCP_SESSION_ID , init .session ().getId ())
430+ .bodyValue (initResult ));
431+ }
432+
433+ /**
434+ * Records the current time as the last access time for the given session, so that an
435+ * active session is not evicted as idle. No-op when idle eviction is disabled or the
436+ * session is no longer tracked.
437+ * @param sessionId the id of the session that was just accessed
438+ */
439+ private void touchSession (String sessionId ) {
440+ if (this .sessionIdleTimeout != null ) {
441+ this .sessionLastAccessTimes .computeIfPresent (sessionId , (id , lastAccess ) -> Instant .now ());
442+ }
443+ }
444+
445+ /**
446+ * Evicts and closes sessions that have been idle for longer than the configured
447+ * {@code sessionIdleTimeout}. This reclaims session slots held by clients that
448+ * initialized a session but never interacted with it again, mitigating resource
449+ * exhaustion from abandoned sessions.
450+ */
451+ private void evictIdleSessions () {
452+ if (this .sessionIdleTimeout == null || this .isClosing ) {
453+ return ;
454+ }
455+ Instant now = Instant .now ();
456+ for (var entry : this .sessionLastAccessTimes .entrySet ()) {
457+ if (Duration .between (entry .getValue (), now ).compareTo (this .sessionIdleTimeout ) > 0 ) {
458+ String sessionId = entry .getKey ();
459+ McpStreamableServerSession session = removeSession (sessionId );
460+ logger .debug ("Evicting idle session: {}" , sessionId );
461+ if (session != null ) {
462+ session .closeGracefully ().onErrorComplete ().subscribe ();
463+ }
464+ }
465+ }
466+ }
467+
468+ /**
469+ * Checks whether the given request carries an {@code Mcp-Session-Id} header.
470+ * @param request the incoming server request
471+ * @return {@code true} if the header is present, regardless of its value
472+ */
473+ private static boolean hasMcpSessionIdHeader (ServerRequest request ) {
474+ return !request .headers ().header (HttpHeaders .MCP_SESSION_ID ).isEmpty ();
475+ }
476+
477+ private McpStreamableServerSession removeSession (String sessionId ) {
478+ this .sessionLastAccessTimes .remove (sessionId );
479+ return this .sessions .remove (sessionId );
480+ }
481+
377482 private class WebFluxStreamableMcpSessionTransport implements McpStreamableServerTransport {
378483
379484 private final FluxSink <ServerSentEvent <?>> sink ;
@@ -450,8 +555,12 @@ public static class Builder {
450555
451556 private Duration keepAliveInterval ;
452557
558+ private long maxSessions = 100_000L ;
559+
453560 private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator .NOOP ;
454561
562+ private Duration sessionIdleTimeout ;
563+
455564 private Builder () {
456565 // used by a static method
457566 }
@@ -531,6 +640,41 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida
531640 return this ;
532641 }
533642
643+ /**
644+ * Sets the maximum supported sessions for this provider. If not set defaults to
645+ * 100_000.
646+ * @param maxSessions - maximum allowed sessions for this provider
647+ * @return this builder instance
648+ * @throws IllegalArgumentException if maximum sessions is not positive number
649+ */
650+ public Builder maxSessions (long maxSessions ) {
651+ Assert .isTrue (maxSessions >= 0 , "Max sessions must be greater than 0" );
652+ this .maxSessions = maxSessions ;
653+ return this ;
654+ }
655+
656+ /**
657+ * Sets the idle timeout after which sessions with no client activity are evicted
658+ * and closed. Activity is any inbound request (GET, POST or DELETE) resolving to
659+ * the session. This reclaims session slots held by clients that initialized a
660+ * session but never interacted with it again, mitigating resource exhaustion from
661+ * abandoned sessions.
662+ * <p>
663+ * Keep-alive and idle eviction are complementary and may be enabled together:
664+ * keep-alive pings preserve connected clients (a ping response counts as
665+ * activity), while idle eviction reclaims abandoned sessions. When both are set,
666+ * {@code sessionIdleTimeout} must be greater than the keep-alive interval so that
667+ * a connected client answering pings is not evicted before it can respond;
668+ * otherwise {@link #build()} throws.
669+ * @param sessionIdleTimeout the maximum idle duration before eviction, or null to
670+ * disable idle eviction (the default)
671+ * @return this builder instance
672+ */
673+ public Builder sessionIdleTimeout (Duration sessionIdleTimeout ) {
674+ this .sessionIdleTimeout = sessionIdleTimeout ;
675+ return this ;
676+ }
677+
534678 /**
535679 * Builds a new instance of {@link WebFluxStreamableServerTransportProvider} with
536680 * the configured settings.
@@ -539,9 +683,14 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida
539683 */
540684 public WebFluxStreamableServerTransportProvider build () {
541685 Assert .notNull (mcpEndpoint , "Message endpoint must be set" );
686+ if (this .sessionIdleTimeout != null && this .keepAliveInterval != null ) {
687+ Assert .isTrue (this .sessionIdleTimeout .compareTo (this .keepAliveInterval ) > 0 ,
688+ "sessionIdleTimeout must be greater than keepAliveInterval, otherwise a connected client "
689+ + "may be evicted before it can answer a keep-alive ping" );
690+ }
542691 return new WebFluxStreamableServerTransportProvider (
543692 jsonMapper == null ? McpJsonDefaults .getMapper () : jsonMapper , mcpEndpoint , contextExtractor ,
544- disallowDelete , keepAliveInterval , securityValidator );
693+ disallowDelete , keepAliveInterval , sessionIdleTimeout , securityValidator , maxSessions );
545694 }
546695
547696 }
0 commit comments