From b37c32cdbaac01963298a05d745d0f8d2085bc0d Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Thu, 3 Jul 2025 15:01:16 +0200
Subject: [PATCH] refactor: extract MCP client initialization logic into
LifecycleInitializer (#370)
- Create new LifecycleInitializer class to handle protocol initialization phase
- Move initialization logic from McpAsyncClient to dedicated initializer
- Add javadocs for MCP initialization process
- Implement protocol version negotiation and capability exchange
- Add exception handling for transport session recovery
- Include test suite for LifecyleInitializer
- Simplify McpAsyncClient by delegating initialization responsibilities
This refactoring improves separation of concerns and makes the initialization
process more maintainable and testable.
Signed-off-by: Christian Tzolov
---
.../client/LifecycleInitializer.java | 348 +++++++++++++++
.../client/McpAsyncClient.java | 222 ++--------
.../client/LifecycleInitializerTests.java | 412 ++++++++++++++++++
3 files changed, 795 insertions(+), 187 deletions(-)
create mode 100644 mcp/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java
create mode 100644 mcp/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java
diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java b/mcp/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java
new file mode 100644
index 0000000..e33fafa
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java
@@ -0,0 +1,348 @@
+package io.modelcontextprotocol.client;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.modelcontextprotocol.spec.McpClientSession;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
+import io.modelcontextprotocol.util.Assert;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.util.context.ContextView;
+
+/**
+ * Handles the protocol initialization phase between client and server
+ *
+ *
+ * The initialization phase MUST be the first interaction between client and server.
+ * During this phase, the client and server perform the following operations:
+ *
+ *
Establish protocol version compatibility
+ *
Exchange and negotiate capabilities
+ *
Share implementation details
+ *
+ *
+ * Client Initialization Process
+ *
+ * The client MUST initiate this phase by sending an initialize request containing:
+ *
+ *
Protocol version supported
+ *
Client capabilities
+ *
Client implementation information
+ *
+ *
+ *
+ * After successful initialization, the client MUST send an initialized notification to
+ * indicate it is ready to begin normal operations.
+ *
+ * Server Response
+ *
+ * The server MUST respond with its own capabilities and information.
+ *
+ * Protocol Version Negotiation
+ *
+ * In the initialize request, the client MUST send a protocol version it supports. This
+ * SHOULD be the latest version supported by the client.
+ *
+ *
+ * If the server supports the requested protocol version, it MUST respond with the same
+ * version. Otherwise, the server MUST respond with another protocol version it supports.
+ * This SHOULD be the latest version supported by the server.
+ *
+ *
+ * If the client does not support the version in the server's response, it SHOULD
+ * disconnect.
+ *
+ * Request Restrictions
+ *
+ * Important: The following restrictions apply during initialization:
+ *
+ *
The client SHOULD NOT send requests other than pings before the server has
+ * responded to the initialize request
+ *
The server SHOULD NOT send requests other than pings and logging before receiving
+ * the initialized notification
+ *
+ */
+class LifecycleInitializer {
+
+ private static final Logger logger = LoggerFactory.getLogger(LifecycleInitializer.class);
+
+ /**
+ * The MCP session supplier that manages bidirectional JSON-RPC communication between
+ * clients and servers.
+ */
+ private final Function sessionSupplier;
+
+ private final McpSchema.ClientCapabilities clientCapabilities;
+
+ private final McpSchema.Implementation clientInfo;
+
+ private List protocolVersions;
+
+ private final AtomicReference initializationRef = new AtomicReference<>();
+
+ /**
+ * The max timeout to await for the client-server connection to be initialized.
+ */
+ private final Duration initializationTimeout;
+
+ public LifecycleInitializer(McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
+ List protocolVersions, Duration initializationTimeout,
+ Function sessionSupplier) {
+
+ Assert.notNull(sessionSupplier, "Session supplier must not be null");
+ Assert.notNull(clientCapabilities, "Client capabilities must not be null");
+ Assert.notNull(clientInfo, "Client info must not be null");
+ Assert.notEmpty(protocolVersions, "Protocol versions must not be empty");
+ Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
+
+ this.sessionSupplier = sessionSupplier;
+ this.clientCapabilities = clientCapabilities;
+ this.clientInfo = clientInfo;
+ this.protocolVersions = Collections.unmodifiableList(new ArrayList<>(protocolVersions));
+ this.initializationTimeout = initializationTimeout;
+ }
+
+ /**
+ * This method is package-private and used for test only. Should not be called by user
+ * code.
+ * @param protocolVersions the Client supported protocol versions.
+ */
+ void setProtocolVersions(List protocolVersions) {
+ this.protocolVersions = protocolVersions;
+ }
+
+ /**
+ * Represents the initialization state of the MCP client.
+ */
+ interface Initialization {
+
+ /**
+ * Returns the MCP client session that is used to communicate with the server.
+ * This session is established during the initialization process and is used for
+ * sending requests and notifications.
+ * @return The MCP client session
+ */
+ McpClientSession mcpSession();
+
+ /**
+ * Returns the result of the MCP initialization process. This result contains
+ * information about the protocol version, capabilities, server info, and
+ * instructions provided by the server during the initialization phase.
+ * @return The result of the MCP initialization process
+ */
+ McpSchema.InitializeResult initializeResult();
+
+ }
+
+ /**
+ * Default implementation of the {@link Initialization} interface that manages the MCP
+ * client initialization process.
+ */
+ private static class DefaultInitialization implements Initialization {
+
+ /**
+ * A sink that emits the result of the MCP initialization process. It allows
+ * subscribers to wait for the initialization to complete.
+ */
+ private final Sinks.One initSink;
+
+ /**
+ * Holds the result of the MCP initialization process. It is used to cache the
+ * result for future requests.
+ */
+ private final AtomicReference result;
+
+ /**
+ * Holds the MCP client session that is used to communicate with the server. It is
+ * set during the initialization process and used for sending requests and
+ * notifications.
+ */
+ private final AtomicReference mcpClientSession;
+
+ private DefaultInitialization() {
+ this.initSink = Sinks.one();
+ this.result = new AtomicReference<>();
+ this.mcpClientSession = new AtomicReference<>();
+ }
+
+ // ---------------------------------------------------
+ // Public access for mcpSession and initializeResult because they are
+ // used in by the McpAsyncClient.
+ // ----------------------------------------------------
+ public McpClientSession mcpSession() {
+ return this.mcpClientSession.get();
+ }
+
+ public McpSchema.InitializeResult initializeResult() {
+ return this.result.get();
+ }
+
+ // ---------------------------------------------------
+ // Private accessors used internally by the LifecycleInitializer to set the MCP
+ // client session and complete the initialization process.
+ // ---------------------------------------------------
+ private void setMcpClientSession(McpClientSession mcpClientSession) {
+ this.mcpClientSession.set(mcpClientSession);
+ }
+
+ /**
+ * Returns a Mono that completes when the MCP client initialization is complete.
+ * This allows subscribers to wait for the initialization to finish before
+ * proceeding with further operations.
+ * @return A Mono that emits the result of the MCP initialization process
+ */
+ private Mono await() {
+ return this.initSink.asMono();
+ }
+
+ /**
+ * Completes the initialization process with the given result. It caches the
+ * result and emits it to all subscribers waiting for the initialization to
+ * complete.
+ * @param initializeResult The result of the MCP initialization process
+ */
+ private void complete(McpSchema.InitializeResult initializeResult) {
+ // first ensure the result is cached
+ this.result.set(initializeResult);
+ // inform all the subscribers waiting for the initialization
+ this.initSink.emitValue(initializeResult, Sinks.EmitFailureHandler.FAIL_FAST);
+ }
+
+ private void error(Throwable t) {
+ this.initSink.emitError(t, Sinks.EmitFailureHandler.FAIL_FAST);
+ }
+
+ private void close() {
+ this.mcpSession().close();
+ }
+
+ private Mono closeGracefully() {
+ return this.mcpSession().closeGracefully();
+ }
+
+ }
+
+ public boolean isInitialized() {
+ return this.currentInitializationResult() != null;
+ }
+
+ public McpSchema.InitializeResult currentInitializationResult() {
+ DefaultInitialization current = this.initializationRef.get();
+ McpSchema.InitializeResult initializeResult = current != null ? current.result.get() : null;
+ return initializeResult;
+ }
+
+ /**
+ * Hook to handle exceptions that occur during the MCP transport session.
+ *
+ * If the exception is a {@link McpTransportSessionNotFoundException}, it indicates
+ * that the session was not found, and we should re-initialize the client.
+ *
+ * @param t The exception to handle
+ */
+ public void handleException(Throwable t) {
+ logger.warn("Handling exception", t);
+ if (t instanceof McpTransportSessionNotFoundException) {
+ DefaultInitialization previous = this.initializationRef.getAndSet(null);
+ if (previous != null) {
+ previous.close();
+ }
+ // Providing an empty operation since we are only interested in triggering
+ // the implicit initialization step.
+ withIntitialization("re-initializing", result -> Mono.empty()).subscribe();
+ }
+ }
+
+ /**
+ * Utility method to ensure the initialization is established before executing an
+ * operation.
+ * @param The type of the result Mono
+ * @param actionName The action to perform when the client is initialized
+ * @param operation The operation to execute when the client is initialized
+ * @return A Mono that completes with the result of the operation
+ */
+ public Mono withIntitialization(String actionName, Function> operation) {
+ return Mono.deferContextual(ctx -> {
+ DefaultInitialization newInit = new DefaultInitialization();
+ DefaultInitialization previous = this.initializationRef.compareAndExchange(null, newInit);
+
+ boolean needsToInitialize = previous == null;
+ logger.debug(needsToInitialize ? "Initialization process started" : "Joining previous initialization");
+
+ Mono initializationJob = needsToInitialize ? doInitialize(newInit, ctx)
+ : previous.await();
+
+ return initializationJob.map(initializeResult -> this.initializationRef.get())
+ .timeout(this.initializationTimeout)
+ .onErrorResume(ex -> {
+ logger.warn("Failed to initialize", ex);
+ return Mono.error(new McpError("Client failed to initialize " + actionName));
+ })
+ .flatMap(operation);
+ });
+ }
+
+ private Mono doInitialize(DefaultInitialization initialization, ContextView ctx) {
+ initialization.setMcpClientSession(this.sessionSupplier.apply(ctx));
+
+ McpClientSession mcpClientSession = initialization.mcpSession();
+
+ String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
+
+ McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(latestVersion,
+ this.clientCapabilities, this.clientInfo);
+
+ Mono result = mcpClientSession.sendRequest(McpSchema.METHOD_INITIALIZE,
+ initializeRequest, McpAsyncClient.INITIALIZE_RESULT_TYPE_REF);
+
+ return result.flatMap(initializeResult -> {
+ logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
+ initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
+ initializeResult.instructions());
+
+ if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
+ return Mono.error(new McpError(
+ "Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
+ }
+
+ return mcpClientSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
+ .thenReturn(initializeResult);
+ }).doOnNext(initialization::complete).onErrorResume(ex -> {
+ initialization.error(ex);
+ return Mono.error(ex);
+ });
+ }
+
+ /**
+ * Closes the current initialization if it exists.
+ */
+ public void close() {
+ DefaultInitialization current = this.initializationRef.getAndSet(null);
+ if (current != null) {
+ current.close();
+ }
+ }
+
+ /**
+ * Gracefully closes the current initialization if it exists.
+ * @return A Mono that completes when the connection is closed
+ */
+ public Mono> closeGracefully() {
+ return Mono.defer(() -> {
+ DefaultInitialization current = this.initializationRef.getAndSet(null);
+ Mono> sessionClose = current != null ? current.closeGracefully() : Mono.empty();
+ return sessionClose;
+ });
+ }
+
+}
\ No newline at end of file
diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
index fa76f39..cf8142c 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
@@ -10,7 +10,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import org.slf4j.Logger;
@@ -19,8 +18,6 @@ import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpClientSession;
-import io.modelcontextprotocol.spec.McpClientSession.NotificationHandler;
-import io.modelcontextprotocol.spec.McpClientSession.RequestHandler;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
@@ -36,13 +33,12 @@ import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest;
import io.modelcontextprotocol.spec.McpSchema.Root;
-import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
+import io.modelcontextprotocol.spec.McpClientSession.NotificationHandler;
+import io.modelcontextprotocol.spec.McpClientSession.RequestHandler;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
-import reactor.core.publisher.Sinks;
-import reactor.util.context.ContextView;
/**
* The Model Context Protocol (MCP) client implementation that provides asynchronous
@@ -104,13 +100,6 @@ public class McpAsyncClient {
public static final TypeReference LOGGING_MESSAGE_NOTIFICATION_TYPE_REF = new TypeReference<>() {
};
- private final AtomicReference initializationRef = new AtomicReference<>();
-
- /**
- * The max timeout to await for the client-server connection to be initialized.
- */
- private final Duration initializationTimeout;
-
/**
* Client capabilities.
*/
@@ -154,15 +143,9 @@ public class McpAsyncClient {
private final McpClientTransport transport;
/**
- * Supported protocol versions.
+ * The lifecycle initializer that manages the client-server connection initialization.
*/
- private List protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
-
- /**
- * The MCP session supplier that manages bidirectional JSON-RPC communication between
- * clients and servers.
- */
- private final Function sessionSupplier;
+ private final LifecycleInitializer initializer;
/**
* Create a new McpAsyncClient with the given transport and session request-response
@@ -183,7 +166,6 @@ public class McpAsyncClient {
this.clientCapabilities = features.clientCapabilities();
this.transport = transport;
this.roots = new ConcurrentHashMap<>(features.roots());
- this.initializationTimeout = initializationTimeout;
// Request Handlers
Map> requestHandlers = new HashMap<>();
@@ -271,28 +253,11 @@ public class McpAsyncClient {
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
asyncLoggingNotificationHandler(loggingConsumersFinal));
- this.transport.setExceptionHandler(this::handleException);
- this.sessionSupplier = ctx -> new McpClientSession(requestTimeout, transport, requestHandlers,
- notificationHandlers, con -> con.contextWrite(ctx));
- }
-
- private void handleException(Throwable t) {
- logger.warn("Handling exception", t);
- if (t instanceof McpTransportSessionNotFoundException) {
- Initialization previous = this.initializationRef.getAndSet(null);
- if (previous != null) {
- previous.close();
- }
- // Providing an empty operation since we are only interested in triggering the
- // implicit initialization step.
- withSession("re-initializing", result -> Mono.empty()).subscribe();
- }
- }
-
- private McpSchema.InitializeResult currentInitializationResult() {
- Initialization current = this.initializationRef.get();
- McpSchema.InitializeResult initializeResult = current != null ? current.result.get() : null;
- return initializeResult;
+ this.initializer = new LifecycleInitializer(clientCapabilities, clientInfo,
+ List.of(McpSchema.LATEST_PROTOCOL_VERSION), initializationTimeout,
+ ctx -> new McpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers,
+ con -> con.contextWrite(ctx)));
+ this.transport.setExceptionHandler(this.initializer::handleException);
}
/**
@@ -300,7 +265,7 @@ public class McpAsyncClient {
* @return The server capabilities
*/
public McpSchema.ServerCapabilities getServerCapabilities() {
- McpSchema.InitializeResult initializeResult = currentInitializationResult();
+ McpSchema.InitializeResult initializeResult = this.initializer.currentInitializationResult();
return initializeResult != null ? initializeResult.capabilities() : null;
}
@@ -310,7 +275,7 @@ public class McpAsyncClient {
* @return The server instructions
*/
public String getServerInstructions() {
- McpSchema.InitializeResult initializeResult = currentInitializationResult();
+ McpSchema.InitializeResult initializeResult = this.initializer.currentInitializationResult();
return initializeResult != null ? initializeResult.instructions() : null;
}
@@ -319,7 +284,7 @@ public class McpAsyncClient {
* @return The server implementation details
*/
public McpSchema.Implementation getServerInfo() {
- McpSchema.InitializeResult initializeResult = currentInitializationResult();
+ McpSchema.InitializeResult initializeResult = this.initializer.currentInitializationResult();
return initializeResult != null ? initializeResult.serverInfo() : null;
}
@@ -328,8 +293,7 @@ public class McpAsyncClient {
* @return true if the client-server connection is initialized
*/
public boolean isInitialized() {
- Initialization current = this.initializationRef.get();
- return current != null && (current.result.get() != null);
+ return this.initializer.isInitialized();
}
/**
@@ -352,10 +316,7 @@ public class McpAsyncClient {
* Closes the client connection immediately.
*/
public void close() {
- Initialization current = this.initializationRef.getAndSet(null);
- if (current != null) {
- current.close();
- }
+ this.initializer.close();
this.transport.close();
}
@@ -365,9 +326,7 @@ public class McpAsyncClient {
*/
public Mono closeGracefully() {
return Mono.defer(() -> {
- Initialization current = this.initializationRef.getAndSet(null);
- Mono> sessionClose = current != null ? current.closeGracefully() : Mono.empty();
- return sessionClose.then(transport.closeGracefully());
+ return this.initializer.closeGracefully().then(transport.closeGracefully());
});
}
@@ -401,118 +360,7 @@ public class McpAsyncClient {
*
*/
public Mono initialize() {
- return withSession("by explicit API call", init -> Mono.just(init.get()));
- }
-
- private Mono doInitialize(Initialization initialization, ContextView ctx) {
- initialization.setMcpClientSession(this.sessionSupplier.apply(ctx));
-
- McpClientSession mcpClientSession = initialization.mcpSession();
-
- String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
-
- McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
- latestVersion,
- this.clientCapabilities,
- this.clientInfo); // @formatter:on
-
- Mono result = mcpClientSession.sendRequest(McpSchema.METHOD_INITIALIZE,
- initializeRequest, INITIALIZE_RESULT_TYPE_REF);
-
- return result.flatMap(initializeResult -> {
- logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
- initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
- initializeResult.instructions());
-
- if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
- return Mono.error(new McpError(
- "Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
- }
-
- return mcpClientSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
- .thenReturn(initializeResult);
- }).doOnNext(initialization::complete).onErrorResume(ex -> {
- initialization.error(ex);
- return Mono.error(ex);
- });
- }
-
- private static class Initialization {
-
- private final Sinks.One initSink = Sinks.one();
-
- private final AtomicReference result = new AtomicReference<>();
-
- private final AtomicReference mcpClientSession = new AtomicReference<>();
-
- static Initialization create() {
- return new Initialization();
- }
-
- void setMcpClientSession(McpClientSession mcpClientSession) {
- this.mcpClientSession.set(mcpClientSession);
- }
-
- McpClientSession mcpSession() {
- return this.mcpClientSession.get();
- }
-
- McpSchema.InitializeResult get() {
- return this.result.get();
- }
-
- Mono await() {
- return this.initSink.asMono();
- }
-
- void complete(McpSchema.InitializeResult initializeResult) {
- // first ensure the result is cached
- this.result.set(initializeResult);
- // inform all the subscribers waiting for the initialization
- this.initSink.emitValue(initializeResult, Sinks.EmitFailureHandler.FAIL_FAST);
- }
-
- void error(Throwable t) {
- this.initSink.emitError(t, Sinks.EmitFailureHandler.FAIL_FAST);
- }
-
- void close() {
- this.mcpSession().close();
- }
-
- Mono closeGracefully() {
- return this.mcpSession().closeGracefully();
- }
-
- }
-
- /**
- * Utility method to handle the common pattern of ensuring initialization before
- * executing an operation.
- * @param The type of the result Mono
- * @param actionName The action to perform when the client is initialized
- * @param operation The operation to execute when the client is initialized
- * @return A Mono that completes with the result of the operation
- */
- private Mono withSession(String actionName, Function> operation) {
- return Mono.deferContextual(ctx -> {
- Initialization newInit = Initialization.create();
- Initialization previous = this.initializationRef.compareAndExchange(null, newInit);
-
- boolean needsToInitialize = previous == null;
- logger.debug(needsToInitialize ? "Initialization process started" : "Joining previous initialization");
-
- Mono initializationJob = needsToInitialize ? doInitialize(newInit, ctx)
- : previous.await();
-
- return initializationJob.map(initializeResult -> this.initializationRef.get())
- .timeout(this.initializationTimeout)
- .onErrorResume(ex -> {
- logger.warn("Failed to initialize", ex);
- return Mono.error(new McpError("Client failed to initialize " + actionName));
- })
- .flatMap(operation);
- });
+ return this.initializer.withIntitialization("by explicit API call", init -> Mono.just(init.initializeResult()));
}
// --------------------------
@@ -524,7 +372,7 @@ public class McpAsyncClient {
* @return A Mono that completes with the server's ping response
*/
public Mono