4 de octubre de 2026 · 4 min de lectura

Server por request en MCP: por qué compartir instancias rompe bajo concurrencia

mcp producción breaking-changes agentes
Nicolás Neira
Nicolás Neira
Arquitectura de agentes e IA aplicada

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:

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:

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

  1. Construye el server MCP y el transport dentro del handler, o pasa a createMcpHandler una función que devuelva una instancia nueva. Nunca () => server con un server creado fuera.
  2. Reserva el transport con sessionIdGenerator para el modelo de una conexión por sesión. Sin sesiones, un transport por request.
  3. No cierres el transport del handler como forma de “arreglar” la reutilización. Solo oculta el fallo hasta que dos requests se solapen.
  4. Prueba con requests concurrentes antes de publicar. Una prueba secuencial no ejercita ninguno de estos fallos.
  5. Si usas un host node:http sin manejo de errores, añade el tuyo. Un rechazo de connect() o handleRequest() termina el proceso.
  6. Activa onerror en createMcpHandler. Es el único lugar donde se reporta el motivo de un 500 por solape.
  7. 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.
  8. Si implementas un AbstractConcurrencyLimiter, asegúrate de que release() funcione desde cualquier tarea.
  9. Trata como señal de diseño cualquier recurso con ciclo de vida de request que viva en una variable de módulo.

Fuentes:

Seguir leyendo

Nicolás Neira

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.