This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 aa8c9d327034 CAMEL-24369: camel-mcp-server jackson-annotations
alignment + camel-openai deferred MCP client init
aa8c9d327034 is described below
commit aa8c9d327034332b4b0bb8f7cc148645c4da3c82
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Aug 7 12:04:30 2026 +0200
CAMEL-24369: camel-mcp-server jackson-annotations alignment + camel-openai
deferred MCP client init
Add a direct jackson-annotations dependency to camel-mcp-server so consumers
resolving without the jackson-bom get a version aligned with
jackson-databind
(the MCP SDK's older jackson-annotations otherwise wins by nearest-wins and
breaks databind at runtime with NoClassDefFoundError: JsonSerializeAs).
Defer initialization of MCP servers that are unreachable when the
camel-openai
endpoint starts: the server is recorded with a WARN and retried lazily on
first
use via the existing reconnect path and per-server locks. This lets an
application consume its own MCP endpoint on runtimes where the HTTP server
only
accepts connections after the application has started. Invalid configuration
(e.g. missing transportType) still fails fast.
Includes a new unit test (4 scenarios), the openai-mcp error-table update,
and
a fix for the stale ai-tool See Also section.
Closes #25404
Co-Authored-By: Claude <[email protected]>
---
.../camel/catalog/docs/ai-tool-component.adoc | 5 +-
.../org/apache/camel/catalog/docs/openai-mcp.adoc | 2 +-
.../src/main/docs/ai-tool-component.adoc | 5 +-
components/camel-ai/camel-mcp-server/pom.xml | 8 ++
.../camel-openai/src/main/docs/openai-mcp.adoc | 2 +-
.../camel/component/openai/OpenAIEndpoint.java | 56 +++++++-
.../openai/OpenAIEndpointMcpDeferredInitTest.java | 155 +++++++++++++++++++++
7 files changed, 223 insertions(+), 10 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
index 7b305ed88951..defb3f0a929c 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
@@ -311,5 +311,6 @@ YAML::
== See Also
-* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] — will
discover ai-tool tools in a future release
-* xref:spring-ai-chat-component.adoc[Spring AI Chat Component] — will discover
ai-tool tools in a future release
+* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] —
discovers ai-tool tools via the `tags` option
+* xref:spring-ai-chat-component.adoc[Spring AI Chat Component] — discovers
ai-tool tools via the `tags` option
+* xref:others:mcp-server.adoc[MCP Server] — exposes ai-tool routes as MCP
tools over streamable HTTP
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
index ec12fccde32a..7123962058e5 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
@@ -732,7 +732,7 @@ Two endpoint options control what happens when a tool call
fails:
|===
| Scenario | Behavior
-| MCP client initialization failure | Route fails to start (`RuntimeException`
during `doStart()`)
+| MCP server unreachable at startup | Route starts anyway: a WARN is logged
and initialization of that server is deferred and retried on first use. This
lets an application consume its own MCP endpoint (or one of a service starting
concurrently) on runtimes where the HTTP server only accepts connections after
the application has started. Invalid configuration (for example a missing
`transportType`) still fails the route at startup.
| Tool execution throws an exception | Governed by
`toolExecutionErrorStrategy`: `failExchange` (default) propagates the exception
to the exchange; `repromptModel` catches the error, logs it as WARN, and sends
it as tool result text to the model
| MCP transport error (`mcpReconnect=true`) | Automatic reconnection and retry
(once). If retry fails, behavior follows `toolExecutionErrorStrategy`
| MCP `CallToolResult.isError()` is true | Error content is sent as tool
result text to the model
diff --git
a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
index 7b305ed88951..defb3f0a929c 100644
--- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
+++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
@@ -311,5 +311,6 @@ YAML::
== See Also
-* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] — will
discover ai-tool tools in a future release
-* xref:spring-ai-chat-component.adoc[Spring AI Chat Component] — will discover
ai-tool tools in a future release
+* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] —
discovers ai-tool tools via the `tags` option
+* xref:spring-ai-chat-component.adoc[Spring AI Chat Component] — discovers
ai-tool tools via the `tags` option
+* xref:others:mcp-server.adoc[MCP Server] — exposes ai-tool routes as MCP
tools over streamable HTTP
diff --git a/components/camel-ai/camel-mcp-server/pom.xml
b/components/camel-ai/camel-mcp-server/pom.xml
index feb84234815f..4e37d50c31f0 100644
--- a/components/camel-ai/camel-mcp-server/pom.xml
+++ b/components/camel-ai/camel-mcp-server/pom.xml
@@ -63,6 +63,14 @@
<artifactId>mcp-json-jackson2</artifactId>
<version>${mcp-java-sdk-version}</version>
</dependency>
+ <!-- direct dependency so consumers resolving without the jackson-bom
get a
+ jackson-annotations aligned with jackson-databind: mcp-core
declares an older
+ jackson-annotations that otherwise wins by nearest-wins and
breaks databind
+ at runtime (NoClassDefFoundError: JsonSerializeAs) -->
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-annotations</artifactId>
+ </dependency>
<!-- test dependencies -->
<dependency>
diff --git a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
index ec12fccde32a..7123962058e5 100644
--- a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
+++ b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
@@ -732,7 +732,7 @@ Two endpoint options control what happens when a tool call
fails:
|===
| Scenario | Behavior
-| MCP client initialization failure | Route fails to start (`RuntimeException`
during `doStart()`)
+| MCP server unreachable at startup | Route starts anyway: a WARN is logged
and initialization of that server is deferred and retried on first use. This
lets an application consume its own MCP endpoint (or one of a service starting
concurrently) on runtimes where the HTTP server only accepts connections after
the application has started. Invalid configuration (for example a missing
`transportType`) still fails the route at startup.
| Tool execution throws an exception | Governed by
`toolExecutionErrorStrategy`: `failExchange` (default) propagates the exception
to the exchange; `repromptModel` catches the error, logs it as WARN, and sends
it as tool result text to the model
| MCP transport error (`mcpReconnect=true`) | Automatic reconnection and retry
(once). If retry fails, behavior follows `toolExecutionErrorStrategy`
| MCP `CallToolResult.isError()` is true | Error content is sent as tool
result text to the model
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 094a5a9e7afe..63f872b3745a 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
@@ -99,6 +99,13 @@ public class OpenAIEndpoint extends DefaultEndpoint {
private final Set<String> manualReturnDirectAdded =
ConcurrentHashMap.newKeySet();
private final Set<String> manualReturnDirectRemoved =
ConcurrentHashMap.newKeySet();
+ /**
+ * MCP servers that were unreachable when the endpoint started.
Initialization is retried lazily on first use so
+ * that an application acting as MCP client of itself (or of another
service starting concurrently) does not fail
+ * route startup on runtimes where the HTTP server only accepts
connections after the application has started.
+ */
+ private final Set<String> pendingMcpServers =
ConcurrentHashMap.newKeySet();
+
private volatile McpToolState mcpToolState = McpToolState.empty();
private volatile boolean mcpStopped;
private Map<String, Map<String, String>> serverConfigs;
@@ -140,6 +147,7 @@ public class OpenAIEndpoint extends DefaultEndpoint {
globalMcpLock.lock();
try {
mcpStopped = true;
+ pendingMcpServers.clear();
toClose = new HashSet<>(mcpToolState.toolClientMap().values());
mcpToolState = McpToolState.empty();
} finally {
@@ -198,11 +206,24 @@ public class OpenAIEndpoint extends DefaultEndpoint {
throw new IllegalArgumentException("mcpServer." + serverName +
".transportType is required");
}
- McpSyncClient mcpClient = createMcpClient(serverName, props);
- LOG.debug("MCP server '{}' initialized, listing tools",
serverName);
+ McpSyncClient mcpClient;
+ List<McpSchema.Tool> serverTools;
+ try {
+ mcpClient = createMcpClient(serverName, props);
+ LOG.debug("MCP server '{}' initialized, listing tools",
serverName);
- McpSchema.ListToolsResult toolsResult = mcpClient.listTools();
- List<McpSchema.Tool> serverTools =
filterTools(toolsResult.tools(), serverName, props);
+ McpSchema.ListToolsResult toolsResult = mcpClient.listTools();
+ serverTools = filterTools(toolsResult.tools(), serverName,
props);
+ } catch (Exception e) {
+ // do not fail route startup on an unreachable server: the
server may simply not be
+ // accepting connections yet (e.g. this application's own MCP
endpoint on runtimes
+ // where the HTTP server starts after the CamelContext); retry
lazily on first use
+ pendingMcpServers.add(serverName);
+ LOG.warn("MCP server '{}' is unreachable at endpoint startup
({}); "
+ + "initialization deferred to first use",
+ serverName, e.getMessage());
+ continue;
+ }
for (McpSchema.Tool tool : serverTools) {
if (toolClientMap.putIfAbsent(tool.name(), mcpClient) != null)
{
@@ -780,9 +801,36 @@ public class OpenAIEndpoint extends DefaultEndpoint {
}
McpToolState getMcpToolState() {
+ if (!pendingMcpServers.isEmpty()) {
+ initializePendingMcpServers();
+ }
return mcpToolState;
}
+ /**
+ * Retries the initialization of MCP servers that were unreachable when
the endpoint started. Runs at most one
+ * initialization attempt per server at a time via the per-server locks; a
thread that finds a server being
+ * initialized by another thread simply skips it and serves the current
tool state.
+ */
+ private void initializePendingMcpServers() {
+ for (String serverName : pendingMcpServers) {
+ ReentrantLock lock = mcpClientLocks.get(serverName);
+ if (lock == null || !lock.tryLock()) {
+ continue;
+ }
+ try {
+ if (mcpStopped || !pendingMcpServers.contains(serverName)) {
+ continue;
+ }
+ if (doReconnectMcpServer(null, serverName) != null) {
+ pendingMcpServers.remove(serverName);
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+
// Package-private setters for testing
void setMcpToolState(McpToolState state) {
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpDeferredInitTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpDeferredInitTest.java
new file mode 100644
index 000000000000..5d76f7bc5851
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpDeferredInitTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import com.openai.client.OpenAIClient;
+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.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests that an MCP server that is unreachable when the endpoint starts does
not fail the route: initialization is
+ * deferred and retried on first use. This is the self-referential agent
scenario, where an application consumes its own
+ * MCP endpoint and the HTTP server only accepts connections after the
application has started.
+ */
+class OpenAIEndpointMcpDeferredInitTest {
+
+ private static final String SERVER = "orderDesk";
+ private static final List<String> SERVER_TOOLS = List.of("say_hello",
"list_orders");
+
+ /**
+ * Overrides the client-creation seam: throws while {@code serverUp} is
false, simulating an MCP server that is not
+ * yet accepting connections, and returns a mock client once the server is
"up".
+ */
+ private static class TestEndpoint extends OpenAIEndpoint {
+ volatile boolean serverUp;
+ final AtomicInteger connectionAttempts = new AtomicInteger();
+
+ TestEndpoint(OpenAIComponent component, OpenAIConfiguration config) {
+ super("openai:chat-completion", component, config);
+ }
+
+ @Override
+ McpSyncClient createMcpClient(String serverName, Map<String, String>
props) {
+ connectionAttempts.incrementAndGet();
+ if (!serverUp) {
+ throw new RuntimeException("Client failed to initialize by
explicit API call");
+ }
+ McpSyncClient client = mock(McpSyncClient.class);
+ List<McpSchema.Tool> tools = SERVER_TOOLS.stream()
+ .map(n -> McpSchema.Tool.builder(n, Map.of("type",
"object")).description("mock " + n).build())
+ .toList();
+
when(client.listTools()).thenReturn(McpSchema.ListToolsResult.builder(tools).build());
+ return client;
+ }
+
+ @Override
+ protected OpenAIClient createClient() {
+ return mock(OpenAIClient.class);
+ }
+ }
+
+ private TestEndpoint createEndpoint() {
+ DefaultCamelContext ctx = new DefaultCamelContext();
+ OpenAIComponent component = new OpenAIComponent();
+ component.setCamelContext(ctx);
+
+ Map<String, Object> flatConfig = new HashMap<>();
+ flatConfig.put(SERVER + ".transportType", "streamableHttp");
+ flatConfig.put(SERVER + ".url", "http://localhost:1/mcp");
+
+ OpenAIConfiguration config = new OpenAIConfiguration();
+ config.setMcpServer(flatConfig);
+
+ TestEndpoint endpoint = new TestEndpoint(component, config);
+ endpoint.setCamelContext(ctx);
+ endpoint.setOperation(OpenAIOperations.chatCompletion);
+ return endpoint;
+ }
+
+ private static Set<String> toolNames(McpToolState state) {
+ return state.tools().stream().map(t ->
t.function().name()).collect(Collectors.toSet());
+ }
+
+ @Test
+ void unreachableServerDoesNotFailEndpointStart() throws Exception {
+ TestEndpoint endpoint = createEndpoint();
+
+ assertThatCode(endpoint::doStart).doesNotThrowAnyException();
+ assertThat(endpoint.connectionAttempts).hasValue(1);
+
+ endpoint.doStop();
+ }
+
+ @Test
+ void toolsAppearOnFirstUseOnceServerIsReachable() throws Exception {
+ TestEndpoint endpoint = createEndpoint();
+ endpoint.doStart();
+
+ // still down on first use: no tools, but no failure either
+ assertThat(toolNames(endpoint.getMcpToolState())).isEmpty();
+
+ endpoint.serverUp = true;
+ McpToolState state = endpoint.getMcpToolState();
+
assertThat(toolNames(state)).containsExactlyInAnyOrderElementsOf(SERVER_TOOLS);
+ assertThat(state.toolToServerName()).containsValues(SERVER);
+
+ // once initialized, further uses do not attempt new connections
+ int attempts = endpoint.connectionAttempts.get();
+ endpoint.getMcpToolState();
+ assertThat(endpoint.connectionAttempts).hasValue(attempts);
+
+ endpoint.doStop();
+ }
+
+ @Test
+ void reachableServerInitializesEagerlyAtStart() throws Exception {
+ TestEndpoint endpoint = createEndpoint();
+ endpoint.serverUp = true;
+
+ endpoint.doStart();
+ assertThat(endpoint.connectionAttempts).hasValue(1);
+
assertThat(toolNames(endpoint.getMcpToolState())).containsExactlyInAnyOrderElementsOf(SERVER_TOOLS);
+ assertThat(endpoint.connectionAttempts).hasValue(1);
+
+ endpoint.doStop();
+ }
+
+ @Test
+ void stoppedEndpointDoesNotRetryPendingServers() throws Exception {
+ TestEndpoint endpoint = createEndpoint();
+ endpoint.doStart();
+ endpoint.doStop();
+
+ endpoint.serverUp = true;
+ assertThat(toolNames(endpoint.getMcpToolState())).isEmpty();
+ assertThat(endpoint.connectionAttempts).hasValue(1);
+ }
+}