This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-26359-mcp-origin
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 6401b496e6280ea2251dd605687e139209a53eb3
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 5 21:37:36 2026 +0100

    CAMEL-26359: camel-mcp-server - check the Origin header on /mcp (Camel Main 
engine)
    
    The MCP streamable HTTP transport requires a server to validate the Origin
    header. The Vert.x engine on Camel Main and Camel JBang did not look at it.
    
    Every request on the MCP endpoint (POST, the SSE GET and DELETE) now goes
    through an origin check before any other handler. A request without an
    Origin header is served as before. A request with an Origin header that is
    not allowed gets HTTP 403 with a JSON-RPC error without an id, and no tool 
or
    resource route is called. The new camel.server.mcp-allowed-origins option
    lists the allowed origins (exact, or ending with :* for any port); when not
    set, only loopback origins are allowed. Matching uses the origin check of 
the
    MCP Java SDK.
    
    Docs gain an "Origins and binding" section, and the 4.24 upgrade guide an
    entry. Stdio mode, Spring Boot and Quarkus are not changed.
    
    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  |  47 ++++
 .../main/camel-main-configuration-metadata.json    |   1 +
 .../component/mcp/server/McpServerBridge.java      |  30 ++-
 .../mcp/server/McpServerConfiguration.java         |  17 ++
 .../camel/component/mcp/server/McpServerInfo.java  |  21 +-
 .../component/mcp/server/McpServerBridgeTest.java  |  31 +++
 .../camel-mcp-server/src/main/docs/mcp-server.adoc |  47 ++++
 .../mcp/server/main/DefaultMcpServerFactory.java   |   1 +
 .../mcp/server/vertx/VertxMcpServerEngine.java     |   6 +-
 .../VertxMcpStreamableServerTransportProvider.java |  76 ++++++
 .../server/main/McpServerMainPropertiesTest.java   |  45 ++++
 .../mcp/server/vertx/VertxMcpOriginTest.java       | 282 +++++++++++++++++++++
 ...ttpServerConfigurationPropertiesConfigurer.java |   7 +
 .../camel-main-configuration-metadata.json         |   1 +
 core/camel-main/src/main/docs/main.adoc            |   3 +-
 .../main/HttpServerConfigurationProperties.java    |  25 ++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  12 +
 18 files changed, 648 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..d6a0a07312fc 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. 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). |  | String
 | `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..9c58c67b57ee 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,12 @@ 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. 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 +343,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 +394,45 @@ 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 exact origin (scheme, host and port, as the browser sends
+  it), 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:*
+----
+
+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..c3860ba5fe48 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. 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 [...]
     { "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..867988b8695b 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
@@ -16,8 +16,10 @@
  */
 package org.apache.camel.component.mcp.server;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
@@ -137,7 +139,7 @@ public class McpServerBridge extends ServiceSupport 
implements CamelContextAware
                 configuration.getSessionKeepAliveInterval(), 
configuration.getSessionIdleTtl(),
                 configuration.getServerTitle(), 
configuration.getServerDescription(),
                 configuration.getServerWebsiteUrl(), 
configuration.getInstructions(),
-                configuration.getServerIcons()));
+                configuration.getServerIcons(), 
parseAllowedOrigins(configuration.getAllowedOrigins())));
 
         if (!engine.consumesServingConfiguration()) {
             if 
(!McpServerConstants.DEFAULT_PATH.equals(configuration.getPath())) {
@@ -145,6 +147,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",
@@ -210,6 +217,27 @@ public class McpServerBridge extends ServiceSupport 
implements CamelContextAware
                 || (configuration.getServerIcons() != null && 
!configuration.getServerIcons().isEmpty());
     }
 
+    /**
+     * Splits the comma-separated allowed origins into a list, trimming each 
entry and dropping a trailing {@code /}.
+     * Returns {@code null} when not set, so the engine applies its default 
(loopback origins only).
+     */
+    static List<String> parseAllowedOrigins(String allowedOrigins) {
+        if (!isSet(allowedOrigins)) {
+            return null;
+        }
+        List<String> answer = new ArrayList<>();
+        for (String origin : allowedOrigins.split(",")) {
+            String value = origin.trim();
+            while (value.endsWith("/")) {
+                value = value.substring(0, value.length() - 1);
+            }
+            if (!value.isEmpty()) {
+                answer.add(value);
+            }
+        }
+        return answer;
+    }
+
     private static boolean isSet(String value) {
         return value != null && !value.isBlank();
     }
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..9109ce5686f4 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,20 @@ 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. 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/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..64bbb7e4df9b 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
@@ -219,6 +219,37 @@ 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/ ,, 
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();
+        assertThat(McpServerBridge.parseAllowedOrigins(null)).isNull();
+        assertThat(McpServerBridge.parseAllowedOrigins(" ")).isNull();
+        // a value with no origin in it allows none
+        assertThat(McpServerBridge.parseAllowedOrigins(",")).isEmpty();
+    }
+
     @Test
     void testNameCollisionIsRefused() throws Exception {
         McpServerTool published = engine.tools().get("query_db");
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..9c58c67b57ee 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,12 @@ 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. 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 +343,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 +394,45 @@ 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 exact origin (scheme, host and port, as the browser sends
+  it), 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:*
+----
+
+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..18bc7aca3c5f 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,7 @@ 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.platform.http.vertx.VertxPlatformHttpRouter;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -55,6 +60,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 +79,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 +119,24 @@ 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:*}); {@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 : 
DEFAULT_ALLOWED_ORIGINS)
+                .build();
     }
 
     @Override
@@ -142,13 +182,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 +208,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 +574,19 @@ 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) {
+            return ctx.request().headers().getAll(name);
+        }
+
+        @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..1d08e61c76ad 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,47 @@ 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/, 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();
+        }
+    }
+
+    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..91d4798431fe
--- /dev/null
+++ 
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpOriginTest.java
@@ -0,0 +1,282 @@
+/*
+ * 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 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..c3860ba5fe48 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. 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 [...]
     { "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..d6a0a07312fc 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. 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). |  | String
 | `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..17ced4321a0c 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,20 @@ 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. 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 +835,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 c7fa7e136c87..6410b39f29c5 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,18 @@ 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. 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