# Implementing Rate Limiting and Cost Ceilings in MCP Server Middleware

> Platform Engineer’s field notes on the middleware that keeps an MCP server from spending next quarter’s AI budget before lunch.
## 1. Introduction: The $1,000 Recursive Loop — Why Governance Must Be Architectural
Every MCP team has the same origin story. The agent calls a tool, the tool returns an error, the agent retries with different arguments, the tool errors again — and because each attempt *looks* like progress to the model, the loop never self-terminates. Meanwhile every iteration appends its own output to an ever-growing context window, and per-token pricing quietly multiplies: forty steps of a “three-step task,” each one reading an escalating transcript, adds up to a conversation that bills like a small data center. No malice, no prompt injection, no rogue employee. Just one tool with ambiguous error semantics and no ceiling above it.
That is the $1,000 recursive loop, and the reason it keeps happening is that most teams treat cost as a *reporting* problem. They discover it in the provider’s billing export. Governance that arrives after the invoice is not governance — it is archaeology.
The fix is architectural: put the limits in the path that every model interaction already crosses. In a Model Context Protocol (MCP) deployment, that path is the server. Every tool call — from Claude, GPT-4-class models, or an open-weights deployment in your own VPC — arrives as a JSON-RPC `tools/call` over a streamable HTTP or SSE transport and lands in one dispatch layer. If rate limits, budget ceilings, and audit live in that layer as middleware, then *no client can route around them* — the middleware does not ask nicely. That seam is why we treat MCP as a governance boundary, not another API gateway: done badly, an MCP server is just a slower way to call your database; done well, it is the only place agents cross into your business logic, and it enforces the rules there.
This post is the playbook we use at OnlineCloud when we wire MCP servers to real systems — Oracle, MS SQL, Postgres, CRMs — with real budgets attached. Three layers: **token-weighted rate limiting** so no tenant can monopolize the pipeline, **hard cost ceilings** enforced before a single token is spent, and **observability** that turns spend and throughput into two lines on a live dashboard instead of a surprise in a billing export.
## 2. Token-Based Rate Limiting: Leaky Bucket and Fixed Window per Tenant
Request-level rate limits are the first thing everyone reaches for, and the first thing that fails. Ten requests per second tells you nothing about load when one request asks the model to reason over a 40,000-token context and the next is a two-line classification. Agents amplify the skew: a single planning loop can fire dozens of tool calls in a burst, each carrying a different payload weight. **Rate limiting for agentic traffic has to be denominated in tokens, not requests.**
That means counting what each tool call will plausibly consume before admission — an estimate derived from the arguments it carries and the model class it targets — and charging the tenant’s bucket by that weight. That way, flooding the pipeline with tiny calls and running amok on huge context become the same accounting problem.
Two algorithms dominate for per-tenant control, and they answer different questions:
**Fixed window** is brutally simple: a counter per tenant per window (say, 60 seconds). One atomic increment against a TTL’d Redis key — but with a well-known seam: a tenant that exhausts the window at second 59 can immediately spend a fresh window at second 60, doubling its limit in adjacent bursts.
**Leaky bucket** smooths that out. Tokens drip out of the bucket at a constant refill rate, and a call that would overflow the bucket is rejected (or queued) regardless of where it lands in the clock. Bursts are bounded by bucket capacity; sustained load is bounded by the drip rate. For agentic workloads the leaky bucket is usually the right default: agent loops produce *clusters* of calls, and you want each cluster bounded without penalizing steady background traffic.
A per-tenant leaky bucket in Node.js is small enough to inline:
“`ts
type TenantBucket = { tokens: number; lastRefill: number };
class TokenLeakyBucket {
constructor(
private capacity: number, // max burst, in tokens
private refillPerSec: number, // steady-state drip
) {}
tryAcquire(tenant: string, weight: number, now = Date.now()): boolean {
const b = (this.buckets.get(tenant) ??= {
tokens: this.capacity,
lastRefill: now,
});
// Drip: refill is proportional to elapsed time.
b.tokens = Math.min(
this.capacity,
b.tokens + ((now – b.lastRefill) / 1000) * this.refillPerSec,
);
b.lastRefill = now;
if (b.tokens < weight) return false; // 429 → retry after
b.tokens -= weight; // atomic in Redis
return true;
}
private buckets = new Map
}
“`
In a multi-instance deployment, the same logic moves into a Redis `EVAL` script so the bucket is shared across replicas; the algorithm is identical, only the store changes. What *must not* change is that the token weight is computed in one place — the governance layer — so a tool that fans out a sub-agent or streams a large document gets charged its true weight, not the weight of a cheap request.
| Algorithm | Burst behavior | Smooths load? | Implementation cost | Best for |
|—|—|—|—|—|
| Fixed window | Boundary bursts (2× limit) | No | Trivial (INCR + TTL) | Coarse quotas, alerting thresholds |
| Leaky bucket | Bounded by capacity | Yes | Simple (refill + subtract) | Agentic, bursty per-tenant traffic |
| Token bucket | Bounded, allows credit accumulation | Partial | Simple | APIs with periodic batch jobs |
Whichever you pick, per-tenant state must be keyed by an authenticated tenant identity — never by IP or connection — because an agent session can hop transports and reconnect. And rejections must return a *structured, retryable* error the model can actually handle: an MCP error result with code and a `Retry-After`-style hint, so the agent backs off instead of looping harder. Rate limiting that fails closed with an unparseable error just moves the recursion from your bill to your error logs.
## 3. Cost Ceilings: Real-Time Token Counting and Hard Budgets at the Protocol Layer
Rate limiting answers “how fast may this tenant go?” Cost ceilings answer a different question: “how much may this tenant *cost* before someone human gets involved?” A well-behaved tenant at modest throughput can still burn a shocking daily bill if it is running a wide agent over a long conversation — no individual request was excessive, so throughput limits never see the problem.
The enforcement unit is the token, and the discipline is **check before you spend, not after.** Counting happens in the request path, where the middleware knows three numbers for every call: input tokens, output tokens (or a strict cap), and the price per token for the model actually serving the tenant. For the input side you can count deterministically — arguments are fully known before dispatch — using a tokenizer matched to the model family (the `cl100k_base` / `o200k_base` BPE encodings for the GPT/Claude-class models). For the output side you cannot know the future, so you enforce a *bound*: a per-call `max_tokens` ceiling derived from the tenant’s remaining budget. Every call is then:
1. **Pre-check** — estimate input tokens, take the bounded output allowance, and ask the tenant ledger: *does the remaining budget cover this step?* If not, refuse with a structured error and an escalation path. The agent cannot nickel-and-dime its way past the ceiling, because every call is checked against what *remains*.
2. **Execute** — the call runs with a hard output cap.
3. **Post-charge** — actual tokens observed on the response are debited from the ledger. In streaming mode, charge incrementally: accumulate tokens from the stream as they arrive and abort the stream the moment the step budget is exhausted, so a runaway generation dies mid-stream rather than completing and billing.
The ledger is a per-tenant, per-period budget — `{ tenant, period, model, spentTokens }` in Redis with a daily rollover — and the middleware treats it like a resource limit on a container. Memory limits do not ask the process nicely to stop allocating; the kernel kills it. Your cost ceiling should have the same temperament.
“`ts
interface BudgetGate {
remaining(tenant: string): number; // dollars, this period
reserve(tenant: string, estCost: number): boolean; // pre-check
settle(tenant: string, actualTokens: number): void; // post-charge
}
const budgetGate: BudgetGate = {
reserve(tenant, estCost) {
if (ledger.remaining(tenant) < estCost) {
// Hard stop: structured MCP error → agent escalates to a human.
throw mcpError("BudgetExceeded",
`Tenant ${tenant} has ${ledger.remaining(tenant).toFixed(2)} left ` +
`for this estimated ${estCost.toFixed(2)} call`);
}
ledger.debit(tenant, estCost); // optimistic hold
return true;
},
settle(tenant, actualCost) {
ledger.adjust(tenant, actualCost); // reconcile hold vs. reality
},
};
```
Two properties make this a *hard* ceiling rather than an aspiration. First, the pre-check runs at the protocol layer, in the middleware — not in the tool implementation, and certainly not in the prompt. No tool author can forget to check the budget, because they never see the request until the gate has passed it. Second, ceilings compose: per-call, per-run (an agent loop gets a max-iteration cap and a dollar cap), and per-tenant daily caps. A loop that hits the run cap terminates *gracefully* — a structured handoff to a human checkpoint — not an agent retrying a refund call against an empty ledger.
## 4. Middleware Design: Wrapping MCP Tool Calls with Governance Logic
MCP gives you a clean place to stand: the server owns the tool registry, and every client — any agent framework, any host — reaches business logic only through `tools/call`. Wrap that dispatch in a middleware chain and governance becomes a property of the *server*, not of the clients. One chain, in order: **authenticate → rate-limit → budget-gate → audit → invoke → settle**.
With the TypeScript MCP SDK the pattern is a higher-order function around tool registration:
```ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
type GuardedTool = {
name: string;
costClass: "cheap" | "standard" | "expensive"; // → token weight + price
run: (args: unknown, ctx: CallContext) => Promise
};
export function registerGuarded(server: Server, tool: GuardedTool) {
server.registerTool(tool.name, {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (args, extra) => {
const tenant = resolveTenant(extra.session); // auth first
rateLimiter.tryAcquire(tenant, weight(tool, args)); // tokens/sec
const reserved = budgetGate.reserve(tenant, estCost(tool, args));
const started = now();
audit.log({ tenant, tool: tool.name, phase: “start”, args }); // decision log
try {
const result = await tool.run(args, { tenant });
const cost = settleFromUsage(result, tool.costClass);
budgetGate.settle(tenant, cost); // debit actual
audit.log({ tenant, tool: tool.name, phase: “done”, cost });
emitMetrics(tenant, tool, cost, now() – started); // → dashboards
return result;
} catch (err) {
audit.log({ tenant, tool: tool.name, phase: “error”, err });
throw err;
}
},
});
}
“`
The same shape ports to Go (`github.com/mark3labs/mcp-go` or the official SDK): a wrapper that takes a tool handler and returns a guarded one, with the chain expressed as composable middleware structs. What matters is not the language but the invariants. **Every tool gets the same chain** — no tool can declare itself exempt, because exemption would just become the bug that produces the incident. **Weights and prices are declared per tool** (`costClass`) so policy lives with the schema, not scattered across handlers. And **every refusal and every charge is an auditable event** with a trace ID, so when finance asks why tenant X was throttled at 14:02, the answer is a query, not a meeting.
Keep the middleware *fast and pure*: state in Redis (shared across instances), synchronous decisions, single-digit milliseconds added to a model round-trip that costs hundreds. One chain, one seam, one place where the rules are enforced and audited.
## 5. Observability: Visualizing Spend vs. Throughput in Real Time
Governance without evidence is a policy document. The third layer makes the first two *visible*: a live view that plots the two variables every platform engineer cares about — **spend** (dollars per minute, per tenant, per tool) against **throughput** (tokens and calls per minute) — with budget ceilings drawn as horizontal lines that you can watch the burn approach.
The metrics come straight out of the middleware in Section 4. Each guarded call emits one event on completion (and one on refusal) with the fields that make the dashboard meaningful:
“`json
{
“ts”: “2026-09-06T14:02:31.004Z”,
“tenant”: “acme-corp”,
“tool”: “orders.lookup”,
“decision”: “allowed”,
“tokens_in”: 1843,
“tokens_out”: 512,
“cost_usd”: 0.0211,
“latency_ms”: 844,
“budget_remaining_usd”: 142.08,
“trace_id”: “8f3a1c…”
}
“`
Feed those events through OpenTelemetry into a Prometheus/ClickHouse-plus-Grafana stack (or any metrics backend you already run), and build four panels:
| Panel | Series | What it tells you |
|—|—|—|
| Spend vs. throughput | $/min (area), tokens/min (line), per-tenant | Are we getting busier or just more expensive? |
| Ceiling burn | Cumulative $ per tenant + ceiling line | Which tenant is sprinting at their budget? |
| Refusals by reason | 429s (rate) vs. budget refusals | Rate refusals → sizing issue; budget refusals → ceiling tuning |
| Step latency & size | p50/p95 latency, tokens/call distribution | Context bloat and slow tools, before they become bills |
The dashboard’s real job is to make *shapes* recognizable. A tenant whose spend line goes vertical while throughput stays flat is running a recursive loop with growing context — the $1,000 story happening live, in time to stop it. A sawtooth of budget refusals every morning means your ceiling is tuned to yesterday’s traffic. Alert on the shapes: spend rate crossing an anomaly band, any tenant above 80% of ceiling, refusal-rate spikes. And keep the panel honest with a **cost-per-outcome** view — dollars per resolved ticket, per order lookup, per deflection — because it is the number the business renews on — and the line that justifies the whole middleware layer in a quarterly review.
## 6. The OnlineCloud Standard: Production-Grade Governance in 6–10 Weeks
None of this is exotic, but all of it is *exacting* — and the difference between a demo and a deployment is whether the middleware exists before the first production tenant connects. At OnlineCloud we ship MCP servers with rate limiting, budget gates, and dashboards as part of the baseline architecture, on a 6–10 week delivery model:
– **Week 1 — discovery.** Map the data systems and auth boundaries, pick the model class per workload, and write the cost model: token weights per tool, prices per model, tenant tiers. If you cannot price the system in week one, you cannot ceiling it in week nine.
– **Weeks 2–7 — vertical slices.** Ship one usable, instrumented slice every 1–2 weeks against real data — middleware chain and dashboard panels included from day one. Governance is not a phase; it ships with the first tool.
– **Weeks 8–10 — harden and hand over.** Load-test the failure paths: a simulated recursive loop dies at the ceiling and pages someone; an over-limit tenant gets clean retryable errors. Hand over the runbooks, alert tuning, and on-call.
That last part is the OnlineCloud difference: the engineers who scope the system push the commits and own the dashboards after launch. We instrument outcomes — accuracy, latency, dollars per request — and share live numbers rather than a slide deck. Evaluated, observable, cost-bounded systems are the deliverable; the middleware is how we keep the promise.
## Conclusion: Make the Ceiling the Architecture
Rate limiting, cost ceilings, and observability are one stance applied three times: *an agentic system should not be able to surprise you.* The token-weighted bucket bounds how fast any tenant can move; the protocol-layer budget gate bounds how much any tenant can spend; the dashboard makes both visible before they become incidents. None of it depends on the model behaving — models will not always behave. Recursive loops, context bloat, and runaway spend are not bugs you can prompt your way out of; they are resource problems that need resource enforcement in the one seam every interaction crosses: MCP server middleware.
If you are standing up MCP against real systems — Oracle, MS SQL, Postgres, Snowflake, your CRM — with real budgets and real tenants, the OnlineCloud engineers build this in from week one: typed tools, auditable transports, per-tenant rate limits and cost ceilings, and dashboards your operations team can sleep through. [Contact the OnlineCloud team](https://www.onlinecloud.dev/) to start the conversation — and bring the workload you are tired of babysitting.