Desarrollo IATutorial

Servidor MCP Remoto en 2026: Stateless, Autenticado y Desplegable

14 de octubre de 2026
15 min de lectura
Tutorial de servidor MCP remoto: Streamable HTTP stateless con autenticación OAuth
Compartir:

Durante un año el dolor de los servidores MCP remotos fue siempre el mismo: el cliente abre una sesión, el balanceador manda la siguiente petición a otro nodo y el servidor responde ’sesión no encontrada’. La revisión 2026-07-28 de la spec, publicada el 28 de julio de 2026, elimina la causa: no hay sesión a nivel de protocolo, no hay handshake initialize y cada petición lleva lo que el servidor necesita.

En este tutorial te muestro cómo construir un servidor MCP remoto sobre esa spec con el SDK oficial de TypeScript v2: una herramienta mínima, Streamable HTTP stateless, autenticación OAuth con bearer token, un despliegue estilo serverless, pruebas con el MCP Inspector y un checklist de migración para servidores con estado o SSE. El código está copiado o adaptado de la documentación primaria citada al final y contrastado con ella el 2026-10-14; no lo desplegué ni lo medí para este artículo.

1. El Problema: Las Sesiones Atan al Cliente a un Nodo

Entre 2025-03-26 y 2025-11-25, un servidor Streamable HTTP podía generar un Mcp-Session-Id, guardar estado por conexión en memoria, enviar sus propias peticiones al cliente por streams SSE y dejar que los clientes reanudaran streams rotos con Last-Event-ID. Eso funciona en una sola máquina. Detrás de un balanceador o en funciones serverless exige routing sticky, almacenes compartidos y reproducción de eventos — la propia documentación del SDK de TypeScript describe el despliegue con sesiones como un mapa de transportes enrutado por la cabecera de sesión, más timers de inactividad, un tope de sesiones y un 404 para ids desconocidos.

El Error Más Común

Migrar el transporte pero mantener estado oculto en la memoria del proceso. El SDK construye una instancia nueva del servidor en cada petición, así que un Map a nivel de módulo con ’el carrito actual del usuario’ funciona en tu laptop y pierde datos en producción en cuanto dos instancias atienden al mismo cliente. Como dice el blog oficial, quitar la sesión a nivel de protocolo no obliga a que tu aplicación sea stateless: solo hace que el estado sea problema explícito tuyo.

La nueva revisión invierte el valor por defecto. El protocolo es petición/respuesta y stateless, y un servidor que de verdad necesita estado entre llamadas devuelve un handle explícito, generado por el servidor, que el modelo reenvía como un argumento normal de la herramienta (SEP-2567). El resultado es un servidor que cualquier instancia puede atender, justo lo que quieren el escalado horizontal y las plataformas serverless. El precio es una migración: el blog admite abiertamente que habrá algo de costo de migración, sobre todo para quienes dependían de identificadores de sesión.

2. Conceptos Mínimos Antes de Tocar Código

Seis ideas explican casi todo lo que cambió del lado del servidor. Con esto claro, el código de abajo se lee como lenguaje natural.

🧾

_meta por petición

reemplaza a initialize

Cada petición lleva io.modelcontextprotocol/protocolVersion y clientCapabilities en _meta. Los clientes DEBERÍAN añadir clientInfo y los servidores DEBERÍAN responder con serverInfo en el _meta del resultado. Una versión no soportada devuelve UnsupportedProtocolVersionError.

🔎

server/discover

el servidor DEBE implementarlo

Un RPC que devuelve supportedVersions, capabilities, identidad e instrucciones opcionales. Los clientes PUEDEN llamarlo primero; también pueden invocar cualquier método directamente y manejar el error de versión.

🧭

Cabeceras de routing

validadas contra el cuerpo

Los POST llevan MCP-Protocol-Version, Mcp-Method y, en tools/call, resources/read y prompts/get, Mcp-Name. Si una cabecera no coincide con el cuerpo, el servidor DEBE responder 400 con HeaderMismatch (-32020).

🧷

Handles de estado explícitos

sin session id

Si un flujo abarca varias llamadas, una herramienta genera un identificador (id de carrito, id de job) y el modelo lo devuelve como argumento normal. Los datos detrás viven en una base de datos o caché, nunca en la memoria de la instancia.

🔁

MRTR y subscriptions

sin peticiones servidor-a-cliente

En vez de enviar peticiones de sampling, elicitation o roots por un stream abierto, el servidor devuelve un InputRequiredResult y el cliente reintenta con inputResponses. Las notificaciones de cambios pasan a un stream subscriptions/listen.

🔐

Auth como resource server

OAuth 2.1 + RFC 9728

Tu servidor MCP solo valida tokens: publica Protected Resource Metadata, responde con WWW-Authenticate y acepta solo tokens emitidos para su propia audiencia (RFC 8707). Nunca emite tokens.

El modelo mental a conservar

Un servidor MCP 2026-07-28 es un endpoint HTTP que atiende POST autocontenidos. Todo lo que necesita saber llega en la petición; todo lo que necesita recordar va a un almacenamiento que controlas tú. Si puedes ejecutarlo como una función stateless, puedes escalarlo como una.

3. Paso 1 — Un Servidor Mínimo con el SDK de TypeScript v2

Uso el SDK oficial de TypeScript v2: el paquete @modelcontextprotocol/server, cuyo tag latest en npm era 2.3.1 (publicado el 2026-10-05) al momento de escribir, con Node.js 20+ y Zod 4 como librería de schemas. El paquete anterior @modelcontextprotocol/sdk es la línea v1 (latest 1.32.1 en la misma fecha). La función clave es createMcpHandler: le pasas una factory que devuelve un McpServer nuevo y recibes un objeto cuyo fetch es un (Request) => Promise<Response> estándar web.

TypeScript — una herramienta, un handler (docs del SDK: Serve over HTTP)

// npm install @modelcontextprotocol/server zod
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const handler = createMcpHandler(() => {
    const server = new McpServer({ name: 'notes', version: '1.0.0' });
    server.registerTool(
        'add-note',
        {
            description: 'Save a note',
            inputSchema: z.object({ text: z.string() })
        },
        async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })
    );
    return server;
});

Ese handler todavía no escucha en ningún puerto. En Node puro lo envuelves con toNodeHandler de @modelcontextprotocol/node, haces bind explícito a loopback y pones las guardas de Host y Origin por delante: el handler no valida ninguna, y la spec dice que los servidores DEBEN validar la cabecera Origin y responder 403 si es inválida, para frenar el DNS rebinding.

TypeScript — montar en node:http (docs del SDK: Serve over HTTP)

import { createServer } from 'node:http';
import {
    toNodeHandler,
    localhostHostValidation,
    localhostOriginValidation
} from '@modelcontextprotocol/node';

const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer((req, res) => {
    if (!validateHost(req, res) || !validateOrigin(req, res)) return;
    void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');

Tip: mantén la factory barata

La factory se ejecuta una vez por petición HTTP, así que crea pools de conexiones y cachés a nivel de módulo y captúralos por cierre. Nunca registres herramientas en una instancia compartida fuera de la factory. La factory también recibe era, authInfo y requestInfo, así que puedes construir la instancia alrededor de quien llama. Verificado contra la documentación del SDK el 2026-10-14.

4. Paso 2 — Autenticación: Sé un Resource Server, No un Auth Server

La autorización es opcional en la spec, pero cualquier servidor remoto que toque datos de usuarios o de negocio la necesita. El modelo es simple: tu servidor MCP es un resource server de OAuth 2.1 y nunca emite tokens. Responde las peticiones sin autenticar con 401 y una cabecera WWW-Authenticate cuyo parámetro resource_metadata apunta a tu Protected Resource Metadata (RFC 9728); ese documento lista al menos una entrada en authorization_servers; y el servidor acepta solo tokens emitidos específicamente para él (RFC 8707). Los tokens viajan en la cabecera Authorization: Bearer en cada petición, nunca en el query string.

TypeScript — gate bearer en un host web-standard (docs del SDK: Require authorization)

import { createMcpHandler, requireBearerAuth } from '@modelcontextprotocol/server';

const mcpServerUrl = new URL('https://api.example.com/mcp');

// verifier = { verifyAccessToken }, definido en el siguiente snippet
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl });
const handler = createMcpHandler(buildServer); // buildServer = la factory del Paso 1

export default {
    async fetch(request: Request): Promise<Response> {
        const auth = await gate(request);
        if (auth instanceof Response) return auth;
        return handler.fetch(request, { authInfo: auth });
    }
};

La única función que escribes tú es verifyAccessToken: recibe el token en crudo y devuelve un AuthInfo. Verificación local de JWT, introspección RFC 7662 o una llamada a tu proveedor de identidad caben detrás. Reporta en AuthInfo.resource la audiencia que corresponde a tu servidor para que expectedResource pueda compararla, y rellena siempre expiresAt.

TypeScript — verificador de tokens (docs del SDK; verifyJwt es tu librería de JWT)

async function verifyAccessToken(token: string): Promise<AuthInfo> {
    const payload = await verifyJwt(token);
    // `aud` is one value, a list, or absent: report the entry on this server's origin, if any,
    // and let `expectedResource` compare it.
    const audience = [payload.aud ?? []].flat().find(aud => URL.canParse(aud) && new URL(aud).origin === mcpServerUrl.origin);
    const resource = audience ? new URL(audience) : undefined;
    return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp, resource };
}

Los clientes encuentran tu servidor de autorización a través de la metadata. La spec te deja anunciarla en el challenge WWW-Authenticate, en una URI well-known que incluye la ruta del endpoint (por ejemplo /.well-known/oauth-protected-resource/mcp) o en la raíz. En un host fetch-native, oauthMetadataResponse del SDK sirve esos documentos y devuelve undefined para todo lo demás, así que sigues con tu propio routing.

TypeScript — servir la metadata (docs del SDK) y el challenge 401 (ejemplo de la spec)

import { oauthMetadataResponse } from '@modelcontextprotocol/server';

async function webStandardFetch(request: Request): Promise<Response> {
    // serveMcp = the gated fetch from the first snippet
    return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
}

// What an unauthenticated client receives (spec example):
// HTTP/1.1 401 Unauthorized
// WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
//                          scope="files:read"

Registro: CIMD, no DCR

El registro de clientes es trabajo del servidor de autorización, pero condiciona lo que ven tus usuarios. La spec 2026-07-28 dice que clientes y servidores de autorización DEBERÍAN soportar Client ID Metadata Documents (una URL HTTPS usada como client_id). Dynamic Client Registration (RFC 7591) queda formalmente deprecado y se mantiene solo por compatibilidad hacia atrás. Elige un proveedor de identidad que anuncie client_id_metadata_document_supported, o planifica clientes pre-registrados.

Tres trampas de auth directo de la documentación

Primera: el gate responde 401 invalid_token a cualquier token cuyo expiresAt no esté definido. Segunda: si no defines expectedResource, AuthInfo.resource no se compara con nada y la validación de audiencia no hace nada en silencio; la documentación además avisa de que @modelcontextprotocol/express 2.0.1 no pasaba expectedResource (el latest en npm era 2.0.2 el 2026-10-14, así que revisa tu versión). Tercera: nunca reenvíes el token del cliente a una API upstream: la spec dice que el servidor MCP NO DEBE pasar el token que recibió.

5. Paso 3 — Despliega Donde lo Stateless Brilla (Vercel y Similares)

Como cada petición es autocontenida, el handler es una función de Request a Response. Eso encaja en cualquier runtime fetch-native: la documentación del SDK lo monta con export default handler en Cloudflare Workers, Deno y Bun, y los frameworks de Node usan toNodeHandler. En Vercel, la documentación de la plataforma (actualizada el 2026-09-18) despliega servidores MCP como Vercel Functions dentro de una app Next.js App Router con el paquete mcp-handler, fijando mcp-handler@2.1.1 junto a @modelcontextprotocol/server@2 y Zod 4; el tag latest de mcp-handler en npm era 2.3.0 el 2026-10-14. La versión 2 eliminó HTTP+SSE y la configuración de Redis.

TypeScript — app/api/mcp/route.ts con mcp-handler (forma tomada de la documentación de Vercel)

import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';

const handler = createMcpHandler((server) => {
  server.registerTool(
    'add-note',
    {
      description: 'Save a note',
      inputSchema: z.object({ text: z.string() }),
    },
    async ({ text }) => ({
      content: [{ type: 'text', text: `Saved: ${text}` }],
    }),
  );
});

export { handler as GET, handler as POST };

La auth usa helpers del mismo paquete. withMcpAuth envuelve la ruta con un verificador de tokens, un flag required y los scopes requeridos, y protectedResourceHandler sirve el documento de metadata desde app/.well-known/oauth-protected-resource/route.ts. La documentación de Vercel es explícita: el paquete verifica bearer tokens y publica metadata pero no emite tokens, y la verificación en producción debe comprobar emisor, audiencia, expiración y scopes.

TypeScript — wrapper de auth y ruta de metadata (forma tomada de la documentación de Vercel)

// app/api/mcp/route.ts
import type { AuthInfo } from '@modelcontextprotocol/server';
import { createMcpHandler, withMcpAuth } from 'mcp-handler';

const handler = createMcpHandler(/* ...same factory as above... */);

const verifyToken = async (
  _req: Request,
  bearerToken?: string,
): Promise<AuthInfo | undefined> => {
  // Verify issuer, audience, expiry and scopes with your IdP here.
  // On success return { token: bearerToken, scopes: [...], clientId: '...' }.
  return undefined; // reject everything until real verification exists
};

const authHandler = withMcpAuth(handler, verifyToken, {
  required: true,
  requiredScopes: ['notes:write'],
  resourceMetadataPath: '/.well-known/oauth-protected-resource',
});

export { authHandler as GET, authHandler as POST };

// app/.well-known/oauth-protected-resource/route.ts
import {
  protectedResourceHandler,
  metadataCorsOptionsRequestHandler,
} from 'mcp-handler';

const metadata = protectedResourceHandler({
  authServerUrls: ['https://your-authorization-server-issuer.example'],
  resourceUrl: 'https://your-app.vercel.app/api/mcp',
});

const corsHandler = metadataCorsOptionsRequestHandler();

export { metadata as GET, corsHandler as OPTIONS };

Stateless no significa ’sin infraestructura’

Dos cosas siguen necesitando almacenamiento compartido. Los handles de estado deben apuntar a datos que toda instancia pueda alcanzar, como una base de datos o una caché. Y los streams subscriptions/listen son de larga duración: el bus de eventos por defecto del SDK vive en el proceso, así que un notify en un nodo nunca llega a un suscriptor que sostiene otro nodo. Para despliegues multinodo la documentación del SDK indica implementar un ServerEventBus sobre tu pub/sub y pasárselo al createMcpHandler de cada nodo, y conviene revisar los límites de duración de funciones de tu plataforma antes de prometer notificaciones en vivo.

6. Paso 4 — Prueba con el Inspector CLI y curl

El MCP Inspector usa por defecto la era legacy (initialize normal, sin probing), así que para esta spec hay que activarlo: --protocol-era modern fija 2026-07-28 sin fallback, y auto prueba server/discover y cae a initialize si falla. El CLI ejecuta un método por invocación y devuelve exit codes estables, lo que lo hace apto para CI: 3 significa que el servidor exige autenticación, 4 que no es alcanzable y 5 que una herramienta devolvió isError.

Bash — Inspector CLI contra un servidor remoto (flags de la documentación del Inspector)

# Pin the 2026-07-28 era and list tools
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \
  --transport http --protocol-era modern --method tools/list

# Same, with a bearer token
npx @modelcontextprotocol/inspector --cli https://mcp.example.com/mcp \
  --transport http --protocol-era modern \
  --header "Authorization: Bearer $MCP_TOKEN" --method tools/list

# Call a tool
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \
  --transport http --protocol-era modern \
  --method tools/call --tool-name add-note --tool-arg text="ship the release notes"

Cuando quieres ver los bytes, curl alcanza porque no hay handshake que hacer. Esta petición está armada con los propios ejemplos de la spec: las tres cabeceras requeridas más el envelope _meta. Añade una cabecera Authorization: Bearer cuando actives el gate.

curl — un tools/call en crudo (armado con los ejemplos de Streamable HTTP de la spec)

curl -i -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: add-note' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add-note","arguments":{"text":"hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0.0.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}'

Tres chequeos que vale la pena automatizar

Uno: sin token el endpoint responde 401 con un parámetro resource_metadata. Dos: una petición cuyo Mcp-Name no coincide con el cuerpo responde 400 con HeaderMismatch (-32020). Tres: un initialize de la era 2025 sigue funcionando salvo que configures legacy: ’reject’. El flag --stored-auth-only del Inspector mantiene estos chequeos no interactivos en CI.

7. Checklist de Migración: de Servidores con Estado o SSE a 2026-07-28

Si hoy tienes un servidor con estado o de la era SSE, este es el orden que seguiría. El SDK incluye un codemod y la spec define una matriz de compatibilidad, así que no necesitas un flag day.

🔍 Auditar (una tarde)

  1. 1. Busca Mcp-Session-Id, initialize, handlers GET en el endpoint MCP y Last-Event-ID: cada coincidencia es comportamiento que la nueva revisión eliminó
  2. 2. Lista cada uso de sampling, elicitation y roots; reemplaza las llamadas push por resultados input_required, o por llamadas directas al proveedor, parámetros de herramienta o configuración del servidor, como sugieren las notas de deprecación de la spec
  3. 3. Inventaría el estado por conexión (carritos, cursores, contexto de auth) y decide qué handle, fila o clave de caché reemplaza a cada uno

🔧 Cambiar (el código)

  1. Paquetes: pasa de @modelcontextprotocol/sdk (v1.x) a @modelcontextprotocol/server v2; la guía del SDK ofrece npx @modelcontextprotocol/codemod@latest v1-to-v2 . y deja marcadores @mcp-codemod-error donde no puede reescribir
  2. Transporte: reemplaza StreamableHTTPServerTransport + connect() por createMcpHandler(factory); SSEServerTransport ya no existe (hay una copia congelada y deprecada en @modelcontextprotocol/server-legacy/sse)
  3. Handlers: registra con registerTool y un Standard Schema como z.object(...); lee al llamante desde ctx.http?.authInfo
  4. Estado y tasks: los flujos de varios pasos usan handles explícitos o requestState (un string opaco que debes tratar como no confiable); las tasks experimentales pasan a la extensión io.modelcontextprotocol/tasks con polling vía tasks/get y sin tasks/list
  5. Caché: los resultados de listas ahora llevan ttlMs y cacheScope; el SDK usa por defecto ttlMs 0 y cacheScope private, así que define cacheHints si quieres que los clientes cacheen

🚦 Desplegar (la ventana de compatibilidad)

  1. Doble era: createMcpHandler atiende por defecto a clientes de la era 2025 de forma stateless; configura legacy: ’reject’ para un endpoint solo moderno, y mantén un despliegue 2025 con sesiones detrás de isLegacyRequest mientras los clientes se actualizan
  2. Clientes viejos: según la matriz de la spec, los clientes legacy no pueden usar un servidor solo moderno; responde 405 a GET y DELETE e ignora Mcp-Session-Id y Last-Event-ID
  3. Deprecaciones: Roots, Sampling, Logging, HTTP+SSE y Dynamic Client Registration siguen funcionando al menos doce meses según la política de deprecación de la spec; programa su retiro en vez de esperar a que te obliguen
  4. Auth: publica Protected Resource Metadata, valida la audiencia y planifica Client ID Metadata Documents

8. Errores Comunes (Casi Todos Arreglables en una Tarde)

Estos son los fallos sobre los que más advierten la spec y la documentación del SDK. La columna izquierda es lo que hacen los despliegues que funcionan; la derecha es lo que te despierta de noche.

✅ Haz esto

  • • Construye un McpServer nuevo dentro de la factory en cada petición; deja pools y cachés a nivel de módulo
  • • Pon la validación de Host y Origin y la verificación de tokens por delante del handler, que no hace ninguna
  • • Define expectedResource, reporta la audiencia correspondiente en AuthInfo.resource y rellena siempre expiresAt
  • • Responde 401 y 403 con WWW-Authenticate incluyendo resource_metadata y scope
  • • Devuelve un handle para el estado entre llamadas y guarda sus datos en almacenamiento compartido
  • • Envía MCP-Protocol-Version, Mcp-Method, Mcp-Name y Content-Type: application/json desde clientes hechos a mano

❌ No esto

  • • Un Map a nivel de módulo con estado por usuario dentro de un handler stateless
  • • Registrar herramientas en una instancia compartida de McpServer fuera de la factory
  • • Aceptar cualquier JWT válido sin mirar la audiencia, o reenviar el token del cliente upstream
  • • Access tokens en el query string
  • • Depender de Mcp-Session-Id, streams GET, ping o reanudación con Last-Event-ID
  • • Probar solo en la era legacy por defecto del Inspector y dar por hecho que 2026-07-28 funciona

Regla de oro

Si una segunda instancia de tu servidor no puede atender la siguiente petición, el servidor todavía no es stateless. Haz que el estado sea un handle explícito y almacenado, valida quién llama y para qué recurso se emitió su token, y prueba en la era moderna a propósito.

Conclusión

La revisión 2026-07-28 convierte al servidor MCP remoto en algo que casi cualquier ingeniero backend ya sabe operar: un endpoint HTTP stateless detrás de OAuth. Viste el camino completo: createMcpHandler para un servidor por petición, un gate bearer con Protected Resource Metadata, un despliegue estilo Vercel, pruebas en la era moderna y un checklist de migración para servidores con estado y SSE.

Esta semana, toma un servidor existente, corre el codemod en una rama, cambia el transporte por createMcpHandler y pruébalo con --protocol-era modern. Después decide, herramienta por herramienta, qué estado oculto pasa a ser un handle explícito. Si tu stack es Python, el SDK oficial figura como Tier 1 y habla 2026-07-28 según el blog de MCP (mcp estaba en 2.3.0 en PyPI el 2026-10-02 y fastmcp en 4.1.0 el 2026-10-08); revisa sus notas de versión para el comportamiento exacto. Usé TypeScript porque su documentación incluye las recetas de HTTP y auth que usé aquí.

Machete del Tutorial

Servidor

  • • createMcpHandler(factory)
  • • McpServer nuevo por petición
  • • estado = handle explícito

Auth

  • • requireBearerAuth + verifier
  • • metadata + WWW-Authenticate
  • • audiencia + expiresAt

Despliegue

  • • fetch-native o ruta de Vercel
  • • prueba con --protocol-era modern
  • • legacy: ’reject’ cuando toque

Fuentes

Diego Rodriguez

Diego Rodriguez

Ingeniero Senior Full-Stack & AI

Diego tiene mas de 9 anos de experiencia construyendo aplicaciones potenciadas por IA de produccion, desde orquestacion de LLMs y pipelines RAG hasta deteccion de riesgos con ML y sistemas de trading algoritmico.

Conoce mas sobre Diego →