Rate limiting em MCP: como adicionar limites de taxa a um servidor MCP
Um plano prático de rate limiting em MCP com TypeScript. Crie um token bucket, identifique os chamadores por token ou client id, retorne 429 e Retry-After via HTTP, pondere as ferramentas pelo custo, escale os contadores com Redis e teste cada limite com fake timers antes que um agente encontre as falhas.
Um agente de IA nunca se cansa. Aponte um para o seu servidor MCP com uma tarefa vaga e ele pode disparar 40 chamadas de ferramentas em dez segundos, tentar de novo cada falha imediatamente e lançar requisições paralelas que ninguém planejou. Isso é ótimo para a produtividade e péssimo para a sua fatura. O rate limiting em MCP é o que mantém um servidor do Model Context Protocol útil sob esse tipo de pressão: cada chamador recebe um orçamento justo, ferramentas caras custam mais que as baratas, e quem esgota o orçamento recebe uma mensagem clara sobre quando voltar.
Este artigo mostra como adicionar limites de taxa a um servidor MCP em TypeScript, desde um token bucket curto até um limitador apoiado em Redis que funciona em várias instâncias. Você vai ver onde as verificações devem ficar, como retornar erros nos quais um agente consiga agir e como testar tudo isso sem esperar um minuto de verdade.
Por que servidores MCP precisam de rate limits
Um rate limit é uma promessa sobre capacidade: este chamador pode usar tanto assim, por unidade de tempo, e não mais que isso. As notas de segurança para ferramentas na especificação do Model Context Protocol listam o rate limiting das invocações de ferramentas como um requisito dos servidores, ao lado da validação de entrada e do controle de acesso. Os SDKs oficiais cuidam dos transportes e dos schemas, mas deixam o limitador por sua conta, então todo autor de servidor acaba escrevendo um.
Agentes tentam de novo sem se cansar
Uma pessoa clicando em um botão é lenta e fácil de prever. Um loop de agente não é nem uma coisa nem outra. O modelo chama uma ferramenta, lê o resultado e decide o que chamar em seguida, muitas vezes em milissegundos. Três padrões aparecem repetidamente:
Tempestades de retentativas. Uma ferramenta falha, o modelo tenta de novo, falha outra vez e repete até o contexto ou o orçamento acabar.
Fan-out paralelo. Os clientes podem enviar várias chamadas de ferramentas ao mesmo tempo, então um único prompt pode virar uma dúzia de requisições simultâneas.
Loops descontrolados. Uma tarefa vaga somada a uma ferramenta que nunca diz "pronto" produz centenas de chamadas em uma só sessão.
Há também um ângulo de segurança. Uma página web ou um documento que o agente lê pode esconder instruções mandando ele chamar uma ferramenta várias vezes. Nem sempre você consegue impedir a injeção, mas um rate limit limita o estrago.
Ferramentas encapsulam APIs pagas
A maioria das ferramentas MCP são wrappers finos em volta de algo que custa dinheiro ou tem cota própria: um modelo de linguagem, um gerador de imagens, uma API de busca, um banco de dados. Um limitador protege três coisas ao mesmo tempo:
Seu orçamento, porque uma sessão barulhenta não deveria queimar um dia inteiro de gastos.
Suas cotas upstream, porque os provedores respondem a abusos com respostas 429 que atingem todos os usuários do seu servidor.
A latência dos outros usuários, porque um cliente ganancioso que satura seus workers deixa todo mundo mais lento.
💡 Servidores stdio locais também precisam de limites. Mesmo quando só uma pessoa roda o servidor em um notebook, um agente em loop pode esvaziar a API paga por trás dele. Um orçamento por ferramenta não custa nada para adicionar e evita uma surpresa bem desagradável.
Escolha o algoritmo certo
Seis abordagens cobrem quase todos os casos. Veja como se comportam quando o tráfego de agentes chega até eles:
Algoritmo
Comportamento com rajadas
Memória por chamador
Melhor para
Janela fixa
Permite até 2x nas bordas da janela
Um contador
Cotas simples, como limites diários
Log de janela deslizante
Exato, sem rajadas nas bordas
Um timestamp por requisição
Volume baixo, limites rígidos
Contador de janela deslizante
Próximo do exato
Dois contadores
Endpoints HTTP de alto volume
Token bucket
Rajadas controladas, reposição constante
Dois números
Chamadas de ferramentas por agentes
Leaky bucket
Sem rajadas, saída suave
Uma fila
Alimentar serviços upstream frágeis
Limite de concorrência
Limita trabalhos paralelos
Um contador
Ferramentas de execução longa
Janelas fixas e deslizantes
Um contador de janela fixa é o desenho mais simples: conte as requisições por minuto e zere no início de cada minuto. É barato e fácil de explicar, mas um chamador pode enviar uma cota inteira às 12:00:59 e outra cota inteira às 12:01:00, dobrando a rajada que seu servidor recebe.
Uma janela deslizante elimina essa borda olhando os 60 segundos anteriores a partir do momento atual. Ela ou guarda cada timestamp (exato, mas com muita memória) ou pondera o contador da janela anterior (suficientemente próximo e barato). Recorra às janelas quando quiser cotas simples, como 1.000 chamadas por dia, em que o momento exato da reposição não importa.
Por que o token bucket geralmente vence
O tráfego de agentes é em rajadas: nada por dez segundos, depois seis chamadas de ferramentas de uma vez, depois silêncio. Um token bucket se encaixa nesse formato. Cada chamador tem um balde com uma capacidade (a maior rajada permitida) e uma taxa de reposição (o ritmo sustentado). Uma chamada retira tokens, e o tempo os devolve. Um balde de 60 tokens que repõe um por segundo permite uma rajada de 60 chamadas e depois uma chamada por segundo, o que é fácil de explicar aos usuários como "60 agora, 60 por minuto depois disso".
Duas propriedades o tornam ideal para MCP:
Reposição preguiçosa. Você calcula a reposição quando a requisição chega, então não há timers para gerenciar.
Suporte a custos. Uma ferramenta de vídeo pode consumir 20 tokens enquanto uma consulta consome um, tudo do mesmo orçamento.
Construa o limitador em TypeScript
O limitador abaixo funciona em qualquer servidor MCP em TypeScript construído sobre @modelcontextprotocol/sdk. Ele não tem dependências e mantém o estado na memória.
A classe do limitador
// rate-limit.ts
export type Decision = {
allowed: boolean;
remaining: number;
retryAfterMs: number;
};
type Bucket = { tokens: number; updatedAt: number };
export class TokenBucket {
private buckets = new Map<string, Bucket>();
constructor(
private readonly capacity: number,
private readonly refillPerSecond: number,
) {}
take(id: string, cost = 1): Decision {
if (cost > this.capacity) {
throw new RangeError(`Cost ${cost} is larger than the bucket (${this.capacity})`);
}
const now = Date.now();
const bucket = this.buckets.get(id) ?? { tokens: this.capacity, updatedAt: now };
const elapsedSeconds = (now - bucket.updatedAt) / 1000;
bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
bucket.updatedAt = now;
this.buckets.set(id, bucket);
if (bucket.tokens >= cost) {
bucket.tokens -= cost;
return { allowed: true, remaining: Math.floor(bucket.tokens), retryAfterMs: 0 };
}
const missing = cost - bucket.tokens;
return {
allowed: false,
remaining: 0,
retryAfterMs: Math.ceil((missing / this.refillPerSecond) * 1000),
};
}
// Drop idle buckets so the map cannot grow forever.
sweep(maxIdleMs = 10 * 60_000) {
const cutoff = Date.now() - maxIdleMs;
for (const [id, bucket] of this.buckets) {
if (bucket.updatedAt < cutoff) this.buckets.delete(id);
}
}
}
export const budget = new TokenBucket(60, 1); // burst of 60, refills one per second
setInterval(() => budget.sweep(), 60_000).unref();
Três detalhes merecem uma segunda olhada. A reposição vem do tempo decorrido, então não há setInterval por chamador. O argumento cost permite que um único limitador atenda ferramentas baratas e caras. E retryAfterMs informa exatamente quanto tempo falta até o balde ter tokens suficientes, que é o número que você mostra ao agente.
Envolva cada handler de ferramenta
Coloque a verificação em um único wrapper para que nenhuma ferramenta possa esquecê-la:
import { z } from "zod";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { budget } from "./rate-limit.js";
type Extra = { authInfo?: { clientId?: string }; sessionId?: string };
export function limited<Args>(
tool: string,
cost: number,
handler: (args: Args, extra: Extra) => Promise<CallToolResult>,
) {
return async (args: Args, extra: Extra): Promise<CallToolResult> => {
const caller = extra.authInfo?.clientId ?? "anonymous";
const decision = budget.take(caller, cost);
if (!decision.allowed) {
const seconds = Math.ceil(decision.retryAfterMs / 1000);
return {
isError: true,
content: [
{
type: "text",
text: `Rate limit reached for ${tool}. Wait ${seconds} seconds before calling it again.`,
},
],
};
}
return handler(args, extra);
};
}
server.registerTool(
"generate_image",
{ description: "Generate an image from a prompt", inputSchema: { prompt: z.string().max(4000) } },
limited("generate_image", 5, async ({ prompt }) => {
const url = await createImage(prompt);
return { content: [{ type: "text", text: url }] };
}),
);
💡 Retorne o bloqueio como resultado da ferramenta, não como erro lançado. A especificação separa erros de protocolo de erros de execução de ferramentas. Um resultado com isError: true permanece dentro do contexto do modelo, então o agente lê "aguarde 12 segundos" e se ajusta. Uma exceção lançada vira um erro JSON-RPC que muitos clientes mostram apenas como falha, sem nada mais.
Identifique os chamadores e proteja o endpoint
Um limite só é tão justo quanto a forma como você identifica o chamador. Errar aqui faz você limitar todo mundo junto ou deixar um cliente contornar o limite reconectando.
Escolha a identidade certa
Transporte e autenticação
Identidade a usar
Cuidado com
stdio
Um orçamento compartilhado por ferramenta
Um processo atende um cliente, então não há chamador a diferenciar
Streamable HTTP com OAuth
O id do cliente ou do usuário vindo de authInfo
A melhor opção, já que sobrevive a reconexões
Streamable HTTP com bearer token estático
Um hash do token
Gire os tokens e faça o hash antes de armazenar
HTTP anônimo
Endereço IP
Escritórios compartilhados e redes móveis parecem um único chamador
Resista à tentação de usar o cabeçalho Mcp-Session-Id como identidade principal. O servidor o emite, e um cliente pode simplesmente inicializar uma nova sessão para obter um balde novo. Use as sessões como limite secundário, por exemplo para limitar os trabalhos em andamento por sessão, e mantenha o orçamento ligado a algo que sobreviva a reconexões.
Retorne 429 com Retry-After
Coloque um limite grosseiro na camada HTTP e um limite preciso dentro das ferramentas. A camada HTTP é barata e roda antes de qualquer JSON ser analisado ou qualquer sessão ser criada, então protege o servidor contra enxurradas. Ela não consegue distinguir tools/list de um tools/call caro sem ler o corpo, então mantenha-a generosa e deixe a camada de ferramentas fazer a contabilidade precisa.
import { createHash } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import { TokenBucket } from "./rate-limit.js";
const httpBudget = new TokenBucket(120, 2); // 120 burst, 2 per second sustained
function callerId(req: Request): string {
const auth = req.header("authorization");
if (auth) return "tok:" + createHash("sha256").update(auth).digest("hex").slice(0, 16);
return "ip:" + req.ip;
}
export function limitHttp(req: Request, res: Response, next: NextFunction) {
const decision = httpBudget.take(callerId(req));
res.setHeader("RateLimit-Remaining", String(decision.remaining));
if (decision.allowed) return next();
res.setHeader("Retry-After", String(Math.ceil(decision.retryAfterMs / 1000)));
res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Too many requests. Retry after the delay in Retry-After." },
id: null,
});
}
// app.set("trust proxy", 1);
// app.post("/mcp", limitHttp, handleMcp);
Faça o hash do bearer token antes de usá-lo como identificador, para que as credenciais brutas nunca fiquem em um Map ou em uma linha de log. Envie Retry-After em segundos inteiros, porque clientes HTTP com lógica de retentativa leem esse valor. E atrás de um proxy, defina trust proxy, ou todo chamador anônimo vai compartilhar o endereço do proxy.
Pondere as ferramentas pelo que custam
Entre em uma cozinha de restaurante movimentada e você verá comandas de tamanhos bem diferentes penduradas no mesmo trilho. Uma salada de acompanhamento e um cozido demorado não exigem o mesmo esforço, e uma boa cozinha não os trata da mesma forma. As chamadas de ferramentas funcionam do mesmo jeito.
Tipo de ferramenta
Exemplo
Custo em tokens
Proteção extra
Consulta somente leitura
list_articles, get_article
1
Nenhuma
Escrita ou publicação
save_article
2
Verificação de idempotência
Geração de texto
Resumos feitos por um modelo de linguagem
3
Limitar o tamanho da saída
Geração de imagem
generate_image
5
2 simultâneas por chamador
Geração de vídeo
generate_image_to_video
20
1 simultânea, envios espaçados
Com um balde de 60 tokens que repõe um por segundo, um chamador pode executar 60 consultas em rajada, ou 12 gerações de imagem, ou 3 trabalhos de vídeo, e o orçamento se recompõe por completo em um minuto.
Pesos de custo por ferramenta
Mantenha os pesos em um único lugar e passe-os para o wrapper da seção anterior:
Comece com pesos proporcionais ao que cada chamada custa em dinheiro ou em segundos upstream, depois ajuste com base no tráfego real.
Limite trabalhos e recue com o upstream
Um orçamento de tokens limita com que frequência um chamador inicia trabalho. Ele não limita quanto trabalho roda ao mesmo tempo. Ferramentas longas precisam de um limite de concorrência, e filas upstream compartilhadas às vezes exigem espaçamento entre os envios. Ambos são curtos:
const inFlight = new Map<string, number>();
export async function withConcurrency<T>(
caller: string,
max: number,
job: () => Promise<T>,
): Promise<T | "busy"> {
const current = inFlight.get(caller) ?? 0;
if (current >= max) return "busy";
inFlight.set(caller, current + 1);
try {
return await job();
} finally {
const left = (inFlight.get(caller) ?? 1) - 1;
if (left <= 0) inFlight.delete(caller);
else inFlight.set(caller, left);
}
}
// One submission per slot: concurrent callers queue behind each other.
let nextSlot = 0;
export async function waitForSlot(minGapMs = 30_000) {
const now = Date.now();
const start = Math.max(now, nextSlot);
nextSlot = start + minGapMs;
await new Promise((resolve) => setTimeout(resolve, start - now));
}
// When the upstream API answers 429 or 5xx, wait and retry with jitter.
export async function fetchWithBackoff(
send: () => Promise<Response>,
maxAttempts = 4,
): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const response = await send();
const retryable = response.status === 429 || response.status >= 500;
if (!retryable || attempt >= maxAttempts) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delayMs + Math.random() * 250));
}
}
Retorne "busy" ao agente como um erro normal de ferramenta que diz que um trabalho já está em execução e sugere consultar o status dele. O jitter em fetchWithBackoff importa: sem ele, todas as instâncias que receberam um 429 acordam no mesmo instante e batem no upstream juntas de novo.
💡 Limites reais, publicados. A própria API da PicassoIA documenta 5 predições simultâneas por conta, compartilhadas entre tokens de API e conexões MCP, além de prompts de 4.000 caracteres, corpos de requisição de 10 MB e um timeout de 3 horas. Suas ferramentas de imagem e vídeo também devolvem um predict_id e uma dica next_poll_in_seconds, então o cliente nunca precisa adivinhar com que frequência consultar. Copie essa ideia: um limite com número publicado e uma dica de polling é um limite que agentes conseguem obedecer.
Escale além de um processo
O limitador em memória tem uma falha: a memória dele pertence a um único processo. Rode três instâncias atrás de um balanceador de carga e cada uma mantém seus próprios contadores, então um chamador efetivamente recebe três vezes o limite. Plataformas serverless são piores, já que cada cold start começa com baldes vazios. A solução é mover os contadores para um armazenamento compartilhado, e o Redis é a escolha habitual porque suas operações são atômicas e rápidas.
Contadores compartilhados com Redis
import Redis from "ioredis";
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import type { Decision } from "./rate-limit.js";
const redis = new Redis(process.env.REDIS_URL!);
const FAIL_OPEN = process.env.RATE_LIMIT_FAIL_OPEN === "true";
const limiter = new RateLimiterRedis({
storeClient: redis,
points: 60, // budget per window
duration: 60, // window length in seconds
});
export async function takeShared(caller: string, cost: number): Promise<Decision> {
try {
const res = await limiter.consume(caller, cost);
return { allowed: true, remaining: res.remainingPoints, retryAfterMs: 0 };
} catch (rejection) {
if (rejection instanceof RateLimiterRes) {
return { allowed: false, remaining: 0, retryAfterMs: rejection.msBeforeNext };
}
// Redis itself failed, so apply the configured failure policy.
return { allowed: FAIL_OPEN, remaining: 0, retryAfterMs: 5_000 };
}
}
Esta biblioteca conta em janelas fixas, então a rajada na borda da tabela de algoritmos se aplica. Para a maioria dos servidores MCP essa contrapartida é aceitável. Se você precisar de um token bucket real entre instâncias, guarde os dois números em um hash do Redis e execute a reposição e a retirada em um único script Lua, porque uma leitura seguida de uma escrita feita por duas instâncias é uma condição de corrida.
Falhar aberto ou falhar fechado
O Redis vai cair com o tempo, e seu limitador precisa escolher um lado:
Falhar fechado para ferramentas que custam dinheiro. Uma queda curta sai mais barata que uma fila de vídeo sem limite.
Falhar aberto para leituras baratas, em que bloquear todo mundo causaria mais dano que uma rajada de consultas.
Degradar, não desligar. A biblioteca oferece um insuranceLimiter em memória como fallback. Os limites então se aplicam por instância, o que ainda é muito melhor que nenhum.
Qualquer que seja a escolha, registre cada falha do limitador em alto e bom som, porque uma falha aberta silenciosa é assim que um limite deixa de existir sem ninguém perceber.
Teste e monitore os limites
Um limitador que nunca rejeitou nada em um teste é um limitador em que você não pode confiar.
Teste com fake timers
Fake timers permitem que um teste unitário percorra um minuto inteiro em um único milissegundo:
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TokenBucket } from "./rate-limit";
describe("TokenBucket", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("allows a burst, then blocks with a wait time", () => {
const bucket = new TokenBucket(3, 1);
for (let i = 0; i < 3; i++) expect(bucket.take("a").allowed).toBe(true);
const blocked = bucket.take("a");
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterMs).toBeGreaterThan(0);
});
it("refills as time passes", () => {
const bucket = new TokenBucket(1, 1);
bucket.take("a");
expect(bucket.take("a").allowed).toBe(false);
vi.advanceTimersByTime(1000);
expect(bucket.take("a").allowed).toBe(true);
});
it("keeps callers apart", () => {
const bucket = new TokenBucket(1, 1);
bucket.take("a");
expect(bucket.take("b").allowed).toBe(true);
});
});
Depois dos testes unitários, rode o servidor real sob o MCP Inspector (npx @modelcontextprotocol/inspector) e chame uma ferramenta em loop. As primeiras chamadas devem ter sucesso e as demais devem retornar a mensagem de rate limit com um tempo de espera. Para testar a camada HTTP, envie uma rajada com curl e conte os códigos de status:
for i in $(seq 1 150); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
done | sort | uniq -c
As respostas iniciais dependem da configuração da sua sessão, mas, quando o balde estiver vazio, toda resposta deve ser 429.
Métricas que vale acompanhar
Conte cada rejeição com o nome da ferramenta e um balde do chamador (nunca a identidade bruta). Esses poucos números mostram se os limites são justos:
Métrica
O que ela diz
Alerte quando
Taxa de rejeição por ferramenta
Limites apertados demais, ou um chamador barulhento
Acima de 5% por 10 minutos
Principais chamadores por rejeições
Um agente em loop ou abusivo
Um chamador concentra mais da metade delas
Retry-After, percentil 95
Quanto tempo os agentes estão realmente esperando
Acima de 60 segundos
Trabalhos em andamento
Saturação de concorrência
No limite por 5 minutos
Erros do limitador
Saúde do Redis
Qualquer um
Erros que aparecem em servidores reais:
Usar o id da sessão como única identidade, para que uma reconexão zere o limite.
Compartilhar um único balde global, para que um chamador pesado deixe todos sem recursos.
Descartar ou travar requisições em silêncio em vez de retornar um erro com tempo de espera.
Definir os limites uma vez e nunca mais olhar os números de rejeição.
Experimente na PicassoIA
O limitador acima tem menos de cem linhas, o que faz dele uma boa tarefa para um modelo de linguagem: bem especificado, fácil de testar e rápido de revisar. A PicassoIA reúne vários modelos com capacidade de programação em um só lugar, para que você possa rascunhar, comparar e corrigir sem gerenciar várias contas.
Cole um prompt específico. Informe o SDK, o transporte, o algoritmo, o orçamento, o custo de cada ferramenta e o texto exato do erro. Por exemplo:
Write a TypeScript token bucket limiter for an MCP server built on
@modelcontextprotocol/sdk. Budget: 60 tokens, refill 1 per second.
Costs: list_articles 1, generate_image 5, generate_image_to_video 20.
Identify callers by authInfo.clientId, fall back to "anonymous".
When blocked, return isError: true with the wait time in seconds.
Include vitest tests that use fake timers.
Peça os testes primeiro. Ler os testes mostra qual comportamento o modelo presumiu antes de você ler uma linha de implementação.
Rode-os e envie as falhas de volta. Cole a saída exata do erro na mesma conversa e peça uma correção.
Peça uma revisão. Termine com "Revise este limitador em busca de condições de corrida e crescimento de memória" e leia a resposta de forma crítica.
Mantenha cada requisição focada em uma preocupação e dê ao modelo os seus números reais, em vez de "padrões razoáveis". Outros modelos merecem uma segunda opinião:
Depois que o seu servidor estiver protegido, coloque-o para trabalhar. Cada fotografia deste artigo começou como um prompt de texto simples escrito para o P-Image, e você pode rodar o mesmo prompt no Flux 2 Pro para comparar os resultados. Abra o Picasso IA, descreva uma cena em uma ou duas frases e gere sua primeira imagem. Mude a lente, a luz ou o ângulo, gere de novo e depois anime seu resultado favorito em um vídeo curto. A forma mais rápida de descobrir o que a plataforma pode fazer é experimentar com as suas próprias ideias.