This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 827bcd581d40 CAMEL-26359: camel-mcp-server - check the Origin header
on /mcp (Camel Main engine) (#29284)
827bcd581d40 is described below
commit 827bcd581d40d753df97606fa13bd6f206d3fd09
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 09:50:53 2026 +0100
CAMEL-26359: camel-mcp-server - check the Origin header on /mcp (Camel Main
engine) (#29284)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../org/apache/camel/catalog/docs/main.adoc | 3 +-
.../org/apache/camel/catalog/docs/mcp-server.adoc | 60 ++++
.../main/camel-main-configuration-metadata.json | 1 +
.../component/mcp/server/McpServerBridge.java | 8 +-
.../mcp/server/McpServerConfiguration.java | 20 ++
.../camel/component/mcp/server/McpServerInfo.java | 21 +-
.../component/mcp/server/McpServerOrigins.java | 151 +++++++++++
.../component/mcp/server/McpServerBridgeTest.java | 44 +++
.../component/mcp/server/McpServerOriginsTest.java | 95 +++++++
.../camel-mcp-server/src/main/docs/mcp-server.adoc | 60 ++++
.../mcp/server/main/DefaultMcpServerFactory.java | 1 +
.../mcp/server/vertx/VertxMcpServerEngine.java | 6 +-
.../VertxMcpStreamableServerTransportProvider.java | 85 ++++++
.../server/main/McpServerMainPropertiesTest.java | 62 +++++
.../mcp/server/vertx/VertxMcpOriginTest.java | 301 +++++++++++++++++++++
...ttpServerConfigurationPropertiesConfigurer.java | 7 +
.../camel-main-configuration-metadata.json | 1 +
core/camel-main/src/main/docs/main.adoc | 3 +-
.../main/HttpServerConfigurationProperties.java | 28 ++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 14 +
20 files changed, 964 insertions(+), 7 deletions(-)
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 964f456c127b..e7b523095997 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
@@ -197,7 +197,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 35 options, which are listed below.
+The camel.server supports 36 options, which are listed below.
[width="100%",cols="2,5,^1,2",options="header"]
|===
@@ -217,6 +217,7 @@ The camel.server supports 35 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.mcpAllowedOrigins` | Comma-separated list of origins allowed
to call the MCP endpoint on the Vert.x streamable transport. Each entry is an
exact origin (scheme, host and port), or ends with a colon and an asterisk to
match any port. Scheme and host are compared ignoring case, and a default port
(80 for http, 443 for https) may be left out. A wildcard for all origins is not
supported: an entry that is not of the form scheme://host or scheme://host:port
fails the startup. A [...]
| `camel.server.mcpEnabled` | Whether to expose ai-tool routes as MCP tools
and ai-resource routes as MCP resources. Requires camel-mcp-server on the
classpath. Use setMcpTransport(String) to choose streamable HTTP (default) or
stdio for IDE subprocess integration. By default, the MCP server is not
enabled. | false | boolean
| `camel.server.mcpInstructions` | Top-level MCP instructions returned to
clients on initialize. | | String
| `camel.server.mcpPath` | HTTP path where the MCP endpoint is served. | /mcp
| String
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc
index e7f46982a45f..c2a36f3a4898 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc
@@ -289,6 +289,13 @@ JBang (see the xref:main.adoc[camel-main] options) or on
sessions on the Vert.x streamable transport. Sessions with no activity for
longer than this interval are evicted. `0` disables idle eviction. |
`300000` | engine
+| `camel.server.mcp-allowed-origins` | Comma-separated list of origins allowed
+ to call the MCP endpoint, such as `+https://app.example.com+` or
+ `+http://localhost:*+` (`+:*+` matches any port). A request without an
`Origin`
+ header is always served; a request with an `Origin` header that is not in the
+ list gets HTTP 403. Scheme and host are compared ignoring case, and a default
+ port may be left out. `+*+` is not supported. When not set, only loopback
+ origins are allowed. See <<_origins_and_binding>>. | loopback origins |
engine
|===
Bridge-owned options are honored identically on every runtime. Engine-owned
@@ -337,6 +344,8 @@ The Vert.x engine implements the MCP streamable HTTP
transport:
removes sessions with no traffic. Configure with
`camel.server.mcp-session-keep-alive-interval` and
`camel.server.mcp-session-idle-ttl` (set either to `0` to disable).
+* an `Origin` header check on every method (`POST`, `GET` and `DELETE`), see
+ <<_origins_and_binding>>.
Tools and resources appearing or disappearing (routes starting and stopping)
emit `notifications/tools/list_changed` and
@@ -386,6 +395,57 @@ following rules:
Camel subprocess (local IDE or agent). Route authors remain fully trusted;
only explicitly tagged tools are exposed.
+=== Origins and binding
+
+The MCP streamable HTTP transport requires a server to validate the `Origin`
+header. The Vert.x engine on Camel Main and Camel JBang does so on every
request
+to the MCP endpoint (`POST`, the SSE `GET` and `DELETE`):
+
+* A request *without* an `Origin` header is served. MCP clients run as a
+ process (an IDE, an agent, another Camel integration) and send none.
+* A request *with* an `Origin` header is served only when the origin is
+ allowed. Any other origin gets HTTP 403 with a JSON-RPC error that has no
+ `id`, and no tool or resource route is called.
+* By default only loopback origins are allowed: `localhost`, `127.0.0.1` and
+ `[::1]`, over `http` or `https`, on any port. Browser-based tools on the
+ same machine, such as the MCP Inspector, keep working.
+* `camel.server.mcp-allowed-origins` replaces the default with your own list.
+ Each entry is an origin (scheme, host and port), or ends with `+:*+` to match
+ any port. Add the loopback entries again if you still need them:
+
+[source,properties]
+----
+camel.server.mcp-allowed-origins = https://app.example.com, http://localhost:*
+----
+
+Entries are compared in the form a browser sends the `Origin` header: the
+scheme and host ignoring case, and without the default port, so
+`+https://App.Example.com:443/+` and `+https://app.example.com+` are the same
+origin. An entry that can never match an `Origin` header fails the startup with
+a message saying why: `+*+` (the check cannot be turned off; clients that are
+not browsers send no `Origin` header and are always served), an entry with a
+path such as `+https://app.example.com/mcp+`, an entry without a scheme such as
+`app.example.com`, or a wildcard in the host such as `+https://*.example.com+`.
+
+A refused request is logged at `DEBUG` level by the
+`org.apache.camel.component.mcp.server.vertx.VertxMcpStreamableServerTransportProvider`
+logger, with the `Origin` it carried.
+
+The check is independent of the platform-http CORS configuration: CORS only
+adds response headers for the browser, while the origin check refuses the
+request on the server.
+
+The main HTTP server listens on all network interfaces by default
+(`camel.server.host = 0.0.0.0`). When the MCP server is only meant for clients
+on the same machine, bind it to the loopback interface:
+
+[source,properties]
+----
+camel.server.host = 127.0.0.1
+----
+
+Stdio mode has no HTTP transport and is not affected.
+
== Runtime notes
* *Camel Main / JBang (HTTP)*: requires the Camel main HTTP 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 fbcff6d0ed74..742db1c4abff 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
@@ -455,6 +455,7 @@
{ "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.mcpAllowedOrigins", "required": false,
"description": "Comma-separated list of origins allowed to call the MCP
endpoint on the Vert.x streamable transport. Each entry is an exact origin
(scheme, host and port), or ends with a colon and an asterisk to match any
port. Scheme and host are compared ignoring case, and a default port (80 for
http, 443 for https) may be left out. A wildcard for all origins is not
supported: an entry that is not of the form scheme:\/\ [...]
{ "name": "camel.server.mcpEnabled", "required": false, "description":
"Whether to expose ai-tool routes as MCP tools and ai-resource routes as MCP
resources. Requires camel-mcp-server on the classpath. Use
setMcpTransport(String) to choose streamable HTTP (default) or stdio for IDE
subprocess integration. By default, the MCP server is not enabled.",
"sourceType": "org.apache.camel.main.HttpServerConfigurationProperties",
"type": "boolean", "javaType": "boolean", "defaultValue": fals [...]
{ "name": "camel.server.mcpInstructions", "required": false,
"description": "Top-level MCP instructions returned to clients on initialize.",
"sourceType": "org.apache.camel.main.HttpServerConfigurationProperties",
"type": "string", "javaType": "java.lang.String", "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 },
diff --git
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java
index 7dfab72b0125..46477af08c69 100644
---
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java
+++
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java
@@ -137,7 +137,8 @@ public class McpServerBridge extends ServiceSupport
implements CamelContextAware
configuration.getSessionKeepAliveInterval(),
configuration.getSessionIdleTtl(),
configuration.getServerTitle(),
configuration.getServerDescription(),
configuration.getServerWebsiteUrl(),
configuration.getInstructions(),
- configuration.getServerIcons()));
+ configuration.getServerIcons(),
+
McpServerOrigins.parseAllowedOrigins(configuration.getAllowedOrigins())));
if (!engine.consumesServingConfiguration()) {
if
(!McpServerConstants.DEFAULT_PATH.equals(configuration.getPath())) {
@@ -145,6 +146,11 @@ public class McpServerBridge extends ServiceSupport
implements CamelContextAware
+ "decides the endpoint path",
engine.getClass().getSimpleName());
}
+ if (isSet(configuration.getAllowedOrigins())) {
+ LOG.warn("The MCP allowed origins option is ignored by engine
{}: the runtime's native MCP server "
+ + "configuration decides which origins are allowed",
+ engine.getClass().getSimpleName());
+ }
if (hasServerMetadataConfiguration()) {
LOG.warn("The MCP server metadata options may be ignored by
engine {}: the runtime's native MCP "
+ "server configuration decides the server identity
and initialize metadata",
diff --git
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java
index 0d961343a8de..5cdf2b43cb17 100644
---
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java
+++
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java
@@ -42,6 +42,7 @@ public class McpServerConfiguration {
private List<McpServerIcon> serverIcons;
private long sessionKeepAliveInterval =
McpServerConstants.DEFAULT_SESSION_KEEP_ALIVE_INTERVAL;
private long sessionIdleTtl = McpServerConstants.DEFAULT_SESSION_IDLE_TTL;
+ private String allowedOrigins;
/**
* Comma-separated list of ai-tool tag patterns to expose as MCP tools.
Matching is case-insensitive and supports
@@ -185,4 +186,23 @@ public class McpServerConfiguration {
public void setSessionIdleTtl(long sessionIdleTtl) {
this.sessionIdleTtl = sessionIdleTtl;
}
+
+ /**
+ * Comma-separated list of origins allowed to call the MCP endpoint, such
as {@code https://app.example.com} or
+ * {@code http://localhost:*} (any port). A request without an {@code
Origin} header is always served; a request
+ * with an {@code Origin} header that is not allowed is rejected with HTTP
403. Scheme and host are compared
+ * ignoring case, and a default port ({@code :80} for http, {@code :443}
for https) may be left out. An entry that
+ * is not of the form {@code scheme://host[:port]}, such as {@code *},
fails the startup (see
+ * {@link McpServerOrigins}). When not set, only loopback origins are
allowed. Engine-owned: ignored by native
+ * engines.
+ *
+ * @since 4.24
+ */
+ public String getAllowedOrigins() {
+ return allowedOrigins;
+ }
+
+ public void setAllowedOrigins(String allowedOrigins) {
+ this.allowedOrigins = allowedOrigins;
+ }
}
diff --git
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java
index 5169a3426e97..cfd0549e1df9 100644
---
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java
+++
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerInfo.java
@@ -36,6 +36,9 @@ import java.util.List;
* @param websiteUrl optional documentation or home page URL
advertised in {@code serverInfo}
* @param instructions optional top-level instructions returned
to MCP clients on initialize
* @param icons optional icons advertised in {@code
serverInfo}
+ * @param allowedOrigins origins allowed to call the MCP endpoint
(exact, or {@code scheme://host:*} for any
+ * port); {@code null} allows loopback
origins only. A request without an
+ * {@code Origin} header is not affected.
Since 4.24.
*
* @since 4.22
*/
@@ -49,19 +52,28 @@ public record McpServerInfo(
String description,
String websiteUrl,
String instructions,
- List<McpServerIcon> icons) {
+ List<McpServerIcon> icons,
+ List<String> allowedOrigins) {
public McpServerInfo(String serverName, String version, String path) {
this(serverName, version, path,
McpServerConstants.DEFAULT_SESSION_KEEP_ALIVE_INTERVAL,
McpServerConstants.DEFAULT_SESSION_IDLE_TTL,
- null, null, null, null, null);
+ null, null, null, null, null, null);
}
public McpServerInfo(String serverName, String version, String path,
long sessionKeepAliveIntervalMs, long
sessionIdleTtlMs) {
this(serverName, version, path, sessionKeepAliveIntervalMs,
sessionIdleTtlMs,
- null, null, null, null, null);
+ null, null, null, null, null, null);
+ }
+
+ public McpServerInfo(String serverName, String version, String path,
+ long sessionKeepAliveIntervalMs, long
sessionIdleTtlMs,
+ String title, String description, String websiteUrl,
String instructions,
+ List<McpServerIcon> icons) {
+ this(serverName, version, path, sessionKeepAliveIntervalMs,
sessionIdleTtlMs,
+ title, description, websiteUrl, instructions, icons, null);
}
public McpServerInfo {
@@ -74,5 +86,8 @@ public record McpServerInfo(
if (icons != null) {
icons = List.copyOf(icons);
}
+ if (allowedOrigins != null) {
+ allowedOrigins = List.copyOf(allowedOrigins);
+ }
}
}
diff --git
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerOrigins.java
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerOrigins.java
new file mode 100644
index 000000000000..6f6c259faf3d
--- /dev/null
+++
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerOrigins.java
@@ -0,0 +1,151 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+/**
+ * Parses and normalizes the origins allowed to call the MCP endpoint, and the
{@code Origin} header of a request, so
+ * that both are compared in the form a browser sends: scheme and host in
lower case, and no default port ({@code :80}
+ * for {@code http}, {@code :443} for {@code https}).
+ * <p>
+ * An allowed origin is {@code scheme://host} or {@code scheme://host:port},
where the port may be {@code *} to match
+ * any port. Anything else, such as {@code *}, an entry with a path or an
entry without a scheme, can never match an
+ * {@code Origin} header and is refused with an {@link
IllegalArgumentException}, so a mistake shows at startup instead
+ * of as HTTP 403 for every browser request.
+ *
+ * @since 4.24
+ */
+public final class McpServerOrigins {
+
+ private static final String SCHEME_SEPARATOR = "://";
+ private static final String ANY_PORT = "*";
+ private static final Pattern SCHEME =
Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*");
+ private static final Pattern HOST =
Pattern.compile("[a-zA-Z0-9._-]+|\\[[0-9a-fA-F:.]+]");
+ private static final Pattern PORT = Pattern.compile("\\d{1,5}");
+
+ private McpServerOrigins() {
+ }
+
+ /**
+ * Splits a comma-separated list of allowed origins, skipping empty
entries, and normalizes each entry with
+ * {@link #normalizeAllowedOrigin(String)}.
+ *
+ * @param allowedOrigins the comma-separated list
+ * @return the normalized origins, or {@code
null} when not set, so the engine applies its
+ * default (loopback origins only)
+ * @throws IllegalArgumentException when an entry is not a valid origin
+ */
+ public static List<String> parseAllowedOrigins(String allowedOrigins) {
+ if (allowedOrigins == null || allowedOrigins.isBlank()) {
+ return null;
+ }
+ List<String> answer = new ArrayList<>();
+ for (String entry : allowedOrigins.split(",")) {
+ if (!entry.isBlank()) {
+ answer.add(normalizeAllowedOrigin(entry));
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Normalizes an allowed origin: trims it, drops a trailing {@code /},
lower-cases the scheme and host, and drops a
+ * default port.
+ *
+ * @param allowedOrigin an origin such as {@code
https://App.Example.com:443} or
+ * {@code http://localhost:*}
+ * @return the normalized origin, such as {@code
https://app.example.com}
+ * @throws IllegalArgumentException when the entry is {@code *} or not of
the form {@code scheme://host[:port]}
+ */
+ public static String normalizeAllowedOrigin(String allowedOrigin) {
+ String value = allowedOrigin == null ? "" : allowedOrigin.trim();
+ while (value.endsWith("/")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ if (ANY_PORT.equals(value)) {
+ throw new IllegalArgumentException(
+ "MCP allowed origin * is not supported: the Origin check
cannot be turned off. List each origin"
+ + " instead, such as
https://app.example.com or http://localhost:* (any port)."
+ + " A request without an Origin
header, as sent by MCP clients that are not"
+ + " browsers, is always
served.");
+ }
+ String answer = normalize(value, true);
+ if (answer == null) {
+ throw new IllegalArgumentException(
+ "Invalid MCP allowed origin '" + allowedOrigin + "':
expected scheme://host or scheme://host:port,"
+ + " where the port may be * for
any port, such as https://app.example.com or"
+ + " http://localhost:*");
+ }
+ return answer;
+ }
+
+ /**
+ * Normalizes the value of an {@code Origin} header the same way as the
allowed origins, so that both compare as
+ * strings.
+ *
+ * @param origin the header value
+ * @return the normalized origin, or the value as is when it is not
of the form {@code scheme://host[:port]}
+ * (such as {@code null}), so it matches no allowed origin
+ */
+ public static String normalizeOrigin(String origin) {
+ if (origin == null) {
+ return null;
+ }
+ String answer = normalize(origin.trim(), false);
+ return answer != null ? answer : origin;
+ }
+
+ private static String normalize(String value, boolean allowAnyPort) {
+ int separator = value.indexOf(SCHEME_SEPARATOR);
+ if (separator <= 0) {
+ return null;
+ }
+ String scheme = value.substring(0, separator);
+ String authority = value.substring(separator +
SCHEME_SEPARATOR.length());
+ String host = authority;
+ String port = null;
+ int colon = authority.lastIndexOf(':');
+ if (colon >= 0 && colon > authority.lastIndexOf(']')) {
+ host = authority.substring(0, colon);
+ port = authority.substring(colon + 1);
+ }
+ if (!SCHEME.matcher(scheme).matches() ||
!HOST.matcher(host).matches()) {
+ return null;
+ }
+ scheme = scheme.toLowerCase(Locale.ROOT);
+ host = host.toLowerCase(Locale.ROOT);
+ if (port != null) {
+ if (ANY_PORT.equals(port)) {
+ if (!allowAnyPort) {
+ return null;
+ }
+ } else if (PORT.matcher(port).matches() && Integer.parseInt(port)
<= 65535) {
+ port = String.valueOf(Integer.parseInt(port));
+ if ("http".equals(scheme) && "80".equals(port) ||
"https".equals(scheme) && "443".equals(port)) {
+ port = null;
+ }
+ } else {
+ return null;
+ }
+ }
+ return port != null ? scheme + SCHEME_SEPARATOR + host + ":" + port :
scheme + SCHEME_SEPARATOR + host;
+ }
+}
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
index d11d3ef2a177..cb4b35ec5b9c 100644
---
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
@@ -26,6 +26,7 @@ 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.assertThatThrownBy;
class McpServerBridgeTest extends CamelTestSupport {
@@ -219,6 +220,49 @@ class McpServerBridgeTest extends CamelTestSupport {
}
}
+ @Test
+ void testAllowedOriginsPassedToEngine() throws Exception {
+ CamelContext originContext = new DefaultCamelContext();
+ RecordingMcpServerEngine originEngine = new RecordingMcpServerEngine();
+ originContext.getRegistry().bind("mcpServerEngine", originEngine);
+ McpServerConfiguration configuration = new McpServerConfiguration();
+ configuration.setTags("crm");
+ configuration.setAllowedOrigins(" https://App.Example.com:443/ ,,
http://localhost:* ");
+ McpServerBridge originBridge = new McpServerBridge(configuration);
+ originContext.addService(originBridge);
+ originContext.start();
+ originBridge.start();
+
+ try {
+ assertThat(originEngine.info().allowedOrigins())
+ .containsExactly("https://app.example.com",
"http://localhost:*");
+ } finally {
+ originBridge.stop();
+ originContext.stop();
+ }
+ }
+
+ @Test
+ void testAllowedOriginsNotSetLeavesTheEngineDefault() {
+ assertThat(engine.info().allowedOrigins()).isNull();
+ }
+
+ @Test
+ void testAllowedOriginThatCanNeverMatchFailsTheStart() throws Exception {
+ try (DefaultCamelContext originContext = new DefaultCamelContext()) {
+ originContext.getRegistry().bind("mcpServerEngine", new
RecordingMcpServerEngine());
+ McpServerConfiguration configuration = new
McpServerConfiguration();
+ configuration.setTags("crm");
+ configuration.setAllowedOrigins("*");
+ McpServerBridge originBridge = new McpServerBridge(configuration);
+
+ // adding the bridge to a context that is already built
initializes it, and the origins are checked then
+ assertThatThrownBy(() -> originContext.addService(originBridge))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("MCP allowed origin * is not
supported");
+ }
+ }
+
@Test
void testNameCollisionIsRefused() throws Exception {
McpServerTool published = engine.tools().get("query_db");
diff --git
a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerOriginsTest.java
b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerOriginsTest.java
new file mode 100644
index 000000000000..83e7be1da9df
--- /dev/null
+++
b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerOriginsTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class McpServerOriginsTest {
+
+ @Test
+ void notSetLeavesTheEngineDefault() {
+ assertThat(McpServerOrigins.parseAllowedOrigins(null)).isNull();
+ assertThat(McpServerOrigins.parseAllowedOrigins(" ")).isNull();
+ // a value with no origin in it allows none
+ assertThat(McpServerOrigins.parseAllowedOrigins(",")).isEmpty();
+ }
+
+ @Test
+ void entriesAreTrimmedAndNormalized() {
+ assertThat(McpServerOrigins.parseAllowedOrigins(
+ " https://App.Example.com:443/ ,, HTTP://localhost:* ,
http://Tools.Example.com:8080 "))
+ .containsExactly("https://app.example.com",
"http://localhost:*", "http://tools.example.com:8080");
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "https://app.example.com, https://app.example.com",
+ "https://App.Example.COM, https://app.example.com",
+ "HTTPS://app.example.com, https://app.example.com",
+ "https://app.example.com:443, https://app.example.com",
+ "https://app.example.com/, https://app.example.com",
+ "http://app.example.com:80, http://app.example.com",
+ "http://app.example.com:443, http://app.example.com:443",
+ "https://app.example.com:80, https://app.example.com:80",
+ "http://localhost:08080, http://localhost:8080",
+ "http://localhost:*, http://localhost:*",
+ "http://[::1]:*, http://[::1]:*",
+ "http://[::1]:80, http://[::1]",
+ "http://[FE80::1]:3000, http://[fe80::1]:3000",
+ "vscode-webview://abc123, vscode-webview://abc123" })
+ void allowedOriginIsNormalized(String entry, String expected) {
+
assertThat(McpServerOrigins.normalizeAllowedOrigin(entry)).isEqualTo(expected);
+ }
+
+ @Test
+ void wildcardForAllOriginsIsRefused() {
+ assertThatThrownBy(() ->
McpServerOrigins.parseAllowedOrigins("https://app.example.com, *"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("MCP allowed origin * is not
supported")
+ .hasMessageContaining("http://localhost:*");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "app.example.com", "localhost:8080", "null",
"https://app.example.com/mcp", "https://app.example.com?x=1",
+ "https://[email protected]", "https://*.example.com",
"https://app.example.com:", "https://app.example.com:x",
+ "https://app.example.com:70000", "https://", "://app.example.com",
"https://app example.com" })
+ void entryThatCanNeverMatchIsRefused(String entry) {
+ assertThatThrownBy(() ->
McpServerOrigins.normalizeAllowedOrigin(entry))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("Invalid MCP allowed origin '" + entry
+ "'");
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "https://app.example.com, https://app.example.com",
+ "https://App.Example.com:443, https://app.example.com",
+ "http://LOCALHOST:6274, http://localhost:6274",
+ "http://localhost:80, http://localhost",
+ "null, null",
+ "http://localhost:*, http://localhost:*",
+ "https://app.example.com/path, https://app.example.com/path" })
+ void requestOriginIsNormalizedOrKeptAsIs(String origin, String expected) {
+
assertThat(McpServerOrigins.normalizeOrigin(origin)).isEqualTo(expected);
+ }
+}
diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc
b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc
index e7f46982a45f..c2a36f3a4898 100644
--- a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc
+++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc
@@ -289,6 +289,13 @@ JBang (see the xref:main.adoc[camel-main] options) or on
sessions on the Vert.x streamable transport. Sessions with no activity for
longer than this interval are evicted. `0` disables idle eviction. |
`300000` | engine
+| `camel.server.mcp-allowed-origins` | Comma-separated list of origins allowed
+ to call the MCP endpoint, such as `+https://app.example.com+` or
+ `+http://localhost:*+` (`+:*+` matches any port). A request without an
`Origin`
+ header is always served; a request with an `Origin` header that is not in the
+ list gets HTTP 403. Scheme and host are compared ignoring case, and a default
+ port may be left out. `+*+` is not supported. When not set, only loopback
+ origins are allowed. See <<_origins_and_binding>>. | loopback origins |
engine
|===
Bridge-owned options are honored identically on every runtime. Engine-owned
@@ -337,6 +344,8 @@ The Vert.x engine implements the MCP streamable HTTP
transport:
removes sessions with no traffic. Configure with
`camel.server.mcp-session-keep-alive-interval` and
`camel.server.mcp-session-idle-ttl` (set either to `0` to disable).
+* an `Origin` header check on every method (`POST`, `GET` and `DELETE`), see
+ <<_origins_and_binding>>.
Tools and resources appearing or disappearing (routes starting and stopping)
emit `notifications/tools/list_changed` and
@@ -386,6 +395,57 @@ following rules:
Camel subprocess (local IDE or agent). Route authors remain fully trusted;
only explicitly tagged tools are exposed.
+=== Origins and binding
+
+The MCP streamable HTTP transport requires a server to validate the `Origin`
+header. The Vert.x engine on Camel Main and Camel JBang does so on every
request
+to the MCP endpoint (`POST`, the SSE `GET` and `DELETE`):
+
+* A request *without* an `Origin` header is served. MCP clients run as a
+ process (an IDE, an agent, another Camel integration) and send none.
+* A request *with* an `Origin` header is served only when the origin is
+ allowed. Any other origin gets HTTP 403 with a JSON-RPC error that has no
+ `id`, and no tool or resource route is called.
+* By default only loopback origins are allowed: `localhost`, `127.0.0.1` and
+ `[::1]`, over `http` or `https`, on any port. Browser-based tools on the
+ same machine, such as the MCP Inspector, keep working.
+* `camel.server.mcp-allowed-origins` replaces the default with your own list.
+ Each entry is an origin (scheme, host and port), or ends with `+:*+` to match
+ any port. Add the loopback entries again if you still need them:
+
+[source,properties]
+----
+camel.server.mcp-allowed-origins = https://app.example.com, http://localhost:*
+----
+
+Entries are compared in the form a browser sends the `Origin` header: the
+scheme and host ignoring case, and without the default port, so
+`+https://App.Example.com:443/+` and `+https://app.example.com+` are the same
+origin. An entry that can never match an `Origin` header fails the startup with
+a message saying why: `+*+` (the check cannot be turned off; clients that are
+not browsers send no `Origin` header and are always served), an entry with a
+path such as `+https://app.example.com/mcp+`, an entry without a scheme such as
+`app.example.com`, or a wildcard in the host such as `+https://*.example.com+`.
+
+A refused request is logged at `DEBUG` level by the
+`org.apache.camel.component.mcp.server.vertx.VertxMcpStreamableServerTransportProvider`
+logger, with the `Origin` it carried.
+
+The check is independent of the platform-http CORS configuration: CORS only
+adds response headers for the browser, while the origin check refuses the
+request on the server.
+
+The main HTTP server listens on all network interfaces by default
+(`camel.server.host = 0.0.0.0`). When the MCP server is only meant for clients
+on the same machine, bind it to the loopback interface:
+
+[source,properties]
+----
+camel.server.host = 127.0.0.1
+----
+
+Stdio mode has no HTTP transport and is not affected.
+
== Runtime notes
* *Camel Main / JBang (HTTP)*: requires the Camel main HTTP 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
index e3272be303d7..09a472726686 100644
---
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
@@ -54,6 +54,7 @@ public class DefaultMcpServerFactory implements
McpServerFactory {
mcpConfiguration.setServerIcons(McpServerIconsParser.parse(configuration.getMcpServerIcons()));
mcpConfiguration.setSessionKeepAliveInterval(configuration.getMcpSessionKeepAliveInterval());
mcpConfiguration.setSessionIdleTtl(configuration.getMcpSessionIdleTtl());
+
mcpConfiguration.setAllowedOrigins(configuration.getMcpAllowedOrigins());
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
index a27494e33bca..9c254db6647d 100644
---
a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java
+++
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java
@@ -53,6 +53,10 @@ import org.slf4j.LoggerFactory;
* serves on the main server port and inherits its lifecycle, authentication
and CORS configuration. Set
* {@link #setTargetServerType(String)} to {@link
VertxPlatformHttpRouter#SERVER_TYPE_MANAGEMENT} to serve on the
* management HTTP server router instead (e.g. for dev/diagnostics tools that
must not be publicly exposed).
+ * <p>
+ * Every request on the MCP endpoint that carries an {@code Origin} header
must come from an allowed origin
+ * ({@link McpServerInfo#allowedOrigins()}, loopback origins by default), or
it is rejected with HTTP 403 before it
+ * reaches the MCP server. Requests without an {@code Origin} header (MCP
clients running as a process) are served.
*/
@JdkService(McpServerConstants.MCP_SERVER_ENGINE_FACTORY)
public class VertxMcpServerEngine extends ServiceSupport implements
McpServerEngine {
@@ -120,7 +124,7 @@ public class VertxMcpServerEngine extends ServiceSupport
implements McpServerEng
jsonMapper = McpJsonDefaults.getMapper();
transport = new VertxMcpStreamableServerTransportProvider(
jsonMapper, info.path(),
- info.sessionKeepAliveIntervalMs(), info.sessionIdleTtlMs());
+ info.sessionKeepAliveIntervalMs(), info.sessionIdleTtlMs(),
info.allowedOrigins());
McpServer.SyncSpecification<?> specification =
McpServer.sync(transport)
.serverInfo(buildServerInfo(info))
// resources(subscribe, listChanged): per-resource
subscriptions are not offered, but clients are
diff --git
a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
index 9c6b6805ffe5..35fe74c4fa07 100644
---
a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
+++
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
@@ -27,6 +27,10 @@ import java.util.concurrent.atomic.AtomicInteger;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
+import
io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
+import io.modelcontextprotocol.server.transport.HeaderAccessor;
+import io.modelcontextprotocol.server.transport.ServerHttpHeaderValidator;
+import
io.modelcontextprotocol.server.transport.ServerTransportSecurityException;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
@@ -41,6 +45,8 @@ import io.vertx.ext.auth.User;
import io.vertx.ext.web.Route;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.BodyHandler;
+import io.vertx.ext.web.handler.SecurityPolicyHandler;
+import org.apache.camel.component.mcp.server.McpServerOrigins;
import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpRouter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -55,6 +61,10 @@ import reactor.core.publisher.Mono;
* 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.
+ * <p>
+ * Every request carrying an {@code Origin} header is checked against the
allowed origins before any other handling, as
+ * the MCP streamable HTTP transport requires; a request from an origin that
is not allowed gets HTTP 403 with a
+ * JSON-RPC error and never reaches the MCP session. Requests without an
{@code Origin} header are served.
*/
public class VertxMcpStreamableServerTransportProvider implements
McpStreamableServerTransportProvider {
@@ -70,11 +80,28 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
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 static final String ORIGIN = "Origin";
+ // a JSON-RPC error response without an id (McpSchema.JSONRPCResponse
requires one, so it is written as is)
+ private static final String ORIGIN_NOT_ALLOWED = "{\"jsonrpc\":\"" +
McpSchema.JSONRPC_VERSION
+ +
"\",\"error\":{\"code\":" + McpSchema.ErrorCodes.INVALID_REQUEST
+ + ",\"message\":\"Origin
not allowed\"}}";
+
+ /**
+ * The origins allowed when none are configured: loopback hosts on any
port, so browser-based tools on the same
+ * machine keep working.
+ */
+ static final List<String> DEFAULT_ALLOWED_ORIGINS = List.of(
+ "http://localhost:*", "https://localhost:*",
+ "http://127.0.0.1:*", "https://127.0.0.1:*",
+ "http://[::1]:*", "https://[::1]:*");
private final McpJsonMapper jsonMapper;
private final String path;
private final long sessionKeepAliveIntervalMs;
private final long sessionIdleTtlMs;
+ private final ServerHttpHeaderValidator originValidator;
+ // Vert.x lets a handler of this type run before the body handler
+ private final SecurityPolicyHandler originHandler = this::checkOrigin;
private final ConcurrentHashMap<String, ManagedSession> sessions = new
ConcurrentHashMap<>();
private final List<Route> routes = new ArrayList<>();
@@ -93,10 +120,27 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
public VertxMcpStreamableServerTransportProvider(
McpJsonMapper jsonMapper,
String path, long sessionKeepAliveIntervalMs,
long sessionIdleTtlMs) {
+ this(jsonMapper, path, sessionKeepAliveIntervalMs, sessionIdleTtlMs,
null);
+ }
+
+ /**
+ * @param allowedOrigins the origins allowed to call the endpoint, each
exact (e.g. {@code https://app.example.com})
+ * or with a wildcard port (e.g. {@code
http://localhost:*}), normalized with
+ * {@link
McpServerOrigins#normalizeAllowedOrigin(String)}; {@code null} for
+ * {@link #DEFAULT_ALLOWED_ORIGINS}
+ */
+ public VertxMcpStreamableServerTransportProvider(
+ McpJsonMapper jsonMapper,
String path, long sessionKeepAliveIntervalMs,
+ long sessionIdleTtlMs,
List<String> allowedOrigins) {
this.jsonMapper = jsonMapper;
this.path = path;
this.sessionKeepAliveIntervalMs = sessionKeepAliveIntervalMs;
this.sessionIdleTtlMs = sessionIdleTtlMs;
+ this.originValidator =
DefaultServerTransportSecurityValidator.builder()
+ .allowedOrigins(allowedOrigins != null
+ ?
allowedOrigins.stream().map(McpServerOrigins::normalizeAllowedOrigin).toList()
+ : DEFAULT_ALLOWED_ORIGINS)
+ .build();
}
@Override
@@ -142,13 +186,17 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
public void registerRoutes(VertxPlatformHttpRouter router) {
vertx = router.vertx();
Route post = router.route(path).method(HttpMethod.POST);
+ // the origin check comes first, so a request that is not allowed is
not even read
+ post.handler(originHandler);
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(originHandler);
get.handler(ctx -> dispatch(vertx, ctx, this::handleGet));
routes.add(get);
Route delete = router.route(path).method(HttpMethod.DELETE);
+ delete.handler(originHandler);
delete.handler(ctx -> dispatch(vertx, ctx, this::handleDelete));
routes.add(delete);
startSessionMaintenance();
@@ -164,6 +212,25 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
return sessions.size();
}
+ /**
+ * Serves a request without an {@code Origin} header or from an allowed
origin; answers any other with HTTP 403 and
+ * a JSON-RPC error that has no {@code id}. Runs on the event loop before
any other handler of the route.
+ */
+ private void checkOrigin(RoutingContext ctx) {
+ try {
+ originValidator.validate(new VertxHeaderAccessor(ctx));
+ } catch (ServerTransportSecurityException e) {
+ LOG.debug("Rejected MCP {} request on {}: origin {} is not
allowed", ctx.request().method(), path,
+ ctx.request().getHeader(ORIGIN));
+ ctx.response()
+ .setStatusCode(e.getStatusCode())
+ .putHeader("Content-Type", APPLICATION_JSON)
+ .end(ORIGIN_NOT_ALLOWED);
+ return;
+ }
+ ctx.next();
+ }
+
@FunctionalInterface
private interface BlockingRequestHandler {
void handle(RoutingContext ctx, Context connection) throws Exception;
@@ -511,6 +578,24 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
connection.runOnContext(v ->
ctx.response().setStatusCode(status).end());
}
+ private record VertxHeaderAccessor(RoutingContext ctx) implements
HeaderAccessor {
+
+ @Override
+ public List<String> getHeader(String name) {
+ List<String> values = ctx.request().headers().getAll(name);
+ if (ORIGIN.equalsIgnoreCase(name)) {
+ // compare in the form the allowed origins are kept in:
lower-case scheme and host, no default port
+ return
values.stream().map(McpServerOrigins::normalizeOrigin).toList();
+ }
+ return values;
+ }
+
+ @Override
+ public List<String> getHeaderNames() {
+ return List.copyOf(ctx.request().headers().names());
+ }
+ }
+
private static final class ManagedSession {
private final McpStreamableServerSession session;
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
index 4d2f2ff13729..272bbeab3263 100644
---
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
@@ -18,6 +18,10 @@ package org.apache.camel.component.mcp.server.main;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
@@ -90,6 +94,64 @@ class McpServerMainPropertiesTest {
}
}
+ @Test
+ void testMcpAllowedOriginsFromProperties() throws Exception {
+ int port = AvailablePortFinder.getNextAvailable();
+
+ Main main = new Main();
+ 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-allowed-origins",
"https://App.Example.com:443/, http://localhost:3000");
+ main.start();
+
+ try {
+ assertThat(initialize(port, null)).isEqualTo(200);
+ assertThat(initialize(port,
"https://app.example.com")).isEqualTo(200);
+ assertThat(initialize(port,
"http://localhost:3000")).isEqualTo(200);
+ // the configured list replaces the loopback default
+ assertThat(initialize(port,
"http://localhost:6274")).isEqualTo(403);
+ assertThat(initialize(port,
"https://other.example.com")).isEqualTo(403);
+ } finally {
+ main.stop();
+ }
+ }
+
+ @Test
+ void testMcpAllowedOriginWildcardFailsFast() {
+ Main main = new Main();
+ main.addInitialProperty("camel.server.enabled", "true");
+ main.addInitialProperty("camel.server.port",
String.valueOf(AvailablePortFinder.getNextAvailable()));
+ main.addInitialProperty("camel.server.mcp-enabled", "true");
+ main.addInitialProperty("camel.server.mcp-tags", "crm");
+ main.addInitialProperty("camel.server.mcp-allowed-origins", "*");
+
+ try {
+ assertThatThrownBy(main::start)
+ .hasStackTraceContaining("MCP allowed origin * is not
supported");
+ } finally {
+ main.stop();
+ }
+ }
+
+ private static int initialize(int port, String origin) throws Exception {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/mcp"))
+ .timeout(Duration.ofSeconds(10))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json, text/event-stream")
+ .POST(HttpRequest.BodyPublishers.ofString("""
+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",
+
"capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"""));
+ if (origin != null) {
+ builder.header("Origin", origin);
+ }
+ try (HttpClient client = HttpClient.newHttpClient()) {
+ return client.send(builder.build(),
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+ }
+
@Test
void testMcpEnabledWithoutHttpServerFailsFastForHttpTransport() {
Main main = new Main();
diff --git
a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpOriginTest.java
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpOriginTest.java
new file mode 100644
index 000000000000..0847a3a4a808
--- /dev/null
+++
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpOriginTest.java
@@ -0,0 +1,301 @@
+/*
+ * 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.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.component.ai.tool.AiToolParameterHelper.ParameterDef;
+import org.apache.camel.component.mcp.server.McpServerInfo;
+import org.apache.camel.component.mcp.server.McpServerTool;
+import org.apache.camel.component.mcp.server.McpToolCallHandler;
+import org.apache.camel.component.mcp.server.McpToolCallResult;
+import org.apache.camel.component.platform.http.main.MainHttpServer;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.test.AvailablePortFinder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Verifies that the Vert.x streamable transport checks the {@code Origin}
header on every method of the MCP endpoint
+ * (CAMEL-26359).
+ */
+class VertxMcpOriginTest {
+
+ private static final HttpClient HTTP = HttpClient.newHttpClient();
+
+ private static final String INITIALIZE_REQUEST = """
+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",
+ "capabilities":{},"clientInfo":{"name":"test","version":"1"}}}""";
+
+ private static final String INITIALIZED_NOTIFICATION = """
+
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""";
+
+ private static final String CALL_TOOL_REQUEST = """
+
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{}}}""";
+
+ private CamelContext camelContext;
+ private VertxMcpServerEngine engine;
+ private int port;
+
+ @AfterEach
+ void stop() {
+ if (engine != null) {
+ engine.stop();
+ }
+ if (camelContext != null) {
+ camelContext.stop();
+ }
+ }
+
+ @Test
+ void requestWithoutOriginIsServed() throws Exception {
+ start(null);
+
+ HttpResponse<String> response = post(null, null, INITIALIZE_REQUEST);
+
+ assertThat(response.statusCode()).isEqualTo(200);
+
assertThat(response.headers().firstValue("mcp-session-id")).isPresent();
+ assertThat(engine.sessionCount()).isEqualTo(1);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "http://localhost", "http://localhost:6274",
"https://localhost:8443",
+ "http://127.0.0.1:3000", "http://[::1]:8080" })
+ void loopbackOriginIsServedByDefault(String origin) throws Exception {
+ start(null);
+
+ HttpResponse<String> response = post(origin, null, INITIALIZE_REQUEST);
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(engine.sessionCount()).isEqualTo(1);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "http://app.example.com", "https://localhost.example.com",
"http://127.0.0.2:8080", "null" })
+ void otherOriginIsRejectedByDefault(String origin) throws Exception {
+ start(null);
+
+ HttpResponse<String> response = post(origin, null, INITIALIZE_REQUEST);
+
+ assertThat(response.statusCode()).isEqualTo(403);
+
assertThat(response.headers().firstValue("Content-Type")).hasValueSatisfying(
+ value -> assertThat(value).startsWith("application/json"));
+ assertThat(response.body())
+ .contains("\"jsonrpc\":\"2.0\"")
+ .contains("\"code\":-32600")
+ .contains("Origin not allowed")
+ .doesNotContain("\"id\"");
+ assertThat(response.headers().firstValue("mcp-session-id")).isEmpty();
+ assertThat(engine.sessionCount()).isZero();
+ }
+
+ @Test
+ void configuredOriginsReplaceTheLoopbackDefault() throws Exception {
+ start(List.of("https://app.example.com",
"http://tools.example.com:*"));
+
+ assertThat(post("https://app.example.com", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ assertThat(post("http://tools.example.com:9000", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ assertThat(post("https://app.example.com:8443", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(403);
+ assertThat(post("http://app.example.com", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(403);
+ assertThat(post("http://localhost:6274", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(403);
+ assertThat(post(null, null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void originsAreComparedIgnoringCaseAndDefaultPort() throws Exception {
+ start(List.of("https://App.Example.com:443/",
"http://Tools.Example.com:*"));
+
+ assertThat(post("https://app.example.com", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ assertThat(post("HTTPS://APP.EXAMPLE.COM:443", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ assertThat(post("http://tools.example.com", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ assertThat(post("http://TOOLS.example.com:9000", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ assertThat(post("https://app.example.com:8443", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(403);
+ assertThat(post("https://app.example.com.evil.test", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(403);
+ }
+
+ @Test
+ void loopbackOriginIsComparedIgnoringCase() throws Exception {
+ start(null);
+
+ assertThat(post("http://LocalHost:6274", null,
INITIALIZE_REQUEST).statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void rejectedOriginDoesNotCallTheTool() throws Exception {
+ start(null);
+ AtomicInteger calls = new AtomicInteger();
+ engine.toolAdded(tool("ping", arguments -> {
+ calls.incrementAndGet();
+ return new McpToolCallResult("pong", false);
+ }));
+ String sessionId = initializeSession();
+
+ HttpResponse<String> rejected = post("http://app.example.com",
sessionId, CALL_TOOL_REQUEST);
+ assertThat(rejected.statusCode()).isEqualTo(403);
+ assertThat(calls).hasValue(0);
+
+ HttpResponse<String> served = post("http://localhost:6274", sessionId,
CALL_TOOL_REQUEST);
+ assertThat(served.statusCode()).isEqualTo(200);
+ assertThat(served.body()).contains("pong");
+ assertThat(calls).hasValue(1);
+ }
+
+ @Test
+ void sseChannelChecksTheOrigin() throws Exception {
+ start(null);
+ String sessionId = initializeSession();
+
+ HttpResponse<InputStream> rejected = get("http://app.example.com",
sessionId);
+ try (InputStream body = rejected.body()) {
+ assertThat(rejected.statusCode()).isEqualTo(403);
+ }
+
+ // the SSE response headers go out with the first event, so add a tool
to make the server send one
+ CompletableFuture<HttpResponse<InputStream>> pending = HTTP.sendAsync(
+ getRequest("http://localhost:6274", sessionId),
HttpResponse.BodyHandlers.ofInputStream());
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ engine.toolAdded(tool("tool" + System.nanoTime(), arguments -> new
McpToolCallResult("ok", false)));
+ assertThat(pending).isDone();
+ });
+ HttpResponse<InputStream> served = pending.get();
+ try (InputStream body = served.body()) {
+ assertThat(served.statusCode()).isEqualTo(200);
+
assertThat(served.headers().firstValue("Content-Type")).hasValue("text/event-stream");
+ }
+ }
+
+ @Test
+ void deleteChecksTheOrigin() throws Exception {
+ start(null);
+ String sessionId = initializeSession();
+
+ assertThat(delete("http://app.example.com", sessionId)).isEqualTo(403);
+ assertThat(engine.sessionCount()).isEqualTo(1);
+
+ assertThat(delete("http://127.0.0.1:6274", sessionId)).isEqualTo(200);
+ assertThat(engine.sessionCount()).isZero();
+ }
+
+ private void start(List<String> allowedOrigins) throws Exception {
+ port = AvailablePortFinder.getNextAvailable();
+ camelContext = new DefaultCamelContext();
+ MainHttpServer main = new MainHttpServer();
+ main.setCamelContext(camelContext);
+ main.setHost("0.0.0.0");
+ main.setPort(port);
+ camelContext.addService(main);
+ camelContext.start();
+
+ engine = new VertxMcpServerEngine();
+ engine.setCamelContext(camelContext);
+ engine.initialize(new McpServerInfo(
+ "origin", "1.0", "/mcp", 0, 0, null, null, null, null, null,
allowedOrigins));
+ engine.start();
+ }
+
+ private String initializeSession() throws Exception {
+ HttpResponse<String> response = post(null, null, INITIALIZE_REQUEST);
+ assertThat(response.statusCode()).isEqualTo(200);
+ String sessionId =
response.headers().firstValue("mcp-session-id").orElseThrow();
+ assertThat(post(null, sessionId,
INITIALIZED_NOTIFICATION).statusCode()).isEqualTo(202);
+ return sessionId;
+ }
+
+ private HttpResponse<String> post(String origin, String sessionId, String
body) throws Exception {
+ HttpRequest.Builder builder = request(origin, sessionId)
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json, text/event-stream")
+ .POST(HttpRequest.BodyPublishers.ofString(body));
+ return HTTP.send(builder.build(),
HttpResponse.BodyHandlers.ofString());
+ }
+
+ private HttpResponse<InputStream> get(String origin, String sessionId)
throws Exception {
+ return HTTP.send(getRequest(origin, sessionId),
HttpResponse.BodyHandlers.ofInputStream());
+ }
+
+ private HttpRequest getRequest(String origin, String sessionId) {
+ return request(origin, sessionId)
+ .header("Accept", "text/event-stream")
+ .GET()
+ .build();
+ }
+
+ private int delete(String origin, String sessionId) throws Exception {
+ HttpRequest.Builder builder = request(origin, sessionId).DELETE();
+ return HTTP.send(builder.build(),
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+
+ private HttpRequest.Builder request(String origin, String sessionId) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + "/mcp"))
+ .timeout(Duration.ofSeconds(10));
+ if (origin != null) {
+ builder.header("Origin", origin);
+ }
+ if (sessionId != null) {
+ builder.header("Mcp-Session-Id", sessionId);
+ }
+ return builder;
+ }
+
+ private static McpServerTool tool(String name, McpToolCallHandler handler)
{
+ return new McpServerTool() {
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public String description() {
+ return "Answers pong";
+ }
+
+ @Override
+ public String inputSchemaJson() {
+ return null;
+ }
+
+ @Override
+ public Map<String, ParameterDef> parameters() {
+ return Map.of();
+ }
+
+ @Override
+ public McpToolCallHandler handler() {
+ return handler;
+ }
+ };
+ }
+}
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 e9ebd8cc4f15..68cc1a2c18bf 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
@@ -38,6 +38,7 @@ 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("McpAllowedOrigins", java.lang.String.class);
map.put("McpEnabled", boolean.class);
map.put("McpInstructions", java.lang.String.class);
map.put("McpPath", java.lang.String.class);
@@ -93,6 +94,8 @@ public class HttpServerConfigurationPropertiesConfigurer
extends org.apache.came
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 "mcpallowedorigins":
+ case "mcpAllowedOrigins":
target.setMcpAllowedOrigins(property(camelContext, java.lang.String.class,
value)); return true;
case "mcpenabled":
case "mcpEnabled": target.setMcpEnabled(property(camelContext,
boolean.class, value)); return true;
case "mcpinstructions":
@@ -171,6 +174,8 @@ public class HttpServerConfigurationPropertiesConfigurer
extends org.apache.came
case "jwtKeystoreType": return java.lang.String.class;
case "maxbodysize":
case "maxBodySize": return java.lang.Long.class;
+ case "mcpallowedorigins":
+ case "mcpAllowedOrigins": return java.lang.String.class;
case "mcpenabled":
case "mcpEnabled": return boolean.class;
case "mcpinstructions":
@@ -245,6 +250,8 @@ public class HttpServerConfigurationPropertiesConfigurer
extends org.apache.came
case "jwtKeystoreType": return target.getJwtKeystoreType();
case "maxbodysize":
case "maxBodySize": return target.getMaxBodySize();
+ case "mcpallowedorigins":
+ case "mcpAllowedOrigins": return target.getMcpAllowedOrigins();
case "mcpenabled":
case "mcpEnabled": return target.isMcpEnabled();
case "mcpinstructions":
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 fbcff6d0ed74..742db1c4abff 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
@@ -455,6 +455,7 @@
{ "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.mcpAllowedOrigins", "required": false,
"description": "Comma-separated list of origins allowed to call the MCP
endpoint on the Vert.x streamable transport. Each entry is an exact origin
(scheme, host and port), or ends with a colon and an asterisk to match any
port. Scheme and host are compared ignoring case, and a default port (80 for
http, 443 for https) may be left out. A wildcard for all origins is not
supported: an entry that is not of the form scheme:\/\ [...]
{ "name": "camel.server.mcpEnabled", "required": false, "description":
"Whether to expose ai-tool routes as MCP tools and ai-resource routes as MCP
resources. Requires camel-mcp-server on the classpath. Use
setMcpTransport(String) to choose streamable HTTP (default) or stdio for IDE
subprocess integration. By default, the MCP server is not enabled.",
"sourceType": "org.apache.camel.main.HttpServerConfigurationProperties",
"type": "boolean", "javaType": "boolean", "defaultValue": fals [...]
{ "name": "camel.server.mcpInstructions", "required": false,
"description": "Top-level MCP instructions returned to clients on initialize.",
"sourceType": "org.apache.camel.main.HttpServerConfigurationProperties",
"type": "string", "javaType": "java.lang.String", "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 },
diff --git a/core/camel-main/src/main/docs/main.adoc
b/core/camel-main/src/main/docs/main.adoc
index 964f456c127b..e7b523095997 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -197,7 +197,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 35 options, which are listed below.
+The camel.server supports 36 options, which are listed below.
[width="100%",cols="2,5,^1,2",options="header"]
|===
@@ -217,6 +217,7 @@ The camel.server supports 35 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.mcpAllowedOrigins` | Comma-separated list of origins allowed
to call the MCP endpoint on the Vert.x streamable transport. Each entry is an
exact origin (scheme, host and port), or ends with a colon and an asterisk to
match any port. Scheme and host are compared ignoring case, and a default port
(80 for http, 443 for https) may be left out. A wildcard for all origins is not
supported: an entry that is not of the form scheme://host or scheme://host:port
fails the startup. A [...]
| `camel.server.mcpEnabled` | Whether to expose ai-tool routes as MCP tools
and ai-resource routes as MCP resources. Requires camel-mcp-server on the
classpath. Use setMcpTransport(String) to choose streamable HTTP (default) or
stdio for IDE subprocess integration. By default, the MCP server is not
enabled. | false | boolean
| `camel.server.mcpInstructions` | Top-level MCP instructions returned to
clients on initialize. | | String
| `camel.server.mcpPath` | HTTP path where the MCP endpoint is served. | /mcp
| String
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 c6bb81bf4d0b..98e36209780d 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
@@ -88,6 +88,8 @@ public class HttpServerConfigurationProperties implements
BootstrapCloseable {
@Metadata(defaultValue = "300000")
private long mcpSessionIdleTtl = 300000;
@Metadata
+ private String mcpAllowedOrigins;
+ @Metadata
private String mcpServerName;
@Metadata
private String mcpServerTitle;
@@ -520,6 +522,23 @@ public class HttpServerConfigurationProperties implements
BootstrapCloseable {
this.mcpSessionIdleTtl = mcpSessionIdleTtl;
}
+ public String getMcpAllowedOrigins() {
+ return mcpAllowedOrigins;
+ }
+
+ /**
+ * Comma-separated list of origins allowed to call the MCP endpoint on the
Vert.x streamable transport. Each entry
+ * is an exact origin (scheme, host and port), or ends with a colon and an
asterisk to match any port. Scheme and
+ * host are compared ignoring case, and a default port (80 for http, 443
for https) may be left out. A wildcard for
+ * all origins is not supported: an entry that is not of the form
scheme://host or scheme://host:port fails the
+ * startup. A request without an Origin header is always served; a request
with an Origin header that is not allowed
+ * is rejected with HTTP 403. When not set, only loopback origins are
allowed (localhost, 127.0.0.1 and ::1 on any
+ * port).
+ */
+ public void setMcpAllowedOrigins(String mcpAllowedOrigins) {
+ this.mcpAllowedOrigins = mcpAllowedOrigins;
+ }
+
/**
* Whether embedded HTTP server is enabled. By default, the server is not
enabled.
*/
@@ -819,4 +838,13 @@ public class HttpServerConfigurationProperties implements
BootstrapCloseable {
return this;
}
+ /**
+ * Comma-separated list of origins allowed to call the MCP endpoint on the
Vert.x streamable transport. When not
+ * set, only loopback origins are allowed.
+ */
+ public HttpServerConfigurationProperties withMcpAllowedOrigins(String
mcpAllowedOrigins) {
+ this.mcpAllowedOrigins = mcpAllowedOrigins;
+ return this;
+ }
+
}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 9a1ccf71dc0c..7fdfc73bea93 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -133,6 +133,20 @@ now reject the invocation before the tool route runs.
Previously, these argument
required-argument check and then be discarded by the reserved-name filter,
leaving the route without
a required value. Rename these parameters and update their `parameter.*` or
`argSchema` declarations.
+=== camel-mcp-server - the Origin header is checked on /mcp
+
+On Camel Main and Camel JBang, the streamable HTTP MCP endpoint now checks the
`Origin` header on every request
+(`POST`, the SSE `GET` and `DELETE`), as the MCP streamable HTTP transport
requires. A request without an `Origin`
+header is served as before; MCP clients that run as a process send none. A
request with an `Origin` header is served
+only when the origin is allowed, and gets HTTP 403 otherwise.
+
+By default only loopback origins are allowed (`localhost`, `127.0.0.1` and
`[::1]`, on any port). A browser-based
+client served from another origin must now be listed in the new
`camel.server.mcp-allowed-origins` option, for example
+`+camel.server.mcp-allowed-origins = https://app.example.com,
http://localhost:*+`. The list replaces the loopback
+default. Scheme and host are compared ignoring case and a default port may be
left out; `+*+` and other entries that are
+not of the form `scheme://host[:port]` fail the startup. Stdio mode is not
affected, and neither are the Spring Boot and
+Quarkus engines.
+
=== camel-mina-sftp - TCP_NODELAY is set on the connections
The `mina-sftp` component now sets `TCP_NODELAY` on its connections, as the
JSch-based `sftp` component does.