/** * 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) // Minimal blank diagram used to bootstrap new sessions. // This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent. const DEFAULT_DIAGRAM_XML = `` // Normalize URL for iframe src - ensure no double slashes function normalizeUrl(url: string): string { // Remove trailing slash to avoid double slashes return url.replace(/\/$/, "") } function isLikelyMcpSessionId(sessionId: string): boolean { // Keep this cheap and conservative to avoid creating state for arbitrary IDs. return sessionId.startsWith("mcp-") && sessionId.length <= 128 } // Find the most recent active session (for auto-redirect when no sessionId provided) function getMostRecentSessionId(): string | null { let mostRecent: { id: string; lastUpdated: Date } | null = null for (const [sessionId, state] of stateStore) { if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) { mostRecent = { id: sessionId, lastUpdated: state.lastUpdated } } } return mostRecent?.id || null } function ensureSessionStateInitialized(sessionId: string): void { if (!sessionId) return if (!isLikelyMcpSessionId(sessionId)) return if (stateStore.has(sessionId)) return setState(sessionId, DEFAULT_DIAGRAM_XML) } 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 exportFormat?: "png" | "svg" // Set by MCP tool to request browser export exportData?: string // Base64/SVG data returned by browser after export } 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 exportFormat: existing?.exportFormat, // Preserve pending export request exportData: existing?.exportData, // Preserve export result }) 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, "127.0.0.1", () => { 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}`) const requestOrigin = req.headers.origin if (requestOrigin === `http://localhost:${serverPort}`) { res.setHeader("Access-Control-Allow-Origin", requestOrigin) 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") { const sessionId = url.searchParams.get("mcp") || "" // Auto-redirect to most recent session if no sessionId provided if (!sessionId) { const recentSessionId = getMostRecentSessionId() if (recentSessionId) { res.writeHead(302, { Location: `/?mcp=${recentSessionId}` }) res.end() return } } ensureSessionStateInitialized(sessionId) res.writeHead(200, { "Content-Type": "text/html" }) res.end(getHtmlPage(sessionId)) } 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 } ensureSessionStateInitialized(sessionId) 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, exportFormat: state?.exportFormat || null, }), ) } else if (req.method === "POST") { let body = "" req.on("data", (chunk) => { body += chunk }) req.on("end", () => { try { const data = JSON.parse(body) const { sessionId } = data if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } // Browser is returning export data (png/svg) if (data.exportData !== undefined) { const state = stateStore.get(sessionId) if (state) { state.exportData = data.exportData state.exportFormat = undefined log.debug( `Export data received for session=${sessionId}`, ) } res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true })) return } const version = setState(sessionId, data.xml, data.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 ` Next AI Draw.io Next AI Draw.io ${sessionId ? `${sessionId.slice(-8)}` : ""} History Download History No history yet Download Diagram Format Draw.io (.drawio) PNG Image (.png) SVG Vector (.svg) Filename .drawio ` }