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 <mxGraph/> deleted
  <mxGraphModel>), fixes tag case, drops orphan <mxPoint>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
This commit is contained in:
dayuan.jiang
2026-10-03 22:03:44 +09:00
parent a46787c1b8
commit 6d67a0ec69
13 changed files with 712 additions and 204 deletions
+64 -85
View File
@@ -19,15 +19,14 @@
*/
import { createRequire } from "node:module"
import { homedir } from "node:os"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
import { z } from "zod"
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import type { DiagramOperation } from "./diagram-operations.js"
import { installDomPolyfill } from "./dom.js"
import { editDiagram, targetPageXml } from "./edit-diagram.js"
import { checkEditGate } from "./edit-gate.js"
import { addHistory } from "./history.js"
import {
@@ -135,6 +134,13 @@ function pickPageSelector(input: {
return selector
}
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
function expandHome(p: string): string {
if (p === "~") return homedir()
if (p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1)
return p
}
/** Format a selector for human-readable error messages. */
function describeSelector(s: PageSelector): string {
if (s.page_id) return `id="${s.page_id}"`
@@ -144,9 +150,11 @@ function describeSelector(s: PageSelector): string {
}
// Register prompt with workflow guidance
server.prompt(
server.registerPrompt(
"diagram-workflow",
"Guidelines for creating and editing draw.io diagrams",
{
description: "Guidelines for creating and editing draw.io diagrams",
},
() => ({
messages: [
{
@@ -177,7 +185,7 @@ server.prompt(
## Important Notes
- create_new_diagram REPLACES the entire document, including ALL pages - only use for new diagrams. Use add_page to add a tab without losing existing content.
- edit_diagram PRESERVES the user's manual changes (fetches browser state first)
- edit_diagram PRESERVES the user's manual changes: it is rejected with the current XML when the user edited the diagram since you last saw it
- Always use unique cell_ids within a page (cell ids "0" and "1" are reserved root sentinels and can repeat across pages)`,
},
},
@@ -433,7 +441,7 @@ server.registerTool(
path: z
.string()
.describe(
"Path to the .drawio file to load (e.g., ./diagram.drawio)",
"Absolute path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project.",
),
},
},
@@ -453,7 +461,7 @@ server.registerTool(
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
const absolutePath = nodePath.resolve(path)
const absolutePath = nodePath.resolve(expandHome(path))
let content: string
try {
@@ -543,10 +551,11 @@ server.registerTool(
{
description:
"Edit a specific page in the current diagram by ID-based operations (update/add/delete cells).\n\n" +
"All-or-nothing: if any operation fails, nothing is applied and every failure is listed.\n\n" +
"Freshness: the server remembers the last diagram state you have seen, and rejects this call " +
"only if the user edited the diagram in the browser since then. You do NOT need to call " +
"get_diagram before every edit — if your view is stale, the call is rejected (with no side " +
"effects) and the error tells you to call get_diagram once and retry.\n\n" +
"get_diagram before every edit: a rejected call changes nothing and includes the current XML " +
"of the page, so you can rebuild your operations and retry.\n\n" +
"Call get_diagram first only when you don't know the current diagram content (cell IDs, " +
"structure) — e.g. the diagram wasn't created in this conversation, or you're unsure your " +
"memory of it is accurate.\n\n" +
@@ -625,6 +634,12 @@ server.registerTool(
}
}
const pageSelector = pickPageSelector({
page_id,
page_name,
page_index,
})
// Enforce workflow: the model must have seen the current diagram
// state. Content comparison instead of a wall-clock timeout —
// slow reasoning between get_diagram and edit_diagram is fine as
@@ -637,90 +652,54 @@ server.registerTool(
log.warn(
gate.reason === "stale"
? "edit_diagram called with unseen browser changes - rejecting to prevent data loss"
: "edit_diagram called without get_diagram - rejecting to prevent data loss",
: "edit_diagram called without seeing the diagram - rejecting to prevent data loss",
)
// The error carries the current page, so the model has now
// seen it and can retry without a get_diagram round-trip.
currentSession.lastSeenXml =
browserState?.xml || currentSession.xml
const reason =
gate.reason === "stale"
? "The diagram changed in the browser since you last saw it (e.g. manual user edits). No changes were made."
: "You have not seen this diagram yet, so no changes were made."
return {
content: [
{
type: "text",
text:
gate.reason === "stale"
? "Error: The diagram changed in the browser since you last fetched it (e.g. manual user edits).\n\n" +
"Call get_diagram to see the latest state, then rebuild your edit operations on top of it."
: "Error: You must call get_diagram first before edit_diagram.\n\n" +
"This ensures you have the latest diagram state including any manual edits the user made in the browser. " +
"Please call get_diagram, then use that XML to construct your edit operations.",
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nBuild your operations on this XML and retry.`,
},
],
isError: true,
}
}
const pageSelector = pickPageSelector({
page_id,
page_name,
page_index,
})
log.info(
`Editing diagram with ${operations.length} operation(s) on ${describeSelector(pageSelector)}`,
)
// Validate and auto-fix new_xml for each operation
const validatedOps = operations.map((op) => {
if (op.new_xml) {
const { valid, error, fixed, fixes } = validateAndFixXml(
op.new_xml,
)
if (fixed) {
log.info(
`Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
)
return { ...op, new_xml: fixed }
}
if (!valid && error) {
log.warn(
`Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,
)
}
}
return op
})
// Apply operations on the targeted page
const { result, errors } = applyDiagramOperations(
const outcome = editDiagram(
currentSession.xml,
validatedOps as DiagramOperation[],
operations as DiagramOperation[],
pageSelector,
)
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${e.type} ${e.cellId}: ${e.message}`)
.join("\n")
log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`)
}
// A page-level error (empty cellId — e.g. the selector matched no
// page, or the page had no <root>) means NOTHING was applied and
// `result` is the unchanged input. Surface it as a hard error
// instead of persisting a no-op and reporting success, so the
// caller doesn't build on a wrong assumption.
const pageError = errors.find((e) => e.cellId === "")
if (pageError) {
if (!outcome.ok) {
log.warn(`Edit rejected: ${outcome.errors.join("; ")}`)
const text = outcome.pageError
? `Error: ${outcome.errors[0]}`
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nFix the operations against this XML and retry.`
return {
content: [
{
type: "text",
text: `Error: ${pageError.message}`,
},
],
content: [{ type: "text", text }],
isError: true,
}
}
if (outcome.fixes.length > 0) {
log.info(`new_xml auto-fixed: ${outcome.fixes.join("; ")}`)
}
const result = outcome.xml
// Save the pre-edit state for undo (with cached SVG from browser).
// Done only now that we know the edit applied — a page-level error
// returns above without leaving a phantom history entry.
// Done only once the edit applied: a rejected edit returns above
// without leaving a phantom history entry.
addHistory(
currentSession.id,
currentSession.xml,
@@ -741,17 +720,11 @@ server.registerTool(
log.info(`Diagram edited successfully`)
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s) on ${describeSelector(pageSelector)}.`
const errorMsg =
errors.length > 0
? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}`
: ""
return {
content: [
{
type: "text",
text: successMsg + errorMsg,
text: `Diagram edited successfully!\n\nApplied ${outcome.applied} operation(s) on ${describeSelector(pageSelector)}.`,
},
],
}
@@ -802,11 +775,14 @@ server.registerTool(
}
// Request browser to push fresh state and wait for it
let staleNote = ""
const syncRequested = requestSync(currentSession.id)
if (syncRequested) {
const synced = await waitForSync(currentSession.id)
if (!synced) {
log.warn("get_diagram: sync timeout - state may be stale")
staleNote =
"\n\nNote: the browser did not respond, so this XML may not include the user's latest manual edits (is the preview tab open?)."
}
}
@@ -852,7 +828,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Current diagram XML:\n\n${currentSession.xml}\n\n${pageList}`,
text: `Current diagram XML:\n\n${currentSession.xml}\n\n${pageList}${staleNote}`,
},
],
}
@@ -878,7 +854,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}`,
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}`,
},
],
}
@@ -952,7 +928,7 @@ server.registerTool(
path: z
.string()
.describe(
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)",
"Absolute file path to save to (e.g. /Users/me/diagram.drawio, ~/diagram.png). Relative paths resolve against the MCP server's working directory, which is often not your project.",
),
format: z
.enum(["drawio", "png", "svg"])
@@ -962,7 +938,8 @@ server.registerTool(
),
},
},
async ({ path, format, page_id, page_name, page_index }) => {
async ({ path: rawPath, format, page_id, page_name, page_index }) => {
const path = expandHome(rawPath)
try {
if (!currentSession) {
return {
@@ -976,10 +953,12 @@ server.registerTool(
}
}
// Fetch latest state
// Fetch latest state, re-normalised to mxfile so a page
// selector works on a bare <mxGraphModel> pushed by the browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
currentSession.xml =
normalizeToMxfile(browserState.xml) ?? browserState.xml
}
if (!currentSession.xml) {
@@ -1012,7 +991,7 @@ server.registerTool(
// .drawio path - write XML directly (no browser round-trip).
if (detectedFormat === "drawio") {
let filePath = path
if (!filePath.endsWith(".drawio")) {
if (!filePath.toLowerCase().endsWith(".drawio")) {
filePath = `${filePath}.drawio`
}
const absolutePath = nodePath.resolve(filePath)