Un GET colgaba mi servidor MCP cinco minutos y ningún test lo vio

Un GET a un endpoint MCP no es un health check. Abre un stream SSE de servidor a cliente, y si tu servidor corre stateless no hay nadie del otro lado que lo cierre. En un proceso de larga vida eso es una conexión ociosa. En serverless es una factura: cinco minutos de función por cada GET, y un 504 al final.
Lo encontré buscando otra cosa. Mis tests estaban todos en verde.
Tres timeouts de cinco minutos en un día
Estaba investigando un reporte distinto, que los clientes conectados por OAuth perdían la autorización después de cada deploy, cuando abrí los logs de runtime y me encontré con esto:
17:00:29 GET /api/mcp 504 Vercel Runtime Timeout Error: Task timed out after 300 seconds
17:00:26 GET /api/mcp 504 Vercel Runtime Timeout Error: Task timed out after 300 seconds
16:04:11 GET /api/mcp 504 Vercel Runtime Timeout Error: Task timed out after 300 seconds
Tres en veinticuatro horas. Cada uno son cinco minutos de función facturada que no entregan absolutamente nada y terminan en un 504 para un cliente que hizo una pregunta legítima.
Lo curioso: los tres son GET. Los POST, que son el 90% del tráfico MCP real, funcionaban bien.
Qué significa un GET en Streamable HTTP
El transporte Streamable HTTP de MCP usa dos verbos y no son simétricos. Si quieres el recorrido completo del protocolo, lo desarmé en MCP por dentro.
POST es el que todo el mundo conoce: el cliente manda un mensaje JSON-RPC, el servidor responde. Pides la lista de tools, ejecutas una, recibes el resultado.
GET es otra cosa. La spec lo define así, en la sección "Listening for Messages from the Server":
The client MAY issue an HTTP GET to the MCP endpoint. This can be used to open an SSE stream, allowing the server to communicate to the client, without the client first sending data via HTTP POST.
O sea: el GET es el cliente abriendo un canal de servidor a cliente. Es cómo el servidor empuja notificaciones sin que nadie se las haya pedido. Un tools/list_changed, un log, un sampling request. El cliente deja esa conexión abierta y escucha.
Es SSE, no es un request y una respuesta. Está diseñado para quedarse abierto.
Y ahí está el problema.
Un stream sin nadie del otro lado
Mi ruta construye el transporte así, que es la configuración que recomienda todo el mundo para serverless:
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless, una instancia por request
})sessionIdGenerator: undefined significa modo stateless. Sin sesiones. Cada request crea un transporte nuevo, lo usa y lo tira. Es lo correcto en serverless, donde no tienes ninguna garantía de que dos requests caigan en la misma instancia. Es también la dirección en la que se está moviendo el protocolo entero.
Ahora lee el manejador de GET del SDK con eso en la cabeza (webStandardStreamableHttp.js, versión 1.29.0):
const readable = new ReadableStream({
start: controller => {
streamController = controller;
},
cancel: () => {
this._streamMapping.delete(this._standaloneSseStreamId);
}
});
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
};Crea un ReadableStream, se guarda el controller en _streamMapping bajo la clave _GET_stream, y devuelve 200 text/event-stream.
Ese controller es lo único que puede escribir en el stream, o cerrarlo. Y vive en una instancia del transporte que se descarta apenas el handler retorna.
En modo stateless no hay sesión, así que nunca va a haber una notificación para empujar por ahí. No hay nada que escriba. No hay nada que cierre. El stream queda abierto para siempre, esperando a un escritor que ya recolectó el garbage collector.
El SDK no está roto. En un servidor stateful de larga vida ese código es exactamente correcto: mantienes el transporte vivo, guardas la sesión y empujas notificaciones por ese canal durante horas. El bug aparece en la intersección entre ese diseño y sessionIdGenerator: undefined, y el SDK no expone ninguna opción para desactivar el stream de GET.
En Vercel, "para siempre" tiene un techo. El mío:
export const maxDuration = 300Cinco minutos después, la plataforma mata la función y el cliente recibe un 504.
Reproducirlo sin base de datos, sin red y sin auth
Antes de teorizar quería un comando que se pusiera en rojo. La tentación era levantar todo el stack, pero no hace falta nada de eso: el gate de autenticación ya pasó cuando el request llega al transporte, así que el bug se aísla instanciando el SDK a mano.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
const server = new McpServer({ name: "probe", version: "1.0.0" })
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
await server.connect(transport)
const t0 = Date.now()
const response = await transport.handleRequest(
new Request("https://example.com/api/mcp", {
method: process.env.METHOD ?? "GET",
headers: { Accept: "text/event-stream" },
})
)
console.log(`headers en ${Date.now() - t0}ms -> ${response.status}`)
// Aquí está la parte que importa: drenar el cuerpo con un deadline.
const timer = setTimeout(() => {
console.log("ROJO: el cuerpo sigue abierto")
process.exit(1)
}, 5000)
const reader = response.body.getReader()
while (true) {
const { done } = await reader.read()
if (done) break
}
clearTimeout(timer)
console.log("VERDE: el cuerpo cerró")Salida:
headers en 1ms -> HTTP 200 content-type=text/event-stream
ROJO el cuerpo sigue abierto después de 5000ms
exit=1Y el control con POST, que nunca estuvo roto:
headers en 1ms -> HTTP 406 content-type=application/json
VERDE POST cerró el cuerpo en 2ms (142 bytes)El 406 del POST es correcto y no tiene nada que ver con el bug: la sonda manda solamente Accept: text/event-stream, y para POST la spec exige que el cliente liste application/json y text/event-stream. El SDK lo rechaza bien. Lo que me importaba del control era que respondiera y cerrara, no con qué código.
Cinco segundos, determinista, sin dependencias. Ese fue el momento en que el bug pasó de "algo raro en los logs" a algo que podía arreglar.
Por qué la suite entera estaba en verde
Esta es la parte que de verdad quiero contar, porque el bug es aburrido y esto no. Es el mismo patrón que me llevó a escribir sobre los seis bugs que mis 615 aserciones en verde no detectaron: el problema no era la falta de tests, era dónde miraban.
Tenía tests para la ruta MCP. Buenos tests, con cobertura de los caminos de auth. Y pasaban todos, con el bug en producción quemando función a razón de cinco minutos por GET.
Dos razones se combinaron.
La primera: mockeaba el SDK
vi.mock("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js", () => ({
WebStandardStreamableHTTPServerTransport: class {},
}))Perfectamente razonable. El test era sobre el mapeo de 401 contra 403 en el gate de autenticación, no sobre el transporte, y stubbearlo mantiene el import barato y el test enfocado. Pero el bug vivía adentro justamente de lo que estaba stubbeado. Un transporte de mentira nunca abre un stream, así que nunca se cuelga.
La segunda, más sutil: el status code miente
const response = await transport.handleRequest(request)
return new Response(response.body, {
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...CORS_HEADERS },
})handleRequest devuelve en 1 milisegundo, con headers impecables y un 200. La ruta reenvía el cuerpo tal cual. Todo lo que un test observa normalmente (el status, los headers, el content-type) es correcto e inmediato.
El síntoma no está en la respuesta. Está en el cuerpo de la respuesta, que nunca termina.
Cualquier aserción de la forma expect(res.status).toBe(200) queda verde sobre el código roto. Y esa es la forma que tiene el 99% de los tests de rutas HTTP que escribimos todos.
La lección generaliza más allá de MCP: si tu handler puede devolver un stream, tu test tiene que consumirlo. Un status code es una promesa sobre lo que viene después, y en streaming esa promesa se puede incumplir sin que el status se entere.
El test de regresión que escribí drena el cuerpo con deadline, y corre contra el SDK real:
async function drainWithin(res: Response, ms: number): Promise<number> {
if (!res.body) return 0
let bytes = 0
const reader = res.body.getReader()
const drain = (async () => {
for (;;) {
const { done, value } = await reader.read()
if (done) return bytes
bytes += value?.byteLength ?? 0
}
})()
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`body still open after ${ms}ms`)), ms)
)
return Promise.race([drain, timeout])
}El arreglo: 405, y una aparente contradicción en la spec
La spec resuelve esto explícitamente. Punto 3 de "Listening for Messages from the Server":
The server MUST either return
Content-Type: text/event-streamin response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint.
Un servidor stateless no puede ofrecer ese stream. Entonces el 405 no es un parche: es literalmente la respuesta que la especificación prescribe para este caso.
Vale la pena señalar la tensión, porque un lector atento la va a encontrar. Unos párrafos más arriba, la misma spec dice:
The server MUST provide a single HTTP endpoint path (hereafter referred to as the MCP endpoint) that supports both POST and GET methods.
Leído solo, eso parece prohibir el 405. No se contradicen: "soportar GET" significa manejarlo de forma definida, y el punto 3 enumera las dos maneras válidas de hacerlo. Devolver 405 es soportar el GET, diciendo con claridad que aquí no hay stream. Lo que la spec no permite es lo que hacía yo, que era prometer un text/event-stream y después no entregar nada.
El código quedó así:
export function GET(): Response {
return Response.json(
{ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null },
{ status: 405, headers: { ...CORS_HEADERS, Allow: "POST, DELETE, OPTIONS" } }
)
}Dos decisiones que no son obvias:
Se responde antes del gate de autenticación. El método no está soportado para nadie, así que ningún header Authorization puede cambiar la respuesta. Chequear credenciales primero sería trabajo (una consulta a la base) para llegar al mismo lugar.
El 405 no lleva WWW-Authenticate. Mi 401 sí lo lleva, que es correcto y es la señal que le dice al cliente "ve a reautorizarte". Mandarlo en un error de método le diría a un cliente que rehaga todo el flujo OAuth por haber usado el verbo equivocado. Es un detalle chico con consecuencias grandes cuando el que lo lee es un agente automatizado que va a obedecer.
También saqué GET del transport.methods que anuncia mi server card. Publicitar un método que devuelve 405 es invitar a los clientes a abrir un stream que solo puede terminar en timeout.
Verificar el rojo, y un rojo falso en el camino
Un test de regresión que nunca viste fallar no es un test de regresión, es decoración. Así que revertí el handler y lo corrí.
Primer intento, rojo falso:
TypeError: Cannot read properties of undefined (reading 'headers')
❯ handleMcpRequest app/api/mcp/route.ts:59:30
Mis tests llamaban GET() sin argumentos, porque el handler nuevo no los necesita. El handler viejo recibía un Request y lo reenviaba. Entonces, sobre el código viejo, el test explotaba antes de llegar al transporte. Estaba en rojo, sí, pero probando que había cambiado la aridad de la función, no que el stream se colgaba. Un test así no habría cazado el bug original.
Lo arreglé llamando a través de una firma permisiva, para que la misma suite corra contra las dos versiones y falle por el comportamiento:
type AnyGet = (req?: Request) => Response | Promise<Response>
const callGet = (bearer?: string) => Promise.resolve((GET as AnyGet)(getRequest(bearer)))Segundo intento, el rojo de verdad:
× closes its body instead of holding a stream open 1022ms
AssertionError: promise rejected "Error: body still open after 1000ms"
× answers 405, not a stream
AssertionError: expected 200 to be 405
Ahí sí. Ese es el síntoma que veía producción, reproducido en un segundo en vez de en trescientos.
Qué revisar si tu MCP corre en serverless
Un GET a un endpoint MCP no es un health check. Es la apertura de un canal SSE. Si corres stateless, no tienes nada que mandar por ahí y tienes que decirlo con un 405.
En serverless, "el stream se queda abierto" no es una descripción de diseño, es una factura. El modelo mental de SSE asume un proceso de larga vida. Vercel, Lambda y Cloud Run cobran por tiempo activo y cortan por maxDuration. Todo lo que en un servidor tradicional es una conexión ociosa y gratis, aquí tiene precio.
Mockear la dependencia donde vive el bug es el punto ciego más caro que hay. No es un argumento contra los mocks. Es un argumento para tener al menos un test que atraviese la cosa real, sobre todo cuando esa cosa real maneja el ciclo de vida de una conexión.
Si el handler puede devolver un stream, drena el cuerpo en el test. El status code describe el principio de la respuesta. En streaming, el bug vive en el final.
Y la que menos esperaba: esto lo encontré buscando otra cosa. La investigación original, por qué los clientes OAuth pierden la autorización en cada deploy, sigue abierta y sin reproducir. Este era un defecto vecino, confirmado en el camino. Los separé en dos issues a propósito, porque la tentación de arreglar lo que sí puedes reproducir y declarar cerrado lo que no es exactamente cómo un bug difícil sobrevive otro mes.
Si estás montando uno desde cero, el recorrido básico está en cómo crear un servidor MCP en TypeScript.
Fuentes
- MCP, Transports (revisión 2025-06-18), secciones "Streamable HTTP", "Listening for Messages from the Server" y "Session Management"
@modelcontextprotocol/sdkv1.29.0,dist/esm/server/webStandardStreamableHttp.js, métodohandleGetRequest- Vercel Functions, configuración de duración máxima
Preguntas frecuentes
¿Para qué sirve el GET en un servidor MCP?
Para abrir un stream SSE de servidor a cliente. Es el canal por el que el servidor empuja notificaciones que el cliente no pidió: tools/list_changed, logs o peticiones de sampling. No es un health check ni una forma alternativa de listar herramientas.
¿Puedo devolver 405 en el GET y seguir cumpliendo la spec?
Sí. La revisión 2025-06-18 dice que el servidor debe responder al GET con Content-Type: text/event-stream o con un 405 Method Not Allowed. Si corres stateless no tienes nada que empujar, así que el 405 es la opción correcta de las dos.
¿Por qué el problema solo aparece en serverless?
Porque el stream sin cerrar es gratis en un proceso de larga vida y caro en una función. Vercel, Lambda y Cloud Run cobran por tiempo activo y cortan por duración máxima, así que una conexión ociosa se convierte en tiempo facturado y en un 504.
¿Cómo detecto esto en un test?
Consumiendo el cuerpo de la respuesta con un deadline, no solo mirando el status. handleRequest devuelve 200 en un milisegundo con headers correctos: el defecto está en que el cuerpo nunca termina.