From 6f5f7b668b5d9aa948a3bbadaf25795f6e620138 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 4 Oct 2026 20:15:44 +0900 Subject: [PATCH] fix(mcp-server): reject text between tags, which draw.io cannot open draw.io reads any text inside a page as compressed page data, so a stray text node makes the whole page fail with an atob error. gpt-5-mini sends new cells with a literal "\n" between the tags; the edit card said Complete while draw.io showed the error and kept the old diagram. Validation now reports text between tags, and auto-fix turns a literal \n, \t or \r between tags into whitespace. Other text goes back to the model as an error. The compressed data directly under is fine. --- packages/mcp-server/src/xml-validation.ts | 33 +++++++++++++++++++ .../mcp-server/tests/edit-diagram.test.ts | 13 ++++++++ .../mcp-server/tests/xml-validation.test.ts | 33 ++++++++++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/xml-validation.ts b/packages/mcp-server/src/xml-validation.ts index fddba4c8..c1edca7b 100644 --- a/packages/mcp-server/src/xml-validation.ts +++ b/packages/mcp-server/src/xml-validation.ts @@ -404,6 +404,21 @@ function findOrphanMxPoints( * s. Used for XML the model wrote, not for files or browser state. * @returns null if valid, error message string if invalid */ +/** The first non-blank text under el, skipping a page's compressed data */ +function findTextBetweenTags(el: Element | null): string | null { + if (!el) return null + for (const node of Array.from(el.childNodes)) { + if (node.nodeType === 1) { + const text = findTextBetweenTags(node as Element) + if (text) return text + } else if (node.nodeType === 3 && el.tagName !== "diagram") { + const text = node.textContent?.trim() + if (text) return text.slice(0, 40) + } + } + return null +} + export function validateMxCellStructure( xml: string, opts: { strict?: boolean } = {}, @@ -429,6 +444,15 @@ export function validateMxCellStructure( return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` } } + + // draw.io reads any text inside a page as compressed page data and + // then fails to open the page + if (!doc.querySelector("parsererror")) { + const text = findTextBetweenTags(doc.documentElement) + if (text) { + return `Invalid XML: Found text "${text}" between tags. Labels belong in the value attribute; remove any other text between tags.` + } + } } catch (error) { console.warn( "[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:", @@ -542,6 +566,15 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push("Fixed JSON-escaped XML") } + // 0b. Literal \n, \t or \r between tags, from escaping the XML twice + const unescaped = fixed.replace(/>(?:\s|\\[nrt])+ + gap.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, ""), + ) + if (unescaped !== fixed) { + fixed = unescaped + fixes.push("Replaced literal \\n between tags with line breaks") + } + // 1. Remove CDATA wrapper if (/^\s*\s*$/, "") diff --git a/packages/mcp-server/tests/edit-diagram.test.ts b/packages/mcp-server/tests/edit-diagram.test.ts index 1e3a35b7..7354c99c 100644 --- a/packages/mcp-server/tests/edit-diagram.test.ts +++ b/packages/mcp-server/tests/edit-diagram.test.ts @@ -110,6 +110,19 @@ describe("editDiagram", () => { expect(out.ok).toBe(true) }) + it("fixes a literal \\n between tags, as gpt-5-mini sends it", () => { + const newXml = `\\n \\n` + const out = editDiagram( + DOC, + [{ operation: "add", cell_id: "c", new_xml: newXml }], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.xml).toContain('value="Reset password"') + expect(out.xml).not.toContain("\\n") + }) + it("reports a missing page as a page-level error", () => { const out = editDiagram(DOC, [{ operation: "delete", cell_id: "a" }], { page_id: "nope", diff --git a/packages/mcp-server/tests/xml-validation.test.ts b/packages/mcp-server/tests/xml-validation.test.ts index af95f21b..f828fc6a 100644 --- a/packages/mcp-server/tests/xml-validation.test.ts +++ b/packages/mcp-server/tests/xml-validation.test.ts @@ -212,7 +212,7 @@ describe("autoFixXml keeps valid tags", () => { it("removes a stray without touching waypoints", () => { const edge = `` - const r = validateAndFixXml(model(`x${edge}`)) + const r = validateAndFixXml(model(`${edge}`)) expect(r.valid).toBe(true) expect(r.fixed).toContain('') expect(r.fixed).toContain('') @@ -277,3 +277,34 @@ describe("validateAndFixXml strict checks", () => { expect(r.fixed).toContain('') }) }) + +describe("text between tags", () => { + // draw.io reads any text inside a page as compressed data, so the whole + // page fails to open with an atob error + it("turns a literal \\n between tags into a line break", () => { + const cell = `\\n \\n` + const r = validateAndFixXml(cell) + expect(r.valid).toBe(true) + expect(r.fixed).toBe( + `\n \n`, + ) + }) + + it("rejects other text between tags", () => { + const r = validateAndFixXml( + model( + `Reset password`, + ), + ) + expect(r.valid).toBe(false) + expect(r.error).toMatch(/Reset password/) + }) + + it("accepts a compressed page", () => { + expect( + validateAndFixXml( + `dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF`, + ).valid, + ).toBe(true) + }) +})