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-master4.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.camelcamel-mdc
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.camelcamel-mdc
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/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
new file mode 100644
index 0000000000000..611acea305092
--- /dev/null
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc
@@ -0,0 +1,192 @@
+= 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"
+----
+
+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]
+----
+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");
+----
+
+== 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
+
+| `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
+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/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/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-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));
+ }
+ }
+}
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..c3021ab31007b
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java
@@ -0,0 +1,308 @@
+/*
+ * 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:
+ *
+ *
Only tools whose tags intersect the configured {@code tags} are exposed. The untagged default pool is never
+ * exposed — external MCP clients are untrusted senders and crossing that trust boundary is an explicit per-tool
+ * opt-in.
+ *
MCP has a flat tool namespace: a tool whose name collides with an already published tool is refused with an ERROR
+ * log, never silently replaced.
+ *
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 {
+
+ 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");
+ }
+ return answer;
+ }
+
+ private void publish(AiToolSpec spec) {
+ // 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());
+ 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);
+ engine.toolAdded(createTool(spec));
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void unpublish(AiToolSpec spec) {
+ 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());
+ engine.toolRemoved(spec.getName());
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ 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, 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();
+ 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..3439bf7db8de3
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ *
+ * @since 4.22
+ */
+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..59ffd9b8b717c
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConstants.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;
+
+/**
+ * Constants of the Camel MCP server.
+ *
+ * @since 4.22
+ */
+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..9592e9150dc77
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerEngine.java
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ *
+ * @since 4.22
+ */
+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..1893a4789c867
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java
@@ -0,0 +1,32 @@
+/*
+ * 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
+ *
+ * @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
new file mode 100644
index 0000000000000..06b6d00986dc3
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java
@@ -0,0 +1,55 @@
+/*
+ * 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.
+ *
+ * @since 4.22
+ */
+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..2d429bec4af22
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ *
+ * @since 4.22
+ */
+@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..526236d5a11b0
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java
@@ -0,0 +1,29 @@
+/*
+ * 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
+ *
+ * @since 4.22
+ */
+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..2adf333c000de
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server/pom.xml
@@ -0,0 +1,110 @@
+
+
+
+ 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
+ 3
+
+
+
+
+
+ org.apache.camel
+ camel-mcp-server-api
+
+
+ org.apache.camel
+ camel-main
+
+
+ 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.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
+ ${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 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/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..611acea305092
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc
@@ -0,0 +1,192 @@
+= 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"
+----
+
+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]
+----
+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");
+----
+
+== 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
+
+| `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
+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/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/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..2dd0e8e0d20cd
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java
@@ -0,0 +1,170 @@
+/*
+ * 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..12c8e5ed90a24
--- /dev/null
+++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
@@ -0,0 +1,416 @@
+/*
+ * 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.time.Duration;
+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 {
+
+ 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 Duration INITIALIZATION_TIMEOUT = Duration.ofSeconds(30);
+
+ 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 {
+ // 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());
+ }
+ }));
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.fromRunnable(() -> {
+ closing = true;
+ sessions.values().forEach(session -> {
+ try {
+ session.closeGracefully().block(NOTIFICATION_TIMEOUT);
+ } 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;
+ }
+ 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)) {
+ 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(INITIALIZATION_TIMEOUT);
+ 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;
+ // the client is gone: drop the session like the SDK servlet transport does
+ sessions.remove(sessionId);
+ }
+ 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/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/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/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
+```
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-tokenizercamel-langchain4j-toolscamel-langchain4j-web-search
+ camel-mcp-server
+ camel-mcp-server-apicamel-milvuscamel-neo4jcamel-openai
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..0216483ac1578
--- /dev/null
+++ b/core/camel-main/src/main/java/org/apache/camel/main/McpServerFactory.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.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.
+ *
+ * @since 4.22
+ */
+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);
+
+}
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
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/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..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
@@ -35,6 +35,8 @@ 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/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
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}