refactor(mcp-server): make the diagram modules usable from the web app

The web app will reuse the MCP server's XML engine instead of its own
copy in lib/utils.ts, so these modules now run in the browser too.

- Relative imports end in .ts, rewritten to .js by tsc
  (rewriteRelativeImportExtensions); Next.js resolves them directly
- Every module uses the global DOMParser/XMLSerializer: native in the
  browser, linkedom in Node via installDomPolyfill. pages.ts parsed with
  linkedom but serialized with the global serializer, which throws in
  the browser
- The saxes syntax check moves to xml-syntax.ts, so the browser does not
  pull in linkedom; it now also rejects undeclared prefixes such as
  xlink:, as the browser does
- Page decompression uses pako and atob instead of node:zlib and Buffer
- hasCells moves to pages.ts, away from the file system code
- The duplicate cell id check counts UserObject/object ids
- wrapCellsInModel drops comments and text before the first cell, which
  the web app accepts today
- validateAndFixXml takes { strict: false } for diagrams with user content
- Web tests run these modules with a browser DOM (jsdom)
- saxes becomes a direct dependency of the web app
This commit is contained in:
dayuan.jiang
2026-10-04 12:25:53 +09:00
parent 289e6bc25f
commit b7c543ca70
29 changed files with 287 additions and 98 deletions
@@ -7,10 +7,10 @@
* first page is targeted (the "active page by convention" — see pages.ts).
*/
import { getXmlSyntaxError } from "./dom.js"
import { decompressPageContent } from "./load-diagram.js"
import { log } from "./logger.js"
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
import { decompressPageContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface DiagramOperation {
operation: "update" | "add" | "delete"
+5 -21
View File
@@ -1,29 +1,13 @@
/**
* DOM setup for Node.
* DOM setup for Node. The XML helpers use the global DOMParser and
* XMLSerializer, which the browser has and Node gets from here.
*
* linkedom gives us a DOM with querySelector, but it is lenient: it never
* reports syntax errors (no <parsererror>), and its serializer writes raw
* newlines inside attribute values, which the browser reads back as spaces.
* saxes, a strict XML parser, checks well-formedness the way draw.io's
* DOMParser will, and serializeXml writes attribute values safely.
* reports syntax errors (no <parsererror>; xml-syntax.ts checks them), and
* its serializer writes raw newlines inside attribute values, which the
* browser reads back as spaces. serializeXml writes attribute values safely.
*/
import { DOMParser } from "linkedom"
import { SaxesParser } from "saxes"
/**
* Returns the first XML syntax error as "line:column: message", or null if
* the XML is well-formed. Surrounding whitespace is ignored because every
* caller trims before the XML reaches the browser.
*/
export function getXmlSyntaxError(xml: string): string | null {
let error: string | null = null
const parser = new SaxesParser()
parser.on("error", (err) => {
error ??= err.message
})
parser.write(xml.trim()).close()
return error
}
const ESCAPES: Record<string, string> = {
"&": "&amp;",
+3 -3
View File
@@ -10,9 +10,9 @@
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import { type PageSelector, projectPage } from "./pages.js"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.js"
} from "./diagram-operations.ts"
import { type PageSelector, projectPage } from "./pages.ts"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
export type EditOutcome =
| { ok: true; xml: string; applied: number; fixes: string[] }
+1 -1
View File
@@ -17,7 +17,7 @@
* change: the set of pages, each page's name, and each page's cell tree
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
*/
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts"
export type EditGateResult =
| { ok: true }
+1 -1
View File
@@ -3,7 +3,7 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { log } from "./logger.js"
import { log } from "./logger.ts"
const MAX_HISTORY = 20
+2 -2
View File
@@ -38,8 +38,8 @@ import {
getHistory,
getHistoryEntry,
updateLastHistorySvg,
} from "./history.js"
import { log } from "./logger.js"
} from "./history.ts"
import { log } from "./logger.ts"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
+14 -13
View File
@@ -24,12 +24,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
import { z } from "zod"
import type { DiagramOperation } from "./diagram-operations.js"
import { installDomPolyfill } from "./dom.js"
import { DRAWING_GUIDE } from "./drawing-guide.js"
import { editDiagram, targetPageXml } from "./edit-diagram.js"
import { checkEditGate } from "./edit-gate.js"
import { addHistory } from "./history.js"
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 } from "./edit-gate.ts"
import { addHistory } from "./history.ts"
import {
type ExportFormat,
type ExportOptions,
@@ -42,13 +42,14 @@ import {
shutdown,
startHttpServer,
waitForSync,
} from "./http-server.js"
import { parseDrawioFileContent } from "./load-diagram.js"
import { log } from "./logger.js"
} from "./http-server.ts"
import { parseDrawioFileContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import {
addPageToDoc,
deletePageFromDoc,
findPageElement,
hasCells,
hasPageSelector,
listPagesFromDoc,
normalizeToMxfile,
@@ -58,10 +59,10 @@ import {
renamePageInDoc,
serializeMxfile,
wrapCellsInModel,
} from "./pages.js"
import { Autosaver, defaultDataDir, hasCells } from "./persistence.js"
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.js"
import { validateAndFixXml } from "./xml-validation.js"
} from "./pages.ts"
import { Autosaver, defaultDataDir } from "./persistence.ts"
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.ts"
import { validateAndFixXml } from "./xml-validation.ts"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
installDomPolyfill()
+8 -7
View File
@@ -7,16 +7,15 @@
* diagram's text content. The rest of the server assumes plain XML inside
* every <diagram>, so loading decompresses all pages up front.
*/
import { inflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import { getXmlSyntaxError } from "./dom.js"
import { inflateRaw } from "pako"
import {
isMxFile,
isMxGraphModel,
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "./pages.js"
} from "./pages.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export type LoadResult =
| { ok: true; xml: string }
@@ -28,9 +27,11 @@ export type LoadResult =
*/
export function decompressPageContent(compressed: string): string | null {
try {
const inflated = inflateRawSync(
Buffer.from(compressed.trim(), "base64"),
).toString("utf-8")
// atob and pako work in Node and in the browser
const bytes = Uint8Array.from(atob(compressed.trim()), (c) =>
c.charCodeAt(0),
)
const inflated = inflateRaw(bytes, { to: "string" })
try {
return decodeURIComponent(inflated)
} catch {
+2 -1
View File
@@ -14,7 +14,8 @@ export const log = {
console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)
},
debug: (msg: string, ...args: unknown[]) => {
if (process.env.DEBUG === "true") {
// process is missing when the web app runs this code in the browser
if (typeof process !== "undefined" && process.env.DEBUG === "true") {
console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)
}
},
+19 -7
View File
@@ -17,8 +17,7 @@
* - how to add/rename/delete pages without re-parsing ad-hoc.
*/
import { DOMParser } from "linkedom"
import { getXmlSyntaxError } from "./dom.js"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface PageInfo {
id: string
@@ -53,6 +52,10 @@ export function generatePageId(): string {
return `${a}-${b}`
}
/** Any cell besides the root cells "0" and "1" */
export const hasCells = (xml: string) =>
/<(mxCell\b[^>]*\bid="(?![01]")|UserObject\b|object\b)/.test(xml)
/** Cheap regex check — does the XML start with an <mxfile> root? */
export function isMxFile(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
@@ -87,15 +90,24 @@ const ROOT_CELLS = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
* Turn a list of bare cells (optionally inside <root>) into a one-page
* <mxGraphModel>, adding the "0" and "1" root cells. The model then only
* writes its own cells, as in the web app (wrapWithMxFile in lib/utils.ts).
* Root cells the model wrote anyway are replaced, and trailing closing tags
* some providers append are dropped. <mxfile>, <mxGraphModel> and anything
* else are returned unchanged.
* Root cells the model wrote anyway are replaced, and comments or text
* before the first cell and trailing closing tags some providers append
* are dropped. <mxfile>, <mxGraphModel> and anything else are returned
* unchanged.
*/
export function wrapCellsInModel(xml: string): string {
let content = stripXmlDeclaration(xml.trim())
if (!/^<(mxCell|UserObject|object|root)[\s/>]/.test(content)) return xml
const start = content.search(/<(mxCell|UserObject|object|root)[\s/>]/)
if (start === -1) return xml
// Only comments and plain text may come before the first cell
if (!/^(?:<!--[\s\S]*?-->|[^<])*$/.test(content.slice(0, start))) {
return xml
}
content = content.replace(/<\/?root>/g, "").trim()
content = content
.slice(start)
.replace(/<\/?root>/g, "")
.trim()
// End of the last cell, counting wrapped cells (</UserObject>, </object>)
let end = -1
for (const close of ["/>", "</mxCell>", "</UserObject>", "</object>"]) {
+2 -5
View File
@@ -17,7 +17,8 @@ import {
} from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { log } from "./logger.js"
import { log } from "./logger.ts"
import { hasCells } from "./pages.ts"
const DELAY_MS = 1000
const MAX_FILES = 50
@@ -29,10 +30,6 @@ export function defaultDataDir(): string | null {
return dir || join(homedir(), ".next-ai-drawio")
}
/** Any cell besides the root cells "0" and "1" */
export const hasCells = (xml: string) =>
/<(mxCell\b[^>]*\bid="(?![01]")|UserObject\b|object\b)/.test(xml)
export class Autosaver {
private pending = new Map<
string,
+21
View File
@@ -0,0 +1,21 @@
/**
* Strict XML syntax check, used in Node and in the web app's browser code.
* saxes reports syntax errors the way draw.io's DOMParser will, including
* an attribute prefix such as xlink: that was never declared.
*/
import { SaxesParser } from "saxes"
/**
* Returns the first XML syntax error as "line:column: message", or null if
* the XML is well-formed. Surrounding whitespace is ignored because every
* caller trims before the XML reaches the browser.
*/
export function getXmlSyntaxError(xml: string): string | null {
let error: string | null = null
const parser = new SaxesParser({ xmlns: true })
parser.on("error", (err) => {
error ??= err.message
})
parser.write(xml.trim()).close()
return error
}
+23 -9
View File
@@ -3,7 +3,7 @@
* Copied from lib/utils.ts to avoid cross-package imports
*/
import { getXmlSyntaxError } from "./dom.js"
import { getXmlSyntaxError } from "./xml-syntax.ts"
// ============================================================================
// Constants
@@ -216,15 +216,25 @@ function checkDuplicateIds(xml: string): string | null {
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
}
// 2) Within each page, mxCell ids must be unique.
// 2) Within each page, cell ids must be unique. A cell with
// a link or custom data is a UserObject/object holding the
// id, around an mxCell whose own id does not count.
for (let i = 0; i < diagrams.length; i++) {
const diagram = diagrams[i]
const pageId = diagram.getAttribute("id") || `(index ${i})`
const cells = diagram.querySelectorAll("mxCell")
const cells = diagram.querySelectorAll(
"mxCell, UserObject, object",
)
const cellIds = new Map<string, number>()
cells.forEach((c) => {
const wrapped =
c.tagName === "mxCell" &&
/^(UserObject|object)$/.test(
c.parentElement?.tagName ?? "",
)
const id = c.getAttribute("id")
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
if (id && !wrapped)
cellIds.set(id, (cellIds.get(id) || 0) + 1)
})
const dups = Array.from(cellIds.entries())
.filter(([, c]) => c > 1)
@@ -1040,19 +1050,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// ============================================================================
/**
* Validates XML and attempts to fix if invalid. Runs the strict checks
* (unknown elements, orphan mxPoints): every caller passes XML the model wrote.
* Validates XML and attempts to fix if invalid. By default runs the strict
* checks (unknown elements, orphan mxPoints), meant for XML the model wrote.
* Pass strict: false for a diagram that also holds the user's own content.
* @param xml - The XML string to validate and potentially fix
* @returns Object with validation result, fixed XML if applicable, and fixes applied
*/
export function validateAndFixXml(xml: string): {
export function validateAndFixXml(
xml: string,
{ strict = true }: { strict?: boolean } = {},
): {
valid: boolean
error: string | null
fixed: string | null
fixes: string[]
} {
// First validation attempt
let error = validateMxCellStructure(xml, { strict: true })
let error = validateMxCellStructure(xml, { strict })
if (!error) {
return { valid: true, error: null, fixed: null, fixes: [] }
@@ -1062,7 +1076,7 @@ export function validateAndFixXml(xml: string): {
const { fixed, fixes } = autoFixXml(xml)
// Validate the fixed version
error = validateMxCellStructure(fixed, { strict: true })
error = validateMxCellStructure(fixed, { strict })
if (!error) {
return { valid: true, error: null, fixed, fixes }