mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
feat(mcp-server): saved sessions, restore_version and History on disk (#981)
* feat(mcp-server): list and resume saved diagram sessions start_session accepts an optional session_id to continue a saved diagram under the same preview URL and auto-save file, reporting the restored pages. New read-only tool list_saved_diagrams prints every auto-saved file newest first with its pages. Autosaver gains list() and dataDir(). INSTRUCTIONS and both READMEs mention the new tool. * feat(mcp-server): add restore_version tool for undo and redo from History Add the restore_version write tool so the model can put an earlier version from History back on the canvas (steps_back, default 1). The current canvas is kept in History unless it is the blank page, so a redo is possible. http-server.ts exports restoreHistoryEntry, shared by the HTTP restore endpoint and the tool; history.ts exports HistoryEntry and otherVersions (distinct versions other than the canvas, newest first). The drawing guide tells the model to call restore_version when the user asks to undo, and both READMEs document the tool. * feat(mcp-server): save the diagram History next to the auto-saved file The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files. * fix(mcp-server): review fixes for saved sessions, undo tool, history on disk * fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk - start_session resumes every saved file, also a cleared one or one of empty named pages, and decompresses pages draw.io compressed - History kept by a lost user edit or a recovering tab is saved too - A diagram cleared before its first save keeps its History on disk - The History file is written again after the 50-file cap removed it - restore_version compares versions like the edit gate (a bare mxGraphModel from the browser has no page name), checks the restore result, and says "none" when no version is left - The preview closes an open History list when the server state was recreated, since the entries got new ids - start_session's description mentions session_id * fix(mcp-server): compare History versions pairwise and tie restores to the server state Second Codex review of the History fixes: - One bare mxGraphModel in History no longer hides page renames in every comparison: names are left out only when one of the two is bare - A History list asked for before the server recreated the session is dropped, and a restore names the state its list belongs to; the server refuses one from a lost state (History ids change after a restart) * fix(mcp-server): restore from a History list with the state it was taken in Third Codex review: a restore sent the tab's current state, so an old list shown after a failed refresh passed the server's check. The list now keeps the state it came from, and the server refuses a missing (null) state too; only a tab of an older version, which sends none, is let through.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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/<session-id>.drawio`, so it survives a restart of the MCP client
|
||||
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, and its last 20 versions to `<session-id>.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 `<session-id>.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
|
||||
|
||||
|
||||
@@ -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 <mxCell id="0"/><mxCell id="1" parent="0"/>; 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 <mxfile>, <mxGraphModel>, <root> 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: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
||||
|
||||
@@ -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 <mxGraphModel> (the browser's sync
|
||||
// can send one) has no page name, so names count only when both have them
|
||||
function sameVersion(
|
||||
a: ReturnType<typeof versionOf>,
|
||||
b: ReturnType<typeof versionOf>,
|
||||
): 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=<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=<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) {
|
||||
|
||||
@@ -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 <session-id>.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<string, string>()
|
||||
|
||||
// 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-<time in base 36>-<random>, made
|
||||
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
|
||||
// the user's diagrams
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
|
||||
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
for (const { f } of files.slice(this.maxFiles)) {
|
||||
unlinkSync(join(dir, f))
|
||||
const own = this.files(/^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/)
|
||||
for (const { name, path } of own.slice(this.maxFiles)) {
|
||||
unlinkSync(path)
|
||||
// Its History goes with it (there may be none), and is written
|
||||
// again if the session saves once more
|
||||
rmSync(path.replace(/\.drawio$/, ".history.json"), { force: true })
|
||||
this.historyKeys.delete(name.slice(0, -".drawio".length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
// process restarted. Decide whose diagram wins.
|
||||
function recoverState(s) {
|
||||
stateId = s.stateId;
|
||||
// History entries got new ids with the new state: an open list is stale
|
||||
if (historyModal.classList.contains('open')) cancelBtn.onclick();
|
||||
// The old state's pending work is gone with it
|
||||
const projectionShown = projectionExportActive;
|
||||
projectionExportActive = false;
|
||||
@@ -453,18 +455,24 @@ const historyGrid = document.getElementById('history-grid');
|
||||
const historyEmpty = document.getElementById('history-empty');
|
||||
const restoreBtn = document.getElementById('restore-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
let historyData = [], selectedId = null;
|
||||
// historyStateId: the server state the list belongs to, sent with a restore
|
||||
let historyData = [], historyStateId = null, selectedId = null;
|
||||
|
||||
historyBtn.onclick = async () => {
|
||||
if (!sessionId) return;
|
||||
// A list for a state the server recreated meanwhile has old ids
|
||||
const sid = stateId;
|
||||
try {
|
||||
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (sid !== stateId) return;
|
||||
historyData = d.entries || [];
|
||||
historyStateId = sid;
|
||||
renderHistory();
|
||||
}
|
||||
} catch {}
|
||||
if (sid !== stateId) return;
|
||||
historyModal.classList.add('open');
|
||||
};
|
||||
|
||||
@@ -518,9 +526,10 @@ restoreBtn.onclick = async () => {
|
||||
const r = await fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, id: selectedId })
|
||||
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
|
||||
});
|
||||
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||
else if (r.status === 409) { cancelBtn.onclick(); showNotice('History changed on the server. Please open it again.'); }
|
||||
else { showNotice('Restore failed. Please try again.'); }
|
||||
} catch { showNotice('Restore failed. Please try again.'); }
|
||||
restoreBtn.textContent = 'Restore';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Tests for the diagram history: the versions restore_version can go to.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.ts"
|
||||
import {
|
||||
addHistory,
|
||||
clearHistory,
|
||||
getHistory,
|
||||
isInHistory,
|
||||
otherVersions,
|
||||
} from "../src/history.ts"
|
||||
|
||||
beforeAll(() => {
|
||||
// XML parsing for the content fingerprints
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
const page = (cellId: string) =>
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
const A = page("a")
|
||||
const B = page("b")
|
||||
const C = page("c")
|
||||
|
||||
// draw.io's copy of B: other attribute order, whitespace and viewport
|
||||
// attributes, same diagram
|
||||
const B_COPY = `<mxfile host="app.diagrams.net">
|
||||
<diagram name="P" id="p">
|
||||
<mxGraphModel dx="100" dy="200" pageWidth="850">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="b" parent="1" vertex="1" />
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>`
|
||||
|
||||
const fill = (id: string, versions: string[]) => {
|
||||
clearHistory(id)
|
||||
for (const xml of versions) addHistory(id, xml)
|
||||
}
|
||||
|
||||
describe("otherVersions", () => {
|
||||
it("lists the versions before the canvas, newest first", () => {
|
||||
const id = "history-abc"
|
||||
fill(id, [A, B, C])
|
||||
expect(otherVersions(id, C).map((e) => e.xml)).toEqual([B, A])
|
||||
})
|
||||
|
||||
it("lists each version once after an undo, the left one first", () => {
|
||||
const id = "history-abcb"
|
||||
fill(id, [A, B, C, B])
|
||||
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([C, A])
|
||||
})
|
||||
|
||||
it("lists every version when the canvas is in none of them", () => {
|
||||
const id = "history-manual"
|
||||
fill(id, [A, B, C])
|
||||
expect(otherVersions(id, page("manual")).map((e) => e.xml)).toEqual([
|
||||
C,
|
||||
B,
|
||||
A,
|
||||
])
|
||||
})
|
||||
|
||||
it("counts a re-serialised copy as the same version", () => {
|
||||
const id = "history-copy"
|
||||
fill(id, [A, B, B_COPY])
|
||||
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([A])
|
||||
expect(otherVersions(id, B_COPY).map((e) => e.xml)).toEqual([A])
|
||||
})
|
||||
|
||||
it("keeps the newest copy of a repeated version", () => {
|
||||
const id = "history-newest"
|
||||
fill(id, [A, B, A])
|
||||
const [newestA] = otherVersions(id, B)
|
||||
expect(newestA.id).toBe(getHistory(id)[2].id)
|
||||
expect(otherVersions(id, B)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("matches a bare model from the browser with its named page", () => {
|
||||
const id = "history-bare"
|
||||
const named = (xml: string) => xml.replace('name="P"', 'name="Arch"')
|
||||
fill(id, [named(A), named(B)])
|
||||
// The browser's sync sent page B without its <mxfile> wrapper
|
||||
const bareB = B.replace(/^.*?(<mxGraphModel>)/, "$1").replace(
|
||||
"</diagram></mxfile>",
|
||||
"",
|
||||
)
|
||||
expect(otherVersions(id, bareB).map((e) => e.xml)).toEqual([named(A)])
|
||||
expect(isInHistory(id, bareB)).toBe(true)
|
||||
expect(isInHistory(id, C)).toBe(false)
|
||||
})
|
||||
|
||||
it("still counts a rename when an unrelated bare model is in History", () => {
|
||||
const id = "history-mixed"
|
||||
const bareX = page("x")
|
||||
.replace(/^.*?(<mxGraphModel>)/, "$1")
|
||||
.replace("</diagram></mxfile>", "")
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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("<mxfile>lost user edit</mxfile>")
|
||||
})
|
||||
|
||||
it("asks for a save when a lost user edit is kept in history", async () => {
|
||||
const id = "mcp-conflict-save"
|
||||
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
|
||||
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
||||
const saves: string[] = []
|
||||
onStateChange((sessionId, xml) => saves.push(`${sessionId} ${xml}`))
|
||||
try {
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: "<mxfile>lost user edit</mxfile>",
|
||||
baseVersion: aiVersion - 1,
|
||||
})
|
||||
} finally {
|
||||
onStateChange(() => {})
|
||||
}
|
||||
// The canvas stays the AI write; the save takes the new History
|
||||
expect(saves).toEqual([`${id} <mxfile>AI edit</mxfile>`])
|
||||
})
|
||||
|
||||
it("ends a pending sync when the sync reply is older than an AI write", async () => {
|
||||
const id = "mcp-stale-sync"
|
||||
setState(id, "<mxfile>before</mxfile>", undefined, true)
|
||||
@@ -468,6 +489,34 @@ describe("history restore", () => {
|
||||
const page = (cellId: string) =>
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = `<mxfile><diagram id="p" name="Flow"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
// Claude Code truncates tool descriptions and server instructions here
|
||||
const MAX_DESCRIPTION = 2048
|
||||
|
||||
// The server reads <DRAWIO_DATA_DIR>/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) {
|
||||
|
||||
@@ -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<ReturnType<typeof inStep>>, 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, "<mxfile>A</mxfile>"))
|
||||
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, "<mxfile>A</mxfile>"))
|
||||
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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user