Files
next-ai-draw-io/packages/mcp-server/shell/use-mcp-sync.ts
T
dayuan.jiang a2ca229830 feat(mcp-server): the shell syncs with the server like the classic page
shell/mcp-sync-core.ts ports preview.js's protocol without the page or
React: polling GET /api/state every 2 s with the state id and version,
pushes of the user's edits with their base version and state, 409 and 413
handling, recovery of a recreated session (the tab's copy goes to History),
the token refresh after a 403, sync and export requests (PNG by page id,
SVG of another page through a transient one-page projection with autosave
ignored, the 600 ms wait for icons), thumbnails for History, and History
reads and restores that name the state the list belongs to.

A server write goes on the canvas as one undo step (loadDiagram commit,
with the change marked) when decideLoad finds it changes only the page on
screen, keeps the file variables and needs no load-only setting; the other
pages are compared with the MCP core's contentFingerprint. draw.io's own
re-serialized copy of such a write is recognized with isSameDocument and
not pushed back as an edit.

shell/use-mcp-sync.ts mounts the sync once inside DiagramProvider, reads
draw.io's autosave and load messages, and reports the connection state,
which the shell's status bar shows; notices come as toasts from the
dictionaries.
2026-10-11 20:56:07 +09:00

119 lines
4.6 KiB
TypeScript

import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams } from "@/lib/diagram-diff"
import { highlightCells } from "@/lib/drawio/editor-bridge"
import { useCanvasStore } from "@/stores/canvas-store"
import { createMcpSync, type McpSync, type SyncStatus } from "./mcp-sync-core"
import type { ShellConfig } from "./runtime-config"
/**
* Keeps the canvas in step with the MCP server (mcp-sync-core.ts has the
* protocol): mounted once inside DiagramProvider. Server writes go on the
* canvas through loadDiagram, as one undo step when they can; the user's
* edits are pushed; the server's export and sync requests are answered.
* Returns the connection state for the status bar.
*/
export function useMcpSync(config: ShellConfig): SyncStatus {
const dict = useDictionary()
const diagram = useDiagram()
const [status, setStatus] = useState<SyncStatus>("waiting")
const syncRef = useRef<McpSync | null>(null)
// The provider's functions are new on every render; the sync (created
// once) reads the latest through refs
const diagramRef = useRef(diagram)
diagramRef.current = diagram
const dictRef = useRef(dict)
dictRef.current = dict
useEffect(() => {
if (!config.sessionId) return
const sync = createMcpSync({
sessionId: config.sessionId,
apiBase: config.apiBase,
token: config.token,
canvas: {
load: (xml, decision) =>
diagramRef.current.loadDiagram(
xml,
true,
decision.mode,
decision.mode === "commit"
? { pageId: decision.pageId }
: undefined,
),
showTransient: (xml) => diagramRef.current.showTransient(xml),
export: (request, timeoutMs) =>
diagramRef.current.requestExport(request, timeoutMs),
currentXml: () => diagramRef.current.chartXMLRef.current,
currentPageId: () => useCanvasStore.getState().currentPageId,
},
onNotice: (notice) =>
toast(dictRef.current.shell[notice], { duration: 8000 }),
onStatus: setStatus,
})
syncRef.current = sync
sync.setReady(diagramRef.current.isDrawioReady)
sync.start()
// draw.io's autosave and load events, as the classic page reads
// them: the frame component handles them for the canvas, the sync
// needs them for the protocol (an edit to push, a load done)
const drawioOrigin = new URL(config.drawioBaseUrl, location.href).origin
const onMessage = (event: MessageEvent) => {
if (event.source === window || event.origin !== drawioOrigin) {
return
}
if (typeof event.data !== "string") return
let message: { event?: string; xml?: unknown }
try {
message = JSON.parse(event.data)
} catch {
return
}
if (
message.event === "autosave" &&
typeof message.xml === "string"
) {
sync.onAutoSave(message.xml)
} else if (message.event === "load") {
sync.onDrawioLoad()
}
}
window.addEventListener("message", onMessage)
return () => {
window.removeEventListener("message", onMessage)
sync.stop()
syncRef.current = null
}
}, [config])
useEffect(() => {
syncRef.current?.setReady(diagram.isDrawioReady)
}, [diagram.isDrawioReady])
// Mark what a server write changed on the page, as the web app does
// for its versions (use-versions.ts); any action on the canvas clears it
useEffect(() => {
diagram.setCommitHandler(({ beforeXml, afterXml, pageId }) => {
const { touchedIds, fromScratch } = diffDiagrams(
beforeXml,
afterXml,
pageId,
)
if (fromScratch) return
// Once draw.io has drawn the replaced cells
setTimeout(() => {
const marker = getComputedStyle(document.documentElement)
.getPropertyValue("--marker")
.trim()
highlightCells(touchedIds, marker || "#ffd84d")
}, 60)
})
return () => diagram.setCommitHandler(null)
}, [])
return status
}