Servidor NestJS MCP
npm i @nestjs/mcp @modelcontextprotocol/server zod
npm i -D @modelcontextprotocol/client # cliente de prueba en memoria
Zod 4.2+ y ArkType funcionan directamente; Valibot a través de toStandardJsonSchema(); un esquema JSON existente mediante fromJsonSchema(). Los esquemas de Zod 3 son rechazados al inicio.
McpModule.forRoot({
name: 'cat-store', version: '1.0.0',
instructions: 'Busca los pedidos con find-order antes de modificarlos.', // le indica al modelo cómo usar el servidor
}),
app.listen(3000) luego sirve el endpoint en /mcp. Inspecciónalo con npx @modelcontextprotocol/inspector. Al iniciar, el módulo escanea los providers (todos los módulos, o solo include) en busca de métodos decorados. Falla rápidamente en nombres duplicados, esquemas inválidos o scopes declarados sin auth.
Primitivas: métodos en providers
| Primitiva | Decorador | Quién la usa | Se llama con |
|---|---|---|---|
| Herramienta (una acción) | @Tool({ name, description, inputSchema, outputSchema?, annotations?, scopes? }) | el modelo | entrada validada, ctx |
| Recurso (datos) | @Resource({ uri, mimeType }) / @ResourceTemplate({ uriTemplate: 'orders://{orderId}' }) | el cliente, a menudo adjuntado por el usuario | {} o las variables de la plantilla, ctx (ctx.uri) |
| Plantilla (un prompt) | @Prompt({ name, description, argsSchema }) | el usuario elige una | args validados, ctx |
@Injectable() // un provider simple, listado en los providers de un módulo
export class OrdersTools {
constructor(private readonly orders: OrdersService) {}
@Tool({
name: 'find-order',
description: 'Encuentra un pedido por su id, o los últimos pedidos de un cliente por email',
inputSchema: z.object({
orderId: z.string().optional().describe('El id del pedido, por ejemplo ord_123'),
email: z.email().optional().describe('La dirección de correo electrónico del cliente'),
}),
annotations: { readOnlyHint: true }, // el cliente puede ejecutarlo sin preguntar al usuario
})
async find({ orderId, email }: { orderId?: string; email?: string }) {
const found = orderId ? [await this.orders.findById(orderId)] : await this.orders.latestFor(email!);
if (!found[0]) throw new NotFoundException('No existe tal pedido'); // → resultado de herramienta con isError: true
return found;
}
@Tool({
name: 'change-address',
description: 'Cambia la dirección de entrega de un pedido que aún no se ha enviado',
inputSchema: z.object({ orderId: z.string(), address: z.string().min(10) }),
outputSchema: z.object({ orderId: z.string(), address: z.string(), status: z.string() }),
scopes: ['orders:write'],
})
async changeAddress({ orderId, address }: { orderId: string; address: string }, ctx: McpContext) {
const o = await this.orders.changeAddress(orderId, address, ctx.authInfo?.clientId);
return { orderId: o.id, address: o.address, status: o.status }; // → structuredContent + texto JSON
}
}
Valores de retorno: el string de una herramienta se convierte en un bloque de texto. Un objeto se convierte en structuredContent si hay un outputSchema, y en texto JSON en caso contrario. Devuelve un CallToolResult tú mismo para imágenes, audio o recursos incrustados; undefined da un resultado vacío. El string de un recurso se envía como texto, un Buffer/Uint8Array como un blob, un objeto como JSON. El string de una plantilla se convierte en un mensaje de usuario; un array devuelve PromptMessage[]. Los nombres de las herramientas tienen entre 1 y 128 caracteres de A-Z a-z 0-9 _ - . y por defecto toman el nombre del método.
Escribe nombres, descripciones y textos .describe() para el modelo. Es todo lo que conoce: di qué hace la herramienta, cuándo usarla, si cambia algo, y proporciona un valor de ejemplo para cada argumento. Usa recursos para contenido que se carga tal cual; usa herramientas para búsquedas que toman argumentos.
Contexto de llamada
ctx: McpContext tiene estas propiedades:
kind,name,uriauthInfo(clientId,scopes,expiresAt,extra)request(la solicitud de la plataforma;undefinedsobre stdio)requestInfotransport('http' | 'stdio')era('modern'2026-07-28 /'legacy'2025)signal: pásalo afetchy a llamadas a BD; llama athrowIfAborted()en buclesreportProgress(progress, total?, message?)server(contexto del SDK)
Para preguntar al usuario (confirmaciones, valores faltantes), devuelve inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema }) } }). En el reintento, lee la respuesta con acceptedContent(ctx.server.mcpReq.inputResponses, 'confirm', Schema). Sobre HTTP esto solo funciona para clientes de 2026-07-28, porque el endpoint no tiene estado. Sobre stdio funciona para ambas eras.
Los providers siguen siendo singletons. Lee los datos por llamada de ctx, no de providers con ámbito de solicitud.
Mejoradores y errores
- Guards, interceptores, pipes y filtros se ejecutan como en las rutas, con
context.getType() === 'mcp'.McpExecutionContext.create(context)proporcionagetInput(),getContext(),getKind(),getName(),getRequest()ygetAuthInfo(). - Los mejoradores globales se ejecutan en las llamadas MCP y en la solicitud HTTP portadora. Los mejoradores solo HTTP deben verificar
context.getType() === 'http'antes de llamar aswitchToHttp(). - Un
ValidationPipe({ transform: true })global valida la entrada tipada por DTO. Los decoradores de parámetros personalizados van después de(input, ctx, @CurrentUser() user). - Los errores lanzados por herramientas se convierten en resultados
isError: truede los que el modelo puede recuperarse. UnHttpExceptionconserva su mensaje; cualquier otro error se convierte enError interno del servidory se registra. - Los recursos y plantillas responden con errores JSON-RPC:
NotFoundException→ recurso no encontrado,BadRequestException→ parámetros inválidos. - Filtros: verifica
host.getType<McpContextType>() === 'mcp'. Lo que devuelva el filtro se convierte en el resultado; para enviar un error JSON-RPC, lanza unProtocolError.
Autorización (servidores accesibles por red)
McpModule.forRootAsync({
imports: [ConfigModule, AuthModule],
inject: [ConfigService, IdentityProviderTokenVerifier],
useFactory: (config: ConfigService, verifier: IdentityProviderTokenVerifier) => ({
name: 'cat-store', version: config.get('VERSION'),
auth: {
verifier, // implementa McpTokenVerifier.verifyAccessToken(token) → AuthInfo | null
resource: config.get('MCP_RESOURCE'), // URL pública canónica = audiencia del token
authorizationServers: [config.get('IDENTITY_PROVIDER_URL')],
scopesSupported: ['orders:read', 'orders:write'],
},
}),
}),
- El servidor es un servidor de recursos OAuth 2.1: verifica tokens y nunca los emite.
- Sirve metadatos RFC 9728 en
/.well-known/oauth-protected-resource/mcp. Las solicitudes sin token reciben un 401 con un desafíoWWW-Authenticate: Bearer, que inicia el inicio de sesión del cliente. - Verificadores:
- JWT: usa
JwtVerifierde@nestjs/authentication(verifica firma, emisor, audiencia y caducidad). - Tokens opacos: llama al endpoint de introspección RFC 7662.
- El
AuthInfodevuelto debe contenerresource === auth.resourcey unexpiresAtfuturo; de lo contrario, el token es rechazado con 401. - Lanza un error 401 para rechazar un token; cualquier otro error se convierte en 500.
- JWT: usa
scopesfaltantes en una primitiva → 403 con desafío de escalado.auth.requiredScopesse aplica a cada solicitud.- Con
@nestjs/authentication≥ 0.1.3 y@nestjs/authorization≥ 0.1.1:- Sus guards globales autentican cada llamada, y
@CurrentUser()y@Can(Policy, 'action')funcionan en las herramientas. - Establece
decorators: [Public()]en el endpoint para queauthmaneje el desafío. - Una herramienta sin
@Can()es denegada.
- Sus guards globales autentican cada llamada, y
- Los servidores solo internos pueden omitir OAuth y usar
decorators: [UseGuards(ApiKeyGuard)]en el endpoint.
Transportes
- HTTP streamable se sirve por defecto;
path: falselo desactiva. El endpoint es un controlador normal, por lo que se aplican el prefijo global, el versionado (endpointVersion), el middleware y CORS. No tiene estado: no se necesitan sesiones adhesivas. Se sirven ambas eras del protocolo (http.legacy: 'reject'sirve solo 2026-07-28).http.responseModepuede ser'auto','json'o'sse'. - stdio, para asistentes que lanzan un proceso local: crea
stdio.tsconNestFactory.createApplicationContext(AppModule, { logger: new McpStdioLogger() }), luegoawait app.get(McpStdioServer).listen(); await app.close();. Cualquier otra cosa escrita en stdout rompe el protocolo. Las credenciales provienen deprocess.env; los ámbitos no se verifican, pero los guards se ejecutan.
Consulta references/mcp-reference.md para la tabla completa de opciones, configuraciones de producción, la configuración del cliente stdio, patrones de prueba y el mapa de migración.
Flujo de trabajo
- 1. Instala; verifica Zod 4.2+;
McpModule.forRootconname,version,instructions. - 2. Añade herramientas, recursos y plantillas en providers, con descripciones orientadas al modelo,
.describe()en cada argumento,readOnlyHinten herramientas de solo lectura, youtputSchemacuando el resultado sea estructurado. - 3. Asegúrate de que los mejoradores globales solo HTTP verifiquen
getType(); añade guards y filtros MCP según sea necesario. - 4. Servidores accesibles por red: añade
auth(verificador, recurso, servidores de autorización, ámbitos),allowedHosts/allowedOrigins, cabeceras CORS y el límite del body. Servidores locales: añadestdio.tsconMcpStdioLogger. - 5. Pruebas: prueba métodos unitariamente directamente; ejecuta pruebas e2e con
connectMcpClient(moduleRef, { token })contra un verificador de prueba; prueba la ruta HTTP a través de un socket. Compila, lint, ejecuta pruebas, inicia la aplicación y lista las herramientas con el Inspector.
Archivos de referencia
references/mcp-reference.md: opciones del módulo, opciones de decoradores, endurecimiento HTTP (Origin, Host, cabeceras CORS, tamaño del body), configuración del cliente stdio, convención de nombres Observe de NestJS, lista de verificación de producción, pruebas (tokens y ámbitos, eras del protocolo, comprobaciones de McpRegistry, pruebas HTTP y stdio) y migración desde@rekog/mcp-nest1.x/2.x. Léelo al configurar producción, escribir pruebas o migrar.
Fuentes: Visión general · Primitivas · Transportes · Pruebas · Migración