feat(mcp-server): auto-save each session's diagram to a .drawio file

- Save the latest diagram of every session 1 second after each change
  (AI write, browser edit, history restore) to ~/.next-ai-drawio/<id>.drawio,
  keep the newest 50, flush on shutdown; DRAWIO_DATA_DIR changes the folder
  and "off" disables it, like the web app's IndexedDB sessions
- start_session names the file, so a resumed conversation can reopen the
  diagram with load_diagram after the MCP process restarted
- Fix PNG/SVG exports randomly timing out: a previous export's 10 second
  timer cleared the export in progress, and a late reply could be taken
  for the current one; exports are now numbered
This commit is contained in:
dayuan.jiang
2026-10-04 06:42:44 +09:00
parent 81ad317375
commit 667f678eb3
6 changed files with 232 additions and 12 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.4.0",
"version": "0.5.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.31.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.4.0",
"version": "0.5.0",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
+26 -8
View File
@@ -87,7 +87,8 @@ function ensureSessionStateInitialized(sessionId: string): void {
if (!isValidSessionId(sessionId)) return
if (stateStore.has(sessionId)) return
setState(sessionId, DEFAULT_DIAGRAM_XML)
// Not a change worth saving: the browser fills it on its next push
setState(sessionId, DEFAULT_DIAGRAM_XML, undefined, false, false)
}
interface SessionState {
@@ -126,11 +127,21 @@ export function getState(sessionId: string): SessionState | undefined {
return stateStore.get(sessionId)
}
// Called after every state change (AI write, browser push, restore)
let stateListener: ((sessionId: string, xml: string) => void) | null = null
export function onStateChange(
listener: (sessionId: string, xml: string) => void,
): void {
stateListener = listener
}
export function setState(
sessionId: string,
xml: string,
svg?: string,
fromBrowser = false,
notify = true,
): number {
const existing = stateStore.get(sessionId)
const newVersion = (existing?.version || 0) + 1
@@ -148,6 +159,7 @@ export function setState(
exportData: existing?.exportData, // Preserve export result
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
if (notify) stateListener?.(sessionId, xml)
return newVersion
}
@@ -833,6 +845,7 @@ function getHtmlPage(sessionId: string): string {
let pendingSvgBase = 0; // version the pending autosave was based on
let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
let mcpExportSeq = 0; // number of the latest MCP export
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
let forceReload = false; // reload the server state on the next poll even if the version is unchanged
let noticeTimer = null;
@@ -873,6 +886,8 @@ function getHtmlPage(sessionId: string): string {
// back in msg.message, which tells it apart from autosave and
// preview SVG exports.
if (msg.message && msg.message.mcpExport) {
// A late reply to an export that already timed out
if (msg.message.mcpExport !== mcpExportSeq) return;
const d = msg.data;
const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png');
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
@@ -1025,14 +1040,16 @@ function getHtmlPage(sessionId: string): string {
// projection is showing (see projectionExportActive guard).
if (s.exportFormat && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat;
const seq = ++mcpExportSeq;
const extra = s.exportOptions || {};
const fireExport = () => {
// mcpExport is echoed back in msg.message (see the
// handler). PNG: width caps the size, pageId picks a
// page; without one draw.io would use the first page.
// mcpExport carries this export's number and is echoed
// back in msg.message (see the handler). PNG: width
// caps the size, pageId picks a page; without one
// draw.io would use the first page.
const exportOpts = pendingMcpExport === 'png'
? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: true }
: { action: 'export', format: 'svg', mcpExport: true };
? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: seq }
: { action: 'export', format: 'svg', mcpExport: seq };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
};
if (s.exportXml) {
@@ -1046,9 +1063,10 @@ function getHtmlPage(sessionId: string): string {
fireExport();
}
// Timeout: reset if draw.io never responds, and restore the
// real document if a projection was left showing.
// real document if a projection was left showing. Only for
// this export: a later one may be running by then.
setTimeout(() => {
if (pendingMcpExport) {
if (pendingMcpExport && seq === mcpExportSeq) {
pendingMcpExport = null;
restoreFromProjection();
}
+13 -1
View File
@@ -34,6 +34,7 @@ import {
type ExportOptions,
getServerPort,
getState,
onStateChange,
requestExport,
requestSync,
setState,
@@ -57,6 +58,7 @@ import {
serializeMxfile,
wrapCellsInModel,
} from "./pages.js"
import { Autosaver, defaultDataDir } from "./persistence.js"
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.js"
import { validateAndFixXml } from "./xml-validation.js"
@@ -68,6 +70,10 @@ 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())
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
// Session state (single session for simplicity)
let currentSession: {
id: string
@@ -267,13 +273,18 @@ server.registerTool(
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
await open(browserUrl)
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.`
: ""
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
return {
content: [
{
type: "text",
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.\n\n${DRAWING_GUIDE}`,
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}\n\n${DRAWING_GUIDE}`,
},
],
}
@@ -1731,6 +1742,7 @@ function gracefulShutdown(reason: string) {
if (isShuttingDown) return
isShuttingDown = true
log.info(`Shutting down: ${reason}`)
autosaver.flush()
shutdown()
process.exit(0)
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Auto-save of each session's latest diagram as a plain .drawio file, so a
* 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.
*/
import {
existsSync,
mkdirSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { log } from "./logger.js"
const DELAY_MS = 1000
const MAX_FILES = 50
/** DRAWIO_DATA_DIR, default ~/.next-ai-drawio; "off" disables saving. */
export function defaultDataDir(): string | null {
const dir = process.env.DRAWIO_DATA_DIR
if (dir === "off") return null
return dir || join(homedir(), ".next-ai-drawio")
}
/** Any cell besides the root cells "0" and "1" */
const hasCells = (xml: string) =>
/<(mxCell\b[^>]*\bid="(?![01]")|UserObject\b|object\b)/.test(xml)
export class Autosaver {
private pending = new Map<
string,
{ xml: string; timer: ReturnType<typeof setTimeout> }
>()
constructor(
private dir: string | null,
private delayMs = DELAY_MS,
private maxFiles = MAX_FILES,
) {}
/** Path of a session's file, or null when saving is off. */
pathFor(sessionId: string): string | null {
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
}
schedule(sessionId: string, xml: string): void {
if (!this.dir) return
const previous = this.pending.get(sessionId)
if (previous) clearTimeout(previous.timer)
const timer = setTimeout(() => this.write(sessionId), this.delayMs)
timer.unref?.()
this.pending.set(sessionId, { xml, timer })
}
/** Write every pending save now (on shutdown). */
flush(): void {
for (const [sessionId, { timer }] of this.pending) {
clearTimeout(timer)
this.write(sessionId)
}
}
private write(sessionId: string): void {
const entry = this.pending.get(sessionId)
this.pending.delete(sessionId)
const path = this.pathFor(sessionId)
if (!entry || !this.dir || !path) return
try {
const isNew = !existsSync(path)
// A blank page the browser shows before any drawing: nothing to keep
if (isNew && !hasCells(entry.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)
if (isNew) this.removeOldest()
} catch (error) {
log.warn(`Auto-save failed for ${path}: ${error}`)
}
}
private removeOldest(): void {
if (!this.dir) return
const dir = this.dir
const files = readdirSync(dir)
.filter((f) => f.endsWith(".drawio"))
.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))
}
}
}
@@ -0,0 +1,90 @@
/**
* Tests for the per-session .drawio auto-save (src/persistence.ts).
*/
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
utimesSync,
writeFileSync,
} from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import { Autosaver, defaultDataDir } from "../src/persistence.js"
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const BLANK = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const tempDir = () => mkdtempSync(join(tmpdir(), "mcp-autosave-"))
describe("Autosaver", () => {
it("writes the latest XML once changes settle", async () => {
const dir = tempDir()
const saver = new Autosaver(dir, 30)
saver.schedule("mcp-a", DIAGRAM.replace('id="a"', 'id="first"'))
saver.schedule("mcp-a", DIAGRAM)
const path = saver.pathFor("mcp-a") as string
expect(existsSync(path)).toBe(false)
await sleep(80)
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
expect(readdirSync(dir)).toEqual(["mcp-a.drawio"])
})
it("skips a blank page that was never saved, but saves clearing a diagram", async () => {
const dir = tempDir()
const saver = new Autosaver(dir, 10)
saver.schedule("mcp-blank", BLANK)
await sleep(40)
expect(existsSync(saver.pathFor("mcp-blank") as string)).toBe(false)
saver.schedule("mcp-b", DIAGRAM)
saver.flush()
saver.schedule("mcp-b", BLANK)
saver.flush()
expect(readFileSync(saver.pathFor("mcp-b") as string, "utf-8")).toBe(
BLANK,
)
})
it("keeps only the newest files", () => {
const dir = tempDir()
const saver = new Autosaver(dir, 10, 2)
for (const [i, id] of ["mcp-old", "mcp-mid"].entries()) {
writeFileSync(join(dir, `${id}.drawio`), DIAGRAM)
utimesSync(join(dir, `${id}.drawio`), 1000 + i, 1000 + i)
}
saver.schedule("mcp-new", DIAGRAM)
saver.flush()
expect(readdirSync(dir).sort()).toEqual([
"mcp-mid.drawio",
"mcp-new.drawio",
])
})
it("does nothing when saving is off", () => {
const saver = new Autosaver(null)
expect(saver.pathFor("mcp-x")).toBeNull()
saver.schedule("mcp-x", DIAGRAM)
saver.flush()
})
})
describe("defaultDataDir", () => {
const original = process.env.DRAWIO_DATA_DIR
afterEach(() => {
if (original === undefined) delete process.env.DRAWIO_DATA_DIR
else process.env.DRAWIO_DATA_DIR = original
})
it("reads DRAWIO_DATA_DIR, where off disables saving", () => {
process.env.DRAWIO_DATA_DIR = "off"
expect(defaultDataDir()).toBeNull()
process.env.DRAWIO_DATA_DIR = "/tmp/x"
expect(defaultDataDir()).toBe("/tmp/x")
delete process.env.DRAWIO_DATA_DIR
expect(defaultDataDir()).toMatch(/\.next-ai-drawio$/)
})
})