diff --git a/packages/mcp/package.json b/packages/mcp/package.json index b19b5eca..9418bfb3 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -52,6 +52,7 @@ "form-data": "^4.0.0", "jsdom": "^27.4.0", "marked": "^17.0.1", + "pako": "^2.0.3", "re2": "^1.21.0", "ws": "^8.19.0", "y-prosemirror": "1.3.7", diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 7e3f017d..8c421a21 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -45,6 +45,15 @@ import { } from "./lib/node-ops.js"; import { searchInDoc, SearchOptions } from "./lib/page-search.js"; import { withPageLock } from "./lib/page-lock.js"; +import { + prepareModel, + decodeDrawioSvg, + buildDrawioSvg, + mxHash, + normalizeXml, + countUserCells, +} from "./lib/drawio-xml.js"; +import { renderDiagramShapes } from "./lib/drawio-preview.js"; import { applyTextEdits, TextEdit, @@ -3384,6 +3393,453 @@ export class DocmostClient { }); } + // --- draw.io diagrams (issue #423) --- + + /** + * Upload a ready-made byte buffer as a page attachment via the same + * multipart /files/upload endpoint uploadImage uses. Split out as its own + * (overridable) seam so drawio_create/update can upload the generated + * `.drawio.svg` without going through the URL-fetch path, and so tests can + * stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403 + * re-auth handling (a FormData body is single-use, so it must be rebuilt per + * attempt). + */ + protected async uploadAttachmentBuffer( + pageId: string, + buffer: Buffer, + fileName: string, + mime: string, + ): Promise<{ id: string; fileName: string; fileSize: number }> { + await this.ensureAuthenticated(); + const buildForm = () => { + const form = new FormData(); + form.append("pageId", pageId); + form.append("file", buffer, { filename: fileName, contentType: mime }); + return form; + }; + const uploadUrl = `${this.apiUrl}/files/upload`; + let response; + try { + const form = buildForm(); + response = await axios.post(uploadUrl, form, { + headers: { + ...form.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } catch (error) { + if ( + axios.isAxiosError(error) && + (error.response?.status === 401 || error.response?.status === 403) + ) { + await this.login(); + const form2 = buildForm(); + response = await axios.post(uploadUrl, form2, { + headers: { + ...form2.getHeaders(), + Authorization: this.client.defaults.headers.common["Authorization"], + }, + timeout: 60000, + }); + } else if (axios.isAxiosError(error)) { + if (process.env.DEBUG) { + console.error( + "Attachment upload failed; response body:", + JSON.stringify(error.response?.data), + ); + } + throw new Error( + `Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`, + ); + } else { + throw error; + } + } + const att = response.data?.data ?? response.data; + if (!att?.id || !att?.fileName) { + throw new Error( + "Unexpected /files/upload response: " + JSON.stringify(response.data), + ); + } + return { + id: att.id, + fileName: att.fileName, + fileSize: att.fileSize ?? buffer.length, + }; + } + + /** + * Fetch a stored `.drawio.svg` attachment as text. Overridable seam over + * fetchInternalFile (the authed loopback fetch, which also rejects any + * traversal/SSRF src) so drawio_get/update can read the current diagram and + * tests can stub the bytes. + */ + protected async fetchAttachmentText(src: string): Promise { + const { buffer } = await this.fetchInternalFile(src); + return buffer.toString("utf-8"); + } + + /** + * Resolve a drawio node on a page by `attrs.id` or `#` and return the + * node plus its ref. Throws a clear error if the ref does not resolve to a + * drawio node. + */ + private async resolveDrawioNode( + pageId: string, + node: string, + ): Promise<{ node: any; ref: string }> { + const data = await this.getPageRaw(pageId); + const hit = getNodeByRef( + data.content ?? { type: "doc", content: [] }, + node, + ); + if (!hit) { + throw new Error( + `drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#" from get_outline)`, + ); + } + if (hit.type !== "drawio") { + throw new Error( + `drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`, + ); + } + return { node: hit.node, ref: node }; + } + + /** + * Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`. + * Runs the decode chain (base64/entity content= → drawio file → nested XML or + * pako-inflated compressed ). The returned `hash` is the + * optimistic-lock key for drawio_update. + */ + async drawioGet( + pageId: string, + node: string, + format: "xml" | "svg" = "xml", + ): Promise<{ + pageId: string; + nodeId: string; + format: "xml" | "svg"; + content: string; + meta: { + attachmentId: string | null; + title: string | null; + width: number | null; + height: number | null; + cellCount: number; + hash: string; + }; + }> { + await this.ensureAuthenticated(); + const { node: drawio } = await this.resolveDrawioNode(pageId, node); + const attrs = drawio.attrs || {}; + const src = attrs.src; + if (!src) { + throw new Error( + `drawio: node "${node}" on page ${pageId} has no src to read`, + ); + } + const svg = await this.fetchAttachmentText(src); + const modelXml = decodeDrawioSvg(svg); + const meta = { + attachmentId: attrs.attachmentId ?? null, + title: attrs.title ?? null, + width: attrs.width != null ? Number(attrs.width) : null, + height: attrs.height != null ? Number(attrs.height) : null, + cellCount: countUserCells(modelXml), + hash: mxHash(modelXml), + }; + return { + pageId, + nodeId: attrs.id ?? node, + format, + content: format === "svg" ? svg : normalizeXml(modelXml), + meta, + }; + } + + /** + * Create a drawio diagram from mxGraph XML: lint → schematic SVG preview + * (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the + * attachment → insert a `drawio` node before/after an anchor or appended. + * `xml` is a bare `` or a list of `` (the server wraps + * it and adds the id=0/id=1 sentinels). + */ + async drawioCreate( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + xml: string, + title?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + if ( + !where || + (where.position !== "before" && + where.position !== "after" && + where.position !== "append") + ) { + throw new Error( + 'drawio_create: `where.position` must be one of "before", "after", "append"', + ); + } + if (where.position === "before" || where.position === "after") { + const hasId = + typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0; + const hasText = + typeof where.anchorText === "string" && where.anchorText.length > 0; + if (hasId === hasText) { + throw new Error( + `drawio_create: position "${where.position}" requires exactly one of anchorNodeId or anchorText`, + ); + } + } + + // Pre-write pipeline (throws a structured DrawioLintError on any violation). + const prepared = prepareModel(xml); + const inner = renderDiagramShapes(prepared.cells, prepared.bbox); + const diagramTitle = title || "Page-1"; + const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); + + const att = await this.uploadAttachmentBuffer( + pageId, + Buffer.from(svg, "utf-8"), + "diagram.drawio.svg", + "image/svg+xml", + ); + + // NOTE: no `id` attribute is set here. The vendored `drawio` node schema + // (diagramAttributes) declares no `id`, so any block id would be silently + // dropped by PMNode.fromJSON on save and the returned handle would fail to + // resolve. The addressable handle is the node's "#" (like image/table + // nodes), computed after the insert below. + const drawioNode: any = { + type: "drawio", + attrs: { + src: `/api/files/${att.id}/${att.fileName}`, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + align: "center", + }, + }; + if (title) drawioNode.attrs.title = title; + // Reuse the existing URL trust boundary (rejects unsafe src schemes). + this.validateDocUrls(drawioNode); + + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + + let inserted = false; + let insertedIndex = -1; + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + inserted = false; + insertedIndex = -1; + const { doc: nd, inserted: ins } = insertNodeRelative( + liveDoc, + drawioNode, + where, + ); + inserted = ins; + if (!inserted) return null; // anchor not found -> skip the write + // Locate the freshly-inserted node to derive its "#" handle. The + // just-uploaded attachmentId is unique, so it identifies our node. + if (Array.isArray(nd.content)) { + insertedIndex = nd.content.findIndex( + (b: any) => + b && + b.type === "drawio" && + b.attrs && + b.attrs.attachmentId === att.id, + ); + } + return nd; + }, + ); + + if (!inserted) { + const anchorDesc = where.anchorNodeId + ? `anchorNodeId "${where.anchorNodeId}"` + : `anchorText "${where.anchorText}"`; + throw new Error( + `drawio_create: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`, + ); + } + + if (insertedIndex < 0) { + // The node was inserted nested (e.g. inside a callout/table cell via an + // anchor), where "#" — which addresses only top-level blocks — + // cannot reference it. drawio nodes carry no persisted id, so there is no + // stable handle for a nested diagram. + throw new Error( + `drawio_create: the diagram was inserted on page ${pageId} but not as a ` + + `top-level block, so it has no addressable "#" handle. Anchor ` + + `on a top-level block (or append) so the diagram can be re-read.`, + ); + } + + // The returned handle is POSITIONAL ("#"): valid for the immediate + // create -> get/update flow, but re-resolve via get_outline if the document + // structure changes (blocks added/removed before it shift the index). + const nodeId = `#${insertedIndex}`; + + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: prepared.warnings, + verify: mutation.verify, + }; + } + + /** + * Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is + * compared against the hash of the diagram's CURRENT XML (from drawio_get); + * any mismatch means a human or another agent edited the diagram after the + * read, so the write is refused with a conflict error. On success the new + * `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is + * avoided — some Docmost versions corrupt an attachment on overwrite, exactly + * as replaceImage documents) and the node is repointed with new dimensions. + */ + async drawioUpdate( + pageId: string, + node: string, + xml: string, + baseHash: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + if (typeof baseHash !== "string" || baseHash.length === 0) { + throw new Error( + "drawio_update: baseHash is mandatory — read the diagram with drawio_get first and pass back its meta.hash", + ); + } + + // Resolve the node and read the CURRENT diagram to enforce the optimistic + // lock before doing any write or upload. + const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); + const oldAttrs = drawio.attrs || {}; + const oldSrc = oldAttrs.src; + // The returned handle is the caller-supplied reference. drawio nodes carry + // no persisted id, so `ref` (an "#" or a rare legacy attrs.id) is the + // honest identifier to hand back. + const nodeId = oldAttrs.id ?? ref; + if (!oldSrc) { + throw new Error( + `drawio_update: node "${node}" on page ${pageId} has no src to compare against`, + ); + } + const currentSvg = await this.fetchAttachmentText(oldSrc); + const currentHash = mxHash(decodeDrawioSvg(currentSvg)); + if (currentHash !== baseHash) { + throw new Error( + `drawio_update: conflict — the diagram changed since it was read ` + + `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawio_get and retry.`, + ); + } + + // Pipeline for the new content (throws a structured DrawioLintError). + const prepared = prepareModel(xml); + const inner = renderDiagramShapes(prepared.cells, prepared.bbox); + const diagramTitle = oldAttrs.title || "Page-1"; + const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); + + const att = await this.uploadAttachmentBuffer( + pageId, + Buffer.from(svg, "utf-8"), + "diagram.drawio.svg", + "image/svg+xml", + ); + const newSrc = `/api/files/${att.id}/${att.fileName}`; + + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + + let repointed = 0; + const repoint = (n: any) => { + n.attrs = { + ...n.attrs, + src: newSrc, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + }; + repointed++; + }; + + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + repointed = 0; + const doc = + liveDoc && liveDoc.type === "doc" + ? liveDoc + : { type: "doc", content: [] }; + if (!Array.isArray(doc.content)) doc.content = []; + // Repoint ONLY the resolved node — never every node that happens to + // share this attachmentId (a copied diagram is two nodes with one + // attachmentId; keying on it would clobber both). Re-resolve the same + // handle against the live doc and walk to its exact position. + const hit = getNodeByRef(doc, ref); + if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip + let target: any = doc; + for (const idx of hit.path) { + if (!target || !Array.isArray(target.content)) { + target = null; + break; + } + target = target.content[idx]; + } + if (!target || target.type !== "drawio") return null; + repoint(target); + if (repointed === 0) return null; // node vanished concurrently -> skip + return doc; + }, + ); + + if (repointed === 0) { + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: [ + ...prepared.warnings, + "target drawio node was removed concurrently; uploaded attachment is unreferenced", + ], + verify: mutation.verify, + }; + } + + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: prepared.warnings, + verify: mutation.verify, + }; + } + // --- Page history / diff / transform --- /** diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 17cabe18..a2581c49 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -52,7 +52,7 @@ const VERSION = packageJson.version; export const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent.\n" + "READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" + - "EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" + + "EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" + "PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" + "HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown."; @@ -465,6 +465,38 @@ registerShared( }, ); +// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG). +registerShared( + SHARED_TOOL_SPECS.drawioGet, + async ({ pageId, node, format }) => { + const result = await docmostClient.drawioGet(pageId, node, format ?? "xml"); + return jsonContent(result); + }, +); + +// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node. +registerShared( + SHARED_TOOL_SPECS.drawioCreate, + async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => { + const result = await docmostClient.drawioCreate( + pageId, + { position, anchorNodeId, anchorText }, + xml, + title, + ); + return jsonContent(result); + }, +); + +// Tool: drawio_update — optimistic-locked full replacement of a diagram. +registerShared( + SHARED_TOOL_SPECS.drawioUpdate, + async ({ pageId, node, xml, baseHash }) => { + const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash); + return jsonContent(result); + }, +); + // Tool: share_page // Schema + description now live in the shared registry (#294). The execute body // keeps this transport's own `searchIndexing ?? true` default. diff --git a/packages/mcp/src/lib/drawio-preview.ts b/packages/mcp/src/lib/drawio-preview.ts new file mode 100644 index 00000000..eca58df7 --- /dev/null +++ b/packages/mcp/src/lib/drawio-preview.ts @@ -0,0 +1,193 @@ +// Pure-TS schematic SVG preview for draw.io diagrams (issue #423, stage 1). +// +// HARD CONSTRAINT: no backend rendering. This is a dependency-free string +// builder — given the parsed mxGraph cells it draws a rough schematic (rects, +// ellipses, diamonds, edges + labels) that stands in as the diagram's visible +// image UNTIL a human first opens it in the draw.io editor and saves, at which +// point the client replaces this with the pixel-perfect export SVG. It is +// deliberately approximate: it exists so a freshly-agent-created diagram is not +// an empty box in the page. + +import type { DrawioCell, DrawioBBox } from "./drawio-xml.js"; +import { absolutePos } from "./drawio-xml.js"; + +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Strip HTML markup from a cell value (draw.io labels are HTML when html=1), + * decode the handful of entities we care about, and collapse whitespace so the + * label fits on the schematic. `
` becomes a space (this is a one-line + * preview label, not a faithful multi-line render). + */ +function labelText(value: string): string { + return value + .replace(//gi, " ") + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/ | /gi, " ") + .replace(/&/g, "&") + .replace(/\s+/g, " ") + .trim(); +} + +function centeredLabel(cx: number, cy: number, value: string, color = "#000000"): string { + const text = labelText(value); + if (!text) return ""; + return ( + `` + + `${esc(text)}` + ); +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +interface ShapeKind { + kind: "ellipse" | "rhombus" | "triangle" | "rect"; +} + +/** + * Decide which schematic primitive to draw for a vertex. A shape can be named + * either as the style's base token (e.g. "ellipse;…") or as a key (e.g. + * "shape=rhombus" / "ellipse=1"), so both the base style and the map are + * checked. + */ +function shapeKind( + styleMap: Record, + baseStyle?: string, +): ShapeKind { + const shape = styleMap.shape ?? baseStyle; + const has = (name: string) => shape === name || styleMap[name] != null; + if (has("ellipse")) return { kind: "ellipse" }; + if (has("rhombus")) return { kind: "rhombus" }; + if (has("triangle")) return { kind: "triangle" }; + // Everything else — including unknown stencils (shape=mxgraph.*), swimlanes, + // and plain boxes — is drawn as a (rounded) rectangle. + return { kind: "rect" }; +} + +function fill(styleMap: Record): string { + const c = styleMap.fillColor; + if (!c || c.toLowerCase() === "none") return "#ffffff"; + return c; +} + +function stroke(styleMap: Record): string { + const c = styleMap.strokeColor; + if (!c || c.toLowerCase() === "none") return "#000000"; + return c; +} + +/** + * Render the schematic shapes as the INNER content of the `.drawio.svg` (the + * outer wrapper is added by drawio-xml.buildDrawioSvg). Coordinates are + * absolute (container children are resolved via the parent chain). + */ +export function renderDiagramShapes(cells: DrawioCell[], _bbox: DrawioBBox): string { + const byId = new Map(cells.map((c) => [c.id, c])); + const parts: string[] = []; + + // Edges first so vertices sit on top of their connectors. + for (const c of cells) { + if (!c.edge) continue; + parts.push(renderEdge(c, byId)); + } + + for (const c of cells) { + if (!c.vertex || !c.geometry.hasGeometry) continue; + const g = c.geometry; + if (g.width == null || g.height == null) continue; + const { x, y } = absolutePos(c, byId); + parts.push(renderVertex(c, x, y, g.width, g.height)); + } + + return `${parts.filter(Boolean).join("")}`; +} + +function renderVertex( + c: DrawioCell, + x: number, + y: number, + w: number, + h: number, +): string { + const f = esc(fill(c.styleMap)); + const s = esc(stroke(c.styleMap)); + const { kind } = shapeKind(c.styleMap, c.baseStyle); + const cx = x + w / 2; + const cy = y + h / 2; + let shape = ""; + switch (kind) { + case "ellipse": + shape = + ``; + break; + case "rhombus": { + const pts = [ + `${round(cx)},${round(y)}`, + `${round(x + w)},${round(cy)}`, + `${round(cx)},${round(y + h)}`, + `${round(x)},${round(cy)}`, + ].join(" "); + shape = ``; + break; + } + case "triangle": { + const pts = [ + `${round(x)},${round(y)}`, + `${round(x + w)},${round(cy)}`, + `${round(x)},${round(y + h)}`, + ].join(" "); + shape = ``; + break; + } + default: { + const rounded = c.styleMap.rounded === "1"; + const rx = rounded ? Math.min(12, w / 2, h / 2) : 0; + shape = + ``; + } + } + return shape + centeredLabel(cx, cy, c.value, c.styleMap.fontColor || "#000000"); +} + +function renderEdge(c: DrawioCell, byId: Map): string { + const src = c.source != null ? byId.get(c.source) : undefined; + const tgt = c.target != null ? byId.get(c.target) : undefined; + const p1 = anchorPoint(src, byId); + const p2 = anchorPoint(tgt, byId); + if (!p1 || !p2) return ""; // a floating endpoint with no fixed point: skip + const line = + ``; + const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + return line + centeredLabel(mid.x, mid.y, c.value); +} + +/** Center point of a vertex used as an edge anchor (approximate). */ +function anchorPoint( + cell: DrawioCell | undefined, + byId: Map, +): { x: number; y: number } | null { + if (!cell || !cell.geometry.hasGeometry) return null; + const g = cell.geometry; + if (g.width == null || g.height == null) return null; + const { x, y } = absolutePos(cell, byId); + return { x: x + g.width / 2, y: y + g.height / 2 }; +} diff --git a/packages/mcp/src/lib/drawio-xml.ts b/packages/mcp/src/lib/drawio-xml.ts new file mode 100644 index 00000000..56f1b840 --- /dev/null +++ b/packages/mcp/src/lib/drawio-xml.ts @@ -0,0 +1,771 @@ +// draw.io (mxGraph) XML support for the MCP drawio tools (issue #423, stage 1). +// +// This module owns everything that is pure data-plumbing for draw.io diagrams: +// - the DECODE CHAIN that turns a stored `diagram.drawio.svg` attachment back +// into mxGraph XML (handles both the plain nested-XML form Docmost writes +// and draw.io's own COMPRESSED `` payload — base64 + raw-deflate); +// - the ENCODE side that wraps mxGraph XML into the `.drawio.svg` attachment +// using the exact same contract as the import service's createDrawioSvg; +// - a deterministic LINTER that rejects the structural mistakes generators +// make before anything is written (each violation carries the offending +// cellId + position so the model can auto-retry); +// - a stable HASH over the normalized XML, used as the optimistic-lock key. +// +// HARD CONSTRAINT: no backend rendering. Nothing here shells out or renders a +// bitmap; the only runtime dependencies are jsdom (already used across this +// package for XML parsing) and pako (raw-inflate for the compressed format). + +import { createHash } from "node:crypto"; +import { JSDOM } from "jsdom"; +import pako from "pako"; + +// --- shared XML parser ----------------------------------------------------- + +// A single reusable JSDOM window; constructing one per parse is wasteful and +// these tools are low-frequency. Only the DOMParser is used. +let _window: any = null; +function xmlWindow(): any { + if (!_window) _window = new JSDOM("").window; + return _window; +} + +/** Default mxGraphModel attributes used when the server wraps a cell list. */ +const DEFAULT_MODEL_ATTRS = + 'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100"'; + +// --- structured lint errors ------------------------------------------------ + +export interface DrawioLintIssue { + /** Machine-readable rule id, e.g. "edge-geometry". */ + rule: string; + /** Human-readable explanation the model can act on. */ + message: string; + /** The offending cell's id, when the rule is cell-scoped. */ + cellId?: string; + /** Extra location info: cell index in , or a parser line:col. */ + position?: string; +} + +/** + * Thrown by the linter and by decode/prepare when the input is unusable. Carries + * the full list of issues so the caller can surface a structured tool-error the + * model auto-retries against. + */ +export class DrawioLintError extends Error { + issues: DrawioLintIssue[]; + constructor(issues: DrawioLintIssue[]) { + const summary = issues + .map((i) => { + const where = [ + i.cellId != null ? `cellId=${i.cellId}` : null, + i.position != null ? `at ${i.position}` : null, + ] + .filter(Boolean) + .join(", "); + return `[${i.rule}] ${i.message}${where ? ` (${where})` : ""}`; + }) + .join("; "); + super(`drawio lint failed: ${summary}`); + this.name = "DrawioLintError"; + this.issues = issues; + } +} + +// --- parsed-cell model ----------------------------------------------------- + +export interface DrawioGeometry { + x?: number; + y?: number; + width?: number; + height?: number; + relative: boolean; + hasGeometry: boolean; +} + +export interface DrawioCell { + id: string; + parent?: string; + source?: string; + target?: string; + vertex: boolean; + edge: boolean; + value: string; + style: string; + styleMap: Record; + /** Non-key/value leading token of the style (a base stylename), if any. */ + baseStyle?: string; + geometry: DrawioGeometry; +} + +export interface DrawioBBox { + width: number; + height: number; +} + +// --- style parsing --------------------------------------------------------- + +/** + * Parse a draw.io style string into { baseStyle, map }. Grammar: + * [stylename;]key=value;key=value;... + * A single leading token without '=' is the base stylename (e.g. "text" or + * "ellipse"). Every other non-empty segment must be exactly one key=value pair. + * Returns `null` (the segment index) on the first malformed segment so the + * linter can report a precise error. + */ +export function parseStyle( + style: string, +): { baseStyle?: string; map: Record; badSegment?: string } { + const map: Record = {}; + let baseStyle: string | undefined; + const segments = style.split(";"); + for (let i = 0; i < segments.length; i++) { + const seg = segments[i].trim(); + if (seg === "") continue; // trailing/empty segments are fine + const eq = seg.indexOf("="); + if (eq === -1) { + // A bare token is only valid as the FIRST meaningful segment (base style). + if (baseStyle === undefined && Object.keys(map).length === 0) { + baseStyle = seg; + continue; + } + return { baseStyle, map, badSegment: seg }; + } + // A second '=' inside the same segment is malformed. + if (seg.indexOf("=", eq + 1) !== -1) { + return { baseStyle, map, badSegment: seg }; + } + const key = seg.slice(0, eq).trim(); + const val = seg.slice(eq + 1).trim(); + if (key === "") return { baseStyle, map, badSegment: seg }; + map[key] = val; + } + return { baseStyle, map }; +} + +// --- low-level XML helpers ------------------------------------------------- + +function parseXml(xml: string): { doc: any; error: string | null } { + const parser = new (xmlWindow().DOMParser)(); + const doc = parser.parseFromString(xml, "application/xml"); + const err = doc.getElementsByTagName("parsererror"); + if (err.length > 0) { + // jsdom prefixes the message with "line:col:" — keep it as the position. + return { doc, error: (err[0].textContent || "malformed XML").trim() }; + } + return { doc, error: null }; +} + +function num(v: string | null): number | undefined { + if (v == null || v === "") return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} + +/** Extract the raw `` substring, or null. */ +function sliceModel(xml: string): string | null { + const open = xml.indexOf("", open); + if (close === -1) { + // Self-closed empty model, e.g. ``. + const selfClose = xml.indexOf("/>", open); + if (selfClose !== -1) return xml.slice(open, selfClose + 2); + return null; + } + return xml.slice(open, close + "
".length); +} + +// --- decode chain ---------------------------------------------------------- + +/** + * Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a + * base64 payload there (createDrawioSvg); draw.io's own SVG export may store the + * XML entity-encoded instead. The DOM decodes entities for us, so the caller + * only has to distinguish "starts with '<'" (raw XML) from base64. + */ +export function extractContentAttr(svg: string): string { + const { doc, error } = parseXml(svg); + if (!error) { + const root = doc.documentElement; + if (root && root.hasAttribute && root.hasAttribute("content")) { + return root.getAttribute("content") || ""; + } + } + // Fallback for a malformed wrapper: pull the attribute directly. The content + // value itself never contains a double-quote (base64 / entity-encoded XML). + const m = /content="([^"]*)"/.exec(svg); + if (m) { + // Decode the handful of XML entities a raw regex would leave encoded. + return m[1] + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + } + throw new Error("drawio: SVG has no content= attribute to decode"); +} + +/** + * Turn a decoded draw.io file (`` or a bare ``, possibly + * with a COMPRESSED `` payload) into the mxGraphModel XML. For the + * plain form the raw substring is returned verbatim so a round-trip stays + * byte-stable; the compressed form is inflated (base64 → raw-deflate → + * decodeURIComponent), which is how draw.io stores diagrams by default. + */ +export function decodeDrawioFileToModel(fileXml: string): string { + // Plain, nested XML: return the model substring untouched (byte-stable). + const sliced = sliceModel(fileXml); + if (sliced) return sliced; + + // Otherwise it must be the compressed `` text payload. + const open = fileXml.indexOf("", open); + const close = fileXml.indexOf("", gt); + if (gt !== -1 && close !== -1) { + const payload = fileXml.slice(gt + 1, close).trim(); + if (payload) { + const inflated = inflateDiagramPayload(payload); + const model = sliceModel(inflated); + if (model) return model; + return inflated; + } + } + } + throw new Error( + "drawio: could not decode file — no and no compressed payload", + ); +} + +/** + * Upper bound on the inflated size of a compressed `` payload + * (decompression-bomb guard). `fetchInternalFile` caps the DOWNLOAD at 64 MiB, + * but a tiny crafted compressed payload can inflate to gigabytes and OOM the + * process. A real diagram's mxGraphModel XML is small (KBs to low MBs even for + * large diagrams), so 16 MiB is far above any legitimate payload while keeping + * memory bounded. Chars ~= bytes for the (mostly ASCII) URI-encoded XML. + */ +export const MAX_INFLATED_DIAGRAM_BYTES = 16 * 1024 * 1024; + +/** + * Inflate draw.io's compressed diagram payload: + * base64-decode → raw-inflate (raw deflate, windowBits -15) → + * decodeURIComponent. + * + * Uses pako's streaming Inflate so we can abort as soon as the decompressed + * output exceeds MAX_INFLATED_DIAGRAM_BYTES — the full bomb is never + * materialised in memory. + */ +export function inflateDiagramPayload(base64: string): string { + const bytes = Buffer.from(base64, "base64"); + const inflator = new pako.Inflate({ raw: true, to: "string" }); + let total = 0; + const passthrough = inflator.onData.bind(inflator); + inflator.onData = (chunk: string | Uint8Array) => { + total += chunk.length; + if (total > MAX_INFLATED_DIAGRAM_BYTES) { + // Throwing here propagates out of push(), aborting inflation immediately. + throw new Error( + `drawio: refusing to decode diagram — decompressed size exceeds ` + + `${MAX_INFLATED_DIAGRAM_BYTES} bytes (possible decompression bomb)`, + ); + } + passthrough(chunk); + }; + inflator.push(bytes, true); + if (inflator.err) { + throw new Error( + `drawio: failed to inflate compressed payload (${inflator.msg || inflator.err})`, + ); + } + const uriEncoded = inflator.result as string; + return decodeURIComponent(uriEncoded); +} + +/** Full decode chain: `.drawio.svg` string → mxGraphModel XML. */ +export function decodeDrawioSvg(svg: string): string { + const content = extractContentAttr(svg).trim(); + const fileXml = content.startsWith("<") + ? content + : Buffer.from(content, "base64").toString("utf-8"); + return decodeDrawioFileToModel(fileXml); +} + +// --- encode side ----------------------------------------------------------- + +/** + * Wrap an mxGraphModel in the plain (uncompressed) `` envelope. + * draw.io opens uncompressed XML fine, and staying uncompressed keeps the + * write path deterministic and the round-trip byte-stable. + */ +export function encodeDrawioFile(modelXml: string, title = "Page-1"): string { + const safeTitle = xmlEscape(title); + return `${modelXml}`; +} + +/** + * Build the `diagram.drawio.svg` attachment. Mirrors the import service's + * createDrawioSvg contract exactly: + * ${inner} + * plus width/height/viewBox from the diagram bounding box and the schematic + * preview as the visible children (`inner`). + */ +export function buildDrawioSvg( + modelXml: string, + inner: string, + bbox: DrawioBBox, + title = "Page-1", +): string { + const file = encodeDrawioFile(modelXml, title); + const base64 = Buffer.from(file, "utf-8").toString("base64"); + const w = Math.max(1, Math.round(bbox.width)); + const h = Math.max(1, Math.round(bbox.height)); + return ( + `${inner}` + ); +} + +function xmlEscape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// --- normalization + hash -------------------------------------------------- + +/** + * Normalize mxGraph XML for hashing / stable comparison: drop the whitespace + * between tags and trim. This is intentionally conservative — it never reorders + * attributes or cells (that would be lossy) — so two documents hash equal iff + * they differ only in inter-tag formatting. + */ +export function normalizeXml(xml: string): string { + return xml.replace(/>\s+<").trim(); +} + +/** Stable optimistic-lock hash over the normalized model XML (sha256, hex). */ +export function mxHash(modelXml: string): string { + return createHash("sha256").update(normalizeXml(modelXml), "utf-8").digest("hex"); +} + +// --- cell parsing ---------------------------------------------------------- + +/** Parse every `` in a model into a structured DrawioCell list. */ +export function parseCells(modelXml: string): DrawioCell[] { + const { doc, error } = parseXml(modelXml); + if (error) { + throw new DrawioLintError([ + { rule: "well-formed-xml", message: error, position: firstLineCol(error) }, + ]); + } + const cells: DrawioCell[] = []; + const els = doc.getElementsByTagName("mxCell"); + for (let i = 0; i < els.length; i++) { + cells.push(readCell(els[i])); + } + return cells; +} + +function readCell(el: any): DrawioCell { + const style = el.getAttribute("style") || ""; + const parsed = parseStyle(style); + const geoEl = firstChildByTag(el, "mxGeometry"); + const geometry: DrawioGeometry = geoEl + ? { + x: num(geoEl.getAttribute("x")), + y: num(geoEl.getAttribute("y")), + width: num(geoEl.getAttribute("width")), + height: num(geoEl.getAttribute("height")), + relative: geoEl.getAttribute("relative") === "1", + hasGeometry: true, + } + : { relative: false, hasGeometry: false }; + return { + id: el.getAttribute("id") ?? "", + parent: el.getAttribute("parent") ?? undefined, + source: el.getAttribute("source") ?? undefined, + target: el.getAttribute("target") ?? undefined, + vertex: el.getAttribute("vertex") === "1", + edge: el.getAttribute("edge") === "1", + value: el.getAttribute("value") ?? "", + style, + styleMap: parsed.map, + baseStyle: parsed.baseStyle, + geometry, + }; +} + +function firstChildByTag(el: any, tag: string): any { + for (let i = 0; i < el.childNodes.length; i++) { + const c = el.childNodes[i]; + if (c.nodeType === 1 && c.tagName === tag) return c; + } + return null; +} + +function firstLineCol(msg: string): string | undefined { + const m = /^(\d+:\d+)/.exec(msg); + return m ? m[1] : undefined; +} + +// --- bounding box ---------------------------------------------------------- + +/** + * Absolute bounding box of the diagram from its vertex geometries. Container + * children are relative, so absolute positions are resolved along the parent + * chain before taking the extent. Falls back to a default canvas when empty. + */ +export function computeBBox(cells: DrawioCell[]): DrawioBBox { + const byId = new Map(cells.map((c) => [c.id, c])); + let maxX = 0; + let maxY = 0; + let any = false; + for (const c of cells) { + if (!c.vertex || !c.geometry.hasGeometry) continue; + const g = c.geometry; + if (g.width == null || g.height == null) continue; + const { x, y } = absolutePos(c, byId); + maxX = Math.max(maxX, x + g.width); + maxY = Math.max(maxY, y + g.height); + any = true; + } + if (!any) return { width: 300, height: 200 }; + // A small margin so borders/labels are not clipped at the edge. + return { width: Math.ceil(maxX) + 20, height: Math.ceil(maxY) + 20 }; +} + +/** Absolute (x,y) of a vertex, following its parent chain (containers). */ +export function absolutePos( + cell: DrawioCell, + byId: Map, +): { x: number; y: number } { + let x = cell.geometry.x ?? 0; + let y = cell.geometry.y ?? 0; + const seen = new Set([cell.id]); + let parentId = cell.parent; + while (parentId && !seen.has(parentId)) { + seen.add(parentId); + const p = byId.get(parentId); + // Sentinels (0/1) carry no geometry; stop there. + if (!p || !p.vertex || !p.geometry.hasGeometry) break; + x += p.geometry.x ?? 0; + y += p.geometry.y ?? 0; + parentId = p.parent; + } + return { x, y }; +} + +// --- linter ---------------------------------------------------------------- + +/** + * Run every deterministic pre-write rule over a full mxGraphModel string. On any + * violation it throws a DrawioLintError carrying one issue per violation, each + * with the offending cellId + position. Returns the parsed cells on success. + */ +export function lintModel(modelXml: string): { + cells: DrawioCell[]; + warnings: string[]; +} { + const issues: DrawioLintIssue[] = []; + const warnings: string[] = []; + + // Rule: no XML comments. Checked on the raw string (a comment survives DOM + // parsing as a comment node, but the intent is to reject them outright — they + // routinely wrap "TODO" cruft that breaks downstream tooling). + if (modelXml.includes(") are not allowed in diagram XML", + }); + } + + // Rule: value escaping + literal newline. Scan raw tags so the error + // can name the cell id even when the whole document is otherwise malformed. + scanRawValues(modelXml, issues); + + // Well-formedness — everything below needs a parsed DOM. + const { doc, error } = parseXml(modelXml); + if (error) { + issues.push({ + rule: "well-formed-xml", + message: error, + position: firstLineCol(error), + }); + throw new DrawioLintError(issues); + } + + const root = doc.documentElement; + if (!root || root.tagName !== "mxGraphModel") { + issues.push({ + rule: "structure", + message: `root element must be , got <${root ? root.tagName : "?"}>`, + }); + throw new DrawioLintError(issues); + } + if (!firstChildByTag(root, "root")) { + issues.push({ + rule: "structure", + message: " must contain a element", + }); + throw new DrawioLintError(issues); + } + + const cells = parseCells(modelXml); + const ids = new Set(); + + // Rule: sentinel cells id="0" and id="1"(parent="0"). + const cell0 = cells.find((c) => c.id === "0"); + const cell1 = cells.find((c) => c.id === "1"); + if (!cell0) { + issues.push({ + rule: "sentinel-cells", + message: 'missing the root sentinel cell ', + cellId: "0", + }); + } + if (!cell1) { + issues.push({ + rule: "sentinel-cells", + message: 'missing the layer sentinel cell ', + cellId: "1", + }); + } else if (cell1.parent !== "0") { + issues.push({ + rule: "sentinel-cells", + message: 'the layer sentinel must have parent="0"', + cellId: "1", + }); + } + + cells.forEach((c, index) => { + const pos = `cell #${index}`; + const isSentinel = c.id === "0" || c.id === "1"; + + // Rule: unique, non-empty ids; user cells must not reuse 0/1. + if (c.id === "") { + issues.push({ rule: "cell-id", message: "cell has an empty id", position: pos }); + } else if (ids.has(c.id)) { + issues.push({ + rule: "duplicate-id", + message: `duplicate cell id "${c.id}"`, + cellId: c.id, + position: pos, + }); + } + ids.add(c.id); + + if (isSentinel) return; // sentinels are exempt from the shape rules below + + // Rule: vertex XOR edge (a cell may be neither: groups/containers). + if (c.vertex && c.edge) { + issues.push({ + rule: "vertex-edge-exclusive", + message: 'a cell cannot be both vertex="1" and edge="1"', + cellId: c.id, + position: pos, + }); + } + + // Rule: every edge has a child . + if (c.edge && !c.geometry.hasGeometry) { + issues.push({ + rule: "edge-geometry", + message: + 'edge is missing its child — it will not render', + cellId: c.id, + position: pos, + }); + } + + // Rule: edge endpoints resolve to existing ids. + if (c.edge) { + for (const end of ["source", "target"] as const) { + const ref = c[end]; + if (ref != null && ref !== "" && !cellExists(cells, ref)) { + issues.push({ + rule: "edge-endpoint", + message: `edge ${end} "${ref}" does not resolve to any cell`, + cellId: c.id, + position: pos, + }); + } + } + } + + // Rule: parent must exist. + if (c.parent != null && c.parent !== "" && !cellExists(cells, c.parent)) { + issues.push({ + rule: "parent-exists", + message: `parent "${c.parent}" does not resolve to any cell`, + cellId: c.id, + position: pos, + }); + } + + // Rule: style parses as key=value; pairs. + if (c.style !== "") { + const parsed = parseStyle(c.style); + if (parsed.badSegment !== undefined) { + issues.push({ + rule: "style-format", + message: `malformed style segment "${parsed.badSegment}" (expected key=value)`, + cellId: c.id, + position: pos, + }); + } + } + }); + + if (issues.length > 0) throw new DrawioLintError(issues); + return { cells, warnings }; +} + +function cellExists(cells: DrawioCell[], id: string): boolean { + return cells.some((c) => c.id === id); +} + +/** + * Raw-string scan of every `value="…"`/`value='…'` on an mxCell tag. Catches an + * unescaped `&`/`<`/`>` and a literal newline character inside a value, keyed to + * the cell's id. Runs before DOM parsing so a value bug is reported with its + * cellId even when the document is otherwise malformed. + */ +function scanRawValues(xml: string, issues: DrawioLintIssue[]): void { + const tagRe = /]*?)\/?>/g; + let m: RegExpExecArray | null; + while ((m = tagRe.exec(xml)) !== null) { + const attrs = m[1]; + const idM = /\bid\s*=\s*"([^"]*)"/.exec(attrs); + const cellId = idM ? idM[1] : undefined; + const valM = /\bvalue\s*=\s*"([^"]*)"/.exec(attrs) || /\bvalue\s*=\s*'([^']*)'/.exec(attrs); + if (!valM) continue; + const raw = valM[1]; + // Literal newline (0x0A / 0x0D) inside the attribute value. + if (/[\n\r]/.test(raw)) { + issues.push({ + rule: "value-newline", + message: + "value contains a literal newline; use (or
with html=1) instead", + cellId, + }); + } + // Unescaped '<' or '>' inside a value. + if (raw.includes("<") || raw.includes(">")) { + issues.push({ + rule: "value-escaping", + message: "value contains an unescaped '<' or '>'; use < / >", + cellId, + }); + } + // '&' that does not begin a valid entity. + const badAmp = /&(?!(amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/.test(raw); + if (badAmp) { + issues.push({ + rule: "value-escaping", + message: "value contains an unescaped '&'; use &", + cellId, + }); + } + } +} + +// --- input normalization + prepare ----------------------------------------- + +/** + * Normalize an accepted tool input into a full mxGraphModel string: + * - a bare `` is used as-is; + * - an `` is decoded to its first page's model; + * - a list of `` is wrapped with the mxGraphModel/root envelope and + * the sentinel cells (id=0, id=1 parent=0) are added when absent. + */ +export function normalizeInput(inputXml: string): string { + let xml = inputXml.trim(); + // Strip an optional XML prolog. + if (xml.startsWith(""); + if (end !== -1) xml = xml.slice(end + 2).trim(); + } + if (xml.startsWith(", an , or a list of elements", + }, + ]); +} + +function wrapCellFragment(fragment: string): string { + // Validate the fragment is well-formed (wrapped so a bare list parses) and + // discover which sentinels are already present. + const { doc, error } = parseXml(`${fragment}`); + if (error) { + throw new DrawioLintError([ + { + rule: "well-formed-xml", + message: error, + position: firstLineCol(error), + }, + ]); + } + const existing = new Set(); + const els = doc.getElementsByTagName("mxCell"); + for (let i = 0; i < els.length; i++) { + existing.add(els[i].getAttribute("id") ?? ""); + } + let prefix = ""; + if (!existing.has("0")) prefix += ''; + if (!existing.has("1")) prefix += ''; + return `${prefix}${fragment}`; +} + +export interface PreparedModel { + /** Canonical (normalized) mxGraphModel XML that gets written. */ + modelXml: string; + cells: DrawioCell[]; + bbox: DrawioBBox; + /** Number of user cells (excludes the id=0/id=1 sentinels). */ + cellCount: number; + warnings: string[]; + hash: string; +} + +/** + * Full pre-write pipeline for create/update: normalize the input into a model, + * lint it (throws DrawioLintError on any violation), then compute the canonical + * form, bounding box, cell count and hash. Never touches the network. + */ +export function prepareModel(inputXml: string): PreparedModel { + const rawModel = normalizeInput(inputXml); + const { cells, warnings } = lintModel(rawModel); + const modelXml = normalizeXml(rawModel); + const bbox = computeBBox(cells); + const cellCount = cells.filter((c) => c.id !== "0" && c.id !== "1").length; + return { + modelXml, + cells, + bbox, + cellCount, + warnings, + hash: mxHash(modelXml), + }; +} + +/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */ +export function countUserCells(modelXml: string): number { + return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length; +} diff --git a/packages/mcp/src/lib/pako.d.ts b/packages/mcp/src/lib/pako.d.ts new file mode 100644 index 00000000..da4c0bc0 --- /dev/null +++ b/packages/mcp/src/lib/pako.d.ts @@ -0,0 +1,61 @@ +// Minimal ambient type declaration for `pako` (no @types/pako is installed and +// pako 2.x ships no bundled .d.ts). We only use the raw-deflate codec to read +// draw.io's compressed `` payload, so declare just that surface. +declare module "pako" { + interface RawOptions { + /** When "string", the result is returned as a (binary/UTF-8) string. */ + to?: "string"; + /** Raw-deflate window bits; draw.io uses raw deflate (no zlib header). */ + windowBits?: number; + level?: number; + } + + /** Raw-inflate (windowBits: -15). `to:"string"` yields a string. */ + export function inflateRaw( + data: Uint8Array | ArrayBuffer | number[], + options: RawOptions & { to: "string" }, + ): string; + export function inflateRaw( + data: Uint8Array | ArrayBuffer | number[], + options?: RawOptions, + ): Uint8Array; + + /** Raw-deflate (windowBits: -15). Used only by tests to build fixtures. */ + export function deflateRaw( + data: Uint8Array | string, + options?: RawOptions, + ): Uint8Array; + + interface InflateStreamOptions { + to?: "string"; + windowBits?: number; + /** Raw deflate (no zlib header) — equivalent to windowBits: -15. */ + raw?: boolean; + chunkSize?: number; + } + + /** + * Streaming inflate. We use it to bound the decompressed size: `onData` is + * invoked per output chunk, letting us abort a decompression bomb before the + * full output is materialised. + */ + export class Inflate { + constructor(options?: InflateStreamOptions); + onData: (chunk: string | Uint8Array) => void; + onEnd: (status: number) => void; + push( + data: Uint8Array | ArrayBuffer | number[] | string, + flushMode?: boolean | number, + ): boolean; + result: string | Uint8Array; + err: number; + msg: string; + } + + const _default: { + inflateRaw: typeof inflateRaw; + deflateRaw: typeof deflateRaw; + Inflate: typeof Inflate; + }; + export default _default; +} diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 72f6c57d..1516f62e 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -1119,4 +1119,112 @@ export const SHARED_TOOL_SPECS = { alt: z.string().optional(), }), }, + + // --- draw.io diagrams (issue #423, stage 1) --- + + drawioGet: { + mcpName: 'drawio_get', + inAppKey: 'drawioGet', + description: + 'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' + + '`.drawio.svg`. `node` is the drawio node\'s attrs.id (from get_outline / ' + + 'get_page_json) or "#" for a top-level block. Returns the decoded ' + + 'mxGraphModel XML plus meta { attachmentId, title, width, height, ' + + 'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' + + 'as baseHash to drawio_update. Diagrams a human saved from the editor ' + + '(including draw.io\'s compressed format) decode losslessly.', + tier: 'deferred', + catalogLine: + 'drawioGet — read a draw.io diagram as mxGraph XML (+ hash for updates).', + buildShape: (z) => ({ + pageId: z.string().min(1), + node: z + .string() + .min(1) + .describe('The drawio node attrs.id, or "#" for a top-level block.'), + format: z + .enum(['xml', 'svg']) + .optional() + .describe('"xml" (default) for mxGraph XML, or "svg" for the raw .drawio.svg.'), + }), + }, + + drawioCreate: { + mcpName: 'drawio_create', + inAppKey: 'drawioCreate', + description: + 'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' + + 'block. `xml` is a bare `` OR a list of `` elements ' + + '(the server wraps it and adds the id=0 / id=1 sentinel cells). The XML is ' + + 'LINTED first (well-formedness, sentinel cells, unique ids, vertex XOR ' + + 'edge, every edge has a child , edge ' + + 'source/target and every parent resolve, style parses, no XML comments, ' + + 'value escaping) — a violation returns a structured error naming the rule ' + + 'and cellId so you can fix and retry. `where` positions the block like ' + + 'insert_node: position before/after (with exactly one of anchorNodeId or ' + + 'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' + + 'returned `nodeId` is an index-based "#" handle (drawio nodes carry ' + + 'no attrs.id): it addresses the new top-level block and can be fed straight ' + + 'back into drawio_get / drawio_update for THIS document. It is positional, ' + + 'so if you add or remove blocks before it, re-resolve via get_outline. The ' + + 'diagram is editable in the draw.io editor and can be re-read with ' + + 'drawio_get.', + tier: 'deferred', + catalogLine: + 'drawioCreate — create a draw.io diagram from mxGraph XML and insert it.', + buildShape: (z) => ({ + pageId: z.string().min(1), + xml: z + .string() + .min(1) + .describe( + 'mxGraph XML: a bare or a list of elements.', + ), + position: z + .enum(['before', 'after', 'append']) + .describe('Where to insert relative to the anchor.'), + anchorNodeId: z + .string() + .optional() + .describe('Anchor block id (for before/after).'), + anchorText: z + .string() + .optional() + .describe('Anchor text fragment (for before/after).'), + title: z.string().optional().describe('Optional diagram title.'), + }), + }, + + drawioUpdate: { + mcpName: 'drawio_update', + inAppKey: 'drawioUpdate', + description: + 'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' + + 'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' + + 'the drawio_get you based the edit on. If the diagram changed since ' + + '(a human or another agent edited it) the hash mismatches and the update ' + + 'is refused with a conflict error — re-read with drawio_get and retry. On ' + + 'success it overwrites the diagram attachment and updates the node ' + + 'width/height. `node` is the drawio node attrs.id or "#".', + tier: 'deferred', + catalogLine: + 'drawioUpdate — replace a draw.io diagram (optimistic-locked by baseHash).', + buildShape: (z) => ({ + pageId: z.string().min(1), + node: z + .string() + .min(1) + .describe('The drawio node attrs.id, or "#" for a top-level block.'), + xml: z + .string() + .min(1) + .describe( + 'New mxGraph XML: a bare or a list of elements.', + ), + baseHash: z + .string() + .min(1) + .describe('The meta.hash from the drawio_get this edit is based on.'), + }), + }, } satisfies Record; diff --git a/packages/mcp/test/mock/drawio-tools.test.mjs b/packages/mcp/test/mock/drawio-tools.test.mjs new file mode 100644 index 00000000..9183bcff --- /dev/null +++ b/packages/mcp/test/mock/drawio-tools.test.mjs @@ -0,0 +1,467 @@ +// Contract tests for the drawio_get / drawio_create / drawio_update client +// methods (issue #423). Follows the repo's seam-override pattern (see +// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O +// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage +// write) so the tool logic is exercised without a live Docmost or collab socket. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import pako from "pako"; +import { DocmostClient } from "../../build/client.js"; +import { + buildDrawioSvg, + encodeDrawioFile, + normalizeXml, + mxHash, + decodeDrawioSvg, +} from "../../build/lib/drawio-xml.js"; + +const MODEL = + '' + + '' + + '' + + '' + + ''; + +// Build a Docmost-style `.drawio.svg` (base64 content) for a model. +function svgFor(model) { + return buildDrawioSvg(normalizeXml(model), "", { width: 200, height: 120 }); +} + +// Build a human/compressed-export `.drawio.svg` (base64 content wrapping a +// compressed payload), mimicking a diagram a person saved. +function compressedSvgFor(model) { + const compressed = Buffer.from( + pako.deflateRaw(encodeURIComponent(normalizeXml(model))), + ).toString("base64"); + const file = `${compressed}`; + const content = Buffer.from(file, "utf-8").toString("base64"); + return ``; +} + +// The vendored `drawio` node schema (diagramAttributes) declares ONLY these +// attributes; PMNode.fromJSON drops anything else on save. Mirror that here so +// the mock write path behaves like the real one — in particular, a block `id` +// set on a drawio node does NOT survive the save, so a handle keyed on it is +// un-resolvable. This is exactly what the production bug (issue #423 Fix 1) was. +const DRAWIO_SCHEMA_ATTRS = new Set([ + "src", + "title", + "alt", + "width", + "height", + "size", + "aspectRatio", + "align", + "attachmentId", +]); + +function applyDrawioSchemaDrop(node) { + if (!node || typeof node !== "object") return; + if (node.type === "drawio" && node.attrs && typeof node.attrs === "object") { + for (const key of Object.keys(node.attrs)) { + if (!DRAWIO_SCHEMA_ATTRS.has(key)) delete node.attrs[key]; + } + } + if (Array.isArray(node.content)) for (const c of node.content) applyDrawioSchemaDrop(c); +} + +function makeClient({ pageDoc, attachmentSvg } = {}) { + const calls = { uploads: [], mutations: [] }; + class TestClient extends DocmostClient { + async ensureAuthenticated() {} + async getCollabTokenWithReauth() { + return "collab-token"; + } + async resolvePageId(pageId) { + return `uuid-${pageId}`; + } + async getPageRaw(pageId) { + return { + id: pageId, + slugId: "s", + title: "P", + spaceId: "sp", + content: pageDoc ?? { type: "doc", content: [] }, + }; + } + async uploadAttachmentBuffer(pageId, buffer, fileName, mime) { + const id = `att-${calls.uploads.length + 1}`; + calls.uploads.push({ pageId, fileName, mime, svg: buffer.toString("utf-8") }); + return { id, fileName, fileSize: buffer.length }; + } + async fetchAttachmentText(src) { + return attachmentSvg; + } + mutatePage(pageId, token, apiUrl, transform) { + // Run the transform against a clone of the source doc, capture the result. + const clone = structuredClone(pageDoc ?? { type: "doc", content: [] }); + const doc = transform(clone); + // Mirror the real schema: unknown drawio attrs (e.g. a block `id`) are + // dropped on save, so callers can never rely on them to address the node. + if (doc) applyDrawioSchemaDrop(doc); + calls.mutations.push({ pageId, doc }); + return Promise.resolve({ doc, verify: { changed: doc != null } }); + } + } + const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw"); + return { client, calls }; +} + +function findDrawio(node, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === "drawio") acc.push(node); + if (Array.isArray(node.content)) for (const c of node.content) findDrawio(c, acc); + return acc; +} + +// --- drawio_create --------------------------------------------------------- + +test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", async () => { + const pageDoc = { + type: "doc", + content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], + }; + const { client, calls } = makeClient({ pageDoc }); + const res = await client.drawioCreate("page1", { position: "append" }, MODEL, "My diagram"); + + assert.equal(res.success, true); + // The returned handle is an index-based "#" ref (drawio nodes carry no + // persisted attrs.id), addressing the appended top-level block (index 1, after + // the existing paragraph). + assert.equal(res.nodeId, "#1"); + assert.equal(res.attachmentId, "att-1"); + assert.equal(calls.uploads.length, 1); + assert.equal(calls.uploads[0].fileName, "diagram.drawio.svg"); + assert.equal(calls.uploads[0].mime, "image/svg+xml"); + // The uploaded SVG carries the model back (round-trips through the decode chain). + assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(MODEL)); + + // A drawio node was appended with src/attachmentId/dimensions and the title. + const drawios = findDrawio(calls.mutations[0].doc); + assert.equal(drawios.length, 1); + const n = drawios[0]; + // No `id` attribute is set/persisted on the node (schema has none). + assert.equal(n.attrs.id, undefined); + assert.equal(n.attrs.attachmentId, "att-1"); + assert.match(n.attrs.src, /^\/api\/files\/att-1\//); + assert.ok(n.attrs.width > 0 && n.attrs.height > 0); + assert.equal(n.attrs.title, "My diagram"); +}); + +test("drawio_create: a lint violation throws before any upload", async () => { + const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); + // Edge with no child geometry -> edge-geometry rule. + const bad = + '' + + '' + + ''; + await assert.rejects( + () => client.drawioCreate("page1", { position: "append" }, bad, undefined), + /edge-geometry/, + ); + assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure"); +}); + +test("drawio_create: before/after requires exactly one anchor", async () => { + const { client } = makeClient({ pageDoc: { type: "doc", content: [] } }); + await assert.rejects( + () => client.drawioCreate("page1", { position: "before" }, MODEL), + /exactly one of anchorNodeId or anchorText/, + ); +}); + +// --- drawio_get ------------------------------------------------------------ + +test("drawio_get: decodes the model and returns meta with a hash", async () => { + const pageDoc = { + type: "doc", + content: [ + { + type: "drawio", + attrs: { + id: "d1", + src: "/api/files/att-1/diagram.drawio.svg", + attachmentId: "att-1", + title: "T", + width: 200, + height: 120, + }, + }, + ], + }; + const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) }); + const res = await client.drawioGet("page1", "d1", "xml"); + assert.equal(res.content, normalizeXml(MODEL)); + assert.equal(res.meta.attachmentId, "att-1"); + assert.equal(res.meta.title, "T"); + assert.equal(res.meta.cellCount, 1); + assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL))); +}); + +test("drawio_get: format=svg returns the raw .drawio.svg", async () => { + const svg = svgFor(MODEL); + const pageDoc = { + type: "doc", + content: [ + { type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } }, + ], + }; + const { client } = makeClient({ pageDoc, attachmentSvg: svg }); + const res = await client.drawioGet("page1", "d1", "svg"); + assert.equal(res.content, svg); +}); + +test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => { + const pageDoc = { + type: "doc", + content: [ + { type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } }, + ], + }; + const { client } = makeClient({ pageDoc, attachmentSvg: compressedSvgFor(MODEL) }); + const res = await client.drawioGet("page1", "d1", "xml"); + assert.equal(res.content, normalizeXml(MODEL)); +}); + +// --- drawio_update --------------------------------------------------------- + +const UPDATED_MODEL = + '' + + '' + + '' + + '' + + ''; + +function updatePageDoc() { + return { + type: "doc", + content: [ + { + type: "drawio", + attrs: { + id: "d1", + src: "/api/files/att-1/diagram.drawio.svg", + attachmentId: "att-1", + width: 200, + height: 120, + }, + }, + ], + }; +} + +test("drawio_update: stale baseHash -> conflict, no upload", async () => { + const { client, calls } = makeClient({ + pageDoc: updatePageDoc(), + attachmentSvg: svgFor(MODEL), + }); + await assert.rejects( + () => client.drawioUpdate("page1", "d1", UPDATED_MODEL, "deadbeef-stale"), + /conflict/, + ); + assert.equal(calls.uploads.length, 0, "no upload on conflict"); +}); + +test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => { + const currentHash = mxHash(normalizeXml(MODEL)); + const { client, calls } = makeClient({ + pageDoc: updatePageDoc(), + attachmentSvg: svgFor(MODEL), + }); + const res = await client.drawioUpdate("page1", "d1", UPDATED_MODEL, currentHash); + assert.equal(res.success, true); + assert.equal(res.attachmentId, "att-1"); // fresh id from the stub sequence + assert.equal(calls.uploads.length, 1); + // The uploaded SVG carries the NEW model. + assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(UPDATED_MODEL)); + // The node was repointed with the new bounding-box dimensions: + // vertex maxX=320,maxY=220 + the 20px preview margin -> 340 x 240. + const n = findDrawio(calls.mutations[0].doc)[0]; + assert.equal(n.attrs.attachmentId, "att-1"); + assert.equal(n.attrs.width, 340); + assert.equal(n.attrs.height, 240); + // The block `id` used as the legacy resolution handle is dropped on save + // (schema declares no `id`); the update still targeted the correct node. + assert.equal(n.attrs.id, undefined); +}); + +test("drawio_update: baseHash is mandatory", async () => { + const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) }); + await assert.rejects( + () => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""), + /baseHash is mandatory/, + ); +}); + +// --- Fix 1: the create handle must resolve on the SAVED doc (no id) --------- + +test("drawio_create -> get/update: returned # handle resolves on the saved doc (id dropped)", async () => { + // Create appends a drawio node after the existing paragraph. + const createDoc = { + type: "doc", + content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], + }; + const create = makeClient({ pageDoc: createDoc }); + const res = await create.client.drawioCreate( + "page1", + { position: "append" }, + MODEL, + "T", + ); + // The handle is index-based, not a block id. + assert.equal(res.nodeId, "#1"); + + // Take the document EXACTLY as it was saved: the schema drop stripped the + // node's id, so no id-based handle could ever resolve against it. + const savedDoc = create.calls.mutations[0].doc; + assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined); + + // drawio_get with the returned handle resolves the just-created node. + const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) }); + const got = await getClient.client.drawioGet("page1", res.nodeId, "xml"); + assert.equal(got.nodeId, res.nodeId); + assert.equal(got.content, normalizeXml(MODEL)); + + // drawio_update with the same handle + the hash from get repoints that node. + const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) }); + const upd = await upClient.client.drawioUpdate( + "page1", + res.nodeId, + UPDATED_MODEL, + got.meta.hash, + ); + assert.equal(upd.success, true); + assert.equal(upd.nodeId, res.nodeId); + const updated = findDrawio(upClient.calls.mutations[0].doc)[0]; + assert.equal( + decodeDrawioSvg(upClient.calls.uploads[0].svg), + normalizeXml(UPDATED_MODEL), + ); + assert.equal(updated.attrs.width, 340); +}); + +// --- error paths: the LLM must get a clean error, not a crash -------------- + +test("drawio_get: a bad node ref -> clean 'no node found' error", async () => { + // Page has one paragraph; the requested ref resolves to nothing. + const pageDoc = { + type: "doc", + content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], + }; + const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) }); + await assert.rejects( + () => client.drawioGet("page1", "does-not-exist", "xml"), + /no node found for "does-not-exist"/, + ); +}); + +test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => { + const pageDoc = { + type: "doc", + content: [ + // A drawio node that carries no `src` (e.g. a half-written node). + { type: "drawio", attrs: { id: "d1", attachmentId: "att-1" } }, + ], + }; + const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) }); + await assert.rejects( + () => client.drawioGet("page1", "d1", "xml"), + /node "d1" on page page1 has no src to read/, + ); +}); + +test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => { + // "#0" resolves to a paragraph. The update must refuse cleanly rather than + // crash or repoint the wrong node. + const pageDoc = { + type: "doc", + content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], + }; + const { client, calls } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) }); + await assert.rejects( + () => client.drawioUpdate("page1", "#0", UPDATED_MODEL, "any-nonempty-hash"), + /node "#0" on page page1 is a paragraph, not a drawio diagram/, + ); + assert.equal(calls.uploads.length, 0, "no upload when the node is not a diagram"); + assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram"); +}); + +test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => { + // The upload happens before the mutate transform; when the anchor cannot be + // found the write is skipped and the (now unreferenced) attachment is named + // in the error, exactly as the code documents. + const pageDoc = { + type: "doc", + content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], + }; + const { client, calls } = makeClient({ pageDoc }); + await assert.rejects( + () => + client.drawioCreate( + "page1", + { position: "after", anchorNodeId: "nope" }, + MODEL, + "T", + ), + (err) => + /anchor not found/.test(err.message) && + /unreferenced orphan/.test(err.message) && + /att-1/.test(err.message), + ); + // The orphan was uploaded (and reported), but no node was written. + assert.equal(calls.uploads.length, 1, "attachment uploaded before the failed insert"); + const drawios = calls.mutations.length ? findDrawio(calls.mutations[0].doc) : []; + assert.equal(drawios.length, 0, "no drawio node written when the anchor is missing"); +}); + +// --- Fix 2: update targets ONLY the resolved node -------------------------- + +test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => { + // A copied diagram: two drawio nodes share one attachmentId. Updating via the + // "#0" handle must touch node #0 only, never the sibling copy. + const shared = { + type: "doc", + content: [ + { + type: "drawio", + attrs: { + src: "/api/files/shared/x.svg", + attachmentId: "shared", + width: 200, + height: 120, + }, + }, + { + type: "drawio", + attrs: { + src: "/api/files/shared/x.svg", + attachmentId: "shared", + width: 200, + height: 120, + }, + }, + ], + }; + const { client, calls } = makeClient({ + pageDoc: shared, + attachmentSvg: svgFor(MODEL), + }); + const res = await client.drawioUpdate( + "page1", + "#0", + UPDATED_MODEL, + mxHash(normalizeXml(MODEL)), + ); + assert.equal(res.success, true); + + const drawios = findDrawio(calls.mutations[0].doc); + assert.equal(drawios.length, 2); + // Node #0 repointed to the NEW attachment ("att-1" from the stub) and dims. + assert.equal(drawios[0].attrs.attachmentId, "att-1"); + assert.equal(drawios[0].attrs.width, 340); + assert.match(drawios[0].attrs.src, /^\/api\/files\/att-1\//); + // Node #1 (the sibling copy) is untouched despite sharing the old attachmentId. + assert.equal(drawios[1].attrs.attachmentId, "shared"); + assert.equal(drawios[1].attrs.width, 200); + assert.equal(drawios[1].attrs.src, "/api/files/shared/x.svg"); +}); diff --git a/packages/mcp/test/unit/drawio-preview.test.mjs b/packages/mcp/test/unit/drawio-preview.test.mjs new file mode 100644 index 00000000..0aa265a1 --- /dev/null +++ b/packages/mcp/test/unit/drawio-preview.test.mjs @@ -0,0 +1,84 @@ +// Unit tests for the pure-TS schematic SVG preview (issue #423). Asserts that +// the preview emits well-formed SVG covering each primitive (rect/ellipse/ +// rhombus/edge), resolves container-relative coordinates to absolute, and that +// the full `.drawio.svg` wrapper parses. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { renderDiagramShapes } from "../../build/lib/drawio-preview.js"; +import { + parseCells, + computeBBox, + buildDrawioSvg, + normalizeXml, +} from "../../build/lib/drawio-xml.js"; + +const { window } = new JSDOM(""); +function parseSvg(svg) { + const doc = new window.DOMParser().parseFromString(svg, "application/xml"); + const err = doc.getElementsByTagName("parsererror"); + assert.equal(err.length, 0, `SVG did not parse: ${err[0]?.textContent}`); + return doc; +} + +const MODEL = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + +test("renderDiagramShapes emits rect, ellipse, polygon and a line", () => { + const cells = parseCells(MODEL); + const inner = renderDiagramShapes(cells, computeBBox(cells)); + assert.ok(inner.includes(" { + const cells = parseCells(MODEL); + const bbox = computeBBox(cells); + const inner = renderDiagramShapes(cells, bbox); + const svg = buildDrawioSvg(normalizeXml(MODEL), inner, bbox); + const doc = parseSvg(svg); + assert.equal(doc.documentElement.tagName, "svg"); + assert.ok(doc.documentElement.getAttribute("content"), "carries content="); + // The visible children exist. + assert.ok(doc.getElementsByTagName("rect").length >= 1); +}); + +test("container children resolve to absolute coordinates", () => { + // A group at (100,100) with a child rect at relative (10,10,20,20) -> abs 110,110. + const model = + '' + + '' + + '' + + '' + + ''; + const cells = parseCells(model); + const inner = renderDiagramShapes(cells, computeBBox(cells)); + // The child rect must be placed at absolute x=110,y=110. + assert.ok(/ { + const model = + '' + + '' + + '' + + '' + + ''; + const cells = parseCells(model); + const inner = renderDiagramShapes(cells, computeBBox(cells)); + assert.ok(inner.includes(" rect"); + assert.ok(inner.includes(">AWS<"), "keeps the label"); +}); diff --git a/packages/mcp/test/unit/drawio-xml.test.mjs b/packages/mcp/test/unit/drawio-xml.test.mjs new file mode 100644 index 00000000..8d6e83ec --- /dev/null +++ b/packages/mcp/test/unit/drawio-xml.test.mjs @@ -0,0 +1,360 @@ +// Unit tests for the drawio-xml module (issue #423): the linter (a positive +// baseline + a negative case per rule, each asserting rule + cellId), the +// decode chain (plain nested XML AND draw.io's compressed via pako), +// encode/round-trip byte-stability, hash stability, style parsing and the +// bounding box. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import pako from "pako"; +import { + parseStyle, + lintModel, + prepareModel, + normalizeInput, + normalizeXml, + mxHash, + computeBBox, + parseCells, + decodeDrawioSvg, + decodeDrawioFileToModel, + buildDrawioSvg, + encodeDrawioFile, + countUserCells, + DrawioLintError, + inflateDiagramPayload, + MAX_INFLATED_DIAGRAM_BYTES, +} from "../../build/lib/drawio-xml.js"; + +// A well-formed model with one vertex and a valid edge to it. +const VALID_MODEL = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + +function issuesOf(fn) { + try { + fn(); + return null; + } catch (e) { + assert.ok(e instanceof DrawioLintError, `expected DrawioLintError, got ${e}`); + return e.issues; + } +} +const hasRule = (issues, rule, cellId) => + issues.some( + (i) => i.rule === rule && (cellId === undefined || i.cellId === cellId), + ); + +// --- style parsing --------------------------------------------------------- + +test("parseStyle: base stylename + key=value pairs", () => { + const r = parseStyle("ellipse;fillColor=#ff0000;whiteSpace=wrap;"); + assert.equal(r.baseStyle, "ellipse"); + assert.equal(r.map.fillColor, "#ff0000"); + assert.equal(r.map.whiteSpace, "wrap"); + assert.equal(r.badSegment, undefined); +}); + +test("parseStyle: flags a segment with two '='", () => { + const r = parseStyle("a=b=c;"); + assert.equal(r.badSegment, "a=b=c"); +}); + +test("parseStyle: a second bare token is malformed", () => { + const r = parseStyle("rounded=1;bareword"); + assert.equal(r.badSegment, "bareword"); +}); + +// --- linter: positive baseline --------------------------------------------- + +test("lintModel: the canonical valid model passes", () => { + const { cells } = lintModel(VALID_MODEL); + assert.equal(cells.length, 5); +}); + +// --- linter: one negative case per rule ------------------------------------ + +test("rule well-formed-xml: malformed XML", () => { + const issues = issuesOf(() => lintModel("")); + assert.ok(hasRule(issues, "well-formed-xml")); + assert.ok(issues[0].position, "carries a line:col position"); +}); + +test("rule structure: root is not mxGraphModel", () => { + const issues = issuesOf(() => lintModel("")); + assert.ok(hasRule(issues, "structure")); +}); + +test("rule sentinel-cells: missing id=0 / id=1", () => { + const m = + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "sentinel-cells", "0")); + assert.ok(hasRule(issues, "sentinel-cells", "1")); +}); + +test("rule duplicate-id: two cells share an id", () => { + const m = + '' + + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "duplicate-id", "2")); +}); + +test("rule vertex-edge-exclusive: cell is both vertex and edge", () => { + const m = + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "vertex-edge-exclusive", "2")); +}); + +test("rule edge-geometry: self-closed edge without child mxGeometry", () => { + const m = + '' + + '' + + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "edge-geometry", "4")); +}); + +test("rule edge-endpoint: source/target does not resolve", () => { + const m = + '' + + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "edge-endpoint", "4")); +}); + +test("rule parent-exists: parent points at a missing id", () => { + const m = + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "parent-exists", "2")); +}); + +test("rule no-comments: XML comment present", () => { + const m = + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "no-comments")); +}); + +test("rule style-format: malformed style segment (cellId reported)", () => { + const m = + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "style-format", "2")); +}); + +test("rule value-newline: literal newline in a value (cellId reported)", () => { + const m = + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "value-newline", "2")); +}); + +test("rule value-escaping: unescaped ampersand in a value (cellId reported)", () => { + const m = + '' + + '' + + '' + + ''; + const issues = issuesOf(() => lintModel(m)); + assert.ok(hasRule(issues, "value-escaping", "2")); +}); + +test("rule reserved id: escaped entity value passes (no false positive)", () => { + const m = + '' + + '' + + '' + + ''; + assert.doesNotThrow(() => lintModel(m)); +}); + +// --- input normalization --------------------------------------------------- + +test("normalizeInput: a list of is wrapped and sentinels added", () => { + const frag = + ''; + const model = normalizeInput(frag); + assert.ok(model.startsWith("')); + assert.ok(model.includes('')); + // And it lints clean. + assert.doesNotThrow(() => lintModel(model)); +}); + +test("normalizeInput: an existing sentinel is not duplicated", () => { + const frag = + '' + + ''; + const model = normalizeInput(frag); + const count0 = (model.match(/id="0"/g) || []).length; + assert.equal(count0, 1); +}); + +test("prepareModel: returns bbox, cellCount, hash and lints", () => { + const p = prepareModel(VALID_MODEL); + assert.equal(p.cellCount, 3); // 2, 3, 4 (sentinels excluded) + assert.ok(p.bbox.width > 0 && p.bbox.height > 0); + assert.equal(p.hash, mxHash(normalizeXml(VALID_MODEL))); +}); + +// --- decode chain: plain ----------------------------------------------------- + +test("decode chain (plain): buildDrawioSvg -> decodeDrawioSvg round-trips byte-stable", () => { + const model = normalizeXml(VALID_MODEL); + const svg = buildDrawioSvg(model, "", { width: 400, height: 200 }); + const decoded = decodeDrawioSvg(svg); + assert.equal(decoded, model); +}); + +test("decode chain: entity-encoded content= (draw.io export style) is read directly", () => { + const model = normalizeXml(VALID_MODEL); + const file = encodeDrawioFile(model, "Page-1"); + const escaped = file + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + const svg = ``; + const decoded = decodeDrawioSvg(svg); + assert.equal(decoded, model); +}); + +// --- decode chain: compressed (pako) --------------------------------------- + +test("decode chain (compressed pako): human-saved payload decodes losslessly", () => { + const model = normalizeXml(VALID_MODEL); + // Reproduce draw.io's compression: encodeURIComponent -> raw deflate -> base64. + const compressed = Buffer.from( + pako.deflateRaw(encodeURIComponent(model)), + ).toString("base64"); + const file = `${compressed}`; + // Docmost stores the file base64 in content=. + const contentB64 = Buffer.from(file, "utf-8").toString("base64"); + const svg = ``; + const decoded = decodeDrawioSvg(svg); + assert.equal(decoded, model); +}); + +test("decodeDrawioFileToModel: bare mxGraphModel file returns the model substring", () => { + const model = normalizeXml(VALID_MODEL); + assert.equal(decodeDrawioFileToModel(model), model); +}); + +// --- hash stability -------------------------------------------------------- + +test("mxHash: stable across inter-tag whitespace, sensitive to content", () => { + const a = VALID_MODEL; + const b = VALID_MODEL.replace(/>\n <"); // reformat only + assert.equal(mxHash(a), mxHash(b)); + const c = VALID_MODEL.replace('value="Hello"', 'value="Changed"'); + assert.notEqual(mxHash(a), mxHash(c)); +}); + +// --- bounding box + cell count --------------------------------------------- + +test("computeBBox + countUserCells", () => { + const cells = parseCells(VALID_MODEL); + const bbox = computeBBox(cells); + // Vertex 3 spans to x=380,y=180; plus the 20px margin. + assert.equal(bbox.width, 400); + assert.equal(bbox.height, 200); + assert.equal(countUserCells(VALID_MODEL), 3); +}); + +// --- decompression-bomb guard (Fix 3) -------------------------------------- + +test("inflateDiagramPayload: a small legitimate payload inflates fine", () => { + const xml = normalizeXml(VALID_MODEL); + const base64 = Buffer.from( + pako.deflateRaw(encodeURIComponent(xml)), + ).toString("base64"); + assert.equal(inflateDiagramPayload(base64), xml); +}); + +test("inflateDiagramPayload: rejects an over-cap decompression bomb", () => { + // A tiny compressed payload that inflates to just over the cap. Highly + // compressible (all one byte) -> the base64 is small, but the inflated output + // exceeds MAX_INFLATED_DIAGRAM_BYTES and must be refused before it is fully + // materialised. + const bombSize = MAX_INFLATED_DIAGRAM_BYTES + 1024; + const base64 = Buffer.from( + pako.deflateRaw(Buffer.alloc(bombSize, 0x41 /* 'A' */)), + ).toString("base64"); + assert.ok( + base64.length < 1024 * 1024, + "the compressed bomb is tiny relative to its inflated size", + ); + assert.throws( + () => inflateDiagramPayload(base64), + /decompression bomb/, + ); +}); + +test("encode/build: a title with < > \" & round-trips without corrupting the SVG", async () => { + // A user-supplied title full of XML metacharacters must be escaped so the + // inner stays well-formed and the outer content="..." attribute is + // never broken out of. Prove it survives the encode -> build -> decode chain. + const title = 'A < B > C " D & E'; + const model = normalizeXml(VALID_MODEL); + const svg = buildDrawioSvg(model, "", { width: 200, height: 120 }, title); + + // The outer content="..." attribute must not be broken by the title: the raw + // title metacharacters never appear literally in the SVG markup (they are + // base64-encoded inside content=, and escaped inside the file XML). + const contentMatch = /content="([^"]*)"/.exec(svg); + assert.ok(contentMatch, "SVG has a single well-formed content= attribute"); + + // The diagram model still decodes losslessly despite the exotic title. + assert.equal(decodeDrawioSvg(svg), model); + + // The file XML is well-formed: the title lives in name="..." as escaped + // entities, so unescaping recovers the original title byte-for-byte. + const fileXml = Buffer.from(contentMatch[1], "base64").toString("utf-8"); + const nameMatch = //.exec(fileXml); + assert.ok(nameMatch, "the diagram name attribute is intact and quote-safe"); + const decodedTitle = nameMatch[1] + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/&/g, "&"); + assert.equal(decodedTitle, title); + + // encodeDrawioFile alone produces the same escaped, well-formed envelope. + const file = encodeDrawioFile(model, title); + assert.match(file, /name="A < B > C " D & E">/); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71417d37..58ee446d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1044,6 +1044,9 @@ importers: marked: specifier: ^17.0.1 version: 17.0.5 + pako: + specifier: ^2.0.3 + version: 2.0.3 re2: specifier: ^1.21.0 version: 1.25.0