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,
+ })
+ })
})