Danila (Dayfing)
Volver a publicaciones
2394 palabras14 min

Servidor MCP en TypeScript: Streamable HTTP, OAuth y mínimo privilegio

Esta guía construye un pequeño servidor MCP remoto para un servicio de notas. El ejemplo usa la rama v2 estable del SDK de TypeScript, que implementa la revisión MCP 2026-07-28. El servidor acepta solicitudes modernas de Streamable HTTP, verifica tokens de acceso OAuth como servidor de recursos, expone herramientas de lectura y borrado, valida argumentos con Zod y coloca la comprobación de permisos junto a la operación que protege. El código es un ejemplo ejecutable, pero este artículo no afirma que se haya ejecutado en tu entorno.

La versión del protocolo importa. Los paquetes v2 están separados en @modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core y adaptadores del entorno de ejecución. No copies en un servicio nuevo ejemplos antiguos que importan @modelcontextprotocol/sdk/server/mcp.js. La guía de migración MCP 2026-07-28 explica con más detalle los cambios del protocolo y de los paquetes.

Empieza por el modelo de red moderno

Streamable HTTP tiene un único endpoint MCP, normalmente /mcp, y cada solicitud JSON-RPC del cliente es un POST HTTP separado. La respuesta es un objeto JSON o un flujo SSE limitado a esa solicitud. Una notificación se confirma con 202 y sin cuerpo. El cliente anuncia ambos tipos, application/json y text/event-stream, en Accept, y envía JSON con Content-Type: application/json.

La revisión 2026-07-28 elimina el flujo GET antiguo, los identificadores de sesión del protocolo y la reanudación mediante el historial Last-Event-ID. También elimina las solicitudes JSON-RPC independientes del servidor al cliente. Si una herramienta necesita confirmación u otro dato, devuelve un resultado input_required. El cliente responde a la solicitud integrada y repite la llamada original. Las notificaciones de cambios de larga duración usan el flujo de respuesta subscriptions/listen, no una conexión GET general.

Cada POST moderno lleva MCP-Protocol-Version, cuyo valor debe coincidir con _meta.io.modelcontextprotocol/protocolVersion dentro del cuerpo JSON. Mcp-Method refleja el método JSON-RPC de cada solicitud. Mcp-Name refleja params.name o params.uri para las solicitudes tools/call, resources/read y prompts/get. Un gateway puede usar estos encabezados para enrutar, pero la aplicación debe rechazar cualquier diferencia entre encabezados y cuerpo. El cuerpo es la fuente de verdad.

Una llamada moderna mínima tiene esta forma:

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: notes.search

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"notes.search","arguments":{"query":"gateway"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"notes-cli","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}

Instala el SDK v2

Crea un proyecto Node.js ESM con Node.js 20 o posterior, TypeScript 6 y los paquetes del SDK. Fija las versiones en el lockfile de la aplicación. Al comprobar este artículo, el paquete de servidor estable y los adaptadores de Node y Express tenían la versión 2.0.0. El paquete de servidor usa Zod 4 para los esquemas Zod.

npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] express@5 zod@4
npm install --save-dev typescript@6 @types/node @types/express tsx

Con TypeScript 6 incluye de forma explícita los tipos de Node.js si tu configuración aún no los incluye:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "types": ["node"]
  }
}

Construye el servidor y la barrera del servidor de recursos

createMcpHandler recibe una factory y construye un McpServer nuevo para cada solicitud HTTP. toNodeHandler adapta su interfaz web estándar fetch a los objetos de solicitud y respuesta de Node. El adaptador Express proporciona análisis JSON, protecciones de host y origin para un host configurado y el middleware OAuth.

Un servidor MCP es un servidor de recursos OAuth. Verifica tokens emitidos por un servidor de autorización, pero no emite tokens. El verificador siguiente usa introspección RFC 7662. Comprueba deliberadamente active, sub y exp, convierte el campo scope separado por espacios y devuelve el AuthInfo del SDK. Cambia a validación JWT local solo después de implementar y probar issuer, audience, firmas, claves, reloj y rotación de claves.

import {
  createMcpExpressApp,
  getOAuthProtectedResourceMetadataUrl,
  mcpAuthMetadataRouter,
  requireBearerAuth,
  type OAuthTokenVerifier
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
  createMcpHandler,
  McpServer,
  OAuthError,
  OAuthErrorCode,
  type AuthInfo,
  type OAuthMetadata
} from "@modelcontextprotocol/server";
import type { Request, Response } from "express";
import * as z from "zod/v4";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

const host = process.env.HOST ?? "127.0.0.1";
const mcpUrl = new URL(required("MCP_URL"));
const scopes = ["mcp", "notes:read", "notes:write"];
const oauthMetadata: OAuthMetadata = {
  issuer: required("OAUTH_ISSUER"),
  authorization_endpoint: required("OAUTH_AUTHORIZATION_ENDPOINT"),
  token_endpoint: required("OAUTH_TOKEN_ENDPOINT"),
  response_types_supported: ["code"],
  scopes_supported: scopes,
  code_challenge_methods_supported: ["S256"],
  authorization_response_iss_parameter_supported: true
};
const notes = [
  { id: "1", text: "Rotate the signing key after the release." },
  { id: "2", text: "Review the MCP gateway rate limit." }
];

async function verifyAccessToken(token: string): Promise<AuthInfo> {
  const credentials = Buffer.from(`${required("OAUTH_CLIENT_ID")}:${required("OAUTH_CLIENT_SECRET")}`).toString("base64");
  const response = await fetch(required("OAUTH_INTROSPECTION_URL"), {
    method: "POST",
    headers: {
      authorization: `Basic ${credentials}`,
      "content-type": "application/x-www-form-urlencoded"
    },
    body: new URLSearchParams({ token }).toString()
  });
  if (!response.ok) throw new OAuthError(OAuthErrorCode.InvalidToken, "Token introspection failed");
  const payload = await response.json() as {
    active?: unknown;
    sub?: unknown;
    client_id?: unknown;
    scope?: unknown;
    exp?: unknown;
  };
  if (payload.active !== true || typeof payload.sub !== "string" || typeof payload.exp !== "number") {
    throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is inactive or incomplete");
  }
  const tokenScopes = typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [];
  return {
    token,
    clientId: typeof payload.client_id === "string" ? payload.client_id : payload.sub,
    scopes: tokenScopes,
    expiresAt: payload.exp
  };
}

const verifier: OAuthTokenVerifier = { verifyAccessToken };

function buildServer(): McpServer {
  const server = new McpServer({ name: "notes", version: "1.0.0" });
  server.registerTool(
    "notes.search",
    {
      title: "Search notes",
      description: "Find notes containing a phrase.",
      inputSchema: z.object({
        query: z.string().trim().min(1).max(200),
        limit: z.number().int().min(1).max(20).default(10)
      })
    },
    async ({ query, limit }, ctx) => {
      if (!ctx.http?.authInfo?.scopes.includes("notes:read")) {
        return { content: [{ type: "text", text: "insufficient_scope: notes:read is required" }], isError: true };
      }
      const needle = query.toLocaleLowerCase();
      const matches = notes.filter(note => note.text.toLocaleLowerCase().includes(needle)).slice(0, limit);
      return { content: [{ type: "text", text: JSON.stringify(matches) }] };
    }
  );
  server.registerTool(
    "notes.delete",
    {
      title: "Delete a note",
      description: "Delete one note by id.",
      inputSchema: z.object({ id: z.string().regex(/^\d+$/) })
    },
    async ({ id }, ctx) => {
      if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
        return { content: [{ type: "text", text: "insufficient_scope: notes:write is required" }], isError: true };
      }
      const index = notes.findIndex(note => note.id === id);
      if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
      const [removed] = notes.splice(index, 1);
      return { content: [{ type: "text", text: `deleted ${removed.id}` }] };
    }
  );
  return server;
}

const handler = createMcpHandler(buildServer);
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpUrl);
const app = createMcpExpressApp({ host, allowedHosts: [mcpUrl.hostname], jsonLimit: "64kb" });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpUrl, scopesSupported: scopes, resourceName: "Notes MCP" }));
const auth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], resourceMetadataUrl });
const node = toNodeHandler(handler);
app.all(mcpUrl.pathname, auth, (req: Request, res: Response) => {
  void node(req, res, req.body);
});
const port = mcpUrl.port ? Number(mcpUrl.port) : Number(process.env.PORT ?? 3000);
app.listen(port, host);

El scope mcp protege el endpoint. Las dos comprobaciones de herramientas aportan una separación más fina. Un cliente con solo mcp puede descubrir el endpoint, pero recibe un resultado de herramienta normal con isError: true cuando llama a una operación protegida. El rechazo queda visible para el modelo. Si todo el endpoint necesita un scope, inclúyelo en requiredScopes. El middleware devolverá 403 con insufficient_scope.

El metadata router publica Protected Resource Metadata RFC 9728 y refleja el metadata RFC 8414 del servidor de autorización. El cliente sigue resource_metadata desde WWW-Authenticate, descubre endpoints, obtiene un token y reintenta. El SDK de TypeScript no implementa tu proveedor de identidad. Usa un proveedor mantenido. Los helpers v1 en @modelcontextprotocol/server-legacy/auth están congelados para migración.

Las reglas 2026-07-28 también exigen comparar iss con el issuer descubierto antes de enviar el código al token endpoint. Las credenciales deben separarse por issuer. Los Client ID Metadata Documents se prefieren a Dynamic Client Registration, que queda para compatibilidad, para evitar un authorization-server mix-up.

Haz explícita la ausencia de estado

La factory crea un servidor por solicitud, así que no guardes el caller autenticado, la fase de un workflow o una decisión de permiso en una instancia esperando recuperarlos en el siguiente POST. Las solicitudes modernas pueden llegar a cualquier réplica detrás de un balanceador round-robin normal. Guarda el estado de negocio duradero en una base o cola y pasa un handle explícito, corto y validado como argumento de herramienta.

Para una confirmación, devuelve input_required y protege su estado. El SDK exporta inputRequired, acceptedContent y createRequestStateCodec para este patrón:

import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

function buildServerWithConfirmation(): McpServer {
  const key = process.env.REQUEST_STATE_KEY;
  if (!key) throw new Error("Missing REQUEST_STATE_KEY");
  type DeleteState = { noteId: string; step: "confirm" };
  const stateCodec = createRequestStateCodec<DeleteState>({
    key,
    ttlSeconds: 300,
    bind: ctx => `${ctx.mcpReq.method}\u0000${ctx.http?.authInfo?.clientId ?? ""}`
  });
  const server = new McpServer(
    { name: "notes-confirmation", version: "1.0.0" },
    { requestState: { verify: stateCodec.verify } }
  );
  const notes = [{ id: "1", text: "Rotate the signing key after the release." }];
  const confirmationSchema = z.object({ confirm: z.boolean() });
  server.registerTool(
    "notes.delete",
    { inputSchema: z.object({ id: z.string().regex(/^\d+$/) }) },
    async ({ id }, ctx) => {
      const confirmed = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmationSchema);
      if (!confirmed) {
        return inputRequired({
          inputRequests: {
            confirm: inputRequired.elicit({
              message: "Delete this note?",
              requestedSchema: confirmationSchema
            })
          },
          requestState: await stateCodec.mint({ noteId: id, step: "confirm" }, ctx)
        });
      }
      if (!confirmed.confirm) return { content: [{ type: "text", text: "deletion declined" }], isError: true };
      const index = notes.findIndex(note => note.id === id);
      if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
      notes.splice(index, 1);
      return { content: [{ type: "text", text: `deleted ${id}` }] };
    }
  );
  return server;
}

Conecta stateCodec.verify a la opción requestState.verify que pasas a McpServer. El estado está firmado, no cifrado, y el SDK trata el estado devuelto por el cliente como no confiable hasta ejecutar ese hook. Átalo al principal y a la operación, impón una expiración y no pongas secretos en el payload. Todas las réplicas que puedan recibir el retry deben compartir la clave HMAC.

Valida, limita y despliega

La validación Zod es la primera frontera de la aplicación, no todo el modelo de seguridad. Limita cadenas, arrays, números, identificadores, URL, rutas y tamaños de respuesta. Valida de nuevo en el límite de la API posterior, porque un esquema demuestra la forma, no el permiso ni la seguridad de un efecto lateral. No permitas que el modelo elija libremente métodos HTTP, hosts, rutas de archivos, fragmentos SQL o argumentos de shell. Usa listas permitidas y API parametrizadas.

El rate limiting debe usar clientId autenticado o tenant como clave principal, con límites separados para discovery, herramientas costosas y operaciones simultáneas. Devuelve 429 con Retry-After en edge o gateway. Un contador local sirve para un proceso único, pero no es un límite de cluster. Para varias réplicas usa un limitador compartido como Redis. Incluye method y tool name en las métricas, sin registrar el bearer token.

Termina TLS en el punto público o usa TLS de extremo a extremo, conserva el flujo SSE y desactiva el buffering del proxy con X-Accel-Buffering: no si el proxy lo permite. Configura de forma deliberada los timeouts del cuerpo, encabezados, inactividad y upstream. Un servidor local debe escuchar en 127.0.0.1, no en 0.0.0.0, salvo que una allowlist y la autenticación lo protejan. La factory de Express activa protecciones de host y origin solo para la clase localhost configurada. En un bind público define allowedHosts y allowedOrigins conscientemente.

El protocolo moderno no necesita sticky sessions. Un flujo subscriptions/listen es distinto: para distribuir notificaciones entre réplicas, proporciona un ServerEventBus compartido sobre tu sistema pub/sub. Mantén pools de base y cachés a nivel de módulo, pero deja las decisiones del caller en el contexto de solicitud. Durante un apagado ordenado llama a handler.close() para detener los intercambios modernos en curso antes de salir.

Prueba el wire, no solo la función

El SDK v2 documenta una ruta de prueba in-process que llama a createMcpHandler mediante StreamableHTTPClientTransport con una función fetch propia. Usa el camino real request/response para cubrir negociación de versión, encabezados obligatorios, respuestas JSON y SSE, paso de autenticación, fallos de esquema, cancelación y retries input_required. Añade pruebas separadas para 401 sin token, 401 con token inactivo, 403 sin scope del endpoint y un rechazo isError dentro del resultado cuando falta el scope de una herramienta.

Usa mode: { pin: "2026-07-28" } para una prueba del wire moderno. En una prueba de compatibilidad usa mode: "auto" y comprueba el fallback legacy contra una fixture antigua intencionada. Envía dos solicitudes con callers distintos y confirma que la visibilidad y autorización no se filtran entre ellas. En una prueba multi-node envía el retry a otro proceso y verifica que el state codec compartido y el almacenamiento duradero conservan el workflow.

Diagnostica desde la primera capa que falla

Un HTTP 400 con HeaderMismatch significa que el encabezado de protocolo y el claim de _meta difieren, o que falta un encabezado de routing obligatorio. Corrige cliente o gateway. No vuelvas en silencio a un protocolo anterior cuando el cuerpo es un error moderno reconocido. Un HTTP 415 significa que el tipo de media del POST no es application/json. Un valor como text/plain; a=application/json no es válido. Un 404 para GET es esperado en el endpoint moderno. Un 404 para POST puede ser un método desconocido o una ruta que nunca llegó al handler MCP.

Un 401 debería mostrar un desafío WWW-Authenticate. Comprueba URL de metadata, issuer, audience o resource, firma o introspection y expiración numérica. La barrera bearer del SDK rechaza un AuthInfo sin expiresAt. Un 403 insufficient_scope indica un desafío del endpoint y puede iniciar el step-up. Un rechazo con isError: true no autoriza automáticamente un token más amplio.

Si el cliente se bloquea, revisa la URL, el buffering SSE del reverse proxy, el idle timeout y la respuesta con el mismo identificador JSON-RPC. Si un cliente moderno detecta un servidor legacy, revisa que versionNegotiation sea auto o pin, que las versiones del SDK coincidan y que un gateway no haya eliminado _meta o los encabezados obligatorios. Si la confirmación se repite sin fin, comprueba la clave de la respuesta integrada, valida inputResponses, limita los rounds e inspecciona la fase del estado firmado. Para el contexto de migración y la matriz de compatibilidad, consulta la guía de migración MCP 2026-07-28. Sobre amenazas de injection, tool poisoning y autorización alrededor de MCP, lee prompt injection y seguridad MCP. Para límites de servicio, colas y aislamiento de fallos, usa arquitectura de agentes IA en producción.

Fuentes

Más publicaciones