diff --git a/packages/mcp-server/shell/mcp-sync-core.ts b/packages/mcp-server/shell/mcp-sync-core.ts index 84db2bef..3e323832 100644 --- a/packages/mcp-server/shell/mcp-sync-core.ts +++ b/packages/mcp-server/shell/mcp-sync-core.ts @@ -348,12 +348,17 @@ export function createMcpSync(options: SyncOptions): McpSync { const loadFromServer = (xml: string, how: "decide" | "full") => { lastXml = xml latestXml = xml + const before = canvas.currentXml() const decision: LoadDecision = how === "decide" - ? decideLoad(canvas.currentXml(), xml, canvas.currentPageId()) + ? decideLoad(before, xml, canvas.currentPageId()) : { mode: "load" } canvas.load(xml, decision) - awaitingCommitCopy = decision.mode === "commit" + // draw.io autosaves its copy only when the write changed its + // document: after one that left the canvas as it was, the next + // autosave is the user's + awaitingCommitCopy = + decision.mode === "commit" && !sameDiagram(before, xml) // currentVersion is the write's version thumbExport = { stateId, version: currentVersion, xml } setTimeout(captureThumbnail, 500) @@ -595,9 +600,11 @@ export function createMcpSync(options: SyncOptions): McpSync { // The whole export, result delivery included, ends in time: a result // POST that never answers must not keep the projection on screen // past the server's deadline (15 s). Only for this export: a later - // one may be running by then. + // one may be running by then. The export is over: its result POST + // answering after this must not restore the document a second time. setTimeout(() => { if (pendingMcpExport && seq === mcpExportSeq) { + mcpExportSeq++ pendingMcpExport = null restoreFromProjection() } diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts index 6d9b1f43..480f557e 100644 --- a/tests/unit/mcp-sync-core.test.ts +++ b/tests/unit/mcp-sync-core.test.ts @@ -753,6 +753,24 @@ describe("MCP sync export requests", () => { expect(t.sync.read().projectionActive).toBe(false) }) + it("does not restore the document twice when the result POST answers after the 15 s", async () => { + const { t, result } = await projectionExported() + await vi.advanceTimersByTimeAsync(15000) + t.next("GET").answer(state("S1", 2, "A")) + await t.settle() + await t.settle() + expect(t.loads).toHaveLength(2) + // The POST answers before draw.io reports the restore load: no + // second restore poll, no second load + result.answer({ status: 200, body: { success: true } }) + await t.settle() + await t.settle() + expect(t.calls.filter((c) => c.method === "GET")).toHaveLength(0) + expect(t.loads).toHaveLength(2) + t.sync.onDrawioLoad() + expect(t.sync.read().projectionActive).toBe(false) + }) + it("drops an export and reloads the document when draw.io starts over", async () => { const t = await inStep() const poll = t.sync.poll() @@ -1241,4 +1259,44 @@ describe("MCP sync with server writes taken in place", () => { baseVersion: 3, }) }) + + it("a change of the paper size after a write that changed nothing is pushed", async () => { + const t = await inStep({ currentPageId: "p1" }) + t.canvas.xml = canvasXml + let poll = t.sync.poll() + t.next("GET").answer(state("S1", 3, written)) + await poll + const drawioCopy = file( + drawioPage("p1", "One", cell("a", "A") + cell("c", "C", 300)) + + drawioPage("p2", "Two", cell("b", "B")), + ) + t.sync.onAutoSave(drawioCopy) + t.canvas.xml = drawioCopy + await t.settle() + expect(t.posts()).toHaveLength(0) + // The same document written again (the file loaded once more): the + // server has a new version, draw.io sees no change and sends no copy + poll = t.sync.poll() + t.next("GET").answer(state("S1", 4, written)) + await poll + expect(t.loads.at(-1)?.decision.mode).toBe("commit") + // The user picks A4: an edit, not the copy of the write + const a4 = file( + page( + "p1", + "One", + cell("a", "A") + cell("c", "C", 300), + ' dx="1000" dy="600" grid="1" pageWidth="827" pageHeight="1169"', + ) + drawioPage("p2", "Two", cell("b", "B")), + ) + t.sync.onAutoSave(a4) + expect(t.exports.map((e) => e.request.format)).toEqual(["svg"]) + t.nextExport().answer({ data: "" }) + await t.settle() + expect(t.next("POST").body).toMatchObject({ + xml: a4, + source: "edit", + baseVersion: 4, + }) + }) })