Configure tool-based rate limiting
NetScaler® rate limiting enforces request quotas on MCP traffic at the Gateway and on individual MCP routes, giving you control over how much each MCP server, tool, or client can consume. Requests that exceed a configured quota are rejected before they reach the upstream MCP server.
You define a quota with the
ratelimit CRD and attach it either to the Gateway (all MCP routes) or to a single mcproute (one MCP server). Because the Gateway understands the MCP protocol, the counter a request is charged against can be keyed on the routing header (per MCP server), the JSON-RPC tool name in the request body (per tool), or any request header such as a caller-identity header (per user) — not just the client IP address.
Requests exceeding the configured quota receive an
HTTP/1.1 429 Too Many Requests response before the request reaches the MCP server.
Note:
Rate limiting for MCP is supported on both NetScaler CPX (in-cluster) and NetScaler VPX/MPX/BLX (external) on NetScaler 14.1. The
ratelimit CRD is group citrix.com/v1beta1, while mcproute and mcpprofile are group citrix.com/v1.
Per-gateway vs per-route rate limits
A rate limit can be applied globally at the Gateway level (across every MCP route) or to a specific MCP server through an
mcproute.
| Type | How it binds | Scope |
|---|---|---|
| Per gateway | targetRef pointing to the Gateway resource |
All MCP routes on the Gateway |
| Per route | filters[].extensionRef in an mcproute rule |
One MCP server route only |
The
selector_keys field decides which counter a request is charged against:
-
Per MCP server — an
advancedselector on the routing headerx-netscaler-target-mcp-server. Each backend gets its own budget, so a noisy client of one MCP server cannot exhaust another's quota. -
Per tool — an
advancedselector on the JSON-RPCparams.namein the request body. An expensive tool can be capped without affecting the rest of the server. -
Per user — a
basicselector on a request header (for example, a caller-identity header). Each distinct header value gets its own budget.
Note:
Both a per-gateway and a per-route limit can be active at the same time. Requests that exceed either quota receive a
429 Too Many Requests response.
Configuration
Apply a per-MCP-server rate limit at the Gateway
This
ratelimit uses targetRef to reference the Gateway directly, so the quota applies to every MCP route on the Gateway. The advanced selector keys the counter on the MCP server name taken from the routing header, giving each backend an independent budget.
The following example allows 30 requests per MCP server over a 60-second window (
timeslice is in milliseconds). Requests over the quota are answered with a custom 429 Too Many Requests response that carries a Retry-After header (throttle_action: RESPOND with respond_with).
kubectl apply -f - <<EOF
apiVersion: citrix.com/v1beta1
kind: ratelimit
metadata:
name: mcp-per-server-ratelimit
namespace: default
spec:
gatewayClassName: "mcpgwclass"
targetRef:
- group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gw
namespace: default
selector_keys:
advanced:
# Bucket per MCP server: the part of the routing header before the trailing slash.
rules:
- 'HTTP.REQ.HEADER("x-netscaler-target-mcp-server").BEFORE_STR("/")'
include_base_selectors: false
goto_priority_expression: END
ratelimits:
- req_threshold: 30
timeslice: 60000
limittype: BURSTY
mode: REQUEST_RATE
throttle_action: "RESPOND"
respond_with: '"HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nRetry-After: 60\r\nConnection: close\r\n\r\n{\"error\":\"Rate limit exceeded for MCP backend - retry after 60s\"}"'
EOF
The selector keys on the routing header the client sends, for example
x-netscaler-target-mcp-server: everything.mcp.example/. The trailing slash is required: the expression takes the value BEFORE_STR("/"), so everything.mcp.example/ is charged to the everything.mcp.example counter. Routing on this header is covered in MCP routing.
Important:
goto_priority_expression: END is required whenever throttle_action is RESPOND. A RESPOND action is a terminating responder, and NetScaler rejects a terminating responder bound with NEXT.
Apply a per-tool rate limit on an MCP route
This
ratelimit caps a single tool. The advanced selector reads the JSON-RPC params.name from the request body, so two different tool names get two different counters. The match narrows the limit to tools/call, which keeps the initialize and tools/list handshake — that every client must complete — outside the bucket.
The following example allows 3 calls of any one tool over a 120-second window. Requests over the quota are answered with a JSON-RPC error (code
-32003) that echoes the caller's jsonrpc and id, so an MCP client renders it as a tool error in the conversation instead of a transport failure.
kubectl apply -f - <<EOF
apiVersion: citrix.com/v1beta1
kind: ratelimit
metadata:
name: mcp-everything-tool-ratelimit
namespace: default
spec:
gatewayClassName: "mcpgwclass"
selector_keys:
advanced:
# Bucket per tool name from the JSON-RPC body.
rules:
- 'HTTP.REQ.BODY(100000).XPATH_JSON(xp%/params/name%)'
include_base_selectors: false
# Count only tools/call, so the handshake stays outside the quota.
match: 'http.req.header("Content-Type").EQ("application/json") && http.req.body(100000).xpath_json(xp%boolean(//method)%) && http.req.body(100000).xpath_json(xp%/method%).eq("tools/call")'
goto_priority_expression: END
ratelimits:
- req_threshold: 3
timeslice: 120000
limittype: BURSTY
mode: REQUEST_RATE
throttle_action: "RESPOND"
respond_with: '"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"jsonrpc\":\"" + http.req.body(100000).xpath_json(xp%/jsonrpc%) + "\",\"id\":" + http.req.body(100000).xpath_json(xp%/id%) + ",\"error\":{\"code\":-32003,\"message\":\"Rate limit exceeded for this tool. Retry in 2 minutes.\"}}"'
EOF
Bind the rate limit to the MCP route
A per-route
ratelimit takes effect only when an mcproute rule references it through an ExtensionRef filter. For extensionRef, the group is always citrix.com and the kind is ratelimit. The following mcproute routes the everything MCP server and applies the per-tool quota created above. The backend profile is referenced by a second ExtensionRef filter.
kubectl apply -f - <<EOF
apiVersion: citrix.com/v1
kind: mcproute
metadata:
name: mcp-everything
namespace: default
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gw
namespace: default
rules:
- matches:
- headers:
- name: x-netscaler-target-mcp-server
type: MCPTargetServer
value: everything.mcp.example
filters:
- type: ExtensionRef
extensionRef:
group: citrix.com
kind: mcpprofile
name: mcp-everything-backend
- type: ExtensionRef
extensionRef:
group: citrix.com
kind: ratelimit
name: mcp-everything-tool-ratelimit
backendRefs:
- kind: Service
name: mcp-everything
port: 3001
EOF
Note:
The header match value in the
mcproute has no trailing slash (everything.mcp.example), while the client sends the header with the trailing slash (everything.mcp.example/).
Rate limit per user
To cap each caller individually, use a
basic selector keyed on a request header that identifies the user (for example, a header set by your client or an upstream authentication layer). Each distinct header value gets its own counter. This limit can be attached to the Gateway with targetRef (as shown) or to a single MCP route with an ExtensionRef filter.
The following example allows each user 30 requests over a 60-second window. With
throttle_action: RESPOND and no respond_with, exceeding the quota returns a default 429 Too Many Requests response.
kubectl apply -f - <<EOF
apiVersion: citrix.com/v1beta1
kind: ratelimit
metadata:
name: mcp-per-user-ratelimit
namespace: default
spec:
gatewayClassName: "mcpgwclass"
targetRef:
- group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gw
namespace: default
selector_keys:
basic:
header_name: "X-User-Id"
goto_priority_expression: END
ratelimits:
- req_threshold: 30
timeslice: 60000
limittype: BURSTY
mode: REQUEST_RATE
throttle_action: "RESPOND"
EOF