UNPKG

atlas-mcp-server

Version:

ATLAS (Adaptive Task & Logic Automation System): An MCP server enabling LLM agents to manage projects, tasks, and knowledge via a Neo4j-backed, three-tier architecture. Facilitates complex workflow automation and project management through LLM Agents.

531 lines (530 loc) • 26.5 kB
/** * @fileoverview Handles the setup and management of the Streamable HTTP MCP transport. * Implements the MCP Specification 2025-03-26 for Streamable HTTP. * This includes creating an Express server, configuring middleware (CORS, Authentication), * defining request routing for the single MCP endpoint (POST/GET/DELETE), * managing server-side sessions, handling Server-Sent Events (SSE) for streaming, * and binding to a network port with retry logic for port conflicts. * * Specification Reference: * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http * @module src/mcp-server/transports/httpTransport */ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import express from "express"; import http from "http"; import { randomUUID } from "node:crypto"; import { config } from "../../config/index.js"; import { BaseErrorCode, McpError } from "../../types/errors.js"; // For McpError type check import { logger, rateLimiter, requestContextService, } from "../../utils/index.js"; import { mcpAuthMiddleware } from "./authentication/authMiddleware.js"; /** * The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable. * Defaults to 3010 if not specified (default is managed by the config module). * @constant {number} HTTP_PORT * @private */ const HTTP_PORT = config.mcpHttpPort; /** * The host address for the HTTP transport, configured via `MCP_HTTP_HOST` environment variable. * Defaults to '127.0.0.1' if not specified (default is managed by the config module). * MCP Spec Security Note: Recommends binding to localhost for local servers to minimize exposure. * @private */ const HTTP_HOST = config.mcpHttpHost; /** * The single HTTP endpoint path for all MCP communication, as required by the MCP specification. * This endpoint supports POST, GET, DELETE, and OPTIONS methods. * @constant {string} MCP_ENDPOINT_PATH * @private */ const MCP_ENDPOINT_PATH = "/mcp"; /** * Maximum number of attempts to find an available port if the initial `HTTP_PORT` is in use. * The server will try ports sequentially: `HTTP_PORT`, `HTTP_PORT + 1`, ..., up to `MAX_PORT_RETRIES`. * @constant {number} MAX_PORT_RETRIES * @private */ const MAX_PORT_RETRIES = 15; /** * Stores active `StreamableHTTPServerTransport` instances from the SDK, keyed by their session ID. * This is essential for routing subsequent HTTP requests (GET, DELETE, non-initialize POST) * to the correct stateful session transport instance. * @type {Record<string, StreamableHTTPServerTransport>} * @private */ const httpTransports = {}; /** * Checks if an incoming HTTP request's `Origin` header is permissible based on configuration. * MCP Spec Security: Servers MUST validate the `Origin` header for cross-origin requests. * This function checks the request's origin against the `config.mcpAllowedOrigins` list. * If the server is bound to localhost, requests from localhost or with no/null origin are also permitted. * Sets appropriate CORS headers (`Access-Control-Allow-Origin`, etc.) if the origin is allowed. * * @param req - The Express request object. * @param res - The Express response object. * @returns True if the origin is allowed, false otherwise. * @private */ function isOriginAllowed(req, res) { const origin = req.headers.origin; // Determine if the server is bound to a localhost interface using the configured HTTP_HOST. const isLocalhostBinding = ["127.0.0.1", "::1", "localhost"].includes(config.mcpHttpHost); const allowedOrigins = config.mcpAllowedOrigins || []; const context = requestContextService.createRequestContext({ operation: "isOriginAllowed", requestOrigin: origin, // Use a more descriptive key for the request's origin serverBindingHost: config.mcpHttpHost, // Log the server's binding host for context isLocalhostBinding, configuredAllowedOrigins: allowedOrigins, // Use a more descriptive key }); logger.debug("Checking origin allowance", context); const allowed = (origin && allowedOrigins.includes(origin)) || // Origin is explicitly in the whitelist (isLocalhostBinding && (!origin || origin === "null")); // Or server is localhost and origin is missing or "null" if (allowed && origin) { if (origin === "null") { // For "null" origin (e.g., file:// URLs on localhost), allow access but explicitly disallow credentials. res.setHeader("Access-Control-Allow-Origin", "null"); res.setHeader("Access-Control-Allow-Credentials", "false"); // Explicitly false for "null" origin logger.debug(`Origin is "null" (and server is localhost-bound). Allowing request without credentials. ACAO: "null", ACAC: "false"`, context); } else { // For any other allowed, non-null origin (i.e., whitelisted), reflect it and allow credentials. res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Credentials", "true"); logger.debug(`Origin '${origin}' is whitelisted. Allowing with credentials. ACAO: ${origin}, ACAC: "true"`, context); } // Common headers for allowed requests (credentials handled above) res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization"); } else if (allowed && !origin && isLocalhostBinding) { // Case: No origin header, but server is localhost-bound (e.g., same-origin, curl). // 'allowed' is true. We can allow credentials. ACAO is not strictly needed for same-origin or non-browser. logger.debug(`No origin header, but request allowed due to localhost binding. Setting Access-Control-Allow-Credentials to true.`, context); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization"); res.setHeader("Access-Control-Allow-Credentials", "true"); } else if (!allowed && origin) { // Origin was present but not allowed by any rule. logger.warning(`Origin denied: ${origin}`, context); } // If !allowed and !origin, no specific CORS headers needed, request proceeds to be potentially denied by other logic or auth. logger.debug(`Origin check result: ${allowed}`, { ...context, allowed }); return allowed; } /** * Proactively checks if a specific network port is already in use. * @param port - The port number to check. * @param host - The host address to check the port on. * @param parentContext - Logging context from the caller. * @returns A promise that resolves to `true` if the port is in use, or `false` otherwise. * @private */ async function isPortInUse(port, host, parentContext) { const checkContext = requestContextService.createRequestContext({ ...parentContext, operation: "isPortInUse", port, host, }); logger.debug(`Proactively checking port usability...`, checkContext); return new Promise((resolve) => { const tempServer = http.createServer(); tempServer .once("error", (err) => { if (err.code === "EADDRINUSE") { logger.debug(`Proactive check: Port confirmed in use (EADDRINUSE).`, checkContext); resolve(true); } else { logger.debug(`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`, { ...checkContext, errorCode: err.code }); resolve(false); } }) .once("listening", () => { logger.debug(`Proactive check: Port is available.`, checkContext); tempServer.close(() => resolve(false)); }) .listen(port, host); }); } /** * Attempts to start the HTTP server, retrying on incrementing ports if `EADDRINUSE` occurs. * * @param serverInstance - The Node.js HTTP server instance. * @param initialPort - The initial port number to try. * @param host - The host address to bind to. * @param maxRetries - Maximum number of additional ports to attempt. * @param parentContext - Logging context from the caller. * @returns A promise that resolves with the port number the server successfully bound to. * @throws {Error} If binding fails after all retries or for a non-EADDRINUSE error. * @private */ function startHttpServerWithRetry(serverInstance, initialPort, host, maxRetries, parentContext) { const startContext = requestContextService.createRequestContext({ ...parentContext, operation: "startHttpServerWithRetry", initialPort, host, maxRetries, }); logger.debug(`Attempting to start HTTP server...`, startContext); return new Promise(async (resolve, reject) => { let lastError = null; for (let i = 0; i <= maxRetries; i++) { const currentPort = initialPort + i; const attemptContext = requestContextService.createRequestContext({ ...startContext, port: currentPort, attempt: i + 1, maxAttempts: maxRetries + 1, }); logger.debug(`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`, attemptContext); if (await isPortInUse(currentPort, host, attemptContext)) { logger.warning(`Proactive check detected port ${currentPort} is in use, retrying...`, attemptContext); lastError = new Error(`EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`); await new Promise((res) => setTimeout(res, 100)); continue; } try { await new Promise((listenResolve, listenReject) => { serverInstance .listen(currentPort, host, () => { const serverAddress = `http://${host}:${currentPort}${MCP_ENDPOINT_PATH}`; logger.info(`HTTP transport successfully listening on host ${host} at ${serverAddress}`, { ...attemptContext, address: serverAddress }); listenResolve(); }) .on("error", (err) => { listenReject(err); }); }); resolve(currentPort); return; } catch (err) { lastError = err; logger.debug(`Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`, { ...attemptContext, errorCode: err.code, errorMessage: err.message }); if (err.code === "EADDRINUSE") { logger.warning(`Port ${currentPort} already in use (EADDRINUSE), retrying...`, attemptContext); await new Promise((res) => setTimeout(res, 100)); } else { logger.error(`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`, { ...attemptContext, error: err.message }); reject(err); return; } } } logger.error(`Failed to bind to any port after ${maxRetries + 1} attempts. Last error: ${lastError?.message}`, { ...startContext, error: lastError?.message }); reject(lastError || new Error("Failed to bind to any port after multiple retries.")); }); } /** * Sets up and starts the Streamable HTTP transport layer for the MCP server. * * @param createServerInstanceFn - An asynchronous factory function that returns a new `McpServer` instance. * @param parentContext - Logging context from the main server startup process. * @returns A promise that resolves with the Node.js `http.Server` instance when the HTTP server is successfully listening. * @throws {Error} If the server fails to start after all port retries. */ export async function startHttpTransport(createServerInstanceFn, parentContext) { const app = express(); const transportContext = requestContextService.createRequestContext({ ...parentContext, transportType: "HTTP", component: "HttpTransportSetup", }); logger.debug("Setting up Express app for HTTP transport...", transportContext); app.use(express.json()); // Rate Limiting Middleware // Apply this before more expensive operations like auth or request processing. const httpRateLimitMiddleware = (req, res, next) => { // Determine a reliable key for rate limiting. Prioritize req.ip, // then fall back to req.socket.remoteAddress, and finally to a default string. const rateLimitKey = req.ip || req.socket.remoteAddress || "unknown_ip_for_rate_limit"; const context = requestContextService.createRequestContext({ operation: "httpRateLimitCheck", ipAddress: rateLimitKey, // Log the actual key being used method: req.method, path: req.path, }); try { rateLimiter.check(rateLimitKey, context); // Use the guaranteed string key logger.debug("Rate limit check passed.", context); next(); } catch (error) { if (error instanceof McpError && error.code === BaseErrorCode.RATE_LIMITED) { logger.warning(`Rate limit exceeded for IP: ${rateLimitKey}`, { // Use rateLimitKey here ...context, errorMessage: error.message, details: error.details, }); res.status(429).json({ jsonrpc: "2.0", error: { code: -32000, message: "Too Many Requests" }, // Generic JSON-RPC error for rate limit id: req.body?.id || null, }); } else { // For other errors, pass them to the default error handler logger.error("Unexpected error in rate limit middleware", { ...context, error: error instanceof Error ? error.message : String(error), }); next(error); } } }; // Apply rate limiter to the MCP endpoint for all methods app.use(MCP_ENDPOINT_PATH, httpRateLimitMiddleware); app.options(MCP_ENDPOINT_PATH, (req, res) => { const optionsContext = requestContextService.createRequestContext({ ...transportContext, operation: "handleOptions", origin: req.headers.origin, method: req.method, path: req.path, }); logger.debug(`Received OPTIONS request for ${MCP_ENDPOINT_PATH}`, optionsContext); if (isOriginAllowed(req, res)) { logger.debug("OPTIONS request origin allowed, sending 204.", optionsContext); res.sendStatus(204); } else { logger.debug("OPTIONS request origin denied, sending 403.", optionsContext); res.status(403).send("Forbidden: Invalid Origin"); } }); app.use((req, res, next) => { const securityContext = requestContextService.createRequestContext({ ...transportContext, operation: "securityMiddleware", path: req.path, method: req.method, origin: req.headers.origin, }); logger.debug(`Applying security middleware...`, securityContext); if (!isOriginAllowed(req, res)) { logger.debug("Origin check failed, sending 403.", securityContext); res.status(403).send("Forbidden: Invalid Origin"); return; } res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; style-src 'self'; img-src 'self'; media-src 'self'; frame-src 'none'; font-src 'self'; connect-src 'self'"); logger.debug("Security middleware passed.", securityContext); next(); }); app.use(mcpAuthMiddleware); app.post(MCP_ENDPOINT_PATH, async (req, res) => { const basePostContext = requestContextService.createRequestContext({ ...transportContext, operation: "handlePost", method: "POST", path: req.path, origin: req.headers.origin, }); logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, { ...basePostContext, headers: req.headers, bodyPreview: JSON.stringify(req.body).substring(0, 100), }); const sessionId = req.headers["mcp-session-id"]; logger.debug(`Extracted session ID: ${sessionId}`, { ...basePostContext, sessionId, }); let transport = sessionId ? httpTransports[sessionId] : undefined; logger.debug(`Found existing transport for session ID: ${!!transport}`, { ...basePostContext, sessionId, }); const isInitReq = isInitializeRequest(req.body); logger.debug(`Is InitializeRequest: ${isInitReq}`, { ...basePostContext, sessionId, }); const requestId = req.body?.id || null; try { if (isInitReq) { if (transport) { logger.warning("Received InitializeRequest on an existing session ID. Closing old session and creating new.", { ...basePostContext, sessionId }); await transport.close(); delete httpTransports[sessionId]; } logger.info("Handling Initialize Request: Creating new session...", { ...basePostContext, sessionId, }); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => { const newId = randomUUID(); logger.debug(`Generated new session ID: ${newId}`, basePostContext); return newId; }, onsessioninitialized: (newId) => { logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId }); httpTransports[newId] = transport; logger.info(`HTTP Session created: ${newId}`, { ...basePostContext, newSessionId: newId, }); }, }); transport.onclose = () => { const closedSessionId = transport.sessionId; if (closedSessionId) { logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId }); delete httpTransports[closedSessionId]; logger.info(`HTTP Session closed: ${closedSessionId}`, { ...basePostContext, closedSessionId, }); } else { logger.debug("onclose handler triggered for transport without session ID (likely init failure).", basePostContext); } }; logger.debug("Creating McpServer instance for new session...", basePostContext); const server = await createServerInstanceFn(); logger.debug("Connecting McpServer to new transport...", basePostContext); await server.connect(transport); logger.debug("McpServer connected to transport.", basePostContext); } else if (!transport) { logger.warning("Invalid or missing session ID for non-initialize POST request.", { ...basePostContext, sessionId }); res.status(404).json({ jsonrpc: "2.0", error: { code: -32004, message: "Invalid or expired session ID" }, id: requestId, }); return; } const currentSessionId = transport.sessionId; logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq }); await transport.handleRequest(req, res, req.body); logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId }); } catch (err) { const errorSessionId = transport?.sessionId || sessionId; logger.error("Error handling POST request", { ...basePostContext, sessionId: errorSessionId, isInitReq, error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error during POST handling", }, id: requestId, }); } if (isInitReq && transport && !transport.sessionId) { logger.debug("Cleaning up transport after initialization failure.", { ...basePostContext, sessionId: errorSessionId, }); await transport.close().catch((closeErr) => logger.error("Error closing transport after init failure", { ...basePostContext, sessionId: errorSessionId, closeError: closeErr, })); } } }); const handleSessionReq = async (req, res) => { const method = req.method; const baseSessionReqContext = requestContextService.createRequestContext({ ...transportContext, operation: `handle${method}`, method, path: req.path, origin: req.headers.origin, }); logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, { ...baseSessionReqContext, headers: req.headers, }); const sessionId = req.headers["mcp-session-id"]; logger.debug(`Extracted session ID: ${sessionId}`, { ...baseSessionReqContext, sessionId, }); const transport = sessionId ? httpTransports[sessionId] : undefined; logger.debug(`Found existing transport for session ID: ${!!transport}`, { ...baseSessionReqContext, sessionId, }); if (!transport) { logger.warning(`Session not found for ${method} request`, { ...baseSessionReqContext, sessionId, }); res.status(404).json({ jsonrpc: "2.0", error: { code: -32004, message: "Session not found or expired" }, id: null, // Or a relevant request identifier if available from context }); return; } try { logger.debug(`Delegating ${method} request to transport for session ${sessionId}...`, { ...baseSessionReqContext, sessionId }); await transport.handleRequest(req, res); logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId }); } catch (err) { logger.error(`Error handling ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId, error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal Server Error" }, id: null, // Or a relevant request identifier }); } } }; app.get(MCP_ENDPOINT_PATH, handleSessionReq); app.delete(MCP_ENDPOINT_PATH, handleSessionReq); logger.debug("Creating HTTP server instance...", transportContext); const serverInstance = http.createServer(app); try { logger.debug("Attempting to start HTTP server with retry logic...", transportContext); const actualPort = await startHttpServerWithRetry(serverInstance, config.mcpHttpPort, config.mcpHttpHost, MAX_PORT_RETRIES, transportContext); let serverAddressLog = `http://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`; let productionNote = ""; if (config.environment === "production") { // The server itself runs HTTP, but it's expected to be behind an HTTPS proxy in production. // The log reflects the effective public-facing URL. serverAddressLog = `https://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`; productionNote = ` (via HTTPS, ensure reverse proxy is configured)`; } if (process.stdout.isTTY) { console.log(`\nšŸš€ MCP Server running in HTTP mode at: ${serverAddressLog}${productionNote}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`); } return serverInstance; // Return the created server instance } catch (err) { logger.fatal("HTTP server failed to start after multiple port retries.", { ...transportContext, error: err instanceof Error ? err.message : String(err), }); throw err; // Re-throw the error to be caught by the caller } }