ALL POSTS
mcprate limitingai agentsapi keys

Per-user rate limits for MCP servers: your 429 is invisible

OAuth hands your MCP server a verified subject claim. It does not hand you a counter. What to meter, where to hang it, and why the model on the other end never sees your 429.

Sorower

Sorower

Co-founder

Jul 26, 202614 min read
In this article

There is a line in the MCP authorization tutorial that lists the reasons to put OAuth in front of a remote server. The last one reads: "You want to implement rate limiting or usage tracking per user."

Then the page moves on and never mentions it again.

That is fair. The spec's job is to establish who is calling, not how much they are allowed to do. But per-user rate limits for MCP servers depend on both, and the gap between them is where a lot of servers are quietly bleeding money. OAuth 2.1 hands you a verified sub claim. A sub claim is not a counter.

This post is about the counter: what to meter, where to hang it, and the part almost nobody writes down, which is how to say "no" in a way the model on the other end can actually understand. If you have already read what a correct 429 looks like, this is the sequel where none of it works, because the client asking is not a person and does not read headers.

One prompt is not one request

Human traffic and agent traffic have different shapes, and every capacity assumption you carried over from your REST API is wrong in a way that costs you money.

A person clicking a button generates one request. A model deciding to use your search_invoices tool generates however many calls it takes to satisfy a plan it made two seconds ago. Ask it to "reconcile last quarter" and you might get one call, or you might get forty. The number is a function of the model's plan, and the plan is a function of a prompt you never see.

Then there is the retry behaviour, which is where it gets expensive. When a tool call fails in a way that looks fixable, a competent agent adjusts its arguments and tries again. That is not a bug. The tools specification says tool execution errors carry "actionable feedback that language models can use to self-correct," and that clients should pass them to the model for exactly that purpose. Wonderful when the argument was malformed. Ruinous when the actual reason was "you are out of credits," because no amount of parameter adjustment fixes an empty wallet.

Diagram of one user prompt expanding into initialize, tools/list, and repeated tools/call messages, with only tools/call marked billable

"So how many calls is one user?"

There is no number, and any plan built on assuming one is already wrong. This is the first thing to accept: you cannot meter humans here, because you cannot see them. You can only meter calls, attributed to a credential. Design for that and the rest of the problem gets tractable.

What per-user rate limits in an MCP server actually count

Reach for HTTP middleware first and you will meter the wrong thing. The Streamable HTTP transport is blunt about the traffic pattern: every JSON-RPC message the client sends must be its own HTTP POST to the MCP endpoint, and each POST body carries exactly one message. There is no batching to amortise anything.

So a naive per-POST counter charges your customer for the handshake:

MessageWhat it isShould it cost?
initializeVersion and capability negotiationNo
notifications/initializedClient acknowledging the handshake (answered with 202)No
tools/listDiscovery, re-run on every reconnectNo
pingLiveness checkNo
tools/callThe agent actually doing something to your systemYes

An agent that reconnects between turns re-runs the handshake and re-lists your tools every single time. Bill on POSTs and a chatty client pays a tax for discovering what you offer, which is both unfair and impossible to explain on an invoice.

The second half of this: tool calls are not interchangeable units. get_status and run_full_export cost you wildly different amounts of compute, and pricing them the same is how you end up with a customer whose bill is 3% of what they cost you. Weight each tool. It is the same argument as charging in credits rather than requests, and MCP makes the spread between your cheapest and most expensive call much wider than a typical REST API.

Hang the counter on the identity, not the session

The tempting key is Mcp-Session-Id. It is right there in the header, it is per-connection, and it looks like a user.

Do not use it. Two reasons, both in the spec. First, the transport says a server may terminate a session at any time, after which it must answer that session ID with a 404, and the client "MUST start a new session by sending a new InitializeRequest." A session-keyed bucket therefore resets whenever the connection churns, which turns your rate limit into a suggestion for anyone willing to reconnect. Second, the official security guidance says to treat Mcp-Session-Id as untrusted input and never tie authorization to it.

Use the token subject instead. The MCP Python SDK carries a comment on that field worth internalising:

class AccessToken(BaseModel):
    token: str
    client_id: str
    scopes: list[str]
    expires_at: int | None = None
    resource: str | None = None  # RFC 8707 resource indicator
    subject: str | None = None  # RFC 7662/9068 `sub`: resource owner; unique only per issuer
    claims: dict[str, Any] | None = None  # additional claims (e.g. `iss`, `act`)

Unique only per issuer. If you accept tokens from more than one authorization server, a bare sub is not a primary key, and two unrelated customers can collide into one quota bucket. Key on the pair:

from mcp.server.auth.middleware.auth_context import get_access_token

def caller_id() -> str:
    """Stable identity for the caller. Raises if the request is unauthenticated."""
    token = get_access_token()
    if token is None or token.subject is None:
        raise PermissionError("no authenticated caller")
    claims = token.claims or {}
    return f"{claims.get('iss', '')}|{token.subject}"

That string is the thing your counter belongs to, and it should map to a durable record in your own system rather than living in a dictionary that dies with the process. Where exactly that record sits is the same modelling question as where a multi-tenant quota belongs: the account, not the credential. In ReqKey the record is a consumer, and externalId is the field for your own user identifier, which keeps the mapping one lookup wide.

Your 429 is invisible to the model

Here is the part the existing writing on MCP rate limiting skips, and it is the part that decides whether your limit actually stops anything.

HTTP sits beneath JSON-RPC. When your middleware returns a 429, the MCP client's transport layer consumes it. What the model sees afterwards is whatever that client decides to surface, and clients differ wildly: some show the model a generic "tool call failed," some retry silently, some tear down the session and reconnect. The one thing you cannot count on is your carefully constructed Retry-After header reaching the thing that is generating the calls. You wrote a well-formed refusal in a language the reader does not speak.

Three cards comparing an HTTP 429, a JSON-RPC protocol error, and a tool execution error, and who actually sees each one

You have three ways to refuse a call, and they land very differently:

RefusalWho sees itWhat the agent does
HTTP 429 from middlewareThe client transport. The model, maybe.Client-dependent. Often a blind retry or a reconnect.
JSON-RPC protocol errorThe client. Clients may pass it on.Reads as a broken tool. Recovery unlikely, per the spec.
Tool result with isError: trueThe model. Clients should pass it on.Reads the text and acts on it.

Only the third one is addressed to the entity making the decisions. It is an ordinary successful JSON-RPC response carrying a flag:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Quota exhausted: 0 of 5000 monthly credits remaining. This tool will keep refusing until the quota resets on 2026-08-01. Do not retry. Tell the user to add credits at https://example.com/billing."
      }
    ],
    "isError": true
  }
}

Keep the 429 as well. Well-behaved clients and your own dashboards want it, and the transport-level signal is what protects you from a client that is looping below the model. But if you only send the 429, the model never finds out, and a determined agent will happily spend the next thirty seconds discovering that your API is having a bad day.

"Won't an error the model can read just make it retry harder?"

Yes. If you word it badly, that is precisely what happens, and this is the trap.

The isError channel exists so models can self-correct and try again. Hand it something that pattern-matches to a transient failure and you have written an instruction to retry. "Rate limit exceeded" reads, to a model, almost exactly like "try again in a moment." So does anything containing the word "temporarily."

You are not writing a log line here. You are writing a prompt, and it is going into the context of something that will act on it. Make the refusal terminal and specific:

Bad:   "Error: rate limit exceeded."
Bad:   "Service temporarily unavailable, please retry."
Good:  "Rate limit reached: 60 calls per minute. Wait 34 seconds before
        calling any tool on this server again. Retrying sooner will fail."
Good:  "Quota exhausted: 0 of 5000 credits remaining until 2026-08-01.
        Do not retry. Ask the user to upgrade their plan."

Name the limit, name the number, say plainly whether waiting helps, and say what a human needs to do. The difference between the first pair and the second is a retry storm you pay to serve.

Tell the agent what is left, before it runs out

Headers are for clients. Text is for models. Once you accept that, an obvious move follows: hand the agent its remaining budget in the tool result, so it can plan instead of discovering the wall by hitting it.

An agent that knows it has 12 credits left will do something sensible with them. An agent that knows nothing will fire off a 40-call export and fail on call 13, having spent your compute on 12 calls' worth of partial work. Every metered API already has this signal. In MCP you can put it somewhere the decision-maker actually reads.

Do it with restraint. Appending "4,987 credits remaining" to every response burns context on a number nobody needed, and there is a real cost to filling an agent's window with your accounting. Announce it when it matters: below a threshold, or when the call was expensive. That is the same idea as a shadow limit warning a customer before zero, aimed at a different reader.

WARN_BELOW = 500

def with_budget(text: str, remaining: int | None) -> str:
    if remaining is not None and remaining < WARN_BELOW:
        return f"{text}\n\n[{remaining} credits remaining this period.]"
    return text

If your tool declares an outputSchema, put the number in structuredContent too. The spec asks that structured results also be mirrored as serialized JSON in a text block for backwards compatibility, so the model still sees it either way.

Wiring it up

Here is the whole thing in the stable Python SDK. One note on versions before the code: mcp v1.x is the production line and the import below is correct for it. The v2 pre-release renames FastMCP to MCPServer and moves the import to mcp.server, so pin your dependency (mcp>=1.27,<2) unless you are deliberately tracking the beta.

This version uses ReqKey's Python SDK for the counter, because a per-user quota store is a boring thing to build and a genuinely annoying thing to operate correctly under concurrency. The structure is the same if you swap in your own Redis counter.

import logging

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from reqkey import ReqKey, ReqKeyError, VerificationReason

log = logging.getLogger(__name__)
mcp = FastMCP("invoices")
billing = ReqKey.from_env()          # reads REQKEY_PROJECT_KEY

# Weight each tool by what it actually costs you to serve.
TOOL_COST = {"search_invoices": 1, "export_invoices": 25}


def charge(tool: str) -> int | None:
    """Charge the caller for one tool call. Returns credits remaining."""
    # caller_id() is the issuer|subject helper from earlier; key_for_caller is
    # your own lookup from that identity to the key you issued the customer.
    key = key_for_caller(caller_id())

    try:
        result = billing.verify(key, credits=TOOL_COST[tool], resource=tool)
    except ReqKeyError as exc:
        # The metering service is unreachable. Decide this ahead of time.
        log.warning("metering unavailable, serving anyway: %s", exc)
        return None

    if result.valid:
        return result.credits_remaining

    if result.reason is VerificationReason.INSUFFICIENT_CREDITS:
        raise ToolError(
            f"Quota exhausted: 0 of {result.credits_limit} credits remaining. "
            f"This tool will keep refusing until the quota resets. Do not retry. "
            f"Ask the user to top up their plan."
        )

    if result.reason is VerificationReason.RATE_LIMITED:
        wait = int(result.retry_after or 60)
        raise ToolError(
            f"Rate limit reached. Wait {wait} seconds before calling any tool "
            f"on this server again. Retrying sooner will fail."
        )

    raise ToolError("This credential is not valid for this server. Ask the user to reconnect.")


@mcp.tool()
def export_invoices(quarter: str) -> str:
    """Export every invoice for a quarter, for example '2026-Q2'."""
    remaining = charge("export_invoices")
    rows = run_export(quarter)
    return with_budget(f"Exported {len(rows)} invoices for {quarter}.", remaining)

The ToolError is the load-bearing part. FastMCP catches an exception raised inside a tool and converts it into a CallToolResult with isError: true whose text is the exception message, so the string you write above is literally the string the model reads. Which is why it is written like an instruction and not like a stack trace.

What the client receives when the quota is gone:

{
  "jsonrpc": "2.0",
  "id": 12,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Quota exhausted: 0 of 5000 credits remaining. This tool will keep refusing until the quota resets. Do not retry. Ask the user to top up their plan."
      }
    ],
    "isError": true
  }
}

Three details in that code are worth calling out, because they are the ones that bite later.

The resource field is free per-tool analytics. ReqKey's /key/validate takes an optional resource string purely for monitoring, so passing the tool name means your usage breakdown arrives split by tool without any extra instrumentation. When a customer asks which tool ate their quota, you can answer.

Credits and rate limits are different axes. Credits meter how much, rate limits meter how fast, and an agent will find the edge of both in ways a human never would. A consumer with generous credits can still be throttled mid-loop, which is why the code branches on the two reasons separately and only one of them tells the model to wait. Throttled calls do not consume credits, so a client that slows down recovers on its own.

The except ReqKeyError branch is a policy decision, not error handling. The code above fails open: if the counter is unreachable, the call is served for free. That is the right default for a read-only tool and the wrong one for an expensive export. Make that choice deliberately, per tool, before the incident rather than during it. We wrote the long version of that argument in fail open or fail closed.

Where this is heading

Worth knowing while you build: the current specification revision is 2025-11-25, and a new release is dated 2026-07-28, with the Python SDK's v2 line targeted to land alongside it. Import paths will move. The shape of the problem will not. Identity comes from the token, the billable event is the tool call, and the refusal has to be legible to a model.

For background on the authorization half, this walkthrough of the OAuth specification is a solid hour:

Video: MCP Gets OAuth, Understanding the New Authorization Specification

Key takeaways

  • Meter tools/call, not HTTP requests. Every JSON-RPC message is its own POST, so a per-request counter bills your customers for the handshake and tool discovery they re-run on every reconnect.
  • Key the counter on issuer plus subject, never the session ID. Sessions can be terminated and restarted by design, and a restarted session is a fresh bucket for anyone who notices.
  • Return quota refusals as tool execution errors, not just a 429. HTTP lives below JSON-RPC, so the model that is generating the calls may never see your status code. Send both; only one of them reaches the decision-maker.
  • Word the refusal like an instruction, because it is one. The error channel exists for self-correction, so anything that reads as transient is an invitation to retry. Name the limit, the number, and whether waiting helps.
  • Weight tools by cost and show the balance when it is low. An agent told it has 12 credits left plans around them. An agent told nothing discovers the wall halfway through a 40-call job you already paid to run.

Try it against your own server

If you want to skip building the counter, /key/validate takes the credit cost per call and a resource label, which is exactly the shape an MCP tool needs: one call in, a decision plus a remaining balance out. The free tier includes 100,000 requests a month, which is enough to point a real agent at a real server and watch what it actually does to your quota. That number is usually the surprising part.

Start with the concepts docs, or read how plans and credits model the two axes if you are pricing tools rather than endpoints.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.