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:
2026-10-07 19:19:51 +02:00
parent 9236cb73c6
commit 80d9bee6e0
8 changed files with 131 additions and 10 deletions

View 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;
}
}