From c215d8068837e73d16cf256333bdf1277f37df80 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 16:09:14 +0900 Subject: [PATCH 1/6] feat(mcp): add diagram version history - Add history.ts module with circular buffer (max 50 entries) - Add history UI with floating button and modal - Add HTTP endpoints: /api/history, /api/restore - Add MCP tools: list_history, restore_version, get_version - Save history before and after AI changes - Track source (ai/human) for each entry --- .mcp.json | 10 + packages/mcp-server/src/history.ts | 169 +++++++ packages/mcp-server/src/http-server.ts | 623 ++++++++++++++++++++++++- packages/mcp-server/src/index.ts | 264 +++++++++++ 4 files changed, 1055 insertions(+), 11 deletions(-) create mode 100644 .mcp.json create mode 100644 packages/mcp-server/src/history.ts diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..46e0dd1 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "drawio": { + "command": "node", + "args": [ + "/Users/jiangdy/Documents/programming/next-ai-draw-io/packages/mcp-server/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..6070ad7 --- /dev/null +++ b/packages/mcp-server/src/history.ts @@ -0,0 +1,169 @@ +/** + * Diagram Version History for MCP Server + * + * Stores diagram versions in-memory per session. + * Enables users and AI to restore previous diagram states. + */ + +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 +} + +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 { + let history = historyStore.get(sessionId) + if (!history) { + history = { entries: [], nextVersion: 1 } + 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 + } + + const version = history.nextVersion++ + const newEntry: HistoryEntry = { + ...entry, + version, + } + + 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 +} + +/** + * 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) +} + +/** + * Get a specific version from history + */ +export function getVersion( + sessionId: string, + version: number, +): HistoryEntry | undefined { + const history = historyStore.get(sessionId) + if (!history) { + return undefined + } + + return history.entries.find((e) => e.version === version) +} + +/** + * 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 +} diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 3b64d9b..05a338f 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -6,6 +6,13 @@ */ import http from "node:http" +import { + addHistoryEntry, + clearHistory, + getHistory, + getVersion, + updateLatestEntrySvg, +} from "./history.js" import { log } from "./logger.js" interface SessionState { @@ -105,6 +112,7 @@ function cleanupExpiredSessions(): void { for (const [sessionId, state] of stateStore) { if (now - state.lastUpdated.getTime() > SESSION_TTL) { stateStore.delete(sessionId) + clearHistory(sessionId) // Also clean up history log.info(`Cleaned up expired session: ${sessionId}`) } } @@ -148,12 +156,27 @@ function handleRequest( url.pathname === "/api/mcp/state" ) { handleStateApi(req, res, url) + } else if ( + url.pathname === "/api/history" || + url.pathname === "/api/mcp/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 { res.writeHead(404) res.end("Not Found") @@ -206,14 +229,28 @@ 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 } + // 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 { @@ -227,6 +264,153 @@ function handleStateApi( } } +/** + * Handle history API requests + */ +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 limit = parseInt(url.searchParams.get("limit") || "20") + const history = getHistory(sessionId, limit) + + 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 + })), + 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) + res.end("Method Not Allowed") + return + } + + let body = "" + req.on("data", (chunk) => { + body += chunk + }) + req.on("end", () => { + try { + const { sessionId, version } = JSON.parse(body) + if (!sessionId || version === undefined) { + res.writeHead(400, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ error: "sessionId and version required" }), + ) + return + } + + const entry = getVersion(sessionId, version) + if (!entry) { + res.writeHead(404, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Version not found" })) + return + } + + // Restore by setting state (this will trigger browser poll to load it) + const newVersion = setState(sessionId, entry.xml) + + // 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}`, + ) + + res.writeHead(200, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + success: true, + restoredFrom: version, + newVersion, + }), + ) + } catch { + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Invalid JSON" })) + } + }) +} + /** * Generate the HTML page with draw.io embed */ @@ -256,6 +440,163 @@ function getHtmlPage(sessionId: string): string { #header .status.connected { color: #4ade80; } #header .status.disconnected { color: #f87171; } #drawio { flex: 1; border: none; } + + /* History button */ + #history-btn { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + border-radius: 50%; + background: #3b82f6; + color: white; + border: none; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s, background 0.2s; + z-index: 1000; + } + #history-btn:hover { background: #2563eb; transform: scale(1.1); } + #history-btn:disabled { background: #6b7280; cursor: not-allowed; transform: none; } + #history-btn svg { width: 24px; height: 24px; } + #history-badge { + position: absolute; + top: -4px; + right: -4px; + background: #ef4444; + color: white; + font-size: 11px; + font-weight: bold; + padding: 2px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; + } + + /* Modal overlay */ + #history-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 2000; + align-items: center; + justify-content: center; + } + #history-modal.open { display: flex; } + + /* Modal content */ + .modal-content { + background: white; + border-radius: 12px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); + } + .modal-header { + padding: 16px 20px; + border-bottom: 1px solid #e5e7eb; + } + .modal-header h2 { font-size: 18px; margin: 0 0 4px 0; } + .modal-header p { font-size: 13px; color: #6b7280; margin: 0; } + .modal-body { + flex: 1; + overflow-y: auto; + padding: 16px; + } + .modal-footer { + padding: 12px 20px; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + } + .modal-footer .info { flex: 1; font-size: 13px; color: #6b7280; } + + /* Grid of history items */ + .history-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + } + .history-item { + border: 2px solid #e5e7eb; + border-radius: 8px; + padding: 8px; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + } + .history-item:hover { border-color: #3b82f6; } + .history-item.selected { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); + } + .history-item .thumb { + aspect-ratio: 16/9; + background: #f9fafb; + border-radius: 4px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + } + .history-item .thumb img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + .history-item .thumb.no-preview { + color: #9ca3af; + font-size: 12px; + } + .history-item .meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + } + .history-item .version { font-weight: 600; color: #374151; } + .history-item .badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + } + .history-item .badge.ai { background: #dbeafe; color: #1d4ed8; } + .history-item .badge.human { background: #dcfce7; color: #166534; } + + /* Buttons */ + .btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.2s; + } + .btn-primary { background: #3b82f6; color: white; } + .btn-primary:hover { background: #2563eb; } + .btn-primary:disabled { background: #93c5fd; cursor: not-allowed; } + .btn-secondary { background: #f3f4f6; color: #374151; } + .btn-secondary:hover { background: #e5e7eb; } + + /* Empty state */ + .empty-state { + text-align: center; + padding: 40px 20px; + color: #6b7280; + } @@ -270,6 +611,36 @@ function getHtmlPage(sessionId: string): string { + + + + +
+ +
+ ` diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index d0e049a..8f1dcaf 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -34,6 +34,12 @@ import { applyDiagramOperations, type DiagramOperation, } from "./diagram-operations.js" +import { + addHistoryEntry, + getHistory, + getHistoryCount, + getVersion as getHistoryVersion, +} from "./history.js" import { getServerPort, getState, @@ -197,6 +203,21 @@ server.registerTool( log.info(`Displaying diagram, ${xml.length} chars`) + // 1. Save current state to history BEFORE replacing (preserve user's work) + 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", + }) + } + // Update session state currentSession.xml = xml currentSession.version++ @@ -204,6 +225,16 @@ server.registerTool( // 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", + }) + log.info(`Diagram displayed successfully`) return { @@ -295,6 +326,19 @@ server.registerTool( log.info(`Editing diagram with ${operations.length} operation(s)`) + // 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 const validatedOps = operations.map((op) => { if (op.new_xml) { @@ -336,6 +380,16 @@ server.registerTool( // 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)`, + }) + log.info(`Diagram edited successfully`) const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).` @@ -502,6 +556,216 @@ 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 async function main() { log.info("Starting MCP server for Next AI Draw.io (embedded mode)...") From b9bc2a72c6faab046d8010ce0b69499e84fb63ba Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 16:11:49 +0900 Subject: [PATCH 2/6] refactor(mcp): simplify history implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce history.ts from 169 to 51 lines - Remove AI tools (list_history, restore_version, get_version) - Remove /api/update-svg endpoint - Remove 10-second history polling - Simplify HistoryEntry to just {xml, svg} - Use array index instead of version numbers Total reduction: 1936 → 923 lines (-52%) --- packages/mcp-server/src/history.ts | 166 +----- packages/mcp-server/src/http-server.ts | 768 +++++-------------------- packages/mcp-server/src/index.ts | 493 +++------------- 3 files changed, 224 insertions(+), 1203 deletions(-) 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) }) From 864375b8e43052fba25223e2779707a5db0da3a8 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 16:58:41 +0900 Subject: [PATCH 3/6] fix(mcp): capture SVG for AI-generated diagrams - Sync browser state before saving history in display_diagram - Save AI result to history (in addition to state before) - Add SVG capture after browser loads AI diagrams - Add /api/history-svg endpoint to update last entry's SVG - Add updateLastHistorySvg() function to history module --- .mcp.json | 10 ---- packages/mcp-server/src/history.ts | 11 ++++ packages/mcp-server/src/http-server.ts | 81 +++++++++++++++++++++++--- packages/mcp-server/src/index.ts | 74 ++++++++++++++--------- 4 files changed, 132 insertions(+), 44 deletions(-) delete mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 46e0dd1..0000000 --- a/.mcp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "drawio": { - "command": "node", - "args": [ - "/Users/jiangdy/Documents/programming/next-ai-draw-io/packages/mcp-server/dist/index.js" - ] - } - } -} diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 7f4d0ac..492eda1 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -49,3 +49,14 @@ export function getHistoryEntry( 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 21d9e31..27a2e7a 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -9,6 +9,7 @@ import { clearHistory, getHistory, getHistoryEntry, + updateLastHistorySvg, } from "./history.js" import { log } from "./logger.js" @@ -16,6 +17,7 @@ interface SessionState { xml: string version: number lastUpdated: Date + svg?: string // Cached SVG from last browser save } export const stateStore = new Map() @@ -29,13 +31,14 @@ export function getState(sessionId: string): SessionState | undefined { return stateStore.get(sessionId) } -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 @@ -128,6 +131,8 @@ function handleRequest( 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") @@ -161,13 +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 { @@ -255,6 +260,39 @@ function handleRestoreApi( }) } +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 ` @@ -358,6 +396,8 @@ function getHtmlPage(sessionId: string): string { const iframe = document.getElementById('drawio'); const statusEl = document.getElementById('status'); let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; + let pendingSvgExport = null; + let pendingAiSvg = false; window.addEventListener('message', (e) => { if (e.origin !== 'https://embed.diagrams.net') return; @@ -369,24 +409,49 @@ function getHtmlPage(sessionId: string): string { statusEl.className = 'status connected'; if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) { - pushState(msg.xml); + // Request SVG export, then push state with SVG + pendingSvgExport = msg.xml; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); + // Fallback if export doesn't respond + setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000); + } else if (msg.event === 'export' && msg.data) { + let svg = msg.data; + if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + if (pendingSvgExport) { + const xml = pendingSvgExport; + pendingSvgExport = null; + pushState(xml, svg); + } else if (pendingAiSvg) { + pendingAiSvg = false; + fetch('/api/history-svg', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, svg }) + }).catch(() => {}); + } } } catch {} }); - function loadDiagram(xml) { + function loadDiagram(xml, capturePreview = false) { if (!isReady) { pendingXml = xml; return; } lastXml = xml; iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*'); + if (capturePreview) { + setTimeout(() => { + pendingAiSvg = true; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); + }, 500); + } } - async function pushState(xml) { + async function pushState(xml, svg = '') { if (!sessionId) return; try { const r = await fetch('/api/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, xml }) + body: JSON.stringify({ sessionId, xml, svg }) }); if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; } } catch (e) { console.error('Push failed:', e); } @@ -400,7 +465,7 @@ function getHtmlPage(sessionId: string): string { const s = await r.json(); if (s.version > currentVersion && s.xml) { currentVersion = s.version; - loadDiagram(s.xml); + loadDiagram(s.xml, true); } } catch {} } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 94b05c1..23c22b7 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -48,22 +48,26 @@ server.prompt( role: "user", content: { type: "text", - text: `# Draw.io Diagram Workflow + text: `# Draw.io Diagram Workflow Guidelines ## Creating a New Diagram -1. Call start_session to open browser preview -2. Use display_diagram with mxGraphModel XML +1. Call start_session to open the browser preview +2. Use display_diagram with complete mxGraphModel XML to create a new diagram -## Adding Elements -Use edit_diagram with "add" operation - provide cell_id and new_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 -## Modifying/Deleting Elements -1. Call get_diagram to see current cell IDs -2. Use edit_diagram with "update" or "delete" operations +## 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 -## Notes -- display_diagram REPLACES entire diagram -- edit_diagram PRESERVES user's manual changes`, +## 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")`, }, }, ], @@ -75,7 +79,9 @@ server.registerTool( "start_session", { description: - "Start a new diagram session and open browser for real-time preview.", + "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.", inputSchema: {}, }, async () => { @@ -93,7 +99,7 @@ server.registerTool( content: [ { type: "text", - text: `Session started!\n\nSession ID: ${sessionId}\nBrowser: ${browserUrl}`, + text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`, }, ], } @@ -115,7 +121,9 @@ server.registerTool( { description: "Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " + - "Use edit_diagram to add elements to existing 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.", inputSchema: { xml: z .string() @@ -129,7 +137,7 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Call start_session first.", + text: "Error: No active session. Please call start_session first.", }, ], isError: true, @@ -154,16 +162,26 @@ server.registerTool( } } - // Save current state before replacing + // 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) + addHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) } currentSession.xml = xml setState(currentSession.id, xml) - // Save new state - addHistory(currentSession.id, xml) + // Save AI result (no SVG yet) + addHistory(currentSession.id, xml, "") log.info(`Displayed diagram, ${xml.length} chars`) @@ -217,7 +235,7 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Call start_session first.", + text: "Error: No active session. Please call start_session first.", }, ], isError: true, @@ -242,8 +260,12 @@ server.registerTool( } } - // Save before editing - addHistory(currentSession.id, currentSession.xml) + // Save before editing (with cached SVG from browser) + addHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) // Validate operations const validatedOps = operations.map((op) => { @@ -267,8 +289,8 @@ server.registerTool( currentSession.xml = result setState(currentSession.id, result) - // Save after editing - addHistory(currentSession.id, result) + // Save AI result (no SVG yet) + addHistory(currentSession.id, result, "") log.info(`Edited diagram: ${operations.length} operation(s)`) @@ -306,7 +328,7 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Call start_session first.", + text: "Error: No active session. Please call start_session first.", }, ], isError: true, @@ -364,7 +386,7 @@ server.registerTool( content: [ { type: "text", - text: "Error: No active session. Call start_session first.", + text: "Error: No active session. Please call start_session first.", }, ], isError: true, From b97f3ccda9179de704a7a77f7293cf804a853dc2 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 17:41:27 +0900 Subject: [PATCH 4/6] fix(mcp): minimal history integration in index.ts Keep only essential history integration: - Import addHistory from history.js - Remove unused getServerPort import - Add browser state sync and history saving in display_diagram - Add history saving in edit_diagram No changes to prompts, descriptions, or code style. --- packages/mcp-server/src/index.ts | 181 +++++++++++++++++++++++-------- 1 file changed, 136 insertions(+), 45 deletions(-) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 23c22b7..ffb3912 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -4,16 +4,23 @@ * * 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 "" } } @@ -32,13 +39,25 @@ import { getState, setState, startHttpServer } from "./http-server.js" import { log } from "./logger.js" import { validateAndFixXml } from "./xml-validation.js" -const config = { port: parseInt(process.env.PORT || "6002") } +// Server configuration +const config = { + port: parseInt(process.env.PORT || "6002"), +} -let currentSession: { id: string; xml: string } | null = null +// Session state (single session for simplicity) +let currentSession: { + id: string + xml: string + version: number +} | null = null -const server = new McpServer({ name: "next-ai-drawio", version: "0.1.2" }) +// Create MCP server +const server = new McpServer({ + name: "next-ai-drawio", + version: "0.1.2", +}) -// Workflow guidance prompt +// Register prompt with workflow guidance server.prompt( "diagram-workflow", "Guidelines for creating and editing draw.io diagrams", @@ -86,10 +105,18 @@ server.registerTool( }, async () => { try { + // Start embedded HTTP server const port = await startHttpServer(config.port) - const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}` - currentSession = { id: sessionId, xml: "" } + // Create session + const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}` + currentSession = { + id: sessionId, + xml: "", + version: 0, + } + + // Open browser const browserUrl = `http://localhost:${port}?mcp=${sessionId}` await open(browserUrl) @@ -144,6 +171,7 @@ server.registerTool( } } + // Validate and auto-fix XML let xml = inputXml const { valid, error, fixed, fixes } = validateAndFixXml(xml) if (fixed) { @@ -151,6 +179,7 @@ server.registerTool( log.info(`XML auto-fixed: ${fixes.join(", ")}`) } if (!valid && error) { + log.error(`XML validation failed: ${error}`) return { content: [ { @@ -162,6 +191,8 @@ server.registerTool( } } + log.info(`Displaying diagram, ${xml.length} chars`) + // Sync from browser state first const browserState = getState(currentSession.id) if (browserState?.xml) { @@ -177,19 +208,23 @@ server.registerTool( ) } + // Update session state currentSession.xml = xml + currentSession.version++ + + // Push to embedded server state setState(currentSession.id, xml) - // Save AI result (no SVG yet) + // Save AI result (no SVG yet - will be captured by browser) addHistory(currentSession.id, xml, "") - log.info(`Displayed diagram, ${xml.length} chars`) + log.info(`Diagram displayed successfully`) return { content: [ { type: "text", - text: `Diagram displayed! (${xml.length} chars)`, + text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`, }, ], } @@ -210,22 +245,33 @@ server.registerTool( "edit_diagram", { description: - "Edit diagram by operations (update/add/delete cells). " + - "Fetches latest browser state first, preserving user's changes.\n\n" + + "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" + "Operations:\n" + - "- add: Add new cell (cell_id + new_xml)\n" + - "- update: Replace cell (cell_id + new_xml)\n" + - "- delete: Remove cell (cell_id only)", + "- 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.", inputSchema: { operations: z .array( z.object({ - type: z.enum(["update", "add", "delete"]), - cell_id: z.string(), - new_xml: z.string().optional(), + 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)", + ), }), ) - .describe("Operations to apply"), + .describe("Array of operations to apply"), }, }, async ({ operations }) => { @@ -242,10 +288,11 @@ server.registerTool( } } - // Fetch latest from browser + // Fetch latest state 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) { @@ -253,13 +300,15 @@ server.registerTool( content: [ { type: "text", - text: "Error: No diagram to edit. Use display_diagram first.", + text: "Error: No diagram to edit. Please create a diagram first with display_diagram.", }, ], isError: true, } } + log.info(`Editing diagram with ${operations.length} operation(s)`) + // Save before editing (with cached SVG from browser) addHistory( currentSession.id, @@ -267,40 +316,66 @@ server.registerTool( browserState?.svg || "", ) - // Validate operations + // Validate and auto-fix new_xml for each operation const validatedOps = operations.map((op) => { if (op.new_xml) { - const { fixed, fixes } = validateAndFixXml(op.new_xml) + const { valid, error, fixed, fixes } = validateAndFixXml( + op.new_xml, + ) if (fixed) { log.info( - `${op.type} ${op.cell_id}: auto-fixed: ${fixes.join(", ")}`, + `Operation ${op.type} ${op.cell_id}: XML 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) - // Save AI result (no SVG yet) + // Save AI result (no SVG yet - will be captured by browser) addHistory(currentSession.id, result, "") - log.info(`Edited diagram: ${operations.length} operation(s)`) + log.info(`Diagram edited successfully`) - const msg = `Applied ${operations.length} operation(s).` - const warn = + const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).` + const errorMsg = errors.length > 0 - ? `\nWarnings: ${errors.map((e) => `${e.type} ${e.cellId}: ${e.message}`).join(", ")}` + ? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}` : "" - return { content: [{ type: "text", text: msg + warn }] } + return { + content: [ + { + type: "text", + text: successMsg + errorMsg, + }, + ], + } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -318,8 +393,9 @@ server.registerTool( "get_diagram", { description: - "Get current diagram XML (fetches latest from browser). " + - "Call before edit_diagram to see cell IDs.", + "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.", }, async () => { try { @@ -335,6 +411,7 @@ server.registerTool( } } + // Fetch latest state from browser const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml @@ -345,7 +422,7 @@ server.registerTool( content: [ { type: "text", - text: "No diagram yet. Use display_diagram to create one.", + text: "No diagram exists yet. Use display_diagram to create one.", }, ], } @@ -355,13 +432,14 @@ server.registerTool( content: [ { type: "text", - text: `Current diagram:\n\n${currentSession.xml}`, + text: `Current diagram XML:\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, @@ -374,9 +452,13 @@ server.registerTool( server.registerTool( "export_diagram", { - description: "Export diagram to .drawio file.", + description: "Export the current diagram to a .drawio file.", inputSchema: { - path: z.string().describe("File path (e.g., ./diagram.drawio)"), + path: z + .string() + .describe( + "File path to save the diagram (e.g., ./diagram.drawio)", + ), }, }, async ({ path }) => { @@ -393,6 +475,7 @@ server.registerTool( } } + // Fetch latest state const browserState = getState(currentSession.id) if (browserState?.xml) { currentSession.xml = browserState.xml @@ -401,7 +484,10 @@ server.registerTool( if (!currentSession.xml) { return { content: [ - { type: "text", text: "Error: No diagram to export." }, + { + type: "text", + text: "Error: No diagram to export. Please create a diagram first.", + }, ], isError: true, } @@ -411,24 +497,27 @@ server.registerTool( const nodePath = await import("node:path") let filePath = path - if (!filePath.endsWith(".drawio")) filePath += ".drawio" + if (!filePath.endsWith(".drawio")) { + filePath = `${filePath}.drawio` + } const absolutePath = nodePath.resolve(filePath) await fs.writeFile(absolutePath, currentSession.xml, "utf-8") - log.info(`Exported to ${absolutePath}`) + log.info(`Diagram exported to ${absolutePath}`) return { content: [ { type: "text", - text: `Exported to ${absolutePath}`, + text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`, }, ], } } 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, @@ -437,15 +526,17 @@ server.registerTool( }, ) -// Start server +// Start the MCP server async function main() { - log.info("Starting MCP server...") + log.info("Starting MCP server for Next AI Draw.io (embedded mode)...") + const transport = new StdioServerTransport() await server.connect(transport) - log.info("MCP server running") + + log.info("MCP server running on stdio") } main().catch((error) => { - log.error("Fatal:", error) + log.error("Fatal error:", error) process.exit(1) }) From 9d13bd74510cafdd67fdfe498334f7040bf47472 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 18:05:44 +0900 Subject: [PATCH 5/6] chore(mcp): bump version to 0.1.4 --- packages/mcp-server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 287126536261d132e4184887e8593cef0b853ef4 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 18:07:29 +0900 Subject: [PATCH 6/6] docs(mcp): add version history feature to README --- packages/mcp-server/README.md | 1 + 1 file changed, 1 insertion(+) 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