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
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
developersgroup 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.
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.
Install Solo Enterprise for agentregistry and the
arctlCLI.Verify that you are logged in by running the following command.
arctl user whoamiPort-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:12121Install 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_sumandexample_echotool.
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
EOFExample 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.
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>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}" EOFExample 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.
Create an MCP server scaffold. The following command creates a
mymcpdirectory with a FastMCP Python server that includes anexample_echoand anexample_sumtool.arctl init mcp mymcp --framework fastmcp --language python \ --description "Sample MCP server" \ --image localhost:5001/mymcp:latestExample output:
✓ Created MCP server: mymcp (framework: fastmcp, language: python)Build the MCP server container image.
arctl build mymcpExample output:
Building MCP server image: localhost:5001/mymcp:latest ✓ Successfully built Docker image: localhost:5001/mymcp:latestIf 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>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 EOFVerify that the MCP server pod is running.
kubectl get pods -n mcp-testExample output:
NAME READY STATUS RESTARTS AGE mymcp-6f7d9b8c4d-xvpzk 1/1 Running 0 30sRegister 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 EOFExample output:
✓ MCPServer/mymcp-remote (latest) created
Step 2: Expose the MCP servers on the gateway
Create a
Deploymentresource for each MCP server you registered in the previous step. Each Deployment exposes the server through the gateway at the path you specify inruntimeConfig.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 EOFExample output:
✓ Deployment/solo-docs createdarctl 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 EOFExample output:
✓ Deployment/githubcopilot-mcp createdarctl 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 EOFExample output:
✓ Deployment/mymcp-remote createdField Description 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.Verify that all deployments are ready. The
status.details.agentgateway.exposedAtfield 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 yamlExample 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].statusisFalse, check thereasonfield for the cause.Reason Cause NoGatewayBoundNo Gateway or parent HTTPRoute in the cluster was found with the agentregistry.solo.io/runtime: mcp-gatewaylabel. 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=TrueandProgrammed=True. Runkubectl describe gateway agentgateway-proxy -n agentgateway-systemfor details.MCPServerNotRemoteThe targeted MCPServer has no spec.remotefield set. The Virtual runtime only supports remote MCPServers.InvalidRuntimeConfigruntimeConfig.route.pathSuffixis missing or does not start with/.Verify that the agentgateway controller created the corresponding backend resources. The registry reconciler automatically creates two types of
EnterpriseAgentgatewayBackendresources for each MCP server.kubectl get enterpriseagentgatewaybackends -AExample 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 2mgw-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.
Open the MCP Inspector tool.
npx modelcontextprotocol/inspector#0.21.2Select Streamable HTTP as the transport type, enter the gateway URL for your MCP server from the
status.details.agentgateway.exposedAtfield, and click Connect.Enter the URL for the Solo.io docs server, such as
http://172.18.0.4/registry/solo-docs.Navigate to the Tools tab and click List Tools. Verify that you see the
search,get_chunks, andget_full_pagetools.Select a tool and try it.
Enter the URL for the GitHub Copilot server, such as
http://172.18.0.13/registry/githubcopilot.Navigate to the Tools tab and click List Tools. Verify that you see the GitHub tools.
Select the
get_metool and click Run Tool. Verify that you see your GitHub user information.
Enter the URL for the in-cluster server, such as
http://172.18.0.13/registry/mymcp.Navigate to the Tools tab and click List Tools. Verify that you see the
example_echoandexample_sumtools.Select the
example_echotool, 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.
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_ISSUERexactly.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_PORTExample output:
/realms/agentregistry/protocol/openid-connect/certs 8080Apply 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
issandaudclaims.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} EOFField Description targetRefsAttaches the policy to the parent HTTPRoute. The JWT check applies to all MCP servers that are routed through the gateway. traffic.jwtAuthentication.modeStrictrejects requests without a valid JWT with a401 UnauthorizedHTTP response. UseOptionalto allow unauthenticated requests while still extracting claims when a token is present.traffic.jwtAuthentication.providers[].issuerMust match the issclaim in the JWT. For Keycloak this is the realm URL.traffic.jwtAuthentication.providers[].audiencesThe audclaim value that you require in the token. The registry usesar-backendas the audience for all tokens it issues.traffic.jwtAuthentication.providers[].jwks.remote.backendRefThe Kubernetes Service that hosts the JWKS endpoint. Adjust name,namespace, andportto match your Keycloak installation.Verify that the policy was accepted by the agentgateway controller.
kubectl get enterpriseagentgatewaypolicies -AExample output:
NAMESPACE NAME ACCEPTED ATTACHED AGE agentgateway-system mcp-gateway-jwt-auth True True 30sIf a policy shows
Accepted: False, runkubectl describe enterpriseagentgatewaypolicy <name> -n agentgateway-systemto see the rejection reason in thestatus.ancestors[].conditionsfield.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_IPConfirm that the gateway now rejects unauthenticated requests. Send a request to the MCP servers that you exposed on the gateway proxy without an
Authorizationheader and verify that the gateway returns a401 UnauthorizedHTTP 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 foundSend an MCP
initializerequest 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_PATwhen you registered the MCP server in the registry catalog. The agentgateway proxy strips the incoming Keycloak JWT after validation and replaces theAuthorizationheader with theBearer ${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.
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)Create the
developersgroup.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"}'Create a
dev-useraccount and assign it to thedevelopersgroup.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}"Verify that the
developersgroup anddev-userwere 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"
- Create an AccessPolicy that grants users in the
developersgroup 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.
Update the
solo-docsandgithubcopilot-mcpDeployments to restrict access for all users that belong to thedevelopersgroup. Theexistsmacro iterates over thejwt.Groupsarray claim; the leading!inverts the result so that the expression istruefor everyone who is not in thedevelopersgroup.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")' EOFField Description runtimeConfig.backend.mcp.authorization.actionAllowgrants access when anymatchExpressionevaluates totrue. Requests that match no expression are denied.matchExpressions[0]!jwt.Groups.exists(g, g == "developers")— evaluates totruefor callers whoseGroupsclaim does not containdevelopers.Example output:
✓ Deployment/solo-docs updated ✓ Deployment/githubcopilot-mcp updatedLog in with the
dev-userusername andpasswordpassword, and export a fresh token.arctl user logout arctl user login export TOKEN=$(arctl user info --show-tokens | jq -r '.access_token')Initialize a session to
solo-docsand 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_IDConfirm that the
dev-useruser cannot call tools on thesolo-docsMCP server. When the gateway denies a tool call based on a CEL policy, it returns400with anUnknown toolerror rather than403, 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:
400Confirm that the
dev-useruser can still call tools on themymcp. Initialize a new session and call theexample_echotool.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-systemIn 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')Extract the
subclaim from the JWT.export DEV_USER_SUB=$(arctl user info --show-tokens \ | jq -r '.access_token | split(".") | .[1] | @base64d | fromjson | .sub') echo $DEV_USER_SUBExample output:
fbb5e028-4ef7-41de-8a6b-88365c26287dUpdate the
mymcp-remoteDeployment to add the per-user tool restriction. In this example, you allow full access to users that do not match the$DEV_USER_SUBsub claim value that you retrieved earlier. If a user matches the$DEV_USER_SUBsub claim value, you allow access to theexample_echotool only. All other tools are denied. The shell expands${DEV_USER_SUB}into the literal UUID before the YAML is sent toarctl, 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"' EOFField Description matchExpressions[0]jwt.sub != "${DEV_USER_SUB}"evaluates totruefor any user who is not thedev-user. All tools are allowed for non-restricted users.matchExpressions[1]jwt.sub == "${DEV_USER_SUB}" && mcp.tool.name == "example_echo"evaluates totrueonly for thedev-usercalling theexample_echotool. Any other tool call from this user matches no expression and is denied.Example output:
✓ Deployment/mymcp-remote updatedInitialize a session to the
mymcp-remoteMCP 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}"Confirm that the
dev-usercan call theexample_echotool.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:
200Confirm that the
dev-usercannot call theexample_sumtool. When the gateway denies a tool call based on a CEL policy, it returns a400HTTP response with anUnknown toolerror rather than403, 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.
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 EOFField Description traffic.rateLimit.localEnforced per proxy instance without coordination across replicas. Use traffic.rateLimit.globalwith 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. Verify that the policy was accepted.
kubectl get enterpriseagentgatewaypolicies -AExample output:
NAMESPACE NAME ACCEPTED ATTACHED AGE agentgateway-system mcp-gateway-jwt-auth True True 10m agentgateway-system mcp-gateway-rate-limit True True 30sIf a policy shows
Accepted: False, runkubectl describe enterpriseagentgatewaypolicy <name> -n agentgateway-systemto see the rejection reason.Send three
tools/callrequests in rapid succession using the session ID from Step 7. Becausedev-useris restricted to theexample_echotool, 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 theinitializecommand 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" doneExample output:
Request 1: 200 Request 2: 200 Request 3: 429The 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.