Compare commits

..
Author SHA1 Message Date
dayuan.jiang 4ea4944bb9 fix(mcp): report package.json version in the MCP handshake
The McpServer metadata version was a separate hardcoded string that
never matched the published version (stuck at 0.1.2, then 0.3.0 while
npm shipped 0.2.x). Read it from package.json at startup instead —
works from both src/ (tsx) and dist/ (published build).
2026-07-12 19:53:00 +09:00
dayuan.jiang d99b0cf9d1 chore(mcp): version 0.2.3 2026-07-12 19:41:33 +09:00
dayuan.jiang 88536b145c feat(mcp): add load_diagram tool to load .drawio files into the session
Loading a file previously required the agent to read the file itself and
pass the entire XML through create_new_diagram - wasteful for large
diagrams and impossible for draw.io's compressed save format.

load_diagram takes a file path; the server reads it, decompresses any
compressed pages (base64 -> raw deflate -> URI-decode, per page), and
replaces the session document. The loaded XML is deliberately NOT marked
as seen by the edit gate: the model only supplied a path, so it must
call get_diagram once before editing.
2026-07-12 18:30:55 +09:00
NgoQuocViet2001anddayuan.jiang f3a85558d8 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 <[email protected]>
2026-07-12 15:33:20 +09:00
Dayuan Jiang 4f09d9461a ci: auto-publish mcp-server to npm via OIDC trusted publishing (#891)
Publishes @next-ai-drawio/mcp-server when packages/mcp-server changes on
main and the package.json version isn't on npm yet. Uses npm trusted
publishing (OIDC) - no token secret, no OTP, works with the strictest
2FA setting.
2026-07-12 15:33:07 +09:00
9 changed files with 673 additions and 57 deletions
+6 -1
View File
@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
| `list_pages` | List every page (tab) with id, name, index, and cell count |
| `add_page` | Append a new page without touching existing ones |
| `rename_page` | Rename a page |
| `delete_page` | Delete a page (refuses to delete the last one) |
## How It Works
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.1",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.1",
"version": "0.2.3",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.1",
"version": "0.2.3",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
+102
View File
@@ -0,0 +1,102 @@
/**
* Workflow gate for edit_diagram.
*
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
* clients, see #885), we compare content: `lastSeenXml` is the state-store
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
* edit_diagram / page CRUD). The store only changes on server writes or
* browser pushes (user autosave, sync exports), so if the live store still
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
* edit is safe no matter how much time passed.
*
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
* document when it pushes state back (different attribute order, pretty-
* printed whitespace, regenerated diagram ids, viewport attributes like
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
* is a user edit, so the fingerprint keeps only what a user can actually
* change: the set of pages, each page's name, and each page's cell tree
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
*/
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
export type EditGateResult =
| { ok: true }
| { ok: false; reason: "no-context" | "stale" }
/**
* Canonical serialisation of an element subtree: tag + attributes sorted by
* name + child elements in order + non-whitespace text. Whitespace-only text
* nodes (pretty-printing) are dropped.
*/
function canonicalizeElement(el: Element): string {
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
.sort()
.join(" ")
let children = ""
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 1) {
children += canonicalizeElement(child as Element)
} else if (child.nodeType === 3 || child.nodeType === 4) {
const text = (child.textContent ?? "").trim()
if (text) children += JSON.stringify(text)
}
}
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
}
/**
* Structural fingerprint of a diagram document: page names + each page's
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
* Unparseable input falls back to the trimmed raw string, degrading to the
* plain string comparison.
*
* `includeNames=false` drops page names from the fingerprint — used when the
* other side of a comparison is a bare <mxGraphModel>, which carries no page
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
* any real page name).
*/
export function contentFingerprint(xml: string, includeNames = true): string {
const normalized = normalizeToMxfile(xml)
const doc = normalized ? parseMxfile(normalized) : null
if (!doc) return xml.trim()
const pages: string[] = []
doc.querySelectorAll("diagram").forEach((d) => {
const name = includeNames ? d.getAttribute("name") || "" : ""
const root = d.querySelector("root")
// No <root> means the page content is not plain XML (e.g. draw.io's
// compressed format) — fingerprint the raw text instead.
const body = root
? canonicalizeElement(root)
: (d.textContent || "").trim()
pages.push(`${name}=${body}`)
})
return pages.join("\n")
}
export function checkEditGate(
lastSeenXml: string,
liveXml: string,
): EditGateResult {
// Model never fetched or produced any diagram state in this session.
if (!lastSeenXml) return { ok: false, reason: "no-context" }
// Browser state moved since the model last looked (e.g. manual user
// edits): force a re-fetch so update/delete operations don't build on
// stale cell contents. An empty liveXml means the store has no entry to
// compare against, so there is nothing newer to have missed.
if (liveXml && liveXml !== lastSeenXml) {
// A bare <mxGraphModel> on either side carries no page name, so
// comparing names would mismatch against anything not called
// "Page-1". Compare cell trees only in that case.
const includeNames =
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
if (
contentFingerprint(liveXml, includeNames) !==
contentFingerprint(lastSeenXml, includeNames)
)
return { ok: false, reason: "stale" }
}
return { ok: true }
}
+202 -53
View File
@@ -36,6 +36,7 @@ class XMLSerializerPolyfill {
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
import { createRequire } from "node:module"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
@@ -44,6 +45,7 @@ import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import { checkEditGate } from "./edit-gate.js"
import { addHistory } from "./history.js"
import {
getState,
@@ -54,6 +56,7 @@ import {
startHttpServer,
waitForSync,
} from "./http-server.js"
import { parseDrawioFileContent } from "./load-diagram.js"
import { log } from "./logger.js"
import {
addPageToDoc,
@@ -79,13 +82,25 @@ 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
// Create MCP server. The version reported in the MCP handshake is read from
// package.json so it can never drift from the published npm version again
// (it sat hardcoded at stale values for most of this package's history).
// Both src/ (tsx dev) and dist/ (published build) live one level below the
// package root, so the relative path works in either runtime.
const require = createRequire(import.meta.url)
const packageVersion: string = require("../package.json").version
const server = new McpServer({
name: "next-ai-drawio",
version: "0.3.0",
version: packageVersion,
})
// Shared Zod schema fragment for page-targeting parameters.
@@ -158,21 +173,21 @@ server.prompt(
1. Call start_session to open the browser preview
2. Use create_new_diagram with either a bare <mxGraphModel> (single page) or a full <mxfile> with one or more <diagram> children (multi-page)
## Opening an Existing .drawio File
- Use load_diagram with the file path — the server reads and decompresses the file itself; don't read it and pass the XML through create_new_diagram
- After loading, call get_diagram once before editing (you haven't seen the file's cell IDs yet)
## Working with Multiple Pages
- Use list_pages to discover existing pages (id, name, index)
- Use add_page to append a new page (without losing existing ones — unlike create_new_diagram which REPLACES everything)
- 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 +220,7 @@ server.registerTool(
id: sessionId,
xml: "",
version: 0,
lastGetDiagramTime: 0,
lastSeenXml: "",
}
// Open browser
@@ -376,10 +391,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, "")
@@ -417,19 +434,136 @@ COMMON STYLES:
},
)
// Tool: load_diagram
server.registerTool(
"load_diagram",
{
description:
"Load a .drawio file from disk into the current session, REPLACING the entire diagram (all pages). " +
"The server reads the file directly — you do NOT need to read the file yourself or pass its XML through create_new_diagram. " +
"Handles both plain-XML and draw.io's compressed save format.\n\n" +
"After loading, call get_diagram before edit_diagram — you haven't seen the file's cell IDs or structure yet.",
inputSchema: {
path: z
.string()
.describe(
"Path to the .drawio file to load (e.g., ./diagram.drawio)",
),
},
},
async ({ path }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
const absolutePath = nodePath.resolve(path)
let content: string
try {
content = await fs.readFile(absolutePath, "utf-8")
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return {
content: [
{
type: "text",
text: `Error: Cannot read file ${absolutePath}: ${msg}`,
},
],
isError: true,
}
}
const loaded = parseDrawioFileContent(content)
if (!loaded.ok) {
return {
content: [{ type: "text", text: `Error: ${loaded.error}` }],
isError: true,
}
}
const xml = loaded.xml
log.info(
`Loading diagram from ${absolutePath} (${xml.length} chars)`,
)
// Save the user's current state before replacing (same flow as
// create_new_diagram).
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
if (currentSession.xml) {
addHistory(
currentSession.id,
currentSession.xml,
browserState?.svg || "",
)
}
currentSession.xml = xml
currentSession.version++
setState(currentSession.id, xml)
// Deliberately NOT marking the loaded XML as seen: the model only
// supplied a path, so it doesn't know the file's cell IDs. The
// edit gate will require one get_diagram before edits.
currentSession.lastSeenXml = ""
addHistory(currentSession.id, xml, "")
const doc = parseMxfile(xml)
const pages = doc ? listPagesFromDoc(doc) : []
const pageSummary =
pages.length > 0
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
: "no pages parsed"
log.info(`Diagram loaded from file (${pageSummary})`)
return {
content: [
{
type: "text",
text: `Diagram loaded from ${absolutePath}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet.`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("load_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: edit_diagram
server.registerTool(
"edit_diagram",
{
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 +616,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 +639,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 +745,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 +787,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 +824,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 +844,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 +1219,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, "")
},
}
+101
View File
@@ -0,0 +1,101 @@
/**
* File-loading helpers for the load_diagram tool.
*
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
* <mxGraphModel> either as plain XML or — draw.io's default save format —
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
* diagram's text content. The rest of the server assumes plain XML inside
* every <diagram>, so loading decompresses all pages up front.
*/
import { inflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import {
isMxFile,
isMxGraphModel,
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "./pages.js"
export type LoadResult =
| { ok: true; xml: string }
| { ok: false; error: string }
/**
* Decode one compressed page body (base64 → raw deflate → URI-decode).
* Returns null if the text isn't in that format.
*/
export function decompressPageContent(compressed: string): string | null {
try {
const inflated = inflateRawSync(
Buffer.from(compressed.trim(), "base64"),
).toString("utf-8")
try {
return decodeURIComponent(inflated)
} catch {
// Not URI-encoded (older files) — the inflated text is the XML.
return inflated
}
} catch {
return null
}
}
/**
* Parse the content of a .drawio file into the canonical session shape:
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
* any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
const trimmed = content.trim()
if (!trimmed) return { ok: false, error: "File is empty." }
if (isMxGraphModel(trimmed)) {
const normalized = normalizeToMxfile(trimmed)
return normalized
? { ok: true, xml: normalized }
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
}
if (!isMxFile(trimmed)) {
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
}
}
const doc = parseMxfile(trimmed)
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
let decompressedAny = false
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
if (d.querySelector("mxGraphModel")) continue
const text = (d.textContent || "").trim()
if (!text) continue // an empty page is valid
const pageLabel =
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
const xml = decompressPageContent(text)
if (!xml || !isMxGraphModel(xml)) {
return {
ok: false,
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
}
}
const inner = new DOMParser().parseFromString(xml, "text/xml")
if (
inner.querySelector("parsererror") ||
inner.documentElement?.tagName !== "mxGraphModel"
) {
return {
ok: false,
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
}
}
d.textContent = ""
d.appendChild(
doc.importNode(inner.documentElement as unknown as Node, true),
)
decompressedAny = true
}
// Nothing changed — keep the file's own serialisation.
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
*
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
* allowed when the model has seen the current browser state, no matter how
* long ago — and rejected when the browser state moved since. "Seen" is
* judged structurally, so draw.io's re-serialisation of the same content
* (attribute order, whitespace, viewport attributes, wrapper shape) never
* reads as a user edit.
*/
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
})
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
// The same document as draw.io re-serialises it on autosave: different host,
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
// cell attributes, pretty-printed whitespace.
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
<diagram id="regenerated-id" name="Page-1">
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>`
// A real user edit: box1 moved to a different position.
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
// Bare mxGraphModel with identical page content to XML_A.
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
describe("checkEditGate", () => {
it("rejects when no diagram context was ever established", () => {
expect(checkEditGate("", XML_A)).toEqual({
ok: false,
reason: "no-context",
})
})
it("allows when the browser state is exactly what the model saw", () => {
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
})
it("allows when the browser state is a re-serialisation of the same content", () => {
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
})
it("rejects when a cell actually changed", () => {
expect(checkEditGate(XML_A, XML_B)).toEqual({
ok: false,
reason: "stale",
})
})
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
const movedAndReserialized = XML_A_RESERIALIZED.replace(
'x="40" y="40"',
'x="300" y="200"',
)
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
ok: false,
reason: "stale",
})
})
it("allows when the store has no live entry to compare against", () => {
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
})
// A bare <mxGraphModel> push carries no page name, so the gate must not
// compare the invented "Page-1" wrapper name against the real one.
it("allows a bare mxGraphModel push when the page has a custom name", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
})
it("still rejects a bare mxGraphModel push whose cells changed", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
ok: false,
reason: "stale",
})
})
})
describe("contentFingerprint", () => {
it("is invariant under draw.io re-serialisation", () => {
expect(contentFingerprint(XML_A)).toBe(
contentFingerprint(XML_A_RESERIALIZED),
)
})
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
})
it("changes when a cell attribute changes", () => {
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
})
it("changes when a page is renamed", () => {
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
})
it("changes when a page is added", () => {
const twoPages = XML_A.replace(
"</mxfile>",
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
)
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
})
it("falls back to the raw string for unparseable input", () => {
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
})
})
@@ -0,0 +1,126 @@
/**
* Unit tests for load_diagram's file parsing (load-diagram.ts).
*
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
* as draw.io's compressed default (encodeURIComponent → raw deflate →
* base64 text content). The loader must produce the canonical session
* shape: an <mxfile> whose every page is plain XML.
*/
import { deflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
// Install the DOM polyfills exactly as index.ts does at runtime.
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) return node.outerHTML
if (node.documentElement) return node.documentElement.outerHTML
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
})
import {
decompressPageContent,
parseDrawioFileContent,
} from "../src/load-diagram.js"
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
/** Compress a page body exactly the way draw.io does when saving. */
function drawioCompress(xml: string): string {
return deflateRawSync(
Buffer.from(encodeURIComponent(xml), "utf-8"),
).toString("base64")
}
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
describe("decompressPageContent", () => {
it("round-trips draw.io's compressed format", () => {
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
})
it("handles non-URI-encoded legacy payloads", () => {
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
"base64",
)
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
})
it("returns null for garbage", () => {
expect(decompressPageContent("not base64 deflate")).toBeNull()
})
})
describe("parseDrawioFileContent", () => {
it("passes a plain-XML mxfile through unchanged", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
})
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
const r = parseDrawioFileContent(MODEL_XML)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxfile")
expect(r.xml).toContain('value="Hello"')
}
})
it("decompresses a compressed mxfile into plain XML pages", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxGraphModel")
expect(r.xml).toContain('value="Hello"')
// The compressed blob must be gone.
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
}
})
it("decompresses only the compressed pages of a mixed file", () => {
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
const r = parseDrawioFileContent(mixed)
expect(r.ok).toBe(true)
if (r.ok) {
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const diagrams = Array.from(
doc.querySelectorAll("diagram"),
) as Element[]
expect(diagrams).toHaveLength(2)
for (const d of diagrams) {
expect(d.querySelector("mxGraphModel")).not.toBeNull()
}
}
})
it("keeps empty pages as-is", () => {
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
const r = parseDrawioFileContent(withEmpty)
expect(r).toEqual({ ok: true, xml: withEmpty })
})
it("rejects empty files", () => {
const r = parseDrawioFileContent(" ")
expect(r.ok).toBe(false)
})
it("rejects non-drawio content", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
})
it("rejects a page whose content is neither XML nor compressed", () => {
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
const r = parseDrawioFileContent(bad)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain('"Broken"')
})
})
@@ -31,6 +31,7 @@ const tsxBin = path.resolve(
const EXPECTED_TOOLS = [
"start_session",
"create_new_diagram",
"load_diagram",
"edit_diagram",
"get_diagram",
"export_diagram",