UNPKG

@smartbear/mcp

Version:

MCP server for interacting SmartBear Products

378 lines (377 loc) 15 kB
import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { clientRegistry } from "./client-registry.js"; import { SmartBearMcpServer } from "./server.js"; /** * Run server in HTTP mode with Streamable HTTP transport * Supports both SSE (legacy) and StreamableHTTP transports for backwards compatibility */ export async function runHttpMode() { const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [ "http://localhost:3000", ]; // Store transports by session ID const transports = new Map(); // Get dynamic list of allowed headers from registered clients const allowedAuthHeaders = getHttpHeaders(); const allowedHeaders = [ "Content-Type", "Authorization", "MCP-Session-Id", // Required for StreamableHTTP "x-custom-auth-headers", // used by mcp-inspector ...allowedAuthHeaders, ].join(", "); const httpServer = createServer(async (req, res) => { // Enable CORS const origin = req.headers.origin || ""; if (allowedOrigins.includes(origin)) { res.setHeader("Access-Control-Allow-Origin", origin); } res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", allowedHeaders); res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id"); if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; } const url = new URL(req.url || "/", `http://${req.headers.host}`); // HEALTH CHECK ENDPOINT if (req.method === "GET" && url.pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", timestamp: new Date().toISOString() })); return; } // STREAMABLE HTTP ENDPOINT (modern, preferred) if (url.pathname === "/mcp") { await handleStreamableHttpRequest(req, res, transports); return; } // LEGACY SSE ENDPOINT (for backwards compatibility) if (req.method === "GET" && url.pathname === "/sse") { await handleLegacySseRequest(req, res, transports); return; } if (req.method === "POST" && url.pathname === "/message") { await handleLegacyMessageRequest(req, res, url, transports); return; } res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); }); httpServer.listen(PORT, () => { console.log(`[MCP HTTP Server] Listening on http://localhost:${PORT}`); console.log(`[MCP HTTP Server] Health check: http://localhost:${PORT}/health`); console.log(`[MCP HTTP Server] Modern endpoint: http://localhost:${PORT}/mcp (Streamable HTTP)`); console.log(`[MCP HTTP Server] Legacy endpoint: http://localhost:${PORT}/sse (SSE)`); const headerHelp = getHttpHeadersHelp(); if (headerHelp.length > 0) { console.log(`[MCP HTTP Server] Send configuration headers:\n${headerHelp.join("\n")}`); } else { console.warn(`[MCP HTTP Server] No clients support HTTP header configuration`); } }); } /** * Parse request body for POST requests * Reads the request stream and parses it as JSON * @returns Parsed JSON object or undefined if not a POST request or parsing fails */ async function parseRequestBody(req) { if (req.method !== "POST") { return undefined; } let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); return new Promise((resolve) => { req.on("end", () => { try { resolve(JSON.parse(body)); } catch (error) { console.error("Error parsing request body:", error); resolve(undefined); } }); }); } /** * Get existing transport for session or return error response * Validates that the session exists and uses StreamableHTTP transport * @returns StreamableHTTPServerTransport if valid, null otherwise (with error response sent) */ function getExistingTransport(sessionId, transports, res) { const existing = transports.get(sessionId); if (existing && existing.transport instanceof StreamableHTTPServerTransport) { return existing.transport; } // Session doesn't exist or is using a different transport (e.g., SSE) res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: Session exists but uses a different transport protocol", }, id: null, })); return null; } /** * Create new transport for initialize request * Sets up a new MCP server instance with configuration from HTTP headers, * creates a StreamableHTTP transport, and registers session lifecycle handlers * @returns StreamableHTTPServerTransport if successful, null if server initialization fails */ async function createNewTransport(req, res, transports) { // Create and configure server with headers from the request const server = await newServer(req, res); if (!server) { return null; } // Create transport with session management const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { console.log(`[MCP] New session initialized: ${newSessionId}`); // Store session so subsequent requests can find it transports.set(newSessionId, { server, transport }); }, }); // Clean up session on close transport.onclose = () => { if (transport.sessionId) { console.log(`[MCP] Session closed: ${transport.sessionId}`); transports.delete(transport.sessionId); } }; // Connect server to transport to start handling messages await server.connect(transport); return transport; } /** * Handle modern Streamable HTTP requests * This is the main endpoint (/mcp) for the modern MCP StreamableHTTP transport. * * Request flow: * 1. First request (initialize): No session ID, body contains initialize request * - Creates new server + transport, generates session ID * 2. Subsequent requests: Include MCP-Session-Id header * - Routes to existing transport for the session */ async function handleStreamableHttpRequest(req, res, transports) { try { const sessionId = req.headers["mcp-session-id"]; const parsedBody = await parseRequestBody(req); let transport; // Case 1: Existing session - route to existing transport if (sessionId && transports.has(sessionId)) { const existingTransport = getExistingTransport(sessionId, transports, res); if (!existingTransport) return; transport = existingTransport; } // Case 2: New session - must be an initialize request else if (!sessionId && req.method === "POST" && parsedBody && isInitializeRequest(parsedBody)) { const newTransport = await createNewTransport(req, res, transports); if (!newTransport) return; transport = newTransport; } // Case 3: Invalid request else { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: Invalid request", }, id: null, })); return; } // Delegate to transport to handle the MCP protocol message await transport.handleRequest(req, res, parsedBody); } catch (error) { console.error("Error handling StreamableHTTP request:", error); res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Internal server error"); } } /** * Handle legacy SSE connection requests (GET /sse) * * SSE (Server-Sent Events) transport maintains a long-lived connection * for server-to-client messages, with a separate POST endpoint for client-to-server. * * This is kept for backwards compatibility with older MCP clients. * New integrations should use the modern StreamableHTTP transport (/mcp). */ async function handleLegacySseRequest(req, res, transports) { // Create a new server instance for this connection const server = await newServer(req, res); if (!server) { return; } // SSE transport keeps the connection open and sends events to the client const transport = new SSEServerTransport("/message", res); // Store the session so POST /message requests can find it transports.set(transport.sessionId, { server, transport }); // Clean up session when connection closes res.on("close", () => { transports.delete(transport.sessionId); }); // Connect server to transport (this also starts the transport automatically) await server.connect(transport); } /** * Handle legacy POST message requests (POST /message?sessionId=xxx) * * This endpoint is part of the legacy SSE transport, handling client-to-server messages. * The SSE transport uses: * - GET /sse: Server-to-client events (long-lived connection) * - POST /message: Client-to-server messages (individual requests) * * New integrations should use the modern StreamableHTTP transport (/mcp). */ async function handleLegacyMessageRequest(req, res, url, transports) { // Extract session ID from query parameter const sessionId = url.searchParams.get("sessionId"); if (!sessionId) { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Missing sessionId parameter"); return; } // Find the session created by the SSE connection const session = transports.get(sessionId); if (!session) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Session not found"); return; } // Validate this session is using SSE transport if (!(session.transport instanceof SSEServerTransport)) { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Invalid transport for this endpoint"); return; } // Read and parse the request body let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", async () => { try { const parsedBody = JSON.parse(body); // Route message to the SSE transport for processing await session.transport.handlePostMessage(req, res, parsedBody); } catch (error) { console.error("Error handling POST message:", error); res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Internal server error"); } }); } /** * Create a new MCP server instance with configuration from HTTP headers * * Configuration is read from HTTP headers in the format: * {ClientPrefix}-{Field-Name} (e.g., Bugsnag-Auth-Token, Reflect-Api-Token) * * The ClientRegistry validates the configuration and initializes enabled clients. * If configuration fails, an error response is sent and null is returned. * * @returns SmartBearMcpServer instance if successful, null if configuration fails */ async function newServer(req, res) { const server = new SmartBearMcpServer(); try { // Configure server with values from HTTP headers await clientRegistry.configure(server, (client, key) => { const headerName = getHeaderName(client, key); // Check both original case and lower-case headers for compatibility // (HTTP headers are case-insensitive, but Node.js lowercases them) const value = req.headers[headerName] || req.headers[headerName.toLowerCase()]; if (typeof value === "string") { return value; } return null; }); } catch (error) { // Configuration failed - provide helpful error message const headerHelp = getHttpHeadersHelp(); const errorMessage = headerHelp.length > 0 ? `Configuration error: ${error instanceof Error ? error.message : String(error)}. Please provide valid headers:\n${headerHelp.join("\n")}` : "No clients support HTTP header configuration."; res.writeHead(401, { "Content-Type": "text/plain" }); res.end(errorMessage); return null; } return server; } /** * Convert a config key to HTTP header name format * * Examples: * - auth_token -> Auth-Token * - project_api_key -> Project-Api-Key * - base_url -> Base-Url * * Combined with configPrefix: Bugsnag-Auth-Token, Reflect-Api-Token, etc. * * @param client The client instance (provides configPrefix) * @param key The config key in snake_case * @returns Header name in format: {ConfigPrefix}-{Pascal-Kebab-Case} */ function getHeaderName(client, key) { return `${client.configPrefix}-${key .split("_") .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join("-")}`; } /** * Get all HTTP headers that clients support for authentication * Returns a list of header names (in kebab-case) that should be allowed */ function getHttpHeaders() { const headers = new Set(); // Use getAll() to respect MCP_ENABLED_CLIENTS filtering for (const entry of clientRegistry.getAll()) { for (const configKey of Object.keys(entry.config.shape)) { headers.add(getHeaderName(entry, configKey)); } } return Array.from(headers).sort((a, b) => a.localeCompare(b)); } /** * Get human-readable list of HTTP headers for logging/error messages * Organized by client */ function getHttpHeadersHelp() { const messages = []; for (const entry of clientRegistry.getAll()) { messages.push(` - ${entry.name}:`); for (const [configKey, requirement] of Object.entries(entry.config.shape)) { const headerName = getHeaderName(entry, configKey); const requiredTag = requirement.isOptional() ? " (optional)" : " (required)"; messages.push(` - ${headerName}${requiredTag}: ${requirement.description}`); } } return messages; }