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 <diagram> is fine.
This commit is contained in:
dayuan.jiang
2026-10-04 20:15:44 +09:00
parent 6236124338
commit 6f5f7b668b
3 changed files with 78 additions and 1 deletions
+33
View File
@@ -404,6 +404,21 @@ function findOrphanMxPoints(
* <mxPoint>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])+</g, (gap) =>
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*<!\[CDATA\[/.test(fixed)) {
fixed = fixed.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, "")
@@ -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 = `<mxCell id="c" value="Reset password" vertex="1" parent="1">\\n <mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>\\n</mxCell>`
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",
@@ -212,7 +212,7 @@ describe("autoFixXml keeps valid tags", () => {
it("removes a stray <a> without touching <Array> waypoints", () => {
const edge = `<mxCell id="e" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="1" y="2"/></Array></mxGeometry></mxCell>`
const r = validateAndFixXml(model(`<a>x</a>${edge}`))
const r = validateAndFixXml(model(`<a></a>${edge}`))
expect(r.valid).toBe(true)
expect(r.fixed).toContain('<Array as="points">')
expect(r.fixed).toContain('<mxPoint x="1" y="2"/>')
@@ -277,3 +277,34 @@ describe("validateAndFixXml strict checks", () => {
expect(r.fixed).toContain('<mxPoint x="1" y="2"/>')
})
})
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 = `<mxCell id="3" value="Reset password" vertex="1" parent="1">\\n <mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>\\n</mxCell>`
const r = validateAndFixXml(cell)
expect(r.valid).toBe(true)
expect(r.fixed).toBe(
`<mxCell id="3" value="Reset password" vertex="1" parent="1">\n <mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>\n</mxCell>`,
)
})
it("rejects other text between tags", () => {
const r = validateAndFixXml(
model(
`<mxCell id="3" vertex="1" parent="1">Reset password<mxGeometry as="geometry"/></mxCell>`,
),
)
expect(r.valid).toBe(false)
expect(r.error).toMatch(/Reset password/)
})
it("accepts a compressed page", () => {
expect(
validateAndFixXml(
`<mxfile><diagram id="p" name="P">dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF</diagram></mxfile>`,
).valid,
).toBe(true)
})
})