Files
next-ai-draw-io/packages/mcp-server/shell/mcp-sync-core.ts
T
dayuan.jiang b6bd032c2c fix(mcp-server): review fixes for the shell's sync core
A recovery loads the server's diagram in full and waits for draw.io's
load report, as the classic page does: whether the canvas takes a write
in place is decided inside DiagramProvider, and an autosave of the
canvas being replaced went to the server as an edit when the decision
was wrong. A paper size change alone is pushed again: autosaves are
compared as documents only for draw.io's own copy of a committed write.
A projection stays on screen until draw.io reports the restore load
(5 s at most), the whole export ends in 15 s even when its result POST
hangs, and an export in flight is dropped when draw.io starts over. A
refused poll shows the tab offline. With several pages and the page on
screen unknown, a write loads in full. hasLoadOnlySettings and the
highlight of an AI change are shared with the editor bridge.
2026-10-11 20:56:07 +09:00

804 lines
31 KiB
TypeScript

/**
* The shell's side of the MCP server's state protocol (src/http-server.ts),
* ported from the classic preview page (src/preview/preview.js) without the
* page: the server is reached through fetch, the canvas through the
* SyncCanvas functions the hook (use-mcp-sync.ts) passes in. No React here,
* so the state machine can be driven step by step in tests.
*
* The protocol in short: the tab polls GET /api/state every 2 s and loads a
* newer version; a user edit is pushed with the version and state it is
* based on, and the server refuses (409) a push based on a version the AI
* has since written over, or on a state it has lost (it expired, the
* process restarted); the next poll then decides whose diagram wins
* (recoverState). get_diagram asks for a sync (the canvas as draw.io has
* it) and screenshots and file exports for an image, both through the poll.
*/
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
import { hasLoadOnlySettings } from "@/lib/drawio/editor-bridge"
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
import {
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "@/packages/mcp-server/src/pages.ts"
export type ExportFormat = "png" | "svg" | "xmlsvg"
/** GET /api/state as the server answers it */
export interface ServerState {
xml: string | null
version: number
stateId: string | null
blank: boolean
syncRequested: boolean
exportFormat: ExportFormat | null
exportXml: string | null
exportOptions: { width?: number; pageId?: string } | null
exportId: string | null
}
/** An entry of GET /api/history */
export interface HistoryEntryInfo {
id: number
index: number
svg: string
}
/**
* How a server write goes on the canvas: as one undo step on the page the
* user is viewing (the editor highlights it and Ctrl+Z takes it back), or
* as a full load of the document (see decideLoad)
*/
export type LoadDecision =
| { mode: "commit"; pageId: string | null }
| { mode: "load" }
export interface ExportRequest {
format: string
[key: string]: unknown
}
export interface ExportResult {
data?: string
xml?: string
}
/** The canvas as the sync drives it (the hook maps it onto DiagramProvider) */
export interface SyncCanvas {
load(xml: string, decision: LoadDecision): void
/** Show a one-page projection for an export only: not recorded, its
* autosaves ignored until the next load */
showTransient(xml: string): void
/** draw.io's export; null when it does not answer in time */
export(
request: ExportRequest,
timeoutMs: number,
): Promise<ExportResult | null>
/** The document on the canvas now ("" before the first load) */
currentXml(): string
/** The page on screen; null when unknown (external draw.io) */
currentPageId(): string | null
}
export type SyncStatus = "waiting" | "connected" | "offline"
/** Texts shown to the user (the shell takes them from its dictionary) */
export type SyncNotice =
| "tooLarge"
| "unreachable"
| "restoredFromFile"
| "aiChanged"
| "historyChanged"
| "restoreFailed"
export interface SyncOptions {
sessionId: string
/** Prefix of the server's API paths ("/api") */
apiBase: string
/** Every request carries it in the X-Drawio-Token header */
token: string
canvas: SyncCanvas
onNotice: (notice: SyncNotice) => void
onStatus?: (status: SyncStatus) => void
/** The server made a new state for the session: History entries got
* new ids, so a list on screen is stale */
onStateRecreated?: () => void
/** The token of the process now answering on this port, read from a
* fresh copy of this page; default: tokenFromPage of location.href */
refreshToken?: () => Promise<string | null>
fetch?: typeof fetch
}
export interface McpSync {
/** Poll now and every 2 s */
start(): void
stop(): void
poll(): Promise<void>
/** draw.io is ready for exports (DiagramProvider's isDrawioReady) */
setReady(ready: boolean): void
/** draw.io sent an autosave: the canvas changed to this document */
onAutoSave(xml: string): void
/** draw.io reported a load done */
onDrawioLoad(): void
/** The server's History, with the state its ids belong to; null when
* it could not be read, or the state changed meanwhile. For the versions
* strip (plan step 5), with restoreEntry below; until then only the
* unit tests call them */
fetchHistory(): Promise<{
entries: HistoryEntryInfo[]
stateId: string | null
} | null>
/** Put a History entry back, naming the state its list belongs to */
restoreEntry(
id: number,
listStateId: string | null,
): Promise<"ok" | "stale" | "failed">
/** For tests and the status bar */
read(): {
stateId: string | null
currentVersion: number
lastXml: string | null
latestXml: string | null
projectionActive: boolean
status: SyncStatus
}
}
export const POLL_INTERVAL_MS = 2000
/** The API token the server wrote into a shell page (window.__MCP_CONFIG__) */
export function tokenFromPage(html: string): string | null {
const match = html.match(/window\.__MCP_CONFIG__ = (\{.*?\});/)
if (!match) return null
try {
const token = JSON.parse(match[1]).token
return typeof token === "string" && token ? token : null
} catch {
return null
}
}
/**
* The same diagram, as text or as documents (draw.io re-serializes what it
* loads: another attribute order, filled-in defaults). Only documents with
* a page or a model are compared as documents: two without any would
* otherwise count as the same.
*/
function sameDiagram(a: string | null, b: string | null): boolean {
if (a === b) return true
if (a === null || b === null) return false
const hasModel = (xml: string) => /<(mxGraphModel|diagram)[\s>]/.test(xml)
return hasModel(a) && hasModel(b) && isSameDocument(a, b)
}
/** SVG as a data URL, as the server stores thumbnails */
function svgDataUrl(svg: string): string {
if (svg.startsWith("data:")) return svg
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
}
/**
* Whether a server write can go on the canvas as one undo step: it has the
* same pages as the canvas, changes only the one the user is viewing (the
* one with currentPageId; a one-page document needs no id, several pages
* with the page unknown load in full), keeps the file variables and needs
* no load-only page setting. Pages are compared with the MCP core's
* contentFingerprint (names and cells). The editor bridge checks the same
* against the live editor and falls back to a full load on its own.
*/
export function decideLoad(
currentXml: string,
nextXml: string,
currentPageId: string | null,
): LoadDecision {
const parse = (xml: string) => {
const normalized = normalizeToMxfile(xml)
return normalized ? parseMxfile(normalized) : null
}
const full: LoadDecision = { mode: "load" }
const current = parse(currentXml)
const next = parse(nextXml)
if (!current || !next) return full
if (
!sameFileVars(
current.documentElement.getAttribute("vars"),
next.documentElement.getAttribute("vars"),
)
) {
return full
}
const pagesNow = Array.from(current.querySelectorAll("diagram"))
const pagesNext = Array.from(next.querySelectorAll("diagram"))
if (pagesNow.length === 0 || pagesNow.length !== pagesNext.length) {
return full
}
let index = pagesNow.findIndex(
(page) => page.getAttribute("id") === currentPageId,
)
if (index < 0) {
// The page on screen is unknown (an external draw.io cannot tell):
// with several pages the target cannot be told, load in full
if (pagesNow.length > 1) return full
index = 0
}
const target = pagesNext[index]
// The page is found by its id when the document has several
if (
pagesNow.length > 1 &&
target.getAttribute("id") !== pagesNow[index].getAttribute("id")
) {
return full
}
const targetModel = target.querySelector("mxGraphModel")
if (targetModel && hasLoadOnlySettings(targetModel)) return full
// The other pages, as the MCP core compares them (edit-gate.ts)
const others = (doc: Document, pages: Element[]) => {
pages[index].remove()
return contentFingerprint(serializeMxfile(doc))
}
if (others(current, pagesNow) !== others(next, pagesNext)) return full
return { mode: "commit", pageId: target.getAttribute("id") }
}
export function createMcpSync(options: SyncOptions): McpSync {
const { sessionId, apiBase, canvas, onNotice, onStatus } = options
const fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args))
let token = options.token
let currentVersion = 0
let isReady = false
// What the server has (the text it sent or we pushed) and the newest
// diagram on the canvas, saved to the server or not
let lastXml: string | null = null
let latestXml: string | null = null
// The server state this tab is in step with; null until the first poll
let stateId: string | null = null
let pushFailing = false
const pushesInFlight: string[] = []
// After a recovery replaced the canvas, until draw.io reports the load:
// an autosave still on its way belongs to the canvas being replaced
let awaitingLoad = false
let awaitingLoadTimer: ReturnType<typeof setTimeout> | null = null
// A write taken in place (a commit) makes draw.io autosave its own copy
// of it, serialized its way: that one autosave is not an edit
let awaitingCommitCopy = false
let pollSeq = 0
let lastHandledPoll = 0 // polls overlap; older answers are dropped
// The edit whose SVG export is pending, and what it was based on
let pendingSvgExport: string | null = null
// The latest thumbnail export of a loaded server write: the state and
// version it showed, and the XML loaded
let thumbExport: {
stateId: string | null
version: number
xml: string
} | null = null
let pendingMcpExport: ExportFormat | null = null
let mcpExportSeq = 0
let mcpExportId: string | null = null
// A one-page projection is on screen for a page-targeted export
let projectionExportActive = false
// The restore load of the real document was sent; until draw.io reports
// it (or 5 s), the projection still counts as on screen
let projectionRestoreTimer: ReturnType<typeof setTimeout> | null = null
const endProjection = () => {
projectionExportActive = false
if (projectionRestoreTimer) clearTimeout(projectionRestoreTimer)
projectionRestoreTimer = null
}
// Load the server state on the next poll even at the same version
let forceReload = false
let pendingSyncExport = false
let syncExportSeq = 0
let status: SyncStatus = "waiting"
let interval: ReturnType<typeof setInterval> | null = null
const setStatus = (next: SyncStatus) => {
if (status === next) return
status = next
onStatus?.(next)
}
// A 403 means another MCP process (with its own token) now answers on
// this port: read its token and retry once, so the next poll can recover
// the session instead of being refused forever
let tokenRefresh: Promise<void> | null = null
const refreshToken = () => {
if (!tokenRefresh) {
const read =
options.refreshToken ??
(() =>
fetchFn(location.href, { cache: "no-store" })
.then((r) => r.text())
.then(tokenFromPage))
tokenRefresh = read()
.then((next) => {
if (next) token = next
})
.catch(() => {})
.finally(() => {
tokenRefresh = null
})
}
return tokenRefresh
}
const api = async (path: string, init: RequestInit = {}) => {
const send = () =>
fetchFn(`${apiBase}${path}`, {
...init,
headers: { ...(init.headers ?? {}), "X-Drawio-Token": token },
})
let r = await send()
if (r.status === 403) {
await refreshToken()
r = await send()
}
return r
}
const postJson = (path: string, body: unknown) =>
api(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
/** Put a server write on the canvas and take its thumbnail */
const loadFromServer = (xml: string, how: "decide" | "full") => {
lastXml = xml
latestXml = xml
const decision: LoadDecision =
how === "decide"
? decideLoad(canvas.currentXml(), xml, canvas.currentPageId())
: { mode: "load" }
canvas.load(xml, decision)
awaitingCommitCopy = decision.mode === "commit"
// currentVersion is the write's version
thumbExport = { stateId, version: currentVersion, xml }
setTimeout(captureThumbnail, 500)
}
// The image of the server write on the canvas, for its History entry:
// only for the latest load, once draw.io is ready, and only if the
// canvas still shows it when the export answers
const captureThumbnail = () => {
const t = thumbExport
if (!t || !isReady) return
canvas.export({ format: "svg" }, 5000).then((result) => {
if (!result?.data || thumbExport !== t) return
if (!sameDiagram(latestXml, t.xml)) return
thumbExport = null
postJson("/history-svg", {
sessionId,
svg: svgDataUrl(result.data),
stateId: t.stateId,
version: t.version,
}).catch(() => {})
})
}
// Until draw.io reports the load (its messages come in order), an
// autosave is from the canvas being replaced; in case no report comes,
// not for long
const expectLoad = () => {
awaitingLoad = true
if (awaitingLoadTimer) clearTimeout(awaitingLoadTimer)
awaitingLoadTimer = setTimeout(() => {
awaitingLoad = false
}, 5000)
}
// Restore the user's real document after a page-targeted projection
// export by reloading the server state (it also has any autosave that
// was still in flight when the projection started). projectionExportActive
// stays set until the poll loads the document: an edit on the
// projection before that must not be pushed.
const restoreFromProjection = () => {
if (!projectionExportActive) return
forceReload = true
poll()
}
// source is "sync" for replies to a server sync request, "recover" for
// the tab's copy after the server recovered the session, else "edit".
// sid is the server state the push is based on.
async function pushState(
xml: string,
svg = "",
baseVersion = currentVersion,
source: "edit" | "sync" | "recover" = "edit",
sid = stateId,
): Promise<void> {
if (!sessionId) return
pushesInFlight.push(xml)
try {
const r = await postJson("/state", {
sessionId,
xml,
svg,
baseVersion,
source,
stateId: sid,
})
pushFailing = false
if (r.ok) {
const d = await r.json()
// An answer about a state this tab has left since, or one
// that comes after a newer version was loaded or saved
if (sid !== stateId || d.version < currentVersion) return
currentVersion = d.version
lastXml = xml
// The canvas changed while this edit was on its way, to
// something no pending autosave will send (an undo back to
// the previous version): send it now. A sync reply is
// draw.io's export of the canvas, in another format than its
// autosave.
if (
latestXml &&
latestXml !== xml &&
pendingSvgExport !== latestXml &&
source === "edit"
) {
pushState(latestXml)
}
} else if (r.status === 413) {
// Over the server's size limit: the image is most of it, so
// try once without it
if (svg) pushState(xml, "", baseVersion, source, sid)
else onNotice("tooLarge")
} else if (r.status === 409) {
// The AI wrote a newer version, or the server lost the state
// this push was based on; the next poll sorts it out
const d = await r.json().catch(() => ({}))
if (d.savedToHistory) {
onNotice(
source === "recover" ? "restoredFromFile" : "aiChanged",
)
}
poll()
}
} catch (error) {
console.error("Push failed:", error)
setStatus("offline")
if (!pushFailing) {
pushFailing = true
onNotice("unreachable")
}
} finally {
pushesInFlight.splice(pushesInFlight.indexOf(xml), 1)
}
}
// The server made a new state for this session: it expired, or the MCP
// process restarted. Decide whose diagram wins.
const recoverState = (s: ServerState) => {
stateId = s.stateId
options.onStateRecreated?.()
// The old state's pending work is gone with it
const projectionShown = projectionExportActive
endProjection()
forceReload = false
pendingMcpExport = null
pendingSyncExport = false
const mine = latestXml
currentVersion = s.version
if (s.blank || s.xml === lastXml) {
// The server knows nothing, or exactly what this tab last saved:
// the canvas can only be newer, so it wins (edits made while the
// server was down are saved now)
if (projectionShown && mine) {
canvas.load(mine, { mode: "load" })
expectLoad()
}
if (mine && !sameDiagram(mine, s.xml)) {
pushState(mine, "", s.version)
}
} else if (s.xml) {
// The server has a diagram this tab never showed (an AI write it
// missed, a saved file): show that, and keep this tab's copy in
// History unless it is the same. A full load, as the classic
// page does: whether the canvas can take the write in place is
// decided inside DiagramProvider, and the sync cannot tell; until
// draw.io reports the load, an autosave is from the canvas being
// replaced
loadFromServer(s.xml, "full")
expectLoad()
if (mine && !sameDiagram(mine, s.xml)) {
pushState(mine, "", s.version, "recover")
}
}
}
const startSyncExport = () => {
pendingSyncExport = true
// The version and state the export is taken at: a newer AI write may
// load meanwhile, and this older XML must not overwrite it
const base = currentVersion
const sid = stateId
const seq = ++syncExportSeq
canvas.export({ format: "xml" }, 5000).then((result) => {
// A late reply to an earlier request was taken at another version
if (seq !== syncExportSeq) return
pendingSyncExport = false
if (result?.xml) pushState(result.xml, "", base, "sync", sid)
})
}
// Handle an export request from the MCP server (png/svg).
//
// Plain export: capture whatever page is on screen (PNG: pageId picks a
// page, width caps the size).
//
// Page-targeted export: the server sends a single-page <mxfile>
// projection in exportXml. It goes on the canvas as a transient
// document, draw.io renders it, exports, then the real document is
// loaded back from the server. The session state is never changed.
const startMcpExport = (s: ServerState, justLoaded: boolean) => {
const format = s.exportFormat as ExportFormat
pendingMcpExport = format
const seq = ++mcpExportSeq
mcpExportId = s.exportId
const extra = s.exportOptions ?? {}
const fire = () => {
const request: ExportRequest =
format === "png"
? {
format: "png",
scale: 2,
currentPage: !extra.pageId,
...extra,
}
: { format }
canvas.export(request, 10000).then((result) => {
// A later export is running by now, or this one ended
if (seq !== mcpExportSeq || pendingMcpExport === null) return
const d = result?.data ?? ""
const isPng = format === "png" && d.startsWith("data:image/png")
const isSvg =
format !== "png" &&
(d.startsWith("data:image/svg") || d.startsWith("<svg"))
const done = () => {
if (seq !== mcpExportSeq) return
pendingMcpExport = null
// Page-targeted export: restore the user's real document
restoreFromProjection()
}
if (!isPng && !isSvg) {
done()
return
}
// Keep pendingMcpExport set until the server has the result:
// a poll answered before that still sees the request and
// would start the same export again
postJson("/state", {
sessionId,
exportData: d,
exportId: mcpExportId,
})
.catch(() => {})
.finally(done)
})
}
if (s.exportXml) {
projectionExportActive = true
canvas.showTransient(s.exportXml)
// Let draw.io render the loaded page before exporting
setTimeout(fire, 600)
} else if (justLoaded) {
// A write with a screenshot: give the new diagram's external
// icon images a moment to load before the PNG is taken
setTimeout(fire, 600)
} else {
fire()
}
// 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.
setTimeout(() => {
if (pendingMcpExport && seq === mcpExportSeq) {
pendingMcpExport = null
restoreFromProjection()
}
}, 15000)
}
async function poll(): Promise<void> {
if (!sessionId) return
const seq = ++pollSeq
try {
const r = await api(
`/state?sessionId=${encodeURIComponent(sessionId)}`,
)
if (!r.ok) {
// Refused (another process answers on this port and its
// token could not be read) or failing: edits are not saved
setStatus("offline")
return
}
const s: ServerState = await r.json()
// An older answer than one already handled (the interval, the
// 409 handler and the projection restore each poll): it could
// name a state that is gone
if (seq < lastHandledPoll) return
lastHandledPoll = seq
setStatus("connected")
if (stateId === null) stateId = s.stateId
else if (s.stateId && s.stateId !== stateId) recoverState(s)
// Load a new version (before an export, so it pictures the
// latest). While a projection is on screen, only the restore
// (forceReload) replaces it, so a new version doesn't fight the
// projection; currentVersion stays unadvanced until then. The
// tab's own push still on its way is not loaded back: the canvas
// may have moved on since (an undo), and its answer follows.
const ownPush = s.xml !== null && pushesInFlight.includes(s.xml)
let justLoaded = false
if (
(forceReload ||
(s.version > currentVersion &&
!projectionExportActive &&
!ownPush)) &&
s.xml
) {
// The restore after a projection is a full load of the
// document the user had
const how = forceReload ? "full" : "decide"
forceReload = false
currentVersion = s.version
loadFromServer(s.xml, how)
// The projection stays on screen until draw.io reports this
// load (its messages come in order): an autosave before that
// is of the projection, not an edit
if (projectionExportActive && !projectionRestoreTimer) {
projectionRestoreTimer = setTimeout(endProjection, 5000)
}
justLoaded = true
}
// A sync request (get_diagram): after the load above, so draw.io
// exports what it just loaded; never while a one-page projection
// is on screen, which would be sent as the whole document
if (
s.syncRequested &&
!pendingSyncExport &&
isReady &&
!projectionExportActive
) {
startSyncExport()
}
// Nor an export while the projection is still on screen: it
// would picture the projection
if (
s.exportFormat &&
!pendingMcpExport &&
isReady &&
!projectionExportActive
) {
startMcpExport(s, justLoaded)
}
// Extension point (plan step 6, the get_selection tool): a
// selection request in the state would be answered here, like
// the sync request above, with the ids the editor bridge reads
} catch {
setStatus("offline")
}
}
return {
start() {
if (!sessionId || interval) return
poll()
interval = setInterval(poll, POLL_INTERVAL_MS)
},
stop() {
if (interval) clearInterval(interval)
interval = null
},
poll,
setReady(ready) {
isReady = ready
// draw.io is starting over (an external editor reloads on a
// theme switch): an export in flight gets no answer, and a
// projection on the canvas is gone with the old frame. The next
// poll puts the document back and starts the server's export
// again
if (!ready && pendingMcpExport !== null) {
mcpExportSeq++
pendingMcpExport = null
if (projectionExportActive) forceReload = true
}
// A write loaded before draw.io was ready gets its thumbnail now
if (ready && thumbExport) captureThumbnail()
},
onAutoSave(xml) {
// Ignore autosave while a single-page projection is on screen
// for a page-targeted export; otherwise the projection would be
// pushed as the session's document
if (projectionExportActive) return
// An edit of the canvas that recovery is replacing: kept in
// History, never over the recovered diagram
if (awaitingLoad) {
pushState(xml, "", currentVersion, "recover")
return
}
// Also an edit undone back to what the server has, or draw.io's
// own copy of a write it took in place (a commit). Compared as
// documents only for that copy: a change of the paper size alone
// is the same document to isSameDocument when the server's copy
// names none
latestXml = xml
const commitCopy = awaitingCommitCopy
awaitingCommitCopy = false
if (xml === lastXml || (commitCopy && sameDiagram(xml, lastXml))) {
return
}
// Request an SVG export, then push the edit with it; remember
// the version and state it is based on. Without an answer in
// time, push it without the image.
pendingSvgExport = xml
const base = currentVersion
const sid = stateId
canvas.export({ format: "svg" }, 2000).then((result) => {
// A later edit took over the slot, or this one was sent
if (pendingSvgExport !== xml) return
pendingSvgExport = null
const svg = result?.data ? svgDataUrl(result.data) : ""
pushState(xml, svg, base, "edit", sid)
})
},
onDrawioLoad() {
awaitingLoad = false
// A commit that fell back to a full load sends no copy
awaitingCommitCopy = false
// The restore load after a projection export is done
if (projectionRestoreTimer) endProjection()
},
async fetchHistory() {
// A list for a state the server recreated meanwhile has old ids
const sid = stateId
try {
const r = await api(
`/history?sessionId=${encodeURIComponent(sessionId)}`,
)
if (!r.ok) return null
const d = await r.json()
if (sid !== stateId) return null
return { entries: d.entries ?? [], stateId: sid }
} catch {
return null
}
},
async restoreEntry(id, listStateId) {
try {
const r = await postJson("/restore", {
sessionId,
id,
stateId: listStateId,
})
if (r.ok) {
await poll()
return "ok"
}
if (r.status === 409) {
onNotice("historyChanged")
return "stale"
}
} catch {
// reported below
}
onNotice("restoreFailed")
return "failed"
},
read() {
return {
stateId,
currentVersion,
lastXml,
latestXml,
projectionActive: projectionExportActive,
status,
}
},
}
}