Server por request en MCP: por qué compartir instancias rompe bajo concurrencia
Tu servidor MCP pasa las pruebas con un cliente. En producción atiende dos requests a la vez y la segunda falla. El código no cambió. Lo que cambió es que la librería dejó de tolerar un objeto compartido que nunca debió compartirse.
Esta semana dos proyectos distintos cerraron esa puerta por caminos diferentes. Los dos casos tienen la misma raíz: un recurso con vida de una request o de un stream, usado como si fuera global.
Caso 1: un server, un transport
En @modelcontextprotocol/server 2.2.0, createMcpHandler dejó de desbordar la pila cuando la factory devolvía la misma instancia de server para más de una request. La nota aclara que sigue siendo obligatorio devolver una instancia nueva por request. El arreglo evitó el crash, pero no autorizó el patrón.
En 2.3.0 el SDK lo hace cumplir. Un Server o McpServer atiende una conexión a la vez, y un transport Streamable HTTP sin sesiones (sessionIdGenerator: undefined) atiende una sola request. Las notas describen cómo se ve la falla:
- Un server compartido con un transport nuevo por request:
connect()rechaza con unSdkErrorde códigoALREADY_CONNECTED. Falla la segunda request del proceso y todas las siguientes. - Un transport stateless compartido:
WebStandardStreamableHTTPServerTransport.handleRequest()rechaza conStateless transport cannot be reused across requests. Create a new transport per request..NodeStreamableHTTPServerTransport.handleRequest()responde500. createMcpHandler(() => server)con un server construido una vez: una request que llega mientras otra se atiende recibe500con el error JSON-RPC-32603. El motivo solo se reporta por la opciónonerror.
Hay un detalle operativo. Según las notas, lo que ve el caller depende del host. Express 5, Fastify y Hono responden 500. Un listener node:http sin manejo de errores propio recibe un unhandled rejection, que termina el proceso.
Si el handler cierra el transport al terminar la respuesta, las requests que llegan una tras otra siguen funcionando. Solo falla la que se solapa con otra. Es el peor patrón para depurar: pasa en desarrollo y falla bajo carga.
El costo de corregirlo es bajo. Las notas indican que crear un server es barato desde #2889: registerTool ya no convierte los schemas por adelantado, así que un server construido por request no los reconvierte todos cada vez.
Caso 2: un limiter y un slot
El segundo caso viene de Pydantic AI 2.53.0 y es una vulnerabilidad catalogada como alta (GHSA-6fqq-452j-qhrp). Una request en streaming a través de ConcurrencyLimitedModel o limit_model_concurrency podía conservar su slot de concurrencia si este se liberaba en una tarea distinta de la que lo adquirió. Pasaba tras una salida anticipada (el consumidor dejó de iterar, lanzó una excepción o se canceló). Pasaba también tras consumir por completo stream_text() con el debouncing por defecto.
El efecto era acumulativo: repetir streams podía bloquear todas las requests que compartían el limiter. No afecta al max_concurrency a nivel de agente ni a las requests sin streaming, y v1 no está afectada.
El arreglo cambia las reglas de uso:
- Un wrapper de modelo lanza
UserErrorsi comparte limiter con el agente que hace la request o con otro wrapper que lo envuelve. ConcurrencyLimiter.acquire()toma un slot en cada llamada, incluso desde la misma tarea.- Un
AbstractConcurrencyLimiterpropio debe permitirrelease()desde otra tarea.
El hilo común
Los dos casos tienen la misma forma. Un recurso se adquiere en un punto y se libera en otro, y entre ambos hay concurrencia: otra request, otra tarea, un stream que se corta. Mientras todo ocurre en orden, el recurso compartido parece correcto. Cuando el orden se rompe, falla de forma silenciosa o lejos de la causa.
La respuesta de los SDKs también coincide. Antes se toleraba el patrón y el fallo aparecía tarde. Ahora hay un error explícito al conectar, al construir el wrapper o al registrar el limiter. Es un breaking change que te ahorra un incidente.
OpenAI Agents SDK 0.23.0 apunta en la misma dirección. Entre sus correcciones figura aislar el estado de agentes anidados usados como tools entre ejecuciones nuevas, y acotar el backlog de callbacks de streaming en esas tools. No es el mismo bug, pero sí la misma disciplina: el estado pertenece a una ejecución.
Reglas accionables
- Construye el server MCP y el transport dentro del handler, o pasa a
createMcpHandleruna función que devuelva una instancia nueva. Nunca() => servercon un server creado fuera. - Reserva el transport con
sessionIdGeneratorpara el modelo de una conexión por sesión. Sin sesiones, un transport por request. - No cierres el transport del handler como forma de “arreglar” la reutilización. Solo oculta el fallo hasta que dos requests se solapen.
- Prueba con requests concurrentes antes de publicar. Una prueba secuencial no ejercita ninguno de estos fallos.
- Si usas un host
node:httpsin manejo de errores, añade el tuyo. Un rechazo deconnect()ohandleRequest()termina el proceso. - Activa
onerrorencreateMcpHandler. Es el único lugar donde se reporta el motivo de un500por solape. - En Pydantic AI, actualiza a 2.53.0 si limitas concurrencia con streaming. Revisa que ningún wrapper comparta limiter con el agente ni con otro wrapper.
- Si implementas un
AbstractConcurrencyLimiter, asegúrate de querelease()funcione desde cualquier tarea. - Trata como señal de diseño cualquier recurso con ciclo de vida de request que viva en una variable de módulo.
Fuentes:
- https://github.com/modelcontextprotocol/typescript-sdk/releases/tag/v2.2.0
- https://github.com/modelcontextprotocol/typescript-sdk/releases/tag/v2.3.0
- https://github.com/modelcontextprotocol/typescript-sdk/releases/tag/%40modelcontextprotocol/node%402.1.1
- https://github.com/pydantic/pydantic-ai/releases/tag/v2.53.0
- https://github.com/openai/openai-agents-python/releases/tag/v0.23.0
Seguir leyendo
Esto se demuestra en video, y se sigue a diario en la comunidad
Las demos completas van al canal. El seguimiento release por release, adentro.