diff --git a/package-lock.json b/package-lock.json index 01260003..e2106634 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", + "saxes": "^6.0.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", @@ -21478,7 +21479,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -25261,7 +25261,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/y18n": { diff --git a/package.json b/package.json index c131f12e..205055ab 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", + "saxes": "^6.0.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index c8ffaebd..27383f19 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -12,6 +12,7 @@ "@modelcontextprotocol/sdk": "^1.31.0", "linkedom": "^0.18.0", "open": "^11.0.0", + "pako": "^2.2.0", "saxes": "^6.0.0", "zod": "^4.0.0" }, @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "@types/pako": "^2.0.4", "tsx": "^4.19.0", "typescript": "^5", "vitest": "^4.1.8" @@ -909,6 +911,13 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -2601,6 +2610,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index adca08b1..b269a7f3 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -42,11 +42,13 @@ "@modelcontextprotocol/sdk": "^1.31.0", "linkedom": "^0.18.0", "open": "^11.0.0", + "pako": "^2.2.0", "saxes": "^6.0.0", "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^24.0.0", + "@types/pako": "^2.0.4", "tsx": "^4.19.0", "typescript": "^5", "vitest": "^4.1.8" diff --git a/packages/mcp-server/src/diagram-operations.ts b/packages/mcp-server/src/diagram-operations.ts index cc963a3c..a8870e82 100644 --- a/packages/mcp-server/src/diagram-operations.ts +++ b/packages/mcp-server/src/diagram-operations.ts @@ -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" diff --git a/packages/mcp-server/src/dom.ts b/packages/mcp-server/src/dom.ts index fd265c55..c777c03d 100644 --- a/packages/mcp-server/src/dom.ts +++ b/packages/mcp-server/src/dom.ts @@ -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 ), 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 ; 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 = { "&": "&", diff --git a/packages/mcp-server/src/edit-diagram.ts b/packages/mcp-server/src/edit-diagram.ts index f747f5a6..ab51d31a 100644 --- a/packages/mcp-server/src/edit-diagram.ts +++ b/packages/mcp-server/src/edit-diagram.ts @@ -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[] } diff --git a/packages/mcp-server/src/edit-gate.ts b/packages/mcp-server/src/edit-gate.ts index b3b75ee9..9ce1a9c2 100644 --- a/packages/mcp-server/src/edit-gate.ts +++ b/packages/mcp-server/src/edit-gate.ts @@ -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 } diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 22b89544..e317bf8c 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -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 diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 0e96f103..6acd200e 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -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 = diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 6f332cd1..9fd7253a 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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() diff --git a/packages/mcp-server/src/load-diagram.ts b/packages/mcp-server/src/load-diagram.ts index ad704f15..c1171dc2 100644 --- a/packages/mcp-server/src/load-diagram.ts +++ b/packages/mcp-server/src/load-diagram.ts @@ -7,16 +7,15 @@ * diagram's text content. The rest of the server assumes plain XML inside * every , 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 { diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts index 400372c7..22db4ab3 100644 --- a/packages/mcp-server/src/logger.ts +++ b/packages/mcp-server/src/logger.ts @@ -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) } }, diff --git a/packages/mcp-server/src/pages.ts b/packages/mcp-server/src/pages.ts index 5cdfaabe..0a70d977 100644 --- a/packages/mcp-server/src/pages.ts +++ b/packages/mcp-server/src/pages.ts @@ -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 root? */ export function isMxFile(xml: string): boolean { return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) @@ -87,15 +90,24 @@ const ROOT_CELLS = '' * Turn a list of bare cells (optionally inside ) into a one-page * , 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. , 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. , 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 (!/^(?:|[^<])*$/.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 (, ) let end = -1 for (const close of ["/>", "", "", ""]) { diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts index 947fbb8f..edff059e 100644 --- a/packages/mcp-server/src/persistence.ts +++ b/packages/mcp-server/src/persistence.ts @@ -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, diff --git a/packages/mcp-server/src/xml-syntax.ts b/packages/mcp-server/src/xml-syntax.ts new file mode 100644 index 00000000..7d43985f --- /dev/null +++ b/packages/mcp-server/src/xml-syntax.ts @@ -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 +} diff --git a/packages/mcp-server/src/xml-validation.ts b/packages/mcp-server/src/xml-validation.ts index 0d4ffbdf..fddba4c8 100644 --- a/packages/mcp-server/src/xml-validation.ts +++ b/packages/mcp-server/src/xml-validation.ts @@ -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 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() 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 } diff --git a/packages/mcp-server/tests/diagram-operations.test.ts b/packages/mcp-server/tests/diagram-operations.test.ts index 7c0801bc..90a8563b 100644 --- a/packages/mcp-server/tests/diagram-operations.test.ts +++ b/packages/mcp-server/tests/diagram-operations.test.ts @@ -6,13 +6,13 @@ import { deflateRawSync } from "node:zlib" import { beforeAll, describe, expect, it, vi } from "vitest" -import { installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" beforeAll(() => { installDomPolyfill() }) -import { applyDiagramOperations } from "../src/diagram-operations.js" +import { applyDiagramOperations } from "../src/diagram-operations.ts" const DOC = `` diff --git a/packages/mcp-server/tests/edit-diagram.test.ts b/packages/mcp-server/tests/edit-diagram.test.ts index ad83cf30..1e3a35b7 100644 --- a/packages/mcp-server/tests/edit-diagram.test.ts +++ b/packages/mcp-server/tests/edit-diagram.test.ts @@ -3,14 +3,14 @@ */ import { beforeAll, describe, expect, it } from "vitest" -import { installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" beforeAll(() => { installDomPolyfill() }) -import { editDiagram, targetPageXml } from "../src/edit-diagram.js" -import { validateMxCellStructure } from "../src/xml-validation.js" +import { editDiagram, targetPageXml } from "../src/edit-diagram.ts" +import { validateMxCellStructure } from "../src/xml-validation.ts" const cell = (id: string, extra = "") => `` diff --git a/packages/mcp-server/tests/edit-gate.test.ts b/packages/mcp-server/tests/edit-gate.test.ts index ce9fcec5..128c63a0 100644 --- a/packages/mcp-server/tests/edit-gate.test.ts +++ b/packages/mcp-server/tests/edit-gate.test.ts @@ -10,13 +10,13 @@ */ import { beforeAll, describe, expect, it } from "vitest" -import { installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" beforeAll(() => { installDomPolyfill() }) -import { checkEditGate, contentFingerprint } from "../src/edit-gate.js" +import { checkEditGate, contentFingerprint } from "../src/edit-gate.ts" const XML_A = `` diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index a52cca82..402cddc7 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -8,7 +8,7 @@ import http from "node:http" import { afterAll, beforeAll, describe, expect, it } from "vitest" -import { addHistory, getHistory } from "../src/history.js" +import { addHistory, getHistory } from "../src/history.ts" import { getState, requestExport, @@ -17,7 +17,7 @@ import { shutdown, startHttpServer, waitForSync, -} from "../src/http-server.js" +} from "../src/http-server.ts" let port = 0 diff --git a/packages/mcp-server/tests/load-diagram.test.ts b/packages/mcp-server/tests/load-diagram.test.ts index 7d23810d..b2b8cbbe 100644 --- a/packages/mcp-server/tests/load-diagram.test.ts +++ b/packages/mcp-server/tests/load-diagram.test.ts @@ -10,7 +10,7 @@ import { deflateRawSync } from "node:zlib" import { DOMParser } from "linkedom" import { beforeAll, describe, expect, it } from "vitest" -import { installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" // Install the DOM polyfills exactly as index.ts does at runtime. beforeAll(() => { @@ -20,7 +20,7 @@ beforeAll(() => { import { decompressPageContent, parseDrawioFileContent, -} from "../src/load-diagram.js" +} from "../src/load-diagram.ts" const MODEL_XML = `` diff --git a/packages/mcp-server/tests/multi-page.test.ts b/packages/mcp-server/tests/multi-page.test.ts index 0819c64b..5e9011ed 100644 --- a/packages/mcp-server/tests/multi-page.test.ts +++ b/packages/mcp-server/tests/multi-page.test.ts @@ -16,7 +16,7 @@ */ import { beforeAll, describe, expect, it } from "vitest" -import { installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" // Install the DOM polyfill exactly as index.ts does at runtime — the // helpers under test rely on it. @@ -24,7 +24,7 @@ beforeAll(() => { installDomPolyfill() }) -import { applyDiagramOperations } from "../src/diagram-operations.js" +import { applyDiagramOperations } from "../src/diagram-operations.ts" import { addPageToDoc, deletePageFromDoc, @@ -39,8 +39,8 @@ import { projectPage, renamePageInDoc, serializeMxfile, -} from "../src/pages.js" -import { validateAndFixXml } from "../src/xml-validation.js" +} from "../src/pages.ts" +import { validateAndFixXml } from "../src/xml-validation.ts" const BARE_MODEL_ONE_CELL = `` @@ -296,6 +296,26 @@ describe("xml-validation.ts — multi-page support", () => { expect(result.error).toMatch(/duplicate cell ID/i) }) + it("counts the id of a UserObject/object, not of the mxCell it wraps", () => { + const page = (cells: string) => + `${cells}` + const wrapped = (id: string) => + `` + expect(validateAndFixXml(page(wrapped("u"))).valid).toBe(true) + const clash = validateAndFixXml( + page(`${wrapped("2")}`), + ) + expect(clash.valid).toBe(false) + expect(clash.error).toMatch(/duplicate cell ID/i) + }) + + it("can skip the strict checks for diagrams with user content", () => { + const custom = `` + expect(validateAndFixXml(custom).valid).toBe(true) // fixed by removal + const lenient = validateAndFixXml(custom, { strict: false }) + expect(lenient).toMatchObject({ valid: true, fixed: null }) + }) + it("rejects duplicate ids across the file", () => { const bad = `` const result = validateAndFixXml(bad) diff --git a/packages/mcp-server/tests/persistence.test.ts b/packages/mcp-server/tests/persistence.test.ts index 9c37d0f9..41ca4a17 100644 --- a/packages/mcp-server/tests/persistence.test.ts +++ b/packages/mcp-server/tests/persistence.test.ts @@ -13,7 +13,7 @@ import { import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, it } from "vitest" -import { Autosaver, defaultDataDir } from "../src/persistence.js" +import { Autosaver, defaultDataDir } from "../src/persistence.ts" const DIAGRAM = `` const BLANK = `` diff --git a/packages/mcp-server/tests/wrap-cells.test.ts b/packages/mcp-server/tests/wrap-cells.test.ts index 73024010..6e22545f 100644 --- a/packages/mcp-server/tests/wrap-cells.test.ts +++ b/packages/mcp-server/tests/wrap-cells.test.ts @@ -4,14 +4,14 @@ */ import { beforeAll, describe, expect, it } from "vitest" -import { installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" beforeAll(() => { installDomPolyfill() }) -import { wrapCellsInModel } from "../src/pages.js" -import { validateAndFixXml } from "../src/xml-validation.js" +import { wrapCellsInModel } from "../src/pages.ts" +import { validateAndFixXml } from "../src/xml-validation.ts" const A = `` const B = `` @@ -45,6 +45,12 @@ describe("wrapCellsInModel", () => { ) }) + it("drops comments and text before the first cell", () => { + const expected = `${ROOTS}${A}` + expect(wrapCellsInModel(`Here is the diagram: ${A}`)).toBe(expected) + expect(wrapCellsInModel(`\n${A}`)).toBe(expected) + }) + it("leaves and input unchanged", () => { const model = `${ROOTS}${A}` const file = `${model}` diff --git a/packages/mcp-server/tests/xml-validation.test.ts b/packages/mcp-server/tests/xml-validation.test.ts index f42b9143..af95f21b 100644 --- a/packages/mcp-server/tests/xml-validation.test.ts +++ b/packages/mcp-server/tests/xml-validation.test.ts @@ -8,14 +8,15 @@ */ import { beforeAll, describe, expect, it } from "vitest" -import { getXmlSyntaxError, installDomPolyfill } from "../src/dom.js" +import { installDomPolyfill } from "../src/dom.ts" +import { getXmlSyntaxError } from "../src/xml-syntax.ts" beforeAll(() => { installDomPolyfill() }) -import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.js" -import { validateAndFixXml } from "../src/xml-validation.js" +import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts" +import { validateAndFixXml } from "../src/xml-validation.ts" /** Bare model with the root cells plus the given cells. */ const model = (cells: string) => @@ -26,6 +27,18 @@ const model = (cells: string) => const BROKEN_CELL = `` describe("getXmlSyntaxError", () => { + it("rejects an attribute prefix that was never declared", () => { + // The browser's DOMParser, and so draw.io, rejects it too + expect(getXmlSyntaxError(``)).toMatch( + /prefix/, + ) + expect( + getXmlSyntaxError( + ``, + ), + ).toBeNull() + }) + it("accepts well-formed XML", () => { expect(getXmlSyntaxError(model(""))).toBeNull() }) diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json index d3d60a3c..da1ec74c 100644 --- a/packages/mcp-server/tsconfig.json +++ b/packages/mcp-server/tsconfig.json @@ -13,6 +13,7 @@ "declarationMap": true, "sourceMap": true, "resolveJsonModule": true, + "rewriteRelativeImportExtensions": true, "types": ["node"] }, "include": ["src/**/*"], diff --git a/tests/unit/mcp-core.test.ts b/tests/unit/mcp-core.test.ts new file mode 100644 index 00000000..72f3a2f2 --- /dev/null +++ b/tests/unit/mcp-core.test.ts @@ -0,0 +1,90 @@ +/** + * The web app runs the MCP server's diagram modules in the browser, where + * DOMParser and XMLSerializer are the native ones (jsdom here), not the + * linkedom polyfill the MCP tests use. + */ +import { deflateRaw } from "pako" +import { describe, expect, it } from "vitest" +import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts" +import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts" +import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts" +import { + hasCells, + normalizeToMxfile, + wrapCellsInModel, +} from "@/packages/mcp-server/src/pages.ts" +import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts" +import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts" + +const box = (id: string, parent = "1") => + `` +const edge = (id: string, source: string, target: string) => + `` +const file = (cells: string) => + normalizeToMxfile(wrapCellsInModel(cells), { + pageId: "p1", + pageName: "Page-1", + }) as string + +describe("MCP diagram modules with a browser DOM", () => { + it("wraps bare cells into a valid file", () => { + const xml = file(box("a") + box("b")) + expect(xml).toContain('') + expect(validateAndFixXml(xml).valid).toBe(true) + expect(hasCells(xml)).toBe(true) + expect(hasCells(file(""))).toBe(false) + }) + + it("fixes the case of a misspelled tag in model XML", () => { + const result = validateAndFixXml( + file(box("a")) + .replace('", ""), + ) + expect(result.valid).toBe(true) + expect(result.fixes.join(" ")).toMatch(/tag case/) + }) + + it("reports syntax errors with line and column", () => { + expect(getXmlSyntaxError("")).toMatch( + /^1:\d+/, + ) + expect(getXmlSyntaxError(file(box("a")))).toBeNull() + }) + + it("deletes a cell with its edges", () => { + const xml = file(box("a") + box("b") + edge("e", "a", "b")) + const { result, errors } = applyDiagramOperations(xml, [ + { operation: "delete", cell_id: "a" }, + ]) + expect(errors).toEqual([]) + expect(result).not.toContain('id="a"') + expect(result).not.toContain('id="e"') + expect(result).toContain('id="b"') + }) + + it("runs a whole edit and serializes the target page", () => { + const outcome = editDiagram( + file(box("a")), + [{ operation: "add", cell_id: "b", new_xml: box("b") }], + {}, + ) + expect(outcome.ok).toBe(true) + if (outcome.ok) expect(outcome.xml).toContain('id="b"') + + const failed = editDiagram( + file(box("a")), + [{ operation: "add", cell_id: "b", new_xml: box("b") + box("c") }], + {}, + ) + expect(failed.ok).toBe(false) + }) + + it("decompresses a draw.io compressed page", () => { + const model = wrapCellsInModel(box("a")) + const deflated = deflateRaw(encodeURIComponent(model)) + const base64 = btoa(String.fromCharCode(...deflated)) + expect(decompressPageContent(base64)).toBe(model) + expect(decompressPageContent("not compressed")).toBeNull() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e5f90e41..87a85f21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx",