This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 6bda4be83495 CAMEL-24831: camel-ai-tool - authorize tool calls with a
component authorizationPolicy, including over MCP (#27332)
6bda4be83495 is described below
commit 6bda4be83495fe07931147615f6dcdf015352946
Author: Andrea Cosentino <[email protected]>
AuthorDate: Sun Oct 4 16:28:46 2026 +0200
CAMEL-24831: camel-ai-tool - authorize tool calls with a component
authorizationPolicy, including over MCP (#27332)
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../apache/camel/catalog/components/ai-tool.json | 6 +-
.../camel/catalog/docs/ai-tool-component.adoc | 68 +++++++++
.../ai/tool/AiToolComponentConfigurer.java | 6 +
.../ai/tool/AiToolConfigurationConfigurer.java | 6 +
.../ai/tool/AiToolEndpointConfigurer.java | 6 +
.../ai/tool/AiToolEndpointUriFactory.java | 3 +-
.../apache/camel/component/ai/tool/ai-tool.json | 6 +-
.../src/main/docs/ai-tool-component.adoc | 68 +++++++++
.../component/ai/tool/AiToolConfiguration.java | 21 +++
.../camel/component/ai/tool/AiToolConsumer.java | 67 +++++++++
.../camel/component/ai/tool/AiToolExecutor.java | 38 ++++-
.../camel/component/ai/tool/AiToolResult.java | 13 ++
.../tool/AiToolAuthorizationPolicyStartupTest.java | 96 ++++++++++++
.../ai/tool/AiToolAuthorizationPolicyTest.java | 167 +++++++++++++++++++++
.../AiToolComponentAuthorizationPolicyTest.java | 121 +++++++++++++++
.../agent/LangChain4jAgentProducer.java | 4 +
.../component/mcp/server/McpServerBridge.java | 23 ++-
.../component/mcp/server/McpToolCallContext.java | 46 ++++++
.../component/mcp/server/McpToolCallHandler.java | 17 +++
.../mcp/server/vertx/VertxMcpServerEngine.java | 13 +-
.../VertxMcpStreamableServerTransportProvider.java | 22 ++-
.../main/McpServerMainAuthenticationTest.java | 85 +++++++++++
.../component/openai/McpToolCallExecutor.java | 4 +
.../springai/chat/AiToolSpecToSpringAi.java | 4 +
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 9 ++
.../dsl/AiToolComponentBuilderFactory.java | 28 ++++
.../endpoint/dsl/AiToolEndpointBuilderFactory.java | 50 ++++++
27 files changed, 987 insertions(+), 10 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json
index b2d28298224d..069c3ebe3bd4 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json
@@ -38,7 +38,8 @@
"returnDirect": { "index": 11, "kind": "property", "displayName": "Return
Direct", "group": "consumer", "label": "consumer", "required": false, "type":
"boolean", "javaType": "java.lang.Boolean", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue":
false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "When true, AI producers that support agentic
tool loo [...]
"tags": { "index": 12, "kind": "property", "displayName": "Tags", "group":
"consumer", "label": "consumer", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "Comma-separated list of tags used to group
tools. Producers filter the registry by these tags t [...]
"title": { "index": 13, "kind": "property", "displayName": "Title",
"group": "consumer", "label": "consumer", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "Optional display title for MCP tool listings.
Advisory hint for MCP clients only." },
- "autowiredEnabled": { "index": 14, "kind": "property", "displayName":
"Autowired Enabled", "group": "advanced", "label": "advanced", "required":
false, "type": "boolean", "javaType": "boolean", "deprecated": false,
"autowired": false, "secret": false, "defaultValue": true, "description":
"Whether autowiring is enabled. This is used for automatic autowiring options
(the option must be marked as autowired) by looking up in the registry to find
if there is a single instance of matching [...]
+ "autowiredEnabled": { "index": 14, "kind": "property", "displayName":
"Autowired Enabled", "group": "advanced", "label": "advanced", "required":
false, "type": "boolean", "javaType": "boolean", "deprecated": false,
"autowired": false, "secret": false, "defaultValue": true, "description":
"Whether autowiring is enabled. This is used for automatic autowiring options
(the option must be marked as autowired) by looking up in the registry to find
if there is a single instance of matching [...]
+ "authorizationPolicy": { "index": 15, "kind": "property", "displayName":
"Authorization Policy", "group": "security", "label": "consumer,security",
"required": false, "type": "object", "javaType":
"org.apache.camel.spi.AuthorizationPolicy", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false,
"configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration",
"configurationField": "configuration", "description": "Reference to an
org.apache.came [...]
},
"properties": {
"toolName": { "index": 0, "kind": "path", "displayName": "Tool Name",
"group": "consumer", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The tool name. This is the
name the LLM sees and uses to invoke the tool." },
@@ -56,6 +57,7 @@
"title": { "index": 12, "kind": "parameter", "displayName": "Title",
"group": "consumer", "label": "consumer", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "Optional display title for MCP tool listings.
Advisory hint for MCP clients only." },
"bridgeErrorHandler": { "index": 13, "kind": "parameter", "displayName":
"Bridge Error Handler", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "boolean", "javaType":
"boolean", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": false, "description": "Allows for bridging the consumer to the
Camel routing Error Handler, which mean any exceptions (if possible) occurred
while the Camel consumer is trying to pickup incoming [...]
"exceptionHandler": { "index": 14, "kind": "parameter", "displayName":
"Exception Handler", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "object", "javaType":
"org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.",
"deprecated": false, "autowired": false, "secret": false, "description": "To
let the consumer use a custom ExceptionHandler. Notice if the option
bridgeErrorHandler is enabled then this option is not in use. By de [...]
- "exchangePattern": { "index": 15, "kind": "parameter", "displayName":
"Exchange Pattern", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "enum", "javaType":
"org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ],
"deprecated": false, "autowired": false, "secret": false, "description": "Sets
the exchange pattern when the consumer creates an exchange." }
+ "exchangePattern": { "index": 15, "kind": "parameter", "displayName":
"Exchange Pattern", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "enum", "javaType":
"org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ],
"deprecated": false, "autowired": false, "secret": false, "description": "Sets
the exchange pattern when the consumer creates an exchange." },
+ "authorizationPolicy": { "index": 16, "kind": "parameter", "displayName":
"Authorization Policy", "group": "security", "label": "consumer,security",
"required": false, "type": "object", "javaType":
"org.apache.camel.spi.AuthorizationPolicy", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false,
"configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration",
"configurationField": "configuration", "description": "Reference to an
org.apache.cam [...]
}
}
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
index 13e9da463c4e..8d431a7d5210 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
@@ -353,6 +353,74 @@ YAML::
----
====
+== Authorizing tool calls
+
+A tool call is a security boundary: an AI model decides, from its own output,
which `ai-tool` route to invoke. Set
+an `authorizationPolicy` — a reference to an
`org.apache.camel.spi.AuthorizationPolicy` bean — to authorize every
+call before the route runs. Set it on the *component* to guard every tool
route by construction, or on a single
+endpoint to override.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+// one policy guarding every ai-tool route
+AiToolComponent ai = context.getComponent("ai-tool", AiToolComponent.class);
+ai.getConfiguration().setAuthorizationPolicy(myAuthorizationPolicy);
+
+from("ai-tool:transferFunds?tags=banking&description=Transfer funds")
+ .to("bean:ledger");
+
+// ...or override on a single endpoint
+from("ai-tool:transferFunds?tags=banking&description=Transfer
funds&authorizationPolicy=#myAuthorizationPolicy")
+ .to("bean:ledger");
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: ai-tool:transferFunds
+ parameters:
+ tags: banking
+ description: "Transfer funds"
+ authorizationPolicy: "#myAuthorizationPolicy"
+ steps:
+ - to: bean:ledger
+----
+====
+
+The guard runs in front of the route: it wraps the route's outer processor, so
it executes before the route's unit
+of work, tracing and error handling. A denied call
(`CamelAuthorizationException`) is returned to the model as a
+short refusal it can relay — not as a tool result and not as a stack trace —
regardless of the tool-execution error
+strategy; the denial is logged at `WARN`, but it does not produce a route span
or metric.
+
+The policy authorizes on *trustworthy* input only:
+
+* the *tool name* comes from the route (the tool's id), never from model
output;
+* the *caller identity* comes from an exchange *property* (or a validated
token) set before the agent ran — for
+ example by `camel-spiffe` or `camel-keycloak`. Authorize on properties or
validated tokens only, *never* on
+ message headers: on a tool route the headers carry the model-controlled tool
arguments (and, on the
+ `langchain4j-agent` path, the caller's inbound HTTP headers), so a policy
such as OPA with the default
+ `includeHeaders="*"` would otherwise read attacker-influenced values.
+
+Which runtimes carry the caller identity:
+
+* `camel-langchain4j-agent` copies the calling exchange, so the identity
property reaches the tool route today.
+* `camel-openai` and `camel-spring-ai-chat` currently build a fresh exchange,
so an identity-based policy denies
+ under them until CAMEL-24832 propagates the caller context — the guard still
applies, it simply has no identity to
+ authorize on yet.
+* Over the xref:others:mcp-server.adoc[MCP server] the authenticated transport
caller is carried onto the tool
+ exchange as the `CamelMcpSecurityPrincipal` property (the raw transport
principal — for the Vert.x streamable HTTP
+ server, an `io.vertx.ext.auth.User`). A policy over MCP reads that property
directly; Camel's shipped
+ identity/token policies (Keycloak, Spring Security, Shiro) do not read it
yet, so MCP authorization needs a policy
+ that inspects `CamelMcpSecurityPrincipal`. Exposing a runtime-neutral
principal name and roles for the shipped
+ policies is tracked as a follow-up.
+
== See Also
* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] —
discovers ai-tool tools via the `tags` option
diff --git
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java
index a4235b3ed125..c78b3ef676e0 100644
---
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java
+++
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java
@@ -32,6 +32,8 @@ public class AiToolComponentConfigurer extends
PropertyConfigurerSupport impleme
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema":
getOrCreateConfiguration(target).setArgSchema(property(camelContext,
java.lang.String.class, value)); return true;
+ case "authorizationpolicy":
+ case "authorizationPolicy":
getOrCreateConfiguration(target).setAuthorizationPolicy(property(camelContext,
org.apache.camel.spi.AuthorizationPolicy.class, value)); return true;
case "autowiredenabled":
case "autowiredEnabled":
target.setAutowiredEnabled(property(camelContext, boolean.class, value));
return true;
case "bridgeerrorhandler":
@@ -64,6 +66,8 @@ public class AiToolComponentConfigurer extends
PropertyConfigurerSupport impleme
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": return java.lang.String.class;
+ case "authorizationpolicy":
+ case "authorizationPolicy": return
org.apache.camel.spi.AuthorizationPolicy.class;
case "autowiredenabled":
case "autowiredEnabled": return boolean.class;
case "bridgeerrorhandler":
@@ -97,6 +101,8 @@ public class AiToolComponentConfigurer extends
PropertyConfigurerSupport impleme
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": return
getOrCreateConfiguration(target).getArgSchema();
+ case "authorizationpolicy":
+ case "authorizationPolicy": return
getOrCreateConfiguration(target).getAuthorizationPolicy();
case "autowiredenabled":
case "autowiredEnabled": return target.isAutowiredEnabled();
case "bridgeerrorhandler":
diff --git
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java
index 05be37996d54..0b56cc32a071 100644
---
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java
+++
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java
@@ -25,6 +25,8 @@ public class AiToolConfigurationConfigurer extends
org.apache.camel.support.comp
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": target.setArgSchema(property(camelContext,
java.lang.String.class, value)); return true;
+ case "authorizationpolicy":
+ case "authorizationPolicy":
target.setAuthorizationPolicy(property(camelContext,
org.apache.camel.spi.AuthorizationPolicy.class, value)); return true;
case "description": target.setDescription(property(camelContext,
java.lang.String.class, value)); return true;
case "destructivehint":
case "destructiveHint":
target.setDestructiveHint(property(camelContext, java.lang.Boolean.class,
value)); return true;
@@ -52,6 +54,8 @@ public class AiToolConfigurationConfigurer extends
org.apache.camel.support.comp
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": return java.lang.String.class;
+ case "authorizationpolicy":
+ case "authorizationPolicy": return
org.apache.camel.spi.AuthorizationPolicy.class;
case "description": return java.lang.String.class;
case "destructivehint":
case "destructiveHint": return java.lang.Boolean.class;
@@ -80,6 +84,8 @@ public class AiToolConfigurationConfigurer extends
org.apache.camel.support.comp
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": return target.getArgSchema();
+ case "authorizationpolicy":
+ case "authorizationPolicy": return target.getAuthorizationPolicy();
case "description": return target.getDescription();
case "destructivehint":
case "destructiveHint": return target.getDestructiveHint();
diff --git
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java
index a55896933241..cd65ce6ad376 100644
---
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java
+++
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java
@@ -25,6 +25,8 @@ public class AiToolEndpointConfigurer extends
PropertyConfigurerSupport implemen
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema":
target.getConfiguration().setArgSchema(property(camelContext,
java.lang.String.class, value)); return true;
+ case "authorizationpolicy":
+ case "authorizationPolicy":
target.getConfiguration().setAuthorizationPolicy(property(camelContext,
org.apache.camel.spi.AuthorizationPolicy.class, value)); return true;
case "bridgeerrorhandler":
case "bridgeErrorHandler":
target.setBridgeErrorHandler(property(camelContext, boolean.class, value));
return true;
case "description":
target.getConfiguration().setDescription(property(camelContext,
java.lang.String.class, value)); return true;
@@ -58,6 +60,8 @@ public class AiToolEndpointConfigurer extends
PropertyConfigurerSupport implemen
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": return java.lang.String.class;
+ case "authorizationpolicy":
+ case "authorizationPolicy": return
org.apache.camel.spi.AuthorizationPolicy.class;
case "bridgeerrorhandler":
case "bridgeErrorHandler": return boolean.class;
case "description": return java.lang.String.class;
@@ -92,6 +96,8 @@ public class AiToolEndpointConfigurer extends
PropertyConfigurerSupport implemen
switch (ignoreCase ? name.toLowerCase() : name) {
case "argschema":
case "argSchema": return target.getConfiguration().getArgSchema();
+ case "authorizationpolicy":
+ case "authorizationPolicy": return
target.getConfiguration().getAuthorizationPolicy();
case "bridgeerrorhandler":
case "bridgeErrorHandler": return target.isBridgeErrorHandler();
case "description": return target.getConfiguration().getDescription();
diff --git
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java
index e55825db4395..ededca6cc9a1 100644
---
a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java
+++
b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java
@@ -24,8 +24,9 @@ public class AiToolEndpointUriFactory extends
org.apache.camel.support.component
private static final Set<String> ENDPOINT_IDENTITY_PROPERTY_NAMES;
private static final Map<String, String> MULTI_VALUE_PREFIXES;
static {
- Set<String> props = new HashSet<>(16);
+ Set<String> props = new HashSet<>(17);
props.add("argSchema");
+ props.add("authorizationPolicy");
props.add("bridgeErrorHandler");
props.add("description");
props.add("destructiveHint");
diff --git
a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json
b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json
index b2d28298224d..069c3ebe3bd4 100644
---
a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json
+++
b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json
@@ -38,7 +38,8 @@
"returnDirect": { "index": 11, "kind": "property", "displayName": "Return
Direct", "group": "consumer", "label": "consumer", "required": false, "type":
"boolean", "javaType": "java.lang.Boolean", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue":
false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "When true, AI producers that support agentic
tool loo [...]
"tags": { "index": 12, "kind": "property", "displayName": "Tags", "group":
"consumer", "label": "consumer", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "Comma-separated list of tags used to group
tools. Producers filter the registry by these tags t [...]
"title": { "index": 13, "kind": "property", "displayName": "Title",
"group": "consumer", "label": "consumer", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "Optional display title for MCP tool listings.
Advisory hint for MCP clients only." },
- "autowiredEnabled": { "index": 14, "kind": "property", "displayName":
"Autowired Enabled", "group": "advanced", "label": "advanced", "required":
false, "type": "boolean", "javaType": "boolean", "deprecated": false,
"autowired": false, "secret": false, "defaultValue": true, "description":
"Whether autowiring is enabled. This is used for automatic autowiring options
(the option must be marked as autowired) by looking up in the registry to find
if there is a single instance of matching [...]
+ "autowiredEnabled": { "index": 14, "kind": "property", "displayName":
"Autowired Enabled", "group": "advanced", "label": "advanced", "required":
false, "type": "boolean", "javaType": "boolean", "deprecated": false,
"autowired": false, "secret": false, "defaultValue": true, "description":
"Whether autowiring is enabled. This is used for automatic autowiring options
(the option must be marked as autowired) by looking up in the registry to find
if there is a single instance of matching [...]
+ "authorizationPolicy": { "index": 15, "kind": "property", "displayName":
"Authorization Policy", "group": "security", "label": "consumer,security",
"required": false, "type": "object", "javaType":
"org.apache.camel.spi.AuthorizationPolicy", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false,
"configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration",
"configurationField": "configuration", "description": "Reference to an
org.apache.came [...]
},
"properties": {
"toolName": { "index": 0, "kind": "path", "displayName": "Tool Name",
"group": "consumer", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The tool name. This is the
name the LLM sees and uses to invoke the tool." },
@@ -56,6 +57,7 @@
"title": { "index": 12, "kind": "parameter", "displayName": "Title",
"group": "consumer", "label": "consumer", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "configurationClass":
"org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField":
"configuration", "description": "Optional display title for MCP tool listings.
Advisory hint for MCP clients only." },
"bridgeErrorHandler": { "index": 13, "kind": "parameter", "displayName":
"Bridge Error Handler", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "boolean", "javaType":
"boolean", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": false, "description": "Allows for bridging the consumer to the
Camel routing Error Handler, which mean any exceptions (if possible) occurred
while the Camel consumer is trying to pickup incoming [...]
"exceptionHandler": { "index": 14, "kind": "parameter", "displayName":
"Exception Handler", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "object", "javaType":
"org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.",
"deprecated": false, "autowired": false, "secret": false, "description": "To
let the consumer use a custom ExceptionHandler. Notice if the option
bridgeErrorHandler is enabled then this option is not in use. By de [...]
- "exchangePattern": { "index": 15, "kind": "parameter", "displayName":
"Exchange Pattern", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "enum", "javaType":
"org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ],
"deprecated": false, "autowired": false, "secret": false, "description": "Sets
the exchange pattern when the consumer creates an exchange." }
+ "exchangePattern": { "index": 15, "kind": "parameter", "displayName":
"Exchange Pattern", "group": "consumer (advanced)", "label":
"consumer,advanced", "required": false, "type": "enum", "javaType":
"org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ],
"deprecated": false, "autowired": false, "secret": false, "description": "Sets
the exchange pattern when the consumer creates an exchange." },
+ "authorizationPolicy": { "index": 16, "kind": "parameter", "displayName":
"Authorization Policy", "group": "security", "label": "consumer,security",
"required": false, "type": "object", "javaType":
"org.apache.camel.spi.AuthorizationPolicy", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false,
"configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration",
"configurationField": "configuration", "description": "Reference to an
org.apache.cam [...]
}
}
diff --git
a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
index 13e9da463c4e..8d431a7d5210 100644
--- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
+++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
@@ -353,6 +353,74 @@ YAML::
----
====
+== Authorizing tool calls
+
+A tool call is a security boundary: an AI model decides, from its own output,
which `ai-tool` route to invoke. Set
+an `authorizationPolicy` — a reference to an
`org.apache.camel.spi.AuthorizationPolicy` bean — to authorize every
+call before the route runs. Set it on the *component* to guard every tool
route by construction, or on a single
+endpoint to override.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+// one policy guarding every ai-tool route
+AiToolComponent ai = context.getComponent("ai-tool", AiToolComponent.class);
+ai.getConfiguration().setAuthorizationPolicy(myAuthorizationPolicy);
+
+from("ai-tool:transferFunds?tags=banking&description=Transfer funds")
+ .to("bean:ledger");
+
+// ...or override on a single endpoint
+from("ai-tool:transferFunds?tags=banking&description=Transfer
funds&authorizationPolicy=#myAuthorizationPolicy")
+ .to("bean:ledger");
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: ai-tool:transferFunds
+ parameters:
+ tags: banking
+ description: "Transfer funds"
+ authorizationPolicy: "#myAuthorizationPolicy"
+ steps:
+ - to: bean:ledger
+----
+====
+
+The guard runs in front of the route: it wraps the route's outer processor, so
it executes before the route's unit
+of work, tracing and error handling. A denied call
(`CamelAuthorizationException`) is returned to the model as a
+short refusal it can relay — not as a tool result and not as a stack trace —
regardless of the tool-execution error
+strategy; the denial is logged at `WARN`, but it does not produce a route span
or metric.
+
+The policy authorizes on *trustworthy* input only:
+
+* the *tool name* comes from the route (the tool's id), never from model
output;
+* the *caller identity* comes from an exchange *property* (or a validated
token) set before the agent ran — for
+ example by `camel-spiffe` or `camel-keycloak`. Authorize on properties or
validated tokens only, *never* on
+ message headers: on a tool route the headers carry the model-controlled tool
arguments (and, on the
+ `langchain4j-agent` path, the caller's inbound HTTP headers), so a policy
such as OPA with the default
+ `includeHeaders="*"` would otherwise read attacker-influenced values.
+
+Which runtimes carry the caller identity:
+
+* `camel-langchain4j-agent` copies the calling exchange, so the identity
property reaches the tool route today.
+* `camel-openai` and `camel-spring-ai-chat` currently build a fresh exchange,
so an identity-based policy denies
+ under them until CAMEL-24832 propagates the caller context — the guard still
applies, it simply has no identity to
+ authorize on yet.
+* Over the xref:others:mcp-server.adoc[MCP server] the authenticated transport
caller is carried onto the tool
+ exchange as the `CamelMcpSecurityPrincipal` property (the raw transport
principal — for the Vert.x streamable HTTP
+ server, an `io.vertx.ext.auth.User`). A policy over MCP reads that property
directly; Camel's shipped
+ identity/token policies (Keycloak, Spring Security, Shiro) do not read it
yet, so MCP authorization needs a policy
+ that inspects `CamelMcpSecurityPrincipal`. Exposing a runtime-neutral
principal name and roles for the shipped
+ policies is tracked as a follow-up.
+
== See Also
* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] —
discovers ai-tool tools via the `tags` option
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java
index d3ef196b8995..a0f8d2a443dd 100644
---
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java
@@ -20,6 +20,7 @@ import java.util.HashMap;
import java.util.Map;
import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.spi.AuthorizationPolicy;
import org.apache.camel.spi.Configurer;
import org.apache.camel.spi.Metadata;
import org.apache.camel.spi.UriParam;
@@ -106,6 +107,18 @@ public class AiToolConfiguration implements Cloneable {
+ "Also published as an MCP tool annotation when
the tool is exposed via camel-mcp-server.")
private Boolean returnDirect;
+ @Metadata(label = "consumer,security")
+ @UriParam(description = "Reference to an
org.apache.camel.spi.AuthorizationPolicy used to authorize tool calls "
+ + "before the route runs. Set it on the component
to guard every tool route by "
+ + "construction, or per endpoint to override. The
policy authorizes on trustworthy input "
+ + "only: the tool name comes from the route (never
from model output), and the caller "
+ + "identity is carried as an exchange property set
before the agent ran (for example by "
+ + "camel-spiffe or camel-keycloak), which the
model cannot set. Authorize on exchange "
+ + "properties or validated tokens only, never on
message headers (on a tool route the "
+ + "headers carry the model-controlled tool
arguments). A denied call surfaces to the "
+ + "model as a short refusal rather than a stack
trace.")
+ private AuthorizationPolicy authorizationPolicy;
+
public AiToolConfiguration() {
}
@@ -205,6 +218,14 @@ public class AiToolConfiguration implements Cloneable {
this.returnDirect = returnDirect;
}
+ public AuthorizationPolicy getAuthorizationPolicy() {
+ return authorizationPolicy;
+ }
+
+ public void setAuthorizationPolicy(AuthorizationPolicy
authorizationPolicy) {
+ this.authorizationPolicy = authorizationPolicy;
+ }
+
public AiToolConfiguration copy() {
try {
AiToolConfiguration copy = (AiToolConfiguration) super.clone();
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java
index b61762818b31..bf16639aee57 100644
---
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java
@@ -18,9 +18,12 @@ package org.apache.camel.component.ai.tool;
import java.util.Map;
+import org.apache.camel.CamelAuthorizationException;
import org.apache.camel.Processor;
+import org.apache.camel.spi.AuthorizationPolicy;
import org.apache.camel.support.CamelContextHelper;
import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.service.ServiceHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,6 +41,7 @@ public class AiToolConsumer extends DefaultConsumer {
private AiToolSpec registeredSpec;
private String[] registeredTags;
private boolean registeredInDefaultPool;
+ private volatile Processor toolProcessor;
public AiToolConsumer(AiToolEndpoint endpoint, Processor processor) {
super(endpoint, processor);
@@ -54,6 +58,55 @@ public class AiToolConsumer extends DefaultConsumer {
register();
}
+ /**
+ * Wraps the tool route's processor with the configured {@link
AuthorizationPolicy}, if any, so the call is
+ * authorized before the route runs. The policy is applied via {@code
beforeWrap} then {@code wrap} (as the
+ * {@code .policy()} DSL does through {@code PolicyReifier}); the wrapped
processor is started and stopped with this
+ * consumer. Because {@code getProcessor()} is the route's <em>outer</em>
processor, the guard runs in front of the
+ * route (before its unit of work, tracing and error handling): a denied
call is logged and returned to the model as
+ * {@link AiToolResult.AuthorizationDenied}, but it does not produce a
route span or metric. There is no
+ * {@code ProcessorDefinition} here (the component owns the guard, not the
DSL), so {@code beforeWrap} gets a
+ * {@code null} definition, which Camel's {@link AuthorizationPolicy}
implementations ignore (they use only the
+ * route). Called from {@link #prepare()} before the tool is registered,
so the tool is never discoverable
+ * unguarded; idempotent (a policy already applied is not wrapped twice).
+ */
+ private void applyAuthorizationPolicy() throws Exception {
+ if (toolProcessor != null) {
+ return;
+ }
+ AuthorizationPolicy policy = configuration.getAuthorizationPolicy();
+ Processor target = getProcessor();
+ if (policy == null || target == null) {
+ return;
+ }
+ policy.beforeWrap(getRoute(), null);
+ Processor guarded = policy.wrap(getRoute(), target);
+ ServiceHelper.startService(guarded);
+ toolProcessor = guarded;
+ LOG.debug("Tool '{}' is guarded by authorization policy {}", toolName,
policy.getClass().getName());
+ }
+
+ /**
+ * The processor {@link AiToolExecutor} invokes for this tool: the
authorization-guarded processor when an
+ * {@link AuthorizationPolicy} is configured, otherwise the plain route
processor. Fails <em>closed</em>: if a
+ * policy is configured but the guard is not yet in place, it returns a
processor that denies the call rather than
+ * exposing the unguarded route processor.
+ */
+ Processor getToolProcessor() {
+ Processor guarded = toolProcessor;
+ if (guarded != null) {
+ return guarded;
+ }
+ if (configuration.getAuthorizationPolicy() != null) {
+ // fail closed: never fall back to the unguarded route processor
while a policy is configured
+ return exchange -> {
+ throw new CamelAuthorizationException(
+ "Authorization policy for tool '" + toolName + "' is
not ready", exchange);
+ };
+ }
+ return getProcessor();
+ }
+
/**
* Registers during route warm-up, which Camel completes for all routes
before it starts any route consumer, so a
* route that sends a request as soon as its consumer starts (such as
{@code stream:in}) sees every tool. Routes
@@ -73,6 +126,12 @@ public class AiToolConsumer extends DefaultConsumer {
if (registeredSpec != null && !isStarted()) {
deregister();
registeredSpec = null;
+ // prepare() may have wrapped and started the guard during early
registration; stop it so an undone early
+ // registration does not leave a started processor behind (matches
doStop)
+ if (toolProcessor != null) {
+ ServiceHelper.stopService(toolProcessor);
+ toolProcessor = null;
+ }
}
}
@@ -124,6 +183,10 @@ public class AiToolConsumer extends DefaultConsumer {
registeredTags = null;
registeredInDefaultPool = true;
}
+
+ // Apply the authorization guard BEFORE the tool is registered
(register() follows in both doStart() and
+ // registerEarly()), so the tool is never discoverable in an unguarded
state during route warm-up.
+ applyAuthorizationPolicy();
}
@Override
@@ -150,6 +213,10 @@ public class AiToolConsumer extends DefaultConsumer {
registeredTags = null;
registeredInDefaultPool = false;
}
+ if (toolProcessor != null) {
+ ServiceHelper.stopService(toolProcessor);
+ toolProcessor = null;
+ }
super.doStop();
}
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
index 850af4aebc4a..85a485d22fca 100644
---
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
@@ -21,6 +21,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import org.apache.camel.CamelAuthorizationException;
import org.apache.camel.Exchange;
import org.apache.camel.Processor;
import org.apache.camel.support.DefaultConsumer;
@@ -83,7 +84,9 @@ public final class AiToolExecutor {
return new AiToolResult.ExecutionError(cause.getMessage(), cause);
}
- Processor routeProcessor = consumer.getProcessor();
+ // Use the authorization-guarded processor when the consumer applied
an AuthorizationPolicy, so the guard runs
+ // before the route body; falls back to the plain route processor
otherwise.
+ Processor routeProcessor = consumer instanceof AiToolConsumer atc ?
atc.getToolProcessor() : consumer.getProcessor();
if (routeProcessor == null) {
IllegalStateException cause = new IllegalStateException(
String.format("No route processor available for tool
'%s'", toolName));
@@ -144,6 +147,10 @@ public final class AiToolExecutor {
if (exchange.getException() != null) {
Exception routeError = exchange.getException();
+ AiToolResult denied = authorizationDenied(toolName,
routeError);
+ if (denied != null) {
+ return denied;
+ }
LOG.error("Error executing tool '{}': {}", toolName,
routeError.getMessage(), routeError);
return new AiToolResult.ExecutionError(
String.format("Error executing tool '%s': %s",
toolName, routeError.getMessage()), routeError);
@@ -153,12 +160,41 @@ public final class AiToolExecutor {
LOG.debug("Tool '{}' execution completed successfully", toolName);
return buildSuccessResult(spec, exchange, result);
} catch (Exception e) {
+ AiToolResult denied = authorizationDenied(toolName, e);
+ if (denied != null) {
+ return denied;
+ }
LOG.error("Error executing tool '{}': {}", toolName,
e.getMessage(), e);
return new AiToolResult.ExecutionError(
String.format("Error executing tool '%s': %s", toolName,
e.getMessage()), e);
}
}
+ /**
+ * Classifies an error from route execution as an authorization denial
when a {@link CamelAuthorizationException} is
+ * present in its cause chain (the route's {@link
org.apache.camel.spi.AuthorizationPolicy} rejected the call).
+ * Returns a caller-safe {@link AiToolResult.AuthorizationDenied} refusal
that does not leak the policy's internal
+ * message, or {@code null} when the error is not an authorization denial.
+ */
+ private static AiToolResult authorizationDenied(String toolName, Throwable
error) {
+ CamelAuthorizationException denial = findAuthorizationException(error);
+ if (denial == null) {
+ return null;
+ }
+ LOG.warn("Tool '{}' call denied by authorization policy: {}",
toolName, denial.getMessage());
+ return new AiToolResult.AuthorizationDenied(
+ String.format("Access denied: not authorized to call tool
'%s'", toolName), denial);
+ }
+
+ private static CamelAuthorizationException
findAuthorizationException(Throwable error) {
+ for (Throwable t = error; t != null; t = t.getCause()) {
+ if (t instanceof CamelAuthorizationException cae) {
+ return cae;
+ }
+ }
+ return null;
+ }
+
private static AiToolResult buildSuccessResult(AiToolSpec spec, Exchange
exchange, String stringBody) {
String outputSchema = spec.getOutputJsonSchema();
if (outputSchema == null || outputSchema.isBlank()) {
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java
index 0345db189efb..5cea14576540 100644
---
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java
@@ -61,4 +61,17 @@ public sealed interface AiToolResult {
*/
record ExecutionError(String message, Exception cause) implements
AiToolResult {
}
+
+ /**
+ * The route's {@link org.apache.camel.spi.AuthorizationPolicy} denied the
call: the caller is not authorized to
+ * invoke this tool. Framework adapters should return {@link #message()}
to the model as a short refusal it can
+ * relay, rather than rethrowing or failing the exchange — a denial is
expected control flow, not a tool error, so
+ * it is not subject to the tool-execution error strategy. {@link
#message()} is a generic, caller-safe refusal and
+ * must not be enriched with the cause before returning it to the model.
+ *
+ * @param message a short, caller-safe refusal message (no internal policy
detail)
+ * @param cause the underlying {@link
org.apache.camel.CamelAuthorizationException}
+ */
+ record AuthorizationDenied(String message, Exception cause) implements
AiToolResult {
+ }
}
diff --git
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationPolicyStartupTest.java
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationPolicyStartupTest.java
new file mode 100644
index 000000000000..2f2540924a75
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationPolicyStartupTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.tool;
+
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.CamelContext;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.AuthorizationPolicy;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.EventNotifierSupport;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24831: the authorization guard must hold during the window between a
tool's early (warm-up) registration and
+ * its consumer start. Since CAMEL-25000 the tool is published during warm-up,
so a route with a lower startupOrder can
+ * invoke it before the tool route's own consumer starts; the guard must
already be in place by then (applied in
+ * {@code prepare()} before {@code register()}), and {@code
getToolProcessor()} must fail closed otherwise.
+ */
+class AiToolAuthorizationPolicyStartupTest extends CamelTestSupport {
+
+ private static final AtomicReference<AiToolResult> EARLY = new
AtomicReference<>();
+
+ static final class DenyAll implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ // nothing to prepare
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> {
+ throw new CamelAuthorizationException("denied", exchange);
+ };
+ }
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext ctx = super.createCamelContext();
+ ctx.getRegistry().bind("denyAll", new DenyAll());
+ ctx.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ // the caller route has started but the ai-tool route's
consumer has not: the guard must already apply
+ if (event instanceof CamelEvent.RouteStartedEvent rse &&
"caller".equals(rse.getRoute().getRouteId())) {
+
AiToolRegistry.getOrCreate(ctx).getToolsByTag("t").stream().findFirst()
+ .ifPresent(spec -> EARLY.set(
+ AiToolExecutor.execute(spec, Map.of(), new
DefaultExchange(ctx))));
+ }
+ }
+ });
+ return ctx;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+
from("direct:caller").routeId("caller").startupOrder(1).log("caller started");
+
from("ai-tool:transfer?tags=t&description=Transfer&authorizationPolicy=#denyAll")
+
.routeId("tool").startupOrder(2).setBody(constant("TRANSFERRED"));
+ }
+ };
+ }
+
+ @Test
+ void callDuringStartupMustBeDenied() {
+ assertThat(EARLY.get())
+ .as("a tool call during the warm-up/start window must be
guarded, not fall back to the unguarded route")
+ .isInstanceOf(AiToolResult.AuthorizationDenied.class);
+ }
+}
diff --git
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationPolicyTest.java
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationPolicyTest.java
new file mode 100644
index 000000000000..6f6a4183bc1d
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationPolicyTest.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.tool;
+
+import java.util.Map;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.Exchange;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.AuthorizationPolicy;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24831: an {@code authorizationPolicy} on an {@code ai-tool} route
guards the tool call by construction. The
+ * policy runs before the route body; a denial surfaces as {@link
AiToolResult.AuthorizationDenied} (a short refusal),
+ * not as a route result or a rethrow.
+ */
+class AiToolAuthorizationPolicyTest extends CamelTestSupport {
+
+ /**
+ * Allows the call only when the exchange carries {@code subject ==
alice}; otherwise throws
+ * {@link CamelAuthorizationException}, exactly as a real {@link
AuthorizationPolicy} does.
+ */
+ static final class SubjectPolicy implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ // nothing to prepare
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> {
+ if (!"alice".equals(exchange.getProperty("subject",
String.class))) {
+ throw new CamelAuthorizationException("caller is not
authorized", exchange);
+ }
+ processor.process(exchange);
+ };
+ }
+ }
+
+ /** Allows every call (delegates straight to the route). */
+ static final class AllowAll implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return processor::process;
+ }
+ }
+
+ /** Denies by setting the exception on the exchange rather than throwing
(as Spring Security's policy does). */
+ static final class ExceptionDeny implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> exchange.setException(new
CamelAuthorizationException("denied via exchange", exchange));
+ }
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("subjectPolicy", new SubjectPolicy());
+ registry.bind("allowAll", new AllowAll());
+ registry.bind("exceptionDeny", new ExceptionDeny());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ from("ai-tool:guarded"
+ + "?tags=test"
+ + "&description=A guarded tool"
+ + "&authorizationPolicy=#subjectPolicy")
+ .setBody(constant("ok"));
+
+ from("ai-tool:exceptionGuarded"
+ + "?tags=test"
+ + "&description=A tool whose policy sets the exception
instead of throwing"
+ + "&authorizationPolicy=#exceptionDeny")
+ .setBody(constant("ok"));
+
+ from("ai-tool:allowedButFails"
+ + "?tags=test"
+ + "&description=A tool that is allowed but whose route
fails"
+ + "&authorizationPolicy=#allowAll")
+ .throwException(new RuntimeException("route boom"));
+ }
+ };
+ }
+
+ @Test
+ void allowsTheCallWhenThePolicyPasses() {
+ AiToolSpec spec = findSpec("guarded");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setProperty("subject", "alice");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
+
+ assertThat(result).isInstanceOf(AiToolResult.Success.class);
+ assertThat(((AiToolResult.Success) result).value()).isEqualTo("ok");
+ }
+
+ @Test
+ void deniesTheCallAsARefusalWhenThePolicyRejects() {
+ AiToolSpec spec = findSpec("guarded");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setProperty("subject", "mallory");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
+
+ // a denial is its own result type (a short refusal), not a Success
and not a generic ExecutionError
+
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+ assertThat(((AiToolResult.AuthorizationDenied)
result).message()).contains("guarded");
+ }
+
+ @Test
+ void deniesWhenThePolicySetsTheExceptionInsteadOfThrowing() {
+ AiToolSpec spec = findSpec("exceptionGuarded");
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), new
DefaultExchange(context));
+
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+ assertThat(((AiToolResult.AuthorizationDenied)
result).message()).contains("exceptionGuarded");
+ }
+
+ @Test
+ void aNormalRouteErrorIsAnExecutionErrorNotADenial() {
+ // the policy allows, but the route then throws a non-authorization
exception: it must come back as an
+ // ExecutionError, not be misclassified as a denial
+ AiToolSpec spec = findSpec("allowedButFails");
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), new
DefaultExchange(context));
+ assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class);
+ }
+
+ private AiToolSpec findSpec(String toolName) {
+ return
AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream()
+ .filter(s -> toolName.equals(s.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Tool not found: " +
toolName));
+ }
+}
diff --git
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolComponentAuthorizationPolicyTest.java
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolComponentAuthorizationPolicyTest.java
new file mode 100644
index 000000000000..18c81d99aba4
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolComponentAuthorizationPolicyTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.tool;
+
+import java.util.Map;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.AuthorizationPolicy;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24831 "guard by construction": an {@code authorizationPolicy} set on
the ai-tool component guards every tool
+ * route, and a per-endpoint {@code authorizationPolicy} overrides it.
+ */
+class AiToolComponentAuthorizationPolicyTest extends CamelTestSupport {
+
+ static final class SubjectPolicy implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> {
+ if (!"alice".equals(exchange.getProperty("subject",
String.class))) {
+ throw new CamelAuthorizationException("caller is not
authorized", exchange);
+ }
+ processor.process(exchange);
+ };
+ }
+ }
+
+ static final class AllowAll implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return processor::process;
+ }
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext ctx = super.createCamelContext();
+ ctx.getRegistry().bind("allowAll", new AllowAll());
+ // set the policy on the component, so it guards every tool route by
construction
+ AiToolComponent component = ctx.getComponent("ai-tool",
AiToolComponent.class);
+ component.getConfiguration().setAuthorizationPolicy(new
SubjectPolicy());
+ return ctx;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ from("ai-tool:inherits?tags=t&description=Inherits the
component policy")
+ .setBody(constant("ok"));
+
from("ai-tool:overrides?tags=t&description=Overrides&authorizationPolicy=#allowAll")
+ .setBody(constant("ok"));
+ }
+ };
+ }
+
+ @Test
+ void componentPolicyGuardsRoutesWithoutAnEndpointOption() {
+ AiToolSpec spec = findSpec("inherits");
+
+ Exchange bob = new DefaultExchange(context);
+ bob.setProperty("subject", "bob");
+ assertThat(AiToolExecutor.execute(spec, Map.of(), bob))
+ .isInstanceOf(AiToolResult.AuthorizationDenied.class);
+
+ Exchange alice = new DefaultExchange(context);
+ alice.setProperty("subject", "alice");
+ assertThat(AiToolExecutor.execute(spec, Map.of(), alice))
+ .isInstanceOf(AiToolResult.Success.class);
+ }
+
+ @Test
+ void endpointPolicyOverridesTheComponentPolicy() {
+ // bob would be denied by the component policy, but the endpoint
overrides with allow-all
+ AiToolSpec spec = findSpec("overrides");
+ Exchange bob = new DefaultExchange(context);
+ bob.setProperty("subject", "bob");
+ assertThat(AiToolExecutor.execute(spec, Map.of(), bob))
+ .isInstanceOf(AiToolResult.Success.class);
+ }
+
+ private AiToolSpec findSpec(String toolName) {
+ return AiToolRegistry.getOrCreate(context).getToolsByTag("t").stream()
+ .filter(s -> toolName.equals(s.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Tool not found: " +
toolName));
+ }
+}
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
index d82c6da90bb7..87b135a1bfb8 100644
---
a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
+++
b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
@@ -484,6 +484,10 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
} else if (result instanceof AiToolResult.ArgumentError error) {
LOG.warn("Tool '{}' argument error: {}", toolName,
error.message(), error.cause());
return "Invalid arguments: " + error.message();
+ } else if (result instanceof AiToolResult.AuthorizationDenied denied) {
+ // A denial is expected control flow: relay the refusal to the
model instead of rethrowing.
+ LOG.warn("Tool '{}' call denied by authorization policy",
toolName);
+ return denied.message();
} else if (result instanceof AiToolResult.ExecutionError error) {
// Rethrow so LangChain4j's error handling machinery
// (ToolExecutionErrorHandler, compensateOnToolErrors) can fire.
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 2bdc6750fed0..1f39b1083546 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
@@ -386,7 +386,17 @@ public class McpServerBridge extends ServiceSupport
implements CamelContextAware
}
private McpServerTool createTool(AiToolSpec spec) {
- McpToolCallHandler handler = arguments -> execute(spec, arguments);
+ McpToolCallHandler handler = new McpToolCallHandler() {
+ @Override
+ public McpToolCallResult call(Map<String, Object> arguments) {
+ return execute(spec, arguments, null);
+ }
+
+ @Override
+ public McpToolCallResult call(Map<String, Object> arguments,
McpToolCallContext context) {
+ return execute(spec, arguments, context != null ?
context.securityPrincipal() : null);
+ }
+ };
return new McpServerTool() {
@Override
public String name() {
@@ -425,8 +435,13 @@ public class McpServerBridge extends ServiceSupport
implements CamelContextAware
};
}
- private McpToolCallResult execute(AiToolSpec spec, Map<String, Object>
arguments) {
+ private McpToolCallResult execute(AiToolSpec spec, Map<String, Object>
arguments, Object securityPrincipal) {
Exchange exchange = spec.getConsumer().getEndpoint().createExchange();
+ if (securityPrincipal != null) {
+ // carry the transport's authenticated caller onto the tool
exchange so a tool route's AuthorizationPolicy
+ // can authorize on it; it is an exchange property, which the
model cannot set
+
exchange.setProperty(McpToolCallContext.SECURITY_PRINCIPAL_PROPERTY,
securityPrincipal);
+ }
boolean release = true;
try {
Future<AiToolResult> future = executor.submit(() ->
AiToolExecutor.execute(spec, arguments, exchange));
@@ -454,6 +469,10 @@ public class McpServerBridge extends ServiceSupport
implements CamelContextAware
return new McpToolCallResult(success.value(), false,
success.structuredContent());
} else if (result instanceof AiToolResult.ArgumentError error) {
return new McpToolCallResult(error.message(), true);
+ } else if (result instanceof AiToolResult.AuthorizationDenied
denied) {
+ // The denial message is already generic and caller-safe, so
it is safe to return to the MCP client.
+ LOG.warn("MCP tool '{}' call denied by authorization policy",
spec.getName());
+ return new McpToolCallResult(denied.message(), true);
} else {
AiToolResult.ExecutionError error =
(AiToolResult.ExecutionError) result;
// never leak raw route exception messages to remote MCP
clients
diff --git
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallContext.java
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallContext.java
new file mode 100644
index 000000000000..5c9f6994f858
--- /dev/null
+++
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallContext.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.mcp.server;
+
+/**
+ * Transport-supplied context for a single MCP tool call, carrying the
caller's authenticated security principal when
+ * the transport can determine one. An engine builds it from its transport
(for the Vert.x streamable HTTP transport,
+ * from the authenticated {@code RoutingContext} user) and passes it to
+ * {@link McpToolCallHandler#call(java.util.Map, McpToolCallContext)}; the
bridge then stamps the principal onto the
+ * tool exchange as the {@code CamelMcpSecurityPrincipal} exchange property so
a tool route's
+ * {@link org.apache.camel.spi.AuthorizationPolicy} can authorize on it.
+ * <p>
+ * The principal is an opaque {@link Object} (for example an {@code
io.vertx.ext.auth.User}) so this transport-agnostic
+ * API does not depend on any transport library.
+ *
+ * @param securityPrincipal the authenticated caller principal, or {@code
null} when the transport has none
+ * @since 4.23
+ */
+public record McpToolCallContext(Object securityPrincipal) {
+
+ /**
+ * An empty context: no transport-supplied caller principal.
+ */
+ public static final McpToolCallContext EMPTY = new
McpToolCallContext(null);
+
+ /**
+ * Exchange property under which the bridge stamps {@link
#securityPrincipal()} on the tool exchange, so a tool
+ * route (or its {@link org.apache.camel.spi.AuthorizationPolicy}) can
read the MCP caller identity via
+ * {@code exchangeProperty.CamelMcpSecurityPrincipal}.
+ */
+ public static final String SECURITY_PRINCIPAL_PROPERTY =
"CamelMcpSecurityPrincipal";
+}
diff --git
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java
index 2d429bec4af2..ff4b53575462 100644
---
a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java
+++
b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallHandler.java
@@ -37,4 +37,21 @@ public interface McpToolCallHandler {
* @return the sanitized result, never null
*/
McpToolCallResult call(Map<String, Object> arguments);
+
+ /**
+ * Invokes the tool with the given arguments and the transport-supplied
caller context, so a tool route's
+ * {@link org.apache.camel.spi.AuthorizationPolicy} can authorize on the
caller's identity. The default ignores the
+ * context and calls {@link #call(Map)}; the bridge overrides it to stamp
the caller principal onto the tool
+ * exchange. Engines that can determine a caller identity (such as the
Vert.x streamable HTTP transport) call this
+ * overload; others keep calling {@link #call(Map)}.
+ *
+ * @param arguments the tool arguments as parsed from the MCP {@code
tools/call} request, never null
+ * @param context the transport-supplied caller context, never null
(use {@link McpToolCallContext#EMPTY} when
+ * there is no caller identity)
+ * @return the sanitized result, never null
+ * @since 4.23
+ */
+ default McpToolCallResult call(Map<String, Object> arguments,
McpToolCallContext context) {
+ return call(arguments);
+ }
}
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 7da999cddc6b..78b1551fe478 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
@@ -37,6 +37,7 @@ import org.apache.camel.component.mcp.server.McpServerIcon;
import org.apache.camel.component.mcp.server.McpServerInfo;
import org.apache.camel.component.mcp.server.McpServerResource;
import org.apache.camel.component.mcp.server.McpServerTool;
+import org.apache.camel.component.mcp.server.McpToolCallContext;
import org.apache.camel.component.mcp.server.McpToolCallResult;
import org.apache.camel.component.platform.http.PlatformHttpComponent;
import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpRouter;
@@ -66,6 +67,12 @@ public class VertxMcpServerEngine extends ServiceSupport
implements McpServerEng
""";
private static final String APPLICATION_JSON = "application/json";
+ /**
+ * Key under which {@link VertxMcpStreamableServerTransportProvider}
stashes the authenticated caller principal in
+ * the MCP transport context, so the tool-call handler reads it back via
{@code exchange.transportContext()}.
+ */
+ static final String TRANSPORT_PRINCIPAL_KEY = "CamelMcpTransportPrincipal";
+
private CamelContext camelContext;
private McpServerInfo info;
private McpJsonMapper jsonMapper;
@@ -159,7 +166,11 @@ public class VertxMcpServerEngine extends ServiceSupport
implements McpServerEng
.tool(mcpTool)
.callHandler((exchange, request) -> {
Map<String, Object> arguments = request.arguments() !=
null ? request.arguments() : Map.of();
- McpToolCallResult result = tool.handler().call(arguments);
+ Object principal = exchange.transportContext() != null
+ ?
exchange.transportContext().get(TRANSPORT_PRINCIPAL_KEY) : null;
+ McpToolCallContext context = principal != null
+ ? new McpToolCallContext(principal) :
McpToolCallContext.EMPTY;
+ McpToolCallResult result = tool.handler().call(arguments,
context);
McpSchema.CallToolResult.Builder builder =
McpSchema.CallToolResult.builder()
.addTextContent(result.text())
.isError(result.isError());
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 7ef1cf0a2ffe..9c6b6805ffe5 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
@@ -19,10 +19,12 @@ package org.apache.camel.component.mcp.server.vertx;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.spec.HttpHeaders;
@@ -35,6 +37,7 @@ import io.vertx.core.Context;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpServerResponse;
+import io.vertx.ext.auth.User;
import io.vertx.ext.web.Route;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.BodyHandler;
@@ -242,8 +245,11 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
endWithStatus(connection, ctx, 202);
} else if (message instanceof McpSchema.JSONRPCRequest request) {
VertxMcpSessionTransport transport = startSseResponse(ctx,
connection, sessionId);
+ McpTransportContext transportContext = toTransportContext(ctx);
try {
- managed.session.responseStream(request, transport).block();
+ managed.session.responseStream(request, transport)
+ .contextWrite(reactorCtx ->
reactorCtx.put(McpTransportContext.KEY, transportContext))
+ .block();
} catch (Exception e) {
LOG.warn("Failed to handle MCP request stream: {}",
e.getMessage());
transport.close();
@@ -254,6 +260,20 @@ public class VertxMcpStreamableServerTransportProvider
implements McpStreamableS
}
}
+ /**
+ * Builds the MCP transport context carrying the authenticated caller
principal (when the HTTP request was
+ * authenticated), so a tool route invoked by this request can authorize
on it. The principal is the Vert.x
+ * {@link User} that the platform-http authentication handler set on the
routing context; absent it, the context is
+ * empty.
+ */
+ private static McpTransportContext toTransportContext(RoutingContext ctx) {
+ User user = ctx.user();
+ if (user == null) {
+ return McpTransportContext.EMPTY;
+ }
+ return
McpTransportContext.create(Map.of(VertxMcpServerEngine.TRANSPORT_PRINCIPAL_KEY,
user));
+ }
+
private void handleInitialize(RoutingContext ctx, Context connection,
McpSchema.JSONRPCRequest request)
throws Exception {
McpSchema.InitializeRequest initializeRequest
diff --git
a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainAuthenticationTest.java
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainAuthenticationTest.java
index a15a6eccdaa6..90229bc1121c 100644
---
a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainAuthenticationTest.java
+++
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/main/McpServerMainAuthenticationTest.java
@@ -28,8 +28,14 @@ import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import
io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mcp.server.McpToolCallContext;
import org.apache.camel.main.Main;
+import org.apache.camel.spi.AuthorizationPolicy;
import org.apache.camel.test.AvailablePortFinder;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
@@ -62,8 +68,18 @@ class McpServerMainAuthenticationTest {
from("ai-tool:say_hello?tags=secured&description=Say hello"
+ "¶meter.name=string¶meter.name.required=true")
.setBody(simple("Hello ${header.name}"));
+
+ from("ai-tool:whoami?tags=secured&description=Report whether
the caller is authenticated")
+ .process(e -> {
+ Object principal =
e.getProperty(McpToolCallContext.SECURITY_PRINCIPAL_PROPERTY);
+ e.getMessage().setBody(principal != null ?
"principal-present" : "no-principal");
+ });
+
+ from("ai-tool:blocked?tags=secured&description=Always
denied&authorizationPolicy=#deny")
+ .setBody(constant("SHOULD-NOT-RUN"));
}
});
+ main.bind("deny", new DenyAll());
main.addInitialProperty("camel.server.enabled", "true");
main.addInitialProperty("camel.server.port", String.valueOf(PORT));
main.addInitialProperty("camel.server.authentication-enabled", "true");
@@ -127,6 +143,75 @@ class McpServerMainAuthenticationTest {
}
}
+ @Test
+ void testMcpToolCallCarriesAuthenticatedPrincipalToTheToolRoute() {
+ // CAMEL-24831: the authenticated caller (the Vert.x basic-auth user)
must reach the tool route over MCP as the
+ // CamelMcpSecurityPrincipal exchange property, so a tool route's
AuthorizationPolicy can authorize on it.
+ String credentials =
Base64.getEncoder().encodeToString("camel:mcpPass".getBytes(UTF_8));
+ McpSyncClient client = null;
+ try {
+ client =
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" +
PORT)
+ .httpRequestCustomizer((builder, method, uri, body,
context) -> builder
+ .header("Authorization", "Basic " + credentials))
+ .build())
+ .requestTimeout(Duration.ofSeconds(10))
+ .initializationTimeout(Duration.ofSeconds(10))
+ .build();
+
+ client.initialize();
+
+ McpSchema.CallToolResult result
+ = client.callTool(new McpSchema.CallToolRequest("whoami",
Map.of()));
+ assertThat(result.isError()).isNotEqualTo(Boolean.TRUE);
+
assertThat(result.content().toString()).contains("principal-present");
+ } finally {
+ if (client != null) {
+ client.closeGracefully();
+ }
+ }
+ }
+
+ @Test
+ void testMcpToolCallDeniedByPolicyIsReturnedAsError() {
+ // CAMEL-24831: a tool guarded by a deny-all authorizationPolicy,
called over MCP, comes back as an error
+ // (the model-relayable refusal), and the route body does not run.
+ String credentials =
Base64.getEncoder().encodeToString("camel:mcpPass".getBytes(UTF_8));
+ McpSyncClient client = null;
+ try {
+ client =
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" +
PORT)
+ .httpRequestCustomizer((builder, method, uri, body,
context) -> builder
+ .header("Authorization", "Basic " + credentials))
+ .build())
+ .requestTimeout(Duration.ofSeconds(10))
+ .initializationTimeout(Duration.ofSeconds(10))
+ .build();
+ client.initialize();
+
+ McpSchema.CallToolResult result
+ = client.callTool(new McpSchema.CallToolRequest("blocked",
Map.of()));
+ assertThat(result.isError()).isEqualTo(Boolean.TRUE);
+ assertThat(result.content().toString()).contains("blocked");
+ } finally {
+ if (client != null) {
+ client.closeGracefully();
+ }
+ }
+ }
+
+ /** Denies every call, so a tool guarded by it must come back to the MCP
client as an error. */
+ static final class DenyAll implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> {
+ throw new CamelAuthorizationException("denied", exchange);
+ };
+ }
+ }
+
private static HttpRequest.Builder mcpRequest() {
return HttpRequest.newBuilder(URI.create("http://localhost:" + PORT +
"/mcp"))
.header("Accept", "application/json, text/event-stream");
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
index 54ccc06e2987..40fbf92d2e24 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/McpToolCallExecutor.java
@@ -290,6 +290,10 @@ class McpToolCallExecutor extends ServiceSupport {
} else if (result instanceof AiToolResult.ArgumentError error)
{
LOG.warn("Route tool '{}' argument error: {}", toolName,
error.message());
return errorResult(toolCall, "Error: invalid tool
arguments: " + error.message());
+ } else if (result instanceof AiToolResult.AuthorizationDenied
denied) {
+ // A denial is expected control flow: always relay the
refusal to the model, never fail the exchange.
+ LOG.warn("Route tool '{}' call denied by authorization
policy", toolName);
+ return errorResult(toolCall, denied.message());
} else {
AiToolResult.ExecutionError error =
(AiToolResult.ExecutionError) result;
if (config.getToolExecutionErrorStrategy() ==
ToolExecutionErrorStrategy.FAIL_EXCHANGE) {
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
index e4992f29e30c..713dc5f38dee 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/AiToolSpecToSpringAi.java
@@ -49,6 +49,10 @@ final class AiToolSpecToSpringAi {
return success.value();
} else if (result instanceof AiToolResult.ArgumentError
argErr) {
return "Tool execution failed: " + argErr.message();
+ } else if (result instanceof AiToolResult.AuthorizationDenied
denied) {
+ // A denial is expected control flow: relay the refusal to
the model.
+ LOG.warn("Tool '{}' call denied by authorization policy",
spec.getName());
+ return denied.message();
} else if (result instanceof AiToolResult.ExecutionError
execErr) {
LOG.warn("Tool '{}' execution failed: {}", spec.getName(),
execErr.message(), execErr.cause());
return "Tool execution failed";
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 17b251975ef4..c0db19ae1876 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -4413,3 +4413,12 @@ Property placeholders are kept as written instead of
being resolved.
In return, Java is also a target format, and rests, route templates, route
configurations and beans are converted
instead of only routes. A new `notes` field of the result lists what differs
or is not carried over.
+
+=== camel-ai-tool - new AiToolResult.AuthorizationDenied result variant (SPI)
+
+`org.apache.camel.component.ai.tool.AiToolResult` is a sealed interface and
gained a new variant,
+`AiToolResult.AuthorizationDenied`, returned when an `ai-tool` route's
`authorizationPolicy` denies a call. Code that
+consumes `AiToolResult` and handles its variants exhaustively — for example an
AI adapter whose final branch casts to
+`AiToolResult.ExecutionError` — must add a branch for `AuthorizationDenied`
and relay its `message()` to the model as
+a refusal (do not rethrow). The in-tree adapters (camel-openai,
camel-spring-ai-chat, camel-langchain4j-agent and the
+MCP server bridge) already handle it.
diff --git
a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java
b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java
index 9d3eea663f1c..658e4f8d9102 100644
---
a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java
+++
b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java
@@ -343,6 +343,33 @@ public interface AiToolComponentBuilderFactory {
doSetProperty("autowiredEnabled", autowiredEnabled);
return this;
}
+
+ /**
+ * Reference to an org.apache.camel.spi.AuthorizationPolicy used to
+ * authorize tool calls before the route runs. Set it on the component
+ * to guard every tool route by construction, or per endpoint to
+ * override. The policy authorizes on trustworthy input only: the tool
+ * name comes from the route (never from model output), and the caller
+ * identity is carried as an exchange property set before the agent ran
+ * (for example by camel-spiffe or camel-keycloak), which the model
+ * cannot set. Authorize on exchange properties or validated tokens
+ * only, never on message headers (on a tool route the headers carry
the
+ * model-controlled tool arguments). A denied call surfaces to the
model
+ * as a short refusal rather than a stack trace.
+ *
+ * The option is a:
+ * <code>org.apache.camel.spi.AuthorizationPolicy</code>
+ * type.
+ *
+ * Group: security
+ *
+ * @param authorizationPolicy the value to set
+ * @return the dsl builder
+ */
+ default AiToolComponentBuilder
authorizationPolicy(org.apache.camel.spi.AuthorizationPolicy
authorizationPolicy) {
+ doSetProperty("authorizationPolicy", authorizationPolicy);
+ return this;
+ }
}
class AiToolComponentBuilderImpl
@@ -379,6 +406,7 @@ public interface AiToolComponentBuilderFactory {
case "tags": getOrCreateConfiguration((AiToolComponent)
component).setTags((java.lang.String) value); return true;
case "title": getOrCreateConfiguration((AiToolComponent)
component).setTitle((java.lang.String) value); return true;
case "autowiredEnabled": ((AiToolComponent)
component).setAutowiredEnabled((boolean) value); return true;
+ case "authorizationPolicy":
getOrCreateConfiguration((AiToolComponent)
component).setAuthorizationPolicy((org.apache.camel.spi.AuthorizationPolicy)
value); return true;
default: return false;
}
}
diff --git
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java
index 6813dc670ba4..9b46adc52f2e 100644
---
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java
+++
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java
@@ -393,6 +393,56 @@ public interface AiToolEndpointBuilderFactory {
doSetProperty("title", title);
return this;
}
+ /**
+ * Reference to an org.apache.camel.spi.AuthorizationPolicy used to
+ * authorize tool calls before the route runs. Set it on the component
+ * to guard every tool route by construction, or per endpoint to
+ * override. The policy authorizes on trustworthy input only: the tool
+ * name comes from the route (never from model output), and the caller
+ * identity is carried as an exchange property set before the agent ran
+ * (for example by camel-spiffe or camel-keycloak), which the model
+ * cannot set. Authorize on exchange properties or validated tokens
+ * only, never on message headers (on a tool route the headers carry
the
+ * model-controlled tool arguments). A denied call surfaces to the
model
+ * as a short refusal rather than a stack trace.
+ *
+ * The option is a:
+ * <code>org.apache.camel.spi.AuthorizationPolicy</code> type.
+ *
+ * Group: security
+ *
+ * @param authorizationPolicy the value to set
+ * @return the dsl builder
+ */
+ default AiToolEndpointBuilder
authorizationPolicy(org.apache.camel.spi.AuthorizationPolicy
authorizationPolicy) {
+ doSetProperty("authorizationPolicy", authorizationPolicy);
+ return this;
+ }
+ /**
+ * Reference to an org.apache.camel.spi.AuthorizationPolicy used to
+ * authorize tool calls before the route runs. Set it on the component
+ * to guard every tool route by construction, or per endpoint to
+ * override. The policy authorizes on trustworthy input only: the tool
+ * name comes from the route (never from model output), and the caller
+ * identity is carried as an exchange property set before the agent ran
+ * (for example by camel-spiffe or camel-keycloak), which the model
+ * cannot set. Authorize on exchange properties or validated tokens
+ * only, never on message headers (on a tool route the headers carry
the
+ * model-controlled tool arguments). A denied call surfaces to the
model
+ * as a short refusal rather than a stack trace.
+ *
+ * The option will be converted to a
+ * <code>org.apache.camel.spi.AuthorizationPolicy</code> type.
+ *
+ * Group: security
+ *
+ * @param authorizationPolicy the value to set
+ * @return the dsl builder
+ */
+ default AiToolEndpointBuilder authorizationPolicy(String
authorizationPolicy) {
+ doSetProperty("authorizationPolicy", authorizationPolicy);
+ return this;
+ }
}
/**