← Back to DevBytes

Rate Limiting and Retry Strategies with MCP (Model Context Protocol): Complete Guide

Rate Limiting and Retry Strategies with MCP (Model Context Protocol): Complete Guide

The Model Context Protocol (MCP) has emerged as a powerful standard for connecting AI assistants to external data sources, tools, and services. However, as with any networked protocol that interacts with APIs, databases, and third-party services, you will inevitably encounter rate limits, transient failures, and network hiccups. Building robust MCP servers and clients requires a thoughtful approach to rate limiting and retry strategies. This guide walks you through everything you need to know to make your MCP integrations resilient, predictable, and production-ready.

What Is Rate Limiting in the Context of MCP?

Rate limiting is the practice of controlling the rate at which requests are sent or received. In the MCP ecosystem, rate limiting applies on two fronts. First, your MCP server may call upstream APIs (such as GitHub, Slack, or a database) that impose their own rate limits. Second, your MCP server itself may need to limit how many requests it accepts from clients to protect resources and ensure fair usage.

The Model Context Protocol defines a JSON-RPC based communication layer between clients (like Claude Desktop or custom AI applications) and servers that expose tools, resources, and prompts. Because MCP servers often act as bridges to external services, they inherit all the rate limiting concerns of those services. If your MCP tool calls the OpenAI API, the GitHub API, and a PostgreSQL database, you need to manage rate limits for each of those independently while also presenting a smooth experience to the MCP client.

Why Rate Limiting and Retries Matter

Understanding MCP Error Handling

MCP uses JSON-RPC 2.0, which defines a standard error object with a code, message, and optional data field. When your MCP tool encounters a rate limit from an upstream service, you should translate that into a meaningful MCP error rather than letting the request fail opaquely. The MCP specification defines several standard error codes, including -32001 for request cancelled and the JSON-RPC standard range for server errors. For rate limiting, you can use custom error codes in the -32000 to -32099 range reserved for implementation-defined server errors.

Implementing a Token Bucket Rate Limiter

The token bucket algorithm is one of the most popular rate limiting strategies because it allows bursts while maintaining an average rate. Tokens are added to a bucket at a fixed rate, and each request consumes a token. If the bucket is empty, the request is rejected or delayed. Here is a TypeScript implementation you can use inside an MCP server:

class TokenBucket {
  private tokens: number;
  private lastRefill: number;
  private readonly capacity: number;
  private readonly refillRate: number; // tokens per second

  constructor(capacity: number, refillRate: number) {
    this.capacity = capacity;
    this.refillRate = refillRate;
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;
  }

  tryConsume(count: number = 1): boolean {
    this.refill();
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    return false;
  }

  getAvailableTokens(): number {
    this.refill();
    return this.tokens;
  }
}

You can instantiate one bucket per upstream service or per user session. For example, if your MCP server exposes a GitHub tool, create a bucket sized to match GitHub's rate limit for your token.

Building an MCP Server with Rate Limiting

Let us build a complete MCP server that exposes a tool for fetching repository information from GitHub, with built-in rate limiting. We will use the official MCP TypeScript SDK:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const githubBucket = new TokenBucket(30, 0.5); // 30 burst, 0.5/sec

const server = new Server(
  { name: "github-mcp-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_repo",
      description: "Fetch repository metadata from GitHub",
      inputSchema: {
        type: "object",
        properties: {
          owner: { type: "string" },
          repo: { type: "string" },
        },
        required: ["owner", "repo"],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== "get_repo") {
    throw new Error(`Unknown tool: ${request.params.name}`);
  }

  if (!githubBucket.tryConsume()) {
    return {
      content: [
        {
          type: "text",
          text: "Rate limit exceeded for GitHub API. Please try again in a few seconds.",
        },
      ],
      isError: true,
    };
  }

  const { owner, repo } = request.params.arguments as {
    owner: string;
    repo: string;
  };

  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}`,
    {
      headers: {
        Accept: "application/vnd.github.v3+json",
        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
      },
    }
  );

  if (response.status === 404) {
    return {
      content: [{ type: "text", text: `Repository ${owner}/${repo} not found.` }],
      isError: true,
    };
  }

  if (!response.ok) {
    return {
      content: [
        {
          type: "text",
          text: `GitHub API error: ${response.status} ${response.statusText}`,
        },
      ],
      isError: true,
    };
  }

  const data = await response.json();
  return {
    content: [
      {
        type: "text",
        text: JSON.stringify(
          {
            name: data.name,
            description: data.description,
            stars: data.stargazers_count,
            forks: data.forks_count,
            open_issues: data.open_issues_count,
          },
          null,
          2
        ),
      },
    ],
  };
});

const transport = new StdioServerTransport();
await server.connect(transport);

Notice how the rate limit check happens before the HTTP call. This prevents wasting an upstream API call when we already know we are over budget. The error is returned as MCP content with isError: true, which tells the client that the tool execution failed but in a controlled way.

Retry Strategies: Exponential Backoff with Jitter

When a request fails due to a transient error or a 429 response, retrying immediately is usually counterproductive. If the server is overloaded, hammering it again will only make things worse. Exponential backoff increases the wait time between retries, giving the upstream service time to recover. Adding jitter (randomized delay) prevents the thundering herd problem where many clients retry at the same moment.

async function fetchWithRetry(
  url: string,
  options: RequestInit,
  maxRetries: number = 5,
  baseDelayMs: number = 1000
): Promise<Response> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.status === 429 || response.status >= 500) {
        if (attempt === maxRetries) {
          return response; // give up, return the error response
        }

        // Check for Retry-After header
        const retryAfter = response.headers.get("Retry-After");
        let delayMs: number;

        if (retryAfter) {
          delayMs = parseInt(retryAfter, 10) * 1000;
        } else {
          // Exponential backoff with full jitter
          const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
          delayMs = Math.random() * exponentialDelay;
        }

        await new Promise((resolve) => setTimeout(resolve, delayMs));
        continue;
      }

      return response;
    } catch (error) {
      lastError = error as Error;
      if (attempt === maxRetries) {
        throw error;
      }

      const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
      const delayMs = Math.random() * exponentialDelay;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }

  throw lastError ?? new Error("Max retries exceeded");
}

This function handles both HTTP-level errors (429, 5xx) and network-level errors (timeouts, connection resets). It respects the Retry-After header when present, which is the polite way for servers to tell clients exactly how long to wait. When no header is provided, it falls back to exponential backoff with full jitter.

Integrating Retries into MCP Tool Handlers

Now let us update our MCP server to use the retry-enabled fetch function. We will also add a circuit breaker pattern to stop calling an upstream service that is consistently failing:

class CircuitBreaker {
  private failures = 0;
  private lastFailureTime = 0;
  private state: "closed" | "open" | "half-open" = "closed";

  constructor(
    private readonly threshold: number = 5,
    private readonly resetTimeoutMs: number = 60000
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === "open") {
      if (Date.now() - this.lastFailureTime > this.resetTimeoutMs) {
        this.state = "half-open";
      } else {
        throw new Error("Circuit breaker is open. Service temporarily unavailable.");
      }
    }

    try {
      const result = await fn();
      this.failures = 0;
      this.state = "closed";
      return result;
    } catch (error) {
      this.failures++;
      this.lastFailureTime = Date.now();

      if (this.failures >= this.threshold) {
        this.state = "open";
      }

      throw error;
    }
  }
}

const githubBreaker = new CircuitBreaker(5, 60000);

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== "get_repo") {
    throw new Error(`Unknown tool: ${request.params.name}`);
  }

  if (!githubBucket.tryConsume()) {
    return {
      content: [
        {
          type: "text",
          text: "Rate limit exceeded. Please retry shortly.",
        },
      ],
      isError: true,
    };
  }

  const { owner, repo } = request.params.arguments as {
    owner: string;
    repo: string;
  };

  try {
    const response = await githubBreaker.execute(() =>
      fetchWithRetry(
        `https://api.github.com/repos/${owner}/${repo}`,
        {
          headers: {
            Accept: "application/vnd.github.v3+json",
            Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
          },
        }
      )
    );

    if (!response.ok) {
      return {
        content: [
          {
            type: "text",
            text: `GitHub API error after retries: ${response.status}`,
          },
        ],
        isError: true,
      };
    }

    const data = await response.json();
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              name: data.name,
              stars: data.stargazers_count,
              description: data.description,
            },
            null,
            2
          ),
        },
      ],
    };
  } catch (error) {
    return {
      content: [
        {
          type: "text",
          text: `Failed to fetch repository: ${(error as Error).message}`,
        },
      ],
      isError: true,
    };
  }
});

Per-User and Per-Session Rate Limiting

In a multi-user MCP deployment, a single global rate limiter is insufficient. One user could exhaust the budget, blocking everyone else. The solution is to maintain separate rate limiters keyed by user or session identifier. MCP clients can include metadata that identifies the session, and you can use that to partition your rate limiting:

class RateLimiterManager {
  private limiters = new Map<string, TokenBucket>();

  getLimiter(key: string): TokenBucket {
    let limiter = this.limiters.get(key);
    if (!limiter) {
      limiter = new TokenBucket(10, 1); // 10 burst, 1/sec per user
      this.limiters.set(key, limiter);
    }
    return limiter;
  }

  tryConsume(key: string, count: number = 1): boolean {
    return this.getLimiter(key).tryConsume(count);
  }

  // Clean up stale limiters periodically
  cleanup(maxAgeMs: number = 3600000): void {
    // In production, track last access time and remove old entries
    // This is a placeholder for the cleanup logic
  }
}

const limiterManager = new RateLimiterManager();

// In your tool handler, extract a user identifier from the request
// context or generate one based on the transport session
function getSessionKey(request: any): string {
  // MCP does not yet standardize user identity, so you may need
  // to rely on transport-level session IDs or custom headers
  return request._meta?.sessionId ?? "anonymous";
}

Client-Side Retry Strategies for MCP Clients

Rate limiting is not only the server's responsibility. If you are building an MCP client (for example, an AI application that connects to multiple MCP servers), you should also implement retry logic on the client side. When an MCP server returns an error, the client can decide whether to retry the tool call, ask the LLM to try a different approach, or surface the error to the user:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

async function callToolWithRetry(
  client: Client,
  toolName: string,
  args: Record<string, unknown>,
  maxRetries: number = 3
): Promise<any> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const result = await client.callTool({
        name: toolName,
        arguments: args,
      });

      if (result.isError) {
        const text = result.content?.[0]?.text ?? "";
        if (text.includes("Rate limit") && attempt < maxRetries) {
          const delayMs = 2000 * Math.pow(2, attempt) + Math.random() * 500;
          await new Promise((r) => setTimeout(r, delayMs));
          continue;
        }
        return result;
      }

      return result;
    } catch (error) {
      if (attempt === maxRetries) {
        throw error;
      }
      const delayMs = 1000 * Math.pow(2, attempt) + Math.random() * 500;
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}

const transport = new StdioClientTransport({
  command: "node",
  args: ["github-mcp-server.js"],
});

const client = new Client(
  { name: "my-ai-app", version: "1.0.0" },
  { capabilities: {} }
);

await client.connect(transport);

const result = await callToolWithRetry(client, "get_repo", {
  owner: "modelcontextprotocol",
  repo: "python-sdk",
});

console.log(result);

Best Practices for Rate Limiting and Retries in MCP

Monitoring and Observability

Rate limiting and retry logic are only as good as your ability to observe their behavior. You should log every rate limit rejection, every retry attempt, and every circuit breaker state transition. Here is a simple logging wrapper you can integrate:

class ObservableTokenBucket extends TokenBucket {
  constructor(
    capacity: number,
    refillRate: number,
    private readonly label: string
  ) {
    super(capacity, refillRate);
  }

  tryConsume(count: number = 1): boolean {
    const allowed = super.tryConsume(count);
    if (!allowed) {
      console.warn(
        JSON.stringify({
          event: "rate_limit_exceeded",
          label: this.label,
          timestamp: new Date().toISOString(),
        })
      );
    }
    return allowed;
  }
}

function logRetry(attempt: number, maxRetries: number, error: string): void {
  console.info(
    JSON.stringify({
      event: "retry_attempt",
      attempt,
      maxRetries,
      error,
      timestamp: new Date().toISOString(),
    })
  );
}

In a production environment, forward these logs to your observability platform (Datadog, Grafana, CloudWatch, etc.) and set up alerts for spikes in rate limit rejections or circuit breaker openings.

Conclusion

Rate limiting and retry strategies are essential components of any production-grade MCP server or client. By implementing token bucket rate limiters, exponential backoff with jitter, circuit breakers, and per-user isolation, you can build MCP integrations that gracefully handle the realities of upstream API constraints and transient network failures. The key is to be proactive rather than reactive: design your error handling before you hit production traffic, test it under simulated failure conditions, and monitor it continuously once deployed. With the patterns and code examples in this guide, you have everything you need to make your MCP-powered applications resilient, fair, and reliable.

— Ad —

Google AdSense will appear here after approval

← Back to all articles