Preludio

Construir un primer servidor MCP es un proyecto satisfactorio para una tarde de domingo. Se ejecuta en un portátil, se comunica por stdio y da a Claude acceso a un puñado de herramientas internas. Es como conectar una nueva extremidad a la IA. Puedes pedirle a Claude que consulte una base de datos, compruebe el estado de un despliegue o lea de una wiki interna, todo mediante lenguaje natural.

Entonces llega el lunes y un compañero pide usarlo también.

Esa pregunta lo rompe todo. El servidor es un proceso que Claude Code lanza como proceso hijo, leyendo de stdin y escribiendo en stdout. Está vinculado a una máquina, un terminal, una sesión. No hay URL que compartir, ni endpoint al que apuntar, ni forma de que una segunda persona se conecte.

Mover un servidor MCP del portátil de un desarrollador a un entorno de producción lo cambia todo. El transporte cambia. La gestión de errores cambia. Los requisitos de seguridad cambian por completo. Lo que funcionaba como prototipo local necesita autenticación, monitorización, limitación de tasa, empaquetado en contenedores y un pipeline de despliegue antes de poder servir a un equipo.

Esta guía cubre todo lo necesario para esa transición. Si ya has construido tu primer servidor MCP y quieres ir más allá de localhost, este es el camino a seguir.

El Problema

Los servidores MCP en desarrollo son sencillos. La especificación del Model Context Protocol define stdio como el transporte por defecto. Claude Code lanza tu servidor como proceso hijo, envía mensajes JSON-RPC a través de stdin y lee las respuestas de stdout. Sin red, sin puertos, sin más configuración que una ruta de comando.

Esta simplicidad es también un techo. El transporte stdio significa que el servidor vive y muere con el proceso cliente. Se ejecuta en la misma máquina. Sirve exactamente a un cliente. No se puede balancear la carga, comprobar su salud ni monitorizarlo con sistemas externos. Si se cae, el cliente tiene que reiniciarlo. Si tiene fugas de memoria, no hay un vigilante externo que lo detecte.

Las cargas de trabajo en producción necesitan propiedades fundamentalmente diferentes. Múltiples desarrolladores conectándose al mismo servidor. Registro y monitorización centralizados. Autenticación para que solo los usuarios autorizados puedan invocar herramientas. Limitación de tasa para evitar que una sesión de IA descontrolada martillee tu backend. Comprobaciones de salud para que tu orquestador pueda reiniciar instancias fallidas. Escalado horizontal cuando una sola instancia no es suficiente.

La especificación MCP anticipó esto. Define múltiples tipos de transporte, y el diseñado para producción es Streamable HTTP. Pero la especificación te da el protocolo. No te da los patrones de despliegue, las prácticas operativas ni las lecciones difíciles de ejecutar estos sistemas bajo carga real.

Eso es lo que proporciona esta guía.

El Camino

Entendiendo los Tipos de Transporte MCP

El Model Context Protocol define tres mecanismos de transporte, cada uno adaptado a diferentes escenarios de despliegue.

Stdio es el más simple. El cliente lanza el servidor como proceso hijo. Los mensajes fluyen a través de stdin y stdout. Es lo que usas durante el desarrollo y lo que Claude Code utiliza por defecto cuando configuras un servidor MCP con un comando. Es rápido, no requiere configuración de red y funciona en todas partes. Pero es inherentemente local. Un cliente, un servidor, una máquina.

Streamable HTTP es el transporte de producción. El servidor expone un endpoint HTTP (normalmente /mcp), y el cliente envía peticiones JSON-RPC como cuerpos POST. El servidor puede responder con una respuesta JSON simple para patrones de petición-respuesta, o puede elevar la conexión a Server-Sent Events (SSE) para respuestas en streaming y notificaciones iniciadas por el servidor. La gestión de sesiones se realiza mediante la cabecera Mcp-Session-Id. Desde la especificación de junio de 2025, las conexiones basadas en HTTP también deben incluir una cabecera MCP-Protocol-Version que especifique la versión del protocolo negociada, permitiendo una negociación de versión adecuada entre clientes y servidores.

SSE es el transporte heredado de la especificación MCP anterior. Utiliza un endpoint SSE dedicado para mensajes del servidor al cliente y un endpoint POST separado para mensajes del cliente al servidor. Todavía funciona, pero la especificación ahora recomienda Streamable HTTP para todas las implementaciones nuevas. Si estás construyendo algo nuevo, omite SSE por completo.

La transición de stdio a Streamable HTTP no es simplemente un cambio de transporte. Cambia la forma de pensar sobre el ciclo de vida del servidor. Un servidor stdio es efímero. Existe durante la duración de una única sesión de cliente. Un servidor Streamable HTTP es un servicio de larga ejecución que gestiona múltiples sesiones concurrentes, cada una con su propio estado.

Configurando el Transporte Streamable HTTP

El siguiente tutorial cubre la construcción de un servidor MCP listo para producción con transporte Streamable HTTP. Los ejemplos usan TypeScript con el SDK oficial de MCP porque tiene el soporte más maduro para transporte HTTP.

Primero, la estructura básica del servidor.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const app = express();
app.use(express.json());

const server = new McpServer({
  name: "production-tools",
  version: "1.0.0",
});

// Register your tools
server.tool(
  "get_deployment_status",
  "Check the deployment status of a service",
  { service: { type: "string", description: "Service name" } },
  async ({ service }) => {
    const status = await checkDeployment(service);
    return {
      content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
    };
  }
);

// Session management
const sessions = new Map<string, StreamableHTTPServerTransport>();

app.post("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (sessionId && sessions.has(sessionId)) {
    const transport = sessions.get(sessionId)!;
    await transport.handleRequest(req, res);
    return;
  }

  // New session
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
    onsessioninitialized: (id) => {
      sessions.set(id, transport);
    },
  });

  transport.onclose = () => {
    if (transport.sessionId) {
      sessions.delete(transport.sessionId);
    }
  };

  await server.connect(transport);
  await transport.handleRequest(req, res);
});

app.listen(3001, () => {
  console.error("MCP server listening on port 3001");
});

Observa que se usa console.error para el mensaje de inicio, no console.log. Esto importa. Los servidores MCP nunca deben escribir datos que no sean del protocolo en stdout. En modo stdio, stdout es el canal del protocolo. Incluso con transporte HTTP, mantener esta disciplina previene errores sutiles si alguna vez necesitas dar soporte a ambos transportes.

El mapa de gestión de sesiones rastrea las sesiones activas por su Mcp-Session-Id. Cuando un cliente envía su primera petición (el mensaje initialize), el servidor crea un nuevo transporte y asigna un ID de sesión. Las peticiones posteriores del mismo cliente incluyen ese ID de sesión en la cabecera, dirigiéndolas a la instancia de transporte correcta.

Añadiendo Autenticación

Un servidor MCP en producción sin autenticación es una puerta abierta a tus sistemas internos. Cada herramienta que expones se vuelve invocable por cualquiera que conozca el endpoint. Si tu servidor MCP puede consultar una base de datos, un servidor sin autenticación permite que cualquiera consulte esa base de datos.

El enfoque de autenticación más simple es la validación de tokens Bearer. Añade middleware que compruebe cada petición antes de que llegue al manejador MCP.

import { Request, Response, NextFunction } from "express";

const API_KEYS = new Set(
  (process.env.MCP_API_KEYS || "").split(",").filter(Boolean)
);

function authenticate(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization;

  if (!auth || !auth.startsWith("Bearer ")) {
    res.status(401).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: "Authentication required" },
      id: null,
    });
    return;
  }

  const token = auth.slice(7);
  if (!API_KEYS.has(token)) {
    res.status(403).json({
      jsonrpc: "2.0",
      error: { code: -32002, message: "Invalid credentials" },
      id: null,
    });
    return;
  }

  next();
}

app.post("/mcp", authenticate, async (req, res) => {
  // ... MCP handling
});

Esto es lo mínimo. Para un tratamiento más profundo de los patrones de autenticación, incluyendo OAuth 2.1, rotación de tokens y ámbito de herramientas por usuario, consulta la guía complementaria sobre autenticación y seguridad de servidores MCP.

Los tokens Bearer funcionan bien para la comunicación entre servicios donde ambos lados son sistemas que tú controlas. Para despliegues orientados al usuario donde los desarrolladores individuales se autentican con sus propias credenciales, OAuth 2.1 es el mecanismo que recomienda la especificación MCP.

Limitación de Tasa y Prevención de Abuso

Los clientes de IA se comportan de manera diferente a los usuarios humanos. Una única sesión de Claude Code puede generar docenas de llamadas a herramientas en rápida sucesión, particularmente durante flujos de trabajo agénticos donde Claude está iterando sobre un problema. Sin limitación de tasa, la sesión agresiva de un desarrollador puede saturar tus servicios de backend.

Un limitador de tasa con ventana deslizante basado en la clave API del cliente o el ID de sesión funciona bien aquí.

const rateLimits = new Map<string, { count: number; resetAt: number }>();

const RATE_LIMIT = 100;  // requests per window
const WINDOW_MS = 60000; // 1 minute window

function rateLimit(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.slice(7) || "anonymous";
  const now = Date.now();

  let bucket = rateLimits.get(token);
  if (!bucket || now > bucket.resetAt) {
    bucket = { count: 0, resetAt: now + WINDOW_MS };
    rateLimits.set(token, bucket);
  }

  bucket.count++;

  res.setHeader("X-RateLimit-Limit", RATE_LIMIT);
  res.setHeader("X-RateLimit-Remaining", Math.max(0, RATE_LIMIT - bucket.count));
  res.setHeader("X-RateLimit-Reset", Math.ceil(bucket.resetAt / 1000));

  if (bucket.count > RATE_LIMIT) {
    res.status(429).json({
      jsonrpc: "2.0",
      error: {
        code: -32003,
        message: "Rate limit exceeded. Try again later.",
      },
      id: null,
    });
    return;
  }

  next();
}

Cien peticiones por minuto es un punto de partida razonable para la mayoría de herramientas internas. Ajústalo según la capacidad de tu backend y los patrones de llamada esperados de tus herramientas. Algunas herramientas son baratas (leer un valor de configuración) y otras son costosas (ejecutar una migración de base de datos). Quizás quieras límites de tasa por herramienta además del límite global.

Gestión de Errores y Códigos JSON-RPC

MCP usa JSON-RPC 2.0, que define un formato de respuesta de error específico. Hacerlo bien importa porque el cliente (Claude Code) usa estos códigos de error para decidir qué hacer a continuación. Una respuesta de error bien formada permite a Claude reintentar inteligentemente o explicar el fallo al usuario. Un error malformado o una caída de conexión deja a Claude adivinando.

Los códigos de error estándar de JSON-RPC son tu base.

const ErrorCodes = {
  PARSE_ERROR: -32700,      // Invalid JSON
  INVALID_REQUEST: -32600,  // Not a valid JSON-RPC request
  METHOD_NOT_FOUND: -32601, // Tool or method does not exist
  INVALID_PARAMS: -32602,   // Invalid tool arguments
  INTERNAL_ERROR: -32603,   // Server-side failure
};

Más allá de estos, define códigos específicos de aplicación para tus herramientas. El rango de -32000 a -32099 que JSON-RPC reserva para errores definidos por la implementación es ideal para este propósito.

const AppErrorCodes = {
  AUTH_REQUIRED: -32001,
  AUTH_INVALID: -32002,
  RATE_LIMITED: -32003,
  SERVICE_UNAVAILABLE: -32004,
  UPSTREAM_TIMEOUT: -32005,
};

Envuelve las implementaciones de tus herramientas en manejadores de errores que capturen excepciones y devuelvan errores estructurados. Nunca permitas que una excepción no controlada haga caer el servidor o devuelva una traza de pila sin procesar.

server.tool(
  "query_database",
  "Run a read-only SQL query",
  { query: { type: "string" } },
  async ({ query }) => {
    try {
      const result = await db.query(query);
      return {
        content: [{ type: "text", text: JSON.stringify(result.rows) }],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Database query failed: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

La bandera isError: true en la respuesta de la herramienta indica a Claude que la llamada a la herramienta falló. Claude normalmente informará del error al usuario en lugar de intentar interpretar el mensaje de error como salida exitosa. Sin esta bandera, Claude podría tratar un mensaje de error como un resultado de consulta válido.

Monitorización y Observabilidad

Un servidor MCP en producción sin monitorización es un servidor que depurarás a ciegas a las 2 de la madrugada. Considera un escenario en el que una herramienta que consulta una API externa empieza a tener tiempos de espera intermitentes. Sin métricas, no hay forma de saber que está ocurriendo hasta que los usuarios informan de que Claude "va lento".

Empieza con tres capas de observabilidad.

Las comprobaciones de salud indican a tu orquestador si el servidor está vivo y listo para aceptar peticiones.

app.get("/health", (req, res) => {
  const health = {
    status: "ok",
    uptime: process.uptime(),
    activeSessions: sessions.size,
    timestamp: new Date().toISOString(),
  };
  res.json(health);
});

app.get("/ready", async (req, res) => {
  try {
    await db.query("SELECT 1");
    res.json({ status: "ready" });
  } catch {
    res.status(503).json({ status: "not ready", reason: "database unavailable" });
  }
});

Separa la vivacidad (/health) de la disponibilidad (/ready). Tu orquestador usa la vivacidad para decidir si reiniciar el contenedor y la disponibilidad para decidir si enrutar tráfico hacia él. Un servidor puede estar vivo pero no disponible si su conexión a la base de datos está caída.

El registro de peticiones captura cada llamada a herramienta con tiempos, identidad del llamante y resultado.

function requestLogger(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
  const requestId = crypto.randomUUID();

  res.on("finish", () => {
    const duration = Date.now() - start;
    const logEntry = {
      requestId,
      method: req.method,
      path: req.path,
      sessionId: req.headers["mcp-session-id"],
      status: res.statusCode,
      duration,
      timestamp: new Date().toISOString(),
    };
    console.error(JSON.stringify(logEntry));
  });

  next();
}

app.use(requestLogger);

Las métricas alimentan tu stack de monitorización existente. Si usas Prometheus, expón un endpoint /metrics con contadores para llamadas a herramientas, histogramas para tiempos de respuesta y gauges para sesiones activas.

import { Registry, Counter, Histogram, Gauge } from "prom-client";

const registry = new Registry();

const toolCallCounter = new Counter({
  name: "mcp_tool_calls_total",
  help: "Total number of MCP tool calls",
  labelNames: ["tool_name", "status"],
  registers: [registry],
});

const toolCallDuration = new Histogram({
  name: "mcp_tool_call_duration_seconds",
  help: "Duration of MCP tool calls",
  labelNames: ["tool_name"],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 10],
  registers: [registry],
});

const activeSessions = new Gauge({
  name: "mcp_active_sessions",
  help: "Number of active MCP sessions",
  registers: [registry],
});

app.get("/metrics", async (req, res) => {
  res.set("Content-Type", registry.contentType);
  res.end(await registry.metrics());
});

Con estas tres capas, puedes responder a las preguntas que importan. ¿Está sano el servidor? ¿Cuántas peticiones por segundo está gestionando? ¿Qué herramientas son las más lentas? ¿Qué clientes están generando más carga?

Patrones de Escalado

Una única instancia de servidor MCP puede manejar una cantidad sorprendente de carga. Los mensajes JSON-RPC son pequeños, las llamadas a herramientas son típicamente limitadas por I/O (esperando a bases de datos, APIs o sistemas de archivos), y Node.js maneja bien la I/O concurrente. Una sola instancia puede servir cómodamente a 50 o más sesiones concurrentes.

Pero eventualmente necesitas más de una instancia. El desafío clave es el estado de sesión.

Si tu servidor es sin estado (sin caché a nivel de sesión, sin estado en memoria más allá del transporte), el escalado horizontal es directo. Ejecuta múltiples instancias detrás de un balanceador de carga. Cualquier instancia puede manejar cualquier petición.

Si tu servidor mantiene estado de sesión (como hace el mapa de gestión de sesiones del ejemplo anterior), necesitas afinidad de sesión. El balanceador de carga debe enrutar todas las peticiones de la misma sesión a la misma instancia.

# nginx configuration for MCP server with session affinity
upstream mcp_servers {
    hash $http_mcp_session_id consistent;
    server mcp-server-1:3001;
    server mcp-server-2:3001;
    server mcp-server-3:3001;
}

server {
    listen 443 ssl;
    server_name mcp.internal.company.com;

    location /mcp {
        proxy_pass http://mcp_servers;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # SSE support
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 300s;
    }

    location /health {
        proxy_pass http://mcp_servers;
    }

    location /metrics {
        proxy_pass http://mcp_servers;
        # Restrict metrics to internal network
        allow 10.0.0.0/8;
        deny all;
    }
}

La directiva hash $http_mcp_session_id consistent enruta las peticiones con la misma cabecera Mcp-Session-Id al mismo backend. El modificador consistent asegura que cuando se añade o elimina un servidor, solo una fracción de las sesiones se reasignan en lugar de todas.

Para despliegues en producción donde las sesiones deben sobrevivir a reinicios del servidor, saca el estado de sesión del proceso. Redis es la elección natural. Almacena los datos de sesión en Redis indexados por ID de sesión, y cualquier instancia del servidor puede retomar cualquier sesión.

Despliegue en Contenedores

Docker es el empaquetado estándar para servidores MCP en producción. Aquí tienes un Dockerfile de producción.

FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
RUN addgroup --system mcp && adduser --system --ingroup mcp mcp
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER mcp
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD node -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1))"
CMD ["node", "dist/server.js"]

Detalles clave. La construcción multietapa mantiene la imagen final pequeña. El usuario sin privilegios de root (mcp) sigue el principio de mínimo privilegio. La directiva HEALTHCHECK permite que Docker y los orquestadores detecten fallos automáticamente.

Para Kubernetes, un despliegue mínimo tiene este aspecto.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mcp-server
  template:
    metadata:
      labels:
        app: mcp-server
    spec:
      containers:
        - name: mcp-server
          image: registry.internal/mcp-server:1.0.0
          ports:
            - containerPort: 3001
          env:
            - name: MCP_API_KEYS
              valueFrom:
                secretKeyRef:
                  name: mcp-secrets
                  key: api-keys
          livenessProbe:
            httpGet:
              path: /health
              port: 3001
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: 3001
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "512Mi"
              cpu: "500m"

Los secretos como las claves API provienen de secrets de Kubernetes, no de variables de entorno integradas en la imagen. Los límites de recursos evitan que un solo pod consuma memoria o CPU sin límites. Las sondas de vivacidad y disponibilidad proporcionan a Kubernetes la información que necesita para gestionar el ciclo de vida del servidor.

Gestión de Configuración

Los servidores en producción necesitan configuraciones diferentes para diferentes entornos. Usa variables de entorno para valores que cambian entre despliegues y archivos de configuración para valores que cambian entre versiones.

const config = {
  port: parseInt(process.env.PORT || "3001"),
  logLevel: process.env.LOG_LEVEL || "info",
  rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || "100"),
  rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || "60000"),
  dbConnectionString: process.env.DATABASE_URL,
  corsOrigins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean),
  tlsEnabled: process.env.TLS_ENABLED === "true",
};

// Validate required config at startup
const required = ["DATABASE_URL", "MCP_API_KEYS"];
for (const key of required) {
  if (!process.env[key]) {
    console.error(`Missing required environment variable: ${key}`);
    process.exit(1);
  }
}

Falla rápido ante configuración ausente. Un servidor que arranca sin su cadena de conexión a la base de datos fallará en la primera llamada a una herramienta, produciendo un error confuso. Es mejor fallar inmediatamente al inicio con un mensaje claro sobre lo que falta.

Nunca registres secretos. Si registras tu configuración al inicio para depuración (lo cual es recomendable), redacta los valores sensibles.

console.error("Configuration loaded:", {
  ...config,
  dbConnectionString: config.dbConnectionString ? "[REDACTED]" : "not set",
});

La Arquitectura de Producción

Tras una amplia iteración, la arquitectura que mejor funciona para despliegues MCP en producción tiene este aspecto.

Cliente (Claude Code)
    |
    | HTTPS + Token Bearer
    |
Proxy Inverso (Caddy o nginx)
    |
    | HTTP (red interna)
    |
Servidor MCP (Node.js / Rust / Python)
    |
    |--- API Backend (REST/gRPC)
    |--- Base de datos (PostgreSQL/Redis)
    |--- Servicios Externos (APIs, colas)

El proxy inverso gestiona la terminación TLS, los límites de conexión y el almacenamiento en buffer de peticiones. También proporciona un punto natural para añadir listas blancas de IP o TLS mutuo para servicios internos.

El servidor MCP en sí es lo más delgado posible. Valida entradas, llama a servicios backend y formatea resultados. La lógica de negocio reside en los servicios backend, no en el servidor MCP. Esta separación significa que puedes actualizar tus APIs backend sin redesplegar el servidor MCP, y puedes exponer el mismo backend tanto a través de MCP como de APIs REST tradicionales.

Para el soporte de SSE (que Streamable HTTP usa para respuestas en streaming), el proxy inverso debe configurarse para deshabilitar el almacenamiento en buffer de respuestas. Sin esto, los eventos SSE se almacenan en buffer y se entregan por lotes, lo que anula el propósito del streaming.

Con Caddy, la configuración es más simple.

mcp.internal.company.com {
    reverse_proxy mcp-server:3001 {
        flush_interval -1
    }
}

La directiva flush_interval -1 deshabilita el almacenamiento en buffer de respuestas, permitiendo que los eventos SSE fluyan inmediatamente.

Apagado Gracioso

Los servidores en producción deben gestionar las señales de apagado de forma limpia. Cuando Kubernetes envía un SIGTERM, o cuando despliegas una nueva versión, las sesiones activas deben completarse en lugar de ser eliminadas a mitad de petición.

let isShuttingDown = false;

process.on("SIGTERM", async () => {
  console.error("Received SIGTERM, starting graceful shutdown");
  isShuttingDown = true;

  // Stop accepting new sessions
  app.use((req, res, next) => {
    if (req.path === "/mcp" && !req.headers["mcp-session-id"]) {
      res.status(503).json({
        jsonrpc: "2.0",
        error: { code: -32004, message: "Server is shutting down" },
        id: null,
      });
      return;
    }
    next();
  });

  // Wait for active sessions to complete (max 30 seconds)
  const deadline = Date.now() + 30000;
  while (sessions.size > 0 && Date.now() < deadline) {
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }

  // Close remaining sessions
  for (const [id, transport] of sessions) {
    await transport.close();
    sessions.delete(id);
  }

  process.exit(0);
});

El patrón es dejar de aceptar nuevas sesiones, esperar a que las sesiones existentes terminen (con un plazo límite) y luego cerrar forzosamente todo lo que siga abierto. El plazo de 30 segundos coincide con el terminationGracePeriodSeconds por defecto de Kubernetes.

La Lección

Mover un servidor MCP a producción no es principalmente un desafío de código. El protocolo es el mismo. Las herramientas son las mismas. Los mensajes son los mismos payloads JSON-RPC.

El verdadero desafío es operativo. Es decidir cómo autenticar clientes y qué ocurre cuando la autenticación falla. Es saber que tu servidor maneja 50 sesiones concurrentes pero entender qué pasa con 500. Es tener métricas que te digan que la latencia del percentil 99 de una herramienta saltó de 200ms a 2 segundos antes de que tus usuarios lo noten.

Todo sistema en producción, sea MCP o no, sigue el mismo patrón. El protocolo es la parte fácil. El despliegue, la monitorización, la seguridad y las prácticas operativas alrededor del protocolo son lo que lo hace listo para producción.

Si estás construyendo servidores MCP que van a servir a un equipo, empieza con los patrones de esta guía. Transporte Streamable HTTP para acceso remoto. Middleware de autenticación desde el primer día. Comprobaciones de salud y métricas antes de desplegar. Limitación de tasa antes de necesitarla.

Y lee la guía complementaria sobre autenticación y seguridad de servidores MCP antes de exponer cualquier herramienta que toque datos sensibles. La autenticación no es opcional. Es lo primero que hay que hacer bien.

Conclusión

Este viaje empieza con un servidor que se ejecuta en un portátil y sirve a una persona. Termina con un servicio contenerizado detrás de un proxy inverso, autenticado con tokens Bearer, monitorizado con Prometheus y escalado a tres réplicas en Kubernetes.

El camino de localhost a producción está bien transitado. El transporte HTTP te da la capa de red. La autenticación te da el control de acceso. La limitación de tasa te da márgenes de seguridad. Las comprobaciones de salud te dan recuperación automatizada. Las métricas te dan visibilidad. El empaquetado en contenedores te da despliegues reproducibles.

Ninguno de estos conceptos es nuevo para quien haya desplegado servicios web. La clave es que los servidores MCP son servicios web. Hablan JSON-RPC en lugar de REST, y sirven a clientes de IA en lugar de navegadores, pero los requisitos operativos son idénticos.

Construye tu servidor MCP con el mismo rigor que aplicarías a cualquier API en producción. Luego ve más allá. Lee sobre construir servidores MCP en Rust para conocer las características de rendimiento de un lenguaje de sistemas. Lee sobre patrones de autenticación y seguridad si tus herramientas acceden a datos sensibles.

El protocolo es potente. Las herramientas son capaces. La pieza que falta para la mayoría de los equipos es la madurez operativa para ejecutarlos de forma fiable. Esta guía pretende cubrir esa carencia.