feat: implement MCP-compliant keep-alive functionality for server transports

- Add KeepAliveScheduler utility class for configurable periodic session pings
- Integrate keep-alive support in WebFlux, WebMVC, and HttpServlet SSE transport providers
- Add keepAliveInterval configuration option to all transport provider builders
- Deprecate existing constructors in favor of builder pattern with enhanced configuration
- Update graceful shutdown to properly clean up keep-alive schedulers
- Add unit tests for KeepAliveScheduler functionality

Implements MCP specification recommendations for connection health detection:
- Configurable ping frequency to suit different network environments
- Optional keep-alive (disabled by default) to avoid excessive network overhead
- Proper resource cleanup to prevent connection leaks

https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping#implementation-considerations

Resolves: #414, #158
Replaces #353

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-07-30 10:38:07 +02:00 • committed by Christian Tzolov
commit ee2c891cca
5 changed files with 954 additions and 35 deletions

View file

@ -4,6 +4,8 @@
package io.modelcontextprotocol.client;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@ -174,7 +176,10 @@ public class McpAsyncClient {
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
// Ping MUST respond with an empty data, but not NULL response.
requestHandlers.put(McpSchema.METHOD_PING, params -> Mono.just(Map.of()));
requestHandlers.put(McpSchema.METHOD_PING, params -> {
logger.debug("Received ping: {}", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
return Mono.just(Map.of());
});
// Roots List Request Handler
if (this.clientCapabilities.roots() != null) {

View file

@ -6,6 +6,7 @@ package io.modelcontextprotocol.server.transport;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@ -19,6 +20,7 @@ import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransport;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.KeepAliveScheduler;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
@ -103,6 +105,12 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
/** Session factory for creating new sessions */
private McpServerSession.Factory sessionFactory;
/**
* Keep-alive scheduler for managing session pings. Activated if keepAliveInterval is
* set. Disabled by default.
*/
private KeepAliveScheduler keepAliveScheduler;
/**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
* endpoint.
@ -110,7 +118,10 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* serialization/deserialization
* @param messageEndpoint The endpoint path where clients will send their messages
* @param sseEndpoint The endpoint path where clients will establish SSE connections
* @deprecated Use the builder {@link #builder()} instead for better configuration
* options.
*/
@Deprecated
public HttpServletSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint,
String sseEndpoint) {
this(objectMapper, DEFAULT_BASE_URL, messageEndpoint, sseEndpoint);
@ -124,13 +135,47 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param baseUrl The base URL for the server transport
* @param messageEndpoint The endpoint path where clients will send their messages
* @param sseEndpoint The endpoint path where clients will establish SSE connections
* @deprecated Use the builder {@link #builder()} instead for better configuration
* options.
*/
@Deprecated
public HttpServletSseServerTransportProvider(ObjectMapper objectMapper, String baseUrl, String messageEndpoint,
String sseEndpoint) {
this(objectMapper, baseUrl, messageEndpoint, sseEndpoint, null);
}
/**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
* endpoint.
* @param objectMapper The JSON object mapper to use for message
* serialization/deserialization
* @param baseUrl The base URL for the server transport
* @param messageEndpoint The endpoint path where clients will send their messages
* @param sseEndpoint The endpoint path where clients will establish SSE connections
* @param keepAliveInterval The interval for keep-alive pings, or null to disable
* keep-alive functionality
* @deprecated Use the builder {@link #builder()} instead for better configuration
* options.
*/
@Deprecated
public HttpServletSseServerTransportProvider(ObjectMapper objectMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval) {
this.objectMapper = objectMapper;
this.baseUrl = baseUrl;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
if (keepAliveInterval != null) {
this.keepAliveScheduler = KeepAliveScheduler
.builder(() -> (isClosing.get()) ? Flux.empty() : Flux.fromIterable(sessions.values()))
.initialDelay(keepAliveInterval)
.interval(keepAliveInterval)
.build();
this.keepAliveScheduler.start();
}
}
/**
@ -324,7 +369,13 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
isClosing.set(true);
logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size());
return Flux.fromIterable(sessions.values()).flatMap(McpServerSession::closeGracefully).then();
return Flux.fromIterable(sessions.values()).flatMap(McpServerSession::closeGracefully).then().doOnSuccess(v -> {
sessions.clear();
logger.debug("Graceful shutdown completed");
if (this.keepAliveScheduler != null) {
this.keepAliveScheduler.shutdown();
}
});
}
/**
@ -475,6 +526,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
private Duration keepAliveInterval;
/**
* Sets the JSON object mapper to use for message serialization/deserialization.
* @param objectMapper The object mapper to use
@ -522,6 +575,18 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
return this;
}
/**
* Sets the interval for keep-alive pings.
* <p>
* If not specified, keep-alive pings will be disabled.
* @param keepAliveInterval The interval duration for keep-alive pings
* @return This builder instance for method chaining
*/
public Builder keepAliveInterval(Duration keepAliveInterval) {
this.keepAliveInterval = keepAliveInterval;
return this;
}
/**
* Builds a new instance of HttpServletSseServerTransportProvider with the
* configured settings.
@ -535,7 +600,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
if (messageEndpoint == null) {
throw new IllegalStateException("MessageEndpoint must be set");
}
return new HttpServletSseServerTransportProvider(objectMapper, baseUrl, messageEndpoint, sseEndpoint);
return new HttpServletSseServerTransportProvider(objectMapper, baseUrl, messageEndpoint, sseEndpoint,
keepAliveInterval);
}
}

View file

@ -7,6 +7,7 @@ package io.modelcontextprotocol.server.transport;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
@ -28,12 +29,14 @@ import io.modelcontextprotocol.spec.McpStreamableServerSession;
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.KeepAliveScheduler;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
@ -110,6 +113,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
*/
private volatile boolean isClosing = false;
/**
* Keep-alive scheduler for managing session pings. Activated if keepAliveInterval is
* set. Disabled by default.
*/
private KeepAliveScheduler keepAliveScheduler;
/**
* Constructs a new HttpServletStreamableServerTransportProvider instance.
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
@ -121,7 +130,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(ObjectMapper objectMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
boolean disallowDelete, McpTransportContextExtractor<HttpServletRequest> contextExtractor,
Duration keepAliveInterval) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
@ -130,6 +140,18 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
if (keepAliveInterval != null) {
this.keepAliveScheduler = KeepAliveScheduler
.builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values()))
.initialDelay(keepAliveInterval)
.interval(keepAliveInterval)
.build();
this.keepAliveScheduler.start();
}
}
@Override
@ -187,6 +209,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
this.sessions.clear();
logger.debug("Graceful shutdown completed");
}).then().doOnSuccess(v -> {
sessions.clear();
logger.debug("Graceful shutdown completed");
if (this.keepAliveScheduler != null) {
this.keepAliveScheduler.shutdown();
}
});
}
@ -737,6 +765,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private McpTransportContextExtractor<HttpServletRequest> contextExtractor = (serverRequest, context) -> context;
private Duration keepAliveInterval;
/**
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
* messages.
@ -784,6 +814,18 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return this;
}
/**
* Sets the keep-alive interval for the transport. If set, a keep-alive scheduler
* will be activated to periodically ping active sessions.
* @param keepAliveInterval The interval for keep-alive pings. If null, no
* keep-alive will be scheduled.
* @return this builder instance
*/
public Builder keepAliveInterval(Duration keepAliveInterval) {
this.keepAliveInterval = keepAliveInterval;
return this;
}
/**
* Builds a new instance of {@link HttpServletStreamableServerTransportProvider}
* with the configured settings.
@ -795,7 +837,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider(this.objectMapper, this.mcpEndpoint,
this.disallowDelete, this.contextExtractor);
this.disallowDelete, this.contextExtractor, this.keepAliveInterval);
}
}

View file

@ -1,26 +1,27 @@
package io.modelcontextprotocol.spec;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
import io.modelcontextprotocol.server.McpTransportContext;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
import io.modelcontextprotocol.server.McpTransportContext;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
/**
* Representation of a Streamable HTTP server session that keeps track of mapping
* server-initiated requests to the client and mapping arriving responses. It also allows

View file

@ -0,0 +1,216 @@
/**
* Copyright 2025 - 2025 the original author or authors.
*/
package io.modelcontextprotocol.util;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSession;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
/**
* A utility class for scheduling regular keep-alive calls to maintain connections. It
* sends periodic keep-alive, ping, messages to connected mcp clients to prevent idle
* timeouts.
*
* The pings are sent to all active mcp sessions at regular intervals.
*
* @author Christian Tzolov
*/
public class KeepAliveScheduler {
private static final Logger logger = LoggerFactory.getLogger(KeepAliveScheduler.class);
private static final TypeReference<Object> OBJECT_TYPE_REF = new TypeReference<>() {
};
/** Initial delay before the first keepAlive call */
private final Duration initialDelay;
/** Interval between subsequent keepAlive calls */
private final Duration interval;
/** The scheduler used for executing keepAlive calls */
private final Scheduler scheduler;
/** The current state of the scheduler */
private final AtomicBoolean isRunning = new AtomicBoolean(false);
/** The current subscription for the keepAlive calls */
private Disposable currentSubscription;
// TODO Currently we do not support the streams (streamable http session created by
// http post/get)
/** Supplier for reactive McpSession instances */
private final Supplier<Flux<McpSession>> mcpSessions;
/**
* Creates a KeepAliveScheduler with a custom scheduler, initial delay, interval and a
* supplier for McpSession instances.
* @param scheduler The scheduler to use for executing keepAlive calls
* @param initialDelay Initial delay before the first keepAlive call
* @param interval Interval between subsequent keepAlive calls
* @param mcpSessions Supplier for McpSession instances
*/
KeepAliveScheduler(Scheduler scheduler, Duration initialDelay, Duration interval,
Supplier<Flux<McpSession>> mcpSessions) {
this.scheduler = scheduler;
this.initialDelay = initialDelay;
this.interval = interval;
this.mcpSessions = mcpSessions;
}
/**
* Creates a new Builder instance for constructing KeepAliveScheduler.
* @return A new Builder instance
*/
public static Builder builder(Supplier<Flux<McpSession>> mcpSessions) {
return new Builder(mcpSessions);
}
/**
* Starts regular keepAlive calls with sessions supplier.
* @return Disposable to control the scheduled execution
*/
public Disposable start() {
if (this.isRunning.compareAndSet(false, true)) {
this.currentSubscription = Flux.interval(this.initialDelay, this.interval, this.scheduler)
.doOnNext(tick -> {
this.mcpSessions.get()
.flatMap(session -> session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF)
.doOnError(e -> logger.warn("Failed to send keep-alive ping to session {}: {}", session,
e.getMessage()))
.onErrorComplete())
.subscribe();
})
.doOnCancel(() -> this.isRunning.set(false))
.doOnComplete(() -> this.isRunning.set(false))
.onErrorComplete(error -> {
logger.error("KeepAlive scheduler error", error);
this.isRunning.set(false);
return true;
})
.subscribe();
return this.currentSubscription;
}
else {
throw new IllegalStateException("KeepAlive scheduler is already running. Stop it first.");
}
}
/**
* Stops the currently running keepAlive scheduler.
*/
public void stop() {
if (this.currentSubscription != null && !this.currentSubscription.isDisposed()) {
this.currentSubscription.dispose();
}
this.isRunning.set(false);
}
/**
* Checks if the scheduler is currently running.
* @return true if running, false otherwise
*/
public boolean isRunning() {
return this.isRunning.get();
}
/**
* Shuts down the scheduler and releases resources.
*/
public void shutdown() {
stop();
if (this.scheduler instanceof Disposable) {
((Disposable) this.scheduler).dispose();
}
}
/**
* Builder class for creating KeepAliveScheduler instances with fluent API.
*/
public static class Builder {
private Scheduler scheduler = Schedulers.boundedElastic();
private Duration initialDelay = Duration.ofSeconds(0);
private Duration interval = Duration.ofSeconds(30);
private Supplier<Flux<McpSession>> mcpSessions;
/**
* Creates a new Builder instance with a supplier for McpSession instances.
* @param mcpSessions The supplier for McpSession instances
*/
Builder(Supplier<Flux<McpSession>> mcpSessions) {
Assert.notNull(mcpSessions, "McpSessions supplier must not be null");
this.mcpSessions = mcpSessions;
}
/**
* Sets the scheduler to use for executing keepAlive calls.
* @param scheduler The scheduler to use:
* <ul>
* <li>Schedulers.single() - single-threaded scheduler</li>
* <li>Schedulers.boundedElastic() - bounded elastic scheduler for I/O operations
* (Default)</li>
* <li>Schedulers.parallel() - parallel scheduler for CPU-intensive
* operations</li>
* <li>Schedulers.immediate() - immediate scheduler for synchronous execution</li>
* </ul>
* @return This builder instance for method chaining
*/
public Builder scheduler(Scheduler scheduler) {
Assert.notNull(scheduler, "Scheduler must not be null");
this.scheduler = scheduler;
return this;
}
/**
* Sets the initial delay before the first keepAlive call.
* @param initialDelay The initial delay duration
* @return This builder instance for method chaining
*/
public Builder initialDelay(Duration initialDelay) {
Assert.notNull(initialDelay, "Initial delay must not be null");
this.initialDelay = initialDelay;
return this;
}
/**
* Sets the interval between subsequent keepAlive calls.
* @param interval The interval duration
* @return This builder instance for method chaining
*/
public Builder interval(Duration interval) {
Assert.notNull(interval, "Interval must not be null");
this.interval = interval;
return this;
}
/**
* Builds and returns a new KeepAliveScheduler instance.
* @return A new KeepAliveScheduler configured with the builder's settings
*/
public KeepAliveScheduler build() {
return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions);
}
}
}