Protecting AI Agent Tool Access with Kuadrant:A Model Context Protocol Gateway Case Study

As AI agents become more sophisticated and autonomous, they increasingly need access to real-world tools and services through APIs. The Model Context Protocol (MCP) provides a standardized way for AI agents to discover and invoke tools from various servers. However, this creates a complex security challenge: how do you enforce fine-grained access control when an agent aggregates tools from multiple sources with different authentication mechanisms?

This post follows up on Rachel Lawton's Kuadrant and Model context protocol (MCP) the ultimate pairing? to explores how Kuadrant's AuthPolicy provides an elegant solution to the MCP tool aggregation auth problem, using the MCP Gateway as a real-world case study. We'll show how Kuadrant's declarative policy API, built on Gateway API and Envoy, enables sophisticated authentication and authorization patterns that would be prohibitively complex to implement manually.

The MCP Gateway Security Challenge

An MCP Gateway aggregates tools from multiple MCP servers behind a single endpoint. For example, an AI agent might access:

This creates three critical security requirements:

  1. Identity-based tool filtering: Only show users tools they're authorized to access
  2. Token scope reduction: Don't pass overly permissive tokens to backend servers
  3. Credential translation: Support servers with different authentication mechanisms

Traditional API gateway authorization falls short because:

This is exactly the kind of complex, multi-faceted security challenge that Kuadrant's AuthPolicy is designed to solve.

Why Kuadrant?

Kuadrant is a policy engine for Gateway API that extends Envoy with declarative, Kubernetes-native policies for authentication, authorization, and rate limiting. What makes it particularly well-suited for MCP Gateway protection?

1. Declarative, Composable Policies

AuthPolicy uses a multi-phase pipeline that separates concerns:

This separation makes complex flows readable and maintainable.

2. Built on Standards

Kuadrant is built on:

This means it works with multiple Gateway API providers (Istio, Envoy Gateway) and integrates with existing Envoy ecosystems.

3. Powerful Expression Languages

AuthPolicy supports Common Expression Language (CEL) as well as Open Policy Agent for dynamic policy logic, enabling sophisticated authorization rules without custom code.

4. Extensibility Through Metadata

The metadata phase can call external HTTP services, enabling integration with secret stores (Vault), ACL systems, and other external policy decision points.

Let's see how these capabilities solve the MCP Gateway security challenges.

Solution 1: Identity-Based Tool Filtering with Wristbands

The Challenge

When an agent calls tools/list, the gateway returns all available tools. But we want to filter this list based on the authenticated user's permissions. The MCP Gateway needs to:

  1. Receive trusted permission data
  2. Verify it hasn't been tampered with
  3. Filter tools efficiently

The Kuadrant Solution

We use AuthPolicy's wristband feature to create a cryptographically-signed JWT containing the user's permitted tools:

apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
  name: mcp-auth-policy
  namespace: gateway-system
spec:
  targetRef:
    group: gateway.networking.k8s.io
    kind: Gateway
    name: mcp-gateway
    sectionName: mcp
  when:
    - predicate: "!request.path.contains('/.well-known')"
  rules:
    # Phase 1: Authentication
    authentication:
      'keycloak':
        jwt:
          issuerUrl: https://keycloak.example.com/realms/mcp

    # Phase 2: Authorization
    authorization:
      'allow-tool-list':
        patternMatching:
          patterns:
            - predicate: request.headers['x-mcp-method'] in ["tools/list","initialize","notifications/initialized"]

      'authorized-tools':
        opa:
          rego: |
            allow = true
            # Extract tool permissions from JWT resource_access claim
            tools = { server: roles |
              server := object.keys(input.auth.identity.resource_access)[_];
              roles := object.get(input.auth.identity.resource_access, server, {}).roles
            }
          allValues: true

    # Phase 3: Request modification
    response:
      success:
        headers:
          x-authorized-tools:
            wristband:
              issuer: 'authorino'
              customClaims:
                'allowed-tools':
                  selector: auth.authorization.authorized-tools.tools.@tostr
              tokenDuration: 300
              signingKeyRefs:
                - name: trusted-headers-private-key
                  algorithm: ES256

What's happening here:

  1. Authentication: Validates the JWT from Keycloak
  2. Authorization with OPA: Uses Open Policy Agent to extract tool permissions from the JWT's resource_access claim (a Keycloak feature where each MCP server is a client and each tool is a role)
  3. Wristband creation: Creates a new JWT signed with ES256 containing:
    {
      "allowed-tools": "{\"server1.mcp.local\":[\"greet\",\"time\"],\"server2.mcp.local\":[\"headers\"]}",
      "iss": "authorino",
      "exp": 1760004918
    }
    
  4. Header injection: Adds this wristband as the x-authorized-tools header

The MCP Broker then validates this header's signature and filters the tool list. Because it's cryptographically signed, the broker can trust it came from the authorized policy engine.

Why This is Powerful

Solution 2: OAuth2 Token Exchange with RFC 8693

The Challenge

AI agents typically have broad OAuth2 access tokens covering all services. Passing these tokens to every MCP server creates a privilege escalation risk - a malicious server could use the token to access unauthorized resources.

We need to:

  1. Exchange the broad token for a narrow one scoped to each MCP server
  2. Verify the new token has the correct audience
  3. Check the user is authorized for the specific tool being called
  4. Replace the token in the Authorization header

The Kuadrant Solution

AuthPolicy's metadata phase can call external HTTP services, making OAuth2 token exchange a simple declarative configuration:

apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
  name: mcps-auth-policy
  namespace: gateway-system
spec:
  targetRef:
    group: gateway.networking.k8s.io
    kind: Gateway
    name: mcp-gateway
    sectionName: mcps
  rules:
    # Phase 1: Authentication
    authentication:
      'keycloak':
        jwt:
          issuerUrl: https://keycloak.example.com/realms/mcp

    # Phase 2: Metadata - fetch scoped token
    metadata:
      oauth-token-exchange:
        http:
          url: https://keycloak.example.com/realms/mcp/protocol/openid-connect/token
          method: POST
          credentials:
            authorizationHeader:
              prefix: Basic
          sharedSecretRef:
            name: token-exchange
            key: oauth-client-basic-auth
          bodyParameters:
            grant_type:
              value: urn:ietf:params:oauth:grant-type:token-exchange
            subject_token:
              expression: request.headers['authorization'].split('Bearer ')[1]
            subject_token_type:
              value: urn:ietf:params:oauth:token-type:access_token
            audience:
              expression: request.host  # Target MCP server
            scope:
              value: openid

    # Phase 3: Authorization - verify token and tool access
    authorization:
      'token':
        opa:
          rego: |
            # Use exchanged token if available, otherwise original
            scoped_jwt := object.get(object.get(object.get(input.auth, "metadata", {}),
                "oauth-token-exchange", {}), "access_token", "")
            jwt := j { scoped_jwt != ""; j := scoped_jwt }
            jwt := j { scoped_jwt == ""; j := split(input.request.headers["authorization"], "Bearer ")[1] }
            claims := c { [_, c, _] := io.jwt.decode(jwt) }
            allow = true
          allValues: true

      'scoped-audience-check':
        patternMatching:
          patterns:
            - predicate: |
                has(auth.authorization.token.claims.aud) &&
                type(auth.authorization.token.claims.aud) == string &&
                auth.authorization.token.claims.aud == request.host

      'tool-access-check':
        patternMatching:
          patterns:
            - predicate: |
                request.headers['x-mcp-toolname'] in
                  (has(auth.authorization.token.claims.resource_access) &&
                   auth.authorization.token.claims.resource_access.exists(p, p == request.host) ?
                   auth.authorization.token.claims.resource_access[request.host].roles : [])

    # Phase 4: Response - inject scoped token
    response:
      success:
        headers:
          authorization:
            plain:
              expression: "Bearer " + auth.authorization.token.jwt

The flow:

  1. Authenticate the incoming request's JWT
  2. Metadata phase calls Keycloak's token exchange endpoint (RFC 8693), passing:
    • The original token as the subject
    • The target MCP server hostname as the audience
    • The mcp-gateway client credentials for authorization
  3. Authorization phase does three checks:
    • Extracts claims from the exchanged token (or falls back to original)
    • Verifies aud claim matches the target server (:authority header)
    • Verifies the user has access to the specific tool (x-mcp-toolname header)
  4. Response phase replaces the Authorization header with the scoped token

Why This is Powerful

CEL Expressions in Action

The power of CEL is evident in the authorization predicates. For example, the tool access check:

request.headers['x-mcp-toolname'] in
  (has(auth.authorization.token.claims.resource_access) &&
   auth.authorization.token.claims.resource_access.exists(p, p == request.host) ?
   auth.authorization.token.claims.resource_access[request.host].roles : [])

This single expression:

  1. Checks if the JWT has a resource_access claim
  2. Checks if that claim has an entry for the current host
  3. Extracts the roles array for that host
  4. Checks if the requested tool name is in that array
  5. Returns an empty array as fallback if any step fails

Without CEL, this would require custom code or a complex OPA policy.

OPA's built-in functions make it all easier

The possibility to embed OPA's Rego built-in capabilities into the AuthPolicy made it easy to decode the scope token for extracting claim, with fall back to the broad token for resilient policy evaluation.

The next authorization steps thereafter became straightforward with the CEL expressions refering to auth.authorization.token.claims.

Solution 3: Vault Integration for Credential Translation

The Challenge

External MCP servers often don't support OAuth2. GitHub's MCP server requires a Personal Access Token (PAT). Other services use API keys. We need to:

  1. Fetch the appropriate credential for each user and server
  2. Use it instead of OAuth2 when available
  3. Fall back to token exchange when not

The Kuadrant Solution

AuthPolicy's metadata phase can fetch from multiple sources with priorities. We fetch from Vault first, then fall back to OAuth2 token exchange:

metadata:
  # Priority 0: Try Vault first
  vault:
    http:
      urlExpression: |
        "http://vault.vault.svc.cluster.local:8200/v1/secret/data/" +
        auth.identity.preferred_username + "/" + request.host
      method: GET
      credentials:
        customHeader:
          name: X-Vault-Token
      sharedSecretRef:
        name: token-exchange
        key: vault-token
    priority: 0

  # Priority 1: Fallback to token exchange if Vault has no entry
  oauth-token-exchange:
    when:
      - predicate: |
          !has(auth.metadata.vault.data) ||
          !has(auth.metadata.vault.data.data) ||
          !has(auth.metadata.vault.data.data.token) ||
          type(auth.metadata.vault.data.data.token) != string
    http:
      # ... token exchange config ...
    priority: 1

The response injection uses conditional logic:

response:
  success:
    headers:
      authorization:
        plain:
          expression: |
            "Bearer " + (
              (has(auth.metadata.vault.data) &&
               has(auth.metadata.vault.data.data) &&
               has(auth.metadata.vault.data.data.token) &&
               type(auth.metadata.vault.data.data.token) == string) ?
              auth.metadata.vault.data.data.token :
              auth.authorization.token.jwt
            )

The flow:

  1. Vault lookup at path /v1/secret/data/{username}/{server} (e.g., /v1/secret/data/alice/github.mcp.local)
  2. If found, skip token exchange (via the when predicate)
  3. If not found, fall back to OAuth2 token exchange
  4. Response injection uses Vault token if available, otherwise exchanged token

Why This is Powerful

The Complete Picture: Defense in Depth

When all three solutions are combined, you get defense in depth with multiple security layers:

┌──────────────────────────────────────────────────────┐
│                MCP Client (AI Agent)                 │
│      OAuth2 Token (broad scopes, multiple aud)       │
└──────────────────────────┬───────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────┐
│          Gateway API + Kuadrant AuthPolicies         │
│                                                      │
│  ┌─────────────────────────────────────────────────┐ │
│  │  Authentication Phase                           │ │
│  │  - Validate JWT signature                       │ │
│  │  - Extract identity claims                      │ │
│  └───────────────────────┬─────────────────────────┘ │
│                          │                           │
│                          ▼                           │
│  ┌─────────────────────────────────────────────────┐ │
│  │  Metadata Phase                                 │ │
│  │  Priority 0: Check Vault for PAT/API key        │ │
│  │  Priority 1: Exchange token (RFC 8693)          │ │
│  └───────────────────────┬─────────────────────────┘ │
│                          │                           │
│                          ▼                           │
│  ┌─────────────────────────────────────────────────┐ │
│  │  Authorization Phase                            │ │
│  │  - OPA: Extract tool permissions                │ │
│  │  - CEL: Verify audience scope                   │ │
│  │  - CEL: Check tool-level access                 │ │
│  └───────────────────────┬─────────────────────────┘ │
│                          │                           │
│                          ▼                           │
│  ┌─────────────────────────────────────────────────┐ │
│  │  Response Phase                                 │ │
│  │  - (Broker) Create x-authorized-tools wristband │ │
│  │  - (Router) Inject scoped token or Vault PAT    │ │
│  └─────────────────────────────────────────────────┘ │
└─────────────┬──────────────────────┬─────────────────┘
              │                      │
              ▼                      ▼
      ┌───────────────┐      ┌───────────────┐
      │  MCP Broker   │      │  MCP Router   │
      │               │      │  (ext_proc)   │
      │  - Validates  │      │               │
      │    wristband  │      │  - Sets       │
      │  - Filters    │      │    x-mcp-*    │
      │    tool list  │      │    headers    │
      └───────────────┘      └───────┬───────┘
                                     │ Scoped token or PAT
                                     ▼
                             ┌───────────────┐
                             │ Backend MCP   │
                             │   Servers     │
                             └───────────────┘

Each layer enforces a different security invariant:

  1. Authentication: Only valid JWT holders proceed
  2. Metadata: Credentials are scoped/exchanged before authorization
  3. Authorization: Access decisions use correct token and permissions
  4. Response: Injected data is cryptographically signed and verifiable

Kuadrant's Advantages for This Use Case

1. Declarative Configuration

The entire security policy is defined in YAML, making it:

Compare this to writing custom Envoy filters or external auth services - you'd need hundreds of lines of code to achieve the same functionality.

2. Separation of Concerns

The multi-phase pipeline naturally separates:

This mirrors how security teams think about authorization, making policies easier to reason about.

3. Composability

AuthPolicy uses Gateway API's defaults and overrides pattern:

For example, you could set different OAuth2 scopes for different MCP servers by attaching route-specific AuthPolicies.

4. Integration Ecosystem

The metadata phase's HTTP callout capability enables integration with:

All without writing custom code.

5. Expression Language Power

CEL expressions can:

This eliminates the need for custom authorization code for most use cases.

Real-World Benefits

Organizations using this pattern have seen:

Demo of the solution in action

Watch a demo of all 3 solutions in action:


Try It Yourself

The MCP Gateway repository includes a complete working example with Kuadrant:

# Create a Kind cluster with Istio Gateway API and Kuadrant
git clone https://github.com/kagenti/mcp-gateway && cd mcp-gateway
make local-env-setup

# Set up OAuth + token exchange + Vault example
make oauth-token-exchange-example-setup

# Open MCP Inspector to test
make inspect-gateway

This creates:

You can experiment by:

Key Takeaways

  1. AuthPolicy is more than authentication: The metadata and response phases enable sophisticated credential management and request modification.

  2. CEL is powerful: Common Expression Language provides the flexibility of custom code with the safety of declarative config.

  3. Standards-based integration: OAuth2 token exchange (RFC 8693) and Gateway API patterns make this solution portable across environments.

  4. Defense in depth: Multiple authorization layers (authentication, metadata validation, tool-level checks, cryptographic verification) provide strong security guarantees.

  5. MCP is just one use case: The patterns demonstrated here apply to any scenario requiring fine-grained authorization across aggregated backends with heterogeneous authentication.

Conclusion

Protecting AI agent tool access is a complex challenge that combines traditional API gateway concerns with novel requirements like tool-level authorization and credential translation. Kuadrant's AuthPolicy provides a powerful, declarative solution that handles this complexity without custom code.

The MCP Gateway case study demonstrates how Kuadrant can:

If you're building systems that aggregate APIs with different authentication requirements and need fine-grained access control, Kuadrant's AuthPolicy is worth exploring. The declarative, composable approach scales from simple JWT validation to sophisticated multi-phase authorization flows like those demonstrated here.

Learn More