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

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 69ed0a2560 Adapt authenticated JWT identities for Agent Gateway tools 
(#7811)
69ed0a2560 is described below

commit 69ed0a2560b8de191098eaef3c2cda147b008ce1
Author: BobSong <[email protected]>
AuthorDate: Wed Oct 21 10:47:15 2026 +0800

    Adapt authenticated JWT identities for Agent Gateway tools (#7811)
    
    * feat(agent-gateway): map authenticated JWT identity and tool grants
    
    * test(agent-gateway): retain default CSRF protection in JWT fixture
    
    * fix(agent-gateway): accept valid JWT audience claims
    
    * docs(agent-gateway): clarify JWT tenant policy
---
 .../shenyu-plugin-agent-gateway/README.md          |  72 ++++++
 .../agent/gateway/security/AgentMcpIdentity.java   |  10 +
 .../gateway/security/AgentMcpIdentityTest.java     |  58 +++++
 .../gateway/AgentGatewayPluginConfiguration.java   |  51 +++-
 .../agent/gateway/JwtAgentMcpSecurityResolver.java | 131 +++++++++++
 .../AgentGatewayPluginConfigurationTest.java       |  79 +++++++
 .../plugin/agent/gateway/JwtAgentMcpHttpTest.java  | 262 +++++++++++++++++++++
 .../gateway/JwtAgentMcpSecurityResolverTest.java   | 178 ++++++++++++++
 8 files changed, 831 insertions(+), 10 deletions(-)

diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md 
b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
index ecc70d8315..490d1f8458 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
@@ -53,6 +53,78 @@ The Admin plugin is initialized disabled. Enable 
`agentGateway` in Admin only
 after the starter is loaded. Disabling the Admin plugin stops new context
 creation while leaving the existing AI Proxy and MCP plugins unchanged.
 
+## Optional authenticated JWT identity bridge
+
+The Agent Gateway starter can adapt an existing Spring Security Resource Server
+authentication. It does **not** authenticate raw JWTs, create a decoder/filter 
chain,
+or turn the legacy JWT plugin's forwarded headers into trusted identity.
+The application must include its Resource Server dependencies and configure its
+reactive security chain to verify signatures, permitted algorithms, issuer,
+audience and token lifetime before requests reach Agent Gateway.
+Reuse the application's authentication; do not register a second authentication
+service merely for MCP.
+
+This bridge is disabled by default. Application configuration, not Admin rule
+JSON or client metadata, selects its authority:
+
+```yaml
+shenyu:
+  plugins:
+    agent:
+      gateway:
+        enabled: true
+        security:
+          jwt:
+            enabled: true
+            issuer: https://idp.example.com/issuer
+            audience: shenyu-agent-mcp
+            # Choose exactly one tenant policy:
+            tenant-claim-required: true
+            # Or configure a tenant used only when tenant_id is absent:
+            # tenant-claim-required: false
+            # default-tenant: explicitly-assigned-tenant
+```
+
+Only an authenticated `JwtAuthenticationToken` installed on the exchange 
principal
+is accepted. The bridge additionally requires the exact configured `iss` and
+an `aud` string or collection containing the configured audience; its 
principal name must
+equal the nonblank `sub` claim. It reads these dedicated, verified claims:
+
+| Claim | Contract |
+| --- | --- |
+| `tenant_id` | Nonblank tenant string. Only an absent claim can use explicit 
`default-tenant`; null/invalid values are rejected. Startup requires exactly 
one of `tenant-claim-required: true` or `default-tenant`, so missing-tenant 
behavior is an explicit deployment policy. |
+| `tool_grants` | Optional array of exact tool names; absence grants nothing. 
Generic OAuth scopes/roles are not implicitly MCP permissions. |
+| `tool_denies` | Optional array of exact tool names; deny takes precedence 
over grant. |
+
+For example, signed claims `"tool_grants":["orders.lookup","inventory.lookup"]`
+and `"tool_denies":["inventory.lookup"]` grant only `orders.lookup`, still
+intersected with the rule's `allowedTools`. Listing and execution share that
+same effective set. Denied tools are not disclosed by the list and cannot be
+called directly. Remote names retain their `server.tool` prefix.
+
+Claims are copied into an immutable identity. Identity strings are bounded at
+256 characters; each permission collection at 1,024 entries; tool names at 128
+ASCII letters, digits, underscores, dots or hyphens. Wildcards and malformed
+collections are rejected, not partially accepted. Invalid or absent identities
+produce the existing generic HTTP 401 before consuming the MCP body; transport
+preflight checks still precede identity resolution. Missing permission produces
+the existing authorization result, not an anonymous identity.
+
+Exactly one `AgentMcpSecurityResolver` may be registered. Enabling this bridge
+together with another resolver is a startup error, even with `@Primary`;
+disable the bridge when installing a different explicit authority. Optional
+JWT APIs are not required when it is disabled; enabling without them fails
+startup. Legacy custom adapters remain compatible: their two-argument
+`AgentMcpIdentity` has no tenant, not an implicit default. Tenant-aware 
governance
+must handle that absence explicitly. This change does not infer OAuth login or
+API-key identity from arbitrary principals.
+
+The tenant is available to a provider through the request-local Reactor
+`AgentMcpExecutionContext.getTenant()`; no identity header is added to remote
+MCP calls. The bridge neither logs credentials/claims nor owns SDK/key 
lifecycles.
+Cross-request context/event unification and additional authentication adapters
+remain separate work.
+
 ## Rule handle
 
 The LLM rule handle uses these fields:
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
index 4597497d21..af9cf0b7ff 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
@@ -35,6 +35,16 @@ public final class AgentMcpIdentity {
 
     private final String tenant;
 
+    /**
+     * Create a legacy unscoped identity without a tenant assignment.
+     *
+     * <p>The absence of a tenant assignment is intentional for adapters that 
predate
+     * tenant-aware authorization; callers must treat {@link #getTenant()} 
being empty as
+     * unscoped rather than as an anonymous tenant.</p>
+     *
+     * @param subject verified subject
+     * @param toolGrants exact tool names granted by the authority
+     */
     public AgentMcpIdentity(final String subject, final Set<String> 
toolGrants) {
         this(subject, toolGrants, Set.of(), null);
     }
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentityTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentityTest.java
new file mode 100644
index 0000000000..b084788849
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentityTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.shenyu.plugin.agent.gateway.security;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentMcpIdentityTest {
+
+    @Test
+    void legacyAdaptersHaveNoFabricatedTenant() {
+        AgentMcpIdentity identity = new AgentMcpIdentity("legacy", 
Set.of("read"));
+        assertTrue(identity.getTenant().isEmpty());
+        assertTrue(identity.getToolDenials().isEmpty());
+        assertEquals(Set.of("read"), identity.getToolGrants());
+    }
+
+    @Test
+    void denyWinsAndInputsAreRequestOwnedCopies() {
+        Set<String> grants = new HashSet<>(Set.of("read", "write"));
+        Set<String> denies = new HashSet<>(Set.of("write", "unknown"));
+        AgentMcpIdentity identity = new AgentMcpIdentity("tenant", "subject", 
grants, denies);
+        grants.add("admin");
+        denies.clear();
+        assertEquals(Set.of("read"), identity.getToolGrants());
+        assertEquals(Set.of("write", "unknown"), identity.getToolDenials());
+        assertEquals("tenant", identity.getTenant().orElseThrow());
+        assertThrows(UnsupportedOperationException.class, () -> 
identity.getToolGrants().clear());
+    }
+
+    @Test
+    void tenantBoundConstructorRequiresAnExplicitNonBlankTenant() {
+        assertThrows(NullPointerException.class, () -> new 
AgentMcpIdentity(null, "subject", Set.of(), Set.of()));
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpIdentity(" ", "subject", Set.of(), Set.of()));
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpIdentity("tenant", " ", Set.of(), Set.of()));
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
index b9467ef277..4bd2cec580 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
@@ -35,6 +35,7 @@ import 
org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.BeanFactory;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.core.env.Environment;
 import 
org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -56,6 +57,8 @@ import reactor.core.publisher.Mono;
 @ConditionalOnProperty(value = "shenyu.plugins.agent.gateway.enabled", 
havingValue = "true", matchIfMissing = false)
 public class AgentGatewayPluginConfiguration {
 
+    private static final String JWT_SECURITY_RESOLVER_BEAN_NAME = 
"agentGatewayJwtSecurityResolver";
+
     /**
      * Create a plugin with an optional managed remote catalog.
      * @param providers local providers
@@ -63,35 +66,40 @@ public class AgentGatewayPluginConfiguration {
      * @param catalogs optional, unambiguous managed remote catalog
      * @param tracers optional tracing bridge
      * @param environment deployment-controlled tracing opt-in
+     * @param beanFactory application bean factory used to verify the 
conditional JWT resolver
      * @return the plugin
      */
     @Bean
     public ShenyuPlugin agentGatewayPlugin(final 
ObjectProvider<AgentToolProvider> providers, final 
ObjectProvider<AgentMcpSecurityResolver> resolvers,
                                          final 
ObjectProvider<AgentMcpRemoteCatalog> catalogs, final 
ObjectProvider<AgentMcpCallTracer> tracers,
-                                         final Environment environment) {
+                                         final Environment environment, final 
BeanFactory beanFactory) {
         AgentMcpCallTracer tracer = tracers.getIfAvailable();
         if 
(environment.getProperty("shenyu.plugins.agent.gateway.tracing.enabled", 
Boolean.class, false) && java.util.Objects.isNull(tracer)) {
             throw new IllegalStateException("Enabled MCP tracing requires an 
application tracing bridge");
         }
+        List<AgentMcpSecurityResolver> authorities = 
resolvers.stream().toList();
+        if (authorities.size() > 1) {
+            throw new IllegalStateException("MCP requires exactly one 
authoritative security resolver");
+        }
         if 
(environment.getProperty("shenyu.plugins.agent.gateway.security.key-auth.enabled",
 Boolean.class, false)
-                && resolvers.stream().noneMatch(resolver -> resolver 
instanceof KeyAuthMcpSecurityResolver)) {
+                && authorities.stream().noneMatch(resolver -> resolver 
instanceof KeyAuthMcpSecurityResolver)) {
             throw new IllegalStateException("Enabled key-auth MCP 
authorization requires the key-auth plugin dependency");
         }
         if 
(environment.getProperty("shenyu.plugins.agent.gateway.security.principal.enabled",
 Boolean.class, false)
-                && resolvers.stream().noneMatch(resolver -> 
"org.apache.shenyu.springboot.starter.plugin.agent.gateway.PrincipalMcpSecurityResolver".equals(resolver.getClass().getName())))
 {
+                && authorities.stream().noneMatch(resolver -> 
"org.apache.shenyu.springboot.starter.plugin.agent.gateway.PrincipalMcpSecurityResolver".equals(resolver.getClass().getName())))
 {
             throw new IllegalStateException("Enabled MCP principal 
authorization requires an explicit source and its optional dependencies");
         }
-        return createPlugin(providers, resolvers, catalogs.getIfAvailable(), 
tracer);
+        if 
(environment.getProperty("shenyu.plugins.agent.gateway.security.jwt.enabled", 
Boolean.class, false)
+                && !beanFactory.containsBean(JWT_SECURITY_RESOLVER_BEAN_NAME)) 
{
+            throw new IllegalStateException("Enabled MCP JWT identity requires 
the optional JWT adapter dependencies");
+        }
+        return createPlugin(providers, authorities, catalogs.getIfAvailable(), 
tracer);
     }
 
-    private ShenyuPlugin createPlugin(final ObjectProvider<AgentToolProvider> 
providers, final ObjectProvider<AgentMcpSecurityResolver> resolvers,
+    private ShenyuPlugin createPlugin(final ObjectProvider<AgentToolProvider> 
providers, final List<AgentMcpSecurityResolver> resolvers,
                                      final AgentMcpRemoteCatalog catalog, 
final AgentMcpCallTracer tracer) {
         AgentToolRegistry registry = new 
AgentToolRegistry(providers.orderedStream().toList());
-        List<AgentMcpSecurityResolver> candidates = 
resolvers.stream().toList();
-        if (candidates.size() > 1) {
-            throw new IllegalStateException("MCP requires exactly one 
authoritative security resolver");
-        }
-        AgentMcpSecurityResolver resolver = candidates.isEmpty() ? exchange -> 
Mono.empty() : candidates.get(0);
+        AgentMcpSecurityResolver resolver = resolvers.isEmpty() ? exchange -> 
Mono.empty() : resolvers.get(0);
         return new AgentGatewayPlugin(new AgentMcpHttpHandler(new 
AgentMcpDispatcher(registry, "shenyu-agent-gateway", 
AgentGatewayConstants.MCP_SERVER_VERSION, catalog), resolver, tracer));
     }
 
@@ -223,6 +231,29 @@ public class AgentGatewayPluginConfiguration {
         }
     }
 
+    /**
+     * Optional bridge for an application-owned, authenticated Resource Server 
JWT principal.
+     */
+    @Configuration(proxyBeanMethods = false)
+    @ConditionalOnClass(name = 
{"org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken",
+        "org.springframework.security.oauth2.jwt.Jwt"})
+    @ConditionalOnProperty(value = 
"shenyu.plugins.agent.gateway.security.jwt.enabled", havingValue = "true")
+    static class JwtIdentityConfiguration {
+
+        /**
+         * Adapt verified claims without creating a decoder or authentication 
filter chain.
+         * @param environment deployment-owned identity policy
+         * @return the sole identity authority
+         */
+        @Bean(name = JWT_SECURITY_RESOLVER_BEAN_NAME)
+        AgentMcpSecurityResolver agentGatewayJwtSecurityResolver(final 
Environment environment) {
+            String prefix = "shenyu.plugins.agent.gateway.security.jwt.";
+            return new 
JwtAgentMcpSecurityResolver(environment.getRequiredProperty(prefix + "issuer"),
+                    environment.getRequiredProperty(prefix + "audience"), 
environment.getProperty(prefix + "default-tenant"),
+                    environment.getProperty(prefix + "tenant-claim-required", 
Boolean.class, false));
+        }
+    }
+
     /**
      * Optional API bridge; SDK, exporters and their lifecycle belong to the 
application.
      */
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolver.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolver.java
new file mode 100644
index 0000000000..447e63ef9c
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolver.java
@@ -0,0 +1,131 @@
+/*
+ * 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.shenyu.springboot.starter.plugin.agent.gateway;
+
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import 
org.apache.shenyu.plugin.agent.gateway.security.AgentMcpSecurityResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import 
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Maps only an application-authenticated JWT principal, never a raw token or 
header.
+ * A missing default tenant means that an authenticated token must carry 
{@code tenant_id}.
+ * The application's Resource Server owns signature, algorithm and temporal 
validation.
+ */
+public final class JwtAgentMcpSecurityResolver implements 
AgentMcpSecurityResolver {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(JwtAgentMcpSecurityResolver.class);
+
+    private final String issuer;
+
+    private final String audience;
+
+    private final String defaultTenant;
+
+    private final boolean tenantClaimRequired;
+
+    public JwtAgentMcpSecurityResolver(final String issuer, final String 
audience, final String defaultTenant) {
+        this(issuer, audience, defaultTenant, Objects.isNull(defaultTenant));
+    }
+
+    /**
+     * Create a resolver with an explicit policy for tokens that omit the 
tenant claim.
+     * @param issuer required JWT issuer
+     * @param audience required JWT audience
+     * @param defaultTenant explicit fallback tenant, or null when the claim 
is required
+     * @param tenantClaimRequired whether tokens must contain {@code tenant_id}
+     */
+    public JwtAgentMcpSecurityResolver(final String issuer, final String 
audience, final String defaultTenant, final boolean tenantClaimRequired) {
+        this.issuer = text(issuer);
+        this.audience = text(audience);
+        this.defaultTenant = Objects.isNull(defaultTenant) ? null : 
text(defaultTenant);
+        if (Objects.isNull(this.defaultTenant) != tenantClaimRequired) {
+            throw new IllegalArgumentException("Configure exactly one of 
default tenant or required tenant claim");
+        }
+        this.tenantClaimRequired = tenantClaimRequired;
+    }
+
+    @Override
+    public Mono<AgentMcpIdentity> resolve(final ServerWebExchange exchange) {
+        return exchange.getPrincipal().ofType(JwtAuthenticationToken.class)
+                .filter(JwtAuthenticationToken::isAuthenticated)
+                .flatMap(authentication -> {
+                    try {
+                        Map<String, Object> claims = 
authentication.getTokenAttributes();
+                        if (!issuer.equals(claims.get("iss")) || 
!names(claims.get("aud"), false).contains(audience)) {
+                            return Mono.empty();
+                        }
+                        String subject = text(claims.get("sub"));
+                        if (!subject.equals(authentication.getName())) {
+                            return Mono.empty();
+                        }
+                        String tenant;
+                        if (claims.containsKey("tenant_id")) {
+                            tenant = text(claims.get("tenant_id"));
+                        } else if (Objects.nonNull(defaultTenant)) {
+                            tenant = defaultTenant;
+                        } else if (tenantClaimRequired) {
+                            return Mono.empty();
+                        } else {
+                            throw new IllegalStateException("Tenant identity 
policy is not configured");
+                        }
+                        return Mono.just(new AgentMcpIdentity(tenant, subject,
+                                names(claims.getOrDefault("tool_grants", 
Set.of()), true), names(claims.getOrDefault("tool_denies", Set.of()), true)));
+                    } catch (IllegalArgumentException error) {
+                        // Reject malformed claims and diagnose without 
logging JWT or claim values.
+                        LOG.debug("Rejected authenticated JWT with invalid 
Agent Gateway identity claims");
+                        return Mono.empty();
+                    }
+                });
+    }
+
+    private static String text(final Object value) {
+        if (!(value instanceof String string) || string.isBlank() || 
string.length() > 256
+                || !string.equals(string.trim()) || 
string.chars().anyMatch(Character::isISOControl)) {
+            throw new IllegalArgumentException("Invalid identity claim or 
policy");
+        }
+        return string;
+    }
+
+    private static Set<String> names(final Object value, final boolean tools) {
+        if (!tools && value instanceof String string) {
+            return Set.of(text(string));
+        }
+        if (!(value instanceof Collection<?> values) || values.size() > 1024 
|| !tools && values.isEmpty()) {
+            throw new IllegalArgumentException("Invalid permission or audience 
claim");
+        }
+        Set<String> result = new HashSet<>();
+        for (Object item : values) {
+            String name = text(item);
+            if (tools && (name.length() > 128 || 
!name.matches("[a-zA-Z0-9_.-]+"))) {
+                throw new IllegalArgumentException("Invalid tool permission");
+            }
+            result.add(name);
+        }
+        return Set.copyOf(result);
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
index 1385178896..3a9c086fc3 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
@@ -23,6 +23,7 @@ import 
org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
 import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
 import io.opentelemetry.api.OpenTelemetry;
 import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
 import 
org.apache.shenyu.plugin.agent.gateway.security.AgentMcpSecurityResolver;
 import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
@@ -45,6 +46,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.ArgumentMatchers.any;
@@ -168,6 +170,14 @@ class AgentGatewayPluginConfigurationTest {
                 .run(context -> assertNotNull(context.getStartupFailure()));
     }
 
+    @Test
+    void primaryMustNotHideConflictingIdentityAuthorities() {
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
+                .withBean("firstSecurity", AgentMcpSecurityResolver.class, () 
-> next -> Mono.empty(), definition -> definition.setPrimary(true))
+                .withBean("secondSecurity", AgentMcpSecurityResolver.class, () 
-> next -> Mono.empty())
+                .run(context -> assertNotNull(context.getStartupFailure()));
+    }
+
     @Test
     void shouldAssembleOptInCatalogAndWireTrustedPluginSync() {
         
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.aggregation.enabled=true",
@@ -194,6 +204,75 @@ class AgentGatewayPluginConfigurationTest {
                 }).run(context -> assertNotNull(context.getStartupFailure()));
     }
 
+    @Test
+    void jwtBridgeIsOptInAndRequiresItsExactAuthorityPolicy() {
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true").run(context
 ->
+                
assertFalse(context.containsBean("agentGatewayJwtSecurityResolver")));
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.security.jwt.enabled=true")
+                .run(context -> assertNotNull(context.getStartupFailure()));
+        jwtContext().run(context -> {
+            assertTrue(context.getBean(AgentMcpSecurityResolver.class) 
instanceof JwtAgentMcpSecurityResolver);
+            
assertTrue(context.containsBean("agentGatewayJwtSecurityResolver"));
+        });
+    }
+
+    @Test
+    void jwtBridgeCannotCompeteWithACustomPrimaryAuthority() {
+        jwtContext().withBean("custom", AgentMcpSecurityResolver.class, () -> 
next -> Mono.empty(), definition -> definition.setPrimary(true))
+                .run(context -> assertNotNull(context.getStartupFailure()));
+    }
+
+    @Test
+    void jwtBridgeRecognizesItsResolverWhenWrappedInJdkProxy() {
+        jwtContext().withBean("jwtResolverProxy", BeanPostProcessor.class, () 
-> new BeanPostProcessor() {
+            @Override
+            public Object postProcessAfterInitialization(final Object bean, 
final String beanName) {
+                if (!"agentGatewayJwtSecurityResolver".equals(beanName)) {
+                    return bean;
+                }
+                return 
java.lang.reflect.Proxy.newProxyInstance(bean.getClass().getClassLoader(),
+                        new Class<?>[] {AgentMcpSecurityResolver.class}, 
(proxy, method, arguments) -> method.invoke(bean, arguments));
+            }
+        }).run(context -> {
+            assertNull(context.getStartupFailure());
+            
assertTrue(java.lang.reflect.Proxy.isProxyClass(context.getBean(AgentMcpSecurityResolver.class).getClass()));
+        });
+    }
+
+    @Test
+    void 
optionalJwtClassesAreNotRequiredWhenOffButCannotSilentlyDisappearWhenEnabled() {
+        contextRunner.withClassLoader(new 
FilteredClassLoader("org.springframework.security.oauth2"))
+                
.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
+                .run(context -> 
assertNotNull(context.getBean(AgentGatewayPlugin.class)));
+        jwtContext().withClassLoader(new 
FilteredClassLoader("org.springframework.security.oauth2"))
+                .run(context -> assertNotNull(context.getStartupFailure()));
+    }
+
+    @Test
+    void blankDefaultTenantIsAConfigurationFailure() {
+        
jwtContext().withPropertyValues("shenyu.plugins.agent.gateway.security.jwt.default-tenant=")
+                .run(context -> assertNotNull(context.getStartupFailure()));
+    }
+
+    @Test
+    void jwtBridgeRequiresExactlyOneExplicitTenantPolicyAtStartup() {
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.security.jwt.enabled=true",
+                        
"shenyu.plugins.agent.gateway.security.jwt.issuer=https://issuer.example";, 
"shenyu.plugins.agent.gateway.security.jwt.audience=mcp")
+                .run(context -> assertNotNull(context.getStartupFailure()));
+        
jwtContext().withPropertyValues("shenyu.plugins.agent.gateway.security.jwt.default-tenant=tenant-a",
+                        
"shenyu.plugins.agent.gateway.security.jwt.tenant-claim-required=true")
+                .run(context -> assertNotNull(context.getStartupFailure()));
+        
jwtContext().withPropertyValues("shenyu.plugins.agent.gateway.security.jwt.default-tenant=tenant-a",
+                        
"shenyu.plugins.agent.gateway.security.jwt.tenant-claim-required=false")
+                .run(context -> assertNull(context.getStartupFailure()));
+    }
+
+    private ApplicationContextRunner jwtContext() {
+        return 
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.security.jwt.enabled=true",
+                
"shenyu.plugins.agent.gateway.security.jwt.issuer=https://issuer.example";, 
"shenyu.plugins.agent.gateway.security.jwt.audience=mcp",
+                
"shenyu.plugins.agent.gateway.security.jwt.tenant-claim-required=true");
+    }
+
     private MockServerWebExchange exchange() {
         return 
MockServerWebExchange.from(MockServerHttpRequest.post("/agent/mcp")
                 .header("Content-Type", "application/json").header("Accept", 
"application/json, text/event-stream")
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpHttpTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpHttpTest.java
new file mode 100644
index 0000000000..810472af46
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpHttpTest.java
@@ -0,0 +1,262 @@
+/*
+ * 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.shenyu.springboot.starter.plugin.agent.gateway;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.nimbusds.jose.jwk.source.ImmutableSecret;
+import org.apache.shenyu.common.dto.AgentGatewayMcpConfig;
+import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpDispatcher;
+import 
org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpExecutionContext;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
+import org.springframework.security.web.server.WebFilterChainProxy;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+import reactor.netty.resources.LoopResources;
+
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Real loopback HTTP and signature verification, not a preinstalled identity 
fixture.
+ */
+class JwtAgentMcpHttpTest {
+
+    private static final String ISSUER = "https://issuer.example";;
+
+    private static final byte[] KEY = 
"jwt-mcp-test-only-key-never-use-in-production".getBytes(StandardCharsets.UTF_8);
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void missingBearerRetainsCsrfProtectionAndRequiresAuthentication(final 
String mode) {
+        try (Fixture fixture = new Fixture(mode, "explicit-default")) {
+            fixture.request(null, "tools/call").header("X-Tenant-Id", 
"forged").header("X-Agent-Subject", "agent")
+                    .exchange().expectStatus().isForbidden();
+            
fixture.client.get().uri("/").exchange().expectStatus().isUnauthorized();
+            assertEquals(0, fixture.calls.get());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void signedJwtControlsListingAndCallingWithDenyPrecedence(final String 
mode) {
+        try (Fixture fixture = new Fixture(mode, null)) {
+            String allowed = token(Map.of("tenant_id", "tenant-a", 
"tool_grants", List.of("read")), KEY, ISSUER);
+            String denied = token(Map.of("tenant_id", "tenant-a", 
"tool_grants", List.of("read"), "tool_denies", List.of("read")), KEY, ISSUER);
+            fixture.request(allowed, 
"tools/list").exchange().expectStatus().isOk().expectBody(String.class)
+                    .value(body -> 
assertTrue(body.contains("\"name\":\"read\"")));
+            fixture.request(denied, 
"tools/list").exchange().expectStatus().isOk().expectBody(String.class)
+                    .value(body -> assertTrue(body.contains("\"tools\":[]")));
+            fixture.request(denied, 
"tools/call").exchange().expectStatus().isForbidden();
+            assertEquals(0, fixture.calls.get());
+            fixture.request(allowed, 
"tools/call").exchange().expectStatus().isOk().expectBody(String.class)
+                    .value(body -> 
assertTrue(body.contains("\"tenant\":\"tenant-a\"")));
+            assertEquals(1, fixture.calls.get());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void signedJwtAcceptsAStandardsCompliantSingleStringAudience(final String 
mode) {
+        try (Fixture fixture = new Fixture(mode, null)) {
+            String allowed = token(Map.of("aud", "mcp", "tenant_id", 
"tenant-a", "tool_grants", List.of("read")), KEY, ISSUER);
+            fixture.request(allowed, 
"tools/call").exchange().expectStatus().isOk().expectBody(String.class)
+                    .value(body -> 
assertTrue(body.contains("\"tenant\":\"tenant-a\"")));
+            assertEquals(1, fixture.calls.get());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void forgedExpiredWrongIssuerAndWrongAudienceNeverReachTool(final String 
mode) {
+        try (Fixture fixture = new Fixture(mode, "explicit-default")) {
+            fixture.request(token(Map.of("tool_grants", List.of("read")), 
"another-untrusted-test-key-at-least-32-bytes".getBytes(StandardCharsets.UTF_8),
 ISSUER),
+                    "tools/call").exchange().expectStatus().isUnauthorized();
+            fixture.request(token(Map.of("tool_grants", List.of("read")), KEY, 
"https://untrusted.example";), "tools/call")
+                    .exchange().expectStatus().isUnauthorized();
+            fixture.request(token(Map.of("aud", List.of("other"), 
"tool_grants", List.of("read")), KEY, ISSUER), "tools/call")
+                    .exchange().expectStatus().isUnauthorized();
+            fixture.request(token(Map.of("exp", 
Instant.now().minusSeconds(120), "tool_grants", List.of("read")), KEY, ISSUER), 
"tools/call")
+                    .exchange().expectStatus().isUnauthorized();
+            fixture.request(token(Map.of("tenant_id", "", "tool_grants", 
List.of("read")), KEY, ISSUER), "tools/call")
+                    .exchange().expectStatus().isUnauthorized();
+            assertEquals(0, fixture.calls.get());
+            fixture.request(token(Map.of("tool_grants", List.of("read")), KEY, 
ISSUER), "tools/call")
+                    .exchange().expectStatus().isOk().expectBody(String.class)
+                    .value(body -> 
assertTrue(body.contains("\"tenant\":\"explicit-default\"")));
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void ruleIntersectionAndMissingTenantCannotBeBypassed(final String mode) {
+        try (Fixture fixture = new Fixture(mode, null)) {
+            fixture.request(token(Map.of("tool_grants", List.of("read")), KEY, 
ISSUER), "tools/call")
+                    .exchange().expectStatus().isUnauthorized();
+            assertEquals(0, fixture.calls.get());
+        }
+        try (Fixture fixture = new Fixture(mode, null, "[]")) {
+            String allowed = token(Map.of("tenant_id", "tenant-a", 
"tool_grants", List.of("read")), KEY, ISSUER);
+            fixture.request(allowed, 
"tools/list").exchange().expectStatus().isOk().expectBody(String.class)
+                    .value(body -> assertTrue(body.contains("\"tools\":[]")));
+            fixture.request(allowed, 
"tools/call").exchange().expectStatus().isForbidden();
+            assertEquals(0, fixture.calls.get());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void concurrentSameRpcIdsKeepTenantAndSubjectBoundToTheirRequest(final 
String mode) {
+        try (Fixture fixture = new Fixture(mode, null)) {
+            WebClient client = WebClient.create(fixture.url);
+            Flux.range(0, 32).flatMap(index -> {
+                String tenant = "tenant-" + index;
+                String jwt = token(Map.of("sub", "agent-" + index, 
"tenant_id", tenant, "tool_grants", List.of("read")), KEY, ISSUER);
+                return client.post().uri("/").headers(headers -> {
+                    headers.setBearerAuth(jwt);
+                    headers.set("Content-Type", "application/json");
+                    headers.set("Accept", "application/json, 
text/event-stream");
+                    headers.set("MCP-Protocol-Version", "2026-07-28");
+                    headers.set("Mcp-Method", "tools/call");
+                    headers.set("Mcp-Name", "read");
+                
}).bodyValue(body("tools/call")).retrieve().bodyToMono(String.class)
+                        .doOnNext(result -> {
+                            assertTrue(result.contains("\"tenant\":\"" + 
tenant + "\""));
+                            assertTrue(result.contains("\"subject\":\"agent-" 
+ index + "\""));
+                        });
+            }, 32).collectList().block(Duration.ofSeconds(20));
+            assertEquals(32, fixture.calls.get());
+        }
+    }
+
+    private String token(final Map<String, Object> claims, final byte[] key, 
final String issuer) {
+        Instant now = Instant.now();
+        JwtClaimsSet.Builder builder = 
JwtClaimsSet.builder().issuer(issuer).subject("agent").audience(List.of("mcp"))
+                
.issuedAt(now.minusSeconds(180)).expiresAt(now.plusSeconds(300));
+        claims.forEach(builder::claim);
+        return new NimbusJwtEncoder(new ImmutableSecret<>(key)).encode(
+                
JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), 
builder.build())).getTokenValue();
+    }
+
+    private static String body(final String method) {
+        return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method + 
"\",\"params\":{"
+                + ("tools/call".equals(method) ? "\"name\":\"read\"," : "")
+                + 
"\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}";
+    }
+
+    private static final class Fixture implements AutoCloseable {
+
+        private final AtomicInteger calls = new AtomicInteger();
+
+        private final LoopResources loops = 
LoopResources.create("jwt-mcp-http", 2, true);
+
+        private final DisposableServer server;
+
+        private final WebTestClient client;
+
+        private final String url;
+
+        Fixture(final String mode, final String defaultTenant) {
+            this(mode, defaultTenant, "[\"read\"]");
+        }
+
+        Fixture(final String mode, final String defaultTenant, final String 
allowedTools) {
+            AgentToolProvider tool = mock(AgentToolProvider.class);
+            when(tool.getName()).thenReturn("read");
+            when(tool.getDescription()).thenReturn("Read a fixture");
+            
when(tool.getInputSchema()).thenReturn(JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject());
+            when(tool.getRequiredClientCapabilities()).thenReturn(new 
JsonObject());
+            when(tool.invoke(any())).thenAnswer(ignored -> 
Mono.deferContextual(context -> Mono.delay(Duration.ofMillis(5)).map(tick -> {
+                calls.incrementAndGet();
+                JsonObject result = new JsonObject();
+                result.addProperty("tenant", 
context.get(AgentMcpExecutionContext.class).getTenant().orElseThrow());
+                result.addProperty("subject", 
context.get(AgentMcpExecutionContext.class).getSubject());
+                return result;
+            })));
+            AgentMcpHttpHandler handler = new AgentMcpHttpHandler(new 
AgentMcpDispatcher(new AgentToolRegistry(List.of(tool)), "fixture", "1"),
+                    new JwtAgentMcpSecurityResolver(ISSUER, "mcp", 
defaultTenant));
+            AgentGatewayMcpConfig config = 
AgentGatewayMcpConfig.parse(JsonParser.parseString(
+                    "{\"allowedTools\":" + allowedTools + 
",\"responseMode\":\"" + mode + "\"}"), true);
+            NimbusReactiveJwtDecoder decoder = 
NimbusReactiveJwtDecoder.withSecretKey(new SecretKeySpec(KEY, "HmacSHA256"))
+                    .macAlgorithm(MacAlgorithm.HS256).build();
+            
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(ISSUER));
+            // Resource Server handles bearer requests; retain default CSRF 
protection for requests without a bearer token.
+            var security = ServerHttpSecurity.http()
+                    .authorizeExchange(exchanges -> 
exchanges.anyExchange().authenticated())
+                    .oauth2ResourceServer(resource -> resource.jwt(jwt -> 
jwt.jwtDecoder(decoder))).build();
+            var httpHandler = WebHttpHandlerBuilder.webHandler(exchange -> 
handler.handle(exchange, config,
+                    new AgentTrafficContext("fixture-request", "mcp", 
"selector", "rule"), 1))
+                    .filter(new WebFilterChainProxy(security)).build();
+            server = 
HttpServer.create().host("127.0.0.1").port(0).runOn(loops).handle(new 
ReactorHttpHandlerAdapter(httpHandler)).bindNow();
+            url = "http://127.0.0.1:"; + server.port();
+            client = 
WebTestClient.bindToServer().baseUrl(url).responseTimeout(Duration.ofSeconds(10)).build();
+        }
+
+        WebTestClient.RequestHeadersSpec<?> request(final String token, final 
String method) {
+            return client.post().uri("/").headers(headers -> {
+                if (Objects.nonNull(token)) {
+                    headers.setBearerAuth(token);
+                }
+                headers.set("Content-Type", "application/json");
+                headers.set("Accept", "application/json, text/event-stream");
+                headers.set("MCP-Protocol-Version", "2026-07-28");
+                headers.set("Mcp-Method", method);
+                if ("tools/call".equals(method)) {
+                    headers.set("Mcp-Name", "read");
+                }
+            }).bodyValue(body(method));
+        }
+
+        @Override
+        public void close() {
+            server.disposeNow();
+            loops.disposeLater().block(Duration.ofSeconds(10));
+        }
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolverTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolverTest.java
new file mode 100644
index 0000000000..53cabbc917
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolverTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.shenyu.springboot.starter.plugin.agent.gateway;
+
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.Arguments;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.oauth2.jwt.Jwt;
+import 
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JwtAgentMcpSecurityResolverTest {
+
+    private final JwtAgentMcpSecurityResolver resolver = new 
JwtAgentMcpSecurityResolver("https://issuer.example";, "mcp", null);
+
+    @Test
+    void mapsVerifiedClaimsWithDenyPrecedenceAndImmutableSnapshots() {
+        List<String> grants = new ArrayList<>(List.of("read", 
"orders.lookup"));
+        Map<String, Object> claims = claims();
+        claims.put("tool_grants", grants);
+        claims.put("tool_denies", List.of("read"));
+        AgentMcpIdentity identity = resolve(resolver, authenticated(claims));
+        grants.add("admin");
+        assertEquals("tenant-a", identity.getTenant().orElseThrow());
+        assertEquals("agent", identity.getSubject());
+        assertEquals(Set.of("orders.lookup"), identity.getToolGrants());
+        assertEquals(Set.of("read"), identity.getToolDenials());
+        assertThrows(UnsupportedOperationException.class, () -> 
identity.getToolGrants().add("admin"));
+        assertThrows(UnsupportedOperationException.class, () -> 
identity.getToolDenials().clear());
+    }
+
+    @Test
+    void unverifiedJwtAndOrdinaryPrincipalAndForgedHeadersAreNotIdentity() {
+        assertNull(resolve(resolver, new 
JwtAuthenticationToken(jwt(claims()))));
+        assertNull(resolve(resolver, (Principal) () -> "agent"));
+        assertNull(resolve(resolver, null));
+    }
+
+    @Test
+    void rejectsConflictingPrincipalAliasRatherThanChoosingIt() {
+        assertNull(resolve(resolver, new JwtAuthenticationToken(jwt(claims()), 
List.of(), "other-agent")));
+    }
+
+    @Test
+    void explicitDefaultTenantAppliesOnlyToAuthenticatedMissingClaim() {
+        Map<String, Object> claims = claims();
+        claims.remove("tenant_id");
+        JwtAgentMcpSecurityResolver configured = new 
JwtAgentMcpSecurityResolver("https://issuer.example";, "mcp", "server-tenant");
+        assertNull(resolve(resolver, authenticated(claims)));
+        assertEquals("server-tenant", resolve(configured, 
authenticated(claims)).getTenant().orElseThrow());
+        assertNull(resolve(configured, null));
+        assertNull(resolve(configured, new 
JwtAuthenticationToken(jwt(claims))));
+        claims.put("tenant_id", "");
+        assertNull(resolve(configured, authenticated(claims)));
+    }
+
+    @Test
+    void acceptsSingleStringAudienceClaim() {
+        Map<String, Object> claims = claims();
+        claims.put("aud", "mcp");
+        assertEquals("tenant-a", resolve(resolver, 
authenticated(claims)).getTenant().orElseThrow());
+    }
+
+    @Test
+    void missingGrantsNeverBecomeAllToolsOrScopes() {
+        Map<String, Object> claims = claims();
+        claims.remove("tool_grants");
+        claims.put("scope", "read admin");
+        assertTrue(resolve(resolver, 
authenticated(claims)).getToolGrants().isEmpty());
+    }
+
+    @ParameterizedTest
+    @MethodSource("invalidClaims")
+    void malformedOrWrongAuthorityClaimsAreRejected(final String key, final 
Object value) {
+        Map<String, Object> claims = claims();
+        claims.put(key, value);
+        assertNull(resolve(resolver, authenticated(claims)));
+    }
+
+    private static Stream<Arguments> invalidClaims() {
+        return Stream.of(
+                Arguments.of("iss", "https://other.example";), 
Arguments.of("iss", List.of("https://issuer.example";)),
+                Arguments.of("aud", List.of("other")), Arguments.of("aud", 
""), Arguments.of("aud", List.of()), Arguments.of("aud", List.of("mcp", 42)),
+                Arguments.of("tenant_id", null), Arguments.of("tenant_id", 
42), Arguments.of("tenant_id", " tenant-a"),
+                Arguments.of("tenant_id", "a\nb"), Arguments.of("tenant_id", 
"x".repeat(257)),
+                Arguments.of("sub", ""), Arguments.of("sub", "agent "), 
Arguments.of("sub", 42),
+                Arguments.of("tool_grants", "read"), 
Arguments.of("tool_grants", null), Arguments.of("tool_grants", List.of("*")),
+                Arguments.of("tool_grants", List.of("read", 42)), 
Arguments.of("tool_grants", List.of("a".repeat(129))),
+                Arguments.of("tool_grants", 
java.util.Collections.nCopies(1025, "read")),
+                Arguments.of("tool_denies", "read"), 
Arguments.of("tool_denies", null), Arguments.of("tool_denies", 
List.of("read/child")));
+    }
+
+    @Test
+    void rejectsInvalidDeploymentPolicyAtConstruction() {
+        assertThrows(IllegalArgumentException.class, () -> new 
JwtAgentMcpSecurityResolver("", "mcp", null));
+        assertThrows(IllegalArgumentException.class, () -> new 
JwtAgentMcpSecurityResolver("issuer", " ", null));
+        assertThrows(IllegalArgumentException.class, () -> new 
JwtAgentMcpSecurityResolver("issuer", "mcp", ""));
+        assertThrows(IllegalArgumentException.class, () -> new 
JwtAgentMcpSecurityResolver("issuer", "mcp", null, false));
+        assertThrows(IllegalArgumentException.class, () -> new 
JwtAgentMcpSecurityResolver("issuer", "mcp", "tenant-a", true));
+    }
+
+    @Test
+    void concurrentSameSubjectInDifferentTenantsDoesNotShareIdentity() {
+        Map<String, Object> first = claims();
+        Map<String, Object> second = claims();
+        second.put("tenant_id", "tenant-b");
+        second.put("tool_denies", List.of("read"));
+        List<AgentMcpIdentity> identities = reactor.core.publisher.Flux.merge(
+                resolveAsync(resolver, 
authenticated(first)).subscribeOn(Schedulers.parallel()),
+                resolveAsync(resolver, 
authenticated(second)).subscribeOn(Schedulers.parallel())).collectList().block();
+        assertEquals(2, identities.size());
+        assertEquals(Set.of("tenant-a", "tenant-b"), 
identities.stream().map(identity -> 
identity.getTenant().orElseThrow()).collect(java.util.stream.Collectors.toSet()));
+        assertEquals(1, identities.stream().filter(identity -> 
identity.getToolGrants().contains("read")).count());
+    }
+
+    private AgentMcpIdentity resolve(final JwtAgentMcpSecurityResolver 
adapter, final Principal principal) {
+        return resolveAsync(adapter, principal).block();
+    }
+
+    private Mono<AgentMcpIdentity> resolveAsync(final 
JwtAgentMcpSecurityResolver adapter, final Principal principal) {
+        MockServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.post("/agent/mcp")
+                .header("Authorization", "Bearer 
not-a-verified-token").header("X-Tenant-Id", "forged")
+                .header("X-Agent-Subject", "forged").header("tool_grants", 
"admin"));
+        return 
adapter.resolve(exchange.mutate().principal(Mono.justOrEmpty(principal)).build());
+    }
+
+    private JwtAuthenticationToken authenticated(final Map<String, Object> 
claims) {
+        return new JwtAuthenticationToken(jwt(claims), List.of());
+    }
+
+    private Jwt jwt(final Map<String, Object> claims) {
+        return new Jwt("fixture-not-a-raw-credential", Instant.now(), 
Instant.now().plusSeconds(300), Map.of("alg", "RS256"), claims);
+    }
+
+    private Map<String, Object> claims() {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("iss", "https://issuer.example";);
+        claims.put("aud", List.of("mcp"));
+        claims.put("sub", "agent");
+        claims.put("tenant_id", "tenant-a");
+        claims.put("tool_grants", List.of("read"));
+        return claims;
+    }
+}

Reply via email to