MCP Server Rate Limiting: A Practical Checklist

MCP server rate limiting should protect expensive tool execution, not just count HTTP requests. An agent can make a few requests that trigger hundreds of upstream operations, while a legitimate client may generate frequent, inexpensive protocol traffic.

Use authenticated quotas, a shared token bucket, and separate concurrency controls. This checklist and Redis example show where to enforce those controls, how to return useful failures, and how to check that additional replicas don't multiply your allowance.

Rate limiting checklist

Start with these controls before exposing expensive tools:

  • Apply a coarse ingress limit before authentication to contain unauthenticated traffic.
  • Validate the access token, resolve a trusted principal and tenant, then apply their quotas.
  • Charge tool calls by expected work rather than treating every request equally.
  • Keep quota state shared across replicas, with an atomic admission decision.
  • Limit concurrent executions separately from request rate.
  • Reject excess work before starting upstream calls or side effects.
  • Return a clear retry signal without promising that every MCP client will honor it.
  • Test simultaneous requests, multiple identities, multiple replicas, and limiter failure.

These are application and infrastructure controls, not MCP configuration keys. The MCP transport specification defines how messages travel; it doesn't make a reverse proxy aware of the cost of your tools.

Put quota checks before expensive work

Use this request path for a remote HTTP server:

text
Ingress protection
  -> access-token validation
  -> trusted tenant and principal resolution
  -> request validation and authorization
  -> atomic quota admission
  -> concurrency admission
  -> tool execution

Ingress limits can use source IP as a rough abuse signal. Don't use IP as your only customer quota key: shared networks put unrelated users behind one address, and distributed callers can use multiple addresses.

For authenticated quotas, derive the identity from validated credentials and server-side authorization data. An illustrative internal key is:

text
quota:{tenant_42}:principal_17:expensive-tools

The tenant and principal identifiers here are application values, not MCP fields. Never accept them directly from tool arguments or an arbitrary request header. Don't key quotas by raw access tokens either: token rotation would reset the allowance and credentials could leak into operational data.

Keep authorization and quota decisions distinct. A valid token does not necessarily authorize a particular tool. Conversely, an authorized caller can still exceed its allowance. Follow the MCP authorization specification for the HTTP authorization flow; the site's OAuth implementation guide covers that setup separately.

Apply execution quotas after you know the requested tool and have validated its arguments. A batch export with a large row limit should not receive the same charge as a small metadata lookup. Continue to enforce hard argument limits even when the caller has tokens available.

Implement one atomic token bucket

A token bucket permits short bursts while maintaining a refill rate. Define a capacity, a refill rate, and a cost for each admitted operation.

For this example, choose a capacity of 20 tokens and a refill rate of one token per second. A metadata lookup might cost one token; an export might cost five. These are illustrative policy values, not recommended defaults or measured performance figures.

Use Redis to keep admission consistent across workers. The following Lua script reads the bucket, refills it, and deducts the operation's cost in one atomic execution. It uses Redis server time rather than application-server clocks.

lua
-- bucket.lua
-- KEYS[1]: bucket key
-- ARGV: capacity, refill tokens/second, operation cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])

if not capacity or not rate or not cost
   or capacity <= 0 or rate <= 0
   or cost <= 0 or cost > capacity then
  return redis.error_reply('invalid bucket configuration')
end

local clock = redis.call('TIME')
local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000
local state = redis.call('HMGET', KEYS[1], 'tokens', 'updated')
local tokens = tonumber(state[1]) or capacity
local updated = tonumber(state[2]) or now
local elapsed = math.max(0, now - updated)
tokens = math.min(capacity, tokens + elapsed * rate)

local allowed = 0
local retry_after = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retry_after = math.ceil((cost - tokens) / rate)
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'updated', now)
redis.call('EXPIRE', KEYS[1], math.ceil(2 * capacity / rate))
return {allowed, retry_after}

The expiration removes idle buckets after they would have refilled completely. Rejected requests still update the refill timestamp and balance; they don't spend additional tokens.

Call it with the official redis-py client:

server.ts
import os
from pathlib import Path

import redis

client = redis.Redis.from_url(os.environ['REDIS_URL'])
admit = client.register_script(Path('bucket.lua').read_text())

def check_quota(tenant_id, principal_id, cost):
    # IDs must come from trusted authorization data.
    key = f'quota:{{{tenant_id}}}:{principal_id}:expensive-tools'
    allowed, retry_after = admit(
        keys=[key],
        args=[20, 1, cost],
    )
    return bool(allowed), int(retry_after)

This synchronous helper belongs in a synchronous execution path. For an async server, use redis-py's async client and await the script call rather than blocking the event loop. Configure connection timeouts and Redis credentials for your environment.

Call the helper immediately before executing the tool. This isn't a complete MCP server or authentication implementation; it is the shared admission primitive you connect to your existing handler.

The script enforces one bucket. If you need both tenant-wide and per-principal admission, two separate calls aren't an atomic combined reservation: the first can spend tokens while the second rejects. Implement the combined decision in one script over colocated keys, or explicitly accept conservative overcharging. Redis Cluster requires keys used together by a script to share a hash slot.

Separate request rate from active work

A token bucket doesn't cap simultaneous executions. If a tool takes a long time, admitted calls can accumulate even when the arrival rate looks acceptable.

Add a concurrency permit around expensive work. For one process, a semaphore may be enough. Across replicas, use a shared lease mechanism or a queue with a bounded worker pool. A distributed permit needs an expiration and safe ownership checks so a crashed worker doesn't hold capacity forever. If executions can outlive the lease, renew it or terminate the work before the lease expires.

Also bound the work inside each call. Set maximum page counts, export sizes, upstream calls, and execution deadlines. Otherwise, a single admitted request can consume the entire upstream allowance.

Keep rate-limit denial separate from concurrency saturation in your telemetry. One means the caller is spending its allowance too quickly; the other means active work has filled available capacity. Their remedies differ.

Replica-local buckets are appropriate only when independent per-process allowances are intentional. For shared quotas, use the same backend and key derivation everywhere. See MCP Server Stateless Scaling: Sessions and Routing for the surrounding deployment concerns.

Return failures clients can act on

For an HTTP request rejected before MCP dispatch, return HTTP 429 Too Many Requests with Retry-After. This is ordinary HTTP behavior, documented in RFC 6585 and RFC 9110.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 5
Content-Type: application/json
Cache-Control: no-store

{"error":"rate_limited","retry_after_seconds":5}

That body is an application-defined HTTP error, not a JSON-RPC response or a standardized MCP error object. Set the delay from your limiter's result; five seconds is just an example. Another caller may consume newly refilled capacity, so the delay isn't a reservation.

If quota exhaustion is discovered inside an executing tool handler, return a tool execution error using the SDK's tool-result mechanism. The MCP tools specification defines isError for tool execution failures. Include a concise explanation and, when available, a retry delay in the result content. Don't invent a supposedly standard MCP rate-limit error code.

An upstream 429 may represent a shared provider quota, not the caller's own bucket. Record that distinction and avoid automatic immediate retries. For clients you control, use bounded retries, honor available delay information, and add jitter. Never blindly retry a write after an ambiguous timeout: the operation may already have completed.

Budget remote server cost and authentication work

Remote server price is not just the hosting bill. Tool calls can incur upstream API charges, database work, browser execution time, and retained output storage. A rate limit controls throughput; it doesn't automatically enforce a spending cap.

For expensive tools, maintain a separate usage budget. Reserve an estimated amount before execution, then reconcile actual usage afterward. Keep admission-token accounting separate from billing accounting: a failed call may still have consumed paid upstream work.

When finding or comparing servers through AllMCPs, inspect how the server handles upstream credentials and quota failures before installing it. Establish whose provider account pays, whether quotas are shared, and what a rejected tool call returns. Those details matter more than an advertised request allowance alone.

Authentication-platform selection matters here too. Verify that your chosen platform provides validated identity claims you can map to stable internal tenant and principal records. Identity-provider login throttling is not a substitute for MCP tool quotas. Keep access-token validation local where supported through cached verification keys; don't add an unnecessary identity-service request to every tool execution.

Verify admission before enabling real workloads

Run these checks in staging with a cheap tool or a stubbed upstream:

  1. Use a small bucket and send simultaneous calls. Confirm admission matches the bucket balance plus refill during the test.
  2. Send requests through two server replicas using the same identity. Confirm they share one allowance.
  3. Repeat with another principal and tenant. Confirm isolation and any tenant-wide limits.
  4. Saturate the concurrency limit with slow calls. Confirm excess work doesn't start upstream operations.
  5. Interrupt Redis connectivity. Confirm the server follows your chosen failure policy.
  6. Reject a write before execution and verify that no side effect occurred.

For costly or destructive tools, failing closed when quota state is unavailable is usually the safer choice. Return a temporary service failure rather than falsely reporting that the caller exhausted its quota. Low-cost reads may justify a tightly bounded local fallback, but that deliberately weakens the shared limit.

Measure admitted and rejected calls, limiter latency, active executions, upstream throttles, and estimated versus actual cost. Avoid principal IDs as metric labels; put scoped identifiers in access-controlled logs instead. The logging and observability guide covers that operational setup.