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
+11 -2
View File
@@ -237,6 +237,8 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
// process restarted. Decide whose diagram wins.
function recoverState(s) {
stateId = s.stateId;
// History entries got new ids with the new state: an open list is stale
if (historyModal.classList.contains('open')) cancelBtn.onclick();
// The old state's pending work is gone with it
const projectionShown = projectionExportActive;
projectionExportActive = false;
@@ -453,18 +455,24 @@ const historyGrid = document.getElementById('history-grid');
const historyEmpty = document.getElementById('history-empty');
const restoreBtn = document.getElementById('restore-btn');
const cancelBtn = document.getElementById('cancel-btn');
let historyData = [], selectedId = null;
// historyStateId: the server state the list belongs to, sent with a restore
let historyData = [], historyStateId = null, selectedId = null;
historyBtn.onclick = async () => {
if (!sessionId) return;
// A list for a state the server recreated meanwhile has old ids
const sid = stateId;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
if (sid !== stateId) return;
historyData = d.entries || [];
historyStateId = sid;
renderHistory();
}
} catch {}
if (sid !== stateId) return;
historyModal.classList.add('open');
};
@@ -518,9 +526,10 @@ restoreBtn.onclick = async () => {
const r = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, id: selectedId })
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
});
if (r.ok) { cancelBtn.onclick(); await poll(); }
else if (r.status === 409) { cancelBtn.onclick(); showNotice('History changed on the server. Please open it again.'); }
else { showNotice('Restore failed. Please try again.'); }
} catch { showNotice('Restore failed. Please try again.'); }
restoreBtn.textContent = 'Restore';