Files
next-ai-draw-io/packages/mcp-server/shell/use-mcp-sync.ts
T
dayuan.jiang 467fd25fd1 fix(mcp-server): review fixes for get_selection
Server (src/http-server.ts, src/index.ts, src/selection.ts,
src/new-diagram.ts):
- overlapping get_selection calls take turns (readSelection, one slot per
  session as the export slot) instead of replacing each other's request,
  which left one of them with a false "tab not in front" timeout
- an answer is taken only while a request is pending (both ids undefined
  compared equal)
- the tool sees the page start_session actually opens: with dist/shell
  missing the classic page is in use, which never answers, so the tool
  says so instead of timing out
- the result lists at most 100 cells and counts the rest: a whole large
  diagram selected would fill the model's context
- every <diagram> the model sends without an id gets one, so the page id
  the shell reports exists in the server's document

Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts,
contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts):
- an answer whose POST failed is sent again at the next poll
- no answer while a full load has yet to reach the editor: it still shows
  the previous document, whose cells and pages the answer would name
- a hidden tab (the same session open twice) answers a poll later, so the
  tab in front answers first; alone, it still answers within the timeout
- a cell's container is reported by the model's isLayer, not by comparing
  with the default parent, which is the group the user entered
2026-10-11 20:56:07 +09:00

133 lines
5.1 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 {
highlightChangedCells,
readSelectionDetails,
} 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, and the sync itself for
* the version cards (History and restores go through it).
*/
export function useMcpSync(config: ShellConfig): {
status: SyncStatus
sync: McpSync | null
} {
const dict = useDictionary()
const diagram = useDiagram()
const [status, setStatus] = useState<SyncStatus>("waiting")
const [sync, setSync] = useState<McpSync | null>(null)
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,
loadPending: () => diagramRef.current.hasPendingLoad(),
// The bridge reads the editor directly; without it (an
// external draw.io) the selection cannot be read
readSelection: () =>
readSelectionDetails() ?? {
pageId: null,
pageName: null,
cells: [],
unavailable: true,
},
},
onNotice: (notice) =>
toast(dictRef.current.shell[notice], { duration: 8000 }),
onStatus: setStatus,
isHidden: () => document.visibilityState === "hidden",
})
syncRef.current = sync
setSync(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
setSync(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) highlightChangedCells(touchedIds)
})
return () => diagram.setCommitHandler(null)
}, [])
return { status, sync }
}