teachyou.ai academy
← All posts
MCP

Rate Limiting and Cost Control for Agent-Facing MCP Tools

Ira Menon · May 27, 2026 · 16 min read

Why your MCP server needs guardrails an API never did

The first time an MCP server gets loose in production, something strange happens. A human using a REST API sends one request, waits, reads the response, and decides what to do next. An agent using an MCP tool does none of that. It reasons in a loop, calls a tool, reads the result, and calls another tool — sometimes ten times in a second, sometimes in a while loop it wrote for itself because it decided retrying was a good idea. There is no human in that loop slowing things down.

This changes the risk profile of everything you expose through MCP. A search_database tool that was safe behind a rate-limited dashboard becomes dangerous when an LLM can call it 200 times in a minute while trying to "be thorough." A send_email tool that assumed a human would notice if it fired twice becomes a spam cannon when a model gets stuck in a retry loop after a transient error. And a tool that wraps a metered third-party API — an LLM call, a paid search API, a SaaS webhook — turns your MCP server into a direct line from "agent had a bad idea" to "invoice arrived."

None of this is hypothetical if you've shipped an MCP server to more than a handful of users. Agents misbehave in boring, mechanical ways: they retry too aggressively, they parallelize tool calls without checking whether the tool is idempotent, they misread a schema and call the wrong tool in a loop, and — most commonly — they simply do more work than a human would because typing costs them nothing. Rate limiting and cost control for MCP tools isn't a nice-to-have hardening pass. It's the part of the system that keeps a single confused agent session from becoming a five-figure bill or a banned API key.

This article walks through the concrete mechanisms: per-tool rate limits, token/cost budgets, circuit breakers, and the observability you need to catch problems before your credit card does. We'll use TypeScript examples throughout since that's the dominant MCP server runtime today, but the concepts map directly to Python servers built on FastMCP or the official SDK.

The three failure modes unique to agent-facing tools

Before writing any rate-limiting code, it helps to name what you're actually defending against, because "add a rate limiter" undersells the problem. There are three distinct failure modes:

1. Volume failures. The agent calls a tool far more often than any reasonable workflow requires. This happens when a model is stuck in a reasoning loop, when it's exploring a large search space one call at a time instead of batching, or when a multi-agent system has several sub-agents independently hammering the same tool. A classic example: an agent tasked with "find all failing tests and fix them" calls a run_tests tool once per file instead of once for the whole suite, because that's what its plan looked like.

2. Cost-amplification failures. Each tool call is cheap to invoke but expensive to fulfill. Your MCP tool wraps a call to an LLM API, a paid data provider, or a compute-heavy operation (image generation, video transcoding, vector re-indexing). The agent doesn't know or care that the "quick lookup" it just asked for actually cost you $0.40. Multiply that by an agent that calls it 300 times while iterating on a task, and a $5 session becomes a $120 one.

3. Blast-radius failures. The tool has side effects — it sends messages, creates tickets, modifies records, deletes files. A rate limit isn't just about money here; it's about damage. An agent with a create_calendar_event tool that gets caught in a retry loop after a 500 error can create the same meeting fifty times. An agent with delete permissions that misinterprets "clean up the test data" can be far more thorough than any human would dare to be.

Each failure mode needs a different defense. Volume failures need request-rate limits. Cost-amplification failures need budget tracking measured in dollars or tokens, not requests. Blast-radius failures need idempotency keys, confirmation gates, and hard caps on destructive actions per session. Treating all three as "just add a rate limiter" is how teams end up with a limiter that stops nothing that actually hurt them.

Per-tool, per-session rate limiting

The most basic layer is a request-rate limiter scoped to the MCP session, not just the API key. This matters because a single API key or user account might have many concurrent agent sessions (a coding agent, a support bot, a scheduled job), and you want to contain a runaway session without punishing every other session sharing that key.

Here's a minimal token-bucket limiter wired into an MCP server's tool handler:

type BucketState = {
  tokens: number;
  lastRefill: number;
};

class TokenBucketLimiter {
  private buckets = new Map<string, BucketState>();

  constructor(
    private capacity: number,
    private refillPerSecond: number
  ) {}

  private getBucket(key: string): BucketState {
    const now = Date.now();
    let bucket = this.buckets.get(key);
    if (!bucket) {
      bucket = { tokens: this.capacity, lastRefill: now };
      this.buckets.set(key, bucket);
      return bucket;
    }
    const elapsedSeconds = (now - bucket.lastRefill) / 1000;
    const refill = elapsedSeconds * this.refillPerSecond;
    bucket.tokens = Math.min(this.capacity, bucket.tokens + refill);
    bucket.lastRefill = now;
    return bucket;
  }

  tryConsume(key: string, cost = 1): { allowed: boolean; retryAfterMs?: number } {
    const bucket = this.getBucket(key);
    if (bucket.tokens >= cost) {
      bucket.tokens -= cost;
      return { allowed: true };
    }
    const deficit = cost - bucket.tokens;
    const retryAfterMs = Math.ceil((deficit / this.refillPerSecond) * 1000);
    return { allowed: false, retryAfterMs };
  }
}

// One limiter per tool, tuned to that tool's actual cost profile
const limiters = {
  search_database: new TokenBucketLimiter(20, 2),   // 20 burst, 2/sec sustained
  send_email: new TokenBucketLimiter(5, 0.1),       // 5 burst, 1 every 10s
  generate_report: new TokenBucketLimiter(3, 0.05), // expensive, tightly capped
};

function enforceRateLimit(toolName: keyof typeof limiters, sessionId: string) {
  const result = limiters[toolName].tryConsume(sessionId);
  if (!result.allowed) {
    throw new McpToolError(
      "RATE_LIMITED",
      `Tool "${toolName}" rate limit exceeded. Retry after ${result.retryAfterMs}ms.`,
      { retryAfterMs: result.retryAfterMs }
    );
  }
}

Two details matter more than the algorithm itself. First, scope the bucket key to something you can actually attribute — session ID if your transport gives you one, otherwise a combination of API key and connection ID. Scoping only to API key means one runaway agent throttles every other legitimate session using the same credentials, which just moves the outage instead of preventing it.

Second, the error you return matters as much as the limit. When you reject a call, tell the agent exactly why and when it can retry, in a format the model can act on. Returning a bare 429 or an opaque "error": "too many requests" invites the model to just retry immediately in a hot loop, which is worse than not rate limiting at all. An MCP tool error result that says "rate limit exceeded, retry after 4200ms" gives a well-behaved agent a concrete number to reason about — and if you're using the MCP SDK's structured error content, you can put that number somewhere the orchestrating framework's retry logic will actually read.

Budgets are not the same thing as rate limits

Rate limiting controls how often a tool fires. Budgeting controls how much it's allowed to cost, and those are different axes. A tool can be well within its rate limit and still bankrupt you if each call is expensive, and a tool can be firing constantly while staying cheap. You need both.

The cleanest way to implement cost control is to attach a budget to the session or the task, decrement it on every tool call based on that call's actual cost, and refuse calls once the budget is exhausted — independent of how fast or slow those calls came in.

from dataclasses import dataclass, field
from datetime import datetime, timezone

@dataclass
class SessionBudget:
    session_id: str
    max_cost_usd: float
    spent_usd: float = 0.0
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    def remaining(self) -> float:
        return max(0.0, self.max_cost_usd - self.spent_usd)

    def charge(self, amount_usd: float) -> None:
        if amount_usd > self.remaining():
            raise BudgetExceededError(
                f"Charge of ${amount_usd:.4f} exceeds remaining budget "
                f"${self.remaining():.4f} for session {self.session_id}"
            )
        self.spent_usd += amount_usd


# Per-tool cost estimates, updated from real provider pricing
TOOL_COST_ESTIMATES = {
    "web_search": 0.008,          # per call, flat-rate search API
    "generate_image": 0.04,       # per image
    "llm_summarize": None,        # computed dynamically from token usage
}

def cost_for_llm_call(input_tokens: int, output_tokens: int) -> float:
    # Example rates in USD per 1K tokens — replace with your actual provider rates
    input_rate, output_rate = 0.003, 0.015
    return (input_tokens / 1000) * input_rate + (output_tokens / 1000) * output_rate

def handle_tool_call(tool_name: str, budget: SessionBudget, **kwargs):
    estimated = TOOL_COST_ESTIMATES.get(tool_name)
    if estimated is not None and estimated > budget.remaining():
        raise BudgetExceededError(
            f"Session budget exhausted: ${budget.remaining():.4f} remaining, "
            f"tool '{tool_name}' costs ~${estimated:.4f}"
        )
    result = execute_tool(tool_name, **kwargs)
    actual_cost = result.actual_cost_usd if hasattr(result, "actual_cost_usd") else estimated
    if actual_cost:
        budget.charge(actual_cost)
    return result

A few things worth calling out in that design. First, estimate before you spend, reconcile after. Check the estimated cost against the remaining budget before making the call — this stops you launching a call you already know you can't afford — but then charge the *actual* cost once you know it, since LLM token counts and third-party metered APIs rarely match your estimate exactly. If you only ever charge the estimate, cumulative drift will let sessions blow past their real budget without you noticing.

Second, decide what "session" means before you decide what the number should be. A coding agent that runs for six hours doing a large refactor legitimately needs a bigger budget than a one-shot customer support query. Tie the budget to the actual unit of work — a single MCP client connection, a task ID passed in by the orchestrator, or a user-level daily allowance — rather than picking one number for everything.

Third, when a budget is exhausted, fail loudly and specifically. Don't silently degrade or return empty results — that leads to an agent confidently reporting wrong conclusions because it thinks a tool returned "no results" rather than "no budget." Return a distinct error type the calling agent (or the human supervising it) can recognize and act on.

Circuit breakers for the tools you don't fully trust

Rate limits and budgets both assume the tool is working normally and you're just capping legitimate use. Circuit breakers solve a different problem: a downstream dependency is failing, and continuing to hammer it is actively making things worse — burning your budget on calls that error out, or getting your API key banned by a third party for a burst of failed requests.

The pattern is standard: track failures in a rolling window, and once the failure rate crosses a threshold, "open" the circuit and reject calls immediately (cheaply) for a cooldown period, instead of letting every call incur the full timeout of a dying dependency.

enum CircuitState { CLOSED, OPEN, HALF_OPEN }

class CircuitBreaker {
  private state = CircuitState.CLOSED;
  private failureCount = 0;
  private openedAt = 0;

  constructor(
    private failureThreshold: number,
    private cooldownMs: number
  ) {}

  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === CircuitState.OPEN) {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new McpToolError(
          "CIRCUIT_OPEN",
          "Downstream service is failing; tool temporarily disabled. Try again shortly."
        );
      }
      this.state = CircuitState.HALF_OPEN;
    }

    try {
      const result = await fn();
      if (this.state === CircuitState.HALF_OPEN) {
        this.state = CircuitState.CLOSED;
        this.failureCount = 0;
      }
      return result;
    } catch (err) {
      this.failureCount += 1;
      if (this.failureCount >= this.failureThreshold) {
        this.state = CircuitState.OPEN;
        this.openedAt = Date.now();
      }
      throw err;
    }
  }
}

const paymentApiBreaker = new CircuitBreaker(5, 30_000);

async function callPaymentTool(input: unknown) {
  return paymentApiBreaker.call(() => paymentApiClient.charge(input));
}

The reason this matters specifically for agents: an LLM that gets a tool error will very often just try again, sometimes immediately, sometimes with a slightly reworded argument because it assumes the failure was its fault. Without a circuit breaker, ten agent sessions hitting a flaky downstream dependency at once can turn one outage into a self-inflicted denial-of-service against your own infrastructure, and rack up cost on every failed attempt if the downstream API bills per request regardless of success. A tripped circuit converts that into a fast, cheap, informative error instead.

Scoping limits to the right dimension

A single global rate limit on your MCP server is almost never the right shape. You typically need at least three layers stacked together:

  • Per-tool limits, because a cheap list_files call and an expensive run_deployment call have nothing in common and shouldn't share a bucket.
  • Per-session limits, because you want to contain one agent's runaway loop without punishing other sessions.
  • Per-account or per-org limits, because a customer running twenty agent sessions in parallel shouldn't be able to bypass your intended ceiling just by spinning up more sessions.

In practice this looks like a small hierarchy of checks that all have to pass:

async function authorizeToolCall(ctx: {
  toolName: string;
  sessionId: string;
  accountId: string;
}) {
  // 1. Is this tool globally disabled or in maintenance?
  assertToolEnabled(ctx.toolName);

  // 2. Per-tool, per-session rate limit
  enforceRateLimit(ctx.toolName, ctx.sessionId);

  // 3. Per-account aggregate ceiling across all sessions
  const accountUsage = await getAccountUsageLast(ctx.accountId, "1m");
  if (accountUsage.callCount > ACCOUNT_LIMITS[ctx.toolName]) {
    throw new McpToolError("ACCOUNT_RATE_LIMITED", "Account-wide limit reached");
  }

  // 4. Session budget check
  const budget = await getSessionBudget(ctx.sessionId);
  if (budget.remaining() <= 0) {
    throw new McpToolError("BUDGET_EXHAUSTED", "Session budget exhausted");
  }
}

Notice this is explicitly ordered from cheapest check to most expensive — bail out on a disabled tool or an in-memory rate-limit check before you do a database lookup for account-wide usage. If your MCP server handles meaningful traffic, the order of these checks is itself a cost-control decision: the checks that gate expensive tools shouldn't themselves become the expensive part of every call.

Handling the retry storm problem

One MCP-specific wrinkle: many agent frameworks retry failed tool calls automatically, and they often don't back off intelligently unless your error tells them to. If your rate-limit error looks identical to a generic failure, a well-intentioned retry policy in the client framework becomes an amplifier instead of a safety net.

Two things fix most of this. First, use distinct, machine-readable error codes for "you're rate limited, back off" versus "this request was invalid" versus "the server is down" — an agent (or its framework) should never treat these the same way. Second, where your transport allows it, include a concrete retry-after value rather than a vague message, and make sure it reflects your real refill rate rather than an arbitrary constant.

function toMcpError(err: unknown) {
  if (err instanceof RateLimitedError) {
    return {
      isError: true,
      content: [{
        type: "text",
        text: `Rate limited. Retry after ${err.retryAfterMs}ms. Do not retry immediately.`,
      }],
      // Structured metadata a well-built client can parse programmatically
      _meta: { errorCode: "RATE_LIMITED", retryAfterMs: err.retryAfterMs },
    };
  }
  if (err instanceof BudgetExceededError) {
    return {
      isError: true,
      content: [{
        type: "text",
        text: "Session cost budget exhausted. This tool cannot be called again this session.",
      }],
      _meta: { errorCode: "BUDGET_EXHAUSTED" },
    };
  }
  throw err;
}

The instruction embedded directly in the text content — "do not retry immediately" — is not decoration. Remember that the primary consumer reading this error is often the model itself, reasoning over the tool result in-context. Writing the error message as an instruction, not just a status report, measurably changes how often the agent immediately hammers the tool again versus waiting or trying a different approach.

Observability: you can't control what you can't see

None of the above matters if you find out about a runaway session from your cloud bill instead of your monitoring. At minimum, an MCP server serving agents needs:

  • Per-tool call counts and latencies, broken out by session and account, so you can see a spike before it becomes an incident.
  • Real-time cost accrual per session, not just end-of-month totals — you want to know a session is burning $2/minute while it's still running, not after.
  • Rate-limit and budget rejection counts, because a sudden spike in rejections often means an agent is stuck in a loop, which is itself worth alerting on even though the limiter "did its job."
  • Alerting thresholds tied to dollars, not just request counts, since a session making few but expensive calls can be more dangerous than one making many cheap ones.

A simple approach that works well in early-stage systems is to log a structured event on every tool call — tool name, session ID, account ID, latency, estimated cost, actual cost, and outcome — and build dashboards and alerts on top of that single stream rather than inventing bespoke metrics per tool. It's tempting to skip this until "something breaks," but the entire point of cost control is catching the problem while the exposure is still in the tens of dollars, not after a weekend where nobody was watching.

Putting it together: a sane default policy

If you're setting up rate limiting and cost control for a new MCP server today, a reasonable starting policy looks like this:

  1. Give every tool a per-session token bucket sized to its actual cost — cheap read-only tools get generous burst allowances, expensive or destructive tools get tight ones.
  2. Attach a dollar-denominated budget to every session or task, check it before the call and reconcile it after, and fail with a distinct, descriptive error when it's exhausted.
  3. Wrap any tool that calls a flaky or rate-limited third party in a circuit breaker so failures don't compound into wasted spend.
  4. Layer account-level aggregate limits on top of session-level ones so parallel sessions can't be used to route around your intended ceiling.
  5. Make every rejection error explicit and actionable — distinct error codes, concrete retry-after values, and text that reads as an instruction to the model, not just a status report.
  6. Log structured, per-call telemetry from day one, and alert on dollars burned per minute, not just request counts.

None of these individually is exotic engineering. What makes them necessary is the nature of the caller: an agent doesn't get tired, doesn't feel bad about calling a tool 200 times, and doesn't intuit that "quick lookup" might mean "$0.40 API call" unless you tell it so through the constraints you enforce. Build the guardrails once, at the tool-server layer, and every agent that connects to your MCP server inherits them automatically — which is a much better position than hoping every agent framework that ever talks to your tools happens to be well-behaved.

If you want to go deeper on the full lifecycle of building and shipping MCP servers — schema design, authentication, testing tool definitions against real agent behavior, and deployment patterns beyond just rate limiting — that's exactly what we cover in Building & Integrating MCP Servers here on teachyou.ai.

Rate Limiting and Cost Control for Agent-Facing MCP Tools · TeachYou Academy