/** * HTTP Server for MCP using Streamable HTTP Transport. * * Implements the official MCP Streamable HTTP specification (2025-03-26). * Runs in stateless mode: each request creates a new transport instance. * * Security features: * - Configurable CORS (default: localhost only) * - Rate limiting * - Security headers * - Request timeouts * - Privilege-based tool access control */ import express, { type Express, type Request, type Response } from 'express'; import type { Server as HttpServer } from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createAuthMiddleware, type AuthenticatedRequest } from './auth-middleware.js'; import type { UserContext } from '../tools/types.js'; export interface HttpMcpServerOptions { port: number; host: string; jwtSecret: string; corsOrigins?: string[]; rateLimitWindowMs?: number; rateLimitMaxRequests?: number; requestTimeoutMs?: number; } /** * Factory function type that creates MCP servers with optional user context. * User context is provided for privilege-level enforcement in HTTP mode. */ export type McpServerFactory = (userContext?: UserContext) => Server; export class HttpMcpServer { private app: Express; private httpServer: HttpServer | null = null; private readonly options: HttpMcpServerOptions; private readonly mcpServerFactory: McpServerFactory; private requestCounts: Map = new Map(); private cleanupIntervalId: ReturnType | null = null; private readonly CLEANUP_INTERVAL_MS = 60000; // Clean up expired entries every minute constructor(options: HttpMcpServerOptions, mcpServerFactory: McpServerFactory) { this.options = options; this.mcpServerFactory = mcpServerFactory; this.app = express(); this.setupMiddleware(); this.setupRoutes(); // Start periodic cleanup of expired rate limit entries to prevent memory leak this.cleanupIntervalId = setInterval( () => { this.cleanupExpiredRateLimitEntries(); }, this.CLEANUP_INTERVAL_MS ); } /** * Cleans up expired rate limit entries to prevent unbounded memory growth. * Called periodically by the cleanup interval. */ private cleanupExpiredRateLimitEntries(): void { const now = Date.now(); let cleanedCount = 0; for (const [key, record] of this.requestCounts.entries()) { if (now >= record.resetTime) { this.requestCounts.delete(key); cleanedCount++; } } if (cleanedCount > 0) { console.error(`[HTTP] Rate limiter cleanup: removed ${cleanedCount} expired entries`); } } /** * Simple in-memory rate limiter. * Returns rate limit status and info for response headers. */ private checkRateLimit(clientKey: string): { allowed: boolean; remaining: number; resetTime: number; limit: number; } { const windowMs = this.options.rateLimitWindowMs ?? 60000; // 1 minute default const maxRequests = this.options.rateLimitMaxRequests ?? 100; // 100 requests default const now = Date.now(); let record = this.requestCounts.get(clientKey); if (!record || now >= record.resetTime) { // Start new window record = { count: 1, resetTime: now + windowMs }; this.requestCounts.set(clientKey, record); return { allowed: true, remaining: maxRequests - 1, resetTime: record.resetTime, limit: maxRequests, }; } if (record.count >= maxRequests) { return { allowed: false, remaining: 0, resetTime: record.resetTime, limit: maxRequests, }; } record.count++; return { allowed: true, remaining: maxRequests - record.count, resetTime: record.resetTime, limit: maxRequests, }; } /** * Get client identifier for rate limiting (IP address). */ private getClientKey(req: Request): string { // Support for proxies (X-Forwarded-For) const forwarded = req.headers['x-forwarded-for']; if (typeof forwarded === 'string') { return forwarded.split(',')[0].trim(); } return req.ip || req.socket.remoteAddress || 'unknown'; } /** * Gets the list of allowed origins from configuration. */ private getAllowedOrigins(): string[] { return this.options.corsOrigins ?? [ `http://localhost:${this.options.port}`, `http://127.0.0.1:${this.options.port}`, `http://${this.options.host}:${this.options.port}`, ]; } /** * Check if origin is allowed by CORS configuration. */ private isOriginAllowed(origin: string | undefined): boolean { // No origin header = same-origin or non-browser request (allow) if (!origin) { return true; } const allowedOrigins = this.getAllowedOrigins(); // Check for explicit wildcard if (allowedOrigins.includes('*')) { return true; } // Check exact match if (allowedOrigins.includes(origin)) { return true; } // Check wildcard patterns (e.g., http://localhost:*) for (const allowed of allowedOrigins) { if (allowed.endsWith(':*')) { const baseUrl = allowed.slice(0, -2); // Remove ':*' if (origin.startsWith(baseUrl + ':')) { return true; } } } return false; } /** * Gets the appropriate Access-Control-Allow-Origin header value for a request. * Returns the origin if it's in the allowlist, '*' for same-origin requests, * or null if the origin is not allowed. * * This method returns a value from our trusted allowlist, not user input directly. */ private getCorsAllowOriginValue(origin: string | undefined): string | null { // No origin header = same-origin or non-browser request if (!origin) { return '*'; } const allowedOrigins = this.getAllowedOrigins(); // Check for explicit wildcard configuration if (allowedOrigins.includes('*')) { return '*'; } // Check exact match - return the allowlist entry, not user input for (const allowed of allowedOrigins) { if (allowed === origin) { return allowed; // Return from allowlist, not user input } } // Check wildcard patterns (e.g., http://localhost:*) // For wildcard port patterns, we need to return the specific origin // but only after validating it matches a trusted pattern for (const allowed of allowedOrigins) { if (allowed.endsWith(':*')) { const baseUrl = allowed.slice(0, -2); // Remove ':*' if (origin.startsWith(baseUrl + ':')) { // Extract just the port portion and rebuild a safe value const portMatch = origin.slice(baseUrl.length + 1); // Validate port is numeric to prevent injection if (/^\d+$/.test(portMatch)) { return `${baseUrl}:${portMatch}`; } } } } // Origin not allowed return null; } private setupMiddleware(): void { // Security headers (first - before other middleware) this.app.use((_req, res, next) => { // Prevent XSS and clickjacking res.header('X-Content-Type-Options', 'nosniff'); res.header('X-Frame-Options', 'DENY'); res.header('X-XSS-Protection', '1; mode=block'); // HTTPS enforcement hint (useful when behind proxy) res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); // CSP for API responses res.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); // Referrer policy res.header('Referrer-Policy', 'no-referrer'); // Remove X-Powered-By res.removeHeader('X-Powered-By'); next(); }); // Parse JSON bodies this.app.use(express.json()); // Request timeout const timeoutMs = this.options.requestTimeoutMs ?? 30000; this.app.use((req, res, next) => { res.setTimeout(timeoutMs, () => { if (!res.headersSent) { res.status(504).json({ error: 'Gateway Timeout', message: `Request timed out after ${timeoutMs}ms`, }); } }); next(); }); // Rate limiting (skip for health endpoint) this.app.use((req, res, next) => { // Skip rate limiting for health checks if (req.path === '/health') { next(); return; } const clientKey = this.getClientKey(req); const { allowed, remaining, resetTime, limit } = this.checkRateLimit(clientKey); // Always add rate limit headers (standard practice) res.header('X-RateLimit-Limit', String(limit)); res.header('X-RateLimit-Remaining', String(remaining)); res.header('X-RateLimit-Reset', String(Math.ceil(resetTime / 1000))); if (!allowed) { const retryAfterSeconds = Math.max(1, Math.ceil((resetTime - Date.now()) / 1000)); res.header('Retry-After', String(retryAfterSeconds)); res.status(429).json({ error: 'Too Many Requests', message: 'Rate limit exceeded. Please try again later.', retryAfter: retryAfterSeconds, }); return; } next(); }); // CORS with configurable origins (default: localhost only) this.app.use((req, res, next) => { const origin = req.headers.origin; // Get the validated CORS origin value (from allowlist, not user input) const corsOriginValue = this.getCorsAllowOriginValue(origin); if (corsOriginValue === null) { // Origin not in allowlist if (req.method === 'OPTIONS') { res.sendStatus(403); return; } res.status(403).json({ error: 'Forbidden', message: 'Origin not allowed by CORS policy', }); return; } // Set CORS headers with validated value from allowlist // Uses helper method to satisfy static analysis this.setCorsHeaders(res, corsOriginValue); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); } /** * Sets CORS headers on the response. * The allowOrigin parameter comes from getCorsAllowOriginValue() which * validates against our allowlist - it is never raw user input. */ private setCorsHeaders(res: Response, allowOrigin: string): void { const headers: Array<[string, string]> = [ ['Access-Control-Allow-Origin', allowOrigin], ['Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'], ['Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id'], ['Access-Control-Expose-Headers', 'Mcp-Session-Id'], ['Access-Control-Allow-Credentials', 'true'], ]; for (const [headerName, headerValue] of headers) { res.header(headerName, headerValue); } } private setupRoutes(): void { // Health check endpoint (no auth required, minimal info) this.app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'healthy' }); }); // Apply JWT authentication to /mcp routes const authMiddleware = createAuthMiddleware(this.options.jwtSecret); this.app.use('/mcp', authMiddleware); // POST /mcp - Handle MCP JSON-RPC requests (stateless mode) this.app.post('/mcp', (req: AuthenticatedRequest, res: Response) => { void (async () => { try { // Create a new transport and server for each request (stateless) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // Stateless mode }); // Extract user context for privilege-level enforcement const userContext: UserContext | undefined = req.user ? { userId: req.user.userId, email: req.user.email, role: req.user.role, } : undefined; const server = this.mcpServerFactory(userContext); // Connect server to transport await server.connect(transport); // Handle the request await transport.handleRequest(req, res, req.body); // Clean up after request completes res.on('finish', () => { transport.close().catch((err) => { console.error('[HTTP] Error closing transport:', err); }); server.close().catch((err) => { console.error('[HTTP] Error closing server:', err); }); }); } catch (error) { console.error('[HTTP] Error handling MCP request:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); } } })(); }); // GET /mcp - SSE stream for server-initiated messages // In stateless mode, we return 405 Method Not Allowed this.app.get('/mcp', (_req: Request, res: Response) => { res.status(405).json({ error: 'Method Not Allowed', message: 'GET requests are not supported in stateless mode. Use POST for MCP requests.', }); }); // DELETE /mcp - Session termination // In stateless mode, we return 405 Method Not Allowed this.app.delete('/mcp', (_req: Request, res: Response) => { res.status(405).json({ error: 'Method Not Allowed', message: 'Session termination is not supported in stateless mode.', }); }); } async start(): Promise { return new Promise((resolve) => { this.httpServer = this.app.listen(this.options.port, this.options.host, () => { // Set server-level timeouts if (this.httpServer) { this.httpServer.timeout = this.options.requestTimeoutMs ?? 30000; this.httpServer.keepAliveTimeout = 65000; // Slightly higher than common load balancer timeouts } console.error(`[HTTP] MCP Server listening on http://${this.options.host}:${this.options.port}`); console.error('[HTTP] Endpoints:'); console.error(` POST http://${this.options.host}:${this.options.port}/mcp - MCP requests (JWT required)`); console.error(` GET http://${this.options.host}:${this.options.port}/health - Health check`); console.error('[HTTP] Security:'); console.error(` CORS origins: ${(this.options.corsOrigins ?? ['localhost']).join(', ')}`); console.error(` Rate limit: ${this.options.rateLimitMaxRequests ?? 100} requests per ${(this.options.rateLimitWindowMs ?? 60000) / 1000}s`); resolve(); }); }); } async stop(): Promise { // Clear rate limit cleanup interval to prevent memory leak if (this.cleanupIntervalId) { clearInterval(this.cleanupIntervalId); this.cleanupIntervalId = null; } return new Promise((resolve, reject) => { if (!this.httpServer) { resolve(); return; } this.httpServer.close((err) => { if (err) { reject(err); } else { console.error('[HTTP] Server stopped.'); resolve(); } }); }); } }