How to rate limit an MCP server
An MCP server is called by a language model, not a human clicking a button. That changes the rate-limiting math. A model in an agent loop can fire the same tool dozens of times in a second, retry on every error, or get stuck in a cycle where each tool result prompts another call. If one of your tools hits a paid API, a database, or an outbound fetch, an unbounded caller turns into a runaway bill or a denial of service against your own backend. Rate limiting is the seatbelt that keeps a misbehaving client from taking the whole server down.
Why MCP servers need it more than a normal API
A REST API mostly sees traffic shaped by real users. An MCP server sees traffic shaped by an LLM that has no instinct for cost and will happily retry forever. Three patterns make this acute:
- Agent loops. A tool result feeds back into the model, which calls again. A bad prompt or a confusing error message can produce a tight loop of identical calls.
- Fan-out. One user request can become many parallel tool calls when the model decides to "check everything."
- Amplification. A single cheap MCP call can trigger an expensive downstream action: an LLM completion, a paid search API, a large query. The cost is not where the limit usually sits.
So you are protecting two things at once: your upstream dependencies, and the client's own budget.
Per-client token buckets
The token bucket is the right primitive here. Each client gets a bucket with a maximum capacity (the burst) that refills at a fixed rate (the sustained rate). Every call removes one token. If the bucket is empty, the call is rejected or made to wait. This allows short bursts while capping the long-run average, which fits agent traffic well: a flurry of calls is fine, a sustained flood is not.
The key word is per-client. A single global limit means one noisy agent starves everyone else. You need to key the bucket on something stable. In rough order of preference:
- An authenticated identity (API key or OAuth subject) when your server has authentication. This is the only key an attacker cannot trivially spoof.
- A session ID for stateful transports, so each MCP session gets its own bucket.
- The source IP as a last resort for unauthenticated local servers. Weak, but better than a single global counter.
Here is a minimal, correct token bucket keyed per client. It uses lazy refill, so you do not need a background timer.
class TokenBucket {
constructor(capacity, refillPerSec) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.tokens = capacity;
this.last = Date.now();
}
take(n = 1) {
const now = Date.now();
const elapsed = (now - this.last) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
this.last = now;
if (this.tokens >= n) { this.tokens -= n; return true; }
return false;
}
}
const buckets = new Map();
function allow(clientKey) {
let b = buckets.get(clientKey);
if (!b) { b = new TokenBucket(20, 5); buckets.set(clientKey, b); } // burst 20, 5/sec
return b.take();
}
That gives each client a burst of 20 calls and a steady 5 calls per second. For multi-process or serverless deployments, move the counter into Redis (the INCR plus EXPIRE pattern, or a Lua token-bucket script) so the limit holds across instances. An in-memory map only works for a single process.
Where to enforce it: edge vs per-tool
You generally want both, at different granularities.
- At the edge (per request). A coarse limit on every incoming MCP call, applied before routing. This is your cheap, blunt protection against floods and loops. It does not care which tool was called.
- Per tool. A tighter limit on the expensive tools. The tool that calls a paid LLM or sends email should have its own small bucket, separate from the read-only
list_filestool that can run freely. A single global rate is either too loose for the costly path or too tight for the cheap one.
The cleanest place to put the edge check is a wrapper around your request handler. Reject early, before any work happens, and return a proper error rather than crashing.
async function handleToolCall(req, ctx) {
const key = ctx.clientId ?? ctx.sessionId ?? "anon";
// Edge limit: every call counts.
if (!allow("edge:" + key)) {
throw new McpError(-32000, "Rate limit exceeded. Slow down and retry.");
}
// Per-tool limit: only the expensive ones.
const costly = new Set(["web_search", "send_email", "run_query"]);
if (costly.has(req.tool) && !allow("tool:" + req.tool + ":" + key)) {
throw new McpError(-32000, `Rate limit for ${req.tool}. Try again shortly.`);
}
return runTool(req.tool, req.args, ctx);
}
Return errors the model can act on
An HTTP API returns 429 and a Retry-After header. MCP travels over JSON-RPC, so the model sees your error message, not the status code. Make the message tell the model what to do. "Rate limit exceeded, retry in 2 seconds" is far better than a generic failure, because the model reads it and can back off instead of hammering. A vague error often causes the exact retry storm you were trying to prevent.
- Say it is a rate limit, not a real failure, so the model does not treat the task as impossible.
- Give a rough wait time if you can compute one from the bucket state.
- Never echo back the full quota or reset internals that an attacker could use to time their abuse.
Common mistakes to check for
- One global limit shared by all clients, so a single agent can lock everyone out.
- Limiting only at the edge while an expensive tool runs unbounded behind it.
- An in-memory bucket on a multi-instance deploy, where the real limit is silently multiplied by the instance count.
- Counting calls but not weight, so one heavy call costs the same as one trivial one. Charge more tokens for heavier tools with
take(n). - No limit at all on a remote server with no auth, which is both a rate-limit and an access-control hole.
That last one is worth a scan of your config. The free mcp-audit tool flags remote MCP servers exposed without authentication or over cleartext, the same servers that tend to ship with no rate limiting either. It runs locally with zero dependencies, so it is a quick first check before you ship.
Ship rate limiting without writing it from scratch
MCP Forge Kit includes a per-client token-bucket limiter with edge and per-tool enforcement, Redis support, and tests, wired into a hardened server template.
Get MCP Forge Kit, €39Related: Authentication · Write a secure tool · Security checklist