From e941157d8fbce3fa6afbaf95bde68e0778bf1ef4 Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 09:31:06 +0200 Subject: [PATCH 01/11] CAMEL-24309: camel-ai-tool - AiToolRegistry listener SPI for tool registration changes Add AiToolRegistryListener with toolRegistered/toolDeregistered callbacks fired on ai-tool consumer lifecycle events (route start/resume registers, stop/suspend deregisters). Callbacks fire outside the registry lock, only on actual state changes, and a failing listener cannot break registration. Prerequisite for MCP tools/list_changed notifications (CAMEL-24308). Co-Authored-By: Claude Fable 5 --- .../component/ai/tool/AiToolRegistry.java | 68 +++++++- .../ai/tool/AiToolRegistryListener.java | 52 ++++++ .../AiToolRegistryListenerLifecycleTest.java | 144 ++++++++++++++++ .../ai/tool/AiToolRegistryListenerTest.java | 158 ++++++++++++++++++ 4 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java create mode 100644 components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java index c47562fd820c6..8c3d71372e935 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java @@ -19,11 +19,15 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.ReentrantLock; import org.apache.camel.CamelContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * CamelContext-scoped registry mapping tags to {@link AiToolSpec} instances. AI components (LangChain4j, Spring AI) @@ -32,6 +36,9 @@ * Each {@link CamelContext} gets its own registry instance, registered as a context plugin. Use * {@link #getOrCreate(CamelContext)} to obtain the instance for a given context. *

+ * Adapters that need to react to tools appearing or disappearing (e.g. to push MCP {@code tools/list_changed} + * notifications) can register an {@link AiToolRegistryListener} instead of polling. + *

* Replaces the duplicated {@code CamelToolExecutorCache} singletons from {@code camel-langchain4j-tools} and * {@code camel-spring-ai-tools}. * @@ -39,11 +46,14 @@ */ public final class AiToolRegistry { + private static final Logger LOG = LoggerFactory.getLogger(AiToolRegistry.class); + private static final ReentrantLock FACTORY_LOCK = new ReentrantLock(); private final ReentrantLock lock = new ReentrantLock(); private final Map> tools; private final Set defaultTools; + private final List listeners = new CopyOnWriteArrayList<>(); AiToolRegistry() { tools = new HashMap<>(); @@ -71,6 +81,7 @@ public static AiToolRegistry getOrCreate(CamelContext context) { } public void put(String tag, AiToolSpec spec) { + boolean added; lock.lock(); try { Set set = tools.computeIfAbsent(tag, k -> new LinkedHashSet<>()); @@ -81,18 +92,22 @@ public void put(String tag, AiToolSpec spec) { + "': tool names must be unique per tag"); } } - set.add(spec); + added = set.add(spec); } finally { lock.unlock(); } + if (added) { + notifyRegistered(tag, spec); + } } public void remove(String tag, AiToolSpec spec) { + boolean removed = false; lock.lock(); try { Set set = tools.get(tag); if (set != null) { - set.remove(spec); + removed = set.remove(spec); if (set.isEmpty()) { tools.remove(tag); } @@ -100,9 +115,13 @@ public void remove(String tag, AiToolSpec spec) { } finally { lock.unlock(); } + if (removed) { + notifyDeregistered(tag, spec); + } } public void putDefault(AiToolSpec spec) { + boolean added; lock.lock(); try { for (AiToolSpec existing : defaultTools) { @@ -112,19 +131,60 @@ public void putDefault(AiToolSpec spec) { + "' in the default pool: tool names must be unique"); } } - defaultTools.add(spec); + added = defaultTools.add(spec); } finally { lock.unlock(); } + if (added) { + notifyRegistered(null, spec); + } } public void removeDefault(AiToolSpec spec) { + boolean removed; lock.lock(); try { - defaultTools.remove(spec); + removed = defaultTools.remove(spec); } finally { lock.unlock(); } + if (removed) { + notifyDeregistered(null, spec); + } + } + + /** + * Adds a listener notified on tool registration changes. See {@link AiToolRegistryListener} for the callback + * contract and the subscribe-then-snapshot idiom to observe current state without missing events. + */ + public void addListener(AiToolRegistryListener listener) { + listeners.add(listener); + } + + public void removeListener(AiToolRegistryListener listener) { + listeners.remove(listener); + } + + private void notifyRegistered(String tag, AiToolSpec spec) { + for (AiToolRegistryListener listener : listeners) { + try { + listener.toolRegistered(tag, spec); + } catch (Exception e) { + LOG.warn("AiToolRegistryListener {} failed on toolRegistered for tool '{}': {}", + listener, spec.getName(), e.getMessage(), e); + } + } + } + + private void notifyDeregistered(String tag, AiToolSpec spec) { + for (AiToolRegistryListener listener : listeners) { + try { + listener.toolDeregistered(tag, spec); + } catch (Exception e) { + LOG.warn("AiToolRegistryListener {} failed on toolDeregistered for tool '{}': {}", + listener, spec.getName(), e.getMessage(), e); + } + } } /** diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java new file mode 100644 index 0000000000000..378842f53e270 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java @@ -0,0 +1,52 @@ +/* + * 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.ai.tool; + +/** + * Listener notified when tool specifications are registered to or deregistered from an {@link AiToolRegistry}. + *

+ * Registration events are driven by the {@code ai-tool} consumer lifecycle: a tool is registered when its route starts + * or resumes, and deregistered when its route stops or suspends. A tool endpoint declaring multiple tags fires one + * event per tag. + *

+ * Callbacks are invoked outside the registry lock, on the thread performing the (de)registration — typically a route + * lifecycle thread. Implementations must be thread-safe and non-blocking; a callback that throws is logged and does not + * affect the registration itself or other listeners. + *

+ * To observe the current registry state without missing concurrent changes, add the listener first and then read a + * snapshot (e.g. {@link AiToolRegistry#getTools()}), tolerating events that duplicate snapshot content. + * + * @since 4.22 + */ +public interface AiToolRegistryListener { + + /** + * Called after a tool specification has been registered. + * + * @param tag the tag the tool was registered under, or {@code null} for the default (untagged) pool + * @param spec the registered tool specification + */ + void toolRegistered(String tag, AiToolSpec spec); + + /** + * Called after a tool specification has been deregistered. + * + * @param tag the tag the tool was deregistered from, or {@code null} for the default (untagged) pool + * @param spec the deregistered tool specification + */ + void toolDeregistered(String tag, AiToolSpec spec); +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java new file mode 100644 index 0000000000000..bba34ceea07de --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java @@ -0,0 +1,144 @@ +/* + * 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.ai.tool; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +/** + * Verifies that {@link AiToolRegistryListener} callbacks are driven by the {@code ai-tool} consumer lifecycle: route + * start/resume registers, route stop/suspend deregisters. + */ +class AiToolRegistryListenerLifecycleTest extends CamelTestSupport { + + private final RecordingListener listener = new RecordingListener(); + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext camelContext = super.createCamelContext(); + AiToolRegistry.getOrCreate(camelContext).addListener(listener); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:getWeather?tags=weather&description=Get the weather") + .routeId("weather-route") + .setBody(constant("sunny")); + } + }; + } + + @Test + void testEventsOnRouteStartAndStop() throws Exception { + assertThat(listener.events) + .as("Route start should fire toolRegistered") + .extracting(Event::type, Event::tag, Event::toolName) + .containsExactly(tuple("registered", "weather", "getWeather")); + + context.getRouteController().stopRoute("weather-route"); + + assertThat(listener.events) + .extracting(Event::type, Event::tag, Event::toolName) + .containsExactly( + tuple("registered", "weather", "getWeather"), + tuple("deregistered", "weather", "getWeather")); + } + + @Test + void testEventsOnSuspendAndResume() throws Exception { + context.getRouteController().suspendRoute("weather-route"); + context.getRouteController().resumeRoute("weather-route"); + + assertThat(listener.events) + .extracting(Event::type, Event::tag, Event::toolName) + .containsExactly( + tuple("registered", "weather", "getWeather"), + tuple("deregistered", "weather", "getWeather"), + tuple("registered", "weather", "getWeather")); + } + + @Test + void testMultiTagEndpointFiresOneEventPerTag() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:sendEmail?tags=notify,crm&description=Send an email") + .routeId("email-route") + .setBody(constant("sent")); + } + }); + + assertThat(listener.events) + .filteredOn(e -> "sendEmail".equals(e.toolName())) + .extracting(Event::type, Event::tag) + .containsExactlyInAnyOrder( + tuple("registered", "notify"), + tuple("registered", "crm")); + } + + @Test + void testUntaggedEndpointFiresDefaultPoolEvent() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:lookupOrder?description=Look up an order") + .routeId("order-route") + .setBody(constant("order")); + } + }); + + assertThat(listener.events) + .filteredOn(e -> "lookupOrder".equals(e.toolName())) + .extracting(Event::type, Event::tag) + .containsExactly(tuple("registered", null)); + + context.getRouteController().stopRoute("order-route"); + + assertThat(listener.events) + .filteredOn(e -> "lookupOrder".equals(e.toolName())) + .extracting(Event::type, Event::tag) + .containsExactly( + tuple("registered", null), + tuple("deregistered", null)); + } + + private record Event(String type, String tag, String toolName) { + } + + private static final class RecordingListener implements AiToolRegistryListener { + private final List events = new CopyOnWriteArrayList<>(); + + @Override + public void toolRegistered(String tag, AiToolSpec spec) { + events.add(new Event("registered", tag, spec.getName())); + } + + @Override + public void toolDeregistered(String tag, AiToolSpec spec) { + events.add(new Event("deregistered", tag, spec.getName())); + } + } +} diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java new file mode 100644 index 0000000000000..3d1c5e009acf0 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java @@ -0,0 +1,158 @@ +/* + * 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.ai.tool; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AiToolRegistryListenerTest { + + private AiToolRegistry registry; + private RecordingListener listener; + + @BeforeEach + void setUp() { + registry = new AiToolRegistry(); + listener = new RecordingListener(); + registry.addListener(listener); + } + + @Test + void testRegisteredEventOnPut() { + AiToolSpec spec = spec("getWeather"); + registry.put("weather", spec); + + assertThat(listener.events).containsExactly(new Event("registered", "weather", spec)); + } + + @Test + void testNoDuplicateEventOnRepeatedPutOfSameSpec() { + AiToolSpec spec = spec("getWeather"); + registry.put("weather", spec); + registry.put("weather", spec); + + assertThat(listener.events) + .as("Re-adding the same spec instance should not fire a second event") + .hasSize(1); + } + + @Test + void testDeregisteredEventOnRemove() { + AiToolSpec spec = spec("getWeather"); + registry.put("weather", spec); + registry.remove("weather", spec); + + assertThat(listener.events).containsExactly( + new Event("registered", "weather", spec), + new Event("deregistered", "weather", spec)); + } + + @Test + void testNoEventOnRemovingAbsentSpec() { + registry.remove("weather", spec("getWeather")); + registry.removeDefault(spec("getWeather")); + + assertThat(listener.events) + .as("Removing a spec that was never registered should not fire events") + .isEmpty(); + } + + @Test + void testDefaultPoolEventsUseNullTag() { + AiToolSpec spec = spec("getWeather"); + registry.putDefault(spec); + registry.removeDefault(spec); + + assertThat(listener.events).containsExactly( + new Event("registered", null, spec), + new Event("deregistered", null, spec)); + } + + @Test + void testNoEventWhenPutThrowsOnDuplicateName() { + registry.put("weather", spec("getWeather")); + listener.events.clear(); + + assertThatThrownBy(() -> registry.put("weather", spec("getWeather"))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(listener.events) + .as("A rejected registration should not fire an event") + .isEmpty(); + } + + @Test + void testListenerExceptionDoesNotBreakRegistrationOrOtherListeners() { + registry.addListener(new AiToolRegistryListener() { + @Override + public void toolRegistered(String tag, AiToolSpec spec) { + throw new IllegalStateException("boom"); + } + + @Override + public void toolDeregistered(String tag, AiToolSpec spec) { + throw new IllegalStateException("boom"); + } + }); + RecordingListener second = new RecordingListener(); + registry.addListener(second); + + AiToolSpec spec = spec("getWeather"); + registry.put("weather", spec); + + assertThat(registry.getToolsByTag("weather")) + .as("Registration should succeed despite a failing listener") + .contains(spec); + assertThat(second.events) + .as("Listeners after the failing one should still be notified") + .containsExactly(new Event("registered", "weather", spec)); + } + + @Test + void testRemovedListenerReceivesNoFurtherEvents() { + registry.removeListener(listener); + registry.put("weather", spec("getWeather")); + + assertThat(listener.events).isEmpty(); + } + + private static AiToolSpec spec(String name) { + return new AiToolSpec(name, name + " description", null, null, null); + } + + private record Event(String type, String tag, AiToolSpec spec) { + } + + private static final class RecordingListener implements AiToolRegistryListener { + private final List events = new ArrayList<>(); + + @Override + public void toolRegistered(String tag, AiToolSpec spec) { + events.add(new Event("registered", tag, spec)); + } + + @Override + public void toolDeregistered(String tag, AiToolSpec spec) { + events.add(new Event("deregistered", tag, spec)); + } + } +} From 02523f35a5e1102ac63584e1c2642ac4d0b203dd Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 09:56:59 +0200 Subject: [PATCH 02/11] CAMEL-24310: camel-mcp-server - bridge, McpServerEngine SPI and Vert.x engine Expose ai-tool routes (CAMEL-23382) as MCP tools over streamable HTTP: - camel-mcp-server-api: runtime-agnostic bridge + McpServerEngine SPI. The bridge selects tools by tag (untagged default pool never exposed), refuses flat-namespace name collisions, executes via AiToolExecutor with a bounded per-call timeout, sanitizes execution errors, and reacts to AiToolRegistry listener events (CAMEL-24309). Enforcer rule bans MCP SDK/Reactor/Vert.x/platform-http from compile/runtime scope. Ships the engine conformance kit as a test-jar (CAMEL-24313). - camel-mcp-server: engine for Camel Main/JBang on the official MCP Java SDK with a custom Vert.x streamable HTTP transport registered on the platform HTTP router (the SDK ships only servlet/stdio server transports): POST json/SSE, GET SSE channel with Last-Event-ID replay, Mcp-Session-Id sessions, DELETE termination, tools/ list_changed on route lifecycle (CAMEL-24312). The api/engine module naming differs from the sub-task sketch (engine-as-runtime-dep would be a Maven dependency cycle): plain Camel users add camel-mcp-server; native-engine runtimes depend on camel-mcp-server-api, following the camel-langchain4j-agent-api precedent. Co-Authored-By: Claude Fable 5 --- catalog/camel-allcomponents/pom.xml | 10 + .../camel-ai/camel-mcp-server-api/pom.xml | 135 ++++++ .../org/apache/camel/other.properties | 7 + .../generated/resources/mcp-server-api.json | 14 + .../component/mcp/server/McpServerBridge.java | 309 ++++++++++++++ .../mcp/server/McpServerConfiguration.java | 79 ++++ .../mcp/server/McpServerConstants.java | 39 ++ .../component/mcp/server/McpServerEngine.java | 62 +++ .../component/mcp/server/McpServerInfo.java | 30 ++ .../component/mcp/server/McpServerTool.java | 53 +++ .../mcp/server/McpToolCallHandler.java | 38 ++ .../mcp/server/McpToolCallResult.java | 27 ++ .../server/McpServerBridgeResolutionTest.java | 36 ++ .../mcp/server/McpServerBridgeTest.java | 165 +++++++ .../mcp/server/RecordingMcpServerEngine.java | 74 ++++ .../McpServerConformanceTestSupport.java | 204 +++++++++ components/camel-ai/camel-mcp-server/pom.xml | 88 ++++ .../org/apache/camel/mcp-server-engine | 2 + .../org/apache/camel/other.properties | 7 + .../src/generated/resources/mcp-server.json | 15 + .../src/main/docs/mcp-server.adoc | 180 ++++++++ .../server/vertx/VertxMcpServerEngine.java | 164 +++++++ ...xMcpStreamableServerTransportProvider.java | 402 ++++++++++++++++++ .../vertx/VertxMcpServerConformanceTest.java | 44 ++ components/camel-ai/pom.xml | 2 + docs/components/modules/others/nav.adoc | 1 + .../modules/others/pages/mcp-server.adoc | 1 + parent/pom.xml | 16 + .../camel/maven/packaging/MojoHelper.java | 1 + 29 files changed, 2205 insertions(+) create mode 100644 components/camel-ai/camel-mcp-server-api/pom.xml create mode 100644 components/camel-ai/camel-mcp-server-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties create mode 100644 components/camel-ai/camel-mcp-server-api/src/generated/resources/mcp-server-api.json create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeResolutionTest.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/RecordingMcpServerEngine.java create mode 100644 components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/conformance/McpServerConformanceTestSupport.java create mode 100644 components/camel-ai/camel-mcp-server/pom.xml create mode 100644 components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server-engine create mode 100644 components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/other.properties create mode 100644 components/camel-ai/camel-mcp-server/src/generated/resources/mcp-server.json create mode 100644 components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc create mode 100644 components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java create mode 100644 components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java create mode 100644 components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerConformanceTest.java create mode 120000 docs/components/modules/others/pages/mcp-server.adoc diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index c86546a549f6f..ab917546b88b6 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -1457,6 +1457,16 @@ camel-master ${project.version} + + org.apache.camel + camel-mcp-server + ${project.version} + + + org.apache.camel + camel-mcp-server-api + ${project.version} + org.apache.camel camel-mdc diff --git a/components/camel-ai/camel-mcp-server-api/pom.xml b/components/camel-ai/camel-mcp-server-api/pom.xml new file mode 100644 index 0000000000000..08aa1a653fdd9 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/pom.xml @@ -0,0 +1,135 @@ + + + + 4.0.0 + + + org.apache.camel + camel-ai-parent + 4.22.0-SNAPSHOT + + + camel-mcp-server-api + jar + Camel :: AI :: MCP Server API + Runtime-agnostic bridge and engine SPI to expose ai-tool routes as MCP tools + + + 4.22.0 + Preview + + + + + + org.apache.camel + camel-support + + + org.apache.camel + camel-ai-tool + + + + + org.apache.camel + camel-test-junit6 + test + + + + io.modelcontextprotocol.sdk + mcp-core + ${mcp-java-sdk-version} + test + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp-java-sdk-version} + test + + + org.awaitility + awaitility + ${awaitility-version} + test + + + org.assertj + assertj-core + test + + + + + + + + + maven-jar-plugin + + + + test-jar + + + + + + log4j2.properties + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + ban-engine-dependencies + + enforce + + + + + + io.modelcontextprotocol.sdk:*:*:*:compile + io.modelcontextprotocol.sdk:*:*:*:runtime + io.projectreactor:*:*:*:compile + io.projectreactor:*:*:*:runtime + io.vertx:*:*:*:compile + io.vertx:*:*:*:runtime + org.apache.camel:camel-platform-http:*:*:compile + org.apache.camel:camel-platform-http-vertx:*:*:compile + + + + + + + + + + + diff --git a/components/camel-ai/camel-mcp-server-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties b/components/camel-ai/camel-mcp-server-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties new file mode 100644 index 0000000000000..dbe36fb5a3d7d --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +name=mcp-server-api +groupId=org.apache.camel +artifactId=camel-mcp-server-api +version=4.22.0-SNAPSHOT +projectName=Camel :: AI :: MCP Server API +projectDescription=Runtime-agnostic bridge and engine SPI to expose ai-tool routes as MCP tools diff --git a/components/camel-ai/camel-mcp-server-api/src/generated/resources/mcp-server-api.json b/components/camel-ai/camel-mcp-server-api/src/generated/resources/mcp-server-api.json new file mode 100644 index 0000000000000..420142538f934 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/generated/resources/mcp-server-api.json @@ -0,0 +1,14 @@ +{ + "other": { + "kind": "other", + "name": "mcp-server-api", + "title": "Mcp Server Api", + "description": "Runtime-agnostic bridge and engine SPI to expose ai-tool routes as MCP tools", + "deprecated": false, + "firstVersion": "4.22.0", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-mcp-server-api", + "version": "4.22.0-SNAPSHOT" + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java new file mode 100644 index 0000000000000..1514ca77bc3ed --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java @@ -0,0 +1,309 @@ +/* + * 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.mcp.server; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.Exchange; +import org.apache.camel.StaticService; +import org.apache.camel.component.ai.tool.AiToolExecutor; +import org.apache.camel.component.ai.tool.AiToolParameterHelper; +import org.apache.camel.component.ai.tool.AiToolParameterHelper.ParameterDef; +import org.apache.camel.component.ai.tool.AiToolRegistry; +import org.apache.camel.component.ai.tool.AiToolRegistryListener; +import org.apache.camel.component.ai.tool.AiToolResult; +import org.apache.camel.component.ai.tool.AiToolSpec; +import org.apache.camel.support.ResolverHelper; +import org.apache.camel.support.service.ServiceHelper; +import org.apache.camel.support.service.ServiceSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Bridges the {@link AiToolRegistry} to an {@link McpServerEngine}: selects {@code ai-tool} routes by tag, publishes + * them as MCP tools, and executes calls with a bounded timeout and sanitized error mapping. + *

+ * Security notes: + *

+ */ +public class McpServerBridge extends ServiceSupport implements CamelContextAware, StaticService { + + private static final Logger LOG = LoggerFactory.getLogger(McpServerBridge.class); + + private static final String GENERIC_EXECUTION_ERROR = "Tool execution failed"; + private static final String GENERIC_TIMEOUT_ERROR = "Tool execution timed out"; + + private final McpServerConfiguration configuration; + private final RegistryListener listener = new RegistryListener(); + private final ReentrantLock lock = new ReentrantLock(); + private final Map published = new HashMap<>(); + + private CamelContext camelContext; + private McpServerEngine engine; + private AiToolRegistry registry; + private Set selectedTags = Set.of(); + private ExecutorService executor; + + public McpServerBridge(McpServerConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + public McpServerConfiguration getConfiguration() { + return configuration; + } + + public McpServerEngine getEngine() { + return engine; + } + + @Override + protected void doInit() throws Exception { + if (configuration.getTags() != null) { + selectedTags = new LinkedHashSet<>(Arrays.asList(AiToolParameterHelper.splitTags(configuration.getTags()))); + } + if (selectedTags.isEmpty()) { + LOG.warn("No MCP tags configured: no ai-tool routes will be exposed as MCP tools. " + + "Set tags to opt-in the tools to expose."); + } + + engine = resolveEngine(); + CamelContextAware.trySetCamelContext(engine, camelContext); + + String serverName = configuration.getServerName() != null ? configuration.getServerName() : camelContext.getName(); + engine.initialize(new McpServerInfo(serverName, camelContext.getVersion(), configuration.getPath())); + + if (!engine.consumesServingConfiguration()) { + if (!McpServerConstants.DEFAULT_PATH.equals(configuration.getPath())) { + LOG.warn("The MCP path option is ignored by engine {}: the runtime's native MCP server configuration " + + "decides the endpoint path", + engine.getClass().getSimpleName()); + } + if (configuration.getServerName() != null) { + LOG.warn("The MCP serverName option may be ignored by engine {}: the runtime's native MCP server " + + "configuration decides the server identity", + engine.getClass().getSimpleName()); + } + } + + ServiceHelper.initService(engine); + } + + @Override + protected void doStart() throws Exception { + executor = camelContext.getExecutorServiceManager().newCachedThreadPool(this, "McpServerToolCall"); + ServiceHelper.startService(engine); + + registry = AiToolRegistry.getOrCreate(camelContext); + // subscribe before snapshotting so no concurrent registration is missed; publishing is idempotent + registry.addListener(listener); + registry.getTools().forEach((tag, specs) -> { + if (selectedTags.contains(tag)) { + specs.forEach(this::publish); + } + }); + } + + @Override + protected void doStop() throws Exception { + if (registry != null) { + registry.removeListener(listener); + } + lock.lock(); + try { + published.clear(); + } finally { + lock.unlock(); + } + ServiceHelper.stopService(engine); + if (executor != null) { + camelContext.getExecutorServiceManager().shutdownGraceful(executor); + executor = null; + } + } + + private McpServerEngine resolveEngine() { + McpServerEngine answer = camelContext.getRegistry().findSingleByType(McpServerEngine.class); + if (answer == null) { + answer = ResolverHelper.resolveMandatoryService(camelContext, McpServerConstants.MCP_SERVER_ENGINE_FACTORY, + McpServerEngine.class, "camel-mcp-server-engine-vertx"); + } + return answer; + } + + private void publish(AiToolSpec spec) { + McpServerTool tool = null; + lock.lock(); + try { + AiToolSpec existing = published.get(spec.getName()); + if (existing == spec) { + return; + } + if (existing != null) { + LOG.error("Refusing to expose MCP tool '{}': the name collides with an already exposed tool. " + + "MCP has a flat tool namespace - rename one of the ai-tool routes.", + spec.getName()); + return; + } + published.put(spec.getName(), spec); + tool = createTool(spec); + } finally { + lock.unlock(); + } + engine.toolAdded(tool); + } + + private void unpublish(AiToolSpec spec) { + boolean removed = false; + lock.lock(); + try { + if (published.get(spec.getName()) != spec) { + return; + } + // the same spec may be registered under several selected tags; only remove when it is gone from all + boolean stillSelected = registry.getTools().entrySet().stream() + .anyMatch(e -> selectedTags.contains(e.getKey()) && e.getValue().contains(spec)); + if (!stillSelected) { + published.remove(spec.getName()); + removed = true; + } + } finally { + lock.unlock(); + } + if (removed) { + engine.toolRemoved(spec.getName()); + } + } + + private McpServerTool createTool(AiToolSpec spec) { + McpToolCallHandler handler = arguments -> execute(spec, arguments); + return new McpServerTool() { + @Override + public String name() { + return spec.getName(); + } + + @Override + public String description() { + return spec.getDescription(); + } + + @Override + public String inputSchemaJson() { + return spec.getParametersJsonSchema(); + } + + @Override + public Map parameters() { + return spec.getParameterDefs(); + } + + @Override + public McpToolCallHandler handler() { + return handler; + } + }; + } + + private McpToolCallResult execute(AiToolSpec spec, Map arguments) { + Exchange exchange = spec.getConsumer().getEndpoint().createExchange(); + boolean release = true; + try { + Future future = executor.submit(() -> AiToolExecutor.execute(spec, arguments, exchange)); + AiToolResult result; + try { + result = future.get(configuration.getToolTimeout(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + future.cancel(true); + // the route may still be using the exchange; do not return it to the pool + release = false; + LOG.warn("MCP tool '{}' did not complete within {} ms; returning a timeout error to the client. " + + "The route keeps running until it completes on its own.", + spec.getName(), configuration.getToolTimeout()); + return new McpToolCallResult(GENERIC_TIMEOUT_ERROR, true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + release = false; + return new McpToolCallResult(GENERIC_EXECUTION_ERROR, true); + } catch (ExecutionException e) { + LOG.warn("MCP tool '{}' execution failed", spec.getName(), e.getCause()); + return new McpToolCallResult(GENERIC_EXECUTION_ERROR, true); + } + if (result instanceof AiToolResult.Success success) { + return new McpToolCallResult(success.value(), false); + } else if (result instanceof AiToolResult.ArgumentError error) { + return new McpToolCallResult(error.message(), true); + } else { + AiToolResult.ExecutionError error = (AiToolResult.ExecutionError) result; + // never leak raw route exception messages to remote MCP clients + LOG.warn("MCP tool '{}' execution failed: {}", spec.getName(), error.message(), error.cause()); + return new McpToolCallResult(GENERIC_EXECUTION_ERROR, true); + } + } finally { + if (release) { + spec.getConsumer().releaseExchange(exchange, false); + } + } + } + + private final class RegistryListener implements AiToolRegistryListener { + + @Override + public void toolRegistered(String tag, AiToolSpec spec) { + // the untagged default pool (tag == null) is never exposed + if (tag != null && selectedTags.contains(tag) && isStartingOrStarted()) { + publish(spec); + } + } + + @Override + public void toolDeregistered(String tag, AiToolSpec spec) { + if (tag != null && selectedTags.contains(tag) && isStartingOrStarted()) { + unpublish(spec); + } + } + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java new file mode 100644 index 0000000000000..1c962df45b238 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java @@ -0,0 +1,79 @@ +/* + * 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.mcp.server; + +/** + * Configuration for the {@link McpServerBridge}. + *

+ * Bridge-owned options ({@code tags}, {@code toolTimeout}) are honored on every runtime. Engine-owned options + * ({@code path}, {@code serverName}) are consumed only by engines that serve through Camel — native engines (Quarkus, + * Spring Boot) use their own runtime configuration instead. + */ +public class McpServerConfiguration { + + private String tags; + private long toolTimeout = McpServerConstants.DEFAULT_TOOL_TIMEOUT; + private String path = McpServerConstants.DEFAULT_PATH; + private String serverName; + + /** + * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are + * published; the untagged default pool is never exposed. When not set, no tools are published. + */ + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + /** + * Per-call tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP + * client; the underlying route keeps running until it completes on its own. + */ + public long getToolTimeout() { + return toolTimeout; + } + + public void setToolTimeout(long toolTimeout) { + this.toolTimeout = toolTimeout; + } + + /** + * HTTP path where the MCP endpoint is served. Engine-owned: ignored by native engines. + */ + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + /** + * MCP server name advertised to clients. Defaults to the CamelContext name. Engine-owned hint: native engines MAY + * ignore it. + */ + public String getServerName() { + return serverName; + } + + public void setServerName(String serverName) { + this.serverName = serverName; + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java new file mode 100644 index 0000000000000..50561a885b5df --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java @@ -0,0 +1,39 @@ +/* + * 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.mcp.server; + +public final class McpServerConstants { + + /** + * FactoryFinder key (under {@code META-INF/services/org/apache/camel/}) used to discover the + * {@link McpServerEngine} implementation on the classpath. + */ + public static final String MCP_SERVER_ENGINE_FACTORY = "mcp-server-engine"; + + /** + * Default HTTP path where the MCP endpoint is served by engines that consume the serving configuration. + */ + public static final String DEFAULT_PATH = "/mcp"; + + /** + * Default per-call tool execution timeout in milliseconds. + */ + public static final long DEFAULT_TOOL_TIMEOUT = 20_000; + + private McpServerConstants() { + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java new file mode 100644 index 0000000000000..485ec575fe00d --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java @@ -0,0 +1,62 @@ +/* + * 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.mcp.server; + +import org.apache.camel.CamelContextAware; +import org.apache.camel.Service; + +/** + * SPI for the runtime-specific serving layer of the Camel MCP server: a sink the bridge publishes tools into. + *

+ * The bridge owns tool selection, execution, timeout and error sanitization — identical on every runtime. The engine + * owns protocol serving (HTTP transport, sessions, notifications). One logical MCP server exists per CamelContext. + *

+ * Resolution: a bean of this type in the Camel registry wins; otherwise the engine is discovered via FactoryFinder + * under {@link McpServerConstants#MCP_SERVER_ENGINE_FACTORY}. + *

+ * Lifecycle: the bridge calls {@link #initialize(McpServerInfo)} once before starting the engine, then + * {@link #toolAdded(McpServerTool)} for the initial tool set and for every later change (driven by route + * start/stop/suspend/resume of {@code ai-tool} routes). Engines with a {@code listChanged} capability should emit + * {@code notifications/tools/list_changed} on add/remove. + */ +public interface McpServerEngine extends Service, CamelContextAware { + + /** + * Passes the server identity and serving hints. Called once, before {@link #start()}. Engines backed by a native + * runtime MCP server MAY ignore the serving hints — see {@link #consumesServingConfiguration()}. + */ + void initialize(McpServerInfo info); + + /** + * Publishes a tool. Called for the initial set and whenever a matching {@code ai-tool} route starts or resumes. + */ + void toolAdded(McpServerTool tool); + + /** + * Removes a tool by name. Called whenever a matching {@code ai-tool} route stops or suspends. + */ + void toolRemoved(String toolName); + + /** + * Whether this engine consumes the Camel-owned serving configuration ({@code path}, {@code serverName}). Engines + * backed by a native runtime MCP server return false — their own configuration decides serving concerns — and the + * bridge then warns when Camel serving properties are set but ignored. + */ + default boolean consumesServingConfiguration() { + return false; + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java new file mode 100644 index 0000000000000..1dec37eb849b8 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java @@ -0,0 +1,30 @@ +/* + * 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.mcp.server; + +/** + * Identity and serving hints passed to an {@link McpServerEngine} before it is started. + *

+ * Engines backed by a native runtime MCP server (Quarkus, Spring Boot) MAY ignore the serving hints ({@code path}) — + * their own runtime configuration decides how the server is exposed. + * + * @param serverName the MCP server name advertised to clients (defaults to the CamelContext name) + * @param version the MCP server version advertised to clients + * @param path the HTTP path where the MCP endpoint should be served + */ +public record McpServerInfo(String serverName, String version, String path) { +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java new file mode 100644 index 0000000000000..983aa368f3364 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java @@ -0,0 +1,53 @@ +/* + * 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.mcp.server; + +import java.util.Map; + +import org.apache.camel.component.ai.tool.AiToolParameterHelper.ParameterDef; + +/** + * A tool published by the bridge into an {@link McpServerEngine}. Engines pick whichever input-schema representation + * fits their API: the pre-built JSON Schema string or the structured parameter definitions. + */ +public interface McpServerTool { + + /** + * The tool name, unique within the MCP server (flat namespace). + */ + String name(); + + /** + * Human-readable tool description. + */ + String description(); + + /** + * The tool input as a JSON Schema object string, or null when the tool declares no parameters. + */ + String inputSchemaJson(); + + /** + * The tool input as structured parameter definitions; empty when the tool declares no parameters. + */ + Map parameters(); + + /** + * The handler executing the tool. Blocking, timeout-bounded and pre-sanitized by the bridge. + */ + McpToolCallHandler handler(); +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java new file mode 100644 index 0000000000000..1f0058f3c5eca --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java @@ -0,0 +1,38 @@ +/* + * 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.mcp.server; + +import java.util.Map; + +/** + * Executes a single MCP tool call. Implemented by the bridge; engines invoke it when an MCP client calls the tool. + *

+ * The call is blocking and bounded: the bridge applies the configured per-call timeout and maps every outcome + * (including route exceptions and timeouts) to a pre-sanitized {@link McpToolCallResult} — it never throws and never + * exposes route internals. + */ +@FunctionalInterface +public interface McpToolCallHandler { + + /** + * Invokes the tool with the given arguments. + * + * @param arguments the tool arguments as parsed from the MCP {@code tools/call} request, never null + * @return the sanitized result, never null + */ + McpToolCallResult call(Map arguments); +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java new file mode 100644 index 0000000000000..80a4267711ef6 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java @@ -0,0 +1,27 @@ +/* + * 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.mcp.server; + +/** + * Result of an MCP tool invocation, pre-sanitized by the bridge: the text is safe to return to a remote MCP client and + * never contains raw route exception messages. + * + * @param text the tool output, or a safe error message when {@code isError} is true + * @param isError whether the invocation failed + */ +public record McpToolCallResult(String text, boolean isError) { +} diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeResolutionTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeResolutionTest.java new file mode 100644 index 0000000000000..35ca14f1a793b --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeResolutionTest.java @@ -0,0 +1,36 @@ +/* + * 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.mcp.server; + +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class McpServerBridgeResolutionTest { + + @Test + void testStartupFailsWithClearMessageWhenNoEngineAvailable() throws Exception { + try (DefaultCamelContext camelContext = new DefaultCamelContext()) { + McpServerBridge bridge = new McpServerBridge(new McpServerConfiguration()); + assertThatThrownBy(() -> { + camelContext.addService(bridge); + camelContext.start(); + }).hasStackTraceContaining("camel-mcp-server"); + } + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java new file mode 100644 index 0000000000000..9018fa48777a4 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java @@ -0,0 +1,165 @@ +/* + * 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.mcp.server; + +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerBridgeTest extends CamelTestSupport { + + private final RecordingMcpServerEngine engine = new RecordingMcpServerEngine(); + private McpServerBridge bridge; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext camelContext = super.createCamelContext(); + // a registry bean of type McpServerEngine wins over FactoryFinder discovery + camelContext.getRegistry().bind("mcpServerEngine", engine); + McpServerConfiguration configuration = new McpServerConfiguration(); + configuration.setTags("crm,notify"); + configuration.setToolTimeout(500); + bridge = new McpServerBridge(configuration); + camelContext.addService(bridge); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:query_db?tags=crm&description=Query the customer database" + + "¶meter.customerId=string¶meter.customerId.required=true") + .routeId("query-db-route") + .setBody(simple("customer-${header.customerId}")); + + from("ai-tool:send_email?tags=notify,crm&description=Send an email") + .routeId("send-email-route") + .setBody(constant("sent")); + + from("ai-tool:boom?tags=crm&description=Always fails") + .routeId("boom-route") + .process(e -> { + throw new IllegalStateException("secret internal detail"); + }); + + from("ai-tool:slow?tags=crm&description=Too slow") + .routeId("slow-route") + .delay(5000) + .setBody(constant("done")); + + from("ai-tool:hidden_tool?description=Untagged tool") + .setBody(constant("hidden")); + + from("ai-tool:other_tool?tags=untrusted&description=Other tag") + .setBody(constant("other")); + } + }; + } + + @Test + void testPublishesOnlySelectedTags() { + assertThat(engine.tools()) + .containsKeys("query_db", "send_email", "boom", "slow") + .doesNotContainKeys("hidden_tool", "other_tool"); + assertThat(engine.info().serverName()).isEqualTo(context.getName()); + + McpServerTool tool = engine.tools().get("query_db"); + assertThat(tool.description()).isEqualTo("Query the customer database"); + assertThat(tool.inputSchemaJson()).contains("customerId"); + assertThat(tool.parameters()).containsKey("customerId"); + } + + @Test + void testCallToolSuccess() { + McpToolCallResult result = engine.tools().get("query_db").handler().call(Map.of("customerId", "42")); + + assertThat(result.isError()).isFalse(); + assertThat(result.text()).isEqualTo("customer-42"); + } + + @Test + void testCallToolMissingRequiredArgument() { + McpToolCallResult result = engine.tools().get("query_db").handler().call(Map.of()); + + assertThat(result.isError()).isTrue(); + assertThat(result.text()).contains("customerId"); + } + + @Test + void testCallToolExecutionErrorIsSanitized() { + McpToolCallResult result = engine.tools().get("boom").handler().call(Map.of()); + + assertThat(result.isError()).isTrue(); + assertThat(result.text()) + .doesNotContain("secret internal detail") + .isEqualTo("Tool execution failed"); + } + + @Test + void testCallToolTimeout() { + McpToolCallResult result = engine.tools().get("slow").handler().call(Map.of()); + + assertThat(result.isError()).isTrue(); + assertThat(result.text()).contains("timed out"); + } + + @Test + void testToolRemovedAndReAddedOnRouteLifecycle() throws Exception { + context.getRouteController().stopRoute("query-db-route"); + assertThat(engine.tools()).doesNotContainKey("query_db"); + assertThat(engine.removed()).contains("query_db"); + + context.getRouteController().startRoute("query-db-route"); + assertThat(engine.tools()).containsKey("query_db"); + } + + @Test + void testMultiTagToolRemovedOnceWhenRouteStops() throws Exception { + // send_email is registered under two selected tags: stopping the route fires two deregistration + // events but must remove the published tool exactly once + context.getRouteController().stopRoute("send-email-route"); + + assertThat(engine.tools()).doesNotContainKey("send_email"); + assertThat(engine.removed()).containsOnlyOnce("send_email"); + } + + @Test + void testNameCollisionIsRefused() throws Exception { + McpServerTool published = engine.tools().get("query_db"); + + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:query_db?tags=notify&description=Colliding tool") + .routeId("colliding-route") + .setBody(constant("other")); + } + }); + + // the colliding tool is refused: the originally published tool stays + assertThat(engine.tools().get("query_db")).isSameAs(published); + + // and removing the colliding route does not remove the published tool + context.getRouteController().stopRoute("colliding-route"); + assertThat(engine.tools()).containsKey("query_db"); + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/RecordingMcpServerEngine.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/RecordingMcpServerEngine.java new file mode 100644 index 0000000000000..738dffb5b2961 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/RecordingMcpServerEngine.java @@ -0,0 +1,74 @@ +/* + * 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.mcp.server; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.apache.camel.CamelContext; +import org.apache.camel.support.service.ServiceSupport; + +/** + * Mock {@link McpServerEngine} recording the tools published by the bridge, for engine-less bridge tests. + */ +public class RecordingMcpServerEngine extends ServiceSupport implements McpServerEngine { + + private final Map tools = new ConcurrentHashMap<>(); + private final List removed = new CopyOnWriteArrayList<>(); + private CamelContext camelContext; + private McpServerInfo info; + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + @Override + public void initialize(McpServerInfo info) { + this.info = info; + } + + @Override + public void toolAdded(McpServerTool tool) { + tools.put(tool.name(), tool); + } + + @Override + public void toolRemoved(String toolName) { + tools.remove(toolName); + removed.add(toolName); + } + + public Map tools() { + return tools; + } + + public List removed() { + return removed; + } + + public McpServerInfo info() { + return info; + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/conformance/McpServerConformanceTestSupport.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/conformance/McpServerConformanceTestSupport.java new file mode 100644 index 0000000000000..119f309b258f0 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/conformance/McpServerConformanceTestSupport.java @@ -0,0 +1,204 @@ +/* + * 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.mcp.server.conformance; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mcp.server.McpServerBridge; +import org.apache.camel.component.mcp.server.McpServerConfiguration; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Engine conformance kit: the behavioural contract every {@link org.apache.camel.component.mcp.server.McpServerEngine} + * implementation must satisfy, verified with the official MCP Java SDK client over streamable HTTP. + *

+ * Engine modules extend this class (it is shipped in the camel-mcp-server-api test-jar), install their serving + * infrastructure in {@link #customizeCamelContext(CamelContext)} and point {@link #mcpServerBaseUrl()} at the running + * server. The kit owns the ai-tool routes and the {@link McpServerBridge} so tool semantics cannot drift between + * engines. + */ +public abstract class McpServerConformanceTestSupport extends CamelTestSupport { + + public static final String CONFORMANCE_TAG = "conformance"; + public static final long TOOL_TIMEOUT_MILLIS = 2000; + + protected McpServerBridge bridge; + private McpSyncClient client; + + /** + * Base URL of the server under test, without the MCP endpoint path (the SDK client appends {@code /mcp}). + */ + protected abstract String mcpServerBaseUrl(); + + /** + * Installs the serving infrastructure the engine under test needs (e.g. an HTTP server service). Called before the + * bridge is added to the context. + */ + protected void customizeCamelContext(CamelContext camelContext) throws Exception { + } + + /** + * Adjusts the bridge configuration; tags and tool timeout are preset by the kit. + */ + protected void configureBridge(McpServerConfiguration configuration) { + } + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext camelContext = super.createCamelContext(); + customizeCamelContext(camelContext); + McpServerConfiguration configuration = new McpServerConfiguration(); + configuration.setTags(CONFORMANCE_TAG); + configuration.setToolTimeout(TOOL_TIMEOUT_MILLIS); + configureBridge(configuration); + bridge = new McpServerBridge(configuration); + camelContext.addService(bridge); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:say_hello?tags=" + CONFORMANCE_TAG + "&description=Say hello" + + "¶meter.name=string¶meter.name.description=Who to greet¶meter.name.required=true") + .routeId("say-hello-route") + .setBody(simple("Hello ${header.name}")); + + from("ai-tool:fail_tool?tags=" + CONFORMANCE_TAG + "&description=Always fails") + .routeId("fail-tool-route") + .process(e -> { + throw new IllegalStateException("secret internal detail"); + }); + + from("ai-tool:slow_tool?tags=" + CONFORMANCE_TAG + "&description=Exceeds the tool timeout") + .routeId("slow-tool-route") + .delay(TOOL_TIMEOUT_MILLIS * 3) + .setBody(constant("done")); + + from("ai-tool:hidden_tool?description=Untagged tool, must not be exposed") + .setBody(constant("hidden")); + + from("ai-tool:other_tool?tags=untrusted&description=Not a selected tag, must not be exposed") + .setBody(constant("other")); + } + }; + } + + protected McpSyncClient client() { + if (client == null) { + client = McpClient.sync(HttpClientStreamableHttpTransport.builder(mcpServerBaseUrl()).build()) + .requestTimeout(Duration.ofSeconds(10)) + .initializationTimeout(Duration.ofSeconds(10)) + .build(); + client.initialize(); + } + return client; + } + + @AfterEach + void closeClient() { + if (client != null) { + client.closeGracefully(); + client = null; + } + } + + @Test + void testListToolsExposesOnlySelectedTags() { + List tools = client().listTools().tools(); + + assertThat(tools).extracting(McpSchema.Tool::name) + .contains("say_hello", "fail_tool", "slow_tool") + .doesNotContain("hidden_tool", "other_tool"); + + McpSchema.Tool sayHello = tools.stream().filter(t -> "say_hello".equals(t.name())).findFirst().orElseThrow(); + assertThat(sayHello.description()).isEqualTo("Say hello"); + assertThat(sayHello.inputSchema()).containsKey("properties"); + assertThat(sayHello.inputSchema().toString()).contains("name"); + } + + @Test + void testCallToolSuccess() { + McpSchema.CallToolResult result + = client().callTool(new McpSchema.CallToolRequest("say_hello", Map.of("name", "World"))); + + assertThat(result.isError()).isNotEqualTo(Boolean.TRUE); + assertThat(textOf(result)).isEqualTo("Hello World"); + } + + @Test + void testCallToolMissingRequiredArgument() { + McpSchema.CallToolResult result = client().callTool(new McpSchema.CallToolRequest("say_hello", Map.of())); + + assertThat(result.isError()).isEqualTo(Boolean.TRUE); + assertThat(textOf(result)).contains("name"); + } + + @Test + void testCallToolExecutionErrorIsSanitized() { + McpSchema.CallToolResult result = client().callTool(new McpSchema.CallToolRequest("fail_tool", Map.of())); + + assertThat(result.isError()).isEqualTo(Boolean.TRUE); + assertThat(textOf(result)) + .doesNotContain("secret internal detail") + .isEqualTo("Tool execution failed"); + } + + @Test + void testCallToolTimeout() { + McpSchema.CallToolResult result = client().callTool(new McpSchema.CallToolRequest("slow_tool", Map.of())); + + assertThat(result.isError()).isEqualTo(Boolean.TRUE); + assertThat(textOf(result)).contains("timed out"); + } + + @Test + void testToolsListReflectsRouteStopAndStart() throws Exception { + assertThat(client().listTools().tools()).extracting(McpSchema.Tool::name).contains("say_hello"); + + context.getRouteController().stopRoute("say-hello-route"); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertThat(client().listTools().tools()) + .extracting(McpSchema.Tool::name).doesNotContain("say_hello")); + + context.getRouteController().startRoute("say-hello-route"); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertThat(client().listTools().tools()) + .extracting(McpSchema.Tool::name).contains("say_hello")); + } + + protected static String textOf(McpSchema.CallToolResult result) { + return result.content().stream() + .filter(McpSchema.TextContent.class::isInstance) + .map(c -> ((McpSchema.TextContent) c).text()) + .collect(Collectors.joining()); + } +} diff --git a/components/camel-ai/camel-mcp-server/pom.xml b/components/camel-ai/camel-mcp-server/pom.xml new file mode 100644 index 0000000000000..02ed87c632731 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/pom.xml @@ -0,0 +1,88 @@ + + + + 4.0.0 + + + org.apache.camel + camel-ai-parent + 4.22.0-SNAPSHOT + + + camel-mcp-server + jar + Camel :: AI :: MCP Server + Expose ai-tool routes as MCP tools over streamable HTTP + + + 4.22.0 + + MCP Server + Preview + + + + + + org.apache.camel + camel-mcp-server-api + + + org.apache.camel + camel-platform-http-vertx + + + io.modelcontextprotocol.sdk + mcp-core + ${mcp-java-sdk-version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp-java-sdk-version} + + + + + org.apache.camel + camel-mcp-server-api + test-jar + test + + + org.apache.camel + camel-test-junit6 + test + + + org.awaitility + awaitility + ${awaitility-version} + test + + + org.assertj + assertj-core + test + + + + + diff --git a/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server-engine b/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server-engine new file mode 100644 index 0000000000000..9dad13da0ccb3 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server-engine @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.mcp.server.vertx.VertxMcpServerEngine diff --git a/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/other.properties b/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/other.properties new file mode 100644 index 0000000000000..fdb4e613d7813 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/other.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +name=mcp-server +groupId=org.apache.camel +artifactId=camel-mcp-server +version=4.22.0-SNAPSHOT +projectName=Camel :: AI :: MCP Server +projectDescription=Expose ai-tool routes as MCP tools over streamable HTTP diff --git a/components/camel-ai/camel-mcp-server/src/generated/resources/mcp-server.json b/components/camel-ai/camel-mcp-server/src/generated/resources/mcp-server.json new file mode 100644 index 0000000000000..b1eea4c1ff42d --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/generated/resources/mcp-server.json @@ -0,0 +1,15 @@ +{ + "other": { + "kind": "other", + "name": "mcp-server", + "title": "MCP Server", + "description": "Expose ai-tool routes as MCP tools over streamable HTTP", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "ai", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-mcp-server", + "version": "4.22.0-SNAPSHOT" + } +} diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc new file mode 100644 index 0000000000000..3fb200e38dd24 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc @@ -0,0 +1,180 @@ += MCP Server Component +:doctitle: MCP Server +:shortname: mcp-server +:artifactid: camel-mcp-server +:description: Expose ai-tool routes as MCP tools over streamable HTTP +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: + +*Since Camel {since}* + +The camel-mcp-server module exposes Camel routes registered via the +xref:components::ai-tool-component.adoc[ai-tool] component as tools of a +https://modelcontextprotocol.io[Model Context Protocol] (MCP) server, served +over MCP streamable HTTP. No route is needed for the server itself: add the +dependency, configure which tags to expose, and every matching `ai-tool` route +becomes an MCP tool that any MCP client (another Camel application, an IDE, a +coding agent) can discover and call. + +Maven users will need to add the following dependency to their `pom.xml`: + +[source,xml] +---- + + org.apache.camel + camel-mcp-server + x.x.x + + +---- + +== Architecture + +The module is split in two artifacts: + +* `camel-mcp-server-api` — the runtime-agnostic _bridge_ and the small + `McpServerEngine` SPI. The bridge owns tool selection (tags), execution via + the shared `AiToolExecutor` (per-call timeout, error sanitization) and reacts + to `AiToolRegistry` changes when routes start and stop. It has no dependency + on the MCP Java SDK. +* `camel-mcp-server` — the serving engine for Camel Main and Camel JBang, + built on the official MCP Java SDK with a Vert.x streamable HTTP transport. + The MCP endpoint is registered on the Camel main HTTP server's router, so it + serves on the main server port (`camel.server.port`) and inherits its + lifecycle, authentication and CORS configuration. + +Engine resolution mirrors the platform-http engine: a bean of type +`McpServerEngine` in the Camel registry wins; otherwise the engine is +discovered on the classpath. Other runtimes plug native engines through the +same SPI: on Quarkus the `camel-quarkus-mcp-server` extension serves through +the Quarkiverse `quarkus-mcp-server` (configured via `quarkus.mcp.server.*`), +and on Spring Boot the starter serves through the Spring AI MCP server +(configured via `spring.ai.mcp.server.*`). Bridge behavior — tag selection, +timeout, sanitization — is identical on every runtime and verified by a shared +conformance test kit. + +== Usage + +Define tools as regular `ai-tool` routes and give them tags: + +[source,yaml] +---- +- route: + from: + uri: "ai-tool:query_db" + parameters: + description: "Query customer database" + tags: "crm" + parameter.customerId: string + parameter.customerId.description: "The customer id" + parameter.customerId.required: "true" + steps: + - to: "jdbc:dataSource" +---- + +Start the MCP server by adding the `McpServerBridge` service to the +CamelContext, selecting the tags to expose: + +[source,java] +---- +McpServerConfiguration configuration = new McpServerConfiguration(); +configuration.setTags("crm,notify"); +camelContext.addService(new McpServerBridge(configuration)); +---- + +The MCP endpoint is then served at `http://:/mcp` on the Camel +main HTTP server. Any MCP client can connect over streamable HTTP, for +example another Camel integration using the +xref:components::openai-component.adoc[camel-openai] MCP client: + +[source,java] +---- +from("direct:agent") + .to("openai:chat-completion" + + "?model={{llm.model}}" + + "&autoToolExecution=true" + + "&mcpServer.myCamelTools.transportType=streamableHttp" + + "&mcpServer.myCamelTools.url=http://localhost:8080/mcp"); +---- + +NOTE: Configuration through `camel.server.mcp-*` properties (no code at all, +like Jolokia or Prometheus) is tracked by CAMEL-24311 and arrives together +with the camel-main wiring. + +== Options + +The `McpServerConfiguration` options: + +[width="100%",cols="2,5,2,1",options="header"] +|=== +| Option | Description | Default | Owner + +| `tags` | Comma-separated list of ai-tool tags to expose as MCP tools. Only + tools registered under one of these tags are published; the untagged + default pool is never exposed. When not set, no tools are published. | | + bridge +| `toolTimeout` | Per-call tool execution timeout in milliseconds. A call + exceeding the timeout returns an error result to the MCP client; the + underlying route keeps running until it completes on its own. | `20000` | + bridge +| `path` | HTTP path where the MCP endpoint is served. | `/mcp` | engine +| `serverName` | MCP server name advertised to clients. | CamelContext name | + engine +|=== + +Bridge-owned options are honored identically on every runtime. Engine-owned +options are consumed by the Vert.x engine only; on runtimes with a native +engine (Quarkus, Spring Boot) the native configuration decides serving +concerns and a startup WARN is logged when an ignored option is set. + +== Protocol + +The Vert.x engine implements the MCP streamable HTTP transport: + +* `POST /mcp` answering `application/json` or `text/event-stream` depending on + the request, +* a long-lived `GET /mcp` SSE channel for server notifications, with + `Last-Event-ID` replay, +* session management via the `Mcp-Session-Id` header and `DELETE /mcp` for + session termination. + +Tools appearing or disappearing (routes starting and stopping) emit +`notifications/tools/list_changed` to connected clients. + +== Security + +External MCP clients are *untrusted senders* under the +xref:manual::security-model.adoc[Camel security model]. The module applies the +following rules: + +* *Explicit opt-in per tool*: only tools whose tags intersect the configured + `tags` are exposed. The untagged default pool is never exposed implicitly. +* *Flat namespace protection*: a tool whose name collides with an already + exposed tool is refused with an ERROR log — never silently replaced. +* *Error sanitization*: route exceptions are mapped to a generic error + message; the cause is logged server-side and never sent to the client. + Argument validation messages (missing or invalid parameters) are returned + as-is. +* *Bounded execution*: every call is subject to the `toolTimeout`. Note that a + timed-out route keeps running server-side until it completes; the timeout + bounds the MCP request, not the route. +* *Authentication*: the MCP endpoint is served through the main HTTP server + router, so platform-http authentication (basic, JWT via + `camel.server.authentication*` options) applies to it. The MCP + specification's authorization model is OAuth 2.1; see + xref:components:others:oauth.adoc[camel-oauth] for resource-server style + protection. On Quarkus and Spring Boot, authentication is owned by the + native runtime security. + +== Runtime notes + +* *Camel Main / JBang*: requires the Camel main HTTP server + (`camel.server.enabled=true` with `camel-platform-http-main`, automatic + with Camel JBang) or a `VertxPlatformHttpServer` service. Serving is fully + asynchronous: tool calls are offloaded to the Vert.x worker pool and the + long-lived SSE channel does not occupy a worker thread. +* *Quarkus*: use the `camel-quarkus-mcp-server` extension (serves through + quarkus-mcp-server; the MCP Java SDK is not on the classpath). +* *Spring Boot*: use the `camel-mcp-server-starter` (serves through the + Spring AI MCP server). diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java new file mode 100644 index 0000000000000..ecbec78c47ec7 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java @@ -0,0 +1,164 @@ +/* + * 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.mcp.server.vertx; + +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.McpServerConstants; +import org.apache.camel.component.mcp.server.McpServerEngine; +import org.apache.camel.component.mcp.server.McpServerInfo; +import org.apache.camel.component.mcp.server.McpServerTool; +import org.apache.camel.component.mcp.server.McpToolCallResult; +import org.apache.camel.component.platform.http.PlatformHttpComponent; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpRouter; +import org.apache.camel.spi.annotations.JdkService; +import org.apache.camel.support.service.ServiceSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link McpServerEngine} for Camel Main / JBang: serves MCP streamable HTTP through the Vert.x platform HTTP router + * using the official MCP Java SDK. The MCP endpoint is registered on the main HTTP server's router, so it serves on the + * main server port and inherits its lifecycle, authentication and CORS configuration. + */ +@JdkService(McpServerConstants.MCP_SERVER_ENGINE_FACTORY) +public class VertxMcpServerEngine extends ServiceSupport implements McpServerEngine { + + private static final Logger LOG = LoggerFactory.getLogger(VertxMcpServerEngine.class); + + private static final String EMPTY_OBJECT_SCHEMA = "{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}"; + private static final String APPLICATION_JSON = "application/json"; + + private CamelContext camelContext; + private McpServerInfo info; + private McpJsonMapper jsonMapper; + private VertxMcpStreamableServerTransportProvider transport; + private McpSyncServer server; + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + @Override + public void initialize(McpServerInfo info) { + this.info = info; + } + + @Override + public boolean consumesServingConfiguration() { + return true; + } + + @Override + protected void doStart() throws Exception { + VertxPlatformHttpRouter router = lookupRouter(); + jsonMapper = McpJsonDefaults.getMapper(); + transport = new VertxMcpStreamableServerTransportProvider(jsonMapper, info.path()); + server = McpServer.sync(transport) + .serverInfo(info.serverName(), info.version()) + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .immediateExecution(true) + .build(); + // register routes only once the server has set the session factory on the transport + transport.registerRoutes(router); + + PlatformHttpComponent platformHttpComponent + = (PlatformHttpComponent) camelContext.hasComponent("platform-http"); + if (platformHttpComponent != null) { + platformHttpComponent.addHttpEndpoint(info.path(), "GET,POST,DELETE", APPLICATION_JSON, + "application/json,text/event-stream", null); + } + LOG.info("MCP server '{}' serving tools on path {}", info.serverName(), info.path()); + } + + @Override + protected void doStop() throws Exception { + if (transport != null) { + transport.unregisterRoutes(); + } + if (server != null) { + server.closeGracefully(); + server = null; + } + PlatformHttpComponent platformHttpComponent + = (PlatformHttpComponent) camelContext.hasComponent("platform-http"); + if (platformHttpComponent != null && info != null) { + platformHttpComponent.removeHttpEndpoint(info.path()); + } + transport = null; + } + + @Override + public void toolAdded(McpServerTool tool) { + String schema = tool.inputSchemaJson() != null ? tool.inputSchemaJson() : EMPTY_OBJECT_SCHEMA; + McpSchema.Tool mcpTool = McpSchema.Tool.builder(tool.name(), jsonMapper, schema) + .description(tool.description()) + .build(); + McpServerFeatures.SyncToolSpecification spec = McpServerFeatures.SyncToolSpecification.builder() + .tool(mcpTool) + .callHandler((exchange, request) -> { + Map arguments = request.arguments() != null ? request.arguments() : Map.of(); + McpToolCallResult result = tool.handler().call(arguments); + return McpSchema.CallToolResult.builder() + .addTextContent(result.text()) + .isError(result.isError()) + .build(); + }) + .build(); + server.addTool(spec); + LOG.debug("MCP tool added: {}", tool.name()); + } + + @Override + public void toolRemoved(String toolName) { + try { + server.removeTool(toolName); + LOG.debug("MCP tool removed: {}", toolName); + } catch (Exception e) { + LOG.debug("Failed to remove MCP tool {}: {}", toolName, e.getMessage()); + } + } + + private VertxPlatformHttpRouter lookupRouter() { + Set routers = camelContext.getRegistry().findByType(VertxPlatformHttpRouter.class); + VertxPlatformHttpRouter router = routers.stream() + .filter(VertxPlatformHttpRouter::isMainServer) + .findFirst() + .orElseGet(() -> routers.size() == 1 ? routers.iterator().next() : null); + if (router == null) { + throw new IllegalStateException( + "The MCP server requires the Vert.x platform HTTP server. Enable the Camel main HTTP server " + + "(camel.server.enabled=true with camel-platform-http-main on the classpath) " + + "or add a VertxPlatformHttpServer service to the CamelContext."); + } + return router; + } +} diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java new file mode 100644 index 0000000000000..c5d616ba4414f --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java @@ -0,0 +1,402 @@ +/* + * 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.mcp.server.vertx; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.HttpHeaders; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStreamableServerSession; +import io.modelcontextprotocol.spec.McpStreamableServerTransport; +import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; +import io.vertx.core.Context; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpRouter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * MCP streamable HTTP server transport serving through the Vert.x platform HTTP router: POST answering + * {@code application/json} or {@code text/event-stream}, long-lived GET SSE channel with {@code Last-Event-ID} replay, + * {@code Mcp-Session-Id} session management and DELETE for session termination. + *

+ * This is the Vert.x equivalent of the MCP SDK's {@code HttpServletStreamableServerTransportProvider} (the SDK ships + * only servlet and stdio server transports). Request handling is offloaded to the Vert.x worker pool (unordered); + * response writes always run on the connection's event-loop context. The long-lived GET stream does not occupy a worker + * thread. + */ +public class VertxMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { + + public static final String MESSAGE_EVENT_TYPE = "message"; + + private static final Logger LOG = LoggerFactory.getLogger(VertxMcpStreamableServerTransportProvider.class); + + private static final String ACCEPT = "Accept"; + private static final String APPLICATION_JSON = "application/json"; + private static final String TEXT_EVENT_STREAM = "text/event-stream"; + + private final McpJsonMapper jsonMapper; + private final String path; + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + private final List routes = new ArrayList<>(); + + private McpStreamableServerSession.Factory sessionFactory; + private volatile boolean closing; + + public VertxMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, String path) { + this.jsonMapper = jsonMapper; + this.path = path; + } + + @Override + public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono notifyClients(String method, Object params) { + if (sessions.isEmpty()) { + return Mono.empty(); + } + return Mono.fromRunnable(() -> sessions.values().forEach(session -> { + try { + session.sendNotification(method, params).block(); + } catch (Exception e) { + LOG.debug("Failed to send notification to MCP session {}: {}", session.getId(), e.getMessage()); + } + })); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + closing = true; + sessions.values().forEach(session -> { + try { + session.closeGracefully().block(); + } catch (Exception e) { + LOG.debug("Failed to close MCP session {}: {}", session.getId(), e.getMessage()); + } + }); + sessions.clear(); + }); + } + + /** + * Registers the POST/GET/DELETE routes for the MCP endpoint. Must be called after the MCP server has been built + * (the server sets the session factory on construction). + */ + public void registerRoutes(VertxPlatformHttpRouter router) { + Vertx vertx = router.vertx(); + Route post = router.route(path).method(HttpMethod.POST); + post.handler(BodyHandler.create(false)); + post.handler(ctx -> dispatch(vertx, ctx, this::handlePost)); + routes.add(post); + Route get = router.route(path).method(HttpMethod.GET); + get.handler(ctx -> dispatch(vertx, ctx, this::handleGet)); + routes.add(get); + Route delete = router.route(path).method(HttpMethod.DELETE); + delete.handler(ctx -> dispatch(vertx, ctx, this::handleDelete)); + routes.add(delete); + } + + public void unregisterRoutes() { + routes.forEach(Route::remove); + routes.clear(); + } + + @FunctionalInterface + private interface BlockingRequestHandler { + void handle(RoutingContext ctx, Context connection) throws Exception; + } + + private void dispatch(Vertx vertx, RoutingContext ctx, BlockingRequestHandler handler) { + // capture the connection's event-loop context before offloading; all response writes go through it + Context connection = vertx.getOrCreateContext(); + vertx.executeBlocking(() -> { + handler.handle(ctx, connection); + return null; + }, false).onFailure(t -> { + LOG.warn("Error handling MCP request", t); + if (!ctx.response().ended()) { + ctx.response().setStatusCode(500).end(); + } + }); + } + + private void handlePost(RoutingContext ctx, Context connection) throws Exception { + if (closing) { + endWithStatus(connection, ctx, 503); + return; + } + List badRequestErrors = new ArrayList<>(); + String accept = ctx.request().getHeader(ACCEPT); + if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) { + badRequestErrors.add("text/event-stream required in Accept header"); + } + if (accept == null || !accept.contains(APPLICATION_JSON)) { + badRequestErrors.add("application/json required in Accept header"); + } + + McpSchema.JSONRPCMessage message; + try { + message = McpSchema.deserializeJsonRpcMessage(jsonMapper, ctx.body().asString()); + } catch (Exception e) { + respondError(connection, ctx, 400, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Invalid message format: " + e.getMessage()).build()); + return; + } + + if (message instanceof McpSchema.JSONRPCRequest request + && McpSchema.METHOD_INITIALIZE.equals(request.method())) { + if (respondBadRequest(connection, ctx, badRequestErrors)) { + return; + } + handleInitialize(ctx, connection, request); + return; + } + + String sessionId = ctx.request().getHeader(HttpHeaders.MCP_SESSION_ID); + if (sessionId == null || sessionId.isBlank()) { + badRequestErrors.add("Session ID required in " + HttpHeaders.MCP_SESSION_ID + " header"); + } + if (respondBadRequest(connection, ctx, badRequestErrors)) { + return; + } + McpStreamableServerSession session = sessions.get(sessionId); + if (session == null) { + respondError(connection, ctx, 404, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Session not found: " + sessionId).build()); + return; + } + + if (message instanceof McpSchema.JSONRPCResponse response) { + session.accept(response).block(); + endWithStatus(connection, ctx, 202); + } else if (message instanceof McpSchema.JSONRPCNotification notification) { + session.accept(notification).block(); + endWithStatus(connection, ctx, 202); + } else if (message instanceof McpSchema.JSONRPCRequest request) { + VertxMcpSessionTransport transport = startSseResponse(ctx, connection, sessionId); + try { + session.responseStream(request, transport).block(); + } catch (Exception e) { + LOG.warn("Failed to handle MCP request stream: {}", e.getMessage()); + transport.close(); + } + } else { + respondError(connection, ctx, 500, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Unknown message type").build()); + } + } + + private void handleInitialize(RoutingContext ctx, Context connection, McpSchema.JSONRPCRequest request) + throws Exception { + McpSchema.InitializeRequest initializeRequest + = jsonMapper.convertValue(request.params(), new TypeRef() { + }); + McpStreamableServerSession.McpStreamableServerSessionInit init = sessionFactory.startSession(initializeRequest); + sessions.put(init.session().getId(), init.session()); + McpSchema.InitializeResult initResult = init.initResult().block(); + String json = jsonMapper.writeValueAsString(McpSchema.JSONRPCResponse.result(request.id(), initResult)); + connection.runOnContext(v -> ctx.response() + .setStatusCode(200) + .putHeader("Content-Type", APPLICATION_JSON) + .putHeader(HttpHeaders.MCP_SESSION_ID, init.session().getId()) + .end(json)); + } + + private void handleGet(RoutingContext ctx, Context connection) { + if (closing) { + endWithStatus(connection, ctx, 503); + return; + } + List badRequestErrors = new ArrayList<>(); + String accept = ctx.request().getHeader(ACCEPT); + if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) { + badRequestErrors.add("text/event-stream required in Accept header"); + } + String sessionId = ctx.request().getHeader(HttpHeaders.MCP_SESSION_ID); + if (sessionId == null || sessionId.isBlank()) { + badRequestErrors.add("Session ID required in " + HttpHeaders.MCP_SESSION_ID + " header"); + } + if (respondBadRequest(connection, ctx, badRequestErrors)) { + return; + } + McpStreamableServerSession session = sessions.get(sessionId); + if (session == null) { + endWithStatus(connection, ctx, 404); + return; + } + + VertxMcpSessionTransport transport = startSseResponse(ctx, connection, sessionId); + String lastEventId = ctx.request().getHeader(HttpHeaders.LAST_EVENT_ID); + if (lastEventId != null) { + try { + session.replay(lastEventId).toIterable().forEach(message -> transport.sendMessage(message).block()); + } catch (Exception e) { + LOG.warn("Failed to replay MCP messages: {}", e.getMessage()); + transport.close(); + } + } else { + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream + = session.listeningStream(transport); + connection.runOnContext(v -> ctx.response().closeHandler(x -> listeningStream.close())); + } + } + + private void handleDelete(RoutingContext ctx, Context connection) { + if (closing) { + endWithStatus(connection, ctx, 503); + return; + } + String sessionId = ctx.request().getHeader(HttpHeaders.MCP_SESSION_ID); + if (sessionId == null || sessionId.isBlank()) { + respondError(connection, ctx, 400, McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Session ID required in " + HttpHeaders.MCP_SESSION_ID + " header").build()); + return; + } + McpStreamableServerSession session = sessions.get(sessionId); + if (session == null) { + endWithStatus(connection, ctx, 404); + return; + } + session.delete().block(); + sessions.remove(sessionId); + endWithStatus(connection, ctx, 200); + } + + private VertxMcpSessionTransport startSseResponse(RoutingContext ctx, Context connection, String sessionId) { + connection.runOnContext(v -> ctx.response() + .setChunked(true) + .putHeader("Content-Type", TEXT_EVENT_STREAM) + .putHeader("Cache-Control", "no-cache")); + return new VertxMcpSessionTransport(sessionId, ctx.response(), connection); + } + + private boolean respondBadRequest(Context connection, RoutingContext ctx, List errors) { + if (errors.isEmpty()) { + return false; + } + respondError(connection, ctx, 400, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(String.join("; ", errors)).build()); + return true; + } + + private void respondError(Context connection, RoutingContext ctx, int status, McpError error) { + String json; + try { + json = jsonMapper.writeValueAsString(error); + } catch (Exception e) { + json = "{}"; + } + String body = json; + connection.runOnContext(v -> ctx.response() + .setStatusCode(status) + .putHeader("Content-Type", APPLICATION_JSON) + .end(body)); + } + + private void endWithStatus(Context connection, RoutingContext ctx, int status) { + connection.runOnContext(v -> ctx.response().setStatusCode(status).end()); + } + + /** + * Per-connection transport writing SSE frames on the connection's event-loop context. The SDK session awaits each + * write, providing natural backpressure. + */ + private final class VertxMcpSessionTransport implements McpStreamableServerTransport { + + private final String sessionId; + private final HttpServerResponse response; + private final Context connection; + private volatile boolean closed; + + private VertxMcpSessionTransport(String sessionId, HttpServerResponse response, Context connection) { + this.sessionId = sessionId; + this.response = response; + this.connection = connection; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return sendMessage(message, null); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.create(sink -> connection.runOnContext(v -> { + if (closed || response.ended() || response.closed()) { + sink.success(); + return; + } + try { + String json = jsonMapper.writeValueAsString(message); + String frame = "id: " + (messageId != null ? messageId : sessionId) + "\n" + + "event: " + MESSAGE_EVENT_TYPE + "\n" + + "data: " + json + "\n\n"; + response.write(frame).onComplete(result -> { + if (!result.succeeded()) { + LOG.debug("Failed to write to MCP session {}: {}", sessionId, + result.cause() != null ? result.cause().getMessage() : "unknown"); + closed = true; + } + sink.success(); + }); + } catch (Exception e) { + LOG.warn("Failed to send message to MCP session {}: {}", sessionId, e.getMessage()); + closed = true; + sink.success(); + } + })); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return jsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(this::close); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + connection.runOnContext(v -> { + if (!response.ended() && !response.closed()) { + response.end(); + } + }); + } + } +} diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerConformanceTest.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerConformanceTest.java new file mode 100644 index 0000000000000..572cbc15e273a --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerConformanceTest.java @@ -0,0 +1,44 @@ +/* + * 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.mcp.server.vertx; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.conformance.McpServerConformanceTestSupport; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServer; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServerConfiguration; +import org.apache.camel.test.AvailablePortFinder; + +/** + * Runs the engine conformance kit against {@link VertxMcpServerEngine} serving through a standalone Vert.x platform + * HTTP server (the same serving path as the Camel main HTTP server). + */ +class VertxMcpServerConformanceTest extends McpServerConformanceTestSupport { + + private final int port = AvailablePortFinder.getNextAvailable(); + + @Override + protected void customizeCamelContext(CamelContext camelContext) throws Exception { + VertxPlatformHttpServerConfiguration configuration = new VertxPlatformHttpServerConfiguration(); + configuration.setBindPort(port); + camelContext.addService(new VertxPlatformHttpServer(configuration)); + } + + @Override + protected String mcpServerBaseUrl() { + return "http://localhost:" + port; + } +} diff --git a/components/camel-ai/pom.xml b/components/camel-ai/pom.xml index 05aa67814618d..44440488665dd 100644 --- a/components/camel-ai/pom.xml +++ b/components/camel-ai/pom.xml @@ -51,6 +51,8 @@ camel-langchain4j-tokenizer camel-langchain4j-tools camel-langchain4j-web-search + camel-mcp-server + camel-mcp-server-api camel-milvus camel-neo4j camel-openai diff --git a/docs/components/modules/others/nav.adoc b/docs/components/modules/others/nav.adoc index 9ff3c325c7fa8..093949c595f5e 100644 --- a/docs/components/modules/others/nav.adoc +++ b/docs/components/modules/others/nav.adoc @@ -50,6 +50,7 @@ ** xref:lra.adoc[LRA] ** xref:mail-microsoft-oauth.adoc[Mail Microsoft Oauth] ** xref:main.adoc[Main] +** xref:mcp-server.adoc[MCP Server] ** xref:mdc.adoc[MDC Logging] ** xref:observation.adoc[Micrometer Observability] ** xref:micrometer-observability.adoc[Micrometer Observability 2] diff --git a/docs/components/modules/others/pages/mcp-server.adoc b/docs/components/modules/others/pages/mcp-server.adoc new file mode 120000 index 0000000000000..8aa49a42432c7 --- /dev/null +++ b/docs/components/modules/others/pages/mcp-server.adoc @@ -0,0 +1 @@ +../../../../../components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc \ No newline at end of file diff --git a/parent/pom.xml b/parent/pom.xml index 21c8136c63a5f..2329c85e5262f 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -2110,6 +2110,16 @@ camel-master ${project.version} + + org.apache.camel + camel-mcp-server + ${project.version} + + + org.apache.camel + camel-mcp-server-api + ${project.version} + org.apache.camel camel-mdc @@ -3239,6 +3249,12 @@ ${project.version} test-jar + + org.apache.camel + camel-mcp-server-api + ${project.version} + test-jar + diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java index 2d26ee04cf306..a7060460c6199 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java @@ -45,6 +45,7 @@ public static List getComponentPath(Path dir) { dir.resolve("camel-langchain4j-embeddings"), dir.resolve("camel-langchain4j-embeddingstore"), dir.resolve("camel-langchain4j-tokenizer"), dir.resolve("camel-langchain4j-tools"), dir.resolve("camel-langchain4j-web-search"), + dir.resolve("camel-mcp-server"), dir.resolve("camel-qdrant"), dir.resolve("camel-milvus"), dir.resolve("camel-neo4j"), dir.resolve("camel-openai"), dir.resolve("camel-pgvector"), dir.resolve("camel-pinecone"), dir.resolve("camel-kserve"), From 79d3eccc0913dce0f68ea75e1eee41c3ce0a0fb7 Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 10:07:31 +0200 Subject: [PATCH 03/11] CAMEL-24310: camel-mcp-server - use text block for empty object schema Co-Authored-By: Claude Fable 5 --- .../component/mcp/server/vertx/VertxMcpServerEngine.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java index ecbec78c47ec7..2dd0e8e0d20cd 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java @@ -48,7 +48,13 @@ public class VertxMcpServerEngine extends ServiceSupport implements McpServerEng private static final Logger LOG = LoggerFactory.getLogger(VertxMcpServerEngine.class); - private static final String EMPTY_OBJECT_SCHEMA = "{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}"; + private static final String EMPTY_OBJECT_SCHEMA = """ + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """; private static final String APPLICATION_JSON = "application/json"; private CamelContext camelContext; From d7c8f444544ba552997d7606262dbf1baf96025f Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 10:15:39 +0200 Subject: [PATCH 04/11] CAMEL-24310: camel-mcp-server - integration tests - MainHttpServerMcpConformanceIT: the engine conformance kit against the Camel main HTTP server (camel-platform-http-main), the real Camel Main/JBang serving path. No Docker required. - McpServerOpenAIAgentIT: end-to-end agentic loop from CAMEL-24308 - the application exposes its own ai-tool routes over MCP and an LLM (camel-openai + Ollama test-infra) discovers and calls them with automatic tool execution. CI-gated like the other AI component ITs. - test-execution.md run-book following the camel-openai convention. Co-Authored-By: Claude Fable 5 --- components/camel-ai/camel-mcp-server/pom.xml | 18 ++ .../MainHttpServerMcpConformanceIT.java | 46 +++++ .../integration/McpServerOpenAIAgentIT.java | 157 ++++++++++++++++++ .../camel-mcp-server/test-execution.md | 38 +++++ 4 files changed, 259 insertions(+) create mode 100644 components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/MainHttpServerMcpConformanceIT.java create mode 100644 components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/McpServerOpenAIAgentIT.java create mode 100644 components/camel-ai/camel-mcp-server/test-execution.md diff --git a/components/camel-ai/camel-mcp-server/pom.xml b/components/camel-ai/camel-mcp-server/pom.xml index 02ed87c632731..40db5a9dc0913 100644 --- a/components/camel-ai/camel-mcp-server/pom.xml +++ b/components/camel-ai/camel-mcp-server/pom.xml @@ -36,6 +36,7 @@ MCP Server Preview + 3 @@ -71,6 +72,23 @@ camel-test-junit6 test + + + org.apache.camel + camel-openai + test + + + org.apache.camel + camel-platform-http-main + test + + + org.apache.camel + camel-test-infra-ollama + ${project.version} + test + org.awaitility awaitility diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/MainHttpServerMcpConformanceIT.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/MainHttpServerMcpConformanceIT.java new file mode 100644 index 0000000000000..39234e2e3c6e8 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/MainHttpServerMcpConformanceIT.java @@ -0,0 +1,46 @@ +/* + * 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.mcp.server.vertx.integration; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.conformance.McpServerConformanceTestSupport; +import org.apache.camel.component.platform.http.main.MainHttpServer; +import org.apache.camel.test.AvailablePortFinder; + +/** + * Runs the engine conformance kit against the Camel main HTTP server ({@code camel-platform-http-main}) — the actual + * serving path of a Camel Main / JBang application with {@code camel.server.enabled=true}, as opposed to the bare + * {@code VertxPlatformHttpServer} used by the unit-level conformance test. + */ +class MainHttpServerMcpConformanceIT extends McpServerConformanceTestSupport { + + private final int port = AvailablePortFinder.getNextAvailable(); + + @Override + protected void customizeCamelContext(CamelContext camelContext) throws Exception { + MainHttpServer server = new MainHttpServer(); + server.setCamelContext(camelContext); + server.setHost("0.0.0.0"); + server.setPort(port); + camelContext.addService(server); + } + + @Override + protected String mcpServerBaseUrl() { + return "http://localhost:" + port; + } +} diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/McpServerOpenAIAgentIT.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/McpServerOpenAIAgentIT.java new file mode 100644 index 0000000000000..43f04c98fbad7 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/integration/McpServerOpenAIAgentIT.java @@ -0,0 +1,157 @@ +/* + * 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.mcp.server.vertx.integration; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mcp.server.McpServerBridge; +import org.apache.camel.component.mcp.server.McpServerConfiguration; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.component.openai.OpenAIComponent; +import org.apache.camel.component.openai.OpenAIConstants; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServer; +import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServerConfiguration; +import org.apache.camel.test.AvailablePortFinder; +import org.apache.camel.test.infra.ollama.services.OllamaService; +import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.apache.camel.util.ObjectHelper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end agentic integration test: the exact scenario from CAMEL-24308. This Camel application exposes its own + * {@code ai-tool} routes as MCP tools through the camel-mcp-server Vert.x engine, and an LLM (Ollama via the + * camel-openai component) discovers and calls them over MCP streamable HTTP with automatic tool execution. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", disabledReason = "Requires too much network resources") +class McpServerOpenAIAgentIT extends CamelTestSupport { + + @RegisterExtension + static OllamaService OLLAMA = OllamaServiceFactory.createSingletonService(); + + private final int mcpPort = AvailablePortFinder.getNextAvailable(); + + private String apiKey; + private String baseUrl; + private String model; + + @Override + protected void setupResources() throws Exception { + super.setupResources(); + baseUrl = OLLAMA.baseUrlV1(); + model = OLLAMA.modelName(); + apiKey = OLLAMA.apiKey(); + if (apiKey == null || apiKey.isEmpty()) { + apiKey = "dummy"; + } + } + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext camelContext = super.createCamelContext(); + + OpenAIComponent component = new OpenAIComponent(); + if (ObjectHelper.isNotEmpty(apiKey)) { + component.setApiKey(apiKey); + } + if (ObjectHelper.isNotEmpty(model)) { + component.setModel(model); + } + if (ObjectHelper.isNotEmpty(baseUrl)) { + component.setBaseUrl(baseUrl); + } + camelContext.addComponent("openai", component); + + // this application serves its own MCP endpoint on the Vert.x platform HTTP server + VertxPlatformHttpServerConfiguration serverConfiguration = new VertxPlatformHttpServerConfiguration(); + serverConfiguration.setBindPort(mcpPort); + camelContext.addService(new VertxPlatformHttpServer(serverConfiguration)); + + McpServerConfiguration mcpConfiguration = new McpServerConfiguration(); + mcpConfiguration.setTags("agent"); + camelContext.addService(new McpServerBridge(mcpConfiguration)); + + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + // the tools this application exposes over MCP + from("ai-tool:get_weather?tags=agent&description=Get the current weather for a city" + + "¶meter.city=string¶meter.city.description=The city name" + + "¶meter.city.required=true") + .to("mock:weather-called") + .setBody(simple("Sunny in ${header.city}, 21 degrees celsius")); + } + }; + } + + /** + * The agent route is added once the context is fully started: the openai producer initializes its MCP client + * eagerly on route warm-up, which happens before deferred services (the platform HTTP server and the bridge) have + * started when the route is part of the initial context. + */ + private void addAgentRoute() throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:agent") + .toF("openai:chat-completion" + + "?mcpServer.camelTools.transportType=streamableHttp" + + "&mcpServer.camelTools.url=http://localhost:%d/mcp", + mcpPort) + .to("mock:response"); + } + }); + } + + @Test + void testLlmCallsCamelRouteToolOverMcp() throws Exception { + addAgentRoute(); + + MockEndpoint weatherCalled = getMockEndpoint("mock:weather-called"); + weatherCalled.expectedMinimumMessageCount(1); + MockEndpoint response = getMockEndpoint("mock:response"); + response.expectedMessageCount(1); + + Exchange result = template.request("direct:agent", + e -> e.getIn().setBody("Use the get_weather tool to find the current weather in Paris.")); + + MockEndpoint.assertIsSatisfied(context); + + // the ai-tool route was really invoked through MCP + assertThat(weatherCalled.getExchanges().get(0).getIn().getHeader("city", String.class)) + .isEqualToIgnoringCase("Paris"); + + // the agentic loop executed at least one MCP tool call + Integer iterations = result.getMessage().getHeader(OpenAIConstants.TOOL_ITERATIONS, Integer.class); + assertThat(iterations).isNotNull().isGreaterThanOrEqualTo(1); + + // the tool result made it back into the LLM answer + String answer = result.getMessage().getBody(String.class); + assertThat(answer).isNotNull(); + assertThat(answer.toLowerCase()).containsAnyOf("sunny", "21"); + } +} diff --git a/components/camel-ai/camel-mcp-server/test-execution.md b/components/camel-ai/camel-mcp-server/test-execution.md new file mode 100644 index 0000000000000..5aa3d1449cd05 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/test-execution.md @@ -0,0 +1,38 @@ +# camel-mcp-server test execution + +## Unit tests + +```bash +mvn test +``` + +Runs the bridge tests and the engine conformance test (`VertxMcpServerConformanceTest`) +against a standalone Vert.x platform HTTP server, driven by the official MCP Java SDK +client. No Docker required. + +## Integration tests + +```bash +mvn verify +``` + +- `MainHttpServerMcpConformanceIT` — the conformance kit against the Camel main HTTP + server (`camel-platform-http-main`), the real Camel Main / JBang serving path. + No Docker required. +- `McpServerOpenAIAgentIT` — end-to-end agentic loop: the application exposes its own + `ai-tool` routes over MCP and an LLM (camel-openai) discovers and calls them with + automatic tool execution. Requires Docker (Ollama testcontainer, model + `granite4:3b`) or a local Ollama; disabled on CI (`ci.env.name`). + +### LLM backend selection (same options as camel-openai) + +```bash +# reuse a running Ollama instead of a container +mvn verify -Dollama.instance.type=remote -Dollama.endpoint=http://localhost:11434 -Dollama.model=granite4:3b + +# run against the real OpenAI API +mvn verify -Dollama.instance.type=openai -Dopenai.api.key=sk-... + +# enable GPU for the Ollama container +mvn verify -Dollama.container.enable.gpu=enabled +``` From b27df535b3ea922a7c84f729ebdb52b3c4a81966 Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 11:48:37 +0200 Subject: [PATCH 05/11] CAMEL-24310: camel-mcp-server - address review findings - Fix engine-resolution error message to hint the real artifact (camel-mcp-server, not the pre-rename engine module name) - Add @since 4.22 to the public SPI types in camel-mcp-server-api - Drop the session from the provider map on a failed SSE write, like the SDK servlet reference transport (session TTL/keep-alive eviction stays a follow-up) - Bound notifyClients/closeGracefully per-session blocks with a 5s timeout so one stalled session cannot starve the others - Reduce MESSAGE_EVENT_TYPE to private Co-Authored-By: Claude Fable 5 --- .../camel/component/mcp/server/McpServerBridge.java | 4 +++- .../component/mcp/server/McpServerConfiguration.java | 2 ++ .../component/mcp/server/McpServerConstants.java | 5 +++++ .../camel/component/mcp/server/McpServerEngine.java | 2 ++ .../camel/component/mcp/server/McpServerInfo.java | 2 ++ .../camel/component/mcp/server/McpServerTool.java | 2 ++ .../component/mcp/server/McpToolCallHandler.java | 2 ++ .../component/mcp/server/McpToolCallResult.java | 2 ++ .../VertxMcpStreamableServerTransportProvider.java | 12 +++++++++--- 9 files changed, 29 insertions(+), 4 deletions(-) diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java index 1514ca77bc3ed..8ed23e71232ed 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java @@ -59,6 +59,8 @@ *

  • Raw route exception messages never reach the engine: execution failures map to a generic error message and the * cause is logged server-side.
  • * + * + * @since 4.22 */ public class McpServerBridge extends ServiceSupport implements CamelContextAware, StaticService { @@ -169,7 +171,7 @@ private McpServerEngine resolveEngine() { McpServerEngine answer = camelContext.getRegistry().findSingleByType(McpServerEngine.class); if (answer == null) { answer = ResolverHelper.resolveMandatoryService(camelContext, McpServerConstants.MCP_SERVER_ENGINE_FACTORY, - McpServerEngine.class, "camel-mcp-server-engine-vertx"); + McpServerEngine.class, "camel-mcp-server"); } return answer; } diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java index 1c962df45b238..3439bf7db8de3 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java @@ -22,6 +22,8 @@ * Bridge-owned options ({@code tags}, {@code toolTimeout}) are honored on every runtime. Engine-owned options * ({@code path}, {@code serverName}) are consumed only by engines that serve through Camel — native engines (Quarkus, * Spring Boot) use their own runtime configuration instead. + * + * @since 4.22 */ public class McpServerConfiguration { diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java index 50561a885b5df..59ffd9b8b717c 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.java @@ -16,6 +16,11 @@ */ package org.apache.camel.component.mcp.server; +/** + * Constants of the Camel MCP server. + * + * @since 4.22 + */ public final class McpServerConstants { /** diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java index 485ec575fe00d..9592e9150dc77 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java @@ -32,6 +32,8 @@ * {@link #toolAdded(McpServerTool)} for the initial tool set and for every later change (driven by route * start/stop/suspend/resume of {@code ai-tool} routes). Engines with a {@code listChanged} capability should emit * {@code notifications/tools/list_changed} on add/remove. + * + * @since 4.22 */ public interface McpServerEngine extends Service, CamelContextAware { diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java index 1dec37eb849b8..1893a4789c867 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java @@ -25,6 +25,8 @@ * @param serverName the MCP server name advertised to clients (defaults to the CamelContext name) * @param version the MCP server version advertised to clients * @param path the HTTP path where the MCP endpoint should be served + * + * @since 4.22 */ public record McpServerInfo(String serverName, String version, String path) { } diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java index 983aa368f3364..06b6d00986dc3 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java @@ -23,6 +23,8 @@ /** * A tool published by the bridge into an {@link McpServerEngine}. Engines pick whichever input-schema representation * fits their API: the pre-built JSON Schema string or the structured parameter definitions. + * + * @since 4.22 */ public interface McpServerTool { diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java index 1f0058f3c5eca..2d429bec4af22 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java @@ -24,6 +24,8 @@ * The call is blocking and bounded: the bridge applies the configured per-call timeout and maps every outcome * (including route exceptions and timeouts) to a pre-sanitized {@link McpToolCallResult} — it never throws and never * exposes route internals. + * + * @since 4.22 */ @FunctionalInterface public interface McpToolCallHandler { diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java index 80a4267711ef6..526236d5a11b0 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java @@ -22,6 +22,8 @@ * * @param text the tool output, or a safe error message when {@code isError} is true * @param isError whether the invocation failed + * + * @since 4.22 */ public record McpToolCallResult(String text, boolean isError) { } diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java index c5d616ba4414f..21c44ac44f057 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java @@ -16,6 +16,7 @@ */ package org.apache.camel.component.mcp.server.vertx; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -52,10 +53,12 @@ */ public class VertxMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { - public static final String MESSAGE_EVENT_TYPE = "message"; + private static final String MESSAGE_EVENT_TYPE = "message"; private static final Logger LOG = LoggerFactory.getLogger(VertxMcpStreamableServerTransportProvider.class); + private static final Duration NOTIFICATION_TIMEOUT = Duration.ofSeconds(5); + private static final String ACCEPT = "Accept"; private static final String APPLICATION_JSON = "application/json"; private static final String TEXT_EVENT_STREAM = "text/event-stream"; @@ -85,7 +88,8 @@ public Mono notifyClients(String method, Object params) { } return Mono.fromRunnable(() -> sessions.values().forEach(session -> { try { - session.sendNotification(method, params).block(); + // bounded so a single stalled session cannot starve notifications to healthy sessions + session.sendNotification(method, params).block(NOTIFICATION_TIMEOUT); } catch (Exception e) { LOG.debug("Failed to send notification to MCP session {}: {}", session.getId(), e.getMessage()); } @@ -98,7 +102,7 @@ public Mono closeGracefully() { closing = true; sessions.values().forEach(session -> { try { - session.closeGracefully().block(); + session.closeGracefully().block(NOTIFICATION_TIMEOUT); } catch (Exception e) { LOG.debug("Failed to close MCP session {}: {}", session.getId(), e.getMessage()); } @@ -365,6 +369,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId LOG.debug("Failed to write to MCP session {}: {}", sessionId, result.cause() != null ? result.cause().getMessage() : "unknown"); closed = true; + // the client is gone: drop the session like the SDK servlet transport does + sessions.remove(sessionId); } sink.success(); }); From 3284803c943ce0bfd7d6eacb3a8dff10b52efe2b Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 12:26:11 +0200 Subject: [PATCH 06/11] CAMEL-24310: camel-mcp-server - fix docs xrefs and known-dependencies Use same-version xrefs (ROOT: module / relative) instead of the components:: prefix, which resolves to the latest released docs where the ai-tool page does not exist yet (docs validation failure). Co-Authored-By: Claude Fable 5 --- .../camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc | 6 +++--- .../camel-factoryfinder-known-dependencies.properties | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc index 3fb200e38dd24..3994221885dfb 100644 --- a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc +++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc @@ -10,7 +10,7 @@ *Since Camel {since}* The camel-mcp-server module exposes Camel routes registered via the -xref:components::ai-tool-component.adoc[ai-tool] component as tools of a +xref:ROOT:ai-tool-component.adoc[ai-tool] component as tools of a https://modelcontextprotocol.io[Model Context Protocol] (MCP) server, served over MCP streamable HTTP. No route is needed for the server itself: add the dependency, configure which tags to expose, and every matching `ai-tool` route @@ -86,7 +86,7 @@ camelContext.addService(new McpServerBridge(configuration)); The MCP endpoint is then served at `http://:/mcp` on the Camel main HTTP server. Any MCP client can connect over streamable HTTP, for example another Camel integration using the -xref:components::openai-component.adoc[camel-openai] MCP client: +xref:ROOT:openai-component.adoc[camel-openai] MCP client: [source,java] ---- @@ -163,7 +163,7 @@ following rules: router, so platform-http authentication (basic, JWT via `camel.server.authentication*` options) applies to it. The MCP specification's authorization model is OAuth 2.1; see - xref:components:others:oauth.adoc[camel-oauth] for resource-server style + xref:oauth.adoc[camel-oauth] for resource-server style protection. On Quarkus and Spring Boot, authentication is owned by the native runtime security. diff --git a/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties b/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties index dfb61a3383e3d..1ce0ccf39027e 100644 --- a/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties +++ b/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties @@ -35,6 +35,7 @@ META-INF/services/org/apache/camel/kafka-adapter-factory=camel:kafka META-INF/services/org/apache/camel/kafka-resume-strategy=camel:kafka META-INF/services/org/apache/camel/kinesis-resume-strategy=camel:aws2-kinesis META-INF/services/org/apache/camel/lra-saga-service=camel:lra +META-INF/services/org/apache/camel/mcp-server-engine=camel:mcp-server META-INF/services/org/apache/camel/mdc-service=camel:mdc META-INF/services/org/apache/camel/micrometer-observability-tracer=camel:micrometer-observability META-INF/services/org/apache/camel/micrometer-prometheus=camel:micrometer-prometheus From 13e28c28bff75ea81a968364d7fadfbc713c6342 Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 12:26:40 +0200 Subject: [PATCH 07/11] CAMEL-24310: camel-mcp-server - regen camel-bom entries Co-Authored-By: Claude Fable 5 --- bom/camel-bom/pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index 7995a8756b3e9..d0dbcbaf08022 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -1667,6 +1667,16 @@ camel-master 4.22.0-SNAPSHOT + + org.apache.camel + camel-mcp-server + 4.22.0-SNAPSHOT + + + org.apache.camel + camel-mcp-server-api + 4.22.0-SNAPSHOT + org.apache.camel camel-mdc From 7045ffabf4522ab180ce5f4dc1e72e0d433f11b3 Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 12:35:10 +0200 Subject: [PATCH 08/11] CAMEL-24310: camel-mcp-server - address review observations - Notify the engine while holding the bridge lock so publish/unpublish for the same tool cannot interleave and orphan a tool in the engine - Log the exchange id when a timed-out call keeps its pooled exchange - Reject POST requests without Content-Type application/json with 415 (MCP streamable HTTP spec conformance) - Bound session initialization with a 30s timeout Co-Authored-By: Claude Fable 5 --- .../org/apache/camel/catalog/docs.properties | 1 + .../apache/camel/catalog/docs/mcp-server.adoc | 180 ++++++++++++++++++ .../apache/camel/catalog/others.properties | 1 + .../camel/catalog/others/mcp-server.json | 15 ++ .../component/mcp/server/McpServerBridge.java | 17 +- ...xMcpStreamableServerTransportProvider.java | 10 +- .../others/examples/json/mcp-server-api.json | 1 + .../others/examples/json/mcp-server.json | 1 + 8 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/mcp-server.json create mode 120000 docs/components/modules/others/examples/json/mcp-server-api.json create mode 120000 docs/components/modules/others/examples/json/mcp-server.json diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties index e54fa4930afad..2d9e89d17cc3a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties @@ -386,6 +386,7 @@ main mapstruct-component marshal-eip master-component +mcp-server mdc message message-broker diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc new file mode 100644 index 0000000000000..3994221885dfb --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc @@ -0,0 +1,180 @@ += MCP Server Component +:doctitle: MCP Server +:shortname: mcp-server +:artifactid: camel-mcp-server +:description: Expose ai-tool routes as MCP tools over streamable HTTP +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: + +*Since Camel {since}* + +The camel-mcp-server module exposes Camel routes registered via the +xref:ROOT:ai-tool-component.adoc[ai-tool] component as tools of a +https://modelcontextprotocol.io[Model Context Protocol] (MCP) server, served +over MCP streamable HTTP. No route is needed for the server itself: add the +dependency, configure which tags to expose, and every matching `ai-tool` route +becomes an MCP tool that any MCP client (another Camel application, an IDE, a +coding agent) can discover and call. + +Maven users will need to add the following dependency to their `pom.xml`: + +[source,xml] +---- + + org.apache.camel + camel-mcp-server + x.x.x + + +---- + +== Architecture + +The module is split in two artifacts: + +* `camel-mcp-server-api` — the runtime-agnostic _bridge_ and the small + `McpServerEngine` SPI. The bridge owns tool selection (tags), execution via + the shared `AiToolExecutor` (per-call timeout, error sanitization) and reacts + to `AiToolRegistry` changes when routes start and stop. It has no dependency + on the MCP Java SDK. +* `camel-mcp-server` — the serving engine for Camel Main and Camel JBang, + built on the official MCP Java SDK with a Vert.x streamable HTTP transport. + The MCP endpoint is registered on the Camel main HTTP server's router, so it + serves on the main server port (`camel.server.port`) and inherits its + lifecycle, authentication and CORS configuration. + +Engine resolution mirrors the platform-http engine: a bean of type +`McpServerEngine` in the Camel registry wins; otherwise the engine is +discovered on the classpath. Other runtimes plug native engines through the +same SPI: on Quarkus the `camel-quarkus-mcp-server` extension serves through +the Quarkiverse `quarkus-mcp-server` (configured via `quarkus.mcp.server.*`), +and on Spring Boot the starter serves through the Spring AI MCP server +(configured via `spring.ai.mcp.server.*`). Bridge behavior — tag selection, +timeout, sanitization — is identical on every runtime and verified by a shared +conformance test kit. + +== Usage + +Define tools as regular `ai-tool` routes and give them tags: + +[source,yaml] +---- +- route: + from: + uri: "ai-tool:query_db" + parameters: + description: "Query customer database" + tags: "crm" + parameter.customerId: string + parameter.customerId.description: "The customer id" + parameter.customerId.required: "true" + steps: + - to: "jdbc:dataSource" +---- + +Start the MCP server by adding the `McpServerBridge` service to the +CamelContext, selecting the tags to expose: + +[source,java] +---- +McpServerConfiguration configuration = new McpServerConfiguration(); +configuration.setTags("crm,notify"); +camelContext.addService(new McpServerBridge(configuration)); +---- + +The MCP endpoint is then served at `http://:/mcp` on the Camel +main HTTP server. Any MCP client can connect over streamable HTTP, for +example another Camel integration using the +xref:ROOT:openai-component.adoc[camel-openai] MCP client: + +[source,java] +---- +from("direct:agent") + .to("openai:chat-completion" + + "?model={{llm.model}}" + + "&autoToolExecution=true" + + "&mcpServer.myCamelTools.transportType=streamableHttp" + + "&mcpServer.myCamelTools.url=http://localhost:8080/mcp"); +---- + +NOTE: Configuration through `camel.server.mcp-*` properties (no code at all, +like Jolokia or Prometheus) is tracked by CAMEL-24311 and arrives together +with the camel-main wiring. + +== Options + +The `McpServerConfiguration` options: + +[width="100%",cols="2,5,2,1",options="header"] +|=== +| Option | Description | Default | Owner + +| `tags` | Comma-separated list of ai-tool tags to expose as MCP tools. Only + tools registered under one of these tags are published; the untagged + default pool is never exposed. When not set, no tools are published. | | + bridge +| `toolTimeout` | Per-call tool execution timeout in milliseconds. A call + exceeding the timeout returns an error result to the MCP client; the + underlying route keeps running until it completes on its own. | `20000` | + bridge +| `path` | HTTP path where the MCP endpoint is served. | `/mcp` | engine +| `serverName` | MCP server name advertised to clients. | CamelContext name | + engine +|=== + +Bridge-owned options are honored identically on every runtime. Engine-owned +options are consumed by the Vert.x engine only; on runtimes with a native +engine (Quarkus, Spring Boot) the native configuration decides serving +concerns and a startup WARN is logged when an ignored option is set. + +== Protocol + +The Vert.x engine implements the MCP streamable HTTP transport: + +* `POST /mcp` answering `application/json` or `text/event-stream` depending on + the request, +* a long-lived `GET /mcp` SSE channel for server notifications, with + `Last-Event-ID` replay, +* session management via the `Mcp-Session-Id` header and `DELETE /mcp` for + session termination. + +Tools appearing or disappearing (routes starting and stopping) emit +`notifications/tools/list_changed` to connected clients. + +== Security + +External MCP clients are *untrusted senders* under the +xref:manual::security-model.adoc[Camel security model]. The module applies the +following rules: + +* *Explicit opt-in per tool*: only tools whose tags intersect the configured + `tags` are exposed. The untagged default pool is never exposed implicitly. +* *Flat namespace protection*: a tool whose name collides with an already + exposed tool is refused with an ERROR log — never silently replaced. +* *Error sanitization*: route exceptions are mapped to a generic error + message; the cause is logged server-side and never sent to the client. + Argument validation messages (missing or invalid parameters) are returned + as-is. +* *Bounded execution*: every call is subject to the `toolTimeout`. Note that a + timed-out route keeps running server-side until it completes; the timeout + bounds the MCP request, not the route. +* *Authentication*: the MCP endpoint is served through the main HTTP server + router, so platform-http authentication (basic, JWT via + `camel.server.authentication*` options) applies to it. The MCP + specification's authorization model is OAuth 2.1; see + xref:oauth.adoc[camel-oauth] for resource-server style + protection. On Quarkus and Spring Boot, authentication is owned by the + native runtime security. + +== Runtime notes + +* *Camel Main / JBang*: requires the Camel main HTTP server + (`camel.server.enabled=true` with `camel-platform-http-main`, automatic + with Camel JBang) or a `VertxPlatformHttpServer` service. Serving is fully + asynchronous: tool calls are offloaded to the Vert.x worker pool and the + long-lived SSE channel does not occupy a worker thread. +* *Quarkus*: use the `camel-quarkus-mcp-server` extension (serves through + quarkus-mcp-server; the MCP Java SDK is not on the classpath). +* *Spring Boot*: use the `camel-mcp-server-starter` (serves through the + Spring AI MCP server). diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties index a1f054a312683..e1c80fb219286 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties @@ -28,6 +28,7 @@ lra mail-microsoft-oauth main management +mcp-server mdc micrometer-observability micrometer-prometheus diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/mcp-server.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/mcp-server.json new file mode 100644 index 0000000000000..b1eea4c1ff42d --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/mcp-server.json @@ -0,0 +1,15 @@ +{ + "other": { + "kind": "other", + "name": "mcp-server", + "title": "MCP Server", + "description": "Expose ai-tool routes as MCP tools over streamable HTTP", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "ai", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-mcp-server", + "version": "4.22.0-SNAPSHOT" + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java index 8ed23e71232ed..c3021ab31007b 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java @@ -177,7 +177,8 @@ private McpServerEngine resolveEngine() { } private void publish(AiToolSpec spec) { - McpServerTool tool = null; + // the engine is notified while holding the lock so publish/unpublish for the same tool cannot + // interleave between the map update and the engine call (which would orphan the tool in the engine) lock.lock(); try { AiToolSpec existing = published.get(spec.getName()); @@ -191,15 +192,13 @@ private void publish(AiToolSpec spec) { return; } published.put(spec.getName(), spec); - tool = createTool(spec); + engine.toolAdded(createTool(spec)); } finally { lock.unlock(); } - engine.toolAdded(tool); } private void unpublish(AiToolSpec spec) { - boolean removed = false; lock.lock(); try { if (published.get(spec.getName()) != spec) { @@ -210,14 +209,11 @@ private void unpublish(AiToolSpec spec) { .anyMatch(e -> selectedTags.contains(e.getKey()) && e.getValue().contains(spec)); if (!stillSelected) { published.remove(spec.getName()); - removed = true; + engine.toolRemoved(spec.getName()); } } finally { lock.unlock(); } - if (removed) { - engine.toolRemoved(spec.getName()); - } } private McpServerTool createTool(AiToolSpec spec) { @@ -263,8 +259,9 @@ private McpToolCallResult execute(AiToolSpec spec, Map arguments // the route may still be using the exchange; do not return it to the pool release = false; LOG.warn("MCP tool '{}' did not complete within {} ms; returning a timeout error to the client. " - + "The route keeps running until it completes on its own.", - spec.getName(), configuration.getToolTimeout()); + + "The route keeps running until it completes on its own, and exchange {} is not returned " + + "to the pool.", + spec.getName(), configuration.getToolTimeout(), exchange.getExchangeId()); return new McpToolCallResult(GENERIC_TIMEOUT_ERROR, true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java index 21c44ac44f057..12c8e5ed90a24 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java @@ -58,6 +58,7 @@ public class VertxMcpStreamableServerTransportProvider implements McpStreamableS private static final Logger LOG = LoggerFactory.getLogger(VertxMcpStreamableServerTransportProvider.class); private static final Duration NOTIFICATION_TIMEOUT = Duration.ofSeconds(5); + private static final Duration INITIALIZATION_TIMEOUT = Duration.ofSeconds(30); private static final String ACCEPT = "Accept"; private static final String APPLICATION_JSON = "application/json"; @@ -158,6 +159,13 @@ private void handlePost(RoutingContext ctx, Context connection) throws Exception endWithStatus(connection, ctx, 503); return; } + String contentType = ctx.request().getHeader("Content-Type"); + if (contentType == null || !contentType.contains(APPLICATION_JSON)) { + respondError(connection, ctx, 415, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Content-Type application/json required").build()); + return; + } + List badRequestErrors = new ArrayList<>(); String accept = ctx.request().getHeader(ACCEPT); if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) { @@ -226,7 +234,7 @@ private void handleInitialize(RoutingContext ctx, Context connection, McpSchema. }); McpStreamableServerSession.McpStreamableServerSessionInit init = sessionFactory.startSession(initializeRequest); sessions.put(init.session().getId(), init.session()); - McpSchema.InitializeResult initResult = init.initResult().block(); + McpSchema.InitializeResult initResult = init.initResult().block(INITIALIZATION_TIMEOUT); String json = jsonMapper.writeValueAsString(McpSchema.JSONRPCResponse.result(request.id(), initResult)); connection.runOnContext(v -> ctx.response() .setStatusCode(200) diff --git a/docs/components/modules/others/examples/json/mcp-server-api.json b/docs/components/modules/others/examples/json/mcp-server-api.json new file mode 120000 index 0000000000000..d59b543bb689c --- /dev/null +++ b/docs/components/modules/others/examples/json/mcp-server-api.json @@ -0,0 +1 @@ +../../../../../../components/camel-ai/camel-mcp-server-api/src/generated/resources/mcp-server-api.json \ No newline at end of file diff --git a/docs/components/modules/others/examples/json/mcp-server.json b/docs/components/modules/others/examples/json/mcp-server.json new file mode 120000 index 0000000000000..93c9007e2a457 --- /dev/null +++ b/docs/components/modules/others/examples/json/mcp-server.json @@ -0,0 +1 @@ +../../../../../../components/camel-ai/camel-mcp-server/src/generated/resources/mcp-server.json \ No newline at end of file From a5090700538f27d16fb8e5681a7d5fe13f99b3a4 Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 12:07:48 +0200 Subject: [PATCH 09/11] CAMEL-24311: camel.server.mcp-* configuration properties and camel-main autowiring The MCP server now starts from properties alone, like Jolokia or Prometheus - no code and no route for the server itself: camel.server.enabled = true camel.server.mcp-enabled = true camel.server.mcp-tags = crm,notify - HttpServerConfigurationProperties: mcpEnabled, mcpTags, mcpToolTimeout (20s), mcpPath (/mcp), mcpServerName options with regenerated configurer, main metadata and main.adoc. - McpServerFactory SPI in camel-main (mirrors MainHttpServerFactory); BaseMainSupport resolves it via bootstrap FactoryFinder when mcp-enabled=true and adds the bridge after the HTTP server so the engine finds the running router. With the HTTP server disabled the bridge still starts and fails fast with an actionable message. - DefaultMcpServerFactory in camel-mcp-server maps the options onto McpServerConfiguration. - Docs (CAMEL-24314): properties-based quick start replaces the programmatic example as primary; options table now names the camel.server.mcp-* properties. - Catalog harvest for the new modules (others.properties, others/mcp-server.json, docs copies). Co-Authored-By: Claude Fable 5 --- .../org/apache/camel/catalog/docs/main.adoc | 7 +- .../apache/camel/catalog/docs/mcp-server.adoc | 54 +++++---- .../camel-main-configuration-metadata.json | 5 + components/camel-ai/camel-mcp-server/pom.xml | 4 + .../services/org/apache/camel/mcp-server | 2 + .../src/main/docs/mcp-server.adoc | 48 +++++--- .../server/main/DefaultMcpServerFactory.java | 44 +++++++ .../main/McpServerMainPropertiesTest.java | 103 ++++++++++++++++ ...rverConfigurationPropertiesConfigurer.java | 35 ++++++ .../camel-main-configuration-metadata.json | 5 + core/camel-main/src/main/docs/main.adoc | 7 +- .../apache/camel/main/BaseMainSupport.java | 28 ++++- .../HttpServerConfigurationProperties.java | 112 ++++++++++++++++++ .../org/apache/camel/main/MainConstants.java | 1 + .../apache/camel/main/McpServerFactory.java | 37 ++++++ 15 files changed, 450 insertions(+), 42 deletions(-) create mode 100644 components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server create mode 100644 components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java create mode 100644 components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java create mode 100644 core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc index 71ff06d3aee5f..4fc729cdb740c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc @@ -195,7 +195,7 @@ The camel.routecontroller supports 12 options, which are listed below. === Camel Embedded HTTP Server (only for standalone; not Spring Boot or Quarkus) configurations -The camel.server supports 21 options, which are listed below. +The camel.server supports 26 options, which are listed below. [width="100%",cols="2,5,^1,2",options="header"] |=== @@ -215,6 +215,11 @@ The camel.server supports 21 options, which are listed below. | *camel.server.jwtKeystorePath* | Path to the keystore file used for JWT tokens validation. | | String | *camel.server.jwtKeystoreType* | Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.). | | String | *camel.server.maxBodySize* | Maximum HTTP body size the embedded HTTP server can accept. | | Long +| *camel.server.mcpEnabled* | Whether to expose ai-tool routes as MCP tools over streamable HTTP. Requires camel-mcp-server on the classpath. By default, the MCP server is not enabled. | false | boolean +| *camel.server.mcpPath* | HTTP path where the MCP endpoint is served. | /mcp | String +| *camel.server.mcpServerName* | MCP server name advertised to clients. Defaults to the CamelContext name. | | String +| *camel.server.mcpTags* | Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are exposed; the untagged default pool is never exposed. When not set, no tools are exposed. | | String +| *camel.server.mcpToolTimeout* | Per-call MCP tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP client; the underlying route keeps running until it completes on its own. | 20000 | long | *camel.server.path* | Context-path to use for embedded HTTP server | / | String | *camel.server.port* | Port to use for binding embedded HTTP server. Use 0 to dynamic assign a free random port number. | 8080 | int | *camel.server.staticContextPath* | The context-path to use for serving static content. By default, the root path is used. And if there is an index.html page then this is automatically loaded. | / | String diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc index 3994221885dfb..a1c90a206ae60 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc @@ -10,7 +10,7 @@ *Since Camel {since}* The camel-mcp-server module exposes Camel routes registered via the -xref:ROOT:ai-tool-component.adoc[ai-tool] component as tools of a +xref:components::ai-tool-component.adoc[ai-tool] component as tools of a https://modelcontextprotocol.io[Model Context Protocol] (MCP) server, served over MCP streamable HTTP. No route is needed for the server itself: add the dependency, configure which tags to expose, and every matching `ai-tool` route @@ -73,8 +73,19 @@ Define tools as regular `ai-tool` routes and give them tags: - to: "jdbc:dataSource" ---- -Start the MCP server by adding the `McpServerBridge` service to the -CamelContext, selecting the tags to expose: +On Camel Main and Camel JBang no code is needed — like Jolokia or +Prometheus, the server starts from configuration properties alone: + +[source,properties] +---- +camel.server.enabled = true +camel.server.mcp-enabled = true +camel.server.mcp-tags = crm,notify +camel.server.mcp-server-name = my-integration-app +---- + +On other runtimes, or when wiring programmatically, add the +`McpServerBridge` service to the CamelContext instead: [source,java] ---- @@ -86,7 +97,7 @@ camelContext.addService(new McpServerBridge(configuration)); The MCP endpoint is then served at `http://:/mcp` on the Camel main HTTP server. Any MCP client can connect over streamable HTTP, for example another Camel integration using the -xref:ROOT:openai-component.adoc[camel-openai] MCP client: +xref:components::openai-component.adoc[camel-openai] MCP client: [source,java] ---- @@ -98,29 +109,30 @@ from("direct:agent") + "&mcpServer.myCamelTools.url=http://localhost:8080/mcp"); ---- -NOTE: Configuration through `camel.server.mcp-*` properties (no code at all, -like Jolokia or Prometheus) is tracked by CAMEL-24311 and arrives together -with the camel-main wiring. - == Options -The `McpServerConfiguration` options: +The options, configurable as `camel.server.mcp-*` properties on Camel Main / +JBang (see the xref:main.adoc[camel-main] options) or on +`McpServerConfiguration` programmatically: [width="100%",cols="2,5,2,1",options="header"] |=== | Option | Description | Default | Owner -| `tags` | Comma-separated list of ai-tool tags to expose as MCP tools. Only - tools registered under one of these tags are published; the untagged - default pool is never exposed. When not set, no tools are published. | | - bridge -| `toolTimeout` | Per-call tool execution timeout in milliseconds. A call - exceeding the timeout returns an error result to the MCP client; the - underlying route keeps running until it completes on its own. | `20000` | - bridge -| `path` | HTTP path where the MCP endpoint is served. | `/mcp` | engine -| `serverName` | MCP server name advertised to clients. | CamelContext name | - engine +| `camel.server.mcp-enabled` | Whether to expose ai-tool routes as MCP tools + over streamable HTTP. | `false` | bridge +| `camel.server.mcp-tags` | Comma-separated list of ai-tool tags to expose as + MCP tools. Only tools registered under one of these tags are published; the + untagged default pool is never exposed. When not set, no tools are + published. | | bridge +| `camel.server.mcp-tool-timeout` | Per-call tool execution timeout in + milliseconds. A call exceeding the timeout returns an error result to the + MCP client; the underlying route keeps running until it completes on its + own. | `20000` | bridge +| `camel.server.mcp-path` | HTTP path where the MCP endpoint is served. | + `/mcp` | engine +| `camel.server.mcp-server-name` | MCP server name advertised to clients. | + CamelContext name | engine |=== Bridge-owned options are honored identically on every runtime. Engine-owned @@ -163,7 +175,7 @@ following rules: router, so platform-http authentication (basic, JWT via `camel.server.authentication*` options) applies to it. The MCP specification's authorization model is OAuth 2.1; see - xref:oauth.adoc[camel-oauth] for resource-server style + xref:components:others:oauth.adoc[camel-oauth] for resource-server style protection. On Quarkus and Spring Boot, authentication is owned by the native runtime security. diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json index feb0abb216fb7..c2ad62553b32f 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json @@ -435,6 +435,11 @@ { "name": "camel.server.jwtKeystorePath", "required": false, "description": "Path to the keystore file used for JWT tokens validation.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.jwtKeystoreType", "required": false, "description": "Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.).", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.maxBodySize", "required": false, "description": "Maximum HTTP body size the embedded HTTP server can accept.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "java.lang.Long", "secret": false }, + { "name": "camel.server.mcpEnabled", "required": false, "description": "Whether to expose ai-tool routes as MCP tools over streamable HTTP. Requires camel-mcp-server on the classpath. By default, the MCP server is not enabled.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false }, + { "name": "camel.server.mcpPath", "required": false, "description": "HTTP path where the MCP endpoint is served.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "defaultValue": "\/mcp", "secret": false }, + { "name": "camel.server.mcpServerName", "required": false, "description": "MCP server name advertised to clients. Defaults to the CamelContext name.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, + { "name": "camel.server.mcpTags", "required": false, "description": "Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are exposed; the untagged default pool is never exposed. When not set, no tools are exposed.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, + { "name": "camel.server.mcpToolTimeout", "required": false, "description": "Per-call MCP tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP client; the underlying route keeps running until it completes on its own.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "long", "defaultValue": 20000, "secret": false }, { "name": "camel.server.path", "required": false, "description": "Context-path to use for embedded HTTP server", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "defaultValue": "\/", "secret": false }, { "name": "camel.server.port", "required": false, "description": "Port to use for binding embedded HTTP server. Use 0 to dynamic assign a free random port number.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "int", "defaultValue": 8080, "secret": false }, { "name": "camel.server.staticContextPath", "required": false, "description": "The context-path to use for serving static content. By default, the root path is used. And if there is an index.html page then this is automatically loaded.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "defaultValue": "\/", "secret": false }, diff --git a/components/camel-ai/camel-mcp-server/pom.xml b/components/camel-ai/camel-mcp-server/pom.xml index 40db5a9dc0913..2adf333c000de 100644 --- a/components/camel-ai/camel-mcp-server/pom.xml +++ b/components/camel-ai/camel-mcp-server/pom.xml @@ -45,6 +45,10 @@ org.apache.camel camel-mcp-server-api + + org.apache.camel + camel-main + org.apache.camel camel-platform-http-vertx diff --git a/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server b/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server new file mode 100644 index 0000000000000..240ad12e3b063 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/generated/resources/META-INF/services/org/apache/camel/mcp-server @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.mcp.server.main.DefaultMcpServerFactory diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc index 3994221885dfb..611acea305092 100644 --- a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc +++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc @@ -73,8 +73,19 @@ Define tools as regular `ai-tool` routes and give them tags: - to: "jdbc:dataSource" ---- -Start the MCP server by adding the `McpServerBridge` service to the -CamelContext, selecting the tags to expose: +On Camel Main and Camel JBang no code is needed — like Jolokia or +Prometheus, the server starts from configuration properties alone: + +[source,properties] +---- +camel.server.enabled = true +camel.server.mcp-enabled = true +camel.server.mcp-tags = crm,notify +camel.server.mcp-server-name = my-integration-app +---- + +On other runtimes, or when wiring programmatically, add the +`McpServerBridge` service to the CamelContext instead: [source,java] ---- @@ -98,29 +109,30 @@ from("direct:agent") + "&mcpServer.myCamelTools.url=http://localhost:8080/mcp"); ---- -NOTE: Configuration through `camel.server.mcp-*` properties (no code at all, -like Jolokia or Prometheus) is tracked by CAMEL-24311 and arrives together -with the camel-main wiring. - == Options -The `McpServerConfiguration` options: +The options, configurable as `camel.server.mcp-*` properties on Camel Main / +JBang (see the xref:main.adoc[camel-main] options) or on +`McpServerConfiguration` programmatically: [width="100%",cols="2,5,2,1",options="header"] |=== | Option | Description | Default | Owner -| `tags` | Comma-separated list of ai-tool tags to expose as MCP tools. Only - tools registered under one of these tags are published; the untagged - default pool is never exposed. When not set, no tools are published. | | - bridge -| `toolTimeout` | Per-call tool execution timeout in milliseconds. A call - exceeding the timeout returns an error result to the MCP client; the - underlying route keeps running until it completes on its own. | `20000` | - bridge -| `path` | HTTP path where the MCP endpoint is served. | `/mcp` | engine -| `serverName` | MCP server name advertised to clients. | CamelContext name | - engine +| `camel.server.mcp-enabled` | Whether to expose ai-tool routes as MCP tools + over streamable HTTP. | `false` | bridge +| `camel.server.mcp-tags` | Comma-separated list of ai-tool tags to expose as + MCP tools. Only tools registered under one of these tags are published; the + untagged default pool is never exposed. When not set, no tools are + published. | | bridge +| `camel.server.mcp-tool-timeout` | Per-call tool execution timeout in + milliseconds. A call exceeding the timeout returns an error result to the + MCP client; the underlying route keeps running until it completes on its + own. | `20000` | bridge +| `camel.server.mcp-path` | HTTP path where the MCP endpoint is served. | + `/mcp` | engine +| `camel.server.mcp-server-name` | MCP server name advertised to clients. | + CamelContext name | engine |=== Bridge-owned options are honored identically on every runtime. Engine-owned diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java new file mode 100644 index 0000000000000..d524fa46fa20d --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/main/DefaultMcpServerFactory.java @@ -0,0 +1,44 @@ +/* + * 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.mcp.server.main; + +import org.apache.camel.CamelContext; +import org.apache.camel.Service; +import org.apache.camel.component.mcp.server.McpServerBridge; +import org.apache.camel.component.mcp.server.McpServerConfiguration; +import org.apache.camel.main.HttpServerConfigurationProperties; +import org.apache.camel.main.MainConstants; +import org.apache.camel.main.McpServerFactory; +import org.apache.camel.spi.annotations.JdkService; + +/** + * {@link McpServerFactory} creating the {@link McpServerBridge} from the {@code camel.server.mcp-*} options, so the MCP + * server starts from properties alone on Camel Main / JBang. + */ +@JdkService(MainConstants.MCP_SERVER) +public class DefaultMcpServerFactory implements McpServerFactory { + + @Override + public Service newMcpServer(CamelContext camelContext, HttpServerConfigurationProperties configuration) { + McpServerConfiguration mcpConfiguration = new McpServerConfiguration(); + mcpConfiguration.setTags(configuration.getMcpTags()); + mcpConfiguration.setToolTimeout(configuration.getMcpToolTimeout()); + mcpConfiguration.setPath(configuration.getMcpPath()); + mcpConfiguration.setServerName(configuration.getMcpServerName()); + return new McpServerBridge(mcpConfiguration); + } +} diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java new file mode 100644 index 0000000000000..872d3bb7f93ba --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainPropertiesTest.java @@ -0,0 +1,103 @@ +/* + * 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.mcp.server.main; + +import java.time.Duration; +import java.util.Map; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.main.Main; +import org.apache.camel.test.AvailablePortFinder; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies the camel-main autowiring (CAMEL-24311): the MCP server starts from {@code camel.server.mcp-*} properties + * alone — no code and no route for the server itself. + */ +class McpServerMainPropertiesTest { + + @Test + void testMcpServerStartsFromPropertiesAlone() throws Exception { + int port = AvailablePortFinder.getNextAvailable(); + + Main main = new Main(); + main.configure().addRoutesBuilder(new RouteBuilder() { + @Override + public void configure() { + from("ai-tool:say_hello?tags=crm&description=Say hello" + + "¶meter.name=string¶meter.name.required=true") + .setBody(simple("Hello ${header.name}")); + + from("ai-tool:hidden_tool?description=Untagged, not exposed") + .setBody(constant("hidden")); + } + }); + main.addInitialProperty("camel.server.enabled", "true"); + main.addInitialProperty("camel.server.port", String.valueOf(port)); + main.addInitialProperty("camel.server.mcp-enabled", "true"); + main.addInitialProperty("camel.server.mcp-tags", "crm"); + main.addInitialProperty("camel.server.mcp-server-name", "my-integration-app"); + main.start(); + + McpSyncClient client = null; + try { + client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build()) + .requestTimeout(Duration.ofSeconds(10)) + .initializationTimeout(Duration.ofSeconds(10)) + .build(); + + McpSchema.InitializeResult init = client.initialize(); + assertThat(init.serverInfo().name()).isEqualTo("my-integration-app"); + + assertThat(client.listTools().tools()) + .extracting(McpSchema.Tool::name) + .contains("say_hello") + .doesNotContain("hidden_tool"); + + McpSchema.CallToolResult result + = client.callTool(new McpSchema.CallToolRequest("say_hello", Map.of("name", "Camel"))); + assertThat(result.isError()).isNotEqualTo(Boolean.TRUE); + assertThat(result.content().toString()).contains("Hello Camel"); + } finally { + if (client != null) { + client.closeGracefully(); + } + main.stop(); + } + } + + @Test + void testMcpEnabledWithoutHttpServerFailsFast() { + Main main = new Main(); + main.addInitialProperty("camel.server.mcp-enabled", "true"); + main.addInitialProperty("camel.server.mcp-tags", "crm"); + + try { + assertThatThrownBy(main::start) + .hasStackTraceContaining("Vert.x platform HTTP server"); + } finally { + main.stop(); + } + } +} diff --git a/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java b/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java index 7c1d68afe45da..9129b2b45a812 100644 --- a/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java +++ b/core/camel-main/src/generated/java/org/apache/camel/main/HttpServerConfigurationPropertiesConfigurer.java @@ -37,6 +37,11 @@ public class HttpServerConfigurationPropertiesConfigurer extends org.apache.came map.put("JwtKeystorePath", java.lang.String.class); map.put("JwtKeystoreType", java.lang.String.class); map.put("MaxBodySize", java.lang.Long.class); + map.put("McpEnabled", boolean.class); + map.put("McpPath", java.lang.String.class); + map.put("McpServerName", java.lang.String.class); + map.put("McpTags", java.lang.String.class); + map.put("McpToolTimeout", long.class); map.put("Path", java.lang.String.class); map.put("Port", int.class); map.put("StaticContextPath", java.lang.String.class); @@ -78,6 +83,16 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "jwtKeystoreType": target.setJwtKeystoreType(property(camelContext, java.lang.String.class, value)); return true; case "maxbodysize": case "maxBodySize": target.setMaxBodySize(property(camelContext, java.lang.Long.class, value)); return true; + case "mcpenabled": + case "mcpEnabled": target.setMcpEnabled(property(camelContext, boolean.class, value)); return true; + case "mcppath": + case "mcpPath": target.setMcpPath(property(camelContext, java.lang.String.class, value)); return true; + case "mcpservername": + case "mcpServerName": target.setMcpServerName(property(camelContext, java.lang.String.class, value)); return true; + case "mcptags": + case "mcpTags": target.setMcpTags(property(camelContext, java.lang.String.class, value)); return true; + case "mcptooltimeout": + case "mcpToolTimeout": target.setMcpToolTimeout(property(camelContext, long.class, value)); return true; case "path": target.setPath(property(camelContext, java.lang.String.class, value)); return true; case "port": target.setPort(property(camelContext, int.class, value)); return true; case "staticcontextpath": @@ -128,6 +143,16 @@ public Class getOptionType(String name, boolean ignoreCase) { case "jwtKeystoreType": return java.lang.String.class; case "maxbodysize": case "maxBodySize": return java.lang.Long.class; + case "mcpenabled": + case "mcpEnabled": return boolean.class; + case "mcppath": + case "mcpPath": return java.lang.String.class; + case "mcpservername": + case "mcpServerName": return java.lang.String.class; + case "mcptags": + case "mcpTags": return java.lang.String.class; + case "mcptooltimeout": + case "mcpToolTimeout": return long.class; case "path": return java.lang.String.class; case "port": return int.class; case "staticcontextpath": @@ -174,6 +199,16 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "jwtKeystoreType": return target.getJwtKeystoreType(); case "maxbodysize": case "maxBodySize": return target.getMaxBodySize(); + case "mcpenabled": + case "mcpEnabled": return target.isMcpEnabled(); + case "mcppath": + case "mcpPath": return target.getMcpPath(); + case "mcpservername": + case "mcpServerName": return target.getMcpServerName(); + case "mcptags": + case "mcpTags": return target.getMcpTags(); + case "mcptooltimeout": + case "mcpToolTimeout": return target.getMcpToolTimeout(); case "path": return target.getPath(); case "port": return target.getPort(); case "staticcontextpath": diff --git a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json index feb0abb216fb7..c2ad62553b32f 100644 --- a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json +++ b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json @@ -435,6 +435,11 @@ { "name": "camel.server.jwtKeystorePath", "required": false, "description": "Path to the keystore file used for JWT tokens validation.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.jwtKeystoreType", "required": false, "description": "Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.).", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, { "name": "camel.server.maxBodySize", "required": false, "description": "Maximum HTTP body size the embedded HTTP server can accept.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "java.lang.Long", "secret": false }, + { "name": "camel.server.mcpEnabled", "required": false, "description": "Whether to expose ai-tool routes as MCP tools over streamable HTTP. Requires camel-mcp-server on the classpath. By default, the MCP server is not enabled.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false }, + { "name": "camel.server.mcpPath", "required": false, "description": "HTTP path where the MCP endpoint is served.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "defaultValue": "\/mcp", "secret": false }, + { "name": "camel.server.mcpServerName", "required": false, "description": "MCP server name advertised to clients. Defaults to the CamelContext name.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, + { "name": "camel.server.mcpTags", "required": false, "description": "Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are exposed; the untagged default pool is never exposed. When not set, no tools are exposed.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "secret": false }, + { "name": "camel.server.mcpToolTimeout", "required": false, "description": "Per-call MCP tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP client; the underlying route keeps running until it completes on its own.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "long", "defaultValue": 20000, "secret": false }, { "name": "camel.server.path", "required": false, "description": "Context-path to use for embedded HTTP server", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "defaultValue": "\/", "secret": false }, { "name": "camel.server.port", "required": false, "description": "Port to use for binding embedded HTTP server. Use 0 to dynamic assign a free random port number.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "integer", "javaType": "int", "defaultValue": 8080, "secret": false }, { "name": "camel.server.staticContextPath", "required": false, "description": "The context-path to use for serving static content. By default, the root path is used. And if there is an index.html page then this is automatically loaded.", "sourceType": "org.apache.camel.main.HttpServerConfigurationProperties", "type": "string", "javaType": "java.lang.String", "defaultValue": "\/", "secret": false }, diff --git a/core/camel-main/src/main/docs/main.adoc b/core/camel-main/src/main/docs/main.adoc index 71ff06d3aee5f..4fc729cdb740c 100644 --- a/core/camel-main/src/main/docs/main.adoc +++ b/core/camel-main/src/main/docs/main.adoc @@ -195,7 +195,7 @@ The camel.routecontroller supports 12 options, which are listed below. === Camel Embedded HTTP Server (only for standalone; not Spring Boot or Quarkus) configurations -The camel.server supports 21 options, which are listed below. +The camel.server supports 26 options, which are listed below. [width="100%",cols="2,5,^1,2",options="header"] |=== @@ -215,6 +215,11 @@ The camel.server supports 21 options, which are listed below. | *camel.server.jwtKeystorePath* | Path to the keystore file used for JWT tokens validation. | | String | *camel.server.jwtKeystoreType* | Type of the keystore used for JWT tokens validation (jks, pkcs12, etc.). | | String | *camel.server.maxBodySize* | Maximum HTTP body size the embedded HTTP server can accept. | | Long +| *camel.server.mcpEnabled* | Whether to expose ai-tool routes as MCP tools over streamable HTTP. Requires camel-mcp-server on the classpath. By default, the MCP server is not enabled. | false | boolean +| *camel.server.mcpPath* | HTTP path where the MCP endpoint is served. | /mcp | String +| *camel.server.mcpServerName* | MCP server name advertised to clients. Defaults to the CamelContext name. | | String +| *camel.server.mcpTags* | Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are exposed; the untagged default pool is never exposed. When not set, no tools are exposed. | | String +| *camel.server.mcpToolTimeout* | Per-call MCP tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP client; the underlying route keeps running until it completes on its own. | 20000 | long | *camel.server.path* | Context-path to use for embedded HTTP server | / | String | *camel.server.port* | Port to use for binding embedded HTTP server. Use 0 to dynamic assign a free random port number. | 8080 | int | *camel.server.staticContextPath* | The context-path to use for serving static content. By default, the root path is used. And if there is an index.html page then this is automatically loaded. | / | String diff --git a/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java b/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java index d9560638622f2..b9205a0a73b0a 100644 --- a/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java +++ b/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java @@ -2188,7 +2188,9 @@ private void setHttpServerProperties( mainConfigurationProperties.isAutoConfigurationFailFast(), true, autoConfiguredProperties); if (!server.isEnabled()) { - // http server is disabled + // http server is disabled; the mcp server (if enabled) is still set up so it can + // fail-fast with an actionable message about the missing http server + setupMcpServer(camelContext, server); return; } @@ -2207,6 +2209,20 @@ private void setHttpServerProperties( // force eager starting as embedded http server is used for // container platform to check readiness and need to be started eager camelContext.addService(http, true, true); + + // the mcp server serves through the http server, so it is set up (and started) after it + setupMcpServer(camelContext, server); + } + + private void setupMcpServer(CamelContext camelContext, HttpServerConfigurationProperties server) throws Exception { + if (!server.isMcpEnabled()) { + return; + } + // auto-detect camel-mcp-server on classpath + McpServerFactory factory = resolveMcpServerFactory(camelContext); + Service mcp = factory.newMcpServer(camelContext, server); + // force eager starting so the mcp endpoint is served as soon as the http server is up + camelContext.addService(mcp, true, true); } private void setHttpManagementServerProperties( @@ -3179,6 +3195,16 @@ private static MainHttpServerFactory resolveMainHttpServerFactory(CamelContext c return CamelContextAware.trySetCamelContext(answer, camelContext); } + private static McpServerFactory resolveMcpServerFactory(CamelContext camelContext) { + // lookup in service registry first + McpServerFactory answer = camelContext.getRegistry().findSingleByType(McpServerFactory.class); + if (answer == null) { + answer = ResolverHelper.resolveMandatoryBootstrapService(camelContext, MainConstants.MCP_SERVER, + McpServerFactory.class, "camel-mcp-server"); + } + return CamelContextAware.trySetCamelContext(answer, camelContext); + } + private static final class PropertyPlaceholderListener implements PropertiesLookupListener { private final OrderedLocationProperties olp; diff --git a/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java b/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java index c153b60d1d3f5..da2603c3cce36 100644 --- a/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java +++ b/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java @@ -71,6 +71,17 @@ public class HttpServerConfigurationProperties implements BootstrapCloseable { @Metadata(label = "security", defaultValue = "false", security = "insecure:dev") private boolean jwtAllowMissingIssuerAndAudience; + @Metadata + private boolean mcpEnabled; + @Metadata + private String mcpTags; + @Metadata(defaultValue = "20000") + private long mcpToolTimeout = 20000; + @Metadata(defaultValue = "/mcp") + private String mcpPath = "/mcp"; + @Metadata + private String mcpServerName; + public HttpServerConfigurationProperties(MainConfigurationProperties parent) { this.parent = parent; } @@ -326,6 +337,64 @@ public void setJwtAllowMissingIssuerAndAudience(boolean jwtAllowMissingIssuerAnd this.jwtAllowMissingIssuerAndAudience = jwtAllowMissingIssuerAndAudience; } + public boolean isMcpEnabled() { + return mcpEnabled; + } + + /** + * Whether to expose ai-tool routes as MCP tools over streamable HTTP. Requires camel-mcp-server on the classpath. + * By default, the MCP server is not enabled. + */ + public void setMcpEnabled(boolean mcpEnabled) { + this.mcpEnabled = mcpEnabled; + } + + public String getMcpTags() { + return mcpTags; + } + + /** + * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are + * exposed; the untagged default pool is never exposed. When not set, no tools are exposed. + */ + public void setMcpTags(String mcpTags) { + this.mcpTags = mcpTags; + } + + public long getMcpToolTimeout() { + return mcpToolTimeout; + } + + /** + * Per-call MCP tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the + * MCP client; the underlying route keeps running until it completes on its own. + */ + public void setMcpToolTimeout(long mcpToolTimeout) { + this.mcpToolTimeout = mcpToolTimeout; + } + + public String getMcpPath() { + return mcpPath; + } + + /** + * HTTP path where the MCP endpoint is served. + */ + public void setMcpPath(String mcpPath) { + this.mcpPath = mcpPath; + } + + public String getMcpServerName() { + return mcpServerName; + } + + /** + * MCP server name advertised to clients. Defaults to the CamelContext name. + */ + public void setMcpServerName(String mcpServerName) { + this.mcpServerName = mcpServerName; + } + /** * Whether embedded HTTP server is enabled. By default, the server is not enabled. */ @@ -506,4 +575,47 @@ public HttpServerConfigurationProperties withJwtAllowMissingIssuerAndAudience( return this; } + /** + * Whether to expose ai-tool routes as MCP tools over streamable HTTP. Requires camel-mcp-server on the classpath. + * By default, the MCP server is not enabled. + */ + public HttpServerConfigurationProperties withMcpEnabled(boolean mcpEnabled) { + this.mcpEnabled = mcpEnabled; + return this; + } + + /** + * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are + * exposed; the untagged default pool is never exposed. When not set, no tools are exposed. + */ + public HttpServerConfigurationProperties withMcpTags(String mcpTags) { + this.mcpTags = mcpTags; + return this; + } + + /** + * Per-call MCP tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the + * MCP client; the underlying route keeps running until it completes on its own. + */ + public HttpServerConfigurationProperties withMcpToolTimeout(long mcpToolTimeout) { + this.mcpToolTimeout = mcpToolTimeout; + return this; + } + + /** + * HTTP path where the MCP endpoint is served. + */ + public HttpServerConfigurationProperties withMcpPath(String mcpPath) { + this.mcpPath = mcpPath; + return this; + } + + /** + * MCP server name advertised to clients. Defaults to the CamelContext name. + */ + public HttpServerConfigurationProperties withMcpServerName(String mcpServerName) { + this.mcpServerName = mcpServerName; + return this; + } + } diff --git a/core/camel-main/src/main/java/org/apache/camel/main/MainConstants.java b/core/camel-main/src/main/java/org/apache/camel/main/MainConstants.java index 0304faa0acef1..e0130140a264e 100644 --- a/core/camel-main/src/main/java/org/apache/camel/main/MainConstants.java +++ b/core/camel-main/src/main/java/org/apache/camel/main/MainConstants.java @@ -24,6 +24,7 @@ public final class MainConstants { public static final String CLOUD_PROPERTIES_LOCATION = "camel.main.cloud-properties-location"; public static final String PROPERTY_PLACEHOLDER_LOCATION = "camel.main.property-placeholder-location"; public static final String PLATFORM_HTTP_SERVER = "platform-http-server"; + public static final String MCP_SERVER = "mcp-server"; public static final String PROFILE = "camel.main.profile"; private MainConstants() { diff --git a/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java b/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java new file mode 100644 index 0000000000000..818af0ae8ab05 --- /dev/null +++ b/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java @@ -0,0 +1,37 @@ +/* + * 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.main; + +import org.apache.camel.CamelContext; +import org.apache.camel.Service; + +/** + * Factory for creating the MCP server exposing ai-tool routes as MCP tools, for standalone Camel (not Spring Boot or + * Quarkus). Provided by camel-mcp-server. + */ +public interface McpServerFactory { + + /** + * Creates the MCP server bridge configured from the {@code camel.server.mcp-*} options. + * + * @param camelContext the camel context + * @param configuration server configuration carrying the mcp options + * @return the bridge as a {@link Service} to be managed by {@link org.apache.camel.CamelContext}. + */ + Service newMcpServer(CamelContext camelContext, HttpServerConfigurationProperties configuration); + +} From a43720db20c5e23d1fcda275086e5daecbbaf82a Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 12:27:24 +0200 Subject: [PATCH 10/11] CAMEL-24311: camel-mcp-server - regen catalog docs copy and known-dependencies Co-Authored-By: Claude Fable 5 --- .../resources/org/apache/camel/catalog/docs/mcp-server.adoc | 6 +++--- .../camel-factoryfinder-known-dependencies.properties | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc index a1c90a206ae60..611acea305092 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc @@ -10,7 +10,7 @@ *Since Camel {since}* The camel-mcp-server module exposes Camel routes registered via the -xref:components::ai-tool-component.adoc[ai-tool] component as tools of a +xref:ROOT:ai-tool-component.adoc[ai-tool] component as tools of a https://modelcontextprotocol.io[Model Context Protocol] (MCP) server, served over MCP streamable HTTP. No route is needed for the server itself: add the dependency, configure which tags to expose, and every matching `ai-tool` route @@ -97,7 +97,7 @@ camelContext.addService(new McpServerBridge(configuration)); The MCP endpoint is then served at `http://:/mcp` on the Camel main HTTP server. Any MCP client can connect over streamable HTTP, for example another Camel integration using the -xref:components::openai-component.adoc[camel-openai] MCP client: +xref:ROOT:openai-component.adoc[camel-openai] MCP client: [source,java] ---- @@ -175,7 +175,7 @@ following rules: router, so platform-http authentication (basic, JWT via `camel.server.authentication*` options) applies to it. The MCP specification's authorization model is OAuth 2.1; see - xref:components:others:oauth.adoc[camel-oauth] for resource-server style + xref:oauth.adoc[camel-oauth] for resource-server style protection. On Quarkus and Spring Boot, authentication is owned by the native runtime security. diff --git a/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties b/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties index 1ce0ccf39027e..e97aa071a6542 100644 --- a/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties +++ b/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties @@ -36,6 +36,7 @@ META-INF/services/org/apache/camel/kafka-resume-strategy=camel:kafka META-INF/services/org/apache/camel/kinesis-resume-strategy=camel:aws2-kinesis META-INF/services/org/apache/camel/lra-saga-service=camel:lra META-INF/services/org/apache/camel/mcp-server-engine=camel:mcp-server +META-INF/services/org/apache/camel/mcp-server=camel:mcp-server META-INF/services/org/apache/camel/mdc-service=camel:mdc META-INF/services/org/apache/camel/micrometer-observability-tracer=camel:micrometer-observability META-INF/services/org/apache/camel/micrometer-prometheus=camel:micrometer-prometheus From f1a07124b6f2363f0c8cdd56d6cd621b7e9a299f Mon Sep 17 00:00:00 2001 From: croway Date: Mon, 3 Aug 2026 12:35:49 +0200 Subject: [PATCH 11/11] CAMEL-24311: add @since to McpServerFactory Co-Authored-By: Claude Fable 5 --- .../src/main/java/org/apache/camel/main/McpServerFactory.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java b/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java index 818af0ae8ab05..0216483ac1578 100644 --- a/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java +++ b/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.java @@ -22,6 +22,8 @@ /** * Factory for creating the MCP server exposing ai-tool routes as MCP tools, for standalone Camel (not Spring Boot or * Quarkus). Provided by camel-mcp-server. + * + * @since 4.22 */ public interface McpServerFactory {