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");
+ }
+}