Files
next-ai-draw-io/packages/mcp-server/src/history.ts
T
Dayuan Jiang 77be43d996 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.
2026-10-11 20:23:23 +09:00

130 lines
3.7 KiB
TypeScript

/**
* Simple diagram history - matches Next.js app pattern
* Stores {xml, svg} entries in a circular buffer
*/
import { contentFingerprint } from "./edit-gate.ts"
import { log } from "./logger.ts"
import { isMxGraphModel } from "./pages.ts"
const MAX_HISTORY = 20
export interface HistoryEntry {
id: number // Stable across shifts of the circular buffer
xml: string
svg: string
}
let nextEntryId = 0
const historyStore = new Map<string, HistoryEntry[]>()
export function addHistory(sessionId: string, xml: string, svg = ""): number {
let history = historyStore.get(sessionId)
if (!history) {
history = []
historyStore.set(sessionId, history)
}
// Dedupe: skip if same as last entry (any other change, also of page
// settings only, is a new version; keepInHistory leaves out draw.io's
// copy of a server write)
const last = history[history.length - 1]
if (last && last.xml === xml) {
if (svg && !last.svg) last.svg = svg
return history.length - 1
}
history.push({ id: nextEntryId++, xml, svg })
// Circular buffer
if (history.length > MAX_HISTORY) {
history.shift()
}
log.debug(`History: session=${sessionId}, entries=${history.length}`)
return history.length - 1
}
export function getHistory(sessionId: string): HistoryEntry[] {
return historyStore.get(sessionId) || []
}
/** Look up an entry by its id; the array index shifts as old entries drop. */
export function getHistoryEntry(
sessionId: string,
id: number,
): HistoryEntry | undefined {
return historyStore.get(sessionId)?.find((entry) => entry.id === id)
}
export function clearHistory(sessionId: string): void {
historyStore.delete(sessionId)
}
/**
* The versions the diagram can go back (or forward) to: every entry whose
* content differs from currentXml, newest first, one entry per distinct
* content (its newest copy). Re-serialised copies of one diagram count as
* the same version.
*/
export function otherVersions(
sessionId: string,
currentXml: string,
): HistoryEntry[] {
const kept = [versionOf(currentXml)]
const result: HistoryEntry[] = []
const history = getHistory(sessionId)
for (let i = history.length - 1; i >= 0; i--) {
const version = versionOf(history[i].xml)
if (kept.some((seen) => sameVersion(seen, version))) continue
kept.push(version)
result.push(history[i])
}
return result
}
/** True when History holds a copy of this diagram. */
export function isInHistory(sessionId: string, xml: string): boolean {
const version = versionOf(xml)
return getHistory(sessionId).some((entry) =>
sameVersion(version, versionOf(entry.xml)),
)
}
/** A diagram's fingerprints, with and without its page names */
function versionOf(xml: string) {
return {
bare: isMxGraphModel(xml),
named: contentFingerprint(xml),
cells: contentFingerprint(xml, false),
}
}
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
// can send one) has no page name, so names count only when both have them
function sameVersion(
a: ReturnType<typeof versionOf>,
b: ReturnType<typeof versionOf>,
): boolean {
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
}
/**
* Give the last entry the image the browser took of shownXml, the diagram
* it just loaded, when that entry is this diagram
*/
export function updateLastHistorySvg(
sessionId: string,
svg: string,
shownXml: string,
): boolean {
const history = historyStore.get(sessionId)
if (!history || history.length === 0) return false
const last = history[history.length - 1]
if (!last.svg && last.xml === shownXml) {
last.svg = svg
return true
}
return false
}