UNPKG

@rhofkens/mcp-quotes-server

Version:

A Model Context Protocol (MCP) server that provides quotes based on user requests

500 lines 19.2 kB
import express from "express"; import cors from "cors"; import https from "https"; import fs from "fs"; import { randomUUID } from "crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { logger } from "../utils/logger.js"; import { HttpTransportErrorType, } from "../types/http-transport-types.js"; export class HttpTransportService { constructor(config, mcpServerFactory, options = {}) { this.sessions = new Map(); this.startTime = new Date(); this.totalSessionsCreated = 0; this.totalSessionsTerminated = 0; this.isRunning = false; this.config = config; this.mcpServerFactory = mcpServerFactory; this.options = { jsonLimit: "50mb", enableLogging: true, sessionTimeout: 30 * 60 * 1000, ...options, }; this.validateConfig(); this.app = this.createExpressApp(); this.setupRoutes(); this.setupSessionCleanup(); logger.info("HTTP Transport Service initialized", { host: config.host, port: config.port, httpsEnabled: config.https.enabled, allowedHosts: config.security.allowedHosts, allowedOrigins: config.security.allowedOrigins, }); } validateConfig() { if (!this.config.port || this.config.port < 1 || this.config.port > 65535) { throw new Error("Invalid HTTP server configuration: port must be between 1 and 65535"); } if (!this.config.host) { throw new Error("Invalid HTTP server configuration: host is required"); } if (this.config.https.enabled) { if (!this.config.https.certPath || !this.config.https.keyPath) { throw new Error("Invalid HTTP server configuration: HTTPS requires certPath and keyPath"); } this.validateHttpsCertificates(); } } createExpressApp() { const app = express(); app.use(express.json({ limit: this.options.jsonLimit })); app.use(this.createDnsRebindingProtection()); const corsOptions = this.createCorsOptions(); app.use(cors(corsOptions)); if (this.options.enableLogging) { app.use(this.createRequestLoggingMiddleware()); } app.use(this.createGlobalErrorHandler()); return app; } createCorsOptions() { const { allowedOrigins } = this.config.security; return { origin: allowedOrigins.includes("*") ? true : allowedOrigins, exposedHeaders: ["mcp-session-id"], allowedHeaders: ["Content-Type", "mcp-session-id"], credentials: true, }; } createDnsRebindingProtection() { const { allowedHosts } = this.config.security; return (req, res, next) => { const host = req.get("Host"); if (!host) { logger.warn("Request rejected: Missing Host header"); res.status(400).json({ error: "Bad Request", message: "Host header is required", }); return; } const hostname = host.split(":")[0]; if (hostname && !allowedHosts.includes(hostname) && !allowedHosts.includes("*")) { logger.warn("Request rejected: Invalid host", { host: hostname, allowedHosts, }); res.status(403).json({ error: "Forbidden", message: "Host not allowed", }); return; } next(); }; } validateHttpsCertificates() { if (!this.config.https.enabled) return; const { certPath, keyPath } = this.config.https; try { if (!fs.existsSync(certPath)) { throw new Error(`HTTPS certificate file not found: ${certPath}`); } if (!fs.existsSync(keyPath)) { throw new Error(`HTTPS private key file not found: ${keyPath}`); } const cert = fs.readFileSync(certPath, "utf8"); const key = fs.readFileSync(keyPath, "utf8"); if (!cert.includes("-----BEGIN CERTIFICATE-----")) { throw new Error(`Invalid certificate file format: ${certPath}`); } if (!key.includes("-----BEGIN") || !key.includes("PRIVATE KEY-----")) { throw new Error(`Invalid private key file format: ${keyPath}`); } logger.info("HTTPS certificates validated successfully"); } catch (error) { logger.error("HTTPS certificate validation failed", { error }); throw error; } } createRequestLoggingMiddleware() { return (req, res, next) => { const startTime = Date.now(); const requestId = randomUUID(); res.setHeader("x-request-id", requestId); logger.info("HTTP request started", { requestId, method: req.method, url: req.url, userAgent: req.get("User-Agent"), sessionId: req.headers["mcp-session-id"], ip: req.ip, }); res.on("finish", () => { const duration = Date.now() - startTime; logger.info("HTTP request completed", { requestId, method: req.method, url: req.url, statusCode: res.statusCode, duration, sessionId: req.headers["mcp-session-id"], }); }); next(); }; } createGlobalErrorHandler() { return (error, req, res, next) => { const requestId = res.get("x-request-id") || "unknown"; logger.error("Unhandled HTTP error", { requestId, error: error.message, stack: error.stack, method: req.method, url: req.url, sessionId: req.headers["mcp-session-id"], }); if (res.headersSent) { return next(error); } this.sendError(res, { type: HttpTransportErrorType.TRANSPORT_INIT_ERROR, statusCode: 500, message: "Internal Server Error", context: { requestId, error: error.message, }, }); }; } setupRoutes() { this.app.post("/mcp", this.handleMcpPost.bind(this)); this.app.get("/mcp", this.handleMcpGet.bind(this)); this.app.delete("/mcp", this.handleMcpDelete.bind(this)); this.app.get("/health", this.handleHealthCheck.bind(this)); this.app.get("/sessions", this.handleSessionsEndpoint.bind(this)); } async handleMcpPost(req, res) { try { const sessionId = req.headers["mcp-session-id"]; let transport; if (sessionId && this.sessions.has(sessionId)) { const session = this.sessions.get(sessionId); transport = session.transport; session.lastActivity = new Date(); logger.debug("Reusing existing session", { sessionId }); } else if (!sessionId && isInitializeRequest(req.body)) { transport = await this.createNewSession(); logger.debug("Created new session for initialization", { sessionId: transport.sessionId, }); } else { this.sendError(res, { type: HttpTransportErrorType.SESSION_NOT_FOUND, statusCode: 400, message: "Bad Request: No valid session ID provided", }); return; } await transport.handleRequest(req, res, req.body); } catch (error) { logger.error("Error handling MCP POST request", { error }); this.sendError(res, { type: HttpTransportErrorType.TRANSPORT_INIT_ERROR, statusCode: 500, message: "Internal server error", context: { error: error instanceof Error ? error.message : String(error), }, }); } } async handleMcpGet(req, res) { try { const sessionId = req.headers["mcp-session-id"]; if (!sessionId || !this.sessions.has(sessionId)) { this.sendError(res, { type: HttpTransportErrorType.SESSION_NOT_FOUND, statusCode: 400, message: "Invalid or missing session ID", }); return; } const session = this.sessions.get(sessionId); session.lastActivity = new Date(); await session.transport.handleRequest(req, res); logger.debug("Handled SSE request", { sessionId }); } catch (error) { logger.error("Error handling MCP GET request", { error }); this.sendError(res, { type: HttpTransportErrorType.TRANSPORT_INIT_ERROR, statusCode: 500, message: "Internal server error", context: { error: error instanceof Error ? error.message : String(error), }, }); } } async handleMcpDelete(req, res) { try { const sessionId = req.headers["mcp-session-id"]; if (!sessionId || !this.sessions.has(sessionId)) { this.sendError(res, { type: HttpTransportErrorType.SESSION_NOT_FOUND, statusCode: 400, message: "Invalid or missing session ID", }); return; } const session = this.sessions.get(sessionId); await session.transport.handleRequest(req, res); this.terminateSession(sessionId); logger.info("Session terminated", { sessionId }); } catch (error) { logger.error("Error handling MCP DELETE request", { error }); this.sendError(res, { type: HttpTransportErrorType.TRANSPORT_INIT_ERROR, statusCode: 500, message: "Internal server error", context: { error: error instanceof Error ? error.message : String(error), }, }); } } handleHealthCheck(req, res) { const stats = this.getStats(); res.json({ status: "healthy", timestamp: new Date().toISOString(), ...stats, }); } handleSessionsEndpoint(req, res) { const sessionStats = this.getSessionStats(); res.json(sessionStats); } async createNewSession() { const mcpServer = this.mcpServerFactory(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { const session = { sessionId, transport, createdAt: new Date(), lastActivity: new Date(), }; this.sessions.set(sessionId, session); this.totalSessionsCreated++; logger.debug("Session initialized and stored", { sessionId }); }, }); transport.onclose = () => { if (transport.sessionId) { this.terminateSession(transport.sessionId); } }; await mcpServer.connect(transport); return transport; } terminateSession(sessionId) { const session = this.sessions.get(sessionId); if (session) { session.transport.close(); this.sessions.delete(sessionId); this.totalSessionsTerminated++; logger.debug("Session cleaned up", { sessionId, activeSessions: this.sessions.size, }); } } setupSessionCleanup() { const cleanupInterval = Math.min(this.options.sessionTimeout / 4, 5 * 60 * 1000); this.cleanupInterval = setInterval(() => { const now = new Date(); const expiredSessions = []; for (const [sessionId, session] of this.sessions.entries()) { const timeSinceLastActivity = now.getTime() - session.lastActivity.getTime(); if (timeSinceLastActivity > this.options.sessionTimeout) { expiredSessions.push(sessionId); } } expiredSessions.forEach((sessionId) => { logger.info("Session expired, cleaning up", { sessionId }); this.terminateSession(sessionId); }); if (expiredSessions.length > 0) { logger.debug("Session cleanup completed", { expiredSessions: expiredSessions.length, activeSessions: this.sessions.size, }); } }, cleanupInterval); } sendError(res, error) { if (!res.headersSent) { res.status(error.statusCode || 500).json({ jsonrpc: "2.0", error: { code: -32000, message: error.message || "Internal server error", data: error.context, }, id: null, }); } } getSessionStats() { const now = new Date(); let totalDuration = 0; let sessionsWithDuration = 0; for (const session of this.sessions.values()) { const duration = now.getTime() - session.createdAt.getTime(); totalDuration += duration; sessionsWithDuration++; } return { activeSessions: this.sessions.size, totalSessionsCreated: this.totalSessionsCreated, totalSessionsTerminated: this.totalSessionsTerminated, averageSessionDuration: sessionsWithDuration > 0 ? totalDuration / sessionsWithDuration : 0, }; } getStats() { return { isRunning: this.isRunning, activeSessions: this.sessions.size, totalSessionsCreated: this.totalSessionsCreated, totalSessionsTerminated: this.totalSessionsTerminated, uptime: this.isRunning ? Date.now() - this.startTime.getTime() : 0, port: this.config.port, host: this.config.host, https: this.config.https.enabled, }; } async start() { if (this.isRunning) { throw new Error("HTTP transport service is already running"); } return new Promise((resolve, reject) => { try { if (this.config.https.enabled) { this.startHttpsServer(resolve, reject); } else { this.startHttpServer(resolve, reject); } } catch (error) { logger.error("Failed to start HTTP server", { error }); reject(error); } }); } startHttpServer(resolve, reject) { this.server = this.app.listen(this.config.port, this.config.host, () => { this.isRunning = true; this.startTime = new Date(); logger.info("HTTP transport service started", { host: this.config.host, port: this.config.port, protocol: "http", }); resolve(); }); this.server.on("error", (error) => { logger.error("HTTP server error", { error }); reject(error); }); } startHttpsServer(resolve, reject) { try { this.validateHttpsConfig(); const httpsOptions = { cert: fs.readFileSync(this.config.https.certPath), key: fs.readFileSync(this.config.https.keyPath), }; this.server = https.createServer(httpsOptions, this.app); this.server.listen(this.config.port, this.config.host, () => { this.isRunning = true; this.startTime = new Date(); logger.info("HTTPS transport service started", { host: this.config.host, port: this.config.port, protocol: "https", certPath: this.config.https.certPath, }); resolve(); }); this.server.on("error", (error) => { logger.error("HTTPS server error", { error }); reject(error); }); } catch (error) { logger.error("HTTPS server startup failed", { error }); reject(error instanceof Error ? error : new Error(String(error))); } } validateHttpsConfig() { if (!this.config.https.certPath || !this.config.https.keyPath) { throw new Error("HTTPS requires both certPath and keyPath"); } try { fs.accessSync(this.config.https.certPath, fs.constants.R_OK); fs.accessSync(this.config.https.keyPath, fs.constants.R_OK); } catch (error) { throw new Error(`HTTPS certificate files not accessible: ${error}`); } } async stop() { return new Promise((resolve) => { if (!this.server || !this.isRunning) { logger.warn("Attempted to stop HTTP transport service that is not running"); resolve(); return; } if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } for (const [sessionId, session] of this.sessions.entries()) { try { session.transport.close(); this.sessions.delete(sessionId); this.totalSessionsTerminated++; } catch (error) { logger.warn("Error closing session during shutdown", { sessionId, error, }); } } this.server.close(() => { this.isRunning = false; logger.info("HTTP transport service stopped"); resolve(); }); }); } } //# sourceMappingURL=http-transport-service.js.map