feat(mcp-server): saved sessions, restore_version and History on disk (#981)

* feat(mcp-server): list and resume saved diagram sessions

start_session accepts an optional session_id to continue a saved diagram
under the same preview URL and auto-save file, reporting the restored
pages. New read-only tool list_saved_diagrams prints every auto-saved
file newest first with its pages. Autosaver gains list() and dataDir().
INSTRUCTIONS and both READMEs mention the new tool.

* feat(mcp-server): add restore_version tool for undo and redo from History

Add the restore_version write tool so the model can put an earlier
version from History back on the canvas (steps_back, default 1). The
current canvas is kept in History unless it is the blank page, so a redo
is possible. http-server.ts exports restoreHistoryEntry, shared by the
HTTP restore endpoint and the tool; history.ts exports HistoryEntry and
otherVersions (distinct versions other than the canvas, newest first).
The drawing guide tells the model to call restore_version when the user
asks to undo, and both READMEs document the tool.

* feat(mcp-server): save the diagram History next to the auto-saved file

The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files.

* fix(mcp-server): review fixes for saved sessions, undo tool, history on disk

* fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk

- start_session resumes every saved file, also a cleared one or one of
  empty named pages, and decompresses pages draw.io compressed
- History kept by a lost user edit or a recovering tab is saved too
- A diagram cleared before its first save keeps its History on disk
- The History file is written again after the 50-file cap removed it
- restore_version compares versions like the edit gate (a bare
  mxGraphModel from the browser has no page name), checks the restore
  result, and says "none" when no version is left
- The preview closes an open History list when the server state was
  recreated, since the entries got new ids
- start_session's description mentions session_id

* fix(mcp-server): compare History versions pairwise and tie restores to the server state

Second Codex review of the History fixes:
- One bare mxGraphModel in History no longer hides page renames in every
  comparison: names are left out only when one of the two is bare
- A History list asked for before the server recreated the session is
  dropped, and a restore names the state its list belongs to; the server
  refuses one from a lost state (History ids change after a restart)

* fix(mcp-server): restore from a History list with the state it was taken in

Third Codex review: a restore sent the tab's current state, so an old
list shown after a failed refresh passed the server's check. The list now
keeps the state it came from, and the server refuses a missing (null)
state too; only a tab of an older version, which sends none, is let
through.
This commit is contained in:
Dayuan Jiang
2026-10-11 20:23:23 +09:00
committed by GitHub
parent c774a39c33
commit 77be43d996
13 changed files with 1003 additions and 55 deletions
+77
View File
@@ -434,3 +434,80 @@ describe("MCP preview with a diagram over the size limit", () => {
)
})
})
describe("MCP preview History and a recreated session", () => {
/** The pending request to a URL that starts with prefix */
const take = (t: Awaited<ReturnType<typeof inStep>>, prefix: string) => {
const call = t.calls.find((c) => c.url.startsWith(prefix))
if (!call) throw new Error(`no pending request to ${prefix}`)
t.calls.splice(t.calls.indexOf(call), 1)
return call
}
const historyList = {
status: 200,
body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 },
}
const modalOpen = () =>
document.getElementById("history-modal")?.classList.contains("open")
it("drops a History list asked for before the session was recreated", async () => {
const t = await inStep()
;(document.getElementById("history-btn") as HTMLButtonElement).click()
await t.settle()
const list = take(t, "/api/history")
// The server restarts before the list arrives: its ids are old
const poll = t.page.poll()
take(t, "/api/state").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
list.answer(historyList)
await t.settle()
await t.settle()
expect(modalOpen()).toBe(false)
})
it("restores from an old list with that list's state", async () => {
const t = await inStep()
const history = document.getElementById(
"history-btn",
) as HTMLButtonElement
history.click()
await t.settle()
take(t, "/api/history").answer(historyList)
await t.settle()
await t.settle()
// The server restarts; the open list closes
const poll = t.page.poll()
take(t, "/api/state").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
expect(modalOpen()).toBe(false)
// Asking again fails, so the old list shows
history.click()
await t.settle()
take(t, "/api/history").fail()
await t.settle()
expect(modalOpen()).toBe(true)
;(document.querySelector(".history-item") as HTMLElement).click()
;(document.getElementById("restore-btn") as HTMLButtonElement).click()
await t.settle()
// The server refuses it: the ids are S1's
expect(take(t, "/api/restore").body.stateId).toBe("S1")
})
it("names the state its History list belongs to when restoring", async () => {
const t = await inStep()
;(document.getElementById("history-btn") as HTMLButtonElement).click()
await t.settle()
take(t, "/api/history").answer(historyList)
await t.settle()
await t.settle()
expect(modalOpen()).toBe(true)
;(document.querySelector(".history-item") as HTMLElement).click()
;(document.getElementById("restore-btn") as HTMLButtonElement).click()
await t.settle()
expect(take(t, "/api/restore").body).toMatchObject({
sessionId: "mcp-test",
id: 5,
stateId: "S1",
})
})
})