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")