remote-mcp-server-template
Plantilla de servidor MCP remoto lista para producción en TypeScript con OAuth 2.1, alcances por herramienta y autorización escalonada.
Documentación
remote-mcp-server-template
Un punto de partida listo para producción de un servidor MCP remoto en TypeScript: transporte HTTP Streamable, autorización de servidor de recursos OAuth 2.1, sin estado para escalar horizontalmente.
Clónalo, apúntalo a tu proveedor de identidad, reemplaza las herramientas de ejemplo con las tuyas. Cualquier cliente MCP que admita el flujo de autorización puede conectarse.
Lo que obtienes
- Transporte HTTP Streamable en Express 5 con
@modelcontextprotocol/sdk1.32, en modo sin estado (sessionIdGenerator: undefined). Cada POST es autocontenido, por lo que cualquier réplica puede responderlo. - Servidor de recursos OAuth 2.1, siguiendo la especificación de autorización MCP actual:
- Metadatos de recurso protegido (RFC 9728) en
/.well-known/oauth-protected-resource. 401conWWW-Authenticate: Bearer resource_metadata="...".- Validación JWT contra el JWKS del emisor con
jose: firma, emisor, audiencia = este servidor (RFC 8707), caducidad. - Ámbitos por herramienta, respondidos con
403yinsufficient_scopepara que los clientes puedan escalar. - Sin reenvío de tokens: el token de portador se descarta después de la verificación.
- Metadatos de recurso protegido (RFC 9728) en
- Validación de origen contra el rebinding de DNS, con una lista de permitidos del entorno (y CORS para clientes de navegador en la lista de permitidos).
- Tres herramientas de ejemplo:
whoami,notes_list(notes:read),notes_create(notes:write, entrada validada con zod), respaldadas por un almacén en memoria aislado por usuario (sub). - Configuración del entorno validada con zod,
/healthzy registros JSON que nunca contienen un token. - Una suite Vitest que inicia la aplicación real en un puerto efímero y forja tokens localmente. Sin red, sin proveedor de identidad real.
- TypeScript estricto, Biome, Dockerfile de múltiples etapas (no root), CI, Dependabot y una plantilla
server.jsondel Registro MCP.
Inicio rápido
Requiere Node.js 22 o más reciente.
git clone https://github.com/romulorgc/remote-mcp-server-template.git
cd remote-mcp-server-template
npm install
cp .env.example .env # set AUTH_ISSUER to your identity provider
npm run dev
El servidor escucha en 127.0.0.1:3000. Sin un token, indica a los clientes dónde autenticarse:
$ curl -i -X POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer scope="notes:read", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"
$ curl http://localhost:3000/.well-known/oauth-protected-resource
{"resource":"http://localhost:3000","authorization_servers":["https://your-tenant.example.com/"],"scopes_supported":["notes:read","notes:write"],"bearer_methods_supported":["header"],"resource_name":"Remote MCP server template"}
Apunta un cliente MCP a http://localhost:3000/mcp (usa el mismo host que PUBLIC_URL: los clientes verifican que el resource de los metadatos coincida con la URL a la que se conectan). Para probarlo desde un cliente basado en navegador, agrega el origen de ese cliente a ALLOWED_ORIGINS.
| Script | Qué hace |
|---|---|
npm run dev | Ejecutar con tsx watch, cargando .env si está presente |
npm run build | Compilar a dist/ |
npm start | Ejecutar el servidor compilado |
npm run typecheck | tsc --noEmit |
npm run lint | Verificación de Biome (lint, formato, orden de importaciones) |
npm run format | Aplicar correcciones de Biome |
npm test | Vitest |
Conecta tu proveedor de identidad
Este servidor nunca emite tokens. Confía en un servidor de autorización que ya ejecutas o alquilas: Auth0, Keycloak, Clerk, WorkOS o cualquier otro proveedor OpenID Connect u OAuth 2.0 que emita tokens de acceso JWT. Los pasos son los mismos en todas partes, solo cambian los nombres.
- Registra este servidor como una API (un "recurso"). Usa
PUBLIC_URLcomo su identificador, por ejemplohttps://mcp.example.com. Los proveedores lo llaman API, servidor de recursos o audiencia. - Define los ámbitos
notes:readynotes:write, más cualquiera de los tuyos. - Haz que los tokens de acceso lleven este servidor en
aud. El cliente MCP envía el parámetroresourcede RFC 8707; el proveedor debe poner ese valor, o una audiencia fija que configures para la API, en el token. Si tu proveedor no puede usar la URL como audiencia, estableceAUTH_AUDIENCEal valor que sí use. - Permite que los clientes MCP se registren. La especificación pide a los servidores de autorización que admitan Documentos de Metadatos de ID de Cliente OAuth, con Registro Dinámico de Clientes como respaldo. Habilita el que ofrezca tu proveedor, o pre-registra los clientes que te interesen. Los clientes usan código de autorización con PKCE.
- Configura este servidor. Copia el valor de
issuerdehttps://<your-idp>/.well-known/openid-configurationenAUTH_ISSUER, carácter por carácter (algunos proveedores lo terminan con una barra, y la comparación es exacta). DejaAUTH_JWKS_URLvacío para descubrir el conjunto de claves del emisor, o establécelo para omitir el descubrimiento. - Compruébalo. Obtén un token para tu usuario y luego llama a
whoamia través de cualquier cliente MCP. Devuelve elsuby los ámbitos que el servidor leyó del token.
Los ámbitos se leen de la declaración scope (RFC 9068, separados por espacios) o scp (cadena o matriz). Si tu proveedor coloca los permisos en otro lugar, cambia readScopes en src/auth/verifier.ts.
Configuración
| Variable | Requerida | Predeterminado | Significado |
|---|---|---|---|
PUBLIC_URL | sí | Origen canónico de este servidor (esquema, host, puerto opcional; sin ruta). Publicado como el resource de metadatos. | |
AUTH_ISSUER | sí | Emisor de tu servidor de autorización. Se requiere https, excepto localhost. | |
AUTH_JWKS_URL | no | descubierto | Endpoint JWKS. Vacío significa descubrirlo de los metadatos del emisor (descubrimiento OIDC, luego RFC 8414). |
AUTH_AUDIENCE | no | PUBLIC_URL | aud esperado de los tokens de acceso. |
ALLOWED_ORIGINS | no | ninguno | Orígenes de navegador separados por comas permitidos para llamar al servidor. Vacío rechaza cada solicitud con un encabezado Origin. |
HOST | no | 127.0.0.1 | Interfaz a la que vincularse. La imagen Docker establece 0.0.0.0. |
PORT | no | 3000 | Puerto en el que escuchar. |
La configuración no válida detiene el proceso al inicio con un mensaje que nombra cada variable incorrecta.
Endpoints
| Método | Ruta | Autenticación | Propósito |
|---|---|---|---|
POST | /mcp | Token de portador | El endpoint MCP (HTTP Streamable, respuestas JSON) |
GET, DELETE | /mcp | Token de portador | 405: el modo sin estado no tiene sesiones ni flujo iniciado por el servidor |
GET | /.well-known/oauth-protected-resource | ninguna | Metadatos de recurso protegido (RFC 9728) |
GET | /healthz | ninguna | Liveness: {"status":"ok"} |
Cómo responde el servidor y por qué:
| Situación | Estado | WWW-Authenticate |
|---|---|---|
| Sin credenciales | 401 | Bearer scope="notes:read", resource_metadata="..." |
| Firma incorrecta, emisor o audiencia equivocados, caducado, malformado | 401 | Bearer error="invalid_token", ..., resource_metadata="..." |
| Token válido, la herramienta necesita un ámbito que le falta | 403 | Bearer error="insufficient_scope", scope="notes:write", resource_metadata="...", error_description="..." |
Encabezado Origin no en la lista de permitidos | 403 | ninguno |
| Proveedor de identidad inalcanzable (no se pueden obtener claves) | 500 | ninguno, para que los clientes no hagan bucles de re-autenticación |
Un token válido es suficiente para conectarse, llamar a initialize y tools/list, y usar herramientas que no necesitan ámbito. Una llamada a herramienta que necesita más se rechaza en la capa HTTP, antes de que se ejecute cualquier código de herramienta. El parámetro scope enumera todo lo que esa llamada necesita, en un solo desafío, para que un cliente pueda re-autorizarse con la unión de sus ámbitos antiguos y nuevos y reintentar una vez. Un lote JSON-RPC cuenta como una operación. La verificación de ámbito vive en src/auth/scope-guard.ts y lee las mismas definiciones de herramientas que registran las herramientas, por lo que no pueden divergir. Los manejadores de herramientas verifican de nuevo como defensa en profundidad.
La jerarquía de ámbitos se declara en src/auth/scopes.ts: el ámbito general notes implica notes:read y notes:write. Vacía el mapa si tus ámbitos son planos.
Agregar una herramienta
Crea un archivo en src/mcp/tools/:
// src/mcp/tools/notes-search.ts
import { z } from "zod";
import { SCOPES } from "../../auth/scopes.js";
import { defineTool, jsonResult } from "./define-tool.js";
export const notesSearch = defineTool({
name: "notes_search",
title: "Search notes",
description: "Finds the caller's notes whose title or body contains the query.",
scopes: [SCOPES.notesRead],
inputSchema: {
query: z.string().trim().min(1).max(200).describe("Text to look for, case-insensitive"),
},
outputSchema: {
notes: z.array(
z.object({ id: z.string(), title: z.string(), body: z.string(), createdAt: z.string() }),
),
},
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
async handler({ query }, { principal, notes }) {
const needle = query.toLowerCase();
const own = await notes.list(principal.sub, 100);
const matches = own.filter(
(note) =>
note.title.toLowerCase().includes(needle) || note.body.toLowerCase().includes(needle),
);
return jsonResult({ notes: matches });
},
});
Regístrala en src/mcp/tools/index.ts:
export const tools: readonly ToolDefinition[] = [whoami, notesList, notesCreate, notesSearch];
Eso es todo. La herramienta aparece en tools/list, sus scopes se aplican mediante el guardia HTTP, args está tipado desde inputSchema, y el manejador recibe principal (quién llama, desde el token) y el almacén. ¿Necesitas un ámbito nuevo? Agrégalo a SCOPES en src/auth/scopes.ts; se anuncia en los metadatos automáticamente.
Reglas generales:
- Toma al usuario de
principal.sub, nunca de los argumentos de la herramienta. - Limita tu almacenamiento por propietario.
NotesStoretoma al propietario en cada llamada, por lo que el aislamiento es parte de la interfaz. - No reenvíes el token del llamante a otro servicio. Si una herramienta necesita llamar a uno, usa credenciales propias o un intercambio de tokens OAuth.
- Reemplaza
InMemoryNotesStorecon una base de datos antes de ejecutar más de una réplica.
Pruebas
npm test
Las pruebas no necesitan red ni proveedor de identidad. test/helpers/idp.ts hace el papel del proveedor:
- genera un par de claves ES256 con
josey publica la mitad pública como JWKS, más un documento de descubrimiento OIDC, en un puerto efímero; - los tokens se forjan con
josedeSignJWT, por lo que una prueba puede establecer cualquiersub,scope,aud,issoexp, o firmar con una clave que el JWKS no publica; test/helpers/harness.tsejecuta el cargador de configuración real, inicia la aplicación real en el puerto0y se conecta con el propioClientyStreamableHTTPClientTransportdel SDK.
Cubierto: el desafío 401, el documento de metadatos (también seguido por el cliente SDK), initialize y tools/list, audiencia incorrecta, emisor incorrecto, tokens caducados y sin firmar, claves de firma externas, alg: none, ámbito insuficiente (incluidos lotes, la jerarquía de ámbitos y la declaración scp), aislamiento entre dos usuarios, política de origen y preflight, descubrimiento y almacenamiento en caché de JWKS, validación de configuración, y que los registros nunca contengan un token.
Desplegar con Docker
docker build -t remote-mcp-server .
docker run --rm -p 3000:3000 \
-e PUBLIC_URL=https://mcp.example.com \
-e AUTH_ISSUER=https://your-tenant.example.com/ \
remote-mcp-server
La imagen es de múltiples etapas en node:24-alpine, instala dependencias de producción desde el archivo de bloqueo, se ejecuta como el usuario no privilegiado node y tiene una verificación de salud en /healthz.
Coloca un proxy inverso o balanceador de carga al frente para terminar TLS. Sírvelo sobre https en producción y establece PUBLIC_URL a la dirección que los clientes usan realmente. Reenvía el encabezado Authorization sin cambios. Como el servidor no tiene estado, puedes ejecutar tantas réplicas como necesites, sin sesiones fijas, una vez que el almacén de notas se comparta.
Publicar en el Registro MCP
server.example.json es una plantilla para el Registro MCP oficial, escrita contra el esquema 2025-12-11 server.json. Describe un servidor remoto, por lo que no hay paquete que publicar primero.
- Cópialo a
server.jsony reemplaza los marcadores de posición:name,title,description(100 caracteres como máximo),version, las URL del repositorio y elremotes[0].urlde tu endpoint/mcpdesplegado. - El
namedebe estar en un espacio de nombres que puedas demostrar que posees:io.github.<your-user>/<name>con inicio de sesión de GitHub, o un nombre de DNS inverso para un dominio que controles. - Instala
mcp-publisher(consulta el inicio rápido del registro), luego:
mcp-publisher validate
mcp-publisher login github
mcp-publisher publish
Publica después de que el servidor esté desplegado y sea accesible: los clientes que lo encuentren en el registro se conectarán a esa URL e iniciarán el flujo de autorización descrito anteriormente.
Notas de seguridad
Lo que la plantilla garantiza y dónde vive cada garantía:
- Los tokens están vinculados a este servidor.
auddebe coincidir, por lo que los tokens emitidos para otras APIs, y los tokens de ID, son rechazados (src/auth/verifier.ts). - Sin transferencia de tokens. El token bearer sin procesar se descarta después de la verificación. Las herramientas ven un
Principal(sub, id de cliente, alcances), nunca una credencial que puedan reenviar. - Solo algoritmos asimétricos. Se aceptan RS, PS, ES y EdDSA.
noney los algoritmos HMAC no se aceptan, y las claves provienen únicamente del JWKS del emisor. - Coincidencia exacta del emisor. Los documentos de descubrimiento deben nombrar al emisor para el que fueron obtenidos (RFC 8414 sección 3.3).
expysubson obligatorios. - https para endpoints de identidad. El emisor, la URL de JWKS y el
jwks_uridescubierto deben usar https, excepto para localhost. - Las búsquedas de claves están limitadas. Las claves se almacenan en caché, y un
kiddesconocido activa como máximo una nueva búsqueda cada 30 segundos, por lo que los tokens falsificados no pueden inundar tu proveedor de identidad. - Los alcances se aplican dos veces, en la capa HTTP y en el envoltorio del manejador, a partir de una única declaración por herramienta.
- Validación de origen en cada ruta. Una solicitud con un encabezado
Originfuera de la lista de permitidos recibe403. Con una lista de permitidos vacía, cada solicitud de navegador es rechazada. Los clientes que no son navegadores no envíanOriginy no se ven afectados. El servidor se vincula a127.0.0.1a menos que se indique lo contrario. - Aislamiento de inquilinos. El almacenamiento está vinculado al
subdel token, y la API de almacenamiento no se puede llamar sin un propietario. - Registros silenciosos. Una línea JSON por solicitud: método, ruta, estado, duración, sujeto. Sin encabezados, sin cadena de consulta, sin cuerpo, y un filtro de redacción en nombres de claves sensibles como red de seguridad.
- Entradas limitadas. Cuerpos de solicitud de 1 MB, límites de longitud en la entrada de herramientas, un límite por usuario en notas almacenadas.
- Sin superficie de sesión. No hay sesiones que fijar o secuestrar, y no hay flujos de larga duración.
Lo que deliberadamente te deja a ti:
- Limitación de velocidad y controles de abuso. Hazlo en tu puerta de enlace o proxy.
- Revocación. Los tokens se validan como JWT, no se inspeccionan. Usa vidas útiles cortas.
- Persistencia. El almacén de notas está en memoria y es por proceso.
- TLS. Termínalo frente al servidor.
- El servidor de autorización. Este es solo un servidor de recursos. Publica dónde autenticarse y verifica lo que regresa.
Versiones de protocolo
El servidor está construido sobre @modelcontextprotocol/sdk 1.32, que implementa revisiones de protocolo hasta 2025-11-25 y el protocolo de enlace initialize. La revisión más reciente de la especificación (2026-07-28) elimina las sesiones a nivel de protocolo y ese protocolo de enlace. Una solicitud que declara la versión más reciente recibe un 400 que enumera las versiones que este servidor soporta, que es lo que permite a los clientes retroceder. El comportamiento de autorización aquí (metadatos de recursos, desafíos, vinculación de audiencia, desafíos de alcance) sigue la especificación de autorización actual y no depende de esa elección.
Estructura del proyecto
src/
index.ts entry point: config, listen, graceful shutdown
app.ts Express app: middleware order, routes, stateless MCP handler
config.ts environment schema (zod)
logger.ts JSON logger with redaction
auth/
challenge.ts 401 for requests without credentials
verifier.ts JWT access token verification (jose)
jwks.ts JWKS resolution and issuer discovery
scopes.ts scopes, hierarchy, WWW-Authenticate builder
scope-guard.ts per-tool scope enforcement (403 insufficient_scope)
principal.ts the authenticated caller, as tools see it
http/
origin.ts Origin allowlist and CORS
metadata.ts Protected Resource Metadata (RFC 9728)
jsonrpc.ts JSON-RPC error responses
mcp/
server.ts builds an McpServer with every tool registered
tools/ tool definitions: whoami, notes_list, notes_create
notes/store.ts per-user notes store (in memory)
test/ Vitest suites and the test identity provider
Licencia
MIT © 2026 Rômulo Carvalho