/** * Embedded HTTP Server for MCP * Serves draw.io embed with state sync and history UI */ import http from "node:http" import { addHistory, clearHistory, getHistory, getHistoryEntry, updateLastHistorySvg, } from "./history.js" import { log } from "./logger.js" // Configurable draw.io embed URL for private deployments const DRAWIO_BASE_URL = process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net" // Extract origin (scheme + host + port) from URL for postMessage security check function getOrigin(url: string): string { try { const parsed = new URL(url) return `${parsed.protocol}//${parsed.host}` } catch { return url // Fallback if parsing fails } } const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL) // Normalize URL for iframe src - ensure no double slashes function normalizeUrl(url: string): string { // Remove trailing slash to avoid double slashes return url.replace(/\/$/, "") } interface SessionState { xml: string version: number lastUpdated: Date svg?: string // Cached SVG from last browser save syncRequested?: number // Timestamp when sync requested, cleared when browser responds } export const stateStore = new Map() let server: http.Server | null = null let serverPort = 6002 const MAX_PORT = 6020 const SESSION_TTL = 60 * 60 * 1000 export function getState(sessionId: string): SessionState | undefined { return stateStore.get(sessionId) } export function setState(sessionId: string, xml: string, svg?: string): number { const existing = stateStore.get(sessionId) const newVersion = (existing?.version || 0) + 1 stateStore.set(sessionId, { xml, version: newVersion, lastUpdated: new Date(), svg: svg || existing?.svg, // Preserve cached SVG if not provided syncRequested: undefined, // Clear sync request when browser pushes state }) log.debug(`State updated: session=${sessionId}, version=${newVersion}`) return newVersion } export function requestSync(sessionId: string): boolean { const state = stateStore.get(sessionId) if (state) { state.syncRequested = Date.now() log.debug(`Sync requested for session=${sessionId}`) return true } log.debug(`Sync requested for non-existent session=${sessionId}`) return false } export async function waitForSync( sessionId: string, timeoutMs = 3000, ): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { const state = stateStore.get(sessionId) if (!state?.syncRequested) return true // Sync completed await new Promise((r) => setTimeout(r, 100)) } log.warn(`Sync timeout for session=${sessionId}`) return false // Timeout } export function startHttpServer(port = 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(`HTTP server running on http://localhost:${port}`) resolve(port) }) }) } export function stopHttpServer(): void { if (server) { server.close() server = null } } function cleanupExpiredSessions(): void { const now = Date.now() for (const [sessionId, state] of stateStore) { if (now - state.lastUpdated.getTime() > SESSION_TTL) { stateStore.delete(sessionId) clearHistory(sessionId) log.info(`Cleaned up expired session: ${sessionId}`) } } } const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000) export function shutdown(): void { clearInterval(cleanupIntervalId) stopHttpServer() } export function getServerPort(): number { return serverPort } function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, ): void { const url = new URL(req.url || "/", `http://localhost:${serverPort}`) 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 } if (url.pathname === "/" || url.pathname === "/index.html") { res.writeHead(200, { "Content-Type": "text/html" }) res.end(getHtmlPage(url.searchParams.get("mcp") || "")) } else if (url.pathname === "/api/state") { handleStateApi(req, res, url) } else if (url.pathname === "/api/history") { handleHistoryApi(req, res, url) } else if (url.pathname === "/api/restore") { handleRestoreApi(req, res) } else if (url.pathname === "/api/history-svg") { handleHistorySvgApi(req, res) } else { res.writeHead(404) res.end("Not Found") } } 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, syncRequested: !!state?.syncRequested, }), ) } else if (req.method === "POST") { let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const { sessionId, xml, svg } = 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, svg) 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") } } function handleHistoryApi( req: http.IncomingMessage, res: http.ServerResponse, url: URL, ): void { if (req.method !== "GET") { res.writeHead(405) res.end("Method Not Allowed") return } const sessionId = url.searchParams.get("sessionId") if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } const history = getHistory(sessionId) res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ entries: history.map((entry, i) => ({ index: i, svg: entry.svg })), count: history.length, }), ) } function handleRestoreApi( req: http.IncomingMessage, res: http.ServerResponse, ): void { if (req.method !== "POST") { res.writeHead(405) res.end("Method Not Allowed") return } let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const { sessionId, index } = JSON.parse(body) if (!sessionId || index === undefined) { res.writeHead(400, { "Content-Type": "application/json" }) res.end( JSON.stringify({ error: "sessionId and index required" }), ) return } const entry = getHistoryEntry(sessionId, index) if (!entry) { res.writeHead(404, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Entry not found" })) return } const newVersion = setState(sessionId, entry.xml) addHistory(sessionId, entry.xml, entry.svg) log.info(`Restored session ${sessionId} to index ${index}`) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, newVersion })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) } }) } function handleHistorySvgApi( req: http.IncomingMessage, res: http.ServerResponse, ): void { if (req.method !== "POST") { res.writeHead(405) res.end("Method Not Allowed") return } let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const { sessionId, svg } = JSON.parse(body) if (!sessionId || !svg) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId and svg required" })) return } updateLastHistorySvg(sessionId, svg) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) } }) } function getHtmlPage(sessionId: string): string { return ` Draw.io MCP
` }