fix(mcp-server): make edit_diagram all-or-nothing and fix preview sync races

- edit_diagram applies nothing when any operation fails, rejects invalid or
  multi-cell new_xml, validates only the target page, and returns the
  current page XML on every rejection (including stale edits)
- Fix get_diagram reading the old diagram right after an AI write: the
  preview pushed its sync reply with a newer version than it was taken at
- Keep a user edit that loses the race with an AI write in history and
  tell the user in the preview
- Autofix removes only exact foreign tags (a stray <mxGraph/> deleted
  <mxGraphModel>), fixes tag case, drops orphan <mxPoint>s, and rejects
  unknown element names in model XML
- Edit empty and compressed pages; PNG exports use the page on screen;
  tag download exports; reload from the server after a page export
- Expand ~ in paths, tell the model when the browser sync timed out,
  use registerPrompt, require SDK ^1.31.0
This commit is contained in:
dayuan.jiang
2026-10-03 22:03:44 +09:00
parent a46787c1b8
commit 6d67a0ec69
13 changed files with 712 additions and 204 deletions
+52 -1
View File
@@ -8,12 +8,14 @@
import http from "node:http"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { addHistory } from "../src/history.js"
import { addHistory, getHistory } from "../src/history.js"
import {
getState,
requestSync,
setState,
shutdown,
startHttpServer,
waitForSync,
} from "../src/http-server.js"
let port = 0
@@ -189,6 +191,55 @@ describe("POST /api/state", () => {
expect(getState(id)?.xml).toBe(xml)
}
})
it("keeps a rejected user edit in history", async () => {
const id = "mcp-conflict-history"
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
const before = getHistory(id).length
const stale = await postJson("/api/state", {
sessionId: id,
xml: "<mxfile>lost user edit</mxfile>",
baseVersion: aiVersion - 1,
})
expect(stale.status).toBe(409)
expect(JSON.parse(stale.body).savedToHistory).toBe(true)
const history = getHistory(id)
expect(history).toHaveLength(before + 1)
expect(history.at(-1)?.xml).toBe("<mxfile>lost user 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)
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
requestSync(id)
const before = getHistory(id).length
// The browser exported its old diagram, then loaded the AI write
const stale = await postJson("/api/state", {
sessionId: id,
xml: "<mxfile>before</mxfile>",
baseVersion: aiVersion - 1,
source: "sync",
})
expect(stale.status).toBe(409)
expect(JSON.parse(stale.body).savedToHistory).toBe(false)
expect(getState(id)?.xml).toBe("<mxfile>AI edit</mxfile>")
expect(getState(id)?.syncRequested).toBeUndefined()
expect(getHistory(id)).toHaveLength(before)
expect(await waitForSync(id, 200)).toBe(true)
})
})
describe("preview page", () => {
it("serves a script that parses", async () => {
const res = await request("/?mcp=mcp-test-script")
const script = res.body.match(/<script>([\s\S]*)<\/script>/)?.[1]
expect(script).toBeTruthy()
expect(() => new Function(script as string)).not.toThrow()
})
})
describe("history restore", () => {