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.
