Skip to content

Commit 4a152c0

Browse files
committed
Remove inactive sessions with a "sweeper" mechanism
- Sessions are marked active when a client makes a request - A session with an open stream is also considered active - Inactive sessions are removed at a regular interval Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 22866e8 commit 4a152c0

4 files changed

Lines changed: 431 additions & 89 deletions

File tree

mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java

Lines changed: 179 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import java.time.Duration;
1010
import java.util.ArrayList;
1111
import java.util.List;
12+
import java.util.Set;
1213
import java.util.concurrent.ConcurrentHashMap;
14+
import java.util.concurrent.atomic.AtomicReference;
1315
import java.util.concurrent.locks.ReentrantLock;
1416

1517
import io.modelcontextprotocol.common.McpTransportContext;
@@ -28,15 +30,19 @@
2830
import io.modelcontextprotocol.util.Assert;
2931
import io.modelcontextprotocol.util.KeepAliveScheduler;
3032
import jakarta.servlet.AsyncContext;
33+
import jakarta.servlet.AsyncEvent;
34+
import jakarta.servlet.AsyncListener;
3135
import jakarta.servlet.ServletException;
3236
import jakarta.servlet.annotation.WebServlet;
3337
import jakarta.servlet.http.HttpServlet;
3438
import jakarta.servlet.http.HttpServletRequest;
3539
import jakarta.servlet.http.HttpServletResponse;
3640
import org.slf4j.Logger;
3741
import org.slf4j.LoggerFactory;
42+
import reactor.core.Disposable;
3843
import reactor.core.publisher.Flux;
3944
import 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

Comments
 (0)