/** * Embedded HTTP Server for MCP * * Serves a static HTML page with draw.io embed and handles state sync. * This eliminates the need for an external Next.js app. */ import http from "node:http" import { log } from "./logger.js" interface SessionState { xml: string version: number lastUpdated: Date } // In-memory state store (shared with MCP server in same process) export const stateStore = new Map() let server: http.Server | null = null let serverPort: number = 6002 const MAX_PORT = 6020 // Don't retry beyond this port const SESSION_TTL = 60 * 60 * 1000 // 1 hour /** * Get state for a session */ export function getState(sessionId: string): SessionState | undefined { return stateStore.get(sessionId) } /** * Set state for a session */ export function setState(sessionId: string, xml: string): number { const existing = stateStore.get(sessionId) const newVersion = (existing?.version || 0) + 1 stateStore.set(sessionId, { xml, version: newVersion, lastUpdated: new Date(), }) log.debug(`State updated: session=${sessionId}, version=${newVersion}`) return newVersion } /** * Start the embedded HTTP server */ export function startHttpServer(port: number = 6002): Promise { return new Promise((resolve, reject) => { if (server) { resolve(serverPort) return } serverPort = port server = http.createServer(handleRequest) server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { if (port >= MAX_PORT) { reject( new Error( `No available ports in range 6002-${MAX_PORT}`, ), ) return } log.info(`Port ${port} in use, trying ${port + 1}`) server = null startHttpServer(port + 1) .then(resolve) .catch(reject) } else { reject(err) } }) server.listen(port, () => { serverPort = port log.info(`Embedded HTTP server running on http://localhost:${port}`) resolve(port) }) }) } /** * Stop the HTTP server */ export function stopHttpServer(): void { if (server) { server.close() server = null } } /** * Clean up expired sessions */ function cleanupExpiredSessions(): void { const now = Date.now() for (const [sessionId, state] of stateStore) { if (now - state.lastUpdated.getTime() > SESSION_TTL) { stateStore.delete(sessionId) log.info(`Cleaned up expired session: ${sessionId}`) } } } // Run cleanup every 5 minutes setInterval(cleanupExpiredSessions, 5 * 60 * 1000) /** * Get the current server port */ export function getServerPort(): number { return serverPort } /** * Handle HTTP requests */ function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, ): void { const url = new URL(req.url || "/", `http://localhost:${serverPort}`) // CORS headers for local development res.setHeader("Access-Control-Allow-Origin", "*") res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") res.setHeader("Access-Control-Allow-Headers", "Content-Type") if (req.method === "OPTIONS") { res.writeHead(204) res.end() return } // Route handling if (url.pathname === "/" || url.pathname === "/index.html") { serveHtml(req, res, url) } else if ( url.pathname === "/api/state" || url.pathname === "/api/mcp/state" ) { handleStateApi(req, res, url) } else if ( url.pathname === "/api/health" || url.pathname === "/api/mcp/health" ) { res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ status: "ok", mcp: true })) } else { res.writeHead(404) res.end("Not Found") } } /** * Serve the HTML page with draw.io embed */ function serveHtml( req: http.IncomingMessage, res: http.ServerResponse, url: URL, ): void { const sessionId = url.searchParams.get("mcp") || "" res.writeHead(200, { "Content-Type": "text/html" }) res.end(getHtmlPage(sessionId)) } /** * Handle state API requests */ function handleStateApi( req: http.IncomingMessage, res: http.ServerResponse, url: URL, ): void { if (req.method === "GET") { const sessionId = url.searchParams.get("sessionId") if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } const state = stateStore.get(sessionId) res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ xml: state?.xml || null, version: state?.version || 0, lastUpdated: state?.lastUpdated?.toISOString() || null, }), ) } else if (req.method === "POST") { let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const { sessionId, xml } = JSON.parse(body) if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } const version = setState(sessionId, xml) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, version })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) } }) } else { res.writeHead(405) res.end("Method Not Allowed") } } /** * Generate the HTML page with draw.io embed */ function getHtmlPage(sessionId: string): string { return ` Draw.io MCP - ${sessionId || "No Session"}
` }