This is an automated email from the ASF dual-hosted git repository.
Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 2860757583a5 CAMEL-23957: Synchronize MCP reconnect to protect shared
endpoint tool state (#24587)
2860757583a5 is described below
commit 2860757583a5b6e95d17eaeb2d30adb1ed5fa0a3
Author: matteominin <[email protected]>
AuthorDate: Fri Jul 10 20:48:04 2026 +0200
CAMEL-23957: Synchronize MCP reconnect to protect shared endpoint tool
state (#24587)
* CAMEL-23957: Synchronize MCP reconnect to protect shared endpoint tool
state
OpenAIEndpoint shared the MCP tool state (cachedMcpTools, toolClientMap,
returnDirectTools, toolToServerName) across concurrent exchanges and mutated
it in place during reconnectMcpServer. In a multi-threaded route hitting an
MCP transport error this caused:
- ConcurrentModificationException / partially-rebuilt tool lists for
producers iterating getMcpTools();
- concurrent reconnects for the same server closing each other's fresh
clients and leaking transports/stdio subprocesses;
- stale returnDirectTools and toolToServerName entries kept after
re-listing.
Fix:
- guard reconnect with a per-server ReentrantLock plus a re-check so only
the first thread reconnects and the rest observe the new client;
- publish the tool state as immutable snapshots swapped via volatile
references, so readers stay lock-free and never see a half-rebuilt list;
- prune tools that vanished server-side from all four collections.
Adds OpenAIEndpointMcpReconnectConcurrencyTest covering the three issues
(reader safety, single reconnect, server-side tool pruning).
Co-Authored-By: Claude Opus 4.8
* Consolidate MCP tool state into a single immutable snapshot
Address review feedback on the reconnect concurrency fix:
- Replace the four separately-published volatile collections with one
immutable McpToolState record swapped through a single volatile
reference, eliminating torn reads (a reader could see the new tool
list with the old client map).
- Guard the rebuild-and-publish section with a global lock so two
servers reconnecting concurrently no longer lose each other's update;
the per-server lock still covers the expensive close/connect/listTools.
- Make shutdown safe: doStop swaps to an empty snapshot under the global
lock and sets an mcpStopped flag re-checked inside the locked reconnect
section, so a reconnect racing with stop cannot NPE or leak a transport.
- Re-read the endpoint snapshot per tool call in OpenAIProducer and
OpenAIToolExecutionProducer so the agentic loop observes mid-exchange
reconnects instead of holding a stale (closed) client.
- Name the field/accessors mcpToolState/getMcpToolState/setMcpToolState
to match the type and the mcp* convention.
Co-Authored-By: Claude Opus 4.8
---
.../camel/component/openai/McpToolState.java | 45 ++++
.../camel/component/openai/OpenAIEndpoint.java | 258 ++++++++++++++-------
.../camel/component/openai/OpenAIProducer.java | 14 +-
.../openai/OpenAIToolExecutionProducer.java | 6 +-
.../OpenAIEndpointMcpReconnectConcurrencyTest.java | 241 +++++++++++++++++++
.../openai/OpenAIProducerMcpMockTest.java | 8 +-
.../openai/integration/OpenAIMcpAdvancedIT.java | 6 +-
7 files changed, 473 insertions(+), 105 deletions(-)
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolState.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolState.java
new file mode 100644
index 000000000000..72d2e87d9236
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolState.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.openai.models.chat.completions.ChatCompletionFunctionTool;
+import io.modelcontextprotocol.client.McpSyncClient;
+
+/**
+ * Immutable snapshot of the MCP tool state shared by all concurrent exchanges.
+ */
+record McpToolState(
+ List<ChatCompletionFunctionTool> tools,
+ Map<String, McpSyncClient> toolClientMap,
+ Map<String, String> toolToServerName,
+ Set<String> returnDirectTools) {
+
+ McpToolState {
+ tools = List.copyOf(tools);
+ toolClientMap = Map.copyOf(toolClientMap);
+ toolToServerName = Map.copyOf(toolToServerName);
+ returnDirectTools = Set.copyOf(returnDirectTools);
+ }
+
+ static McpToolState empty() {
+ return new McpToolState(List.of(), Map.of(), Map.of(), Set.of());
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java
index b81d54ccff36..7c7b09b85e4a 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java
@@ -26,6 +26,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
@@ -86,11 +88,12 @@ public class OpenAIEndpoint extends DefaultEndpoint {
private OpenAIClient client;
- private List<ChatCompletionFunctionTool> cachedMcpTools;
- private Map<String, McpSyncClient> toolClientMap;
- private Set<String> returnDirectTools;
+ private final ReentrantLock globalMcpLock = new ReentrantLock();
+ private Map<String, ReentrantLock> mcpClientLocks;
+
+ private volatile McpToolState mcpToolState = McpToolState.empty();
+ private volatile boolean mcpStopped;
private Map<String, Map<String, String>> serverConfigs;
- private Map<String, String> toolToServerName;
public OpenAIEndpoint(String uri, OpenAIComponent component,
OpenAIConfiguration configuration) {
super(uri, component);
@@ -115,28 +118,31 @@ public class OpenAIEndpoint extends DefaultEndpoint {
@Override
protected void doStart() throws Exception {
super.doStart();
+ mcpStopped = false;
client = createClient();
initializeMcpServers();
}
@Override
protected void doStop() throws Exception {
- if (toolClientMap != null) {
- for (McpSyncClient mcpClient : new
HashSet<>(toolClientMap.values())) {
- try {
- mcpClient.closeGracefully();
- } catch (Exception e) {
- LOG.warn("Error closing MCP client: {}", e.getMessage(),
e);
- }
- }
- toolClientMap.clear();
- }
- if (cachedMcpTools != null) {
- cachedMcpTools.clear();
+ Set<McpSyncClient> toClose;
+ globalMcpLock.lock();
+ try {
+ mcpStopped = true;
+ toClose = new HashSet<>(mcpToolState.toolClientMap().values());
+ mcpToolState = McpToolState.empty();
+ } finally {
+ globalMcpLock.unlock();
}
- if (returnDirectTools != null) {
- returnDirectTools.clear();
+
+ for (McpSyncClient mcpClient : toClose) {
+ try {
+ mcpClient.closeGracefully();
+ } catch (Exception e) {
+ LOG.warn("Error closing MCP client: {}", e.getMessage(), e);
+ }
}
+
if (client != null) {
client.close();
client = null;
@@ -152,10 +158,7 @@ public class OpenAIEndpoint extends DefaultEndpoint {
}
LOG.debug("Initializing MCP servers from configuration: {}",
mcpServerConfig.keySet());
- cachedMcpTools = new ArrayList<>();
- toolClientMap = new HashMap<>();
- returnDirectTools = new HashSet<>();
- toolToServerName = new HashMap<>();
+ mcpClientLocks = new HashMap<>();
// Group flat keys by server name: "fs.transportType" -> {"fs":
{"transportType": ...}}
serverConfigs = new HashMap<>();
@@ -168,33 +171,31 @@ public class OpenAIEndpoint extends DefaultEndpoint {
String serverName = key.substring(0, dot);
String property = key.substring(dot + 1);
serverConfigs.computeIfAbsent(serverName, k -> new
HashMap<>()).put(property, String.valueOf(entry.getValue()));
+ mcpClientLocks.putIfAbsent(serverName, new ReentrantLock());
}
+ List<ChatCompletionFunctionTool> tools = new ArrayList<>();
+ Map<String, McpSyncClient> toolClientMap = new HashMap<>();
+ Map<String, String> toolToServerName = new HashMap<>();
+ Set<String> returnDirectTools = new HashSet<>();
+
for (Map.Entry<String, Map<String, String>> entry :
serverConfigs.entrySet()) {
String serverName = entry.getKey();
Map<String, String> props = entry.getValue();
- String transportType = props.get("transportType");
- if (transportType == null) {
+ if (props.get("transportType") == null) {
throw new IllegalArgumentException("mcpServer." + serverName +
".transportType is required");
}
- LOG.debug("Creating MCP transport for server '{}' with type '{}'",
serverName, transportType);
- McpClientTransport transport = createMcpTransport(serverName,
transportType, props);
- Duration timeout =
Duration.ofSeconds(configuration.getMcpTimeout());
- McpSyncClient mcpClient = McpClient.sync(transport)
- .requestTimeout(timeout)
- .initializationTimeout(timeout)
- .build();
- mcpClient.initialize();
+ McpSyncClient mcpClient = createMcpClient(serverName, props);
LOG.debug("MCP server '{}' initialized, listing tools",
serverName);
McpSchema.ListToolsResult toolsResult = mcpClient.listTools();
- List<McpSchema.Tool> tools = toolsResult.tools();
+ List<McpSchema.Tool> serverTools = toolsResult.tools();
- cachedMcpTools.addAll(McpToolConverter.convert(tools));
+ tools.addAll(McpToolConverter.convert(serverTools));
- for (McpSchema.Tool tool : tools) {
+ for (McpSchema.Tool tool : serverTools) {
if (toolClientMap.putIfAbsent(tool.name(), mcpClient) != null)
{
LOG.warn("Duplicate MCP tool name '{}' from server '{}',
using first registered", tool.name(),
serverName);
@@ -202,14 +203,16 @@ public class OpenAIEndpoint extends DefaultEndpoint {
toolToServerName.put(tool.name(), serverName);
}
- if (tool.annotations() != null &&
Boolean.TRUE.equals(tool.annotations().returnDirect())) {
+ if (isReturnDirect(tool)) {
returnDirectTools.add(tool.name());
}
}
- LOG.info("Initialized MCP server '{}' with {} tools: {}",
serverName, tools.size(),
- tools.stream().map(McpSchema.Tool::name).toList());
+ LOG.info("Initialized MCP server '{}' with {} tools: {}",
serverName, serverTools.size(),
+ serverTools.stream().map(McpSchema.Tool::name).toList());
}
+
+ mcpToolState = new McpToolState(tools, toolClientMap,
toolToServerName, returnDirectTools);
}
private McpClientTransport createMcpTransport(String serverName, String
transportType, Map<String, String> props)
@@ -270,6 +273,22 @@ public class OpenAIEndpoint extends DefaultEndpoint {
};
}
+ /**
+ * Creates and initializes an MCP client for the given server.
+ */
+ McpSyncClient createMcpClient(String serverName, Map<String, String>
props) throws Exception {
+ String transportType = props.get("transportType");
+ LOG.debug("Creating MCP transport for server '{}' with type '{}'",
serverName, transportType);
+ McpClientTransport transport = createMcpTransport(serverName,
transportType, props);
+ Duration timeout = Duration.ofSeconds(configuration.getMcpTimeout());
+ McpSyncClient mcpClient = McpClient.sync(transport)
+ .requestTimeout(timeout)
+ .initializationTimeout(timeout)
+ .build();
+ mcpClient.initialize();
+ return mcpClient;
+ }
+
private List<String> parseMcpProtocolVersions() {
String versions = configuration.getMcpProtocolVersions();
if (versions == null || versions.isBlank()) {
@@ -279,29 +298,56 @@ public class OpenAIEndpoint extends DefaultEndpoint {
}
/**
- * Reconnects the MCP server for the given tool name. Closes the old
client, creates a new transport and client,
- * re-lists tools, and updates all internal mappings.
+ * Reconnects the MCP server that owns the given tool. Serializes
concurrent reconnects of the same server via a
+ * per-server lock and skips the reconnect if another thread already
replaced the failed client.
*
- * @param toolName the tool whose server needs reconnecting
- * @return the new McpSyncClient, or null if reconnection failed
+ * @param oldClient the client that failed and should be replaced
+ * @param toolName the tool whose server needs reconnecting
+ * @return the new (or already-reconnected) McpSyncClient, or
null if reconnection failed
*/
- McpSyncClient reconnectMcpServer(String toolName) {
- String serverName = toolToServerName.get(toolName);
+ McpSyncClient reconnectMcpServer(McpSyncClient oldClient, String toolName)
{
+ String serverName = mcpToolState.toolToServerName().get(toolName);
if (serverName == null || serverConfigs == null) {
LOG.warn("Cannot reconnect: no server configuration found for tool
'{}'", toolName);
return null;
}
+ ReentrantLock lock = mcpClientLocks.get(serverName);
+ if (lock == null) {
+ LOG.warn("Cannot reconnect: no lock found for server '{}'",
serverName);
+ return null;
+ }
+
+ lock.lock();
+ try {
+ McpSyncClient currClient =
mcpToolState.toolClientMap().get(toolName);
+ if (currClient != null && currClient != oldClient) {
+ return currClient;
+ }
+
+ LOG.info("Reconnecting MCP server '{}' for tool '{}'", serverName,
toolName);
+ return doReconnectMcpServer(oldClient, serverName);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Performs the actual reconnect work: closes the old client, creates a
new transport and client, re-lists tools,
+ * and republishes the shared tool state. The caller must hold the
per-server lock for {@code serverName}.
+ *
+ * @param oldClient the old McpSyncClient to close
+ * @param serverName the name of the MCP server to reconnect
+ * @return the new McpSyncClient, or null if reconnection failed
+ */
+ private McpSyncClient doReconnectMcpServer(McpSyncClient oldClient, String
serverName) {
Map<String, String> props = serverConfigs.get(serverName);
if (props == null) {
LOG.warn("Cannot reconnect: no configuration found for server
'{}'", serverName);
return null;
}
- LOG.info("Reconnecting MCP server '{}' for tool '{}'", serverName,
toolName);
-
// Close the old client for this server
- McpSyncClient oldClient = toolClientMap.get(toolName);
if (oldClient != null) {
try {
oldClient.closeGracefully();
@@ -311,35 +357,41 @@ public class OpenAIEndpoint extends DefaultEndpoint {
}
try {
- String transportType = props.get("transportType");
- McpClientTransport transport = createMcpTransport(serverName,
transportType, props);
- Duration timeout =
Duration.ofSeconds(configuration.getMcpTimeout());
- McpSyncClient newClient = McpClient.sync(transport)
- .requestTimeout(timeout)
- .initializationTimeout(timeout)
- .build();
- newClient.initialize();
-
- // Re-list tools and update mappings for all tools from this server
- McpSchema.ListToolsResult toolsResult = newClient.listTools();
- List<McpSchema.Tool> tools = toolsResult.tools();
-
- // Remove old mappings for this server
- toolClientMap.entrySet().removeIf(e -> e.getValue() == oldClient);
- cachedMcpTools.removeIf(t -> {
- String name = t.function().name();
- return serverName.equals(toolToServerName.get(name));
- });
-
- // Add new mappings
- cachedMcpTools.addAll(McpToolConverter.convert(tools));
- for (McpSchema.Tool tool : tools) {
- toolClientMap.put(tool.name(), newClient);
- toolToServerName.put(tool.name(), serverName);
-
- if (tool.annotations() != null &&
Boolean.TRUE.equals(tool.annotations().returnDirect())) {
- returnDirectTools.add(tool.name());
+ McpSyncClient newClient = createMcpClient(serverName, props);
+
+ List<McpSchema.Tool> tools = newClient.listTools().tools();
+
+ globalMcpLock.lock();
+ try {
+ if (mcpStopped) {
+ newClient.closeGracefully();
+ return null;
}
+ // Rebuild shared tool state, replacing this server's old
tools with the freshly listed ones
+ Set<String> oldServerTools = toolsForServer(serverName);
+ List<ChatCompletionFunctionTool> newTools = new
ArrayList<>(mcpToolState.tools());
+ Map<String, McpSyncClient> newClientMap = new
HashMap<>(mcpToolState.toolClientMap());
+ Map<String, String> newToolToServer = new
HashMap<>(mcpToolState.toolToServerName());
+ Set<String> newReturnDirect = new
HashSet<>(mcpToolState.returnDirectTools());
+
+ newTools.removeIf(t ->
oldServerTools.contains(t.function().name()));
+ newClientMap.keySet().removeIf(oldServerTools::contains);
+ newToolToServer.keySet().removeIf(oldServerTools::contains);
+ newReturnDirect.removeIf(oldServerTools::contains);
+
+ newTools.addAll(McpToolConverter.convert(tools));
+ for (McpSchema.Tool tool : tools) {
+ newClientMap.put(tool.name(), newClient);
+ newToolToServer.put(tool.name(), serverName);
+ if (isReturnDirect(tool)) {
+ newReturnDirect.add(tool.name());
+ }
+ }
+
+ // Publish immutable snapshots for lock-free readers
+ mcpToolState = new McpToolState(newTools, newClientMap,
newToolToServer, newReturnDirect);
+ } finally {
+ globalMcpLock.unlock();
}
LOG.info("Reconnected MCP server '{}' with {} tools: {}",
serverName, tools.size(),
@@ -351,6 +403,17 @@ public class OpenAIEndpoint extends DefaultEndpoint {
}
}
+ private Set<String> toolsForServer(String serverName) {
+ return mcpToolState.toolToServerName().entrySet().stream()
+ .filter(e -> serverName.equals(e.getValue()))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ }
+
+ private static boolean isReturnDirect(McpSchema.Tool tool) {
+ return tool.annotations() != null &&
Boolean.TRUE.equals(tool.annotations().returnDirect());
+ }
+
McpSchema.CallToolResult callTool(McpSyncClient mcpClient, String
toolName, Map<String, Object> argsMap) {
McpSchema.CallToolRequest request = new
McpSchema.CallToolRequest(toolName, argsMap, null);
try {
@@ -360,7 +423,7 @@ public class OpenAIEndpoint extends DefaultEndpoint {
throw e;
}
LOG.info("Transport error calling tool '{}', attempting reconnect:
{}", toolName, e.getMessage());
- McpSyncClient newClient = reconnectMcpServer(toolName);
+ McpSyncClient newClient = reconnectMcpServer(mcpClient, toolName);
if (newClient == null) {
throw e;
}
@@ -517,28 +580,47 @@ public class OpenAIEndpoint extends DefaultEndpoint {
return client;
}
- public List<ChatCompletionFunctionTool> getMcpTools() {
- return cachedMcpTools;
+ public void addReturnDirectTool(String toolName) {
+ globalMcpLock.lock();
+ try {
+ Set<String> newReturnDirect = new
HashSet<>(mcpToolState.returnDirectTools());
+ newReturnDirect.add(toolName);
+ mcpToolState = new McpToolState(
+ mcpToolState.tools(), mcpToolState.toolClientMap(),
+ mcpToolState.toolToServerName(), newReturnDirect);
+ } finally {
+ globalMcpLock.unlock();
+ }
}
- public Map<String, McpSyncClient> getToolClientMap() {
- return toolClientMap;
+ public void removeReturnDirectTool(String toolName) {
+ globalMcpLock.lock();
+ try {
+ Set<String> newReturnDirect = new
HashSet<>(mcpToolState.returnDirectTools());
+ newReturnDirect.remove(toolName);
+ mcpToolState = new McpToolState(
+ mcpToolState.tools(), mcpToolState.toolClientMap(),
+ mcpToolState.toolToServerName(), newReturnDirect);
+ } finally {
+ globalMcpLock.unlock();
+ }
}
- public Set<String> getReturnDirectTools() {
- return returnDirectTools;
+ McpToolState getMcpToolState() {
+ return mcpToolState;
}
// Package-private setters for testing
- void setMcpTools(List<ChatCompletionFunctionTool> tools) {
- this.cachedMcpTools = tools;
+
+ void setMcpToolState(McpToolState state) {
+ this.mcpToolState = state;
}
- void setToolClientMap(Map<String, McpSyncClient> map) {
- this.toolClientMap = map;
+ void setServerConfigs(Map<String, Map<String, String>> configs) {
+ this.serverConfigs = configs;
}
- void setReturnDirectTools(Set<String> tools) {
- this.returnDirectTools = tools;
+ void setMcpClientLocks(Map<String, ReentrantLock> locks) {
+ this.mcpClientLocks = locks;
}
}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index ab9cf76665e9..93c543f9d4d1 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -26,7 +26,6 @@ import java.util.Base64;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -182,7 +181,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
applyAdditionalBodyProperties(paramsBuilder, config);
// Add MCP tools to the request if configured
- List<ChatCompletionFunctionTool> mcpTools =
getEndpoint().getMcpTools();
+ List<ChatCompletionFunctionTool> mcpTools =
getEndpoint().getMcpToolState().tools();
boolean hasMcpTools = mcpTools != null && !mcpTools.isEmpty();
if (hasMcpTools) {
for (ChatCompletionFunctionTool tool : mcpTools) {
@@ -415,7 +414,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
private void processNonStreaming(Exchange exchange,
ChatCompletionCreateParams params, OpenAIConfiguration config)
throws Exception {
- List<ChatCompletionFunctionTool> mcpTools =
getEndpoint().getMcpTools();
+ List<ChatCompletionFunctionTool> mcpTools =
getEndpoint().getMcpToolState().tools();
boolean hasMcpTools = mcpTools != null && !mcpTools.isEmpty();
if (!hasMcpTools || !config.isAutoToolExecution()) {
@@ -452,11 +451,9 @@ public class OpenAIProducer extends DefaultAsyncProducer {
Exchange exchange, ChatCompletionCreateParams params,
OpenAIConfiguration config)
throws Exception {
- Map<String, McpSyncClient> toolClientMap =
getEndpoint().getToolClientMap();
- Set<String> returnDirectTools = getEndpoint().getReturnDirectTools();
int maxIterations = config.getMaxToolIterations();
LOG.debug("Starting agentic loop with maxToolIterations={}, available
tools: {}", maxIterations,
- toolClientMap.keySet());
+ getEndpoint().getMcpToolState().toolClientMap().keySet());
// Rebuild the builder from the immutable params so we can accumulate
messages
ChatCompletionCreateParams.Builder paramsBuilder = params.toBuilder();
@@ -513,7 +510,8 @@ public class OpenAIProducer extends DefaultAsyncProducer {
String toolCallId = toolCall.asFunction().id();
toolCallsLog.add(toolName);
- McpSyncClient mcpClient = toolClientMap.get(toolName);
+ McpToolState mcpToolState = getEndpoint().getMcpToolState();
+ McpSyncClient mcpClient =
mcpToolState.toolClientMap().get(toolName);
if (mcpClient == null) {
throw new IllegalStateException(
"Tool '" + toolName + "' not found in any
configured MCP server");
@@ -532,7 +530,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
allReturnDirect = false;
} else {
resultContent =
extractTextContent(toolResult.content());
- if (!returnDirectTools.contains(toolName)) {
+ if
(!mcpToolState.returnDirectTools().contains(toolName)) {
allReturnDirect = false;
}
}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
index 21544f92869e..616ab9733c29 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
@@ -124,8 +124,7 @@ public class OpenAIToolExecutionProducer extends
DefaultProducer {
.build()));
// Execute each tool call via MCP and add tool result messages
- Map<String, McpSyncClient> toolClientMap =
getEndpoint().getToolClientMap();
- if (toolClientMap == null || toolClientMap.isEmpty()) {
+ if (getEndpoint().getMcpToolState().toolClientMap().isEmpty()) {
throw new IllegalStateException(
"No MCP tool clients configured on the endpoint. Configure
mcpServer.* parameters.");
}
@@ -136,7 +135,8 @@ public class OpenAIToolExecutionProducer extends
DefaultProducer {
String argsJson = toolCall.asFunction().function().arguments();
String toolCallId = toolCall.asFunction().id();
- McpSyncClient mcpClient = toolClientMap.get(toolName);
+ McpToolState mcpToolState = getEndpoint().getMcpToolState();
+ McpSyncClient mcpClient =
mcpToolState.toolClientMap().get(toolName);
if (mcpClient == null) {
throw new IllegalStateException(
"Tool '" + toolName + "' not found in any configured
MCP server");
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpReconnectConcurrencyTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpReconnectConcurrencyTest.java
new file mode 100644
index 000000000000..4b444592ba22
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpReconnectConcurrencyTest.java
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
+
+import com.openai.models.chat.completions.ChatCompletionFunctionTool;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Concurrency regression tests for CAMEL-23957: MCP reconnect must not
corrupt the shared endpoint tool state.
+ */
+class OpenAIEndpointMcpReconnectConcurrencyTest {
+
+ private static final String SERVER = "test-server";
+ private static final List<String> TOOL_NAMES = List.of("get_weather",
"find_location");
+
+ private final AtomicInteger clientsCreated = new AtomicInteger();
+
+ private volatile List<String> serverTools = TOOL_NAMES;
+
+ /**
+ * Overrides the client-creation seam so reconnects return mocks instead
of opening real transports.
+ */
+ private class TestEndpoint extends OpenAIEndpoint {
+ TestEndpoint(OpenAIComponent component) {
+ super("openai:chat-completion", component, new
OpenAIConfiguration());
+ }
+
+ @Override
+ McpSyncClient createMcpClient(String serverName, Map<String, String>
props) {
+ clientsCreated.incrementAndGet();
+ return newMockClient();
+ }
+ }
+
+ private McpSyncClient newMockClient() {
+ McpSyncClient client = mock(McpSyncClient.class);
+
when(client.listTools()).thenReturn(McpSchema.ListToolsResult.builder(mockTools()).build());
+ return client;
+ }
+
+ private List<McpSchema.Tool> mockTools() {
+ return serverTools.stream()
+ .map(n -> McpSchema.Tool.builder(n, Map.of("type",
"object")).description("mock " + n).build())
+ .toList();
+ }
+
+ private TestEndpoint newEndpoint(McpSyncClient initialClient) {
+ DefaultCamelContext ctx = new DefaultCamelContext();
+ OpenAIComponent component = new OpenAIComponent();
+ component.setCamelContext(ctx);
+ TestEndpoint endpoint = new TestEndpoint(component);
+ endpoint.setCamelContext(ctx);
+
+ Map<String, McpSyncClient> clientMap = new ConcurrentHashMap<>();
+ Map<String, String> toolToServer = new ConcurrentHashMap<>();
+ for (String n : serverTools) {
+ clientMap.put(n, initialClient);
+ toolToServer.put(n, SERVER);
+ }
+
+ endpoint.setMcpToolState(new McpToolState(
+ McpToolConverter.convert(mockTools()),
+ clientMap,
+ toolToServer,
+ ConcurrentHashMap.newKeySet()));
+
+ Map<String, Map<String, String>> serverConfigs = new
ConcurrentHashMap<>();
+ serverConfigs.put(SERVER, Map.of("transportType", "stdio"));
+ endpoint.setServerConfigs(serverConfigs);
+
+ Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();
+ locks.put(SERVER, new ReentrantLock());
+ endpoint.setMcpClientLocks(locks);
+
+ return endpoint;
+ }
+
+ @Test
+ void concurrentReadersAndReconnectsDoNotThrow() throws Exception {
+ TestEndpoint endpoint = newEndpoint(newMockClient());
+
+ int readers = 8;
+ int reconnecters = 4;
+ int iterations = 500;
+ ExecutorService pool = Executors.newFixedThreadPool(readers +
reconnecters);
+ CountDownLatch start = new CountDownLatch(1);
+ ConcurrentLinkedQueue<Throwable> errors = new
ConcurrentLinkedQueue<>();
+
+ for (int i = 0; i < readers; i++) {
+ pool.submit(() -> {
+ try {
+ start.await();
+ for (int k = 0; k < iterations; k++) {
+ for (ChatCompletionFunctionTool t :
endpoint.getMcpToolState().tools()) {
+ t.function().name();
+ }
+ for (String name :
endpoint.getMcpToolState().toolClientMap().keySet()) {
+
endpoint.getMcpToolState().toolClientMap().get(name);
+ }
+ endpoint.getMcpToolState().returnDirectTools().size();
+ }
+ } catch (Throwable t) {
+ errors.add(t);
+ }
+ });
+ }
+ for (int i = 0; i < reconnecters; i++) {
+ pool.submit(() -> {
+ try {
+ start.await();
+ for (int k = 0; k < iterations; k++) {
+ String tool = TOOL_NAMES.get(k % TOOL_NAMES.size());
+
endpoint.reconnectMcpServer(endpoint.getMcpToolState().toolClientMap().get(tool),
tool);
+ }
+ } catch (Throwable t) {
+ errors.add(t);
+ }
+ });
+ }
+
+ start.countDown();
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(60, TimeUnit.SECONDS), "threads did
not finish in time");
+
+ assertTrue(errors.isEmpty(), "concurrent access threw: " + errors);
+
+ // state consistency checks
+ assertEquals(TOOL_NAMES.size(),
endpoint.getMcpToolState().tools().size());
+ assertEquals(TOOL_NAMES.size(),
endpoint.getMcpToolState().toolClientMap().size());
+ for (String name : TOOL_NAMES) {
+
assertNotNull(endpoint.getMcpToolState().toolClientMap().get(name), "missing
client for " + name);
+ }
+ }
+
+ @Test
+ void sameServerConcurrentReconnectReconnectsOnceAndClosesOldClientOnce()
throws Exception {
+ McpSyncClient initial = newMockClient();
+ TestEndpoint endpoint = newEndpoint(initial);
+ clientsCreated.set(0);
+
+ int threads = 16;
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ ConcurrentLinkedQueue<McpSyncClient> results = new
ConcurrentLinkedQueue<>();
+ ConcurrentLinkedQueue<Throwable> errors = new
ConcurrentLinkedQueue<>();
+
+ for (int i = 0; i < threads; i++) {
+ pool.submit(() -> {
+ try {
+ start.await();
+ results.add(endpoint.reconnectMcpServer(initial,
"get_weather"));
+ } catch (Throwable t) {
+ errors.add(t);
+ }
+ });
+ }
+ start.countDown();
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS), "threads did
not finish in time");
+
+ assertTrue(errors.isEmpty(), "reconnect threw: " + errors);
+
+ // exactly one real reconnect happened and the other threads observed
it and skipped
+ assertEquals(1, clientsCreated.get(), "server should have been
reconnected exactly once");
+
+ // the failed client was closed exactly once
+ verify(initial, times(1)).closeGracefully();
+
+ // every caller observed the same, non-null reconnected client
+ McpSyncClient reconnected =
endpoint.getMcpToolState().toolClientMap().get("get_weather");
+ assertNotNull(reconnected);
+ for (McpSyncClient c : results) {
+ assertEquals(reconnected, c, "all callers should observe the same
reconnected client");
+ }
+ }
+
+ @Test
+ void reconnectPrunesToolsThatVanishedServerSide() {
+ serverTools = List.of("get_weather", "find_location");
+ McpSyncClient initial = newMockClient();
+ TestEndpoint endpoint = newEndpoint(initial);
+ endpoint.addReturnDirectTool("find_location");
+
+ // The server now only advertises 'get_weather', 'find_location'
disappeared
+ serverTools = List.of("get_weather");
+ McpSyncClient reconnected = endpoint.reconnectMcpServer(initial,
"get_weather");
+ assertNotNull(reconnected);
+
+ // 'find_location' must be pruned from every shared collection
+
assertFalse(endpoint.getMcpToolState().toolClientMap().containsKey("find_location"),
"stale entry in toolClientMap");
+
assertFalse(endpoint.getMcpToolState().toolToServerName().containsKey("find_location"),
+ "stale entry in toolToServerName");
+
assertFalse(endpoint.getMcpToolState().returnDirectTools().contains("find_location"),
+ "stale entry in returnDirectTools");
+ assertFalse(endpoint.getMcpToolState().tools().stream().anyMatch(t ->
t.function().name().equals("find_location")),
+ "stale entry in cachedMcpTools");
+
+ // 'get_weather' is still present and consistent across the collections
+
assertTrue(endpoint.getMcpToolState().toolClientMap().containsKey("get_weather"));
+ assertEquals(SERVER,
endpoint.getMcpToolState().toolToServerName().get("get_weather"));
+ assertEquals(1, endpoint.getMcpToolState().tools().size());
+ assertEquals("get_weather",
endpoint.getMcpToolState().tools().get(0).function().name());
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIProducerMcpMockTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIProducerMcpMockTest.java
index ecbd39dd1c5b..d329bd6835ae 100644
---
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIProducerMcpMockTest.java
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIProducerMcpMockTest.java
@@ -129,9 +129,11 @@ public class OpenAIProducerMcpMockTest extends
CamelTestSupport {
.build())
.toList();
- endpoint.setMcpTools(McpToolConverter.convert(mcpTools));
- endpoint.setToolClientMap(toolClients);
- endpoint.setReturnDirectTools(returnDirectToolNames);
+ endpoint.setMcpToolState(new McpToolState(
+ McpToolConverter.convert(mcpTools),
+ toolClients,
+ Map.of(),
+ returnDirectToolNames));
}
@Test
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/integration/OpenAIMcpAdvancedIT.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/integration/OpenAIMcpAdvancedIT.java
index aeaeb75bdfeb..9e80955a6925 100644
---
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/integration/OpenAIMcpAdvancedIT.java
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/integration/OpenAIMcpAdvancedIT.java
@@ -171,9 +171,9 @@ public class OpenAIMcpAdvancedIT extends OpenAITestSupport {
*/
@Test
void testReturnDirect() throws InterruptedException {
- // Inject "echo" as a returnDirect tool via the public getter (returns
mutable HashSet)
+ // Mark "echo" as a returnDirect tool at runtime
OpenAIEndpoint endpoint = context.getEndpoint(returnDirectEndpointUri,
OpenAIEndpoint.class);
- endpoint.getReturnDirectTools().add("echo");
+ endpoint.addReturnDirectTool("echo");
try {
MockEndpoint mockEndpoint =
getMockEndpoint("mock:return-direct-result");
@@ -199,7 +199,7 @@ public class OpenAIMcpAdvancedIT extends OpenAITestSupport {
"Body should contain the raw tool result but was: " +
body);
} finally {
// Cleanup: remove to avoid affecting other tests
- endpoint.getReturnDirectTools().remove("echo");
+ endpoint.removeReturnDirectTool("echo");
}
}