From c215d8068837e73d16cf256333bdf1277f37df80 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 21 Dec 2025 16:09:14 +0900 Subject: [PATCH] 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)...")