diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 89bea94..12701f9 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -86,6 +86,7 @@ Use the standard MCP configuration with: ## Features - **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them +- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Edit Support**: Modify existing diagrams with natural language instructions - **Export**: Save diagrams as `.drawio` files diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 255d883..039ce16 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.1.3", + "version": "0.1.4", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts new file mode 100644 index 0000000..492eda1 --- /dev/null +++ b/packages/mcp-server/src/history.ts @@ -0,0 +1,62 @@ +/** + * Simple diagram history - matches Next.js app pattern + * Stores {xml, svg} entries in a circular buffer + */ + +import { log } from "./logger.js" + +const MAX_HISTORY = 20 +const historyStore = new Map>() + +export function addHistory(sessionId: string, xml: string, svg = ""): number { + let history = historyStore.get(sessionId) + if (!history) { + history = [] + historyStore.set(sessionId, history) + } + + // Dedupe: skip if same as last entry + const last = history[history.length - 1] + if (last?.xml === xml) { + return history.length - 1 + } + + history.push({ xml, svg }) + + // Circular buffer + if (history.length > MAX_HISTORY) { + history.shift() + } + + log.debug(`History: session=${sessionId}, entries=${history.length}`) + return history.length - 1 +} + +export function getHistory( + sessionId: string, +): Array<{ xml: string; svg: string }> { + return historyStore.get(sessionId) || [] +} + +export function getHistoryEntry( + sessionId: string, + index: number, +): { xml: string; svg: string } | undefined { + const history = historyStore.get(sessionId) + return history?.[index] +} + +export function clearHistory(sessionId: string): void { + historyStore.delete(sessionId) +} + +export function updateLastHistorySvg(sessionId: string, svg: string): boolean { + const history = historyStore.get(sessionId) + if (!history || history.length === 0) return false + const last = history[history.length - 1] + if (!last.svg) { + last.svg = svg + return true + } + return false +} diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 3b64d9b..27a2e7a 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -1,55 +1,50 @@ /** * 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. + * 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" interface SessionState { xml: string version: number lastUpdated: Date + svg?: string // Cached SVG from last browser save } -// 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 +let serverPort = 6002 +const MAX_PORT = 6020 +const SESSION_TTL = 60 * 60 * 1000 -/** - * 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 { +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 }) - log.debug(`State updated: session=${sessionId}, version=${newVersion}`) return newVersion } -/** - * Start the embedded HTTP server - */ -export function startHttpServer(port: number = 6002): Promise { +export function startHttpServer(port = 6002): Promise { return new Promise((resolve, reject) => { if (server) { resolve(serverPort) @@ -81,15 +76,12 @@ export function startHttpServer(port: number = 6002): Promise { server.listen(port, () => { serverPort = port - log.info(`Embedded HTTP server running on http://localhost:${port}`) + log.info(`HTTP server running on http://localhost:${port}`) resolve(port) }) }) } -/** - * Stop the HTTP server - */ export function stopHttpServer(): void { if (server) { server.close() @@ -97,39 +89,29 @@ export function stopHttpServer(): void { } } -/** - * 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) + clearHistory(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") @@ -140,43 +122,23 @@ function handleRequest( 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" - ) { + 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/health" || - url.pathname === "/api/mcp/health" - ) { - res.writeHead(200, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ status: "ok", mcp: true })) + } 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") } } -/** - * 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, @@ -189,14 +151,12 @@ function handleStateApi( 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") { @@ -206,14 +166,13 @@ function handleStateApi( }) req.on("end", () => { try { - const { sessionId, xml } = JSON.parse(body) + 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) + const version = setState(sessionId, xml, svg) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, version })) } catch { @@ -227,35 +186,179 @@ function handleStateApi( } } -/** - * Generate the HTML page with draw.io embed - */ +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 - ${sessionId || "No Session"} + Draw.io MCP @@ -263,121 +366,176 @@ function getHtmlPage(sessionId: string): string { - + +
+ +
` diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index d0e049a..ffb3912 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -34,12 +34,8 @@ import { applyDiagramOperations, type DiagramOperation, } from "./diagram-operations.js" -import { - getServerPort, - getState, - setState, - startHttpServer, -} from "./http-server.js" +import { addHistory } from "./history.js" +import { getState, setState, startHttpServer } from "./http-server.js" import { log } from "./logger.js" import { validateAndFixXml } from "./xml-validation.js" @@ -197,6 +193,21 @@ server.registerTool( log.info(`Displaying diagram, ${xml.length} chars`) + // Sync from browser state first + const browserState = getState(currentSession.id) + if (browserState?.xml) { + currentSession.xml = browserState.xml + } + + // Save user's state before AI overwrites (with cached SVG) + if (currentSession.xml) { + addHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) + } + // Update session state currentSession.xml = xml currentSession.version++ @@ -204,6 +215,9 @@ server.registerTool( // Push to embedded server state setState(currentSession.id, xml) + // Save AI result (no SVG yet - will be captured by browser) + addHistory(currentSession.id, xml, "") + log.info(`Diagram displayed successfully`) return { @@ -295,6 +309,13 @@ server.registerTool( log.info(`Editing diagram with ${operations.length} operation(s)`) + // Save before editing (with cached SVG from browser) + addHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) + // Validate and auto-fix new_xml for each operation const validatedOps = operations.map((op) => { if (op.new_xml) { @@ -336,6 +357,9 @@ server.registerTool( // Push to embedded server setState(currentSession.id, result) + // Save AI result (no SVG yet - will be captured by browser) + addHistory(currentSession.id, result, "") + log.info(`Diagram edited successfully`) const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`