fix(mcp): replace edit_diagram 30s time gate with content comparison (#890)

* fix(mcp): keep diagram context valid during edits

Closes #885

* fix(mcp): replace edit_diagram time gate with content comparison

The 30s wall-clock gate rejected slow-but-correct clients (#885).
Instead of a timeout, remember the exact state-store XML the model
last saw (get_diagram / create_new_diagram / edit_diagram / page CRUD)
and reject edit_diagram only when the live browser state differs -
i.e. the user made edits the model hasn't seen yet. Slow reasoning
no longer trips the gate, while unseen manual edits still do.

* docs(mcp): align edit_diagram/get_diagram descriptions with content-based gate

The 'You MUST call get_diagram BEFORE this tool' requirement and the
'Skipping get_diagram WILL cause user's changes to be LOST' warning no
longer match server behavior: a stale edit is rejected with no side
effects, never silently applied. Describe the freshness check instead,
and direct get_diagram usage at its real purpose - learning the current
diagram content when the model doesn't already know it.

* fix(mcp): compare diagram content structurally in the edit gate

draw.io re-serialises the document when pushing state back (attribute
order, pretty-printing, regenerated diagram ids, viewport attributes,
mxfile host), so byte comparison could flag an unchanged diagram as
stale. Fingerprint what a user can actually change instead - page set,
page names, and each page's root cell tree with sorted attributes -
keeping byte equality as the fast path. A bare mxGraphModel now also
fingerprints identically to its single-page mxfile wrapping.

* fix(mcp): don't compare page names against bare mxGraphModel pushes

A bare <mxGraphModel> pushed by the embed/sync path carries no page name,
so normalizeToMxfile invents "Page-1" — falsely reading any custom page
name as a content change and re-triggering the stale rejection on every
edit. When either side of the gate comparison is a bare mxGraphModel,
fingerprint cell trees only; full-mxfile comparisons still detect renames.

* chore(mcp): bump version to 0.2.2

* chore(mcp): sync package-lock.json version to 0.2.2

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
This commit is contained in:
NgoQuocViet2001
2026-07-12 13:33:20 +07:00
committed by GitHub
parent 4f09d9461a
commit f3a85558d8
5 changed files with 307 additions and 54 deletions

View File

@@ -44,6 +44,7 @@ import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import { checkEditGate } from "./edit-gate.js"
import { addHistory } from "./history.js"
import {
getState,
@@ -79,7 +80,12 @@ let currentSession: {
id: string
xml: string
version: number
lastGetDiagramTime: number // Track when get_diagram was last called (for enforcing workflow)
// The exact state-store XML the model last saw (get_diagram) or wrote
// itself (create/edit/page CRUD). The store only changes on server
// writes or browser pushes (user autosave / sync), so edit_diagram can
// detect unseen user edits by comparing the live store against this.
// Empty = no diagram context established yet.
lastSeenXml: string
} | null = null
// Create MCP server
@@ -164,15 +170,11 @@ server.prompt(
- Use rename_page / delete_page for management
- edit_diagram, get_diagram, and export_diagram all accept optional page_id / page_name / page_index — when omitted they target the first page
## Adding Elements to an Existing Page
1. Use edit_diagram with "add" operation, optionally with a page selector
2. Provide a unique cell_id and complete mxCell XML
3. No need to call get_diagram first - the server fetches latest state automatically
## Modifying or Deleting Existing Elements
1. FIRST call get_diagram to see current cell IDs and page structure
2. THEN call edit_diagram with "update" or "delete" operations
3. For update, provide the cell_id and complete new mxCell XML
## Editing a Page (add / update / delete cells)
1. Call edit_diagram with your operations, optionally with a page selector
2. If you don't know the current cell IDs or structure, call get_diagram first
3. For add/update, provide the cell_id and complete mxCell XML
4. No need to call get_diagram before every edit: the server rejects the edit (with no side effects) if the user changed the diagram in the browser since you last saw it, and tells you to call get_diagram once and retry
## 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.
@@ -205,7 +207,7 @@ server.registerTool(
id: sessionId,
xml: "",
version: 0,
lastGetDiagramTime: 0,
lastSeenXml: "",
}
// Open browser
@@ -376,10 +378,12 @@ COMMON STYLES:
// Update session state
currentSession.xml = xml
currentSession.version++
currentSession.lastGetDiagramTime = Date.now()
// Push to embedded server state
// Push to embedded server state. The model just authored this
// exact XML, so record it as seen — edit_diagram may follow
// without a redundant get_diagram round-trip.
setState(currentSession.id, xml)
currentSession.lastSeenXml = xml
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, xml, "")
@@ -423,13 +427,13 @@ server.registerTool(
{
description:
"Edit a specific page in the current diagram by ID-based operations (update/add/delete cells).\n\n" +
"⚠️ REQUIRED: You MUST call get_diagram BEFORE this tool!\n" +
"This fetches the latest state from the browser including any manual user edits.\n" +
"Skipping get_diagram WILL cause user's changes to be LOST.\n\n" +
"Workflow:\n" +
"1. Call get_diagram to see current cell IDs, page structure, and active page\n" +
"2. Use the returned XML to construct your edit operations\n" +
"3. Call edit_diagram with your operations and (optionally) a page selector\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" +
"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" +
"Multi-page targeting:\n" +
"- page_id / page_name / page_index are optional; when all omitted, the FIRST page is targeted\n" +
"- Use list_pages to discover what pages exist\n\n" +
@@ -482,27 +486,6 @@ server.registerTool(
}
}
// Enforce workflow: require get_diagram to be called first
const timeSinceGet = Date.now() - currentSession.lastGetDiagramTime
if (timeSinceGet > 30000) {
// 30 seconds
log.warn(
"edit_diagram called without recent get_diagram - rejecting to prevent data loss",
)
return {
content: [
{
type: "text",
text:
"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.",
},
],
isError: true,
}
}
// Fetch latest state from browser. Re-normalise to mxfile: the
// embed/sync path can hand back a bare <mxGraphModel>, and adopting
// it verbatim would silently strip a multi-page document down to
@@ -526,6 +509,37 @@ server.registerTool(
}
}
// 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
// long as nothing changed in the browser meanwhile (#885).
const gate = checkEditGate(
currentSession.lastSeenXml,
browserState?.xml ?? "",
)
if (!gate.ok) {
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",
)
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.",
},
],
isError: true,
}
}
const pageSelector = pickPageSelector({
page_id,
page_name,
@@ -601,8 +615,10 @@ server.registerTool(
currentSession.xml = result
currentSession.version++
// Push to embedded server
// Push to embedded server; the pushed XML is now the latest
// state the model has seen.
setState(currentSession.id, result)
currentSession.lastSeenXml = result
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, result, "")
@@ -641,8 +657,9 @@ server.registerTool(
{
description:
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
"Call this BEFORE edit_diagram if you need to update or delete existing elements, " +
"so you can see the current cell IDs, pages, and structure.\n\n" +
"Call this when you don't know the current diagram content (cell IDs, pages, structure) — " +
"e.g. before editing a diagram you didn't create in this conversation, or after edit_diagram " +
"was rejected because the user changed the diagram in the browser.\n\n" +
"Returns the full <mxfile> by default. If a page selector is provided, returns just that page's <mxGraphModel> embedded in a one-page <mxfile> wrapper.",
inputSchema: {
...pageSelectorSchema,
@@ -677,9 +694,6 @@ server.registerTool(
}
}
// Mark that get_diagram was called (for edit_diagram workflow check)
currentSession.lastGetDiagramTime = Date.now()
// Fetch latest state from browser, re-normalising to mxfile so a
// bare <mxGraphModel> pushed back by the embed/sync path doesn't
// strip page structure (see edit_diagram for the same guard).
@@ -700,6 +714,11 @@ server.registerTool(
}
}
// The model is now looking at the current state. Record the raw
// store value — the gate's fast path is plain string equality
// against the store, with a structural comparison as fallback.
currentSession.lastSeenXml = browserState?.xml || currentSession.xml
const pageSelector = pickPageSelector({
page_id,
page_name,
@@ -1070,11 +1089,11 @@ async function loadMxfileForMutation(): Promise<
addHistory(sessionRef.id, sessionRef.xml, browserState?.svg || "")
sessionRef.xml = newXml
sessionRef.version++
// Page CRUD updates the structure that get_diagram would return,
// so refresh the workflow timestamp — subsequent edit_diagram
// calls don't need a redundant get_diagram round-trip.
sessionRef.lastGetDiagramTime = Date.now()
setState(sessionRef.id, newXml)
// The model just wrote this exact state, so mark it as seen —
// subsequent edit_diagram calls don't need a redundant
// get_diagram round-trip.
sessionRef.lastSeenXml = newXml
addHistory(sessionRef.id, newXml, "")
},
}