From 6d67a0ec695a76d6fa0774e5e4aa2a56e2c09b5a Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sat, 3 Oct 2026 22:03:44 +0900 Subject: [PATCH] fix(mcp-server): make edit_diagram all-or-nothing and fix preview sync races - edit_diagram applies nothing when any operation fails, rejects invalid or multi-cell new_xml, validates only the target page, and returns the current page XML on every rejection (including stale edits) - Fix get_diagram reading the old diagram right after an AI write: the preview pushed its sync reply with a newer version than it was taken at - Keep a user edit that loses the race with an AI write in history and tell the user in the preview - Autofix removes only exact foreign tags (a stray deleted ), fixes tag case, drops orphan s, and rejects unknown element names in model XML - Edit empty and compressed pages; PNG exports use the page on screen; tag download exports; reload from the server after a page export - Expand ~ in paths, tell the model when the browser sync timed out, use registerPrompt, require SDK ^1.31.0 --- packages/mcp-server/package-lock.json | 6 +- packages/mcp-server/package.json | 4 +- packages/mcp-server/src/diagram-operations.ts | 43 +++- packages/mcp-server/src/edit-diagram.ts | 103 ++++++++ packages/mcp-server/src/http-server.ts | 89 ++++--- packages/mcp-server/src/index.ts | 149 +++++------- packages/mcp-server/src/pages.ts | 9 +- packages/mcp-server/src/xml-validation.ts | 225 ++++++++++++------ .../tests/diagram-operations.test.ts | 41 ++++ .../mcp-server/tests/edit-diagram.test.ts | 130 ++++++++++ packages/mcp-server/tests/http-server.test.ts | 53 ++++- .../mcp-server/tests/server-wiring.test.ts | 2 +- .../mcp-server/tests/xml-validation.test.ts | 62 +++++ 13 files changed, 712 insertions(+), 204 deletions(-) create mode 100644 packages/mcp-server/src/edit-diagram.ts create mode 100644 packages/mcp-server/tests/edit-diagram.test.ts diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index 5d84a11b..d45f49fc 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -1,15 +1,15 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.3", + "version": "0.2.4", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4", + "@modelcontextprotocol/sdk": "^1.31.0", "linkedom": "^0.18.0", "open": "^11.0.0", "saxes": "^6.0.0", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 702089d7..df4060bd 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.3", + "version": "0.2.4", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", @@ -38,7 +38,7 @@ "access": "public" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4", + "@modelcontextprotocol/sdk": "^1.31.0", "linkedom": "^0.18.0", "open": "^11.0.0", "saxes": "^6.0.0", diff --git a/packages/mcp-server/src/diagram-operations.ts b/packages/mcp-server/src/diagram-operations.ts index 6e40b9f6..cc963a3c 100644 --- a/packages/mcp-server/src/diagram-operations.ts +++ b/packages/mcp-server/src/diagram-operations.ts @@ -8,6 +8,7 @@ */ import { getXmlSyntaxError } from "./dom.js" +import { decompressPageContent } from "./load-diagram.js" import { log } from "./logger.js" import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js" @@ -33,6 +34,44 @@ export interface ApplyOperationsResult { // on the wrapper, so the wrapper is treated as the cell. const CELL_SELECTOR = "mxCell, UserObject, object" +/** + * Return the of a page, creating it when missing. An empty + * page gets a blank model with the "0" and "1" root cells; a page whose text + * is draw.io's compressed format is decompressed in place. Returns null if + * the text is neither empty nor decompressible. + */ +function ensurePageRoot(doc: Document, page: Element): Element | null { + const existing = page.querySelector("root") + if (existing) return existing + + let model = page.querySelector("mxGraphModel") + if (!model) { + const text = page.textContent?.trim() ?? "" + if (text) { + const xml = decompressPageContent(text) + if (!xml || getXmlSyntaxError(xml)) return null + const parsed = new DOMParser().parseFromString(xml, "text/xml") + if (parsed.documentElement?.tagName !== "mxGraphModel") return null + page.textContent = "" + model = page.appendChild( + doc.importNode(parsed.documentElement, true), + ) as Element + const decompressedRoot = model.querySelector("root") + if (decompressedRoot) return decompressedRoot + } else { + model = page.appendChild(doc.createElement("mxGraphModel")) + } + } + + const blank = new DOMParser().parseFromString( + ``, + "text/xml", + ) + return model.appendChild( + doc.importNode(blank.documentElement, true), + ) as Element +} + /** Read parent/source/target, which a wrapped cell keeps on its inner mxCell. */ function cellAttr(cell: Element, name: string): string | null { const inner = @@ -98,7 +137,7 @@ export function applyDiagramOperations( ], } } - root = found.element.querySelector("root") + root = ensurePageRoot(doc as unknown as Document, found.element) if (!root) { const pageId = found.element.getAttribute("id") || `(index ${found.index})` @@ -108,7 +147,7 @@ export function applyDiagramOperations( { type: "update", cellId: "", - message: `Page "${pageId}" has no element`, + message: `Page "${pageId}" has no element and its content could not be decompressed`, }, ], } diff --git a/packages/mcp-server/src/edit-diagram.ts b/packages/mcp-server/src/edit-diagram.ts new file mode 100644 index 00000000..f747f5a6 --- /dev/null +++ b/packages/mcp-server/src/edit-diagram.ts @@ -0,0 +1,103 @@ +/** + * Core of the edit_diagram tool, kept free of session state so it can be + * unit tested. + * + * All-or-nothing, like the web app (hooks/use-diagram-tool-handlers.ts): + * if any operation fails, nothing is written and every failure is reported, + * so the model never builds on a half-applied edit. + */ + +import { + applyDiagramOperations, + type DiagramOperation, +} from "./diagram-operations.js" +import { type PageSelector, projectPage } from "./pages.js" +import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.js" + +export type EditOutcome = + | { ok: true; xml: string; applied: number; fixes: string[] } + | { ok: false; errors: string[]; pageError: boolean } + +/** Number of top-level elements in an XML fragment. */ +function countTopLevelElements(fragment: string): number { + const doc = new DOMParser().parseFromString( + `${fragment}`, + "text/xml", + ) + return doc.documentElement?.children.length ?? 0 +} + +/** The target page as a one-page , or the whole document. */ +export function targetPageXml(xml: string, selector: PageSelector): string { + const projection = projectPage(xml, selector) + return projection.ok ? projection.xml : xml +} + +export function editDiagram( + xml: string, + operations: DiagramOperation[], + selector: PageSelector, +): EditOutcome { + const errors: string[] = [] + const fixes: string[] = [] + const prepared: DiagramOperation[] = [] + + for (const op of operations) { + if (op.operation === "delete" || !op.new_xml) { + prepared.push(op) + continue + } + // Checked before validation: several cells fail the strict parser + // with a misleading "only one root" syntax error. + if (countTopLevelElements(op.new_xml) > 1) { + errors.push( + `${op.operation} ${op.cell_id}: new_xml must contain exactly one cell; use one add operation per cell`, + ) + continue + } + const check = validateAndFixXml(op.new_xml) + if (!check.valid) { + errors.push( + `${op.operation} ${op.cell_id}: invalid new_xml: ${check.error}`, + ) + continue + } + if (check.fixed) { + fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`) + } + prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml }) + } + if (errors.length > 0) return { ok: false, errors, pageError: false } + + const { result, errors: opErrors } = applyDiagramOperations( + xml, + prepared, + selector, + ) + // An empty cellId means the page itself could not be edited + const pageLevel = opErrors.find((e) => e.cellId === "") + if (pageLevel) { + return { ok: false, errors: [pageLevel.message], pageError: true } + } + if (opErrors.length > 0) { + return { + ok: false, + errors: opErrors.map((e) => `${e.type} ${e.cellId}: ${e.message}`), + pageError: false, + } + } + + // Validate only the target page, and reject only errors this edit + // introduced: problems already in other pages or in a loaded file must + // not block every edit. + const after = validateMxCellStructure(targetPageXml(result, selector)) + if (after && !validateMxCellStructure(targetPageXml(xml, selector))) { + return { + ok: false, + errors: [`the edit would make the page invalid: ${after}`], + pageError: false, + } + } + + return { ok: true, xml: result, applied: operations.length, fixes } +} diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 08b50bca..c56a7009 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -256,10 +256,6 @@ export function shutdown(): void { stopHttpServer() } -export function getServerPort(): number { - return serverPort -} - function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, @@ -422,11 +418,23 @@ function handleStateApi( typeof data.baseVersion === "number" && data.baseVersion < (current?.serverVersion ?? 0) ) { + let savedToHistory = false + if (data.source === "sync") { + // A stale sync reply: the store already holds the + // newer AI write, so the sync is done. + if (current) current.syncRequested = undefined + } else if (typeof data.xml === "string" && data.xml) { + // A user edit lost the race with an AI write. Keep + // it in history so the user can restore it. + addHistory(sessionId, data.xml, data.svg || "") + savedToHistory = true + } res.writeHead(409, { "Content-Type": "application/json" }) res.end( JSON.stringify({ error: "Diagram changed on the server", version: current?.version, + savedToHistory, }), ) return @@ -708,6 +716,15 @@ function getHtmlPage(sessionId: string): string { } .filename-group { display: flex; } .filename-group input { border-radius: 8px 0 0 8px; border-right: none; } + #notice { + display: none; position: fixed; left: 50%; bottom: 24px; + transform: translateX(-50%); z-index: 3000; max-width: 480px; + padding: 10px 16px; border-radius: 8px; font-size: 13px; + background: #18181b; color: white; + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + } + #notice.open { display: block; } .filename-group .ext { padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7; border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a; @@ -749,6 +766,7 @@ function getHtmlPage(sessionId: string): string { +