feat(mcp): ready for a remote server: quiet logs and allowed domains
- Logs default to warnings and errors (MCP_LOG_LEVEL): the library logged every tool on every request (~48 lines per call). - DNS rebinding protection on /mcp, as the MCP spec asks of HTTP servers: the Host header must be in MCP_ALLOWED_HOSTS (default: localhost; compose default mcp.taskstream.pireon.dev,localhost) and a browser Origin, when present, too; otherwise 403 with a JSON-RPC error. Uses the official validators from @modelcontextprotocol/server. - Test for allowed/foreign Host and Origin; .env.example, compose and docs/09-mcp.md document the variables; Word/PDF regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
# 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-)
|
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
NEXUS_TOKEN=
|
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
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { McpModule } from '@nestjs-mcp/server';
|
import { McpModule } from '@nestjs-mcp/server';
|
||||||
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
||||||
import { IssuesResolver } from './tools/issues.resolver';
|
import { IssuesResolver } from './tools/issues.resolver';
|
||||||
import { ProjectsResolver } from './tools/projects.resolver';
|
import { ProjectsResolver } from './tools/projects.resolver';
|
||||||
import { ReleasesResolver } from './tools/releases.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',
|
name: 'taskstream',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
instructions: INSTRUCTIONS,
|
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,
|
UsersResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
consumer.apply(McpHostsMiddleware).forRoutes('mcp');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
46
apps/mcp/src/app/mcp-hosts.middleware.ts
Normal file
46
apps/mcp/src/app/mcp-hosts.middleware.ts
Normal file
@@ -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<string>('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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
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 { AddressInfo } from 'node:net';
|
||||||
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@@ -211,4 +216,50 @@ describe('TaskStream MCP', () => {
|
|||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
expect(received).toHaveLength(0);
|
expect(received).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
|
||||||
|
function rawPost(headers: Record<string, string>): Promise<number> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ services:
|
|||||||
aliases: [task-stream-mcp]
|
aliases: [task-stream-mcp]
|
||||||
environment:
|
environment:
|
||||||
TASKSTREAM_API_URL: http://task-stream-api:3000
|
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:
|
depends_on:
|
||||||
api: { condition: service_healthy }
|
api: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
- **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
|
- **Respuestas compactas**: los listados devuelven lo que el modelo necesita (clave, título, estado, persona…); el
|
||||||
detalle completo, con `get_issue` o `get_project`.
|
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)
|
npx nx run mcp:openapi-types # regenerar los tipos tras cambiar la API (apps/api/openapi.json)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Variable | Por defecto | Qué es |
|
| 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) |
|
| `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`) |
|
| `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` |
|
| `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
|
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
|
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`) →
|
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.
|
que es lo que pide `@nestjs-mcp/server` 2.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user