diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index 6d5af543..b991fe64 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -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", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 8ff8d633..f4120946 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.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", diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 00626a7a..38bc8115 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -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(' { - // 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(); } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 1e121fcc..53bd4f11 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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) } diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts new file mode 100644 index 00000000..02ec8208 --- /dev/null +++ b/packages/mcp-server/src/persistence.ts @@ -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 } + >() + + 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)) + } + } +} diff --git a/packages/mcp-server/tests/persistence.test.ts b/packages/mcp-server/tests/persistence.test.ts new file mode 100644 index 00000000..a6dda00c --- /dev/null +++ b/packages/mcp-server/tests/persistence.test.ts @@ -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 = `` +const BLANK = `` +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$/) + }) +})