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 cc762d832432 CAMEL-25405: camel-mcp-server - expose a runtime-neutral 
MCP principal name and roles (#27767)
cc762d832432 is described below

commit cc762d8324328884cc1609d661d06dd5e871de55
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 14 13:47:16 2026 +0200

    CAMEL-25405: camel-mcp-server - expose a runtime-neutral MCP principal name 
and roles (#27767)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../camel/catalog/docs/ai-tool-component.adoc      |  15 ++-
 .../src/main/docs/ai-tool-component.adoc           |  15 ++-
 .../component/mcp/server/McpServerBridge.java      |  31 ++++--
 .../component/mcp/server/McpToolCallContext.java   |  69 +++++++++++--
 .../mcp/server/vertx/VertxMcpPrincipal.java        | 105 ++++++++++++++++++++
 .../mcp/server/vertx/VertxMcpServerEngine.java     |  20 +++-
 .../main/McpServerMainAuthenticationTest.java      |  71 +++++++++++++
 .../mcp/server/vertx/VertxMcpPrincipalTest.java    | 110 +++++++++++++++++++++
 8 files changed, 411 insertions(+), 25 deletions(-)

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 2ddc6ff82265..a57c415026cd 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
@@ -444,10 +444,17 @@ Which runtimes carry the caller identity:
   <<The calling exchange>> above), so the identity property reaches the tool 
route.
 * 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.
+  server, an `io.vertx.ext.auth.User`). Alongside it the server stamps a 
runtime-neutral view derived from that
+  principal — `CamelMcpSecurityPrincipalName` (a `String`) and 
`CamelMcpSecurityPrincipalRoles` (a
+  `Collection<String>`) — so a tool route or a policy can authorize on a 
stable name and roles without knowing the
+  runtime type. Only the Vert.x streamable HTTP server (Camel Main / JBang) 
populates these today. There the name is
+  the user subject (or the `username`/`sub` claim), and the roles come from 
the authenticated token's `roles`, Keycloak
+  `realm_access.roles`, or `groups` claim (JWT/OIDC); a basic-auth caller 
(camel-main property-file auth) carries no
+  roles in its principal, so `CamelMcpSecurityPrincipalRoles` is not set for 
it. Each property is set only when the
+  server could determine it, so treat a missing name or roles as unauthorized. 
Camel's shipped identity/token
+  policies (Keycloak, Spring Security, Shiro) do not consume these 
automatically yet, so MCP authorization still needs
+  a policy (or route guard) that inspects 
`CamelMcpSecurityPrincipalName`/`CamelMcpSecurityPrincipalRoles` or the raw
+  `CamelMcpSecurityPrincipal`.
 
 === Observing denials
 
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 2ddc6ff82265..a57c415026cd 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
@@ -444,10 +444,17 @@ Which runtimes carry the caller identity:
   <<The calling exchange>> above), so the identity property reaches the tool 
route.
 * 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.
+  server, an `io.vertx.ext.auth.User`). Alongside it the server stamps a 
runtime-neutral view derived from that
+  principal — `CamelMcpSecurityPrincipalName` (a `String`) and 
`CamelMcpSecurityPrincipalRoles` (a
+  `Collection<String>`) — so a tool route or a policy can authorize on a 
stable name and roles without knowing the
+  runtime type. Only the Vert.x streamable HTTP server (Camel Main / JBang) 
populates these today. There the name is
+  the user subject (or the `username`/`sub` claim), and the roles come from 
the authenticated token's `roles`, Keycloak
+  `realm_access.roles`, or `groups` claim (JWT/OIDC); a basic-auth caller 
(camel-main property-file auth) carries no
+  roles in its principal, so `CamelMcpSecurityPrincipalRoles` is not set for 
it. Each property is set only when the
+  server could determine it, so treat a missing name or roles as unauthorized. 
Camel's shipped identity/token
+  policies (Keycloak, Spring Security, Shiro) do not consume these 
automatically yet, so MCP authorization still needs
+  a policy (or route guard) that inspects 
`CamelMcpSecurityPrincipalName`/`CamelMcpSecurityPrincipalRoles` or the raw
+  `CamelMcpSecurityPrincipal`.
 
 === Observing denials
 
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 1f39b1083546..6c18d8d82da5 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,6 +16,7 @@
  */
 package org.apache.camel.component.mcp.server;
 
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
@@ -394,7 +395,7 @@ public class McpServerBridge extends ServiceSupport 
implements CamelContextAware
 
             @Override
             public McpToolCallResult call(Map<String, Object> arguments, 
McpToolCallContext context) {
-                return execute(spec, arguments, context != null ? 
context.securityPrincipal() : null);
+                return execute(spec, arguments, context);
             }
         };
         return new McpServerTool() {
@@ -435,12 +436,10 @@ public class McpServerBridge extends ServiceSupport 
implements CamelContextAware
         };
     }
 
-    private McpToolCallResult execute(AiToolSpec spec, Map<String, Object> 
arguments, Object securityPrincipal) {
+    private McpToolCallResult execute(AiToolSpec spec, Map<String, Object> 
arguments, McpToolCallContext context) {
         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);
+        if (context != null) {
+            stampCallerIdentity(exchange, context);
         }
         boolean release = true;
         try {
@@ -486,6 +485,26 @@ public class McpServerBridge extends ServiceSupport 
implements CamelContextAware
         }
     }
 
+    /**
+     * Stamps the MCP caller identity onto the tool exchange as exchange 
properties the model cannot set: the raw
+     * transport principal, and the runtime-neutral name and roles when the 
engine could derive them. A tool route's
+     * {@link org.apache.camel.spi.AuthorizationPolicy} authorizes on these.
+     */
+    private static void stampCallerIdentity(Exchange exchange, 
McpToolCallContext context) {
+        Object principal = context.securityPrincipal();
+        if (principal != null) {
+            
exchange.setProperty(McpToolCallContext.SECURITY_PRINCIPAL_PROPERTY, principal);
+        }
+        String name = context.principalName();
+        if (name != null && !name.isBlank()) {
+            
exchange.setProperty(McpToolCallContext.SECURITY_PRINCIPAL_NAME_PROPERTY, name);
+        }
+        Collection<String> roles = context.principalRoles();
+        if (roles != null && !roles.isEmpty()) {
+            
exchange.setProperty(McpToolCallContext.SECURITY_PRINCIPAL_ROLES_PROPERTY, 
roles);
+        }
+    }
+
     private boolean matchesTag(String tag) {
         return tag != null && PatternHelper.matchSimplePatterns(tag, 
tagPatterns);
     }
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
index 5c9f6994f858..1f5c1208bac5 100644
--- 
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
@@ -16,26 +16,37 @@
  */
 package org.apache.camel.component.mcp.server;
 
+import java.util.Collection;
+import java.util.List;
+
 /**
  * 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.
+ * the transport can determine one, together with a runtime-neutral view of 
it. 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 and its
+ * neutral view onto the tool exchange as exchange properties so a tool route's
+ * {@link org.apache.camel.spi.AuthorizationPolicy} can authorize on them.
  * <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.
+ * The raw {@link #securityPrincipal()} 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. A 
policy can read it directly, but only when it
+ * knows the runtime type. The {@link #principalName()} and {@link 
#principalRoles()} (both since 4.24) are the
+ * runtime-neutral view the engine derives from that principal, so a route or 
the shipped identity policies can
+ * authorize on a stable name and roles regardless of the MCP server runtime.
  *
  * @param securityPrincipal the authenticated caller principal, or {@code 
null} when the transport has none
+ * @param principalName     the runtime-neutral caller name derived from the 
principal, or {@code null} when there is no
+ *                          principal or the transport cannot determine a name
+ * @param principalRoles    the runtime-neutral caller role names derived from 
the principal, or {@code null} when there
+ *                          is no principal or the transport cannot determine 
any roles; when present it is an immutable
+ *                          copy and never contains {@code null}
  * @since                   4.23
  */
-public record McpToolCallContext(Object securityPrincipal) {
+public record McpToolCallContext(Object securityPrincipal, String 
principalName, Collection<String> principalRoles) {
 
     /**
      * An empty context: no transport-supplied caller principal.
      */
-    public static final McpToolCallContext EMPTY = new 
McpToolCallContext(null);
+    public static final McpToolCallContext EMPTY = new 
McpToolCallContext(null, null, null);
 
     /**
      * Exchange property under which the bridge stamps {@link 
#securityPrincipal()} on the tool exchange, so a tool
@@ -43,4 +54,44 @@ public record McpToolCallContext(Object securityPrincipal) {
      * {@code exchangeProperty.CamelMcpSecurityPrincipal}.
      */
     public static final String SECURITY_PRINCIPAL_PROPERTY = 
"CamelMcpSecurityPrincipal";
+
+    /**
+     * Exchange property under which the bridge stamps {@link 
#principalName()} on the tool exchange: the
+     * runtime-neutral caller name, so a tool route (or its {@link 
org.apache.camel.spi.AuthorizationPolicy}) can
+     * authorize on a stable identity via {@code 
exchangeProperty.CamelMcpSecurityPrincipalName}, regardless of the MCP
+     * server runtime. Not set when there is no principal or no name could be 
determined.
+     *
+     * @since 4.24
+     */
+    public static final String SECURITY_PRINCIPAL_NAME_PROPERTY = 
"CamelMcpSecurityPrincipalName";
+
+    /**
+     * Exchange property under which the bridge stamps {@link 
#principalRoles()} on the tool exchange: the
+     * runtime-neutral caller role names (a {@code Collection<String>}), so a 
tool route (or its
+     * {@link org.apache.camel.spi.AuthorizationPolicy}) can authorize on 
stable roles via
+     * {@code exchangeProperty.CamelMcpSecurityPrincipalRoles}, regardless of 
the MCP server runtime. Not set when there
+     * is no principal or no roles could be determined.
+     *
+     * @since 4.24
+     */
+    public static final String SECURITY_PRINCIPAL_ROLES_PROPERTY = 
"CamelMcpSecurityPrincipalRoles";
+
+    /**
+     * Canonical constructor; defensively copies the roles into an immutable 
collection so the stamped exchange property
+     * cannot be mutated through the caller's reference.
+     */
+    public McpToolCallContext {
+        if (principalRoles != null) {
+            principalRoles = List.copyOf(principalRoles);
+        }
+    }
+
+    /**
+     * Creates a context with only the raw principal and no runtime-neutral 
name or roles.
+     *
+     * @param securityPrincipal the authenticated caller principal, or {@code 
null} when the transport has none
+     */
+    public McpToolCallContext(Object securityPrincipal) {
+        this(securityPrincipal, null, null);
+    }
 }
diff --git 
a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpPrincipal.java
 
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpPrincipal.java
new file mode 100644
index 000000000000..51f665b3af80
--- /dev/null
+++ 
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpPrincipal.java
@@ -0,0 +1,105 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.auth.User;
+
+/**
+ * Derives a runtime-neutral view — a caller name and role names — from a 
Vert.x {@link User}, so the MCP bridge can
+ * stamp a stable caller identity ({@code CamelMcpSecurityPrincipalName} / 
{@code CamelMcpSecurityPrincipalRoles}) that
+ * a tool route or its {@link org.apache.camel.spi.AuthorizationPolicy} can 
authorize on without depending on Vert.x.
+ */
+final class VertxMcpPrincipal {
+
+    private VertxMcpPrincipal() {
+    }
+
+    /**
+     * The caller name: the Vert.x {@link User#subject() subject} when 
non-null and non-blank, else the {@code username}
+     * or {@code sub} claim of the principal when non-null and non-blank, else 
{@code null}.
+     */
+    static String name(User user) {
+        if (user == null) {
+            return null;
+        }
+        String subject = user.subject();
+        if (isSet(subject)) {
+            return subject;
+        }
+        JsonObject principal = user.principal();
+        if (principal != null) {
+            String username = principal.getString("username");
+            if (isSet(username)) {
+                return username;
+            }
+            String sub = principal.getString("sub");
+            if (isSet(sub)) {
+                return sub;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * The caller roles, read from the authenticated token's claims copied 
into the principal, in order: a top-level
+     * {@code roles} array, the Keycloak {@code realm_access.roles} array, or 
a {@code groups} array. A claim that is
+     * present but not a JSON array (for example a single-valued {@code 
"roles":"admin"}, or a non-object
+     * {@code realm_access}) is ignored rather than cast, so a token in that 
shape cannot fail the call. Returns
+     * {@code null} when the principal carries no usable roles, so the bridge 
does not stamp an empty roles property;
+     * blank and non-string entries are skipped.
+     */
+    static List<String> roles(User user) {
+        if (user == null) {
+            return null;
+        }
+        JsonObject principal = user.principal();
+        if (principal == null) {
+            return null;
+        }
+        // Read defensively: a token may carry these claims with an unexpected 
type (a single-valued
+        // "roles":"admin" string, or a non-object "realm_access"). The Vert.x 
JWT provider copies every
+        // non-reserved claim into principal(), so casting via 
getJsonArray/getJsonObject would throw a
+        // ClassCastException and fail the tool call before it runs. Ignore 
any shape that is not a JSON array.
+        Object value = principal.getValue("roles");
+        if (!(value instanceof JsonArray)) {
+            Object realmAccess = principal.getValue("realm_access");
+            value = realmAccess instanceof JsonObject jo ? 
jo.getValue("roles") : null;
+        }
+        if (!(value instanceof JsonArray)) {
+            value = principal.getValue("groups");
+        }
+        if (!(value instanceof JsonArray roles) || roles.isEmpty()) {
+            return null;
+        }
+        List<String> answer = new ArrayList<>(roles.size());
+        for (Object role : roles) {
+            if (role instanceof String s && isSet(s)) {
+                answer.add(s);
+            }
+        }
+        return answer.isEmpty() ? null : answer;
+    }
+
+    private static boolean isSet(String value) {
+        return value != null && !value.isBlank();
+    }
+}
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 78b1551fe478..a27494e33bca 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
@@ -28,6 +28,7 @@ import io.modelcontextprotocol.server.McpServerFeatures;
 import io.modelcontextprotocol.server.McpSyncServer;
 import io.modelcontextprotocol.spec.McpError;
 import io.modelcontextprotocol.spec.McpSchema;
+import io.vertx.ext.auth.User;
 import org.apache.camel.CamelContext;
 import org.apache.camel.component.ai.tool.AiToolAnnotations;
 import org.apache.camel.component.mcp.server.McpResourceReadResult;
@@ -168,8 +169,7 @@ public class VertxMcpServerEngine extends ServiceSupport 
implements McpServerEng
                     Map<String, Object> arguments = request.arguments() != 
null ? request.arguments() : Map.of();
                     Object principal = exchange.transportContext() != null
                             ? 
exchange.transportContext().get(TRANSPORT_PRINCIPAL_KEY) : null;
-                    McpToolCallContext context = principal != null
-                            ? new McpToolCallContext(principal) : 
McpToolCallContext.EMPTY;
+                    McpToolCallContext context = toToolCallContext(principal);
                     McpToolCallResult result = tool.handler().call(arguments, 
context);
                     McpSchema.CallToolResult.Builder builder = 
McpSchema.CallToolResult.builder()
                             .addTextContent(result.text())
@@ -184,6 +184,22 @@ public class VertxMcpServerEngine extends ServiceSupport 
implements McpServerEng
         LOG.debug("MCP tool added: {}", tool.name());
     }
 
+    /**
+     * Builds the tool-call context from the transport principal, deriving a 
runtime-neutral name and roles when it is a
+     * Vert.x {@link User} so a tool route or its authorization policy can 
authorize on a stable identity without
+     * depending on Vert.x. A non-{@code User} principal is still carried as 
the raw object, and {@code null} yields the
+     * empty context.
+     */
+    private static McpToolCallContext toToolCallContext(Object principal) {
+        if (principal instanceof User user) {
+            return new McpToolCallContext(user, VertxMcpPrincipal.name(user), 
VertxMcpPrincipal.roles(user));
+        }
+        if (principal != null) {
+            return new McpToolCallContext(principal);
+        }
+        return McpToolCallContext.EMPTY;
+    }
+
     @Override
     public void toolRemoved(String toolName) {
         try {
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 90229bc1121c..c25b8289ef11 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
@@ -75,6 +75,18 @@ class McpServerMainAuthenticationTest {
                             e.getMessage().setBody(principal != null ? 
"principal-present" : "no-principal");
                         });
 
+                from("ai-tool:whoami_name?tags=secured&description=Report the 
runtime-neutral caller name")
+                        .process(e -> {
+                            Object name = 
e.getProperty(McpToolCallContext.SECURITY_PRINCIPAL_NAME_PROPERTY);
+                            e.getMessage().setBody(name != null ? "name=" + 
name : "no-name");
+                        });
+
+                from("ai-tool:whoami_roles?tags=secured&description=Report the 
runtime-neutral caller roles")
+                        .process(e -> {
+                            Object roles = 
e.getProperty(McpToolCallContext.SECURITY_PRINCIPAL_ROLES_PROPERTY);
+                            e.getMessage().setBody(roles != null ? "roles=" + 
roles : "no-roles");
+                        });
+
                 from("ai-tool:blocked?tags=secured&description=Always 
denied&authorizationPolicy=#deny")
                         .setBody(constant("SHOULD-NOT-RUN"));
             }
@@ -171,6 +183,65 @@ class McpServerMainAuthenticationTest {
         }
     }
 
+    @Test
+    void testMcpToolCallCarriesRuntimeNeutralPrincipalNameToTheToolRoute() {
+        // CAMEL-25405: alongside the raw principal, the runtime-neutral 
caller name must reach the tool route over MCP
+        // as the CamelMcpSecurityPrincipalName exchange property, so a policy 
can authorize on a stable name without
+        // depending on the Vert.x user type. For the basic-auth user the name 
is the username "camel".
+        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_name", Map.of()));
+            assertThat(result.isError()).isNotEqualTo(Boolean.TRUE);
+            assertThat(result.content().toString()).contains("name=camel");
+        } finally {
+            if (client != null) {
+                client.closeGracefully();
+            }
+        }
+    }
+
+    @Test
+    void testMcpBasicAuthCallerHasNoRuntimeNeutralRoles() {
+        // CAMEL-25405: roles are read from the authenticated token's claims. 
The camel-main basic-auth user carries
+        // no roles in its principal (property-file roles live in the Vert.x 
authorizations, not principal()), so the
+        // CamelMcpSecurityPrincipalRoles property is not stamped for a 
basic-auth caller. This locks in the documented
+        // limitation; positive role extraction from token claims is covered 
by VertxMcpPrincipalTest.
+        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_roles", Map.of()));
+            assertThat(result.isError()).isNotEqualTo(Boolean.TRUE);
+            assertThat(result.content().toString()).contains("no-roles");
+        } 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
diff --git 
a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpPrincipalTest.java
 
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpPrincipalTest.java
new file mode 100644
index 000000000000..b50460c74f34
--- /dev/null
+++ 
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpPrincipalTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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 io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.auth.User;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link VertxMcpPrincipal}: deriving a runtime-neutral name 
and roles from a Vert.x {@link User}.
+ */
+class VertxMcpPrincipalTest {
+
+    @Test
+    void nameFromUsernameClaim() {
+        User user = User.create(new JsonObject().put("username", "camel"));
+        assertThat(VertxMcpPrincipal.name(user)).isEqualTo("camel");
+    }
+
+    @Test
+    void nameFromSubClaimWhenNoUsername() {
+        User user = User.create(new JsonObject().put("sub", "user-123"));
+        assertThat(VertxMcpPrincipal.name(user)).isEqualTo("user-123");
+    }
+
+    @Test
+    void nameIsNullForNullUserOrNoClaims() {
+        assertThat(VertxMcpPrincipal.name(null)).isNull();
+        assertThat(VertxMcpPrincipal.name(User.create(new 
JsonObject()))).isNull();
+    }
+
+    @Test
+    void rolesFromTopLevelRolesClaim() {
+        User user = User.create(new JsonObject().put("roles", new 
JsonArray().add("admin").add("user")));
+        assertThat(VertxMcpPrincipal.roles(user)).containsExactly("admin", 
"user");
+    }
+
+    @Test
+    void rolesFromKeycloakRealmAccess() {
+        User user = User.create(new JsonObject()
+                .put("realm_access", new JsonObject().put("roles", new 
JsonArray().add("kc-role"))));
+        assertThat(VertxMcpPrincipal.roles(user)).containsExactly("kc-role");
+    }
+
+    @Test
+    void rolesFromGroupsClaim() {
+        User user = User.create(new JsonObject().put("groups", new 
JsonArray().add("g1").add("g2")));
+        assertThat(VertxMcpPrincipal.roles(user)).containsExactly("g1", "g2");
+    }
+
+    @Test
+    void topLevelRolesTakePrecedenceOverGroups() {
+        User user = User.create(new JsonObject()
+                .put("roles", new JsonArray().add("a"))
+                .put("groups", new JsonArray().add("b")));
+        assertThat(VertxMcpPrincipal.roles(user)).containsExactly("a");
+    }
+
+    @Test
+    void rolesSkipBlankAndNonStringEntries() {
+        User user = User.create(new JsonObject()
+                .put("roles", new 
JsonArray().add("ok").add("").add(123).addNull()));
+        assertThat(VertxMcpPrincipal.roles(user)).containsExactly("ok");
+    }
+
+    @Test
+    void rolesAreNullWhenAbsentEmptyOrNoneUsable() {
+        assertThat(VertxMcpPrincipal.roles(null)).isNull();
+        assertThat(VertxMcpPrincipal.roles(User.create(new 
JsonObject()))).isNull();
+        assertThat(VertxMcpPrincipal.roles(User.create(new 
JsonObject().put("roles", new JsonArray())))).isNull();
+        assertThat(VertxMcpPrincipal.roles(User.create(new JsonObject()
+                .put("roles", new JsonArray().add("").addNull())))).isNull();
+    }
+
+    @Test
+    void rolesIgnoreNonArrayClaimShapesInsteadOfThrowing() {
+        // a token issuer may emit these claims with a non-array shape; 
extraction must not throw ClassCastException
+        assertThat(VertxMcpPrincipal.roles(User.create(new 
JsonObject().put("roles", "admin")))).isNull();
+        assertThat(VertxMcpPrincipal.roles(User.create(new 
JsonObject().put("groups", "devs")))).isNull();
+        assertThat(VertxMcpPrincipal.roles(User.create(new 
JsonObject().put("realm_access", "nope")))).isNull();
+        assertThat(VertxMcpPrincipal.roles(User.create(new JsonObject()
+                .put("realm_access", new JsonObject().put("roles", 
"admin"))))).isNull();
+    }
+
+    @Test
+    void rolesFallThroughFromNonArrayRolesToGroups() {
+        // a non-array "roles" claim is ignored, and a valid "groups" array is 
still used
+        User user = User.create(new JsonObject()
+                .put("roles", "admin")
+                .put("groups", new JsonArray().add("g1").add("g2")));
+        assertThat(VertxMcpPrincipal.roles(user)).containsExactly("g1", "g2");
+    }
+}

Reply via email to