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.

Reply via email to