From 8ab837679e2787ab5fd3e2df64076551cb2a5e87 Mon Sep 17 00:00:00 2001
From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com>
Date: Sun, 11 Oct 2026 20:40:05 +0900
Subject: [PATCH] feat(mcp-server): load .drawio.svg, change summary, XML
reference by topic, preview options, truncation message (#982)
* feat(mcp-server): load .drawio.svg (Editable SVG) files with load_diagram
* feat(mcp-server): summarize the user's manual changes per cell in stale rejections and get_diagram
* feat(mcp-server): XML reference for tables, layers and groups via get_drawing_guide topic
* feat(mcp-server): DRAWIO_LANG, DRAWIO_UI and DRAWIO_DARK for the preview page
Three host-config environment variables fix the language, theme and
dark mode of the draw.io editor embedded in the preview page. The new
drawio-themes module holds the theme list and draw.io's locale names
(zh-hant becomes zh-tw); drawioEmbedParams() in http-server.ts builds
the variable tail of the iframe query and getHtmlPage fills the new
{{DRAWIO_PARAMS}} placeholder. Without the variables the page keeps
sending dark=auto as before. Both READMEs document the variables.
* feat(mcp-server): report XML cut off inside a cell and explain drawing in parts
create_new_diagram and add_page now detect bare-cell XML that ends inside an
unfinished mxCell (XML comments stripped first) and return an error with the
last 300 characters of the input, asking the model to resend from that cell or
continue with edit_diagram add operations. isMxCellXmlComplete moves from
lib/utils.ts into packages/mcp-server/src/new-diagram.ts and is re-exported
from lib/utils.ts for the web app. The drawing guide gains a "Large diagrams"
paragraph and the INSTRUCTIONS edit_diagram line mentions drawing in parts.
* fix(mcp-server): review fixes for load .drawio.svg, change summary, XML reference, draw.io embed options, truncation message
* fix(mcp-server): Codex review fixes for .drawio.svg loading, change summary, references, embed options and truncation
- Truncation check: a closing tag such as that the auto-fix
repairs is no cut, and input without any cell keeps the validator's
message
- Guide: a call rejected as cut off drew nothing, so all of its cells are
sent again; the topic pointer is its own paragraph
- Change summary: decoded labels (no or merged words from ),
a fast path for equal XML, and "the order of the cells changed" as
the fallback
- DRAWIO_UI in any case, DRAWIO_LANG=zh-Hans maps to draw.io's zh
- load_diagram and export_diagram describe which files load again
---
lib/utils.ts | 52 +-----
packages/claude-plugin/README.md | 5 +-
packages/mcp-server/README.md | 11 +-
packages/mcp-server/src/drawing-guide.ts | 5 +
packages/mcp-server/src/drawio-themes.ts | 24 +++
packages/mcp-server/src/edit-gate.ts | 113 +++++++++++++
packages/mcp-server/src/http-server.ts | 29 ++++
packages/mcp-server/src/index.ts | 69 ++++++--
packages/mcp-server/src/load-diagram.ts | 38 ++++-
packages/mcp-server/src/new-diagram.ts | 72 ++++++++-
packages/mcp-server/src/preview/index.html | 2 +-
packages/mcp-server/src/xml-reference.ts | 71 ++++++++
packages/mcp-server/tests/edit-gate.test.ts | 137 ++++++++++++++++
packages/mcp-server/tests/http-server.test.ts | 49 ++++++
.../mcp-server/tests/load-diagram.test.ts | 118 +++++++++++++-
packages/mcp-server/tests/new-diagram.test.ts | 152 ++++++++++++++++++
.../mcp-server/tests/server-wiring.test.ts | 19 +++
.../mcp-server/tests/xml-reference.test.ts | 58 +++++++
tests/unit/utils.test.ts | 71 --------
19 files changed, 949 insertions(+), 146 deletions(-)
create mode 100644 packages/mcp-server/src/drawio-themes.ts
create mode 100644 packages/mcp-server/src/xml-reference.ts
create mode 100644 packages/mcp-server/tests/new-diagram.test.ts
create mode 100644 packages/mcp-server/tests/xml-reference.test.ts
diff --git a/lib/utils.ts b/lib/utils.ts
index 20ecd5df..178404db 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -2,8 +2,6 @@ import { type ClassValue, clsx } from "clsx"
import * as pako from "pako"
import { twMerge } from "tailwind-merge"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
-import { readStyleClasses } from "@/packages/mcp-server/src/style-classes.ts"
-import { repairQuoteBeforeSlash } from "@/packages/mcp-server/src/xml-validation.ts"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -34,55 +32,7 @@ export function isRealDiagram(xml: string | undefined | null): boolean {
// mxCell XML Helpers
// ============================================================================
-/**
- * Check if mxCell XML output is complete (not truncated).
- * Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
- * Uses a robust approach that handles any LLM provider's wrapper tags
- * by finding the last valid mxCell ending and checking if suffix is just closing tags.
- * @param xml - The XML string to check (can be undefined/null)
- * @returns true if XML appears complete, false if truncated or empty
- */
-export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
- // Named style definitions before the cells are not cells: output cut off
- // right after them is incomplete. A compact cell whose last quote is
- // missing is complete; prepareNewDiagram repairs it.
- const trimmed = repairQuoteBeforeSlash(
- readStyleClasses(xml || "").xml,
- ).trim()
- if (!trimmed) return false
-
- // Find position of last complete mxCell ending (either /> or )
- const lastSelfClose = trimmed.lastIndexOf("/>")
- const lastMxCellClose = trimmed.lastIndexOf("")
-
- const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
-
- // No valid ending found at all
- if (lastValidEnd === -1) return false
-
- // If the last mxCell has no after it, it must be self-closing.
- // Otherwise the trailing "/>" belongs to a child such as
- // and the output was cut off before the cell was closed.
- const lastCellStart = trimmed.lastIndexOf(" lastMxCellClose &&
- // (quoted values may hold a raw "<", which the auto-fix escapes)
- !/^"']|"[^"]*"|'[^']*')*\/>/.test(
- trimmed.slice(lastCellStart),
- )
- ) {
- return false
- }
-
- // Check what comes after the last valid ending
- // For />: add 2 chars, for : add 9 chars
- const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
- const suffix = trimmed.slice(lastValidEnd + endOffset)
-
- // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
- // This regex matches any sequence of closing XML tags like , , |DSML|xyz>
- return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
-}
+export { isMxCellXmlComplete } from "@/packages/mcp-server/src/new-diagram.ts"
/**
* Extract only complete mxCell elements from partial/streaming XML.
diff --git a/packages/claude-plugin/README.md b/packages/claude-plugin/README.md
index af9e06a6..0f41d5b1 100644
--- a/packages/claude-plugin/README.md
+++ b/packages/claude-plugin/README.md
@@ -94,7 +94,7 @@ Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram
| `get_drawing_guide` | Return the drawing rules again |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
| `create_new_diagram` | Create a new diagram from XML |
-| `load_diagram` | Load a `.drawio` file from disk |
+| `load_diagram` | Load a `.drawio` or `.drawio.svg` file from disk |
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
| `get_diagram` | Get the current diagram XML |
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
@@ -120,6 +120,9 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams and your `instructions.md`; `off` turns auto-save off (`instructions.md` is then read from the default folder) |
+| `DRAWIO_LANG` | unset | Language of the draw.io editor, such as `en`, `zh`, `zh-tw`, `ja` or `de`. Unset, draw.io chooses (the browser language on `embed.diagrams.net`, English on a self-hosted draw.io) and the user can change it under **Extras > Language** |
+| `DRAWIO_UI` | unset | draw.io theme: `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple`. Unset, the user picks one under **Extras > Theme** |
+| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light |
| `DEBUG` | unset | Set to `true` to log debug messages |
## Links
diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md
index ea8f0b6b..fc97d7a2 100644
--- a/packages/mcp-server/README.md
+++ b/packages/mcp-server/README.md
@@ -112,11 +112,11 @@ Use the standard MCP configuration with:
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails, or ask the AI to undo (`restore_version`)
-- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram`
+- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io and `load_diagram` can open again), from the **Download** button or through `export_diagram`
- **Multi-page**: List, add, rename, and delete pages, and edit any page
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/.drawio`, and its last 20 versions to `.history.json`, so the diagram, History and undo survive a restart of the MCP client
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
-- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode
+- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode, or fix them with `DRAWIO_UI`, `DRAWIO_DARK` and `DRAWIO_LANG`
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
## Available Tools
@@ -125,10 +125,10 @@ Use the standard MCP configuration with:
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram |
| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages |
-| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
+| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted, or, with `topic`, a short XML reference for tables, layers or groups |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
-| `load_diagram` | Load a `.drawio` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
+| `load_diagram` | Load a `.drawio` or `.drawio.svg` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
@@ -185,6 +185,9 @@ To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instruct
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files and your `instructions.md`. Set to `off` to turn auto-save off (`instructions.md` is then read from the default folder). |
+| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. |
+| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
+| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. |
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
### Private Deployment (Self-hosted draw.io)
diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts
index 1c171eba..e8c6cdef 100644
--- a/packages/mcp-server/src/drawing-guide.ts
+++ b/packages/mcp-server/src/drawing-guide.ts
@@ -52,6 +52,8 @@ Containers and swimlanes: children use the container id as parent and coordinate
${indent(SWIMLANE_EXAMPLE)}
+Large diagrams: if the diagram will not fit one call, send the first cells with create_new_diagram and add the rest with edit_diagram add operations (one per cell, same id rules). A call rejected because the XML ends inside an unfinished cell drew nothing: send all of its cells again, in smaller parts if needed.
+
## Layout
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
@@ -92,6 +94,9 @@ ${indent(STYLE_CLASS_EXAMPLE)}
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
- Animated connectors: add flowAnimation=1 to the edge style.
+- Edge options: dashed=1; strokeWidth=2; edgeStyle=elbowEdgeStyle;elbow=vertical (or horizontal); jumpStyle=arc;jumpSize=10 where edges cross.
+
+For tables, layers or groups, call get_drawing_guide with topic=tables, layers or groups.
## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
diff --git a/packages/mcp-server/src/drawio-themes.ts b/packages/mcp-server/src/drawio-themes.ts
new file mode 100644
index 00000000..a43b0615
--- /dev/null
+++ b/packages/mcp-server/src/drawio-themes.ts
@@ -0,0 +1,24 @@
+export const DRAWIO_THEMES = [
+ "kennedy",
+ "atlas",
+ "dark",
+ "min",
+ "sketch",
+ "simple",
+] as const
+
+export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
+
+export function isDrawioTheme(value: unknown): value is DrawioTheme {
+ return (
+ typeof value === "string" &&
+ (DRAWIO_THEMES as readonly string[]).includes(value)
+ )
+}
+
+/** draw.io's name for a locale: lowercase, "zh-tw" for Traditional and "zh" for Simplified Chinese */
+export function toDrawioLang(locale: string): string {
+ const lang = locale.toLowerCase()
+ if (lang === "zh-hant") return "zh-tw"
+ return lang === "zh-hans" ? "zh" : lang
+}
diff --git a/packages/mcp-server/src/edit-gate.ts b/packages/mcp-server/src/edit-gate.ts
index f80240b2..2ce81a2c 100644
--- a/packages/mcp-server/src/edit-gate.ts
+++ b/packages/mcp-server/src/edit-gate.ts
@@ -142,3 +142,116 @@ export function markPageSeen(
? liveXml
: lastSeenXml
}
+
+const CELL_TAGS = new Set(["mxCell", "UserObject", "object"])
+
+/** The cells of a page by id: the direct children of its . */
+function pageCells(page: Element): Map {
+ const cells = new Map()
+ for (const el of Array.from(page.querySelector("root")?.children ?? [])) {
+ const id = el.getAttribute("id")
+ if (id && CELL_TAGS.has(el.tagName)) cells.set(id, el)
+ }
+ return cells
+}
+
+/** `id ("label")`: the cell's value without HTML, cut at 30 characters. */
+function describeCell(id: string, cell: Element): string {
+ // getAttributeNode gives the decoded value (linkedom's getAttribute
+ // escapes it again); an HTML label then still holds tags and entities
+ const label = (
+ (cell.getAttributeNode("value") ?? cell.getAttributeNode("label"))
+ ?.value ?? ""
+ )
+ .replace(/<[^>]*>/g, " ")
+ .replace(/ /g, " ")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/&/g, "&")
+ .replace(/\s+/g, " ")
+ .trim()
+ if (!label) return id
+ return `${id} ("${label.length > 30 ? `${label.slice(0, 30)}...` : label}")`
+}
+
+/** At most 20 entries, then "and N more". */
+function listCells(cells: string[]): string {
+ const shown = cells.slice(0, 20).join(", ")
+ return cells.length > 20 ? `${shown} and ${cells.length - 20} more` : shown
+}
+
+/**
+ * One paragraph naming what the user changed between the XML the model last
+ * saw and the live store, for stale rejections and get_diagram. Pages are
+ * paired by index (draw.io regenerates diagram ids on re-serialisation) and
+ * cells by id. Returns "" when there is nothing to compare or the two sides
+ * have the same content.
+ */
+export function describeChanges(lastSeenXml: string, liveXml: string): string {
+ if (!lastSeenXml || lastSeenXml === liveXml) return ""
+ const parse = (xml: string) => {
+ const normalized = normalizeToMxfile(xml)
+ return normalized ? parseMxfile(normalized) : null
+ }
+ const before = parse(lastSeenXml)
+ const live = parse(liveXml)
+ if (!before || !live) return ""
+ // Same rule as checkEditGate: a bare carries no page name
+ const includeNames =
+ !isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
+ if (
+ contentFingerprint(lastSeenXml, includeNames) ===
+ contentFingerprint(liveXml, includeNames)
+ )
+ return ""
+
+ const pagesBefore = Array.from(before.querySelectorAll("diagram"))
+ const pagesLive = Array.from(live.querySelectorAll("diagram"))
+ const pageCount = Math.max(pagesBefore.length, pagesLive.length)
+ const clauses: string[] = []
+ const renames: string[] = []
+ for (let i = 0; i < pageCount; i++) {
+ const pageBefore = pagesBefore[i]
+ const pageLive = pagesLive[i]
+ if (!pageBefore || !pageLive) {
+ clauses.push(`page ${i + 1} ${pageLive ? "added" : "removed"}`)
+ continue
+ }
+ const cellsBefore = pageCells(pageBefore)
+ const cellsLive = pageCells(pageLive)
+ const modified: string[] = []
+ const added: string[] = []
+ const removed: string[] = []
+ for (const [id, cell] of cellsLive) {
+ const old = cellsBefore.get(id)
+ if (!old) added.push(describeCell(id, cell))
+ else if (canonicalizeElement(old) !== canonicalizeElement(cell))
+ modified.push(describeCell(id, cell))
+ }
+ for (const [id, cell] of cellsBefore) {
+ if (!cellsLive.has(id)) removed.push(describeCell(id, cell))
+ }
+ // Name the page only when the document has several
+ const where = pageCount > 1 ? `page ${i + 1} ` : ""
+ if (modified.length)
+ clauses.push(`${where}modified ${listCells(modified)}`)
+ if (added.length) clauses.push(`${where}added ${listCells(added)}`)
+ if (removed.length)
+ clauses.push(`${where}removed ${listCells(removed)}`)
+ if (includeNames) {
+ const name = pageLive.getAttribute("name") ?? ""
+ if (name !== (pageBefore.getAttribute("name") ?? ""))
+ renames.push(`Page ${i + 1} renamed to "${name}".`)
+ }
+ }
+ // The fingerprint also covers the cell order (z-order) and anything in
+ // that is not a cell
+ if (!clauses.length && !renames.length)
+ clauses.push("the order of the cells changed")
+ const intro = "Since you last saw it, the user changed the diagram"
+ const body = clauses.length
+ ? `${intro}: ${clauses.join("; ")}.`
+ : `${intro}.`
+ return [body, ...renames].join(" ")
+}
diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts
index 4b67e87a..ced602ba 100644
--- a/packages/mcp-server/src/http-server.ts
+++ b/packages/mcp-server/src/http-server.ts
@@ -44,6 +44,7 @@ function readBody(
})
}
+import { isDrawioTheme, toDrawioLang } from "./drawio-themes.ts"
import { contentFingerprint } from "./edit-gate.ts"
import {
addHistory,
@@ -813,6 +814,33 @@ function loadPreviewTemplate(): string {
const scriptJson = (value: string) =>
JSON.stringify(value).replace(/ Language / Theme submenu once they are given.
+ */
+export function drawioEmbedParams(
+ env: NodeJS.ProcessEnv = process.env,
+): string {
+ const params = new URLSearchParams()
+ const ui = (env.DRAWIO_UI ?? "").toLowerCase()
+ const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
+ const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
+ if (["1", "true", "dark"].includes(dark)) {
+ params.set("dark", "1")
+ } else if (["0", "false", "light"].includes(dark)) {
+ params.set("dark", "0")
+ } else {
+ // draw.io takes ui=dark as dark mode only when no dark parameter is
+ // present, and this page always sends one
+ params.set("dark", !dark && ui === "dark" ? "1" : "auto")
+ }
+ if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
+ if (isDrawioTheme(ui)) params.set("ui", ui)
+ return params.toString()
+}
+
function getHtmlPage(sessionId: string): string {
return loadPreviewTemplate()
.replace("{{SESSION_BADGE}}", () =>
@@ -822,6 +850,7 @@ function getHtmlPage(sessionId: string): string {
)
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
+ .replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
}
diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts
index 0a0c3f1a..f02ae8a7 100644
--- a/packages/mcp-server/src/index.ts
+++ b/packages/mcp-server/src/index.ts
@@ -32,7 +32,12 @@ import type { DiagramOperation } from "./diagram-operations.ts"
import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts"
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
-import { checkEditGate, contentFingerprint, markPageSeen } from "./edit-gate.ts"
+import {
+ checkEditGate,
+ contentFingerprint,
+ describeChanges,
+ markPageSeen,
+} from "./edit-gate.ts"
import { createExclusive } from "./exclusive.ts"
import {
addHistory,
@@ -64,6 +69,7 @@ import {
prepareNewDiagram,
reservedIdError,
takeStyleDefinitions,
+ truncatedCellError,
} from "./new-diagram.ts"
import {
addPageToDoc,
@@ -84,6 +90,7 @@ import {
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
+import { XML_REFERENCE } from "./xml-reference.ts"
import { validateAndFixXml } from "./xml-validation.ts"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
@@ -163,10 +170,10 @@ After drawing a complex diagram, call screenshot_diagram once to see it, and fix
Tools:
- create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full for several pages.
-- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry.
+- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry; also used to draw a large diagram in parts.
- get_diagram: read the current XML, including the user's manual edits.
- screenshot_diagram: see the rendered diagram as an image.
-- load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths.
+- load_diagram, export_diagram: open or save .drawio or .drawio.svg files and export .png or .svg. Use absolute paths.
- list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=.
- list_pages, add_page, rename_page, delete_page: manage pages (tabs).
- restore_version: undo; go back to an earlier version of the diagram.`
@@ -272,11 +279,26 @@ server.registerTool(
title: "Get drawing guide",
description:
"Return the drawing guide: XML format, layout, edge routing, style and editing rules. " +
- "start_session already returns it; call this only if the guide is no longer in your context.",
- inputSchema: {},
+ "start_session already returns it; call this only if the guide is no longer in your context. " +
+ "With topic, returns a short XML reference for tables, layers or groups.",
+ inputSchema: {
+ topic: z
+ .enum(["tables", "layers", "groups"])
+ .optional()
+ .describe(
+ "Return only the XML reference for this topic instead of the whole guide.",
+ ),
+ },
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async () => ({ content: [{ type: "text", text: guideText() }] }),
+ async ({ topic }) => ({
+ content: [
+ {
+ type: "text",
+ text: topic ? XML_REFERENCE[topic] : guideText(),
+ },
+ ],
+ }),
)
// Tool: get_shape_library
@@ -610,12 +632,13 @@ To clear the canvas to one blank page, send only the two root cells (all pages).\n" +
@@ -1821,6 +1857,13 @@ registerWriteTool(
isError: true,
}
}
+ const truncated = startXml && truncatedCellError(startXml)
+ if (truncated) {
+ return {
+ content: [{ type: "text", text: `Error: ${truncated}` }],
+ isError: true,
+ }
+ }
let cleanXml: string | undefined =
startXml && wrapCellsInModel(startXml)
if (cleanXml) {
diff --git a/packages/mcp-server/src/load-diagram.ts b/packages/mcp-server/src/load-diagram.ts
index 9dbcb5f9..ab395762 100644
--- a/packages/mcp-server/src/load-diagram.ts
+++ b/packages/mcp-server/src/load-diagram.ts
@@ -43,11 +43,27 @@ export function decompressPageContent(compressed: string): string | null {
}
}
+/**
+ * Read the diagram XML a .drawio.svg (Editable SVG) carries in its root
+ * element's content attribute. Returns undefined when the text is not an
+ * SVG, and "" when the SVG has no embedded diagram.
+ *
+ * Uses getAttributeNode().value rather than getAttribute(): linkedom's
+ * getAttribute re-escapes <, > and & in XML documents, which would turn
+ * the embedded " whose every page holds plain XML. Accepts a
- * bare (wrapped into a one-page mxfile) and decompresses
- * any compressed pages.
+ * bare (wrapped into a one-page mxfile), a .drawio.svg with
+ * the diagram embedded, and decompresses any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
let trimmed = content.trim()
@@ -62,9 +78,25 @@ export function parseDrawioFileContent(content: string): LoadResult {
trimmed = normalized
}
if (!isMxFile(trimmed)) {
+ const embedded = extractSvgEmbeddedXml(trimmed)
+ if (embedded === undefined) {
+ return {
+ ok: false,
+ error: "Not a draw.io file: expected an or root element, or a .drawio.svg with the diagram embedded.",
+ }
+ }
+ if (!embedded) {
+ return {
+ ok: false,
+ error: "This SVG has no embedded diagram. Only .drawio.svg (Editable SVG) files can be loaded; ask for the .drawio file or redraw from the image.",
+ }
+ }
+ if (isMxFile(embedded) || isMxGraphModel(embedded)) {
+ return parseDrawioFileContent(embedded)
+ }
return {
ok: false,
- error: "Not a draw.io file: expected an or root element.",
+ error: "The SVG's embedded content is not draw.io XML.",
}
}
const doc = parseMxfile(trimmed)
diff --git a/packages/mcp-server/src/new-diagram.ts b/packages/mcp-server/src/new-diagram.ts
index cc9669f7..68617ed7 100644
--- a/packages/mcp-server/src/new-diagram.ts
+++ b/packages/mcp-server/src/new-diagram.ts
@@ -12,7 +12,7 @@ import {
} from "./style-classes.ts"
import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
-import { validateAndFixXml } from "./xml-validation.ts"
+import { repairQuoteBeforeSlash, validateAndFixXml } from "./xml-validation.ts"
export type NewDiagram =
| { ok: true; xml: string; fixes: string[] }
@@ -53,6 +53,74 @@ export function reservedIdError(input: string): string | null {
return null
}
+/**
+ * Check if mxCell XML output is complete (not truncated).
+ * Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
+ * Uses a robust approach that handles any LLM provider's wrapper tags
+ * by finding the last valid mxCell ending and checking if suffix is just closing tags.
+ * @param xml - The XML string to check (can be undefined/null)
+ * @returns true if XML appears complete, false if truncated or empty
+ */
+export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
+ // Named style definitions before the cells are not cells: output cut off
+ // right after them is incomplete. A compact cell whose last quote is
+ // missing is complete; prepareNewDiagram repairs it.
+ const trimmed = repairQuoteBeforeSlash(
+ readStyleClasses(xml || "").xml,
+ ).trim()
+ if (!trimmed) return false
+
+ // Find position of last complete mxCell ending (either /> or )
+ const lastSelfClose = trimmed.lastIndexOf("/>")
+ const lastMxCellClose = trimmed.lastIndexOf("")
+
+ const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
+
+ // No valid ending found at all
+ if (lastValidEnd === -1) return false
+
+ // If the last mxCell has no after it, it must be self-closing.
+ // Otherwise the trailing "/>" belongs to a child such as
+ // and the output was cut off before the cell was closed.
+ const lastCellStart = trimmed.lastIndexOf(" lastMxCellClose &&
+ // (quoted values may hold a raw "<", which the auto-fix escapes)
+ !/^"']|"[^"]*"|'[^']*')*\/>/.test(
+ trimmed.slice(lastCellStart),
+ )
+ ) {
+ return false
+ }
+
+ // Check what comes after the last valid ending
+ // For />: add 2 chars, for : add 9 chars
+ const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
+ const suffix = trimmed.slice(lastValidEnd + endOffset)
+
+ // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
+ // This regex matches any sequence of closing XML tags like , , |DSML|xyz>
+ return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
+}
+
+/**
+ * Output that stops inside an mxCell would be wrapped and rejected with an
+ * error about wrapper tags the model never sent. Bare cells only. Returns the
+ * error for the model, or null.
+ */
+export function truncatedCellError(input: string): string | null {
+ if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
+ // Comments, and closing tags such as that the auto-fix
+ // repairs, are no cut
+ const stripped = input
+ .replace(//g, "")
+ .replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "$1>")
+ // No cell at all: the validator's message fits better
+ if (!/
-
+
diff --git a/packages/mcp-server/src/xml-reference.ts b/packages/mcp-server/src/xml-reference.ts
new file mode 100644
index 00000000..5c8b353f
--- /dev/null
+++ b/packages/mcp-server/src/xml-reference.ts
@@ -0,0 +1,71 @@
+/**
+ * Short XML references returned by get_drawing_guide with a topic. Each
+ * snippet is the bare mxCell elements of one page, as create_new_diagram
+ * accepts them, and is checked by tests/xml-reference.test.ts.
+ */
+
+import { indent } from "./xml-examples.ts"
+
+const TABLE_ROW_STYLE =
+ "shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;"
+
+const TABLE_CELL_STYLE =
+ "shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;"
+
+const TABLES_EXAMPLE = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+const LAYERS_EXAMPLE = `
+
+
+
+
+
+`
+
+const GROUPS_EXAMPLE = `
+
+
+
+
+
+
+
+`
+
+export const XML_REFERENCE: Record<"tables" | "layers" | "groups", string> = {
+ tables: `## Tables
+A table is three levels of cells, as draw.io's sidebar inserts them: the table container (style "shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;", parent="1", geometry wide enough for all columns), one row per table row (style as below, parent = table id, width = table width, height = row height, y = row offset), and one cell per column inside each row (style as below, parent = row id, x = column offset, y = 0, width = column width, height = row height; the cell text is the value). A header row can use fontStyle=1 on its cells. Example with 2 rows and 2 columns:
+
+${indent(TABLES_EXAMPLE)}
+`,
+ layers: `## Layers
+A layer is the one case of a cell with parent="0" and no vertex or edge attribute; id="1" is the default layer. Add a layer cell and give shapes parent="" to put them on it:
+
+${indent(LAYERS_EXAMPLE)}
+`,
+ groups: `## Groups
+A group is a container with style="group" (parent="1", geometry enclosing its children). Children use parent="" and coordinates relative to the group; moving the group moves them:
+
+${indent(GROUPS_EXAMPLE)}
+`,
+}
diff --git a/packages/mcp-server/tests/edit-gate.test.ts b/packages/mcp-server/tests/edit-gate.test.ts
index 66df1fce..e5cf6a78 100644
--- a/packages/mcp-server/tests/edit-gate.test.ts
+++ b/packages/mcp-server/tests/edit-gate.test.ts
@@ -19,6 +19,7 @@ beforeAll(() => {
import {
checkEditGate,
contentFingerprint,
+ describeChanges,
markPageSeen,
} from "../src/edit-gate.ts"
@@ -168,3 +169,139 @@ describe("markPageSeen", () => {
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
})
})
+
+describe("describeChanges", () => {
+ const BOX2 = ``
+ const XML_A_PLUS_BOX2 = XML_A.replace("", `${BOX2}`)
+ const PAGE_2 = ``
+ const XML_A_TWO_PAGES = XML_A.replace("", `${PAGE_2}`)
+
+ it("names a moved cell with its label", () => {
+ const summary = describeChanges(XML_A, XML_B)
+ expect(summary).toBe(
+ 'Since you last saw it, the user changed the diagram: modified box1 ("Hello").',
+ )
+ expect(summary).toContain("modified box1")
+ })
+
+ it("names a deleted cell", () => {
+ expect(describeChanges(XML_A_PLUS_BOX2, XML_A)).toContain(
+ 'removed box2 ("World")',
+ )
+ })
+
+ it("names an added cell", () => {
+ expect(describeChanges(XML_A, XML_A_PLUS_BOX2)).toContain(
+ 'added box2 ("World")',
+ )
+ })
+
+ it("is empty for draw.io's re-serialisation of the same content", () => {
+ expect(describeChanges(XML_A, XML_A_RESERIALIZED)).toBe("")
+ })
+
+ it("is empty when the model has seen nothing yet", () => {
+ expect(describeChanges("", XML_B)).toBe("")
+ })
+
+ it("is empty when one side does not parse", () => {
+ expect(describeChanges(XML_A, "not xml at all")).toBe("")
+ })
+
+ it("reports a renamed page", () => {
+ const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
+ expect(describeChanges(XML_A, renamed)).toBe(
+ 'Since you last saw it, the user changed the diagram. Page 1 renamed to "Renamed".',
+ )
+ })
+
+ // A bare push carries no page name, so the invented
+ // "Page-1" must not read as a rename
+ it("never reports a rename against a bare mxGraphModel", () => {
+ const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
+ expect(describeChanges(seenRenamed, XML_A_BARE)).toBe("")
+ const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
+ const summary = describeChanges(seenRenamed, bareMoved)
+ expect(summary).toContain("modified box1")
+ expect(summary).not.toContain("renamed")
+ })
+
+ it("strips HTML from labels and reads UserObject labels", () => {
+ const html = XML_A.replace(
+ 'value="Hello"',
+ 'value="<b>Hello</b>"',
+ )
+ expect(describeChanges(XML_A, html)).toContain(
+ 'modified box1 ("Hello")',
+ )
+ const userObject = XML_A.replace(
+ "",
+ ``,
+ )
+ expect(describeChanges(XML_A, userObject)).toContain(
+ 'added u1 ("Link")',
+ )
+ })
+
+ it("keeps the words of an HTML label apart and decodes its spaces", () => {
+ const lines = XML_A.replace(
+ 'value="Hello"',
+ 'value="Line1<br>Line2 end & more"',
+ )
+ expect(describeChanges(XML_A, lines)).toContain(
+ 'modified box1 ("Line1 Line2 end & more")',
+ )
+ })
+
+ it("cuts long labels at 30 characters", () => {
+ const long = "a".repeat(40)
+ const summary = describeChanges(
+ XML_A,
+ XML_A.replace('value="Hello"', `value="${long}"`),
+ )
+ expect(summary).toContain(`("${"a".repeat(30)}...")`)
+ })
+
+ it("lists at most 20 cells and counts the rest", () => {
+ const many = Array.from(
+ { length: 25 },
+ (_, i) => ``,
+ ).join("")
+ const summary = describeChanges(
+ XML_A,
+ XML_A.replace("", `${many}`),
+ )
+ expect(summary).toContain("added n0, n1,")
+ expect(summary).toContain("n19 and 5 more.")
+ expect(summary).not.toContain("n20")
+ })
+
+ it("reports added and removed pages", () => {
+ expect(describeChanges(XML_A, XML_A_TWO_PAGES)).toBe(
+ "Since you last saw it, the user changed the diagram: page 2 added.",
+ )
+ expect(describeChanges(XML_A_TWO_PAGES, XML_A)).toBe(
+ "Since you last saw it, the user changed the diagram: page 2 removed.",
+ )
+ })
+
+ it("names the page of a cell change in a multi-page document", () => {
+ const movedOnPage1 = XML_A_TWO_PAGES.replace(
+ 'x="40" y="40"',
+ 'x="300" y="200"',
+ )
+ expect(describeChanges(XML_A_TWO_PAGES, movedOnPage1)).toContain(
+ 'page 1 modified box1 ("Hello")',
+ )
+ })
+
+ it("falls back to a generic clause when only the cell order changed", () => {
+ const reordered = XML_A_PLUS_BOX2.replace(BOX2, "").replace(
+ ' {
})
})
+describe("draw.io embed parameters from the host config", () => {
+ it("follows the system dark mode and fixes nothing else by default", () => {
+ expect(drawioEmbedParams({})).toBe("dark=auto")
+ })
+
+ it("turns dark mode on with the dark theme", () => {
+ // draw.io ignores ui=dark for dark mode once a dark parameter is sent
+ const params = drawioEmbedParams({ DRAWIO_UI: "dark" })
+ expect(params).toContain("dark=1")
+ expect(params).toContain("ui=dark")
+ // Unless the user asked for light mode
+ expect(drawioEmbedParams({ DRAWIO_UI: "dark", DRAWIO_DARK: "0" })).toBe(
+ "dark=0&ui=dark",
+ )
+ })
+
+ it("reads the theme in any case", () => {
+ expect(drawioEmbedParams({ DRAWIO_UI: "Dark" })).toBe("dark=1&ui=dark")
+ })
+
+ it("ignores a theme or language draw.io does not know", () => {
+ expect(drawioEmbedParams({ DRAWIO_UI: "neon" })).toBe("dark=auto")
+ expect(drawioEmbedParams({ DRAWIO_LANG: "en;drop" })).toBe("dark=auto")
+ expect(drawioEmbedParams({ DRAWIO_LANG: "english" })).toBe("dark=auto")
+ })
+
+ it("uses draw.io's name for Traditional Chinese", () => {
+ for (const value of ["zh-Hant", "zh-hant"]) {
+ expect(drawioEmbedParams({ DRAWIO_LANG: value })).toBe(
+ "dark=auto&lang=zh-tw",
+ )
+ }
+ expect(drawioEmbedParams({ DRAWIO_LANG: "ja" })).toBe(
+ "dark=auto&lang=ja",
+ )
+ // and "zh" for Simplified Chinese
+ expect(drawioEmbedParams({ DRAWIO_LANG: "zh-Hans" })).toBe(
+ "dark=auto&lang=zh",
+ )
+ })
+
+ it("reads dark mode in any spelling", () => {
+ expect(drawioEmbedParams({ DRAWIO_DARK: "TRUE" })).toBe("dark=1")
+ expect(drawioEmbedParams({ DRAWIO_DARK: "light" })).toBe("dark=0")
+ expect(drawioEmbedParams({ DRAWIO_DARK: "maybe" })).toBe("dark=auto")
+ })
+})
+
describe("history restore", () => {
it("restores the entry the user picked after older entries drop", async () => {
const id = "mcp-history"
diff --git a/packages/mcp-server/tests/load-diagram.test.ts b/packages/mcp-server/tests/load-diagram.test.ts
index afce3463..1dc12a2e 100644
--- a/packages/mcp-server/tests/load-diagram.test.ts
+++ b/packages/mcp-server/tests/load-diagram.test.ts
@@ -19,6 +19,7 @@ beforeAll(() => {
import {
decompressPageContent,
+ extractSvgEmbeddedXml,
parseDrawioFileContent,
} from "../src/load-diagram.ts"
@@ -120,11 +121,17 @@ describe("parseDrawioFileContent", () => {
})
it("rejects non-drawio content", () => {
- const r = parseDrawioFileContent("")
+ const r = parseDrawioFileContent("")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
})
+ it("rejects an SVG without an embedded diagram", () => {
+ const r = parseDrawioFileContent("")
+ expect(r.ok).toBe(false)
+ if (!r.ok) expect(r.error).toContain("embedded diagram")
+ })
+
it("rejects a page whose content is neither XML nor compressed", () => {
const bad = `!!! not a diagram !!!`
const r = parseDrawioFileContent(bad)
@@ -159,3 +166,112 @@ describe("hadCompressedPages", () => {
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
})
+
+/**
+ * Escape text for an XML attribute the way draw.io/Chromium serialise the
+ * diagram into the SVG root's content attribute.
+ */
+function escapeAttr(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/\n/g, "
")
+}
+
+/** Build a .drawio.svg (Editable SVG) with the given mxfile embedded. */
+function drawioSvg(mxfileXml: string): string {
+ return ``
+}
+
+const SVG_PROLOG = `
+
+
+`
+
+describe("extractSvgEmbeddedXml", () => {
+ it("returns undefined for non-SVG text", () => {
+ expect(extractSvgEmbeddedXml(PLAIN_MXFILE)).toBeUndefined()
+ expect(extractSvgEmbeddedXml("hello")).toBeUndefined()
+ })
+
+ it("returns an empty string for an SVG without content", () => {
+ expect(extractSvgEmbeddedXml("")).toBe("")
+ })
+
+ it("returns the decoded content attribute", () => {
+ expect(extractSvgEmbeddedXml(drawioSvg(PLAIN_MXFILE))).toBe(
+ PLAIN_MXFILE,
+ )
+ })
+})
+
+describe("parseDrawioFileContent with .drawio.svg", () => {
+ it("loads a compressed one-page mxfile embedded in an SVG", () => {
+ const r = parseDrawioFileContent(drawioSvg(COMPRESSED_MXFILE))
+ expect(r.ok).toBe(true)
+ if (r.ok) {
+ expect(r.xml).toContain(" {
+ const r = parseDrawioFileContent(
+ SVG_PROLOG + drawioSvg(COMPRESSED_MXFILE),
+ )
+ expect(r.ok).toBe(true)
+ if (r.ok) expect(r.xml).toContain('value="Hello"')
+ })
+
+ it("round-trips " and
in an uncompressed embedded label", () => {
+ const model = MODEL_XML.replace(
+ 'value="Hello"',
+ 'value="say "hi"
next"',
+ )
+ // A pretty-printed file: the newline between tags becomes
in
+ // the SVG attribute, the label's entities become " etc.
+ const mxfile = `\n${model}\n`
+ const svg = drawioSvg(mxfile)
+ expect(svg).toContain(""")
+ expect(svg).toContain("
")
+ expect(svg).toContain(">
<diagram")
+
+ const r = parseDrawioFileContent(svg)
+ expect(r.ok).toBe(true)
+ if (r.ok) {
+ expect(r.xml).toContain('value="say "hi"
next"')
+ const doc = new DOMParser().parseFromString(r.xml, "text/xml")
+ const cell = doc.querySelector('mxCell[id="box1"]') as Element
+ expect(cell.getAttributeNode("value")?.value).toBe('say "hi"\nnext')
+ }
+ })
+
+ it("loads a two-page embedded mxfile as two pages", () => {
+ const twoPages = `${drawioCompress(MODEL_XML)}${drawioCompress(MODEL_XML.replace("Hello", "World"))}`
+ const r = parseDrawioFileContent(drawioSvg(twoPages))
+ 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()
+ }
+ expect(r.xml).toContain('value="World"')
+ }
+ })
+
+ it("rejects an SVG whose content is not draw.io XML", () => {
+ const r = parseDrawioFileContent(
+ '',
+ )
+ expect(r.ok).toBe(false)
+ if (!r.ok) expect(r.error).toContain("not draw.io XML")
+ })
+})
diff --git a/packages/mcp-server/tests/new-diagram.test.ts b/packages/mcp-server/tests/new-diagram.test.ts
new file mode 100644
index 00000000..b935ba4f
--- /dev/null
+++ b/packages/mcp-server/tests/new-diagram.test.ts
@@ -0,0 +1,152 @@
+/**
+ * Tests for prepareNewDiagram's truncation check: output that stops inside an
+ * mxCell gets a clear error instead of one about the wrapper tags the model
+ * never sent.
+ */
+
+import { beforeAll, describe, expect, it } from "vitest"
+import { installDomPolyfill } from "../src/dom.ts"
+
+beforeAll(() => {
+ installDomPolyfill()
+})
+
+import {
+ isMxCellXmlComplete,
+ prepareNewDiagram,
+ truncatedCellError,
+} from "../src/new-diagram.ts"
+
+describe("isMxCellXmlComplete", () => {
+ it("returns false for empty/null input", () => {
+ expect(isMxCellXmlComplete("")).toBe(false)
+ expect(isMxCellXmlComplete(null)).toBe(false)
+ expect(isMxCellXmlComplete(undefined)).toBe(false)
+ })
+
+ it("returns true for self-closing mxCell", () => {
+ const xml =
+ ''
+ expect(isMxCellXmlComplete(xml)).toBe(true)
+ // A raw "<" in a value (escaped later by the auto-fix)
+ expect(
+ isMxCellXmlComplete(
+ '',
+ ),
+ ).toBe(true)
+ })
+
+ it("returns true for mxCell with closing tag", () => {
+ const xml = `
+
+ `
+ expect(isMxCellXmlComplete(xml)).toBe(true)
+ })
+
+ it("returns false for truncated mxCell", () => {
+ const xml =
+ ' {
+ const xml = `
+ {
+ const xml = `
+
+
+
+ `
+ expect(isMxCellXmlComplete(xml)).toBe(false)
+ })
+
+ it("returns false when output stops after of an open mxCell", () => {
+ const xml = `
+
+
+ `
+ expect(isMxCellXmlComplete(xml)).toBe(false)
+ })
+
+ it("returns true for a self-closing last mxCell with > in its value", () => {
+ const xml = `
+
+
+ `
+ expect(isMxCellXmlComplete(xml)).toBe(true)
+ })
+
+ it("returns true for multiple complete mxCells", () => {
+ const xml = `
+ `
+ expect(isMxCellXmlComplete(xml)).toBe(true)
+ })
+})
+
+describe("prepareNewDiagram with cut-off output", () => {
+ const A = ``
+
+ it("rejects a cell cut off inside an attribute", () => {
+ const cut = `${A} {
+ const cut = `${A}`
+ const out = prepareNewDiagram(cut)
+ expect(out.ok).toBe(false)
+ if (out.ok) return
+ expect(out.error).toContain("cut off")
+ expect(out.error).not.toContain("")
+ })
+
+ it("quotes at most the last 300 characters", () => {
+ const cut = `${A} {
+ const cells = `${A}`
+ expect(truncatedCellError(cells)).toBeNull()
+ expect(prepareNewDiagram(cells).ok).toBe(true)
+ })
+
+ it("leaves a closing tag the auto-fix repairs to the auto-fix", () => {
+ // is a typo, not a cut
+ const typo = A.replace("", "")
+ expect(truncatedCellError(typo)).toBeNull()
+ expect(prepareNewDiagram(typo).ok).toBe(true)
+ })
+
+ it("leaves input without any cell to the validator", () => {
+ for (const input of ["", "Here is the diagram"]) {
+ expect(truncatedCellError(input)).toBeNull()
+ const out = prepareNewDiagram(input)
+ expect(out.ok).toBe(false)
+ if (!out.ok) expect(out.error).not.toContain("cut off")
+ }
+ })
+
+ it("never checks a full ", () => {
+ const cut = ` {
text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`),
).toBe(true)
})
+
+ it("advertises topic on get_drawing_guide", async () => {
+ const resp = await send("tools/list", {})
+ const guide = resp.result.tools.find(
+ (t: { name: string }) => t.name === "get_drawing_guide",
+ )
+ expect(guide?.inputSchema?.properties?.topic).toBeTruthy()
+ })
+
+ it("serves the table XML reference with topic=tables", async () => {
+ const resp = await send("tools/call", {
+ name: "get_drawing_guide",
+ arguments: { topic: "tables" },
+ })
+ expect(resp.result.isError).toBeFalsy()
+ const text: string = resp.result.content[0].text
+ expect(text).toContain("shape=table")
+ expect(text).not.toContain("Edge routing rules")
+ })
})
describe("load_diagram dual-source arguments", () => {
diff --git a/packages/mcp-server/tests/xml-reference.test.ts b/packages/mcp-server/tests/xml-reference.test.ts
new file mode 100644
index 00000000..6d867d79
--- /dev/null
+++ b/packages/mcp-server/tests/xml-reference.test.ts
@@ -0,0 +1,58 @@
+/**
+ * The XML references returned by get_drawing_guide with a topic. Each
+ * snippet must be accepted by create_new_diagram as bare mxCell elements;
+ * whether draw.io renders it as intended is checked by hand.
+ */
+
+import { beforeAll, describe, expect, it } from "vitest"
+import { installDomPolyfill } from "../src/dom.ts"
+
+beforeAll(() => {
+ installDomPolyfill()
+})
+
+import { prepareNewDiagram } from "../src/new-diagram.ts"
+import { hasCells } from "../src/pages.ts"
+import { XML_REFERENCE } from "../src/xml-reference.ts"
+
+/** The code block of a reference: the lines indented by four spaces. */
+function snippet(topic: keyof typeof XML_REFERENCE): string {
+ return XML_REFERENCE[topic]
+ .split("\n")
+ .filter((line) => line.startsWith(" "))
+ .map((line) => line.slice(4))
+ .join("\n")
+}
+
+describe("XML_REFERENCE", () => {
+ it.each(["tables", "layers", "groups"] as const)(
+ "%s snippet passes prepareNewDiagram",
+ (topic) => {
+ const xml = snippet(topic)
+ expect(xml).toContain(" {
+ const xml = snippet("tables")
+ expect(xml).toContain("shape=table;")
+ expect(xml).toContain("shape=tableRow;")
+ expect(xml).toContain("shape=partialRectangle;")
+ })
+
+ it('layers keep the layer cell with parent="0"', () => {
+ const result = prepareNewDiagram(snippet("layers"))
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(hasCells(result.xml)).toBe(true)
+ expect(result.xml).toContain('id="L2"')
+ expect(result.xml).toContain('parent="L2"')
+ })
+
+ it("groups use a group container", () => {
+ expect(snippet("groups")).toContain('style="group"')
+ })
+})
diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts
index 8bb3087a..e0e89fdc 100644
--- a/tests/unit/utils.test.ts
+++ b/tests/unit/utils.test.ts
@@ -4,7 +4,6 @@ import {
cn,
extractCompleteMxCells,
extractDiagramXML,
- isMxCellXmlComplete,
isRealDiagram,
replaceNodes,
} from "@/lib/utils"
@@ -36,76 +35,6 @@ describe("isRealDiagram", () => {
})
})
-describe("isMxCellXmlComplete", () => {
- it("returns false for empty/null input", () => {
- expect(isMxCellXmlComplete("")).toBe(false)
- expect(isMxCellXmlComplete(null)).toBe(false)
- expect(isMxCellXmlComplete(undefined)).toBe(false)
- })
-
- it("returns true for self-closing mxCell", () => {
- const xml =
- ''
- expect(isMxCellXmlComplete(xml)).toBe(true)
- // A raw "<" in a value (escaped later by the auto-fix)
- expect(
- isMxCellXmlComplete(
- '',
- ),
- ).toBe(true)
- })
-
- it("returns true for mxCell with closing tag", () => {
- const xml = `
-
- `
- expect(isMxCellXmlComplete(xml)).toBe(true)
- })
-
- it("returns false for truncated mxCell", () => {
- const xml =
- ' {
- const xml = `
- {
- const xml = `
-
-
-
- `
- expect(isMxCellXmlComplete(xml)).toBe(false)
- })
-
- it("returns false when output stops after of an open mxCell", () => {
- const xml = `
-
-
- `
- expect(isMxCellXmlComplete(xml)).toBe(false)
- })
-
- it("returns true for a self-closing last mxCell with > in its value", () => {
- const xml = `
-
-
- `
- expect(isMxCellXmlComplete(xml)).toBe(true)
- })
-
- it("returns true for multiple complete mxCells", () => {
- const xml = `
- `
- expect(isMxCellXmlComplete(xml)).toBe(true)
- })
-})
-
describe("cn (class name utility)", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")