Skip to content

Commit e59dab1

Browse files
dimitarproynovKehrlann
authored andcommitted
Introduce maxSessions and sessionIdleTimeout in Spring transports
Port of spring-projects/spring-ai@023867c to the WebFlux and WebMvc Streamable HTTP server transports. Give users the ability to configure maxSessions and a mechanism to evict idle sessions. Initialize requests carrying an Mcp-Session-Id header are rejected, and sessions removed through DELETE are no longer tracked. Signed-off-by: Dimitar Proynov <dimitar.proynov@broadcom.com> Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 1760749 commit e59dab1

4 files changed

Lines changed: 640 additions & 54 deletions

File tree

‎mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java‎

Lines changed: 177 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
import java.io.IOException;
3838
import java.time.Duration;
39+
import java.time.Instant;
3940
import java.util.List;
4041
import java.util.Map;
4142
import java.util.concurrent.ConcurrentHashMap;
@@ -44,6 +45,7 @@
4445
* Implementation of a WebFlux based {@link McpStreamableServerTransportProvider}.
4546
*
4647
* @author Dariusz Jędrzejczyk
48+
* @author Dimitar Proynov
4749
*/
4850
public 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

Comments
 (0)