Most MCP security work is about who may do what. Rate limiting is about how often, and how much it costs when the answer is wrong. It gets skipped because it is not in the authorization specification, and then it shows up in a customer's questionnaire under availability, resource exhaustion, or acceptable use. This is how we design limits for an MCP server, and what we hand the reviewer afterwards.
Why an agent is not a normal API client
Your existing API limits were tuned for integrations written by people. An MCP client is driven by a model, and the traffic shape is different in four ways.
It retries on its own judgement. A tool that returns a vague error invites the model to call it again with slightly different arguments, several times, without a backoff.
It fans out. Ask an agent to "check every open invoice" and a single user turn becomes hundreds of tool calls in a few seconds.
It explores. Early calls in a session are often probing: list, search, list again with a different filter. That traffic is legitimate and it is expensive.
It spends money downstream. If a tool calls a paid API, a search cluster, or a model of your own, the cost of a loop is not just CPU. One user with one prompt can produce a bill nobody approved.
None of that is an attack. It is the normal behaviour of the thing you just connected. Limits exist so that normal behaviour has a ceiling, and so that an actual attack looks different from normal behaviour in your logs.
Pick the subject of the limit
The most common mistake is limiting by connection or by IP. An MCP client may hold one session for hours, and a hosted client fronts many end users behind a small number of egress addresses. Limit by the identities you already validate on every call.
- User. The subject claim from the access token. This is the limit that maps to "the agent cannot exceed the human".
- Tenant. The organization the user belongs to. Protects one customer from another and gives you a number to put in a contract.
- Client. The registered OAuth client identifier. Lets you throttle one misbehaving integration without touching the rest.
- Tool. Some tools are cheap reads and some trigger a payment. A single request-per-minute number across all of them is either too loose for the expensive ones or too tight for the cheap ones.
In practice the budget is a small matrix: per user per tool, with a per-tenant aggregate above it and a global ceiling above that. Keep it small enough to explain in one paragraph. A limit nobody can explain is a limit nobody tunes.
Cost units, not just call counts
Calls per minute is the wrong unit for tools with variable cost. A search that returns ten rows and a report that scans a year of data are not the same event.
Give each tool a weight and spend from a budget. Weight can approximate downstream calls, rows read, or cents. Two budgets work well: a short window that stops loops (a token bucket over seconds) and a long window that stops slow drains (a quota per user per day). The short window protects the service. The long window protects the invoice.
Write the weights down next to the tool definitions, in code, so they are reviewed when the tool changes.
Refuse in a way the model understands
On the HTTP layer, a throttled request is 429 Too Many Requests with a Retry-After header, per RFC 6585 and RFC 9110. Do that first, because proxies, gateways, and client libraries already understand it.
Inside the protocol, the refusal reaches a model, so the text matters. A JSON-RPC error or an error result should say three things and nothing else: that the limit was hit, which budget it was, and when to try again. For example: "Per-user quota for search_invoices exhausted. Resets in 42 seconds. Do not retry before then."
The wording is defensive. Vague failures produce retries. Explicit failures with a time produce waiting, or produce a sentence to the user, which is the outcome you want. Do not include internal counters, other users' activity, or tenant-wide numbers in the message; that is a side channel into another customer's usage.
Also decide what a limit does to a long-running task. If a tool call has been accepted as a task and the client is polling for the result, throttle the polling with a Retry-After, not by failing the task. Cancelling accepted work because the client asked twice is a bug, not a control.
Backpressure before rejection
Rejection is the last resort. Before it, three cheaper mechanisms usually do more.
Cap the response. Every list and search tool takes a limit argument, and the server enforces a maximum regardless of what the model asked for. Return a cursor rather than ten thousand rows. This alone removes most of the resource exhaustion in a first-generation server.
Queue and admit. For expensive tools, a small concurrency limit per tenant turns a burst into a wait instead of a failure. Admission control is friendlier than a 429 and keeps the work.
Make the expensive path asynchronous. If a job takes minutes, it should be a task with a status the client polls, not a request held open. Held-open requests are how one user consumes a connection pool.
Where the limiter lives
Counters have to be shared across instances, so a central store (Redis or equivalent) with atomic increments is the usual answer. Two properties matter for a review. Fail closed for the expensive tools: if the counter store is unavailable, an unmetered call to a paid downstream is worse than an error. And keep the limiter after authentication, because the subject of the limit is a claim from a validated token, not a header a caller controls.
If you run several MCP servers behind a gateway, put the tenant and global ceilings at the gateway and the per-tool weights in each server. The gateway knows about fleets. Only the server knows what a tool costs.
Make it visible in audit
A throttled call is an audit event like any other: timestamp, user, tenant, client, tool, decision rate_limited, and which budget fired. Two reasons. First, a reviewer asks how you would notice abuse, and "we alert on a rise in rate_limited events per tenant" is a real answer. Second, when a customer says the agent stopped working, the answer is in the log rather than in a guess.
Alert on the shape, not the count: a single user hitting the same tool limit repeatedly across hours, a tenant crossing its daily quota before noon, one client identifier throttled across many tenants. Those are the patterns that turn out to be either a broken agent loop or someone testing your edges.
What a reviewer actually asks
The questionnaire wording varies, but the questions are consistent. Per-user and per-tenant limits, what they are, and where they are enforced. What happens to other tenants when one tenant is abusive. Whether there is a cap on the data one call can return. Whether throttling is logged and alerted. Whether a customer can be told their own usage.
Have a one-page answer with the actual numbers in it, and a data-flow line showing the limiter sitting after token validation and before the tool handler. In our audits, this is one of the pages that is easiest to produce and most often missing.
Where to start
If you have nothing today, do this order. Cap result sizes on every list and search tool. Add a per-user token bucket over seconds to stop loops. Add a per-tenant daily quota for the three most expensive tools. Return 429 with Retry-After and an explicit message. Log the decision. That is a few days of work, and it covers most of what a security review will ask about resource exhaustion.
Limits are not a substitute for authorization, and no quota makes a badly scoped tool safe. They are the control that keeps a correct server from being expensive, and the one people find out they need after the first agent loop reaches production.
If you are sizing limits for an MCP server about to go through a customer's review, we can look at the tool set and tell you which tools need a budget and which do not. Some servers do not need more than a result cap.