diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 6070ad7..7f4d0ac 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -1,169 +1,51 @@ /** - * Diagram Version History for MCP Server - * - * Stores diagram versions in-memory per session. - * Enables users and AI to restore previous diagram states. + * Simple diagram history - matches Next.js app pattern + * Stores {xml, svg} entries in a circular buffer */ import { log } from "./logger.js" -export interface HistoryEntry { - version: number - xml: string - svg: string // SVG data for thumbnail preview - source: "ai" | "human" - tool?: string // Which tool made the change (display_diagram, edit_diagram, browser_sync) - timestamp: Date - description?: string -} +const MAX_HISTORY = 20 +const historyStore = new Map>() -interface SessionHistory { - entries: HistoryEntry[] - nextVersion: number -} - -// In-memory history store keyed by session ID -const historyStore = new Map() - -// Configuration -const MAX_HISTORY_ENTRIES = 50 - -/** - * Add a new entry to session history - * Returns the assigned version number - */ -export function addHistoryEntry( - sessionId: string, - entry: Omit, -): number { +export function addHistory(sessionId: string, xml: string, svg = ""): number { let history = historyStore.get(sessionId) if (!history) { - history = { entries: [], nextVersion: 1 } + history = [] historyStore.set(sessionId, history) } - // Deduplicate: skip if XML is identical to last entry - const lastEntry = history.entries[history.entries.length - 1] - if (lastEntry && lastEntry.xml === entry.xml) { - log.debug(`Skipping duplicate history entry for session ${sessionId}`) - return lastEntry.version + // Dedupe: skip if same as last entry + const last = history[history.length - 1] + if (last?.xml === xml) { + return history.length - 1 } - const version = history.nextVersion++ - const newEntry: HistoryEntry = { - ...entry, - version, + history.push({ xml, svg }) + + // Circular buffer + if (history.length > MAX_HISTORY) { + history.shift() } - history.entries.push(newEntry) - - // Prune oldest entries if over limit (circular buffer) - if (history.entries.length > MAX_HISTORY_ENTRIES) { - const removed = history.entries.shift() - log.debug(`Pruned oldest history entry v${removed?.version}`) - } - - log.info( - `Added history v${version} for session ${sessionId} (source: ${entry.source}, entries: ${history.entries.length})`, - ) - - return version + log.debug(`History: session=${sessionId}, entries=${history.length}`) + return history.length - 1 } -/** - * Get history entries for a session - * Returns newest first, limited to specified count - */ export function getHistory( sessionId: string, - limit: number = 20, -): HistoryEntry[] { - const history = historyStore.get(sessionId) - if (!history) { - return [] - } - - // Return newest first - return [...history.entries].reverse().slice(0, limit) +): Array<{ xml: string; svg: string }> { + return historyStore.get(sessionId) || [] } -/** - * Get a specific version from history - */ -export function getVersion( +export function getHistoryEntry( sessionId: string, - version: number, -): HistoryEntry | undefined { + index: number, +): { xml: string; svg: string } | undefined { const history = historyStore.get(sessionId) - if (!history) { - return undefined - } - - return history.entries.find((e) => e.version === version) + return history?.[index] } -/** - * Get the latest version number for a session - */ -export function getLatestVersion(sessionId: string): number { - const history = historyStore.get(sessionId) - if (!history || history.entries.length === 0) { - return 0 - } - return history.entries[history.entries.length - 1].version -} - -/** - * Clear history for a session (used on session expiry) - */ export function clearHistory(sessionId: string): void { - if (historyStore.has(sessionId)) { - historyStore.delete(sessionId) - log.info(`Cleared history for session ${sessionId}`) - } -} - -/** - * Update the SVG of the latest entry (or specific version) that has empty SVG - * Used when browser generates SVG after loading AI diagram - */ -export function updateLatestEntrySvg( - sessionId: string, - svg: string, - targetVersion?: number, -): boolean { - const history = historyStore.get(sessionId) - if (!history || history.entries.length === 0) { - return false - } - - // Find entry to update - either specific version or latest without SVG - let entry: HistoryEntry | undefined - if (targetVersion !== undefined) { - entry = history.entries.find((e) => e.version === targetVersion) - } else { - // Find most recent entry without SVG - for (let i = history.entries.length - 1; i >= 0; i--) { - if (!history.entries[i].svg) { - entry = history.entries[i] - break - } - } - } - - if (entry && !entry.svg) { - entry.svg = svg - log.debug(`Updated SVG for history v${entry.version}`) - return true - } - - return false -} - -/** - * Get count of history entries for a session - */ -export function getHistoryCount(sessionId: string): number { - const history = historyStore.get(sessionId) - return history?.entries.length || 0 + historyStore.delete(sessionId) } diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 05a338f..21d9e31 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -1,17 +1,14 @@ /** * 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 { - addHistoryEntry, + addHistory, clearHistory, getHistory, - getVersion, - updateLatestEntrySvg, + getHistoryEntry, } from "./history.js" import { log } from "./logger.js" @@ -21,42 +18,30 @@ interface SessionState { 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 +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 { 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 { +export function startHttpServer(port = 6002): Promise { return new Promise((resolve, reject) => { if (server) { resolve(serverPort) @@ -88,15 +73,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() @@ -104,40 +86,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) // Also clean up history + 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") @@ -148,58 +119,21 @@ 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/history" || - url.pathname === "/api/mcp/history" - ) { + } else if (url.pathname === "/api/history") { handleHistoryApi(req, res, url) - } else if ( - url.pathname === "/api/restore" || - url.pathname === "/api/mcp/restore" - ) { - handleRestoreApi(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/update-svg" || - url.pathname === "/api/mcp/update-svg" - ) { - handleUpdateSvgApi(req, res) + } else if (url.pathname === "/api/restore") { + handleRestoreApi(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, @@ -212,14 +146,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") { @@ -229,28 +161,13 @@ function handleStateApi( }) req.on("end", () => { try { - const { sessionId, xml, svg } = JSON.parse(body) + const { sessionId, xml } = JSON.parse(body) if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId required" })) return } - - // Update state const version = setState(sessionId, xml) - - // Save to history when browser sends SVG (human edits) - if (svg) { - addHistoryEntry(sessionId, { - xml, - svg, - source: "human", - tool: "browser_sync", - timestamp: new Date(), - description: "Manual edit", - }) - } - res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, version })) } catch { @@ -264,9 +181,6 @@ function handleStateApi( } } -/** - * Handle history API requests - */ function handleHistoryApi( req: http.IncomingMessage, res: http.ServerResponse, @@ -285,71 +199,19 @@ function handleHistoryApi( return } - const limit = parseInt(url.searchParams.get("limit") || "20") - const history = getHistory(sessionId, limit) - + const history = getHistory(sessionId) res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ - entries: history.map((entry) => ({ - version: entry.version, - source: entry.source, - tool: entry.tool, - timestamp: entry.timestamp.toISOString(), - description: entry.description, - svg: entry.svg, - // Don't include full XML in list - use get_version for that - })), + entries: history.map((entry, i) => ({ index: i, svg: entry.svg })), count: history.length, }), ) } -/** - * Handle update-svg API requests (browser sends SVG after loading AI diagram) - */ -function handleUpdateSvgApi( - 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, version } = JSON.parse(body) - if (!sessionId || !svg) { - res.writeHead(400, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "sessionId and svg required" })) - return - } - - // Update the latest AI entry's SVG - const updated = updateLatestEntrySvg(sessionId, svg, version) - - res.writeHead(200, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ success: true, updated })) - } catch { - res.writeHead(400, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "Invalid JSON" })) - } - }) -} - -/** - * Handle restore API requests - */ function handleRestoreApi( req: http.IncomingMessage, res: http.ServerResponse, - url: URL, ): void { if (req.method !== "POST") { res.writeHead(405) @@ -363,47 +225,29 @@ function handleRestoreApi( }) req.on("end", () => { try { - const { sessionId, version } = JSON.parse(body) - if (!sessionId || version === undefined) { + const { sessionId, index } = JSON.parse(body) + if (!sessionId || index === undefined) { res.writeHead(400, { "Content-Type": "application/json" }) res.end( - JSON.stringify({ error: "sessionId and version required" }), + JSON.stringify({ error: "sessionId and index required" }), ) return } - const entry = getVersion(sessionId, version) + const entry = getHistoryEntry(sessionId, index) if (!entry) { res.writeHead(404, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "Version not found" })) + res.end(JSON.stringify({ error: "Entry not found" })) return } - // Restore by setting state (this will trigger browser poll to load it) const newVersion = setState(sessionId, entry.xml) + addHistory(sessionId, entry.xml, entry.svg) - // Add history entry for the restore action - addHistoryEntry(sessionId, { - xml: entry.xml, - svg: entry.svg, - source: "human", - tool: "restore", - timestamp: new Date(), - description: `Restored from v${version}`, - }) - - log.info( - `Restored session ${sessionId} to v${version}, new version: ${newVersion}`, - ) + log.info(`Restored session ${sessionId} to index ${index}`) res.writeHead(200, { "Content-Type": "application/json" }) - res.end( - JSON.stringify({ - success: true, - restoredFrom: version, - newVersion, - }), - ) + res.end(JSON.stringify({ success: true, newVersion })) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) @@ -411,192 +255,72 @@ function handleRestoreApi( }) } -/** - * Generate the HTML page with draw.io embed - */ function getHtmlPage(sessionId: string): string { return ` - Draw.io MCP - ${sessionId || "No Session"} + Draw.io MCP @@ -604,381 +328,149 @@ function getHtmlPage(sessionId: string): string { - - - - -
- ` diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 8f1dcaf..94b05c1 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -4,23 +4,16 @@ * * Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit * draw.io diagrams with real-time browser preview. - * - * Uses an embedded HTTP server - no external dependencies required. */ // Setup DOM polyfill for Node.js (required for XML operations) import { DOMParser } from "linkedom" ;(globalThis as any).DOMParser = DOMParser -// Create XMLSerializer polyfill using outerHTML class XMLSerializerPolyfill { serializeToString(node: any): string { - if (node.outerHTML !== undefined) { - return node.outerHTML - } - if (node.documentElement) { - return node.documentElement.outerHTML - } + if (node.outerHTML !== undefined) return node.outerHTML + if (node.documentElement) return node.documentElement.outerHTML return "" } } @@ -34,40 +27,18 @@ import { applyDiagramOperations, type DiagramOperation, } from "./diagram-operations.js" -import { - addHistoryEntry, - getHistory, - getHistoryCount, - getVersion as getHistoryVersion, -} from "./history.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" -// Server configuration -const config = { - port: parseInt(process.env.PORT || "6002"), -} +const config = { port: parseInt(process.env.PORT || "6002") } -// Session state (single session for simplicity) -let currentSession: { - id: string - xml: string - version: number -} | null = null +let currentSession: { id: string; xml: string } | null = null -// Create MCP server -const server = new McpServer({ - name: "next-ai-drawio", - version: "0.1.2", -}) +const server = new McpServer({ name: "next-ai-drawio", version: "0.1.2" }) -// Register prompt with workflow guidance +// Workflow guidance prompt server.prompt( "diagram-workflow", "Guidelines for creating and editing draw.io diagrams", @@ -77,26 +48,22 @@ server.prompt( role: "user", content: { type: "text", - text: `# Draw.io Diagram Workflow Guidelines + text: `# Draw.io Diagram Workflow ## Creating a New Diagram -1. Call start_session to open the browser preview -2. Use display_diagram with complete mxGraphModel XML to create a new diagram +1. Call start_session to open browser preview +2. Use display_diagram with mxGraphModel XML -## Adding Elements to Existing Diagram -1. Use edit_diagram with "add" operation -2. Provide a unique cell_id and complete mxCell XML -3. No need to call get_diagram first - the server fetches latest state automatically +## Adding Elements +Use edit_diagram with "add" operation - provide cell_id and new_xml -## Modifying or Deleting Existing Elements -1. FIRST call get_diagram to see current cell IDs and structure -2. THEN call edit_diagram with "update" or "delete" operations -3. For update, provide the cell_id and complete new mxCell XML +## Modifying/Deleting Elements +1. Call get_diagram to see current cell IDs +2. Use edit_diagram with "update" or "delete" operations -## Important Notes -- display_diagram REPLACES the entire diagram - only use for new diagrams -- edit_diagram PRESERVES user's manual changes (fetches browser state first) -- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`, +## Notes +- display_diagram REPLACES entire diagram +- edit_diagram PRESERVES user's manual changes`, }, }, ], @@ -108,25 +75,15 @@ server.registerTool( "start_session", { description: - "Start a new diagram session and open the browser for real-time preview. " + - "Starts an embedded server and opens a browser window with draw.io. " + - "The browser will show diagram updates as they happen.", + "Start a new diagram session and open browser for real-time preview.", inputSchema: {}, }, async () => { try { - // Start embedded HTTP server const port = await startHttpServer(config.port) - - // Create session const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}` - currentSession = { - id: sessionId, - xml: "", - version: 0, - } + currentSession = { id: sessionId, xml: "" } - // Open browser const browserUrl = `http://localhost:${port}?mcp=${sessionId}` await open(browserUrl) @@ -136,7 +93,7 @@ server.registerTool( content: [ { type: "text", - text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`, + text: `Session started!\n\nSession ID: ${sessionId}\nBrowser: ${browserUrl}`, }, ], } @@ -158,9 +115,7 @@ server.registerTool( { description: "Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " + - "Use this for creating new diagrams from scratch. " + - "To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " + - "You should generate valid draw.io/mxGraph XML format.", + "Use edit_diagram to add elements to existing diagram.", inputSchema: { xml: z .string() @@ -174,14 +129,13 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Please call start_session first.", + text: "Error: No active session. Call start_session first.", }, ], isError: true, } } - // Validate and auto-fix XML let xml = inputXml const { valid, error, fixed, fixes } = validateAndFixXml(xml) if (fixed) { @@ -189,7 +143,6 @@ server.registerTool( log.info(`XML auto-fixed: ${fixes.join(", ")}`) } if (!valid && error) { - log.error(`XML validation failed: ${error}`) return { content: [ { @@ -201,47 +154,24 @@ server.registerTool( } } - log.info(`Displaying diagram, ${xml.length} chars`) - - // 1. Save current state to history BEFORE replacing (preserve user's work) + // Save current state before replacing if (currentSession.xml) { - // Check last entry's source to use correct label - const lastEntry = getHistory(currentSession.id, 1)[0] - const actualSource = lastEntry?.source || "human" - addHistoryEntry(currentSession.id, { - xml: currentSession.xml, - svg: "", - source: actualSource, - tool: "display_diagram", - timestamp: new Date(), - description: "Before AI replaced", - }) + addHistory(currentSession.id, currentSession.xml) } - // Update session state currentSession.xml = xml - currentSession.version++ - - // Push to embedded server state setState(currentSession.id, xml) - // 2. Save new state to history AFTER generation (capture AI result) - addHistoryEntry(currentSession.id, { - xml: xml, - svg: "", - source: "ai", - tool: "display_diagram", - timestamp: new Date(), - description: "AI generated diagram", - }) + // Save new state + addHistory(currentSession.id, xml) - log.info(`Diagram displayed successfully`) + log.info(`Displayed diagram, ${xml.length} chars`) return { content: [ { type: "text", - text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`, + text: `Diagram displayed! (${xml.length} chars)`, }, ], } @@ -262,33 +192,22 @@ server.registerTool( "edit_diagram", { description: - "Edit the current diagram by ID-based operations (update/add/delete cells). " + - "ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" + - "IMPORTANT workflow:\n" + - "- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" + - "- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" + + "Edit diagram by operations (update/add/delete cells). " + + "Fetches latest browser state first, preserving user's changes.\n\n" + "Operations:\n" + - "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + - "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + - "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + - "For add/update, new_xml must be a complete mxCell element including mxGeometry.", + "- add: Add new cell (cell_id + new_xml)\n" + + "- update: Replace cell (cell_id + new_xml)\n" + + "- delete: Remove cell (cell_id only)", inputSchema: { operations: z .array( z.object({ - type: z - .enum(["update", "add", "delete"]) - .describe("Operation type"), - cell_id: z.string().describe("The id of the mxCell"), - new_xml: z - .string() - .optional() - .describe( - "Complete mxCell XML element (required for update/add)", - ), + type: z.enum(["update", "add", "delete"]), + cell_id: z.string(), + new_xml: z.string().optional(), }), ) - .describe("Array of operations to apply"), + .describe("Operations to apply"), }, }, async ({ operations }) => { @@ -298,18 +217,17 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Please call start_session first.", + text: "Error: No active session. Call start_session first.", }, ], isError: true, } } - // Fetch latest state from browser + // Fetch latest from browser const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml - log.info("Fetched latest diagram state from browser") } if (!currentSession.xml) { @@ -317,95 +235,50 @@ server.registerTool( content: [ { type: "text", - text: "Error: No diagram to edit. Please create a diagram first with display_diagram.", + text: "Error: No diagram to edit. Use display_diagram first.", }, ], isError: true, } } - log.info(`Editing diagram with ${operations.length} operation(s)`) + // Save before editing + addHistory(currentSession.id, currentSession.xml) - // 1. Save current state to history BEFORE editing (preserve user's work) - // Check last entry's source to use correct label - const lastEntry = getHistory(currentSession.id, 1)[0] - const actualSource = lastEntry?.source || "human" - addHistoryEntry(currentSession.id, { - xml: currentSession.xml, - svg: "", - source: actualSource, - tool: "edit_diagram", - timestamp: new Date(), - description: "Before AI edit", - }) - - // Validate and auto-fix new_xml for each operation + // Validate operations const validatedOps = operations.map((op) => { if (op.new_xml) { - const { valid, error, fixed, fixes } = validateAndFixXml( - op.new_xml, - ) + const { fixed, fixes } = validateAndFixXml(op.new_xml) if (fixed) { log.info( - `Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`, + `${op.type} ${op.cell_id}: auto-fixed: ${fixes.join(", ")}`, ) return { ...op, new_xml: fixed } } - if (!valid && error) { - log.warn( - `Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`, - ) - } } return op }) - // Apply operations const { result, errors } = applyDiagramOperations( currentSession.xml, validatedOps as DiagramOperation[], ) - if (errors.length > 0) { - const errorMessages = errors - .map((e) => `${e.type} ${e.cellId}: ${e.message}`) - .join("\n") - log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`) - } - - // Update state currentSession.xml = result - currentSession.version++ - - // Push to embedded server setState(currentSession.id, result) - // 2. Save new state to history AFTER editing (capture AI result) - addHistoryEntry(currentSession.id, { - xml: result, - svg: "", - source: "ai", - tool: "edit_diagram", - timestamp: new Date(), - description: `AI edit: ${operations.length} operation(s)`, - }) + // Save after editing + addHistory(currentSession.id, result) - log.info(`Diagram edited successfully`) + log.info(`Edited diagram: ${operations.length} operation(s)`) - const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).` - const errorMsg = + const msg = `Applied ${operations.length} operation(s).` + const warn = errors.length > 0 - ? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}` + ? `\nWarnings: ${errors.map((e) => `${e.type} ${e.cellId}: ${e.message}`).join(", ")}` : "" - return { - content: [ - { - type: "text", - text: successMsg + errorMsg, - }, - ], - } + return { content: [{ type: "text", text: msg + warn }] } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -423,9 +296,8 @@ server.registerTool( "get_diagram", { description: - "Get the current diagram XML (fetches latest from browser, including user's manual edits). " + - "Call this BEFORE edit_diagram if you need to update or delete existing elements, " + - "so you can see the current cell IDs and structure.", + "Get current diagram XML (fetches latest from browser). " + + "Call before edit_diagram to see cell IDs.", }, async () => { try { @@ -434,14 +306,13 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Please call start_session first.", + text: "Error: No active session. Call start_session first.", }, ], isError: true, } } - // Fetch latest state from browser const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml @@ -452,7 +323,7 @@ server.registerTool( content: [ { type: "text", - text: "No diagram exists yet. Use display_diagram to create one.", + text: "No diagram yet. Use display_diagram to create one.", }, ], } @@ -462,14 +333,13 @@ server.registerTool( content: [ { type: "text", - text: `Current diagram XML:\n\n${currentSession.xml}`, + text: `Current diagram:\n\n${currentSession.xml}`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) - log.error("get_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, @@ -482,13 +352,9 @@ server.registerTool( server.registerTool( "export_diagram", { - description: "Export the current diagram to a .drawio file.", + description: "Export diagram to .drawio file.", inputSchema: { - path: z - .string() - .describe( - "File path to save the diagram (e.g., ./diagram.drawio)", - ), + path: z.string().describe("File path (e.g., ./diagram.drawio)"), }, }, async ({ path }) => { @@ -498,14 +364,13 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Please call start_session first.", + text: "Error: No active session. Call start_session first.", }, ], isError: true, } } - // Fetch latest state const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml @@ -514,10 +379,7 @@ server.registerTool( if (!currentSession.xml) { return { content: [ - { - type: "text", - text: "Error: No diagram to export. Please create a diagram first.", - }, + { type: "text", text: "Error: No diagram to export." }, ], isError: true, } @@ -527,27 +389,24 @@ server.registerTool( const nodePath = await import("node:path") let filePath = path - if (!filePath.endsWith(".drawio")) { - filePath = `${filePath}.drawio` - } + if (!filePath.endsWith(".drawio")) filePath += ".drawio" const absolutePath = nodePath.resolve(filePath) await fs.writeFile(absolutePath, currentSession.xml, "utf-8") - log.info(`Diagram exported to ${absolutePath}`) + log.info(`Exported to ${absolutePath}`) return { content: [ { type: "text", - text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`, + text: `Exported to ${absolutePath}`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) - log.error("export_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, @@ -556,227 +415,15 @@ server.registerTool( }, ) -// Tool: list_history -server.registerTool( - "list_history", - { - description: - "List diagram version history for the current session. " + - "Shows version numbers, who made each change (AI vs human), and timestamps. " + - "Use this to find a version to restore.", - inputSchema: { - limit: z - .number() - .optional() - .describe("Maximum number of entries to return (default: 20)"), - }, - }, - async ({ limit = 20 }) => { - try { - if (!currentSession) { - return { - content: [ - { - type: "text", - text: "Error: No active session. Please call start_session first.", - }, - ], - isError: true, - } - } - - const history = getHistory(currentSession.id, limit) - - if (history.length === 0) { - return { - content: [ - { - type: "text", - text: "No history available yet. Make some changes to create history.", - }, - ], - } - } - - const historyText = history - .map((entry) => { - const time = entry.timestamp.toLocaleTimeString() - const source = entry.source === "ai" ? "AI" : "Human" - const desc = entry.description - ? ` - ${entry.description}` - : "" - return `v${entry.version} [${source}] ${time}${desc}` - }) - .join("\n") - - return { - content: [ - { - type: "text", - text: `Diagram History (${history.length} entries, newest first):\n\n${historyText}\n\nUse restore_version to restore a specific version.`, - }, - ], - } - } catch (error) { - const message = - error instanceof Error ? error.message : String(error) - log.error("list_history failed:", message) - return { - content: [{ type: "text", text: `Error: ${message}` }], - isError: true, - } - } - }, -) - -// Tool: restore_version -server.registerTool( - "restore_version", - { - description: - "Restore diagram to a previous version from history. " + - "Use list_history first to see available versions. " + - "This creates a NEW history entry (non-destructive).", - inputSchema: { - version: z.number().describe("Version number to restore"), - }, - }, - async ({ version }) => { - try { - if (!currentSession) { - return { - content: [ - { - type: "text", - text: "Error: No active session. Please call start_session first.", - }, - ], - isError: true, - } - } - - const entry = getHistoryVersion(currentSession.id, version) - if (!entry) { - return { - content: [ - { - type: "text", - text: `Error: Version ${version} not found in history. Use list_history to see available versions.`, - }, - ], - isError: true, - } - } - - // Restore by updating session and state - currentSession.xml = entry.xml - currentSession.version++ - setState(currentSession.id, entry.xml) - - // Add history entry for the restore - addHistoryEntry(currentSession.id, { - xml: entry.xml, - svg: entry.svg, - source: "ai", - tool: "restore_version", - timestamp: new Date(), - description: `Restored from v${version}`, - }) - - log.info(`Restored diagram to v${version}`) - - return { - content: [ - { - type: "text", - text: `Diagram restored to version ${version} successfully!\n\nThe browser will update automatically.`, - }, - ], - } - } catch (error) { - const message = - error instanceof Error ? error.message : String(error) - log.error("restore_version failed:", message) - return { - content: [{ type: "text", text: `Error: ${message}` }], - isError: true, - } - } - }, -) - -// Tool: get_version -server.registerTool( - "get_version", - { - description: - "Get the XML content of a specific version from history. " + - "Use this to inspect what a previous version looked like before restoring.", - inputSchema: { - version: z.number().describe("Version number to retrieve"), - }, - }, - async ({ version }) => { - try { - if (!currentSession) { - return { - content: [ - { - type: "text", - text: "Error: No active session. Please call start_session first.", - }, - ], - isError: true, - } - } - - const entry = getHistoryVersion(currentSession.id, version) - if (!entry) { - return { - content: [ - { - type: "text", - text: `Error: Version ${version} not found in history.`, - }, - ], - isError: true, - } - } - - const source = entry.source === "ai" ? "AI" : "Human" - const time = entry.timestamp.toISOString() - - return { - content: [ - { - type: "text", - text: `Version ${version} (${source} edit at ${time}):\n\n${entry.xml}`, - }, - ], - } - } catch (error) { - const message = - error instanceof Error ? error.message : String(error) - log.error("get_version failed:", message) - return { - content: [{ type: "text", text: `Error: ${message}` }], - isError: true, - } - } - }, -) - -// Start the MCP server +// Start server async function main() { - log.info("Starting MCP server for Next AI Draw.io (embedded mode)...") - + log.info("Starting MCP server...") const transport = new StdioServerTransport() await server.connect(transport) - - log.info("MCP server running on stdio") + log.info("MCP server running") } main().catch((error) => { - log.error("Fatal error:", error) + log.error("Fatal:", error) process.exit(1) })