Límite de tasa en MCP: cómo añadir rate limits a un servidor MCP
Un plan práctico para el límite de tasa en MCP con TypeScript. Crea un token bucket, identifica a los clientes por token o id de cliente, devuelve 429 y Retry-After por HTTP, pondera las herramientas por su costo, escala los contadores con Redis y prueba cada límite con fake timers antes de que un agente encuentre los huecos.
Un agente de IA nunca se aburre. Si lo pones a trabajar con tu servidor MCP y una tarea vaga, puede lanzar 40 llamadas a herramientas en diez segundos, reintentar cada fallo al instante y disparar peticiones paralelas que nadie previó. Eso es genial para la productividad y terrible para tu factura. El límite de tasa en MCP es lo que permite a un servidor del Model Context Protocol seguir siendo útil bajo esa presión: cada cliente tiene un presupuesto justo, las herramientas caras cuestan más que las baratas y quien agota su cuota recibe un mensaje claro sobre cuándo puede volver.
Este artículo muestra cómo añadir rate limits a un servidor MCP en TypeScript, desde un token bucket sencillo hasta un limitador basado en Redis que funciona en varias instancias. Verás dónde deben ir las comprobaciones, cómo devolver errores que un agente pueda usar y cómo probarlo todo sin esperar un minuto real.
Por qué los servidores MCP necesitan límites de tasa
Un límite de tasa es una promesa sobre la capacidad: este cliente puede usar esta cantidad, por unidad de tiempo, y no más. Las notas de seguridad sobre herramientas de la especificación del Model Context Protocol incluyen la limitación de invocaciones de herramientas como un requisito para los servidores, junto a la validación de entradas y el control de acceso. Los SDK oficiales se encargan de los transportes y los esquemas, pero dejan el limitador en tus manos, así que cada autor de servidor acaba escribiendo uno.
Los agentes reintentan sin cansarse
Una persona que hace clic en un botón es lenta y fácil de predecir. Un bucle de agente no es ni una cosa ni la otra. El modelo llama a una herramienta, lee el resultado y decide qué llamar después, a menudo en milisegundos. Tres patrones aparecen una y otra vez:
Tormentas de reintentos. Una herramienta falla, el modelo lo intenta de nuevo, vuelve a fallar y repite hasta que se agota su contexto o su presupuesto.
Abanico paralelo. Los clientes pueden enviar varias llamadas a herramientas a la vez, así que un solo prompt puede convertirse en una docena de peticiones simultáneas.
Bucles descontrolados. Una tarea vaga junto a una herramienta que nunca dice "listo" produce cientos de llamadas desde una sola sesión.
También hay un ángulo de seguridad. Una página web o un documento que lee el agente puede esconder instrucciones que le pidan llamar a una herramienta una y otra vez. No siempre puedes detener la inyección, pero un rate limit limita el daño.
Las herramientas envuelven APIs de pago
La mayoría de las herramientas MCP son envoltorios finos alrededor de algo que cuesta dinero o tiene su propia cuota: un modelo de lenguaje, un generador de imágenes, una API de búsqueda, una base de datos. Un limitador protege tres cosas a la vez:
Tu presupuesto, porque una sola sesión ruidosa no debería quemar un día entero de gasto.
Tus cuotas upstream, porque los proveedores responden al abuso con respuestas 429 que afectan a todos los usuarios de tu servidor.
La latencia de los demás usuarios, porque un cliente codicioso que satura tus workers ralentiza a todos los demás.
💡 Los servidores stdio locales también necesitan límites. Aunque solo una persona ejecute el servidor en su equipo portátil, un agente en bucle puede vaciar la API de pago que hay detrás. Un presupuesto por herramienta no cuesta nada añadirlo y evita una sorpresa muy desagradable.
Elige el algoritmo adecuado
Seis diseños cubren casi todos los casos. Así se comportan cuando el tráfico de agentes los pone a prueba:
Algoritmo
Comportamiento ante ráfagas
Memoria por cliente
Ideal para
Ventana fija
Permite hasta 2x en los bordes de la ventana
Un contador
Cuotas sencillas, como límites diarios
Registro de ventana deslizante
Exacto, sin ráfagas en los bordes
Una marca de tiempo por petición
Volumen bajo, límites estrictos
Contador de ventana deslizante
Cercano al exacto
Dos contadores
Endpoints HTTP de alto volumen
Token bucket
Ráfagas controladas, recarga constante
Dos números
Llamadas a herramientas desde agentes
Leaky bucket
Sin ráfagas, salida suave
Una cola
Alimentar servicios upstream frágiles
Límite de concurrencia
Limita trabajos en paralelo
Un contador
Herramientas de ejecución larga
Ventanas fijas y deslizantes
Un contador de ventana fija es el diseño más simple: cuenta las peticiones por minuto y se reinicia al comienzo de cada minuto. Es barato y fácil de explicar, pero un cliente puede enviar una cuota completa a las 12:00:59 y otra cuota completa a las 12:01:00, duplicando la ráfaga que ve tu servidor.
Una ventana deslizante elimina ese borde mirando los 60 segundos anteriores al momento actual. O bien guarda cada marca de tiempo (exacto, pero con mucha memoria) o pondera el contador de la ventana anterior (suficientemente cercano y barato). Recurre a las ventanas cuando quieras cuotas simples, como 1.000 llamadas al día, y el momento exacto de la recarga no importe.
Por qué el token bucket suele ganar
El tráfico de agentes llega en ráfagas: nada durante diez segundos, luego seis llamadas a herramientas de golpe, y después silencio. Un token bucket encaja con esa forma. Cada cliente tiene un cubo con una capacidad (la mayor ráfaga permitida) y una tasa de recarga (el ritmo sostenido). Una llamada saca tokens y el tiempo los devuelve. Un cubo de 60 tokens que se recarga a uno por segundo permite una ráfaga de 60 llamadas y luego una llamada por segundo, lo que es fácil de explicar a los usuarios como "60 ahora, 60 por minuto después".
Dos propiedades lo hacen ideal para MCP:
Recarga perezosa. Calculas la recarga cuando llega una petición, así que no hay temporizadores que gestionar.
Soporte de costos. Una herramienta de video puede tomar 20 tokens mientras una consulta toma uno, todo del mismo presupuesto.
Construye el limitador en TypeScript
El limitador siguiente funciona en cualquier servidor MCP en TypeScript construido con @modelcontextprotocol/sdk. No tiene dependencias y mantiene su estado en memoria.
La clase del 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();
Hay tres detalles que merecen una segunda mirada. La recarga se calcula a partir del tiempo transcurrido, así que no hace falta setInterval por cliente. El argumento cost permite que un mismo limitador sirva a herramientas baratas y caras. Y retryAfterMs indica exactamente cuánto falta para que el cubo tenga tokens suficientes, que es el número que muestras al agente.
Envuelve cada manejador de herramienta
Pon la comprobación en un único envoltorio para que ninguna herramienta pueda olvidarla:
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 }] };
}),
);
💡 Devuelve el bloqueo como resultado de la herramienta, no como un error lanzado. La especificación separa los errores de protocolo de los errores de ejecución de herramientas. Un resultado con isError: true se queda dentro del contexto del modelo, así que el agente lee "espera 12 segundos" y se ajusta. Una excepción lanzada se convierte en un error JSON-RPC que muchos clientes muestran como un fallo y nada más.
Identifica a los clientes y protege el endpoint
Un límite es tan justo como la forma en que identificas al cliente. Si lo haces mal, o bien frenas a todos a la vez o bien permites que un cliente esquive el límite reconectándose.
Elige la identidad correcta
Transporte y autenticación
Identidad a usar
Ojo con
stdio
Un presupuesto compartido por herramienta
Un proceso atiende a un solo cliente, así que no hay a quién distinguir
Streamable HTTP con OAuth
El id de cliente o de usuario de authInfo
La mejor opción, ya que sobrevive a las reconexiones
Streamable HTTP con un bearer token estático
Un hash del token
Rota los tokens y haz hash antes de guardarlos
HTTP anónimo
Dirección IP
Las oficinas compartidas y las redes móviles parecen un solo cliente
Resiste la tentación de usar la cabecera Mcp-Session-Id como identidad principal. La emite el servidor, y un cliente puede simplemente iniciar una sesión nueva para obtener un cubo limpio. Usa las sesiones como límite secundario, por ejemplo para limitar los trabajos en curso por sesión, y mantén el presupuesto ligado a algo que sobreviva a las reconexiones.
Devuelve 429 con Retry-After
Pon un límite general en la capa HTTP y uno preciso dentro de las herramientas. La capa HTTP es barata y se ejecuta antes de que se analice cualquier JSON o se cree cualquier sesión, así que protege al servidor de las avalanchas. No puede distinguir tools/list de un tools/call caro sin leer el cuerpo, así que mantenla generosa y deja que la capa de herramientas haga la contabilidad 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);
Haz hash del bearer token antes de usarlo como identificador, para que las credenciales en bruto nunca aparezcan en un Map ni en una línea de log. Envía Retry-After en segundos enteros, porque los clientes HTTP con lógica de reintentos lo leen. Y detrás de un proxy, configura trust proxy, o todos los clientes anónimos compartirán la dirección del proxy.
Asigna costos a las herramientas según lo que cuestan
Entra en la cocina de un restaurante concurrido y verás comandas de tamaños muy distintos colgadas en la misma barra. Una ensalada de acompañamiento y un guiso lento no requieren el mismo esfuerzo, y una buena cocina no los trata igual. Las llamadas a herramientas funcionan igual.
Tipo de herramienta
Ejemplo
Costo en tokens
Protección extra
Consulta de solo lectura
list_articles, get_article
1
Ninguna
Escritura o publicación
save_article
2
Comprobación de idempotencia
Generación de texto
Resúmenes con un modelo de lenguaje
3
Limita la longitud de la salida
Generación de imágenes
generate_image
5
2 simultáneas por cliente
Generación de video
generate_image_to_video
20
1 simultánea, envíos espaciados
Con un cubo de 60 tokens que se recarga a uno por segundo, un cliente puede hacer 60 consultas en una ráfaga, o 12 generaciones de imágenes, o 3 trabajos de video, y el presupuesto se recupera por completo en un minuto.
Pesos de costo por herramienta
Guarda los pesos en un único lugar y pásalos al envoltorio de la sección anterior:
Empieza con pesos proporcionales a lo que cuesta cada llamada en dinero o en segundos de upstream, y ajústalos después con el tráfico real.
Limita los trabajos y reduce la presión upstream
Un presupuesto de tokens limita con qué frecuencia un cliente inicia trabajo. No limita cuánto trabajo se ejecuta en el mismo momento. Las herramientas largas necesitan un tope de concurrencia, y las colas upstream compartidas a veces necesitan espaciar los envíos. Ambas cosas son breves:
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));
}
}
Devuelve "busy" al agente como un error normal de herramienta que diga que ya hay un trabajo en curso y sugiera consultar su estado. El jitter en fetchWithBackoff importa: sin él, cada instancia que recibió un 429 se despierta en el mismo instante y vuelve a golpear el upstream a la vez.
💡 Límites reales, publicados. La propia API de PicassoIA documenta 5 predicciones simultáneas por cuenta, compartidas entre tokens de API y conexiones MCP, además de prompts de 4.000 caracteres, cuerpos de petición de 10 MB y un tiempo de espera de 3 horas. Sus herramientas de imagen y video también devuelven una predict_id y una pista next_poll_in_seconds, así que el cliente nunca tiene que adivinar cada cuánto consultar. Copia esa idea: un límite con una cifra publicada y una pista para consultar es un límite que los agentes pueden cumplir.
Escala más allá de un solo proceso
El limitador en memoria tiene un defecto: su memoria pertenece a un solo proceso. Si ejecutas tres instancias detrás de un balanceador de carga, cada una mantiene sus propios contadores, así que un cliente obtiene en la práctica el triple del límite. Las plataformas serverless son peores, porque cada arranque en frío empieza con cubos vacíos. La solución es mover los contadores a un almacén compartido, y Redis es la opción habitual porque sus operaciones son atómicas y rápidas.
Contadores compartidos con 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 librería cuenta en ventanas fijas, así que se aplica la ráfaga de borde de la tabla de algoritmos. Para la mayoría de los servidores MCP esa contrapartida es aceptable. Si necesitas un token bucket real entre instancias, guarda los dos números en un hash de Redis y ejecuta la recarga y la extracción en un único script Lua, porque leer y luego escribir desde dos instancias es una condición de carrera.
Fallar abierto o fallar cerrado
Redis acabará cayendo, y tu limitador tiene que elegir un bando:
Fallar cerrado para las herramientas que cuestan dinero. Una caída breve sale más barata que una cola de video sin límite.
Fallar abierto para las lecturas baratas, donde bloquear a todos haría más daño que una ráfaga de consultas.
Degradar, no desactivar. La librería admite un insuranceLimiter en memoria como respaldo. Los límites se aplican entonces por instancia, lo que sigue siendo mucho mejor que no tener ninguno.
Elijas lo que elijas, registra cada fallo del limitador con alertas claras, porque un fallo abierto silencioso es la forma en que un límite deja de existir sin que nadie lo note.
Prueba y vigila los límites
Un limitador que nunca ha rechazado nada en una prueba es un limitador en el que no puedes confiar.
Prueba con fake timers
Los fake timers permiten que una prueba unitaria recorra un minuto completo en un solo milisegundo:
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);
});
});
Después de las pruebas unitarias, ejecuta el servidor real bajo el MCP Inspector (npx @modelcontextprotocol/inspector) y llama a una herramienta en bucle. Las primeras llamadas deberían tener éxito y el resto debería devolver el mensaje de límite de tasa con un tiempo de espera. Para probar la capa HTTP, envía una ráfaga con curl y cuenta los códigos de estado:
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
Las primeras respuestas dependen de cómo tengas configuradas las sesiones, pero una vez que el cubo está vacío, cada respuesta debería ser un 429.
Métricas que merece la pena vigilar
Cuenta cada rechazo con el nombre de la herramienta y un cubo de cliente (nunca la identidad en bruto). Estos pocos números muestran si los límites son justos:
Métrica
Qué te indica
Alerta cuando
Tasa de rechazos por herramienta
Límites demasiado estrictos, o un cliente ruidoso
Por encima del 5% durante 10 minutos
Principales clientes por rechazos
Un agente en bucle o abusivo
Un cliente acumula más de la mitad de ellos
Retry-After, percentil 95
Cuánto esperan realmente los agentes
Por encima de 60 segundos
Trabajos en curso
Saturación de concurrencia
En el tope durante 5 minutos
Errores del limitador
Salud de Redis
Cualquiera
Errores que aparecen en servidores reales:
Usar el id de sesión como única identidad, de modo que una reconexión reinicia el límite.
Compartir un único cubo global, de modo que un cliente pesado deja sin recursos a todos los demás.
Descartar o frenar peticiones en silencio en lugar de devolver un error con un tiempo de espera.
Fijar los límites una vez y no volver a revisar nunca los números de rechazos.
Pruébalo en PicassoIA
El limitador de arriba tiene menos de cien líneas, lo que lo convierte en un buen trabajo para un modelo de lenguaje: bien especificado, fácil de probar y rápido de revisar. PicassoIA reúne varios modelos capaces de programar en un mismo sitio, así que puedes redactar, comparar y corregir sin estar cambiando de cuenta.
Pega un prompt concreto. Indica el SDK, el transporte, el algoritmo, el presupuesto, el costo de cada herramienta y el texto exacto del error. Por ejemplo:
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.
Pide primero las pruebas. Leer las pruebas muestra qué comportamiento asumió el modelo antes de que leas una sola línea de implementación.
Ejecútalas y devuelve los fallos. Pega la salida exacta del error en la misma conversación y pide una corrección.
Pide una revisión. Termina con "Revisa este limitador en busca de condiciones de carrera y crecimiento de memoria" y lee la respuesta con sentido crítico.
Limita cada petición a una sola preocupación y da al modelo tus cifras reales en lugar de "valores por defecto razonables". Otros modelos merecen una segunda opinión:
Cuando tu servidor esté protegido, ponlo a trabajar. Cada fotografía de este artículo empezó como un prompt de texto sencillo escrito para P-Image, y puedes ejecutar el mismo prompt en Flux 2 Pro para comparar resultados. Abre Picasso IA, describe una escena en una o dos frases y genera tu primera imagen. Cambia el objetivo, la luz o el ángulo, vuelve a generarla y luego anima tu resultado favorito en un video corto. La forma más rápida de descubrir lo que puede hacer la plataforma es experimentar con tus propias ideas.