Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3ef7d5d
feat(mcp): add notification handlers support for async client
JGoP-L Jan 15, 2026
5b9582d
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 15, 2026
ae06223
feat(mcp): add notification handlers support for sync client
JGoP-L Jan 15, 2026
b966d99
feat(mcp): add notification handlers support for sync client
JGoP-L Jan 15, 2026
378b7b1
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 15, 2026
9a23888
add synchronized
JGoP-L Jan 15, 2026
e9df304
Merge remote-tracking branch 'origin/feat/add-notification-handlers-s…
JGoP-L Jan 15, 2026
fa1e155
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 15, 2026
3e95f85
add volatile
JGoP-L Jan 16, 2026
2d086ef
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 16, 2026
7c97c55
fix Spotless
JGoP-L Jan 16, 2026
e99b485
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 16, 2026
e34c3fd
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 20, 2026
5d37bda
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 26, 2026
3839079
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 26, 2026
5733e57
fix
JGoP-L Jan 26, 2026
a8292de
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 26, 2026
e72373d
fix higress
JGoP-L Jan 26, 2026
20724d9
Merge branch 'main' into feat/add-notification-handlers-support-for-a…
JGoP-L Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,16 @@
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

/**
* Wrapper for asynchronous MCP clients using Project Reactor.
* This implementation delegates to {@link McpAsyncClient} and provides
* reactive operations that return Mono types.
* Wrapper for asynchronous MCP clients using Project Reactor. This implementation delegates to
* {@link McpAsyncClient} and provides reactive operations that return Mono types.
*
* <p>Example usage:
* <pre>{@code
* <p>Example usage: <pre>{@code
* McpAsyncClient client = ... // created via McpClient.async()
* McpAsyncClientWrapper wrapper = new McpAsyncClientWrapper("my-mcp", client);
* wrapper.initialize()
Expand All @@ -41,7 +40,7 @@ public class McpAsyncClientWrapper extends McpClientWrapper {

private static final Logger logger = LoggerFactory.getLogger(McpAsyncClientWrapper.class);

private final McpAsyncClient client;
private final AtomicReference<McpAsyncClient> clientRef;

/**
* Constructs a new asynchronous MCP client wrapper.
Expand All @@ -51,7 +50,32 @@ public class McpAsyncClientWrapper extends McpClientWrapper {
*/
public McpAsyncClientWrapper(String name, McpAsyncClient client) {
super(name);
this.client = client;
this.clientRef = new AtomicReference<>(client);
}

/**
* Sets the underlying MCP async client. This is called by McpClientBuilder after the client
* is created with notification handlers.
*
* @param client the MCP async client
*/
void setClient(McpAsyncClient client) {
this.clientRef.set(client);
}

/**
* Updates the cached tools map with new tools from the server. This method is called when the
* server sends a tools/list_changed notification.
*
* @param tools the new list of tools from the server (empty list clears cache)
*/
void updateCachedTools(List<McpSchema.Tool> tools) {
if (tools != null) {
// Clear and rebuild cache
cachedTools.clear();
tools.forEach(tool -> cachedTools.put(tool.name(), tool));
logger.info("[MCP-{}] Updated cached tools, total: {}", name, tools.size());
}
}
Comment on lines 78 to 87
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updateCachedTools() method performs a non-atomic clear-and-rebuild operation on cachedTools. Since this can be called asynchronously via notification handlers while other threads may be reading from the cache (e.g., via getCachedTool() or during initialization), this creates a race condition. While ConcurrentHashMap is thread-safe for individual operations, the clear-then-add pattern is not atomic. Consider synchronizing this method or using other concurrency patterns to ensure atomicity.

Copilot uses AI. Check for mistakes.

/**
Expand All @@ -68,6 +92,12 @@ public Mono<Void> initialize() {
return Mono.empty();
}

McpAsyncClient client = clientRef.get();
if (client == null) {
return Mono.error(
new IllegalStateException("McpAsyncClient not set. Call setClient() first."));
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "McpAsyncClient not set. Call setClient() first." exposes internal implementation details about the two-phase build pattern. Since setClient() is package-private and not part of the public API, users should never see this message in normal usage. Consider using a more user-friendly message like "MCP client not available" or ensure this state is impossible in production code.

Suggested change
return Mono.error(
new IllegalStateException("McpAsyncClient not set. Call setClient() first."));
return Mono.error(new IllegalStateException("MCP client not available"));

Copilot uses AI. Check for mistakes.
}

logger.info("Initializing MCP async client: {}", name);

return client.initialize()
Expand Down Expand Up @@ -99,7 +129,6 @@ public Mono<Void> initialize() {
* initialized before calling this method.
*
* @return a Mono emitting the list of available tools
* @throws IllegalStateException if the client is not initialized
*/
@Override
public Mono<List<McpSchema.Tool>> listTools() {
Expand All @@ -108,6 +137,11 @@ public Mono<List<McpSchema.Tool>> listTools() {
new IllegalStateException("MCP client '" + name + "' not initialized"));
}

McpAsyncClient client = clientRef.get();
if (client == null) {
return Mono.error(new IllegalStateException("MCP client '" + name + "' not available"));
}

return client.listTools().map(McpSchema.ListToolsResult::tools);
}

Expand All @@ -120,7 +154,6 @@ public Mono<List<McpSchema.Tool>> listTools() {
* @param toolName the name of the tool to call
* @param arguments the arguments to pass to the tool
* @return a Mono emitting the tool call result (may contain error information)
* @throws IllegalStateException if the client is not initialized
*/
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
Expand All @@ -129,6 +162,11 @@ public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Obje
new IllegalStateException("MCP client '" + name + "' not initialized"));
}

McpAsyncClient client = clientRef.get();
if (client == null) {
return Mono.error(new IllegalStateException("MCP client '" + name + "' not available"));
}

logger.debug("Calling MCP tool '{}' on client '{}'", toolName, name);

McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(toolName, arguments);
Expand Down Expand Up @@ -161,16 +199,17 @@ public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Obje
*/
@Override
public void close() {
if (client != null) {
McpAsyncClient toClose = clientRef.getAndSet(null);
if (toClose != null) {
logger.info("Closing MCP async client: {}", name);
try {
client.closeGracefully()
toClose.closeGracefully()
.doOnSuccess(v -> logger.debug("MCP client '{}' closed", name))
.doOnError(e -> logger.error("Error closing MCP client '{}'", name, e))
.block();
} catch (Exception e) {
logger.error("Exception during MCP client close", e);
client.close();
toClose.close();
}
}
initialized = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

/**
Expand Down Expand Up @@ -78,6 +80,7 @@ public class McpClientBuilder {

private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(120);
private static final Duration DEFAULT_INIT_TIMEOUT = Duration.ofSeconds(30);
private static final Logger logger = LoggerFactory.getLogger(McpClientBuilder.class);

private final String name;
private TransportConfig transportConfig;
Expand Down Expand Up @@ -283,6 +286,10 @@ public McpClientBuilder initializationTimeout(Duration timeout) {
/**
* Builds an asynchronous MCP client wrapper.
*
* <p>This method uses a two-phase build pattern to support notification handlers.
* The wrapper is created first, then the MCP client is built with notification consumers
* that can reference the wrapper.
*
* @return Mono emitting the async client wrapper
*/
public Mono<McpClientWrapper> buildAsync() {
Expand All @@ -303,15 +310,76 @@ public Mono<McpClientWrapper> buildAsync() {
McpSchema.ClientCapabilities clientCapabilities =
McpSchema.ClientCapabilities.builder().build();

// ========== Phase 1: Create wrapper (client is temporarily null) ==========
McpAsyncClientWrapper wrapper = new McpAsyncClientWrapper(name, null);

// ========== Phase 2: Build client (can reference wrapper) ==========
McpAsyncClient mcpClient =
McpClient.async(transport)
.requestTimeout(requestTimeout)
.initializationTimeout(initializationTimeout)
.clientInfo(clientInfo)
.capabilities(clientCapabilities)

// ----- Log notification Consumer -----
.loggingConsumer(
notification -> {
// Parse notification content
String level =
notification.level() != null
? notification.level().toString()
: "info";
String loggerName =
notification.logger() != null
? notification.logger()
: "mcp";
String data =
notification.data() != null
? notification.data()
: "";

// Log to SLF4J by level
switch (level.toLowerCase()) {
case "error" ->
logger.error(
"[MCP-{}] [{}] {}",
name,
loggerName,
data);
case "warning" ->
logger.warn(
"[MCP-{}] [{}] {}",
name,
loggerName,
data);
case "debug" ->
logger.debug(
"[MCP-{}] [{}] {}",
name,
loggerName,
data);
default ->
logger.info(
"[MCP-{}] [{}] {}",
name,
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The buildSync() method does not implement notification handlers like buildAsync() does. For consistency and feature parity, synchronous clients should also support logging and tools change notifications. Consider implementing a similar two-phase build pattern for buildSync() with notification consumers.

Copilot uses AI. Check for mistakes.
loggerName,
data);
}
return Mono.empty();
})

// ----- Tools change notification Consumer -----
.toolsChangeConsumer(
tools -> {
// Call wrapper method to update cache
wrapper.updateCachedTools(tools);
return Mono.empty();
})
.build();

return new McpAsyncClientWrapper(name, mcpClient);
// ========== Phase 3: Link MCP client to wrapper ==========
wrapper.setClient(mcpClient);
return wrapper;
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

/**
* Wrapper for synchronous MCP clients that converts blocking calls to reactive Mono types.
* This implementation delegates to {@link McpSyncClient} and wraps blocking operations
* in Reactor's boundedElastic scheduler to avoid blocking the event loop.
* Wrapper for synchronous MCP clients that converts blocking calls to reactive Mono types. This
* implementation delegates to {@link McpSyncClient} and wraps blocking operations in Reactor's
* boundedElastic scheduler to avoid blocking the event loop.
*
* <p>Example usage:
* <pre>{@code
* <p>Example usage: <pre>{@code
* McpSyncClient client = ... // created via McpClient.sync()
* McpSyncClientWrapper wrapper = new McpSyncClientWrapper("my-mcp", client);
* wrapper.initialize()
Expand All @@ -42,7 +42,7 @@ public class McpSyncClientWrapper extends McpClientWrapper {

private static final Logger logger = LoggerFactory.getLogger(McpSyncClientWrapper.class);

private final McpSyncClient client;
private final AtomicReference<McpSyncClient> clientRef;

/**
* Constructs a new synchronous MCP client wrapper.
Expand All @@ -52,7 +52,7 @@ public class McpSyncClientWrapper extends McpClientWrapper {
*/
public McpSyncClientWrapper(String name, McpSyncClient client) {
super(name);
this.client = client;
this.clientRef = new AtomicReference<>(client);
}

/**
Expand All @@ -70,10 +70,16 @@ public Mono<Void> initialize() {
return Mono.empty();
}

logger.info("Initializing MCP sync client: {}", name);

return Mono.fromCallable(
() -> {
McpSyncClient client = clientRef.get();
if (client == null) {
throw new IllegalStateException(
"McpSyncClient not set. Call setClient() first.");
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the async wrapper, this error message exposes internal implementation details. However, McpSyncClientWrapper doesn't have a setClient() method, making this message confusing and potentially misleading. Consider using a consistent message like "MCP client not available" across both wrappers.

Suggested change
throw new IllegalStateException(
"McpSyncClient not set. Call setClient() first.");
throw new IllegalStateException("MCP client not available");

Copilot uses AI. Check for mistakes.
}

logger.info("Initializing MCP sync client: {}", name);

// Initialize the client (blocking)
McpSchema.InitializeResult result = client.initialize();
logger.debug(
Expand Down Expand Up @@ -105,7 +111,6 @@ public Mono<Void> initialize() {
* must be initialized before calling this method.
*
* @return a Mono emitting the list of available tools
* @throws IllegalStateException if the client is not initialized
*/
@Override
public Mono<List<McpSchema.Tool>> listTools() {
Expand All @@ -114,6 +119,11 @@ public Mono<List<McpSchema.Tool>> listTools() {
new IllegalStateException("MCP client '" + name + "' not initialized"));
}

McpSyncClient client = clientRef.get();
if (client == null) {
return Mono.error(new IllegalStateException("MCP client '" + name + "' not available"));
}

return Mono.fromCallable(() -> client.listTools().tools())
.subscribeOn(Schedulers.boundedElastic());
}
Expand All @@ -127,7 +137,6 @@ public Mono<List<McpSchema.Tool>> listTools() {
* @param toolName the name of the tool to call
* @param arguments the arguments to pass to the tool
* @return a Mono emitting the tool call result (may contain error information)
* @throws IllegalStateException if the client is not initialized
*/
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
Expand All @@ -136,6 +145,11 @@ public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Obje
new IllegalStateException("MCP client '" + name + "' not initialized"));
}

McpSyncClient client = clientRef.get();
if (client == null) {
return Mono.error(new IllegalStateException("MCP client '" + name + "' not available"));
}

logger.debug("Calling MCP tool '{}' on client '{}'", toolName, name);

return Mono.fromCallable(
Expand Down Expand Up @@ -172,14 +186,15 @@ public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Obje
*/
@Override
public void close() {
if (client != null) {
McpSyncClient toClose = clientRef.getAndSet(null);
if (toClose != null) {
logger.info("Closing MCP sync client: {}", name);
try {
client.closeGracefully();
toClose.closeGracefully();
logger.debug("MCP client '{}' closed", name);
} catch (Exception e) {
logger.error("Exception during MCP client close", e);
client.close();
toClose.close();
}
}
initialized = false;
Expand Down
Loading
Loading