diff --git a/.env.example b/.env.example index 10b41c9..2735888 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,11 @@ OPENAPI_FILE=apps/api/openapi.json # Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) NEXUS_TOKEN= + +# MCP server (apps/mcp, docs/09-mcp.md). Port: not PORT, which is the API's. +MCP_PORT=3010 +TASKSTREAM_API_URL=http://localhost:3000 +# Domains it answers on, comma-separated (DNS rebinding protection). Empty: localhost only. +MCP_ALLOWED_HOSTS= +# error | warn | log | debug | verbose (warn: the library logs every tool on every call otherwise) +MCP_LOG_LEVEL=warn diff --git a/apps/mcp/src/app/app.module.ts b/apps/mcp/src/app/app.module.ts index 0c90528..09d4e8d 100644 --- a/apps/mcp/src/app/app.module.ts +++ b/apps/mcp/src/app/app.module.ts @@ -1,8 +1,9 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { McpModule } from '@nestjs-mcp/server'; import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api'; import { HealthController } from './health.controller'; +import { McpHostsMiddleware } from './mcp-hosts.middleware'; import { IssuesResolver } from './tools/issues.resolver'; import { ProjectsResolver } from './tools/projects.resolver'; import { ReleasesResolver } from './tools/releases.resolver'; @@ -23,7 +24,9 @@ token the client sends; without one, only public projects can be read.`; name: 'taskstream', version: '1.0.0', instructions: INSTRUCTIONS, - logging: { level: config.get('MCP_LOG_LEVEL', 'log') }, + // The library logs every tool on every request (it builds a server per call): + // only warnings and errors unless asked for more. + logging: { level: config.get('MCP_LOG_LEVEL', 'warn') }, }), }), ], @@ -45,4 +48,8 @@ token the client sends; without one, only public projects can be read.`; UsersResolver, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(McpHostsMiddleware).forRoutes('mcp'); + } +} diff --git a/apps/mcp/src/app/mcp-hosts.middleware.ts b/apps/mcp/src/app/mcp-hosts.middleware.ts new file mode 100644 index 0000000..10dba75 --- /dev/null +++ b/apps/mcp/src/app/mcp-hosts.middleware.ts @@ -0,0 +1,46 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + localhostAllowedHostnames, + validateHostHeader, + validateOriginHeader, +} from '@modelcontextprotocol/server'; +import type { NextFunction, Request, Response } from 'express'; + +/** + * Protection against DNS rebinding on /mcp (what the MCP spec asks of HTTP + * servers): the Host header must be one of MCP_ALLOWED_HOSTS (default: only + * localhost), and a browser's Origin, when sent, too. AI clients calling from + * a server send no Origin, so they only need the right domain. + */ +@Injectable() +export class McpHostsMiddleware implements NestMiddleware { + private readonly hosts: string[]; + + constructor(config: ConfigService) { + const configured = (config.get('MCP_ALLOWED_HOSTS') ?? '') + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter(Boolean); + this.hosts = configured.length ? configured : localhostAllowedHostnames(); + } + + use(req: Request, res: Response, next: NextFunction): void { + const error = this.rejection(req); + if (!error) return next(); + res.status(403).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: error }, + }); + } + + /** Why the request is refused, or null when its Host (and Origin, if any) are allowed. */ + private rejection(req: Request): string | null { + const host = validateHostHeader(req.headers.host, this.hosts); + if ('message' in host) return host.message; + if (!req.headers.origin) return null; + const origin = validateOriginHeader(req.headers.origin, this.hosts); + return 'message' in origin ? origin.message : null; + } +} diff --git a/apps/mcp/src/app/mcp.spec.ts b/apps/mcp/src/app/mcp.spec.ts index 3cce7d0..f6915d9 100644 --- a/apps/mcp/src/app/mcp.spec.ts +++ b/apps/mcp/src/app/mcp.spec.ts @@ -1,6 +1,11 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { createServer, IncomingMessage, Server } from 'node:http'; +import { + createServer, + IncomingMessage, + request as httpRequest, + Server, +} from 'node:http'; import { AddressInfo } from 'node:net'; import { TASKSTREAM_API_URL } from './api/taskstream-api'; import { AppModule } from './app.module'; @@ -211,4 +216,50 @@ describe('TaskStream MCP', () => { expect(result.isError).toBe(true); expect(received).toHaveLength(0); }); + + /** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */ + function rawPost(headers: Record): Promise { + const { port } = new URL(url); + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2025-06-18', + ...headers, + }, + }, + (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on('error', reject); + req.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })); + }); + } + + it('only answers on its own domain (DNS rebinding protection)', async () => { + // Default allowlist: localhost only (MCP_ALLOWED_HOSTS sets the real domain). + expect(await rawPost({ host: `localhost:${new URL(url).port}` })).toBe(200); + expect(await rawPost({ host: 'evil.example' })).toBe(403); + expect( + await rawPost({ + host: `localhost:${new URL(url).port}`, + origin: 'https://evil.example', + }), + ).toBe(403); + expect( + await rawPost({ + host: `localhost:${new URL(url).port}`, + origin: 'http://localhost:6274', + }), + ).toBe(200); + expect((await fetch(`${url}/health`)).status).toBe(200); + }); }); diff --git a/docker-compose.yml b/docker-compose.yml index d2f3ca2..81246f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,9 @@ services: aliases: [task-stream-mcp] environment: TASKSTREAM_API_URL: http://task-stream-api:3000 + # Domains it answers on (protection against DNS rebinding): the one Dokploy gives it. + MCP_ALLOWED_HOSTS: ${MCP_ALLOWED_HOSTS:-mcp.taskstream.pireon.dev,localhost} + MCP_LOG_LEVEL: ${MCP_LOG_LEVEL:-warn} depends_on: api: { condition: service_healthy } restart: unless-stopped diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 2e26631..6bfdd26 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -24,6 +24,9 @@ flowchart LR responde con un error que el modelo puede leer y corregir («Cannot move WEB-16 from Backlog to Hecho. Allowed: Por hacer, En curso, Cancelado»), en vez de cortar la llamada. - **Validación**: los parámetros de cada herramienta se validan con zod antes de llamar a la API. +- **Solo en su dominio**: `/mcp` rechaza (403) las peticiones cuyo `Host` no esté en `MCP_ALLOWED_HOSTS`, y las de un + navegador cuyo `Origin` tampoco lo esté. Es la protección contra _DNS rebinding_ que pide la especificación de MCP; + los clientes que llaman desde un servidor no envían `Origin` y solo necesitan el dominio correcto. - **Respuestas compactas**: los listados devuelven lo que el modelo necesita (clave, título, estado, persona…); el detalle completo, con `get_issue` o `get_project`. @@ -106,11 +109,12 @@ npx nx test mcp # MCP real contra una API simulada: token r npx nx run mcp:openapi-types # regenerar los tipos tras cambiar la API (apps/api/openapi.json) ``` -| Variable | Por defecto | Qué es | -| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------ | -| `MCP_PORT` | `3010` | Puerto del MCP (no `PORT`: Nx carga el `.env` del workspace, donde `PORT` es el de la API) | -| `TASKSTREAM_API_URL` | `http://localhost:3000` | Origen de la API (sus rutas ya llevan `/api`) | -| `MCP_LOG_LEVEL` | `log` | `error`, `warn`, `log`, `debug` o `verbose` | +| Variable | Por defecto | Qué es | +| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `MCP_PORT` | `3010` | Puerto del MCP (no `PORT`: Nx carga el `.env` del workspace, donde `PORT` es el de la API) | +| `TASKSTREAM_API_URL` | `http://localhost:3000` | Origen de la API (sus rutas ya llevan `/api`) | +| `MCP_ALLOWED_HOSTS` | (solo `localhost`) | Dominios en los que responde, separados por comas | +| `MCP_LOG_LEVEL` | `warn` | `error`, `warn`, `log`, `debug` o `verbose`. Por encima de `warn`, la librería registra cada herramienta en cada llamada | Añadir una herramienta: un método con `@Tool({ name, description, paramsSchema: z.object(…), annotations })` en el resolver de su ámbito (`apps/mcp/src/app/tools`), que llame a la API con `this.api.as(ctx)` (cliente tipado con el @@ -128,5 +132,7 @@ docker compose --profile mcp up -d --build # local: http://localhost:3010/mcp ``` En Dokploy: `COMPOSE_PROFILES=mcp` en las variables del proyecto y un dominio (p. ej. `mcp.taskstream.pireon.dev`) → -servicio `mcp`, puerto 3010. Habla con la API por la red interna (`http://task-stream-api:3000`). La imagen usa Node 24, +servicio `mcp`, puerto 3010. Si el dominio es otro, ponlo en `MCP_ALLOWED_HOSTS` (por defecto +`mcp.taskstream.pireon.dev,localhost`); si no, el MCP responderá 403. Habla con la API por la red interna +(`http://task-stream-api:3000`). La imagen usa Node 24, que es lo que pide `@nestjs-mcp/server` 2. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index c291c08..948f9db 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 0b97566..56f09a6 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ