diff --git a/packages/claude-plugin/README.md b/packages/claude-plugin/README.md index dbedcff2..af9e06a6 100644 --- a/packages/claude-plugin/README.md +++ b/packages/claude-plugin/README.md @@ -89,7 +89,8 @@ Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram | Tool | Description | |------|-------------| -| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules | +| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram | +| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages | | `get_drawing_guide` | Return the drawing rules again | | `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` | | `create_new_diagram` | Create a new diagram from XML | @@ -99,6 +100,7 @@ Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram | `screenshot_diagram` | Return a PNG of a page so Claude can check the result | | `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file | | `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams | +| `restore_version` | Undo: go back to an earlier version from History (redo is possible too) | ## How It Works diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 63061079..ea8f0b6b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -111,10 +111,10 @@ Use the standard MCP configuration with: - **Draw from Your Files**: ask the AI to draw from a document, image or web page; it reads the source with the host's own tools and draws. Existing .drawio files open with load_diagram - **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML - **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History -- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails +- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails, or ask the AI to undo (`restore_version`) - **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram` - **Multi-page**: List, add, rename, and delete pages, and edit any page -- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/.drawio`, so it survives a restart of the MCP client +- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/.drawio`, and its last 20 versions to `.history.json`, so the diagram, History and undo survive a restart of the MCP client - **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call - **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) @@ -123,7 +123,8 @@ Use the standard MCP configuration with: | Tool | Description | |------|-------------| -| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules | +| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram | +| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages | | `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted | | `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` | | `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough | @@ -136,12 +137,17 @@ Use the standard MCP configuration with: | `add_page` | Append a new page without touching existing ones | | `rename_page` | Rename a page | | `delete_page` | Delete a page (refuses to delete the last one) | +| `restore_version` | Undo: put an earlier version from History back on the canvas; the current one is kept, so a redo is possible | ## Continue a Diagram Later -After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself. +After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, named after the session id (for example `mcp-mgd0a1b2-x7k2p1.drawio`). `start_session` tells the AI both the id and the file path. -The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off. +To continue a diagram in a later conversation (for example after `claude --resume`, or in a new chat), the AI calls `start_session` with `session_id` set to that id: the same preview URL opens, the saved diagram is shown, and auto-save keeps writing to the same file. If the id is no longer in the conversation, `list_saved_diagrams` returns every saved diagram, newest first, with the names and cell counts of its pages, so you can ask for "the architecture diagram from yesterday" and let the AI pick it. `load_diagram` with the file path still works too, and you can open the file in draw.io yourself. + +History comes back with the diagram: the last 20 versions are saved next to it in `.history.json` (without thumbnails). When the AI continues the session with `start_session` and its `session_id`, or a preview tab is still open after a restart, the **History** button shows them again and `restore_version` can undo to them. + +The newest 50 files are kept, each with its History file. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off. ## Custom Instructions diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts index 35d402c3..1c171eba 100644 --- a/packages/mcp-server/src/drawing-guide.ts +++ b/packages/mcp-server/src/drawing-guide.ts @@ -29,6 +29,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide - For artistic requests (a cat, a logo, a scene), compose the picture from standard shapes and connectors while keeping it clear. - To clear the canvas to one blank page, call create_new_diagram with only the two root cells ; the previous diagram stays in History. - The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost. +- If the user asks to undo or go back, call restore_version instead of re-sending the earlier XML from memory. ## The XML you send Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds , , and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. A cell with source or target is always an edge. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: . An edge's own text simply goes in its value. diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 7fff09a8..197a13e7 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -3,11 +3,13 @@ * Stores {xml, svg} entries in a circular buffer */ +import { contentFingerprint } from "./edit-gate.ts" import { log } from "./logger.ts" +import { isMxGraphModel } from "./pages.ts" const MAX_HISTORY = 20 -interface HistoryEntry { +export interface HistoryEntry { id: number // Stable across shifts of the circular buffer xml: string svg: string @@ -59,6 +61,54 @@ export function clearHistory(sessionId: string): void { historyStore.delete(sessionId) } +/** + * The versions the diagram can go back (or forward) to: every entry whose + * content differs from currentXml, newest first, one entry per distinct + * content (its newest copy). Re-serialised copies of one diagram count as + * the same version. + */ +export function otherVersions( + sessionId: string, + currentXml: string, +): HistoryEntry[] { + const kept = [versionOf(currentXml)] + const result: HistoryEntry[] = [] + const history = getHistory(sessionId) + for (let i = history.length - 1; i >= 0; i--) { + const version = versionOf(history[i].xml) + if (kept.some((seen) => sameVersion(seen, version))) continue + kept.push(version) + result.push(history[i]) + } + return result +} + +/** True when History holds a copy of this diagram. */ +export function isInHistory(sessionId: string, xml: string): boolean { + const version = versionOf(xml) + return getHistory(sessionId).some((entry) => + sameVersion(version, versionOf(entry.xml)), + ) +} + +/** A diagram's fingerprints, with and without its page names */ +function versionOf(xml: string) { + return { + bare: isMxGraphModel(xml), + named: contentFingerprint(xml), + cells: contentFingerprint(xml, false), + } +} + +// Same rule as checkEditGate: a bare (the browser's sync +// can send one) has no page name, so names count only when both have them +function sameVersion( + a: ReturnType, + b: ReturnType, +): boolean { + return a.bare || b.bare ? a.cells === b.cells : a.named === b.named +} + /** * Give the last entry the image the browser took of shownXml, the diagram * it just loaded, when that entry is this diagram diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 26170052..4b67e87a 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -569,6 +569,8 @@ function handleStateApi( data.xml !== current.xml if (saved) { addHistory(sessionId, data.xml, data.svg || "") + // Saved with the History, as after any change + stateListener?.(sessionId, current.xml) } res.writeHead(409, { "Content-Type": "application/json", @@ -605,6 +607,7 @@ function handleStateApi( // it in history so the user can restore it. addHistory(sessionId, data.xml, data.svg || "") savedToHistory = true + if (current) stateListener?.(sessionId, current.xml) } res.writeHead(409, { "Content-Type": "application/json" }) res.end( @@ -670,6 +673,36 @@ function handleHistoryApi( ) } +/** + * Put a History entry back on the canvas (the preview page's Restore button + * and the restore_version tool). Returns the new version, or null when the + * entry is unknown. + */ +export function restoreHistoryEntry( + sessionId: string, + entryId: number, +): number | null { + const entry = getHistoryEntry(sessionId, entryId) + if (!entry) return null + + // Edits in the browser since the last entry are not in history + // yet: keep them, so the restore can be undone + // (any state besides a blank page; a cleared document with its + // own pages counts) + const current = stateStore.get(sessionId) + if ( + current && + contentFingerprint(current.xml) !== contentFingerprint(BLANK_MXFILE) + ) { + keepInHistory(sessionId, current.xml, current.svg) + } + const newVersion = setState(sessionId, entry.xml) + addHistory(sessionId, entry.xml, entry.svg) + + log.info(`Restored session ${sessionId} to history entry ${entryId}`) + return newVersion +} + function handleRestoreApi( req: http.IncomingMessage, res: http.ServerResponse, @@ -682,37 +715,34 @@ function handleRestoreApi( readBody(req, res, (body) => { try { - const { sessionId, id } = JSON.parse(body) + const data = JSON.parse(body) + const { sessionId, id } = data if (!sessionId || typeof id !== "number") { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId and id required" })) return } + // Picked from the list of a state the server has since lost + // (a restart reloads History under new ids), or before the tab + // knew the state. A tab of an older version sends none. + const current = stateStore.get(sessionId) + if ( + current && + "stateId" in data && + data.stateId !== current.stateId + ) { + res.writeHead(409, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Session was recreated" })) + return + } - const entry = getHistoryEntry(sessionId, id) - if (!entry) { + const newVersion = restoreHistoryEntry(sessionId, id) + if (newVersion === null) { res.writeHead(404, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Entry not found" })) return } - // Edits in the browser since the last entry are not in history - // yet: keep them, so the restore can be undone - // (any state besides a blank page; a cleared document with its - // own pages counts) - const current = stateStore.get(sessionId) - if ( - current && - contentFingerprint(current.xml) !== - contentFingerprint(BLANK_MXFILE) - ) { - keepInHistory(sessionId, current.xml, current.svg) - } - const newVersion = setState(sessionId, entry.xml) - addHistory(sessionId, entry.xml, entry.svg) - - log.info(`Restored session ${sessionId} to history entry ${id}`) - res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, newVersion })) } catch { diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index ee6fc7a7..0a0c3f1a 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -32,9 +32,15 @@ import type { DiagramOperation } from "./diagram-operations.ts" import { installDomPolyfill } from "./dom.ts" import { DRAWING_GUIDE } from "./drawing-guide.ts" import { editDiagram, targetPageXml } from "./edit-diagram.ts" -import { checkEditGate, markPageSeen } from "./edit-gate.ts" +import { checkEditGate, contentFingerprint, markPageSeen } from "./edit-gate.ts" import { createExclusive } from "./exclusive.ts" -import { addHistory } from "./history.ts" +import { + addHistory, + getHistory, + type HistoryEntry, + isInHistory, + otherVersions, +} from "./history.ts" import { type ExportFormat, type ExportOptions, @@ -45,6 +51,7 @@ import { onStateChange, requestExport, requestSync, + restoreHistoryEntry, restoreSavedSession, setState, shutdown, @@ -60,6 +67,7 @@ import { } from "./new-diagram.ts" import { addPageToDoc, + BLANK_MXFILE, deletePageFromDoc, findPageElement, hasCells, @@ -86,10 +94,23 @@ const config = { port: parseInt(process.env.PORT || "6002", 10), } -// Keep each session's latest diagram on disk, so it survives this process -const autosaver = new Autosaver(defaultDataDir()) +// Keep each session's latest diagram and its History on disk, so they +// survive this process +const autosaver = new Autosaver(defaultDataDir(), undefined, undefined, (id) => + getHistory(id), +) onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml)) -onSessionRecreate((sessionId) => autosaver.load(sessionId)) +onSessionRecreate((sessionId) => { + const saved = autosaver.load(sessionId) + if (!saved) return null + // A file draw.io saved (compressed pages) comes back as plain XML, as + // with load_diagram + const loaded = parseDrawioFileContent(saved) + // History comes back with the diagram (addHistory drops consecutive + // duplicates) + for (const x of autosaver.loadHistory(sessionId)) addHistory(sessionId, x) + return loaded.ok ? loaded.xml : saved +}) // A one-page view that does not count for the whole document (edit-gate.ts) const OTHER_PAGES_UNSEEN = @@ -146,7 +167,9 @@ Tools: - get_diagram: read the current XML, including the user's manual edits. - screenshot_diagram: see the rendered diagram as an image. - load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths. -- list_pages, add_page, rename_page, delete_page: manage pages (tabs).` +- list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=. +- list_pages, add_page, rename_page, delete_page: manage pages (tabs). +- restore_version: undo; go back to an earlier version of the diagram.` const server = new McpServer( { @@ -289,20 +312,32 @@ registerWriteTool( { title: "Start session", description: - "Start a new diagram session and open the browser for real-time preview. " + + "Start a new diagram session, or continue a saved one with session_id, 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. " + "The result includes the drawing guide; follow it when drawing.", - inputSchema: {}, + inputSchema: { + session_id: z + .string() + .regex(/^mcp-[a-z0-9-]{1,64}$/) + .optional() + .describe( + "Id of a saved diagram (from list_saved_diagrams, or the Session ID of an earlier start_session) to continue it with the same preview URL and auto-save file. Omit to start a new diagram.", + ), + }, annotations: { destructiveHint: false, openWorldHint: false }, }, - async () => { + async (input) => { + // The only field is optional, so a client may send no arguments + const { session_id } = input ?? {} 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)}` + // Create session, or continue a saved one under its old id + const sessionId = + session_id ?? + `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}` currentSession = { id: sessionId, xml: "", @@ -314,9 +349,28 @@ registerWriteTool( const browserUrl = `http://localhost:${port}?mcp=${sessionId}` await open(browserUrl) + // A saved diagram comes back from its file. lastSeenXml stays + // empty, so edit_diagram asks for get_diagram first. + const restored = sessionState(sessionId) + let intro = "Session started successfully!" + if (session_id) { + // blank: the browser asked first and nothing was saved + if (restored && !restored.blank) { + const doc = parseMxfile(restored.xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageSummary = + pages.length > 0 + ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` + : "no pages parsed" + intro = `Resumed session ${sessionId}.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram.` + } else { + intro = `No saved diagram for ${sessionId}; starting blank.` + } + } + const savePath = autosaver.pathFor(sessionId) const saveNote = savePath - ? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue it in a later conversation, call start_session, then load_diagram with this path.` + ? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue this diagram in a later conversation, call start_session with session_id=${sessionId}. list_saved_diagrams lists older diagrams.` : "" const rulesNote = `\n\nYour own drawing rules: write them in ${customInstructionsPath()} (Markdown, up to 5000 characters, read on every call).` @@ -326,7 +380,7 @@ registerWriteTool( content: [ { type: "text", - text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`, + text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`, }, ], } @@ -342,6 +396,83 @@ registerWriteTool( }, ) +/** "2026-10-04 09:30" in the server's local time */ +function formatLocalTime(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +// Tool: list_saved_diagrams +server.registerTool( + "list_saved_diagrams", + { + title: "List saved diagrams", + description: + "List the diagrams saved by earlier sessions (auto-save files), newest first, with their pages. " + + "Continue one with start_session session_id=; the file path also works with load_diagram.", + inputSchema: {}, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async () => { + try { + const dir = autosaver.dataDir() + if (!dir) { + return { + content: [ + { + type: "text", + text: "Auto-save is off (DRAWIO_DATA_DIR=off), so there are no saved diagrams.", + }, + ], + } + } + const saved = autosaver.list() + if (saved.length === 0) { + return { + content: [ + { type: "text", text: `No saved diagrams in ${dir}.` }, + ], + } + } + const fs = await import("node:fs/promises") + const lines: string[] = [] + for (const { sessionId, path, savedAt } of saved) { + // A file may hold compressed pages (saved by draw.io itself) + let pages = "unreadable" + try { + const loaded = parseDrawioFileContent( + await fs.readFile(path, "utf-8"), + ) + const doc = loaded.ok ? parseMxfile(loaded.xml) : null + if (doc) { + pages = listPagesFromDoc(doc) + .map((p) => `${p.name} (${p.cellCount} cells)`) + .join(", ") + } + } catch (error) { + log.warn( + `Could not read the saved diagram ${path}: ${error}`, + ) + } + const current = + sessionId === currentSession?.id ? " (current)" : "" + lines.push( + `${sessionId} saved ${formatLocalTime(savedAt)} pages: ${pages} file: ${path}${current}`, + ) + } + return { content: [{ type: "text", text: lines.join("\n") }] } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("list_saved_diagrams failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + // Tool: create_new_diagram registerWriteTool( "create_new_diagram", @@ -1915,6 +2046,148 @@ registerWriteTool( }, ) +/** One line of pages for a history entry, for restore_version's texts. */ +function describePages(xml: string): string { + const doc = parseMxfile(xml) + const pages = doc ? listPagesFromDoc(doc) : [] + return pages.length > 0 + ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` + : "no pages parsed" +} + +/** The versions restore_version can go to, numbered by steps_back. */ +function describeVersions(entries: HistoryEntry[]): string { + return entries + .slice(0, 20) + .map((entry, i) => { + const doc = parseMxfile(entry.xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const names = + pages.length > 0 + ? pages + .map((p) => `"${p.name}" (${p.cellCount} cells)`) + .join(", ") + : "no pages parsed" + return `steps_back=${i + 1}: ${names}` + }) + .join(" | ") +} + +// Tool: restore_version +registerWriteTool( + "restore_version", + { + title: "Restore version", + description: + "Undo: put an earlier version of the diagram from History back on the canvas (the current canvas is kept in History, so this can be undone too). Use it when the user asks to undo, revert or go back.", + inputSchema: { + steps_back: z + .number() + .int() + .min(1) + .optional() + .describe( + "1 = the newest version that differs from the canvas (default), 2 = the one before it, and so on. After an undo, steps_back=1 returns to the version you just left (redo) and steps_back=2 goes further back.", + ), + }, + annotations: { destructiveHint: false, openWorldHint: false }, + }, + async (input) => { + const stepsBack = input?.steps_back ?? 1 + try { + if (!currentSession) { + return { + content: [ + { + type: "text", + text: "Error: No active session. Please call start_session first.", + }, + ], + isError: true, + } + } + const session = currentSession + + // The canvas, with the user's manual edits, is the state store + const current = sessionState(session.id)?.xml || session.xml + const candidates = otherVersions(session.id, current) + if (candidates.length === 0) { + return { + content: [ + { + type: "text", + text: "Error: No other version in History.", + }, + ], + isError: true, + } + } + if (candidates.length < stepsBack) { + return { + content: [ + { + type: "text", + text: `Error: Only ${candidates.length} other version(s) in History: ${describeVersions(candidates)}`, + }, + ], + isError: true, + } + } + const entry = candidates[stepsBack - 1] + + // Manual edits since the last entry are not in History yet; + // restoreHistoryEntry keeps them (a blank page excepted) + const hadManual = + contentFingerprint(current) !== + contentFingerprint(BLANK_MXFILE) && + !isInHistory(session.id, current) + + if (restoreHistoryEntry(session.id, entry.id) === null) { + return { + content: [ + { + type: "text", + text: "Error: That version is no longer in History.", + }, + ], + isError: true, + } + } + session.xml = entry.xml + session.version++ + // As load_diagram: the model may never have seen this XML, so + // the edit gate requires one get_diagram before edits + session.lastSeenXml = "" + + log.info( + `Restored the version ${stepsBack} step(s) back (entry ${entry.id})`, + ) + + const manualNote = hadManual + ? " Your manual changes were kept in History (steps_back=1 brings them back)." + : "" + const next = + describeVersions(otherVersions(session.id, entry.xml)) || "none" + return { + content: [ + { + type: "text", + text: `Restored the version ${stepsBack} step(s) back.${manualNote}\n\n${describePages(entry.xml)}\n\nVersions you can go to now: ${next}\n\nCall get_diagram before edit_diagram.`, + }, + ], + } + } 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, + } + } + }, +) + // Graceful shutdown handler let isShuttingDown = false function gracefulShutdown(reason: string) { diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts index 2ba44b44..135792c0 100644 --- a/packages/mcp-server/src/persistence.ts +++ b/packages/mcp-server/src/persistence.ts @@ -3,7 +3,8 @@ * diagram survives the MCP process (hosts start a new one when a * conversation is resumed). Like the web app's IndexedDB sessions * (lib/session-storage.ts): saved 1 second after the last change, at most - * 50 kept. History is not saved. + * 50 kept. The session's History (the XML of its last 20 versions, without + * the thumbnails) is saved next to it as .history.json. */ import { @@ -12,6 +13,7 @@ import { readdirSync, readFileSync, renameSync, + rmSync, statSync, unlinkSync, writeFileSync, @@ -65,6 +67,10 @@ export class Autosaver { private dir: string | null, private delayMs = DELAY_MS, private maxFiles = MAX_FILES, + // The session's History entries, saved next to its diagram + private history: ( + sessionId: string, + ) => { id: number; xml: string }[] = () => [], ) {} /** Path of a session's file, or null when saving is off. */ @@ -72,6 +78,60 @@ export class Autosaver { return this.dir ? join(this.dir, `${sessionId}.drawio`) : null } + /** Path of a session's History file, or null when saving is off. */ + historyPathFor(sessionId: string): string | null { + return this.dir ? join(this.dir, `${sessionId}.history.json`) : null + } + + /** The XML of the session's saved History entries, oldest first. */ + loadHistory(sessionId: string): string[] { + const path = this.historyPathFor(sessionId) + if (!path) return [] + try { + const entries: unknown = JSON.parse(readFileSync(path, "utf-8")) + return Array.isArray(entries) && + entries.every((e) => typeof e === "string") + ? entries + : [] + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + log.warn(`Could not read the saved History ${path}: ${error}`) + } + return [] + } + } + + /** The folder the files are saved in, or null when saving is off. */ + dataDir(): string | null { + return this.dir + } + + /** + * Every file a session can resume (start_session's own files and + * hand-named ones such as mcp-notes.drawio), newest first. Empty when + * saving is off or the folder does not exist yet. + */ + list(): { sessionId: string; path: string; savedAt: Date }[] { + return this.files(/^mcp-[a-z0-9-]{1,64}\.drawio$/).map((file) => ({ + sessionId: file.name.slice(0, -".drawio".length), + path: file.path, + savedAt: file.mtime, + })) + } + + /** The files in the folder whose name matches, newest first */ + private files(pattern: RegExp) { + const dir = this.dir + if (!dir || !existsSync(dir)) return [] + return readdirSync(dir) + .filter((name) => pattern.test(name)) + .map((name) => { + const path = join(dir, name) + return { name, path, mtime: statSync(path).mtime } + }) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) + } + // Saved files that could not be read back: never written over, since // the session then shows something else than what they hold. Cleared // once the file is read, or is surely gone (a folder without permission @@ -132,30 +192,60 @@ export class Autosaver { } try { const isNew = !existsSync(path) - // A blank page the browser shows before any drawing: nothing to keep - if (isNew && isBlank(entry.xml)) return + // A blank page the browser shows before any drawing: nothing to + // keep (unless the diagram was cleared before its first save) + if ( + isNew && + isBlank(entry.xml) && + this.history(sessionId).every((e) => isBlank(e.xml)) + ) + return mkdirSync(this.dir, { recursive: true }) // Write to a temporary file first so a crash never leaves half a file writeFileSync(`${path}.tmp`, entry.xml, "utf-8") renameSync(`${path}.tmp`, path) + this.writeHistory(sessionId) if (isNew) this.removeOldest() } catch (error) { log.warn(`Auto-save failed for ${path}: ${error}`) } } + // What each session's History file holds (entry count and last id): + // draw.io autosaves often, and the file is only rewritten when the + // entries changed + private historyKeys = new Map() + + // Saved with the diagram, after the same delay, so the History entry + // that follows every tool write is included. The thumbnails are left + // out (large); the History grid shows the entry's number instead. + private writeHistory(sessionId: string): void { + const path = this.historyPathFor(sessionId) + const entries = this.history(sessionId) + if (!path || entries.length === 0) return + const key = `${entries.length}:${entries[entries.length - 1].id}` + if (this.historyKeys.get(sessionId) === key) return + try { + const json = JSON.stringify(entries.map((e) => e.xml)) + writeFileSync(`${path}.tmp`, json, "utf-8") + renameSync(`${path}.tmp`, path) + this.historyKeys.set(sessionId, key) + } catch (error) { + log.warn(`Saving the History failed for ${path}: ${error}`) + } + } + private removeOldest(): void { - if (!this.dir) return - const dir = this.dir // Only our own session files (mcp-", "") + const arch = B.replace('name="P"', 'name="Arch"') + const overview = B.replace('name="P"', 'name="Overview"') + fill(id, [bareX, arch]) + expect(otherVersions(id, overview).map((e) => e.xml)).toEqual([ + arch, + bareX, + ]) + expect(isInHistory(id, overview)).toBe(false) + }) + + it("returns nothing for an empty history", () => { + const id = "history-empty" + clearHistory(id) + expect(otherVersions(id, A)).toEqual([]) + }) +}) diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index 1f4f2bca..787df286 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -14,8 +14,10 @@ import { getState, keepInHistory, onSessionRecreate, + onStateChange, requestExport, requestSync, + restoreHistoryEntry, setState, shutdown, startHttpServer, @@ -256,6 +258,25 @@ describe("POST /api/state", () => { expect(history.at(-1)?.xml).toBe("lost user edit") }) + it("asks for a save when a lost user edit is kept in history", async () => { + const id = "mcp-conflict-save" + setState(id, "user v1", undefined, true) + const aiVersion = setState(id, "AI edit") + const saves: string[] = [] + onStateChange((sessionId, xml) => saves.push(`${sessionId} ${xml}`)) + try { + await postJson("/api/state", { + sessionId: id, + xml: "lost user edit", + baseVersion: aiVersion - 1, + }) + } finally { + onStateChange(() => {}) + } + // The canvas stays the AI write; the save takes the new History + expect(saves).toEqual([`${id} AI edit`]) + }) + it("ends a pending sync when the sync reply is older than an AI write", async () => { const id = "mcp-stale-sync" setState(id, "before", undefined, true) @@ -468,6 +489,34 @@ describe("history restore", () => { const page = (cellId: string) => `` + it("refuses a restore picked from a state the server has since lost", async () => { + const id = "mcp-history-stale-state" + setState(id, page("now")) + addHistory(id, page("old")) + const [entry] = getHistory(id) + const stale = await postJson("/api/restore", { + sessionId: id, + id: entry.id, + stateId: "a-lost-state", + }) + expect(stale.status).toBe(409) + // A list taken before the tab knew the state + const unknown = await postJson("/api/restore", { + sessionId: id, + id: entry.id, + stateId: null, + }) + expect(unknown.status).toBe(409) + expect(getState(id)?.xml).toBe(page("now")) + const fresh = await postJson("/api/restore", { + sessionId: id, + id: entry.id, + stateId: getState(id)?.stateId, + }) + expect(fresh.status).toBe(200) + expect(getState(id)?.xml).toBe(page("old")) + }) + // A thumbnail the tab took after loading the server write at `version` const thumbnail = (id: string, svg: string, version: number) => postJson("/api/history-svg", { @@ -664,6 +713,32 @@ describe("history restore", () => { expect(getState(id)?.xml).toBe(doc("ai")) expect(getHistory(id).map((e) => e.xml)).toContain(doc("manual")) }) + + it("restoreHistoryEntry leaves the canvas alone for an unknown entry", () => { + const id = "mcp-restore-unknown" + setState(id, page("x")) + addHistory(id, page("x")) + expect(restoreHistoryEntry(id, 999999)).toBeNull() + expect(getState(id)?.xml).toBe(page("x")) + expect(getHistory(id)).toHaveLength(1) + }) + + it("restoreHistoryEntry restores an entry and keeps the canvas in History", () => { + const id = "mcp-restore-entry" + addHistory(id, page("old")) + const [entry] = getHistory(id) + // The canvas is a later state that is not in History yet + setState(id, page("ai")) + + const newVersion = restoreHistoryEntry(id, entry.id) + expect(newVersion).toBe(getState(id)?.version) + expect(getState(id)?.xml).toBe(page("old")) + expect(getHistory(id).map((e) => e.xml)).toEqual([ + page("old"), + page("ai"), + page("old"), + ]) + }) }) describe("bodies over the size limit", () => { diff --git a/packages/mcp-server/tests/persistence.test.ts b/packages/mcp-server/tests/persistence.test.ts index 8e4ddd66..7c2882d4 100644 --- a/packages/mcp-server/tests/persistence.test.ts +++ b/packages/mcp-server/tests/persistence.test.ts @@ -9,6 +9,7 @@ import { readdirSync, readFileSync, rmSync, + statSync, utimesSync, writeFileSync, } from "node:fs" @@ -84,10 +85,14 @@ describe("Autosaver", () => { writeFileSync(join(dir, `${id}.drawio`), DIAGRAM) utimesSync(join(dir, `${id}.drawio`), 1000 + i, 1000 + i) } + // A session's History file goes with its diagram + writeFileSync(join(dir, "mcp-mgd0a1b2-old123.history.json"), "[]") + writeFileSync(join(dir, "mcp-mgd0a1b3-mid456.history.json"), "[]") saver.schedule("mcp-mgd0a1b4-new789", DIAGRAM) saver.flush() expect(readdirSync(dir).sort()).toEqual([ "mcp-mgd0a1b3-mid456.drawio", + "mcp-mgd0a1b3-mid456.history.json", "mcp-mgd0a1b4-new789.drawio", "mcp-notes.drawio", "mcp-system-design-v2.drawio", @@ -188,9 +193,175 @@ describe("Autosaver", () => { it("does nothing when saving is off", () => { const saver = new Autosaver(null) expect(saver.pathFor("mcp-x")).toBeNull() + expect(saver.historyPathFor("mcp-x")).toBeNull() + expect(saver.loadHistory("mcp-x")).toEqual([]) saver.schedule("mcp-x", DIAGRAM) saver.flush() }) + + it("lists every session file newest first, hand-named ones included", () => { + const dir = tempDir() + const saver = new Autosaver(dir, 10) + const names = [ + "mcp-mgd0a1b2-old123.drawio", + "mine.drawio", + "mcp-notes.drawio", + "mcp-readme.txt", + "mcp-mgd0a1b3-new456.drawio", + ] + for (const [i, name] of names.entries()) { + writeFileSync(join(dir, name), DIAGRAM) + utimesSync(join(dir, name), 1000 + i, 1000 + i) + } + expect(saver.dataDir()).toBe(dir) + expect(saver.list()).toEqual([ + { + sessionId: "mcp-mgd0a1b3-new456", + path: join(dir, "mcp-mgd0a1b3-new456.drawio"), + savedAt: new Date(1004 * 1000), + }, + { + sessionId: "mcp-notes", + path: join(dir, "mcp-notes.drawio"), + savedAt: new Date(1002 * 1000), + }, + { + sessionId: "mcp-mgd0a1b2-old123", + path: join(dir, "mcp-mgd0a1b2-old123.drawio"), + savedAt: new Date(1000 * 1000), + }, + ]) + }) + + it("lists nothing when saving is off or the folder does not exist", () => { + const off = new Autosaver(null) + expect(off.dataDir()).toBeNull() + expect(off.list()).toEqual([]) + expect(new Autosaver(join(tempDir(), "missing")).list()).toEqual([]) + }) +}) + +describe("Autosaver History", () => { + const V1 = DIAGRAM.replace('id="a"', 'id="v1"') + const V2 = DIAGRAM.replace('id="a"', 'id="v2"') + const V3 = DIAGRAM.replace('id="a"', 'id="v3"') + // One session's History entries, as history.ts keeps them (the saver + // leaves the thumbnails out) + const withHistory = (dir: string, entries: { id: number; xml: string }[]) => + new Autosaver(dir, 30, 50, () => entries) + + it("saves the XML of the History entries next to the diagram", async () => { + const dir = tempDir() + const entries = [ + { id: 0, xml: V1 }, + { id: 1, xml: V2 }, + ] + const saver = withHistory(dir, entries) + const path = saver.historyPathFor("mcp-h") as string + expect(path).toBe(join(dir, "mcp-h.history.json")) + saver.schedule("mcp-h", V2) + expect(existsSync(path)).toBe(false) + await sleep(80) + expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual([V1, V2]) + expect(saver.loadHistory("mcp-h")).toEqual([V1, V2]) + expect(readdirSync(dir).sort()).toEqual([ + "mcp-h.drawio", + "mcp-h.history.json", + ]) + }) + + it("saves a diagram cleared before its first save, with its History", () => { + const dir = tempDir() + const saver = withHistory(dir, [{ id: 0, xml: V1 }]) + saver.schedule("mcp-cleared", BLANK) + saver.flush() + expect( + readFileSync(saver.pathFor("mcp-cleared") as string, "utf-8"), + ).toBe(BLANK) + expect(saver.loadHistory("mcp-cleared")).toEqual([V1]) + }) + + it("still skips a blank page whose History holds only blank pages", () => { + const dir = tempDir() + const saver = withHistory(dir, [{ id: 0, xml: BLANK }]) + saver.schedule("mcp-empty", BLANK) + saver.flush() + expect(readdirSync(dir)).toEqual([]) + }) + + it("writes the History again after the cap removed it", () => { + const dir = tempDir() + const entries = [{ id: 0, xml: V1 }] + const saver = new Autosaver(dir, 10, 1, () => entries) + const old = "mcp-aaaaaaaa-old" + saver.schedule(old, V1) + saver.flush() + utimesSync(saver.pathFor(old) as string, 1000, 1000) + // A newer session pushes the old one out + saver.schedule("mcp-aaaaaaab-new", V1) + saver.flush() + expect(existsSync(saver.historyPathFor(old) as string)).toBe(false) + // The old session is still open and saves again, History unchanged + saver.schedule(old, V1) + saver.flush() + expect(saver.loadHistory(old)).toEqual([V1]) + }) + + it("rewrites the History file only when the entries changed", () => { + const dir = tempDir() + const entries = [{ id: 0, xml: V1 }] + const saver = withHistory(dir, entries) + saver.schedule("mcp-same", V1) + saver.flush() + const path = saver.historyPathFor("mcp-same") as string + utimesSync(path, 1000, 1000) + const old = statSync(path).mtimeMs + + // draw.io's own autosave of an unchanged diagram + saver.schedule("mcp-same", V1) + saver.flush() + expect(statSync(path).mtimeMs).toBe(old) + + entries.push({ id: 1, xml: V2 }) + saver.schedule("mcp-same", V2) + saver.flush() + expect(statSync(path).mtimeMs).not.toBe(old) + expect(saver.loadHistory("mcp-same")).toEqual([V1, V2]) + + // The circular buffer dropped the oldest entry: same length, new id + entries.shift() + entries.push({ id: 2, xml: V3 }) + saver.schedule("mcp-same", V3) + saver.flush() + expect(saver.loadHistory("mcp-same")).toEqual([V2, V3]) + }) + + it("writes no History file while there are no entries", () => { + const dir = tempDir() + const saver = withHistory(dir, []) + saver.schedule("mcp-empty", DIAGRAM) + saver.flush() + expect(readdirSync(dir)).toEqual(["mcp-empty.drawio"]) + }) + + it("reads [] for a missing or malformed History file", () => { + const dir = tempDir() + const saver = new Autosaver(dir, 10) + const path = saver.historyPathFor("mcp-bad") as string + expect(saver.loadHistory("mcp-bad")).toEqual([]) + for (const content of ["{not json", '{"a":1}', "[1, 2]", '"x"']) { + writeFileSync(path, content) + expect(saver.loadHistory("mcp-bad")).toEqual([]) + } + }) + + it("flush() writes the History on shutdown", () => { + const dir = tempDir() + const saver = withHistory(dir, [{ id: 0, xml: V1 }]) + saver.schedule("mcp-exit", V1) + saver.flush() + expect(saver.loadHistory("mcp-exit")).toEqual([V1]) + }) }) describe("defaultDataDir", () => { diff --git a/packages/mcp-server/tests/server-wiring.test.ts b/packages/mcp-server/tests/server-wiring.test.ts index be63a499..31fbf119 100644 --- a/packages/mcp-server/tests/server-wiring.test.ts +++ b/packages/mcp-server/tests/server-wiring.test.ts @@ -44,15 +44,21 @@ const EXPECTED_TOOLS = [ "get_drawing_guide", "get_shape_library", "screenshot_diagram", + "list_saved_diagrams", + "restore_version", ] +// Auto-save folder of the spawned server, holding one saved session +const dataDir = mkdtempSync(path.join(tmpdir(), "mcp-wiring-")) +const SAVED_ID = "mcp-aaaaaaaa-bbb" +const SAVED_DIAGRAM = `` + // Claude Code truncates tool descriptions and server instructions here const MAX_DESCRIPTION = 2048 // The server reads /instructions.md into the guide; a // developer's real ~/.next-ai-drawio/instructions.md must not leak in const CUSTOM_RULE = "Always use blue fill #dae8fc." -let dataDir: string let proc: ChildProcessWithoutNullStreams let stdoutBuf = "" @@ -79,8 +85,8 @@ function send(method: string, params: unknown, isNotification = false) { } beforeAll(async () => { - dataDir = mkdtempSync(path.join(tmpdir(), "mcp-wiring-")) writeFileSync(path.join(dataDir, "instructions.md"), CUSTOM_RULE) + writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM) proc = spawn(tsxBin, [entry], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, DRAWIO_DATA_DIR: dataDir }, @@ -147,6 +153,29 @@ describe("MCP server wiring", () => { expect(props.page_index).toBeTruthy() }) + it("advertises session_id on start_session", async () => { + const resp = await send("tools/list", {}) + const start = resp.result.tools.find( + (t: { name: string }) => t.name === "start_session", + ) + expect(start?.inputSchema?.properties?.session_id).toBeTruthy() + expect(start?.inputSchema?.required ?? []).not.toContain("session_id") + }) + + it("lists the saved diagrams with their pages, without a session", async () => { + const resp = await send("tools/call", { + name: "list_saved_diagrams", + arguments: {}, + }) + expect(resp.result.isError).toBeFalsy() + const text: string = resp.result.content[0].text + const line = text.split("\n").find((l) => l.startsWith(SAVED_ID)) + expect(line, text).toBeTruthy() + expect(line).toContain("pages: Flow (3 cells)") + expect(line).toContain(path.join(dataDir, `${SAVED_ID}.drawio`)) + expect(line).not.toContain("(current)") + }) + it("advertises name/id/xml on add_page", async () => { const resp = await send("tools/list", {}) const addPage = resp.result.tools.find( @@ -158,6 +187,23 @@ describe("MCP server wiring", () => { expect(props.xml).toBeTruthy() }) + it("advertises steps_back on restore_version", async () => { + const resp = await send("tools/list", {}) + const restore = resp.result.tools.find( + (t: { name: string }) => t.name === "restore_version", + ) + expect(restore?.inputSchema?.properties?.steps_back).toBeTruthy() + }) + + it("refuses restore_version without a session", async () => { + const resp = await send("tools/call", { + name: "restore_version", + arguments: {}, + }) + expect(resp.result.isError).toBe(true) + expect(resp.result.content[0].text).toContain("start_session") + }) + it("keeps every description and the instructions within the host limit", async () => { const resp = await send("tools/list", {}) for (const tool of resp.result.tools) { diff --git a/tests/unit/mcp-preview-recovery.test.ts b/tests/unit/mcp-preview-recovery.test.ts index cf7b4286..dbb28f91 100644 --- a/tests/unit/mcp-preview-recovery.test.ts +++ b/tests/unit/mcp-preview-recovery.test.ts @@ -434,3 +434,80 @@ describe("MCP preview with a diagram over the size limit", () => { ) }) }) + +describe("MCP preview History and a recreated session", () => { + /** The pending request to a URL that starts with prefix */ + const take = (t: Awaited>, prefix: string) => { + const call = t.calls.find((c) => c.url.startsWith(prefix)) + if (!call) throw new Error(`no pending request to ${prefix}`) + t.calls.splice(t.calls.indexOf(call), 1) + return call + } + const historyList = { + status: 200, + body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 }, + } + const modalOpen = () => + document.getElementById("history-modal")?.classList.contains("open") + + it("drops a History list asked for before the session was recreated", async () => { + const t = await inStep() + ;(document.getElementById("history-btn") as HTMLButtonElement).click() + await t.settle() + const list = take(t, "/api/history") + // The server restarts before the list arrives: its ids are old + const poll = t.page.poll() + take(t, "/api/state").answer(state("S2", 1, "A")) + await poll + list.answer(historyList) + await t.settle() + await t.settle() + expect(modalOpen()).toBe(false) + }) + + it("restores from an old list with that list's state", async () => { + const t = await inStep() + const history = document.getElementById( + "history-btn", + ) as HTMLButtonElement + history.click() + await t.settle() + take(t, "/api/history").answer(historyList) + await t.settle() + await t.settle() + // The server restarts; the open list closes + const poll = t.page.poll() + take(t, "/api/state").answer(state("S2", 1, "A")) + await poll + expect(modalOpen()).toBe(false) + // Asking again fails, so the old list shows + history.click() + await t.settle() + take(t, "/api/history").fail() + await t.settle() + expect(modalOpen()).toBe(true) + ;(document.querySelector(".history-item") as HTMLElement).click() + ;(document.getElementById("restore-btn") as HTMLButtonElement).click() + await t.settle() + // The server refuses it: the ids are S1's + expect(take(t, "/api/restore").body.stateId).toBe("S1") + }) + + it("names the state its History list belongs to when restoring", async () => { + const t = await inStep() + ;(document.getElementById("history-btn") as HTMLButtonElement).click() + await t.settle() + take(t, "/api/history").answer(historyList) + await t.settle() + await t.settle() + expect(modalOpen()).toBe(true) + ;(document.querySelector(".history-item") as HTMLElement).click() + ;(document.getElementById("restore-btn") as HTMLButtonElement).click() + await t.settle() + expect(take(t, "/api/restore").body).toMatchObject({ + sessionId: "mcp-test", + id: 5, + stateId: "S1", + }) + }) +})