UNPKG

@cyanheads/git-mcp-server

Version:

An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management,

555 lines (554 loc) • 29.7 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 } }