Skip to content
Latest (currently 2026.9.0) has the newest features, bug fixes, and CVE patches of Solo Enterprise for agentregistry.

For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.

Expose and secure MCP servers with agentgateway

Page as Markdown

Learn how to expose multiple remote MCP servers through an agentgateway Virtual runtime and restrict access by user, group, and tool.

Without a central proxy, MCP servers are scattered across infrastructure with no shared authentication, no consistent discovery endpoint, and no way to enforce who can reach which server or call specific tools. Any MCP client that can reach a server URL has full access to all its tools. Solo Enterprise for agentregistry solves this with an agentgateway virtual runtime, a single, policy-enforced proxy that sits in front of your MCP servers and lets you govern access the same way you govern the rest of your AI artifact catalog.

Think of the agentgateway as a load balancer for MCP traffic. The gateway admin grants a delegation rule once, and then registry users can add or remove MCP servers behind it without touching the gateway configuration. Every server gets a stable, routable URL through the proxy. Access policies control what user groups can see and what a deployed agent is allowed to call.

Tip

New to Solo Enterprise for agentregistry? Learn about core concepts and the architecture before getting started.

About this guide

In this guide, you register three MCP servers in the catalog, expose them through the agentgateway proxy, and then layer security policies on top, including JWT authentication, group-level server restrictions, per-user tool filtering, and rate limiting.

By the end of this guide, you have a working MCP gateway with the following security layers applied:

  • Three MCP servers that are exposed through a central agentgateway proxy with stable, routable URLs.
  • JWT authentication that is required on all MCP traffic so unauthenticated requests are rejected at the proxy.
  • Group-level server restrictions that block users from the developers group from calling tools on specific MCP servers.
  • Per-user tool filtering that limits an individual user to a specific subset of tools.
  • A request rate limit to prevent abuse.

Before you begin

To complete this guide, you need a running installation of Solo Enterprise for agentregistry that is configured with Keycloak as your OIDC provider, Solo Enterprise for agentgateway installed in your cluster, a virtual runtime that connects agentgateway to the registry, and the arctl CLI. If you have not set up these components yet, complete the following steps first.

  1. Set up Keycloak as your OIDC provider. Make sure to follow the General setup tab to install Keycloak with the required realm settings, clients, and secrets.

  2. Install Solo Enterprise for agentregistry and the arctl CLI.

  3. Verify that you are logged in by running the following command.

    arctl user whoami
  4. Port-forward the Solo Enterprise for agentregistry server on port 12121 if you have not done so already.

    kubectl -n agentregistry-system port-forward svc/agentregistry-enterprise-server 12121:12121
  5. Install Solo Enterprise for agentgateway and connect it as a virtual runtime.


Step 1: Add remote MCP servers to the catalog

Register the remote MCP servers that you want to expose through the gateway. This step adds catalog entries for the following MCP servers:

  • Solo.io docs: An MCP server that gives you access to the Solo.io product documentation.
  • GitHub Copilot MCP: An MCP server that gives you access to GitHub. This server requires a GitHub personal access token for authentication.
  • Self-hosted sample server: An in-cluster Python server with an example_sum and example_echo tool.

Register the Solo.io docs MCP server in the catalog.

arctl apply -f- <<EOF
apiVersion: ar.dev/v1alpha1
kind: MCPServer
metadata:
  name: solo-docs
spec:
  title: Solo.io docs MCP
  description: "MCP server for Solo.io product documentation."
  remote:
    type: streamable-http
    url: https://search.solo.io/mcp
EOF

Example output:

✓ MCPServer/solo-docs (latest) created

Create a catalog entry for a publicly available MCP server that requires an authentication header. This example uses the GitHub Copilot MCP server, which expects a GitHub personal access token in every request.

  1. Create a personal access token in GitHub and save it in an environment variable. For more information, see the GitHub docs.

    export GH_PAT=<your-github-personal-access-token>
  2. Register the GitHub Copilot MCP server in the catalog.

    arctl apply -f - <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: MCPServer
    metadata:
      name: githubcopilot-mcp
    spec:
      title: GitHub Copilot MCP
      description: "GitHub Copilot remote MCP server"
      remote:
        type: streamable-http
        url: https://api.githubcopilot.com/mcp
        headers:
        - name: Authorization
          value: "Bearer ${GH_PAT}"
    EOF

    Example output:

    ✓ MCPServer/githubcopilot-mcp (latest) created
    

Use the arctl CLI to scaffold a Python MCP server, build and deploy it to your cluster, and then register it in the catalog.

  1. Create an MCP server scaffold. The following command creates a mymcp directory with a FastMCP Python server that includes an example_echo and an example_sum tool.

    arctl init mcp mymcp --framework fastmcp --language python \
      --description "Sample MCP server" \
      --image localhost:5001/mymcp:latest

    Example output:

    ✓ Created MCP server: mymcp (framework: fastmcp, language: python)
    
  2. Build the MCP server container image.

    arctl build mymcp

    Example output:

    Building MCP server image: localhost:5001/mymcp:latest
    ✓ Successfully built Docker image: localhost:5001/mymcp:latest
    
  3. If you use a Kind cluster, load the image into your cluster. Otherwise, make sure that your cluster can access the image that you built.

    kind load docker-image localhost:5001/mymcp:latest --name <cluster-name>
  4. Deploy the MCP server to your cluster.

    kubectl create namespace mcp-test
    kubectl apply -n mcp-test -f - <<EOF
    apiVersion: v1
    kind: Service
    metadata:
      name: mymcp
      labels:
        app: mymcp
    spec:
      selector:
        app: mymcp
      ports:
        - name: http
          port: 3000
          targetPort: 3000
      type: ClusterIP
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: mymcp
      labels:
        app: mymcp
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: mymcp
      template:
        metadata:
          labels:
            app: mymcp
        spec:
          containers:
            - name: mymcp
              image: localhost:5001/mymcp:latest
              imagePullPolicy: Never
              args: ["--transport", "http", "--host", "0.0.0.0", "--port", "3000"]
              ports:
                - containerPort: 3000
              readinessProbe:
                tcpSocket:
                  port: 3000
                initialDelaySeconds: 5
                periodSeconds: 10
    EOF
  5. Verify that the MCP server pod is running.

    kubectl get pods -n mcp-test

    Example output:

    NAME                     READY   STATUS    RESTARTS   AGE
    mymcp-6f7d9b8c4d-xvpzk   1/1     Running   0          30s
    
  6. Register the in-cluster server in the catalog.

    arctl apply -f - <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: MCPServer
    metadata:
      name: mymcp-remote
    spec:
      title: My MCP Server
      description: "In-cluster MCP server exposed via virtual gateway"
      remote:
        type: streamable-http
        url: http://mymcp.mcp-test.svc.cluster.local:3000/mcp
    EOF

    Example output:

    ✓ MCPServer/mymcp-remote (latest) created
    

Step 2: Expose the MCP servers on the gateway

  1. Create a Deployment resource for each MCP server you registered in the previous step. Each Deployment exposes the server through the gateway at the path you specify in runtimeConfig.route.pathSuffix. The full reachable path becomes /registry<pathSuffix>.

    arctl apply -f- <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: Deployment
    metadata:
      name: solo-docs
    spec:
      targetRef:
        kind: MCPServer
        name: solo-docs
        tag: latest
      runtimeRef:
        name: mcp-gateway
        kind: Runtime
      runtimeConfig:
        route:
          pathSuffix: /solo-docs
    EOF

    Example output:

    ✓ Deployment/solo-docs created
    
    arctl apply -f- <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: Deployment
    metadata:
      name: githubcopilot-mcp
    spec:
      targetRef:
        kind: MCPServer
        name: githubcopilot-mcp
        tag: latest
      runtimeRef:
        name: mcp-gateway
        kind: Runtime
      runtimeConfig:
        route:
          pathSuffix: /githubcopilot
    EOF

    Example output:

    ✓ Deployment/githubcopilot-mcp created
    
    arctl apply -f- <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: Deployment
    metadata:
      name: mymcp-remote
    spec:
      targetRef:
        kind: MCPServer
        name: mymcp-remote
        tag: latest
      runtimeRef:
        name: mcp-gateway
        kind: Runtime
      runtimeConfig:
        route:
          pathSuffix: /mymcp
    EOF

    Example output:

    ✓ Deployment/mymcp-remote created
    
    FieldDescription
    targetRefThe MCPServer and tag to deploy.
    runtimeRefThe Virtual runtime that links to the gateway resources.
    runtimeConfig.route.pathSuffixThe path appended to the parent HTTPRoute prefix. Must start with / and must be unique per runtime.
  2. Verify that all deployments are ready. The status.details.agentgateway.exposedAt field contains the gateway URLs that the MCP server is exposed on. You use these URLs in the next step to test MCP server access.

    arctl get deployments -o yaml

    Example output for the Solo.io docs MCP server:

    ...
    status:
      conditions:
      - lastTransitionTime: "2026-08-10T16:56:22.918848756Z"
        message: deployed via Agentgateway at 1 URL(s) across 1 binding(s)
        reason: DeployedViaAgentgateway
        status: "True"
        type: Ready
      details:
        agentgateway:
          exposedAt:
          - listener: http
            url: http://172.18.0.13/registry/solo-docs
          lastReconciledAt: "2026-08-10T16:56:22Z"
        deploymentController:
          lastAppliedFingerprint: sha256:db2021728c1ebece9e537e1bf223a174f1cecec346db444a39b0cb4577415bea
    ...
    

    If conditions[type=Ready].status is False, check the reason field for the cause.

    ReasonCause
    NoGatewayBoundNo Gateway or parent HTTPRoute in the cluster was found with the agentregistry.solo.io/runtime: mcp-gateway label. Verify that you added this label to the parent HTTPRoute that you created when you set up the virtual runtime.
    NoAcceptedListenerThe labeled Gateway exists but has no listener with Accepted=True and Programmed=True. Run kubectl describe gateway agentgateway-proxy -n agentgateway-system for details.
    MCPServerNotRemoteThe targeted MCPServer has no spec.remote field set. The Virtual runtime only supports remote MCPServers.
    InvalidRuntimeConfigruntimeConfig.route.pathSuffix is missing or does not start with /.
  3. Verify that the agentgateway controller created the corresponding backend resources. The registry reconciler automatically creates two types of EnterpriseAgentgatewayBackend resources for each MCP server.

    kubectl get enterpriseagentgatewaybackends -A

    Example output:

    NAMESPACE              NAME                                                 ACCEPTED   AGE
    agentregistry-system   gw-be-mcpserver-default-githubcopilot-mcp-ad756486   True       2m
    agentregistry-system   gw-be-mcpserver-default-mymcp-remote-2f3b6f39        True       2m
    agentregistry-system   gw-be-mcpserver-default-solo-docs-85fa95de           True       2m
    agentregistry-system   gw-dep-default-githubcopilot-mcp-b3c7dfdf            True       2m
    agentregistry-system   gw-dep-default-mymcp-remote-2978e6d3                 True       2m
    agentregistry-system   gw-dep-default-solo-docs-827635b0                    True       2m
    
    • gw-be-mcpserver-*: One per MCPServer catalog entry. Represents the upstream backend configuration for that server.
    • gw-dep-*: One per catalog Deployment. Configures the routing entry for each deployed instance.

Step 3: Verify MCP server access

Use the MCP Inspector to verify that the gateway routes traffic to each remote MCP server.

  1. Open the MCP Inspector tool.

    npx modelcontextprotocol/inspector#0.21.2
  2. Select Streamable HTTP as the transport type, enter the gateway URL for your MCP server from the status.details.agentgateway.exposedAt field, and click Connect.

    1. Enter the URL for the Solo.io docs server, such as http://172.18.0.4/registry/solo-docs.

    2. Navigate to the Tools tab and click List Tools. Verify that you see the search, get_chunks, and get_full_page tools.

    3. Select a tool and try it.

    1. Enter the URL for the GitHub Copilot server, such as http://172.18.0.13/registry/githubcopilot.

    2. Navigate to the Tools tab and click List Tools. Verify that you see the GitHub tools.

    3. Select the get_me tool and click Run Tool. Verify that you see your GitHub user information.

    1. Enter the URL for the in-cluster server, such as http://172.18.0.13/registry/mymcp.

    2. Navigate to the Tools tab and click List Tools. Verify that you see the example_echo and example_sum tools.

    3. Select the example_echo tool, enter a message string, and click Run Tool. Verify that the server echoes the message back.

Step 4: Require JWT authentication at the gateway

Require all MCP clients to present a valid JWT before the gateway routes any request to a remote MCP server.

The EnterpriseAgentgatewayPolicy resource that you create in this step configures the agentgateway proxy to validate each token’s signature against your IdP’s public keys and to verify the iss and aud claims before forwarding the request. Any request that arrives without a valid token is rejected with a 401 Unauthorized HTTP response at the proxy. Because the policy targets the parent HTTPRoute, it applies uniformly to every MCP server that is exposed on the gateway. No per-server configuration is needed. For more information about how agentgateway handles JWT authentication, see the agentgateway JWT auth docs.

  1. Derive the Keycloak JWKS path from your issuer URL and save it in an environment variable. The path must match the realm name in $KEYCLOAK_ISSUER exactly.

    export KEYCLOAK_JWKS_PATH="$(echo $KEYCLOAK_ISSUER | sed 's|.*\(/realms/[^/]*\).*|\1|')/protocol/openid-connect/certs"
    export KEYCLOAK_PORT=$(echo $KEYCLOAK_ISSUER | sed 's|.*:\([0-9]*\)/.*|\1|')
    echo $KEYCLOAK_JWKS_PATH
    echo $KEYCLOAK_PORT

    Example output:

    /realms/agentregistry/protocol/openid-connect/certs
    8080
    
  2. Apply the JWT authentication policy to the parent HTTPRoute of your remote MCP gateway. The policy validates the token’s signature by using Keycloak’s public key set and checks the iss and aud claims.

    kubectl apply -f - <<EOF
    apiVersion: enterpriseagentgateway.solo.io/v1alpha1
    kind: EnterpriseAgentgatewayPolicy
    metadata:
      name: mcp-gateway-jwt-auth
      namespace: agentgateway-system
    spec:
      targetRefs:
      - group: gateway.networking.k8s.io
        kind: HTTPRoute
        name: agentregistry-delegate
      traffic:
        jwtAuthentication:
          mode: Strict
          providers:
          - issuer: "${KEYCLOAK_ISSUER}"
            audiences:
            - ar-backend
            jwks:
              remote:
                jwksPath: "${KEYCLOAK_JWKS_PATH}"
                backendRef:
                  name: keycloak
                  namespace: keycloak
                  port: ${KEYCLOAK_PORT}
    EOF
    FieldDescription
    targetRefsAttaches the policy to the parent HTTPRoute. The JWT check applies to all MCP servers that are routed through the gateway.
    traffic.jwtAuthentication.modeStrict rejects requests without a valid JWT with a 401 Unauthorized HTTP response. Use Optional to allow unauthenticated requests while still extracting claims when a token is present.
    traffic.jwtAuthentication.providers[].issuerMust match the iss claim in the JWT. For Keycloak this is the realm URL.
    traffic.jwtAuthentication.providers[].audiencesThe aud claim value that you require in the token. The registry uses ar-backend as the audience for all tokens it issues.
    traffic.jwtAuthentication.providers[].jwks.remote.backendRefThe Kubernetes Service that hosts the JWKS endpoint. Adjust name, namespace, and port to match your Keycloak installation.
  3. Verify that the policy was accepted by the agentgateway controller.

    kubectl get enterpriseagentgatewaypolicies -A

    Example output:

    NAMESPACE             NAME                  ACCEPTED   ATTACHED   AGE
    agentgateway-system   mcp-gateway-jwt-auth  True       True       30s
    

    If a policy shows Accepted: False, run kubectl describe enterpriseagentgatewaypolicy <name> -n agentgateway-system to see the rejection reason in the status.ancestors[].conditions field.

  4. Export the gateway IP address from the Gateway resource status.

    export GATEWAY_IP=$(kubectl get gateway agentgateway-proxy -n agentgateway-system \
      -o jsonpath='{.status.addresses[0].value}')
    echo $GATEWAY_IP
  5. Confirm that the gateway now rejects unauthenticated requests. Send a request to the MCP servers that you exposed on the gateway proxy without an Authorization header and verify that the gateway returns a 401 Unauthorized HTTP response.

    curl -vik "http://${GATEWAY_IP}/registry/mymcp"
    curl -vik "http://${GATEWAY_IP}/registry/solo-docs"
    curl -vik "http://${GATEWAY_IP}/registry/githubcopilot"

    Example output:

    HTTP/1.1 401 Unauthorized
    content-length: 0
    authentication failure: no bearer token found
    
  6. Send an MCP initialize request with a valid JWT, and verify that the gateway routes the request to the MCP server.

    arctl user login
    export TOKEN=$(arctl user info --show-tokens | jq -r '.access_token')
    curl -vik \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}' \
      "http://${GATEWAY_IP}/registry/mymcp"
    curl -vik \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}' \
      "http://${GATEWAY_IP}/registry/solo-docs"
    curl -vik \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}' \
      "http://${GATEWAY_IP}/registry/githubcopilot"

    Example output:

    HTTP/1.1 200 OK
    content-type: text/event-stream
    ...
    event: message
    data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"mymcp","version":"0.1.0"}}}
    

    Note

    The GitHub Copilot request succeeds only if you provided a valid GH_PAT when you registered the MCP server in the registry catalog. The agentgateway proxy strips the incoming Keycloak JWT after validation and replaces the Authorization header with the Bearer ${GH_PAT} value from the MCPServer spec before forwarding the request to GitHub.

Step 5: Create a Keycloak group and user

In the next few steps, you explore how to restrict access to specific MCP servers and tools for a user group and a specific user. To test these steps, you must first create a non-admin user group and user.

In this step, you create developers group and a dev-user account in Keycloak.

Use the Keycloak Admin REST API to create a developers group and a user who belongs to it.

  1. Get an admin token for the Keycloak Admin REST API.

    export KEYCLOAK_ADMIN_TOKEN=$(curl -s \
      -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \
      -d "grant_type=password" \
      "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
      | jq -r .access_token)
  2. Create the developers group.

    curl -s -X POST \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      -H "Content-Type: application/json" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/groups" \
      -d '{"name": "developers"}'
  3. Create a dev-user account and assign it to the developers group.

    curl -s -X POST \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      -H "Content-Type: application/json" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users" \
      -d '{
        "username": "dev-user",
        "email": "dev-user@example.com",
        "firstName": "Dev",
        "lastName": "User",
        "enabled": true,
        "credentials": [{"type": "password", "value": "password", "temporary": false}]
      }'
    
    export DEV_USER_ID=$(curl -s \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users?username=dev-user" \
      | jq -r '.[0].id')
    
    export DEVELOPERS_GROUP_ID=$(curl -s \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/groups?search=developers" \
      | jq -r '.[0].id')
    
    curl -s -X PUT \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${DEV_USER_ID}/groups/${DEVELOPERS_GROUP_ID}"
  4. Verify that the developers group and dev-user were created and the group assignment is in place.

    curl -s \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/groups" \
      | jq '.[].name'
    curl -s \
      -H "Authorization: Bearer ${KEYCLOAK_ADMIN_TOKEN}" \
      "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users?username=dev-user" \
      | jq '.[0].username'

    Example output:

    "developers"
    "dev-user"
    
  1. Create an AccessPolicy that grants users in the developers group full access to MCP servers and deployments in the catalog.
    cat <<EOF | arctl apply -f -
    apiVersion: ar.dev/v1alpha1
    kind: AccessPolicy
    metadata:
      name: developers
    spec:
      principals:
        - kind: Role
          name: developers
      rules:
        - actions:
            - "registry:*"
          resources:
            - kind: "server"
              name: "*"
            - kind: "agent"
              name: "*"        
    EOF

Step 6: Restrict MCP server access by user group

By default, any authenticated user can send requests to all MCP servers through the gateway. To limit access to specific MCP servers and tools, you can add a backend.mcp.authorization CEL expression directly to the Deployment resource. The agentregistry reconciler translates the inline policy into the gateway backend configuration automatically. For a full list of supported CEL attributes, see the agentgateway CEL reference.

  1. Update the solo-docs and githubcopilot-mcp Deployments to restrict access for all users that belong to the developers group. The exists macro iterates over the jwt.Groups array claim; the leading ! inverts the result so that the expression is true for everyone who is not in the developers group.

    arctl apply -f - <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: Deployment
    metadata:
      name: solo-docs
    spec:
      targetRef:
        kind: MCPServer
        name: solo-docs
        tag: latest
      runtimeRef:
        name: mcp-gateway
        kind: Runtime
      runtimeConfig:
        route:
          pathSuffix: /solo-docs
        backend:
          mcp:
            authorization:
              action: Allow
              policy:
                matchExpressions:
                - '!jwt.Groups.exists(g, g == "developers")'
    ---
    apiVersion: ar.dev/v1alpha1
    kind: Deployment
    metadata:
      name: githubcopilot-mcp
    spec:
      targetRef:
        kind: MCPServer
        name: githubcopilot-mcp
        tag: latest
      runtimeRef:
        name: mcp-gateway
        kind: Runtime
      runtimeConfig:
        route:
          pathSuffix: /githubcopilot
        backend:
          mcp:
            authorization:
              action: Allow
              policy:
                matchExpressions:
                - '!jwt.Groups.exists(g, g == "developers")'
    EOF
    FieldDescription
    runtimeConfig.backend.mcp.authorization.actionAllow grants access when any matchExpression evaluates to true. Requests that match no expression are denied.
    matchExpressions[0]!jwt.Groups.exists(g, g == "developers") — evaluates to true for callers whose Groups claim does not contain developers.

    Example output:

    ✓ Deployment/solo-docs updated
    ✓ Deployment/githubcopilot-mcp updated
    
  2. Log in with the dev-user username and password password, and export a fresh token.

    arctl user logout
    arctl user login
    export TOKEN=$(arctl user info --show-tokens | jq -r '.access_token')
  3. Initialize a session to solo-docs and capture the session ID.

    curl -s --max-time 5 \
      -D /tmp/mcp-headers.txt \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}' \
      "http://${GATEWAY_IP}/registry/solo-docs" > /dev/null
    
    export SESSION_ID=$(grep -i "^mcp-session-id:" /tmp/mcp-headers.txt \
      | tr -d '\r' | awk '{print $2}')
    echo $SESSION_ID
  4. Confirm that the dev-user user cannot call tools on the solo-docs MCP server. When the gateway denies a tool call based on a CEL policy, it returns 400 with an Unknown tool error rather than 403, so that the restriction is not visible to the caller.

    curl -s -o /dev/null -w "%{http_code}\n" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "mcp-session-id: ${SESSION_ID}" \
      -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"istio"}}}' \
      "http://${GATEWAY_IP}/registry/solo-docs"

    Example output:

    400
    
  5. Confirm that the dev-user user can still call tools on the mymcp. Initialize a new session and call the example_echo tool.

    curl -s --max-time 5 \
      -D /tmp/mcp-headers.txt \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}' \
      "http://${GATEWAY_IP}/registry/mymcp" > /dev/null
    
    export SESSION_ID=$(grep -i "^mcp-session-id:" /tmp/mcp-headers.txt \
      | tr -d '\r' | awk '{print $2}')
    
    curl -s -o /dev/null -w "%{http_code}\n" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "mcp-session-id: ${SESSION_ID}" \
      -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"example_echo","arguments":{"message":"hello"}}}' \
      "http://${GATEWAY_IP}/registry/mymcp"

    Example output:

    200
    

Tip

If a policy does not behave as expected, use agctl proxy trace to open a live tracing window that shows the JWT validation and CEL authorization decisions the agentgateway proxy makes on each request. Install agctl from the agentgateway CLI install guide.

agctl proxy trace gateway/agentgateway-proxy -n agentgateway-system

In a separate terminal, send the request you want to inspect. The trace output shows each policy evaluation step, including the JWT claims that were extracted and whether any CEL expression matched or was denied. This is the fastest way to identify mismatched claims, expired tokens, or CEL expression errors.

Step 7: Restrict tool access for a specific user

In the previous step, you learned how to restrict access at the MCP server level for an entire user group. You can also restrict access at the tool level for a specific individual user. Add a backend.mcp.authorization CEL expression directly to the mymcp-remote Deployment that matches on the user’s jwt.sub claim. The agentregistry reconciler translates the inline policy into the gateway backend configuration automatically.

This step restricts the dev-user to only the example_echo tool on the mymcp-remote MCP server. Access to the example_sum tool is denied.

Tip

If you run into 401 errors during this step, your token has expired. Run the following commands to refresh the token.

arctl user logout
arctl user login
export TOKEN=$(arctl user info --show-tokens | jq -r '.access_token')
  1. Extract the sub claim from the JWT.

    export DEV_USER_SUB=$(arctl user info --show-tokens \
      | jq -r '.access_token | split(".") | .[1] | @base64d | fromjson | .sub')
    echo $DEV_USER_SUB

    Example output:

    fbb5e028-4ef7-41de-8a6b-88365c26287d
    
  2. Update the mymcp-remote Deployment to add the per-user tool restriction. In this example, you allow full access to users that do not match the $DEV_USER_SUB sub claim value that you retrieved earlier. If a user matches the $DEV_USER_SUB sub claim value, you allow access to the example_echo tool only. All other tools are denied. The shell expands ${DEV_USER_SUB} into the literal UUID before the YAML is sent to arctl, so the CEL expressions that are stored in the registry contain the actual sub claim value, not the variable name.

    arctl apply -f - <<EOF
    apiVersion: ar.dev/v1alpha1
    kind: Deployment
    metadata:
      name: mymcp-remote
    spec:
      targetRef:
        kind: MCPServer
        name: mymcp-remote
        tag: latest
      runtimeRef:
        name: mcp-gateway
        kind: Runtime
      runtimeConfig:
        route:
          pathSuffix: /mymcp
        backend:
          mcp:
            authorization:
              action: Allow
              policy:
                matchExpressions:
                - 'jwt.sub != "${DEV_USER_SUB}"'
                - 'jwt.sub == "${DEV_USER_SUB}" && mcp.tool.name == "example_echo"'
    EOF
    FieldDescription
    matchExpressions[0]jwt.sub != "${DEV_USER_SUB}" evaluates to true for any user who is not the dev-user. All tools are allowed for non-restricted users.
    matchExpressions[1]jwt.sub == "${DEV_USER_SUB}" && mcp.tool.name == "example_echo" evaluates to true only for the dev-user calling the example_echo tool. Any other tool call from this user matches no expression and is denied.

    Example output:

    ✓ Deployment/mymcp-remote updated
    
  3. Initialize a session to the mymcp-remote MCP server and capture the session ID.

    curl -s --max-time 5 \
      -D /tmp/mcp-headers.txt \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}' \
      "http://${GATEWAY_IP}/registry/mymcp" > /dev/null
    
    export SESSION_ID=$(grep -i "^mcp-session-id:" /tmp/mcp-headers.txt \
      | tr -d '\r' | awk '{print $2}')
    echo "Session ID: ${SESSION_ID}"
  4. Confirm that the dev-user can call the example_echo tool.

    curl -s -o /dev/null -w "%{http_code}\n" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "mcp-session-id: ${SESSION_ID}" \
      -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"example_echo","arguments":{"message":"hello"}}}' \
      "http://${GATEWAY_IP}/registry/mymcp"

    Example output:

    200
    
  5. Confirm that the dev-user cannot call the example_sum tool. When the gateway denies a tool call based on a CEL policy, it returns a 400 HTTP response with an Unknown tool error rather than 403, so that the policy enforcement is not visible to the caller.

    curl -s -o /dev/null -w "%{http_code}\n" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "mcp-session-id: ${SESSION_ID}" \
      -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"example_sum","arguments":{"a":1,"b":2}}}' \
      "http://${GATEWAY_IP}/registry/mymcp"

    Example output:

    400
    

Step 8: Limit request rates

Apply a local rate limit policy to the parent HTTPRoute to restrict the number of requests that users can send to your MCP servers. Requests that exceed the limit are rejected with 429 Too Many Requests. For more information, including per-tool rate limits with CEL descriptors, see the agentgateway rate limiting docs.

  1. Apply a rate limit policy to the parent HTTPRoute. The following policy limits requests to 1 request per minute, and allows 1 additional request in short bursts.

    kubectl apply -f - <<EOF
    apiVersion: enterpriseagentgateway.solo.io/v1alpha1
    kind: EnterpriseAgentgatewayPolicy
    metadata:
      name: mcp-gateway-rate-limit
      namespace: agentgateway-system
    spec:
      targetRefs:
      - group: gateway.networking.k8s.io
        kind: HTTPRoute
        name: agentregistry-delegate
      traffic:
        rateLimit:
          local:
          - requests: 1
            unit: Minutes
            burst: 1
    EOF
    FieldDescription
    traffic.rateLimit.localEnforced per proxy instance without coordination across replicas. Use traffic.rateLimit.global with an external rate-limit service when you need cluster-wide limits.
    requestsThe sustained request rate per unit.
    burstHow many additional requests are allowed in short bursts above the sustained rate.
  2. Verify that the policy was accepted.

    kubectl get enterpriseagentgatewaypolicies -A

    Example output:

    NAMESPACE             NAME                    ACCEPTED   ATTACHED   AGE
    agentgateway-system   mcp-gateway-jwt-auth    True       True       10m
    agentgateway-system   mcp-gateway-rate-limit  True       True       30s
    

    If a policy shows Accepted: False, run kubectl describe enterpriseagentgatewaypolicy <name> -n agentgateway-system to see the rejection reason.

  3. Send three tools/call requests in rapid succession using the session ID from Step 7. Because dev-user is restricted to the example_echo tool, use that tool for this test. The rate limit allows 1 request per minute with a burst of 1, so the first two requests succeed and the third is rejected. If ${SESSION_ID} has expired since Step 7, re-run the initialize command from Step 7 to get a fresh session ID before continuing.

    for i in 1 2 3; do
      echo -n "Request $i: "
      curl -s -o /dev/null -w "%{http_code}\n" \
        -H "Authorization: Bearer ${TOKEN}" \
        -H "Content-Type: application/json" \
        -H "Accept: application/json, text/event-stream" \
        -H "mcp-session-id: ${SESSION_ID}" \
        -d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"tools/call\",\"params\":{\"name\":\"example_echo\",\"arguments\":{\"message\":\"hello\"}}}" \
        "http://${GATEWAY_IP}/registry/mymcp"
    done

    Example output:

    Request 1: 200
    Request 2: 200
    Request 3: 429
    

    The first two requests succeed (1 base + 1 burst). The third is rejected with 429 Too Many Requests.

Congratulations! You have exposed three MCP servers through a central agentgateway proxy, required JWT authentication on all MCP traffic, restricted catalog visibility by user group, applied per-user tool filtering using JWT claims, and capped request rates to prevent abuse.

Next steps

With your MCP gateway up and running, choose a path based on what you want to do next.

Explore more agentgateway policies

Go deeper on the agentgateway policies you configured in this guide.

Add more registry access controls

Expand the catalog-level access controls beyond what this guide covers.