UNPKG

@nvhien20vn/git-mcp-server

Version:
447 lines (446 loc) • 28.2 kB
/** * Handles the setup and management of the Streamable HTTP MCP transport. * Implements the MCP Specification 2025-03-26 for Streamable HTTP. * Includes Express server creation, middleware (CORS, Auth), request routing * (POST/GET/DELETE on a single endpoint), session handling, SSE streaming, * and port binding with retry logic. * * Specification Reference: * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http */ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; // SDK type guard for InitializeRequest import express from 'express'; import http from 'http'; import { randomUUID } from 'node:crypto'; // Import config and utils import { config } from '../../config/index.js'; // Import the validated config object import { logger } from '../../utils/index.js'; import { mcpAuthMiddleware } from './authentication/authMiddleware.js'; // Import the auth middleware // --- Configuration Constants (Derived from imported config) --- /** * The port number for the HTTP transport, configured via MCP_HTTP_PORT. * Defaults to 3010 (defined in config/index.ts). * @constant {number} HTTP_PORT */ const HTTP_PORT = config.mcpHttpPort; /** * The host address for the HTTP transport, configured via MCP_HTTP_HOST. * Defaults to '127.0.0.1' (defined in config/index.ts). * MCP Spec Security: Recommends binding to localhost for local servers. * @constant {string} HTTP_HOST */ const HTTP_HOST = config.mcpHttpHost; /** * The single HTTP endpoint path for all MCP communication, as required by the spec. * Supports POST, GET, DELETE, OPTIONS methods. * @constant {string} MCP_ENDPOINT_PATH */ const MCP_ENDPOINT_PATH = '/mcp'; /** * Maximum number of attempts to find an available port if the initial HTTP_PORT is in use. * Tries ports sequentially: HTTP_PORT, HTTP_PORT + 1, ... * @constant {number} MAX_PORT_RETRIES */ const MAX_PORT_RETRIES = 15; /** * Stores active StreamableHTTPServerTransport instances, keyed by their session ID. * Essential for routing subsequent requests to the correct stateful session. * @type {Record<string, StreamableHTTPServerTransport>} */ const httpTransports = {}; /** Stores the working directory for each active HTTP session. */ const sessionWorkingDirectories = new Map(); /** * Gets the current working directory set for a specific HTTP session. * @param {string} sessionId - The ID of the session. * @returns {string | undefined} The current working directory path or undefined if not set. */ export function getHttpSessionWorkingDirectory(sessionId) { return sessionWorkingDirectories.get(sessionId); } /** * Sets the working directory for a specific HTTP session. * @param {string} sessionId - The ID of the session. * @param {string} dir - The new working directory path. */ export function setHttpSessionWorkingDirectory(sessionId, dir) { sessionWorkingDirectories.set(sessionId, dir); logger.info(`HTTP session ${sessionId} working directory set to: ${dir}`, { operation: 'setHttpSessionWorkingDirectory', sessionId }); } /** * Checks if an incoming HTTP request's origin header is permissible. * MCP Spec Security: Servers MUST validate the `Origin` header. * This function checks against `MCP_ALLOWED_ORIGINS` and allows requests * from localhost if the server is bound locally. Sets CORS headers if allowed. * * @param {Request} req - Express request object. * @param {Response} res - Express response object. * @returns {boolean} True if the origin is allowed, false otherwise. */ function isOriginAllowed(req, res) { const origin = req.headers.origin; const host = req.hostname; // Considers Host header const isLocalhostBinding = ['127.0.0.1', '::1', 'localhost'].includes(host); const allowedOrigins = config.mcpAllowedOrigins || []; // Use parsed array from config const context = { operation: 'isOriginAllowed', origin, host, isLocalhostBinding, allowedOrigins }; logger.debug('Checking origin allowance', context); // Determine if allowed based on config or localhost binding const allowed = (origin && allowedOrigins.includes(origin)) || (isLocalhostBinding && (!origin || origin === 'null')); if (allowed && origin) { // Origin is allowed and present, set specific CORS headers. res.setHeader('Access-Control-Allow-Origin', origin); // MCP Spec: Streamable HTTP uses POST, GET, DELETE. OPTIONS is for preflight. res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); // MCP Spec: Requires Mcp-Session-Id. Last-Event-ID for SSE resumption. Content-Type is standard. Authorization for security. res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization'); res.setHeader('Access-Control-Allow-Credentials', 'true'); // Set based on whether auth/cookies are used } else if (allowed && !origin) { // Allowed (e.g., localhost binding, file:// origin), but no origin header to echo back. No specific CORS needed. } else if (!allowed && origin) { // Origin provided but not in allowed list. Log warning. logger.warning(`Origin denied: ${origin}`, context); } logger.debug(`Origin check result: ${allowed}`, { ...context, allowed }); return allowed; } /** * Proactively checks if a specific port is already in use. (Asynchronous) * @param {number} port - Port to check. * @param {string} host - Host address to check. * @param {Record<string, any>} context - Logging context. * @returns {Promise<boolean>} True if port is in use (EADDRINUSE), false otherwise. */ async function isPortInUse(port, host, context) { const checkContext = { ...context, 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); // Port is definitely in use } else { logger.debug(`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`, { ...checkContext, errorCode: err.code }); resolve(false); // Other error, let main listen attempt handle it } }) .once('listening', () => { logger.debug(`Proactive check: Port is available.`, checkContext); tempServer.close(() => resolve(false)); // Port is free }) .listen(port, host); }); } /** * Attempts to start the HTTP server, retrying on incrementing ports if EADDRINUSE occurs. (Asynchronous) * Uses proactive checks before attempting to bind the main server instance. * * @param {http.Server} serverInstance - The Node.js HTTP server instance. * @param {number} initialPort - The starting port number. * @param {string} host - The host address to bind to. * @param {number} maxRetries - Maximum number of additional ports to try. * @param {Record<string, any>} context - Logging context. * @returns {Promise<number>} Resolves with the port number successfully bound to. * @throws {Error} Rejects if binding fails after all retries or for non-EADDRINUSE errors. */ function startHttpServerWithRetry(serverInstance, initialPort, host, maxRetries, context) { const startContext = { ...context, 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 = { ...startContext, port: currentPort, attempt: i + 1, maxAttempts: maxRetries + 1 }; logger.debug(`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`, attemptContext); // 1. Proactive Check 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)); // Short delay continue; // Try next port } // 2. Attempt Main Server Bind 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(); // Success }).on('error', (err) => { listenReject(err); // Forward error }); }); resolve(currentPort); // Listen succeeded return; // Exit function } 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)); // Short delay before retry } else { logger.error(`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`, { ...attemptContext, error: err.message }); reject(err); // Non-recoverable error for this port return; // Exit function } } } // Loop finished without success 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 MCP. (Asynchronous) * Creates Express app, configures middleware (CORS, Auth, Security Headers), * defines the single MCP endpoint handler for POST/GET/DELETE, manages sessions, * and starts the HTTP server with retry logic. * * @param {() => Promise<McpServer>} createServerInstanceFn - Async factory function to create a new McpServer instance per session. * @param {Record<string, any>} context - Logging context. * @returns {Promise<void>} Resolves when the server is listening, or rejects on failure. * @throws {Error} If the server fails to start after retries. */ export async function startHttpTransport(createServerInstanceFn, context) { const app = express(); const transportContext = { ...context, transportType: 'HTTP' }; logger.debug('Setting up Express app for HTTP transport...', transportContext); // Middleware to parse JSON request bodies. Required for MCP messages. app.use(express.json()); // --- Security Middleware Pipeline --- // 1. CORS Preflight (OPTIONS) Handler // Handles OPTIONS requests sent by browsers before actual GET/POST/DELETE. app.options(MCP_ENDPOINT_PATH, (req, res) => { const optionsContext = { ...transportContext, operation: 'handleOptions', origin: req.headers.origin }; logger.debug(`Received OPTIONS request for ${MCP_ENDPOINT_PATH}`, optionsContext); if (isOriginAllowed(req, res)) { // isOriginAllowed sets necessary Access-Control-* headers. logger.debug('OPTIONS request origin allowed, sending 204.', optionsContext); res.sendStatus(204); // OK, No Content } else { // isOriginAllowed logs the warning. logger.debug('OPTIONS request origin denied, sending 403.', optionsContext); res.status(403).send('Forbidden: Invalid Origin'); } }); // 2. General Security Headers & Origin Check Middleware (for non-OPTIONS) app.use((req, res, next) => { const securityContext = { ...transportContext, operation: 'securityMiddleware', path: req.path, method: req.method, origin: req.headers.origin }; logger.debug(`Applying security middleware...`, securityContext); // Check origin again for non-OPTIONS requests and set CORS headers if allowed. if (!isOriginAllowed(req, res)) { // isOriginAllowed logs the warning. logger.debug('Origin check failed, sending 403.', securityContext); res.status(403).send('Forbidden: Invalid Origin'); return; // Block request } // Apply standard security headers to all allowed responses. res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); // Basic Content Security Policy (CSP). Adjust if server needs external connections. // 'connect-src 'self'' allows connections back to the server's own origin (needed for SSE). 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'"); // Strict-Transport-Security (HSTS) - IMPORTANT: Enable only if server is *always* served over HTTPS. // if (config.environment === 'production') { // res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); // 1 year // } logger.debug('Security middleware passed.', securityContext); next(); // Proceed to next middleware/handler }); // 3. MCP Authentication Middleware (Optional, based on config) // Verifies Authorization header (e.g., Bearer token) if enabled. app.use(mcpAuthMiddleware); // --- MCP Route Handlers --- // Handle POST requests: Used for Initialize and all subsequent client->server messages. // MCP Spec: Client MUST use POST. Body is single message or batch. // MCP Spec: Server responds 202 for notification/response-only, or JSON/SSE for requests. app.post(MCP_ENDPOINT_PATH, async (req, res) => { // Define base context for this request const basePostContext = { ...transportContext, operation: 'handlePost', method: 'POST' }; logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, { ...basePostContext, headers: req.headers, bodyPreview: JSON.stringify(req.body).substring(0, 100) }); // MCP Spec: Session ID MUST be included by client after initialization. const sessionId = req.headers['mcp-session-id']; // Log extracted session ID, adding it to the context for this specific log message logger.debug(`Extracted session ID: ${sessionId}`, { ...basePostContext, sessionId }); let transport = sessionId ? httpTransports[sessionId] : undefined; // Log transport lookup result, adding sessionId to context logger.debug(`Found existing transport for session ID: ${!!transport}`, { ...basePostContext, sessionId }); // Check if it's an InitializeRequest using SDK helper. const isInitReq = isInitializeRequest(req.body); logger.debug(`Is InitializeRequest: ${isInitReq}`, { ...basePostContext, sessionId }); const requestId = req.body?.id || null; // For potential error responses try { // --- Handle Initialization Request --- if (isInitReq) { if (transport) { // Client sent Initialize on an existing session - likely an error or recovery attempt. // Close the old session cleanly before creating a new one. logger.warning('Received InitializeRequest on an existing session ID. Closing old session and creating new.', { ...basePostContext, sessionId }); await transport.close(); // Ensure cleanup delete httpTransports[sessionId]; } logger.info('Handling Initialize Request: Creating new session...', { ...basePostContext, sessionId }); // Create new SDK transport instance for this session. transport = new StreamableHTTPServerTransport({ // MCP Spec: Server MAY assign session ID on InitializeResponse via Mcp-Session-Id header. sessionIdGenerator: () => { const newId = randomUUID(); // Secure UUID generation logger.debug(`Generated new session ID: ${newId}`, basePostContext); // Use base context here return newId; }, onsessioninitialized: (newId) => { // Store the transport instance once the session ID is confirmed and sent to client. logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId }); httpTransports[newId] = transport; // Store by the generated ID logger.info(`HTTP Session created: ${newId}`, { ...basePostContext, newSessionId: newId }); }, }); // Define cleanup logic when the transport closes (client disconnect, DELETE, error). transport.onclose = () => { const closedSessionId = transport.sessionId; // Get ID before potential deletion // Removed duplicate declaration below if (closedSessionId) { logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId }); delete httpTransports[closedSessionId]; // Remove from active transports sessionWorkingDirectories.delete(closedSessionId); // Clean up working directory state logger.info(`HTTP Session closed and state cleaned: ${closedSessionId}`, { ...basePostContext, closedSessionId }); } else { logger.debug('onclose handler triggered for transport without session ID (likely init failure).', basePostContext); } }; // Create a dedicated McpServer instance for this new session. logger.debug('Creating McpServer instance for new session...', basePostContext); const server = await createServerInstanceFn(); // Connect the server logic to the transport layer. logger.debug('Connecting McpServer to new transport...', basePostContext); await server.connect(transport); logger.debug('McpServer connected to transport.', basePostContext); // NOTE: SDK's connect/handleRequest handles sending the InitializeResult. } else if (!transport) { // --- Handle Non-Initialize Request without Valid Session --- // MCP Spec: Server SHOULD respond 400/404 if session ID is missing/invalid for non-init requests. 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; // Stop processing } // --- Handle Request Content (Initialize or Subsequent Message) --- // Use the extracted sessionId in the context for these logs const currentSessionId = transport.sessionId; // Should be defined here logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq }); // Delegate the actual handling (parsing, routing, response/SSE generation) to the SDK transport instance. // The SDK transport handles returning 202 for notification/response-only POSTs internally. // --- Type modification for req.auth compatibility --- const tempReqPost = req; // Allow modification if (tempReqPost.auth && (typeof tempReqPost.auth === 'string' || (typeof tempReqPost.auth === 'object' && 'devMode' in tempReqPost.auth))) { logger.debug('Sanitizing req.auth for SDK compatibility (POST)', { ...basePostContext, sessionId: currentSessionId, originalAuthType: typeof tempReqPost.auth }); tempReqPost.auth = undefined; } // --- End modification --- await transport.handleRequest(req, res, req.body); logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId }); } catch (err) { // Catch-all for errors during POST handling. // Include sessionId if available in the transport object at this point const errorSessionId = transport?.sessionId || sessionId; // Use extracted or from transport if available logger.error('Error handling POST request', { ...basePostContext, sessionId: errorSessionId, // Add sessionId to error context isInitReq, // Include isInitReq flag error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }); if (!res.headersSent) { // Send generic JSON-RPC error if possible. res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error during POST handling' }, id: requestId }); } // Ensure transport is cleaned up if an error occurred during initialization before session ID assigned. 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 })); } } }); // Unified handler for GET (SSE connection) and DELETE (session termination). const handleSessionReq = async (req, res) => { const method = req.method; // GET or DELETE // Define base context for this request const baseSessionReqContext = { ...transportContext, operation: `handle${method}`, method }; logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, { ...baseSessionReqContext, headers: req.headers }); // MCP Spec: Client MUST include Mcp-Session-Id header (after init). const sessionId = req.headers['mcp-session-id']; // Log extracted session ID, adding it to the context for this specific log message logger.debug(`Extracted session ID: ${sessionId}`, { ...baseSessionReqContext, sessionId }); const transport = sessionId ? httpTransports[sessionId] : undefined; // Log transport lookup result, adding sessionId to context logger.debug(`Found existing transport for session ID: ${!!transport}`, { ...baseSessionReqContext, sessionId }); if (!transport) { // MCP Spec: Server MUST respond 404 if session ID invalid/expired. logger.warning(`Session not found for ${method} request`, { ...baseSessionReqContext, sessionId }); res.status(404).send('Session not found or expired'); return; } try { // Use the extracted sessionId in the context for these logs logger.debug(`Delegating ${method} request to transport for session ${sessionId}...`, { ...baseSessionReqContext, sessionId }); // MCP Spec (GET): Client MAY issue GET to open SSE stream. Server MUST respond text/event-stream or 405. // MCP Spec (GET): Client SHOULD include Last-Event-ID for resumption. Resumption handling depends on SDK transport. // MCP Spec (DELETE): Client SHOULD send DELETE to terminate. Server MAY respond 405 if not supported. // This implementation supports DELETE via the SDK transport's handleRequest. // --- Type modification for req.auth compatibility --- const tempReqSession = req; // Allow modification if (tempReqSession.auth && (typeof tempReqSession.auth === 'string' || (typeof tempReqSession.auth === 'object' && 'devMode' in tempReqSession.auth))) { logger.debug(`Sanitizing req.auth for SDK compatibility (${method})`, { ...baseSessionReqContext, sessionId, originalAuthType: typeof tempReqSession.auth }); tempReqSession.auth = undefined; } // --- End modification --- await transport.handleRequest(req, res); logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId }); // Note: For DELETE, the transport's handleRequest should trigger the 'onclose' handler for cleanup. } catch (err) { // Include sessionId in error context logger.error(`Error handling ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId, // Add sessionId here error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }); if (!res.headersSent) { // Generic error if response hasn't started (e.g., error before SSE connection). res.status(500).send('Internal Server Error'); } // The SDK transport's handleRequest should manage errors occurring *during* an SSE stream. } }; // Route GET and DELETE requests to the unified handler. app.get(MCP_ENDPOINT_PATH, handleSessionReq); app.delete(MCP_ENDPOINT_PATH, handleSessionReq); // --- Start HTTP Server --- logger.debug('Creating HTTP server instance...', transportContext); const serverInstance = http.createServer(app); try { logger.debug('Attempting to start HTTP server with retry logic...', transportContext); // Use configured host and port, with retry logic. const actualPort = await startHttpServerWithRetry(serverInstance, config.mcpHttpPort, config.mcpHttpHost, MAX_PORT_RETRIES, transportContext); // Determine protocol for logging (basic assumption based on HSTS possibility) const protocol = config.environment === 'production' ? 'https' : 'http'; const serverAddress = `${protocol}://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`; // Use logger.notice for startup message to ensure MCP compliance and proper handling by clients. logger.notice(`\nšŸš€ MCP Server running in HTTP mode at: ${serverAddress}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`, transportContext); } catch (err) { logger.fatal('HTTP server failed to start after multiple port retries.', { ...transportContext, error: err instanceof Error ? err.message : String(err) }); throw err; // Propagate error to stop the application } }