# How to rate-limit MCP tool calls

- Tool: MCP
- Difficulty: Advanced
- Time required: 20-30 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+
- Last updated: March 2026

## TL;DR

Rate limit MCP tool calls using a token bucket algorithm to prevent abuse, control API costs, and ensure fair usage. Implement per-tool and per-user limits that return 429-style error results when exceeded. This tutorial builds a reusable rate limiter that tracks tokens per bucket, refills over time, and integrates cleanly with MCP tool handlers.

## Rate Limiting MCP Tool Calls with Token Buckets

MCP tools that call external APIs, query databases, or perform expensive computations need rate limiting to prevent runaway costs and ensure availability. This tutorial implements a token bucket rate limiter — the same algorithm used by AWS, Stripe, and most production APIs. Each tool (or user) gets a bucket of tokens that refills at a steady rate. Each tool call consumes one token. When the bucket is empty, the call returns a rate limit error instead of executing.

## Before you start

- A working MCP server with one or more tools
- Node.js 18+ and npm installed
- Basic understanding of rate limiting concepts
- Familiarity with MCP tool registration patterns

## Step-by-step guide

### 1. Build a token bucket rate limiter class

The token bucket algorithm works by maintaining a bucket of tokens for each key (tool name, user ID, or both). Each call consumes one token. Tokens refill at a steady rate up to a maximum capacity. If the bucket is empty, the call is rejected. This provides smooth rate limiting that allows short bursts while enforcing long-term averages. The implementation uses lazy refill — tokens are calculated on each check rather than using a timer.

```
// src/rate-limiter.ts
export interface RateLimitConfig {
  maxTokens: number;       // Maximum bucket capacity
  refillRate: number;      // Tokens added per second
}

interface Bucket {
  tokens: number;
  lastRefill: number;
}

export class TokenBucketLimiter {
  private buckets = new Map<string, Bucket>();
  private configs = new Map<string, RateLimitConfig>();
  private defaultConfig: RateLimitConfig;

  constructor(defaultConfig: RateLimitConfig = { maxTokens: 60, refillRate: 1 }) {
    this.defaultConfig = defaultConfig;
  }

  setConfig(key: string, config: RateLimitConfig): void {
    this.configs.set(key, config);
  }

  tryConsume(key: string): { allowed: boolean; retryAfterMs: number; remaining: number } {
    const config = this.configs.get(key) || this.defaultConfig;
    const now = Date.now();
    let bucket = this.buckets.get(key);

    if (!bucket) {
      bucket = { tokens: config.maxTokens, lastRefill: now };
      this.buckets.set(key, bucket);
    }

    // Refill tokens based on elapsed time
    const elapsed = (now - bucket.lastRefill) / 1000;
    bucket.tokens = Math.min(config.maxTokens, bucket.tokens + elapsed * config.refillRate);
    bucket.lastRefill = now;

    if (bucket.tokens >= 1) {
      bucket.tokens -= 1;
      return { allowed: true, retryAfterMs: 0, remaining: Math.floor(bucket.tokens) };
    }

    // Calculate when next token will be available
    const deficit = 1 - bucket.tokens;
    const retryAfterMs = Math.ceil((deficit / config.refillRate) * 1000);
    return { allowed: false, retryAfterMs, remaining: 0 };
  }

  reset(key: string): void {
    this.buckets.delete(key);
  }
}
```

**Expected result:** A TokenBucketLimiter class that tracks rate limits per key with configurable capacity and refill rate.

### 2. Configure per-tool rate limits based on cost and risk

Different tools have different costs and risks. A tool that reads a local file is cheap and safe, so it gets a high rate limit. A tool that calls an external API has both monetary cost and rate limit risk from the upstream provider, so it gets a lower limit. Configure each tool's limits based on its actual constraints. Expensive or dangerous tools should have stricter limits.

```
// src/rate-config.ts
import { RateLimitConfig } from "./rate-limiter.js";

export const TOOL_RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Local file operations — fast and cheap
  list_files:    { maxTokens: 120, refillRate: 2 },   // 120/min burst, 2/sec sustained
  read_file:     { maxTokens: 60, refillRate: 1 },    // 60/min burst, 1/sec sustained
  
  // Database queries — moderate cost
  query_db:      { maxTokens: 30, refillRate: 0.5 },  // 30/min burst, 30/min sustained
  
  // External API calls — expensive and upstream-limited
  call_api:      { maxTokens: 10, refillRate: 0.17 }, // 10/min burst, ~10/min sustained
  
  // Write operations — highest risk
  write_file:    { maxTokens: 20, refillRate: 0.33 }, // 20/min burst, ~20/min sustained
  delete_file:   { maxTokens: 5, refillRate: 0.08 },  // 5/min burst, ~5/min sustained
};
```

**Expected result:** A configuration object mapping each tool name to its rate limit parameters.

### 3. Create a rate-limiting middleware wrapper for tool handlers

Build a higher-order function that wraps any tool handler with rate limit checking. Before executing the handler, check the rate limiter. If allowed, run the handler. If denied, return an MCP error result with a retry-after message. Use a combined key of tool name and user ID for per-user, per-tool limits. This wrapper is reusable across all tools.

```
// src/rate-limited-tool.ts
import { TokenBucketLimiter } from "./rate-limiter.js";
import { TOOL_RATE_LIMITS } from "./rate-config.js";

const limiter = new TokenBucketLimiter();

// Initialize tool-specific configs
for (const [tool, config] of Object.entries(TOOL_RATE_LIMITS)) {
  limiter.setConfig(tool, config);
}

export function withRateLimit<T>(
  toolName: string,
  handler: (params: T) => Promise<any>,
  userId?: string
): (params: T) => Promise<any> {
  return async (params: T) => {
    // Check per-tool limit
    const toolResult = limiter.tryConsume(toolName);
    if (!toolResult.allowed) {
      console.error(`[rate-limit] Tool ${toolName} rate limited. Retry after ${toolResult.retryAfterMs}ms`);
      return {
        content: [{
          type: "text",
          text: `Rate limit exceeded for ${toolName}. Please retry after ${Math.ceil(toolResult.retryAfterMs / 1000)} seconds. Remaining capacity: ${toolResult.remaining}.`,
        }],
        isError: true,
      };
    }

    // Check per-user limit if userId provided
    if (userId) {
      const userKey = `user:${userId}`;
      const userResult = limiter.tryConsume(userKey);
      if (!userResult.allowed) {
        console.error(`[rate-limit] User ${userId} rate limited`);
        return {
          content: [{
            type: "text",
            text: `User rate limit exceeded. Please retry after ${Math.ceil(userResult.retryAfterMs / 1000)} seconds.`,
          }],
          isError: true,
        };
      }
    }

    return handler(params);
  };
}
```

**Expected result:** A withRateLimit wrapper that checks rate limits before executing tool handlers.

### 4. Integrate rate limiting with MCP server tool registration

Apply the rate limiter when registering tools with the MCP server. Wrap each tool's handler with withRateLimit before passing it to server.tool(). This keeps the rate limiting logic separate from the tool's business logic. You can also add a rate_limit_status tool that reports current bucket states for debugging.

```
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { withRateLimit } from "./rate-limited-tool.js";

const server = new McpServer({ name: "rate-limited-server", version: "1.0.0" });

// Register tools with rate limiting
server.tool(
  "read_file",
  "Read a file with rate limiting applied",
  { filePath: z.string() },
  withRateLimit("read_file", async ({ filePath }) => {
    const fs = await import("fs/promises");
    const content = await fs.readFile(filePath, "utf-8");
    return { content: [{ type: "text", text: content }] };
  })
);

server.tool(
  "call_api",
  "Call an external API with strict rate limiting",
  { url: z.string(), method: z.enum(["GET", "POST"]).default("GET") },
  withRateLimit("call_api", async ({ url, method }) => {
    const response = await fetch(url, { method });
    const text = await response.text();
    return { content: [{ type: "text", text }] };
  })
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Rate-limited MCP server running");
}
main().catch(e => { console.error(e); process.exit(1); });
```

**Expected result:** MCP server with rate-limited tools that return error results when limits are exceeded.

## Complete code example

File: `src/rate-limiter.ts`

```typescript
export interface RateLimitConfig {
  maxTokens: number;
  refillRate: number;
}

interface Bucket {
  tokens: number;
  lastRefill: number;
}

export class TokenBucketLimiter {
  private buckets = new Map<string, Bucket>();
  private configs = new Map<string, RateLimitConfig>();
  private defaultConfig: RateLimitConfig;

  constructor(defaultConfig: RateLimitConfig = { maxTokens: 60, refillRate: 1 }) {
    this.defaultConfig = defaultConfig;
  }

  setConfig(key: string, config: RateLimitConfig): void {
    this.configs.set(key, config);
  }

  tryConsume(key: string): { allowed: boolean; retryAfterMs: number; remaining: number } {
    const config = this.configs.get(key) || this.defaultConfig;
    const now = Date.now();
    let bucket = this.buckets.get(key);
    if (!bucket) {
      bucket = { tokens: config.maxTokens, lastRefill: now };
      this.buckets.set(key, bucket);
    }
    const elapsed = (now - bucket.lastRefill) / 1000;
    bucket.tokens = Math.min(config.maxTokens, bucket.tokens + elapsed * config.refillRate);
    bucket.lastRefill = now;

    if (bucket.tokens >= 1) {
      bucket.tokens -= 1;
      return { allowed: true, retryAfterMs: 0, remaining: Math.floor(bucket.tokens) };
    }
    const retryAfterMs = Math.ceil(((1 - bucket.tokens) / config.refillRate) * 1000);
    return { allowed: false, retryAfterMs, remaining: 0 };
  }

  reset(key: string): void { this.buckets.delete(key); }

  getStatus(key: string): { tokens: number; maxTokens: number } | null {
    const bucket = this.buckets.get(key);
    const config = this.configs.get(key) || this.defaultConfig;
    if (!bucket) return null;
    const elapsed = (Date.now() - bucket.lastRefill) / 1000;
    const tokens = Math.min(config.maxTokens, bucket.tokens + elapsed * config.refillRate);
    return { tokens: Math.floor(tokens), maxTokens: config.maxTokens };
  }
}

export function withRateLimit<T>(
  limiter: TokenBucketLimiter,
  toolName: string,
  handler: (params: T) => Promise<any>
): (params: T) => Promise<any> {
  return async (params: T) => {
    const result = limiter.tryConsume(toolName);
    if (!result.allowed) {
      return {
        content: [{ type: "text" as const,
          text: `Rate limit exceeded for ${toolName}. Retry after ${Math.ceil(result.retryAfterMs / 1000)}s.` }],
        isError: true as const,
      };
    }
    return handler(params);
  };
}
```

## Common mistakes

- **Using fixed windows (reset every minute) instead of token buckets, causing thundering herd at window boundaries** — undefined Fix: Use token bucket or sliding window algorithms that distribute load evenly without sharp reset boundaries.
- **Applying the same rate limit to all tools regardless of their cost and risk** — undefined Fix: Configure per-tool limits based on each tool's computational cost, external API limits, and risk level.
- **Not returning retry-after information in rate limit errors** — undefined Fix: Include the retry-after time in the error message so clients can wait the appropriate amount before retrying.
- **Rate limiting only by tool name, not by user, allowing one user to exhaust limits for everyone** — undefined Fix: Use composite keys combining tool name and user ID for per-user, per-tool rate limiting.

## Best practices

- Use token bucket algorithm for smooth rate limiting that allows short bursts
- Configure per-tool limits based on cost — cheap tools get higher limits than expensive ones
- Return clear error messages with retry-after timing when rate limits are hit
- Combine per-tool and per-user limits for comprehensive traffic control
- Log all rate limit events to stderr for monitoring and tuning
- Set MCP tool limits below upstream API limits to leave safety headroom
- Use lazy refill calculations instead of timers for accuracy and efficiency
- Add a diagnostic tool that reports current bucket states for debugging

## Frequently asked questions

### What rate limit values should I start with for MCP tools?

Start with 60 tokens max and 1 token/second refill for general tools. Reduce to 10 tokens max and 0.17/second for tools that call external APIs. Monitor actual usage for a week and then tune based on data.

### How does the token bucket algorithm differ from fixed window rate limiting?

Fixed window resets all tokens at the start of each minute, allowing bursts at window boundaries. Token bucket refills continuously, providing smoother traffic distribution and more predictable behavior.

### Can I share rate limit state across multiple MCP server instances?

Yes. Replace the in-memory Map with Redis. Use Redis INCR with EXPIRE for atomic token consumption, or use a Redis-backed rate limiting library like rate-limiter-flexible.

### Should rate limit errors use isError: true?

Yes. Always return isError: true for rate limit responses so the AI client knows the tool call did not succeed and should either retry or inform the user.

### Can RapidDev help configure rate limits for production MCP servers?

Yes, RapidDev can analyze your tool usage patterns and configure appropriate rate limits based on upstream API constraints, cost budgets, and performance requirements.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-rate-limit-mcp-tool-calls
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-rate-limit-mcp-tool-calls
