From 2a951df09660e75b3f441fc17d74f2053e1e80b1 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 23:16:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20drawio=20=D1=81=D1=82=D0=B0=D0=B4?= =?UTF-8?q?=D0=B8=D1=8F=203=20=E2=80=94=20=D1=81=D0=B5=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D1=82=D1=83?= =?UTF-8?q?=D0=BB=D1=8B=20drawioFromGraph/FromMermaid/EditCells=20(#425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сырой XML остаётся escape-hatch, но для 90% случаев модель не видит ни координат, ни style-строк — весь класс ошибок лейаута и иконок уходит by construction: эти решения принимает сервер, а не LLM. Стоит на #443, переиспользует конвейер #423 и shape-index/elkjs/линтер #424. - drawioFromGraph(pageId, where, graph, direction?, preset?, layout?): граф узлов/групп/связей → резолв иконок (shape-index #424; неизвестная → generic по kind с подписью, не пустой квадрат), стили из пресета (kind→палитра), elkjs-layered с compound-группами, ассемблер XML (линтер-чистый by construction: зазоры >=150, прозрачные контейнеры, относительные координаты детей, cross-container рёбра parent=1, эскейп меток). Хинты pinned/sameLayerAs/layer и layout none/full/incremental — детерминированным post-pass'ом (ELK-констрейнты оказались ненадёжны). Пресеты default/dark/colorblind-safe (Okabe-Ito) — данные. - drawioFromMermaid(pageId, where, mermaid): чистый парсер flowchart (без браузера/CLI) → graph → тот же конвейер. Формы/направление/пунктир=async/ subgraph→группы/цепочки; не-flowchart отвергает внятно. - drawioEditCells(pageId, node, operations, baseHash): ID-based add/update/delete, delete каскадит на детей контейнера и связанные рёбра; baseHash обязателен (optimistic lock как drawioUpdate); сентинелы 0/1 от delete защищены. DoS-границы (LLM-вход): MAX_GRAPH_EDGES=1000/GROUPS=500 в validateGraph (узлы уже 500), mermaid MAX_CHARS=200k/LINES=20k/GROUPS=500, chain 500 — все с быстрым throw ДО лейаута/ассемблера (ассемблер и маппер вне ELK-таймаута, иначе OOM воркера). incremental сохраняет неперечисленные существующие ячейки (mergeExisting Cells) — «добавь узел» не стирает ручную расстановку. sameLayerAs/layer после снапа раскладываются по перпендикулярной оси с зазором >=150 → 0 quality-warnings; pinned — точные пользовательские координаты (clamp>=0, могут дать warning, гарантия «0 by construction» относится к авто-лейауту). Регистрация: 3 shared-spec на оба хоста (camelCase, execute-in-spec, inlineBoth Hosts не понадобился), DocmostClientLike/Method += 3, contract, routing-проза (fromGraph→архитектуры/облака, fromMermaid→стандартные flowchart, raw xml→экзотика). Тесты: mcp node --test 782/782 (57 новых) — приёмка (15+ узлов/2 вложенные группы/ AWS-иконки→0 lint/0 warnings/иконки резолвятся, hints, incremental без сдвига, edit_cells update/cascade/stale-baseHash, снапшоты пресетов + colorblind-safe, mermaid ветвление+subgraph) + регрессии на DoS-границы. tsc чисто; server jest (contract + ai-chat) 290/290. closes #425. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/tools/ai-chat-tools.service.ts | 17 + .../ai-chat/tools/docmost-client.loader.ts | 4 + packages/mcp/data/drawio-presets.json | 63 ++ packages/mcp/src/client.ts | 257 +++++ packages/mcp/src/lib/drawio-cell-ops.ts | 168 ++++ packages/mcp/src/lib/drawio-graph.ts | 916 ++++++++++++++++++ packages/mcp/src/lib/drawio-mermaid.ts | 347 +++++++ packages/mcp/src/lib/drawio-presets.ts | 151 +++ packages/mcp/src/server-instructions.ts | 5 +- packages/mcp/src/tool-specs.ts | 231 +++++ .../mcp/test/mock/drawio-graph-tools.test.mjs | 272 ++++++ .../mcp/test/unit/drawio-cell-ops.test.mjs | 128 +++ packages/mcp/test/unit/drawio-graph.test.mjs | 380 ++++++++ .../mcp/test/unit/drawio-mermaid.test.mjs | 114 +++ .../mcp/test/unit/drawio-presets.test.mjs | 117 +++ .../unit/drawio-stage3-registration.test.mjs | 85 ++ 16 files changed, 3254 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/data/drawio-presets.json create mode 100644 packages/mcp/src/lib/drawio-cell-ops.ts create mode 100644 packages/mcp/src/lib/drawio-graph.ts create mode 100644 packages/mcp/src/lib/drawio-mermaid.ts create mode 100644 packages/mcp/src/lib/drawio-presets.ts create mode 100644 packages/mcp/test/mock/drawio-graph-tools.test.mjs create mode 100644 packages/mcp/test/unit/drawio-cell-ops.test.mjs create mode 100644 packages/mcp/test/unit/drawio-graph.test.mjs create mode 100644 packages/mcp/test/unit/drawio-mermaid.test.mjs create mode 100644 packages/mcp/test/unit/drawio-presets.test.mjs create mode 100644 packages/mcp/test/unit/drawio-stage3-registration.test.mjs diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 0cf17e03..dc2270fd 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -123,6 +123,23 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.drawioGet(s, s, 'xml'); void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk'); void client.drawioUpdate(s, s, s, s, 'elk'); + // --- draw.io high-level semantic tools (#425 stage 3) --- + void client.drawioEditCells(s, s, [{ op: 'delete', cellId: s }], s); + void client.drawioFromGraph( + s, + { position: 'append', anchorNodeId: s }, + { nodes: [{ id: s, label: s }] }, + 'LR', + s, + 'full', + s, + ); + void client.drawioFromMermaid( + s, + { position: 'append', anchorNodeId: s }, + s, + s, + ); // --- write (comment) --- void client.createComment(s, s, 'inline', s, s, s); void client.resolveComment(s, true); diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 20c24780..24f16f6a 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -71,6 +71,10 @@ type DocmostClientMethod = | 'drawioGet' | 'drawioCreate' | 'drawioUpdate' + // --- draw.io high-level semantic tools (#425 stage 3) --- + | 'drawioEditCells' + | 'drawioFromGraph' + | 'drawioFromMermaid' // --- write (comment) --- | 'createComment' | 'resolveComment'; diff --git a/packages/mcp/data/drawio-presets.json b/packages/mcp/data/drawio-presets.json new file mode 100644 index 00000000..8e200a29 --- /dev/null +++ b/packages/mcp/data/drawio-presets.json @@ -0,0 +1,63 @@ +{ + "$comment": "Semantic palettes for drawioFromGraph (issue #425). DATA, not code: node `kind` -> fill/stroke slot, edge `kind` -> line-style props, per preset. The `default` node palette is the issue's base table; `dark` keeps the same hues on a dark canvas with lighter strokes/font; `colorblind-safe` maps every slot onto the Okabe-Ito qualitative palette (8 colours proven distinguishable for all common colour-vision deficiencies) so no two adjacent kinds collide. `fontColor`/`fillColor`/`strokeColor` are exact draw.io values. `edgeDefault` is the fallback line style; `group` is the (always-transparent) container stroke per preset.", + "presets": { + "default": { + "canvasDark": false, + "nodes": { + "service": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf", "fontColor": "#000000" }, + "db": { "fillColor": "#d5e8d4", "strokeColor": "#82b366", "fontColor": "#000000" }, + "queue": { "fillColor": "#fff2cc", "strokeColor": "#d6b656", "fontColor": "#000000" }, + "gateway": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00", "fontColor": "#000000" }, + "error": { "fillColor": "#f8cecc", "strokeColor": "#b85450", "fontColor": "#000000" }, + "external": { "fillColor": "#f5f5f5", "strokeColor": "#666666", "fontColor": "#333333" }, + "security": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6", "fontColor": "#000000" } + }, + "edges": { + "sync": { "props": "" }, + "async": { "props": "dashed=1;" }, + "error": { "props": "dashed=1;strokeColor=#DD344C;" } + }, + "edgeDefault": { "strokeColor": "#333333", "fontColor": "#333333" }, + "group": { "strokeColor": "#666666", "fontColor": "#333333" } + }, + "dark": { + "canvasDark": true, + "nodes": { + "service": { "fillColor": "#1a2a44", "strokeColor": "#7ea6e0", "fontColor": "#dae8fc" }, + "db": { "fillColor": "#1f331e", "strokeColor": "#97d077", "fontColor": "#d5e8d4" }, + "queue": { "fillColor": "#3a3218", "strokeColor": "#e5c15a", "fontColor": "#fff2cc" }, + "gateway": { "fillColor": "#3a2812", "strokeColor": "#ffb570", "fontColor": "#ffe6cc" }, + "error": { "fillColor": "#3a1c1b", "strokeColor": "#e08e8b", "fontColor": "#f8cecc" }, + "external": { "fillColor": "#2b2b2b", "strokeColor": "#999999", "fontColor": "#e0e0e0" }, + "security": { "fillColor": "#2c2338", "strokeColor": "#b39ddb", "fontColor": "#e1d5e7" } + }, + "edges": { + "sync": { "props": "" }, + "async": { "props": "dashed=1;" }, + "error": { "props": "dashed=1;strokeColor=#ff6b6b;" } + }, + "edgeDefault": { "strokeColor": "#cccccc", "fontColor": "#e0e0e0" }, + "group": { "strokeColor": "#aaaaaa", "fontColor": "#e0e0e0" } + }, + "colorblind-safe": { + "canvasDark": false, + "okabeIto": ["#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"], + "nodes": { + "service": { "fillColor": "#D6E9F5", "strokeColor": "#0072B2", "fontColor": "#000000" }, + "db": { "fillColor": "#D6EFE4", "strokeColor": "#009E73", "fontColor": "#000000" }, + "queue": { "fillColor": "#FCF8CC", "strokeColor": "#F0E442", "fontColor": "#000000" }, + "gateway": { "fillColor": "#FBEBD0", "strokeColor": "#E69F00", "fontColor": "#000000" }, + "error": { "fillColor": "#F7DDCC", "strokeColor": "#D55E00", "fontColor": "#000000" }, + "external": { "fillColor": "#EDEDED", "strokeColor": "#000000", "fontColor": "#000000" }, + "security": { "fillColor": "#F3DEEB", "strokeColor": "#CC79A7", "fontColor": "#000000" } + }, + "edges": { + "sync": { "props": "" }, + "async": { "props": "dashed=1;" }, + "error": { "props": "dashed=1;strokeColor=#D55E00;" } + }, + "edgeDefault": { "strokeColor": "#000000", "fontColor": "#000000" }, + "group": { "strokeColor": "#000000", "fontColor": "#000000" } + } + } +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 5838b61c..b7d80b8b 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -64,6 +64,14 @@ import { } from "./lib/drawio-xml.js"; import { renderDiagramShapes } from "./lib/drawio-preview.js"; import { applyElkLayout } from "./lib/drawio-layout.js"; +import { + buildFromGraph, + type Graph, + type LayoutMode as GraphLayoutMode, +} from "./lib/drawio-graph.js"; +import { applyCellOps, type CellOp } from "./lib/drawio-cell-ops.js"; +import { mermaidToGraph } from "./lib/drawio-mermaid.js"; +import { parseCells as parseDrawioCells } from "./lib/drawio-xml.js"; import { applyTextEdits, TextEdit, @@ -4630,6 +4638,255 @@ export class DocmostClient { }; } + // --- draw.io high-level semantic tools (issue #425) --- + + /** + * ID-based targeted edits of an existing drawio diagram (add / update / delete + * cells) instead of resending the whole XML. Reads the CURRENT diagram, checks + * the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies + * the operations to the parsed model (a `delete` CASCADES to container children + * and to every edge whose source/target is deleted), then runs the SAME #423 + * pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment -> + * repoint the node). Ids are stable so diffs stay meaningful across edits. + */ + async drawioEditCells( + pageId: string, + node: string, + operations: CellOp[], + 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( + "drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", + ); + } + if (!Array.isArray(operations) || operations.length === 0) { + throw new Error( + "drawioEditCells: operations must be a non-empty array of { op, ... }", + ); + } + + const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); + const oldAttrs = drawio.attrs || {}; + const oldSrc = oldAttrs.src; + const nodeId = oldAttrs.id ?? ref; + if (!oldSrc) { + throw new Error( + `drawioEditCells: node "${node}" on page ${pageId} has no src to edit`, + ); + } + const currentSvg = await this.fetchAttachmentText(oldSrc); + const currentModel = decodeDrawioSvg(currentSvg); + const currentHash = mxHash(currentModel); + if (currentHash !== baseHash) { + throw new Error( + `drawioEditCells: conflict — the diagram changed since it was read ` + + `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, + ); + } + + // Apply the operations to the parsed model, then run the standard pipeline. + const editedModel = applyCellOps(currentModel, operations); + const prepared = prepareModel(editedModel); + 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 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 = []; + const hit = getNodeByRef(doc, ref); + if (!hit || hit.type !== "drawio") return null; + 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; + target.attrs = { + ...target.attrs, + src: newSrc, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + }; + repointed++; + 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, + }; + } + + /** + * The main high-level tool: build a diagram from a SEMANTIC graph (nodes with + * a `kind`/`icon`, groups, edges) — the model never supplies coordinates or + * style strings. The server resolves icons via the shape catalog (#424), + * assigns palette colors from the preset, runs ELK layered layout (honouring + * `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups), + * and assembles linter-clean XML, then inserts it through the SAME create + * pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a + * target `node` is given (it preserves that diagram's existing coordinates and + * places only new cells); on a fresh insert it behaves like "full". + */ + async drawioFromGraph( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + graph: Graph, + direction?: "LR" | "RL" | "TB" | "BT", + preset?: string, + layout?: GraphLayoutMode, + node?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + iconsResolved: number; + iconsMissing: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + // Direction/preset supplied as separate params override the graph fields so + // both the flat tool schema and an inline graph can set them. + const merged: Graph = { + ...graph, + direction: direction ?? graph.direction, + preset: preset ?? graph.preset, + }; + const mode: GraphLayoutMode = layout ?? "full"; + + // Incremental into an EXISTING node: read its coords so ELK preserves them, + // and keep the full existing model so incremental MERGES (never drops) any + // cell the new graph doesn't re-list. + let existingCoords: Map | undefined; + let existingModelXml: string | undefined; + let editExisting = false; + let baseHash: string | undefined; + if (node && (mode === "incremental" || mode === "none")) { + const { node: drawio } = await this.resolveDrawioNode(pageId, node); + const src = (drawio.attrs || {}).src; + if (src) { + const svg = await this.fetchAttachmentText(src); + const model = decodeDrawioSvg(svg); + baseHash = mxHash(model); + existingModelXml = model; + existingCoords = new Map(); + for (const c of parseDrawioCells(model)) { + if (c.vertex && c.geometry.x != null && c.geometry.y != null) { + existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y }); + } + } + editExisting = true; + } + } + + const built = await buildFromGraph( + merged, + mode, + existingCoords, + existingModelXml, + ); + + if (editExisting && node && baseHash) { + // Re-target the existing diagram: replace it with the assembled model. + const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash); + return { + ...res, + iconsResolved: built.iconsResolved, + iconsMissing: built.iconsMissing, + }; + } + + const res = await this.drawioCreate(pageId, where, built.modelXml); + return { + ...res, + iconsResolved: built.iconsResolved, + iconsMissing: built.iconsMissing, + }; + } + + /** + * Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE + * parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the + * drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most + * common wiki case); other diagram types throw a clear error so the model can + * fall back to drawioFromGraph. + */ + async drawioFromMermaid( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + mermaid: string, + preset?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + iconsResolved: number; + iconsMissing: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + const graph = mermaidToGraph(mermaid); + if (preset) graph.preset = preset; + return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset); + } + // --- Page history / diff / transform --- /** diff --git a/packages/mcp/src/lib/drawio-cell-ops.ts b/packages/mcp/src/lib/drawio-cell-ops.ts new file mode 100644 index 00000000..032a863a --- /dev/null +++ b/packages/mcp/src/lib/drawio-cell-ops.ts @@ -0,0 +1,168 @@ +// ID-based cell operations for `drawioEditCells` (issue #425, stage 3). +// +// Instead of resending the whole XML (whose diff is fragile — draw.io reorders +// attributes, and a {search,replace} text match breaks on it), the model sends +// targeted operations keyed by cell id: +// +// { op: "add", xml: "" } // append a new cell +// { op: "update", cellId: "n3", xml: "" } // replace that cell +// { op: "delete", cellId: "n5" } // + CASCADE +// +// `delete` CASCADES: it removes the cell, every descendant cell whose parent +// chain leads to it (container children), AND every edge whose source or target +// is any deleted cell. Ids are STABLE across edits so diffs stay meaningful. +// +// Operations apply to the parsed DOM of the current model; the caller re-lints +// and rebuilds the .drawio.svg through the existing #423 pipeline afterwards. + +import { JSDOM } from "jsdom"; + +let _window: any = null; +function xmlWindow(): any { + if (!_window) _window = new JSDOM("").window; + return _window; +} + +export type CellOp = + | { op: "add"; xml: string } + | { op: "update"; cellId: string; xml: string } + | { op: "delete"; cellId: string }; + +export class CellOpsError extends Error { + constructor(message: string) { + super(`drawioEditCells: ${message}`); + this.name = "CellOpsError"; + } +} + +// The mxGraph root sentinels. id="0" is the graph root; id="1" is the default +// layer that parents every real cell. A delete targeting either would cascade +// through the whole diagram body (every cell chains up to "1"), so such an op is +// rejected outright. +const SENTINEL_IDS = new Set(["0", "1"]); + +/** Parse a single `` fragment into an element, or throw. */ +function parseCellFragment(xml: string): any { + const parser = new (xmlWindow().DOMParser)(); + // Wrap so a self-closed or child-bearing single cell parses as one root. + const doc = parser.parseFromString(`${xml}`, "application/xml"); + if (doc.getElementsByTagName("parsererror").length > 0) { + throw new CellOpsError(`operation xml is not well-formed: ${xml.slice(0, 120)}`); + } + const cells = doc.getElementsByTagName("mxCell"); + if (cells.length !== 1) { + throw new CellOpsError( + `each add/update op must carry exactly one (got ${cells.length})`, + ); + } + return cells[0]; +} + +/** All ids reachable as descendants of `rootId` via the parent relation. */ +function collectDescendants( + rootId: string, + parentOf: Map, +): Set { + const doomed = new Set([rootId]); + let grew = true; + while (grew) { + grew = false; + for (const [id, parent] of parentOf) { + if (!doomed.has(id) && parent != null && doomed.has(parent)) { + doomed.add(id); + grew = true; + } + } + } + return doomed; +} + +/** + * Apply the operation list to a model XML string and return the new model XML. + * Uses the DOM so attribute order / formatting is preserved for untouched cells. + * Throws CellOpsError on an unknown target id or a malformed op fragment (so the + * model gets a precise error and nothing is half-applied). + */ +export function applyCellOps(modelXml: string, ops: CellOp[]): string { + if (!Array.isArray(ops) || ops.length === 0) { + throw new CellOpsError("operations must be a non-empty array"); + } + const parser = new (xmlWindow().DOMParser)(); + const doc = parser.parseFromString(modelXml, "application/xml"); + if (doc.getElementsByTagName("parsererror").length > 0) { + throw new CellOpsError("the current diagram XML is not well-formed"); + } + const root = doc.getElementsByTagName("root")[0]; + if (!root) throw new CellOpsError("the current diagram has no element"); + + const cellEls = () => Array.from(root.getElementsByTagName("mxCell")) as any[]; + const byId = () => { + const m = new Map(); + for (const el of cellEls()) m.set(el.getAttribute("id") ?? "", el); + return m; + }; + + for (const op of ops) { + if (op.op === "add") { + const frag = parseCellFragment(op.xml); + const id = frag.getAttribute("id"); + if (!id) throw new CellOpsError("an add op's is missing an id"); + if (byId().has(id)) + throw new CellOpsError(`add op id "${id}" already exists (use update)`); + root.appendChild(doc.importNode(frag, true)); + } else if (op.op === "update") { + const map = byId(); + const target = map.get(op.cellId); + if (!target) + throw new CellOpsError(`update target cell "${op.cellId}" does not exist`); + const frag = parseCellFragment(op.xml); + const newId = frag.getAttribute("id"); + if (newId && newId !== op.cellId) + throw new CellOpsError( + `update op cellId "${op.cellId}" != the id "${newId}" (ids are stable)`, + ); + // Replace the element in place so surrounding cells are untouched. + const imported = doc.importNode(frag, true); + target.parentNode.replaceChild(imported, target); + } else if (op.op === "delete") { + // Reject a sentinel-targeted delete BEFORE collecting descendants: "0"/"1" + // parent the entire diagram, so a cascade from either would wipe the whole + // model body (doomed.delete("0"/"1") only spared the sentinel itself, not + // its children). + if (SENTINEL_IDS.has(op.cellId)) + throw new CellOpsError( + `cannot delete sentinel cell "${op.cellId}" (the graph root/default layer)`, + ); + const map = byId(); + if (!map.has(op.cellId)) + throw new CellOpsError(`delete target cell "${op.cellId}" does not exist`); + // Build the parent relation over the CURRENT cells for the cascade. + const parentOf = new Map(); + for (const el of cellEls()) { + parentOf.set(el.getAttribute("id") ?? "", el.getAttribute("parent") ?? undefined); + } + const doomed = collectDescendants(op.cellId, parentOf); + // Cascade to edges whose source/target is any doomed cell. + for (const el of cellEls()) { + if (el.getAttribute("edge") !== "1") continue; + const src = el.getAttribute("source"); + const tgt = el.getAttribute("target"); + if ((src && doomed.has(src)) || (tgt && doomed.has(tgt))) { + doomed.add(el.getAttribute("id") ?? ""); + } + } + // Never delete the sentinels even if referenced by a malformed op. + doomed.delete("0"); + doomed.delete("1"); + for (const el of cellEls()) { + const id = el.getAttribute("id") ?? ""; + if (doomed.has(id)) el.parentNode.removeChild(el); + } + } else { + throw new CellOpsError(`unknown op "${(op as any).op}"`); + } + } + + const ser = new (xmlWindow().XMLSerializer)(); + return ser.serializeToString(doc.documentElement); +} diff --git a/packages/mcp/src/lib/drawio-graph.ts b/packages/mcp/src/lib/drawio-graph.ts new file mode 100644 index 00000000..5e8878bd --- /dev/null +++ b/packages/mcp/src/lib/drawio-graph.ts @@ -0,0 +1,916 @@ +// Semantic graph -> draw.io pipeline for `drawioFromGraph` (issue #425, stage 3). +// +// The model describes a diagram SEMANTICALLY — nodes with a `kind` and an +// optional `icon`, groups (containers), edges with a `kind` — and NEVER sees a +// coordinate or a style string. This module owns the whole server-side pipeline: +// +// 1. validateGraph — a hand-written validator (no zod dependency, so this +// lib stays importable by client.ts without coupling to +// a zod major) that rejects malformed graphs early. +// 2. resolveNodeStyle — `icon` -> exact style via the shape catalog (#424); +// an UNKNOWN icon degrades to a generic shape by `kind` +// WITH the label (never an empty square). `kind` -> the +// preset palette slot. +// 3. graphToElk — graph -> ELK-JSON, honouring the layout hints +// (`layer`/`sameLayerAs` -> layer constraints, `pinned` +// -> a fixed node) and compound group nodes. +// 4. assembleModel — graph + ELK coordinates -> a full mxGraphModel XML +// that satisfies the #423 linter BY CONSTRUCTION +// (sentinels, transparent containers, relative child +// coords, cross-container edges parent="1", >=150px +// gaps from ELK spacing, escaped labels). +// +// The `layout` mode: "full" re-lays everything; "incremental" fixes existing +// coordinates (ELK interactive mode) and places only new nodes; "none" keeps the +// caller-provided/prior coordinates untouched. + +import ELK from "elkjs/lib/elk.bundled.js"; +import { JSDOM } from "jsdom"; +import { + searchShapes, + awsServiceStyle, + type ShapeResult, +} from "./drawio-shapes.js"; +import { + getPreset, + genericNodeStyle, + iconNodeStyle, + edgeStyle, + groupStyle, + type PresetData, +} from "./drawio-presets.js"; +import { MIN_SHAPE_GAP } from "./drawio-xml.js"; + +// --- graph schema (plain TS + a hand validator) ---------------------------- + +export interface GraphNode { + id: string; + label: string; + kind?: string; + /** Icon reference, e.g. "aws:lambda" | "azure:cosmos" | "lambda". */ + icon?: string; + /** Group (container) id this node belongs to. */ + group?: string; + /** Layer hint (ELK layerChoiceConstraint): 0-based column/row index. */ + layer?: number; + /** Put this node in the same layer as another node id. */ + sameLayerAs?: string; + /** Fix this node at exact coordinates (an ELK fixed node). */ + pinned?: { x: number; y: number }; +} + +export interface GraphGroup { + id: string; + label: string; + kind?: string; + /** Parent group id — lets a group nest inside another group (e.g. subnet in VPC). */ + group?: string; +} + +export interface GraphEdge { + from: string; + to: string; + label?: string; + kind?: string; +} + +export interface Graph { + nodes: GraphNode[]; + groups?: GraphGroup[]; + edges?: GraphEdge[]; + direction?: "LR" | "RL" | "TB" | "BT"; + preset?: string; +} + +export type LayoutMode = "none" | "full" | "incremental"; + +/** A structured validation error (mirrors the drawio linter's shape loosely). */ +export class GraphValidationError extends Error { + issues: string[]; + constructor(issues: string[]) { + super(`drawioFromGraph: invalid graph — ${issues.join("; ")}`); + this.name = "GraphValidationError"; + this.issues = issues; + } +} + +const MAX_GRAPH_NODES = 500; // parity with drawio-layout's ELK_MAX_NODES. +// Edge/group caps mirror drawio-layout's ELK_MAX_EDGES. Without an edge cap a +// tiny node set with a huge edge list (e.g. 500 nodes / 200000 edges) passes +// node validation, then graphToElk/runElk exhausts the heap SYNCHRONOUSLY inside +// elk.bundled.js — before the 5s ELK timeout can fire and OUTSIDE it entirely +// for the mapper/assembler — crashing the worker on LLM-authored input. Reject +// the over-limit shape here, before any layout or assembly runs. +export const MAX_GRAPH_EDGES = 1000; // parity with drawio-layout's ELK_MAX_EDGES. +export const MAX_GRAPH_GROUPS = 500; // groups are compound ELK nodes; bound them too. + +/** + * Validate the graph structure BEFORE any layout/assembly so the model gets a + * precise, actionable error instead of a corrupt diagram. Throws + * GraphValidationError listing every problem. + */ +export function validateGraph(graph: Graph): void { + const issues: string[] = []; + if (!graph || typeof graph !== "object") { + throw new GraphValidationError(["graph must be an object"]); + } + if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) { + throw new GraphValidationError(["graph.nodes must be a non-empty array"]); + } + // Size caps FIRST (fail fast, before touching per-element loops) so an + // over-limit graph can never reach the layout engine and OOM the worker. + if (graph.nodes.length > MAX_GRAPH_NODES) { + throw new GraphValidationError([ + `graph has ${graph.nodes.length} nodes (max ${MAX_GRAPH_NODES})`, + ]); + } + if (Array.isArray(graph.edges) && graph.edges.length > MAX_GRAPH_EDGES) { + throw new GraphValidationError([ + `graph has ${graph.edges.length} edges (max ${MAX_GRAPH_EDGES})`, + ]); + } + if (Array.isArray(graph.groups) && graph.groups.length > MAX_GRAPH_GROUPS) { + throw new GraphValidationError([ + `graph has ${graph.groups.length} groups (max ${MAX_GRAPH_GROUPS})`, + ]); + } + + const nodeIds = new Set(); + const groupIds = new Set(); + for (const g of graph.groups ?? []) { + if (!g.id) issues.push("a group is missing its id"); + else if (groupIds.has(g.id)) issues.push(`duplicate group id "${g.id}"`); + groupIds.add(g.id); + } + for (const n of graph.nodes) { + if (!n.id) issues.push("a node is missing its id"); + else if (nodeIds.has(n.id)) issues.push(`duplicate node id "${n.id}"`); + else if (groupIds.has(n.id)) + issues.push(`node id "${n.id}" collides with a group id`); + nodeIds.add(n.id); + if (typeof n.label !== "string" || n.label === "") + issues.push(`node "${n.id}" is missing a label`); + if (n.group != null && !groupIds.has(n.group)) + issues.push(`node "${n.id}" references unknown group "${n.group}"`); + if (n.pinned != null) { + if ( + typeof n.pinned.x !== "number" || + typeof n.pinned.y !== "number" || + !Number.isFinite(n.pinned.x) || + !Number.isFinite(n.pinned.y) + ) + issues.push(`node "${n.id}" has an invalid pinned {x,y}`); + } + if (n.layer != null && (!Number.isInteger(n.layer) || n.layer < 0)) + issues.push(`node "${n.id}" has an invalid layer (must be a >=0 integer)`); + } + // sameLayerAs must reference an existing node (checked after all ids known). + for (const n of graph.nodes) { + if (n.sameLayerAs != null && !nodeIds.has(n.sameLayerAs)) + issues.push( + `node "${n.id}" sameLayerAs references unknown node "${n.sameLayerAs}"`, + ); + } + for (const e of graph.edges ?? []) { + if (!e.from || !e.to) { + issues.push("an edge is missing from/to"); + continue; + } + if (!nodeIds.has(e.from) && !groupIds.has(e.from)) + issues.push(`edge from "${e.from}" resolves to no node/group`); + if (!nodeIds.has(e.to) && !groupIds.has(e.to)) + issues.push(`edge to "${e.to}" resolves to no node/group`); + } + if (issues.length > 0) throw new GraphValidationError(issues); +} + +// --- icon resolution ------------------------------------------------------- + +/** + * Resolve a node's `icon` reference to a concrete style-string + size via the + * shape catalog. Accepts "aws:lambda", "azure:cosmos", or a bare "lambda". An + * AWS `resIcon` name is built directly (exact service-icon template). Anything + * else goes through searchShapes. Returns null when nothing resolves — the + * caller then falls back to a generic shape by kind (never an empty box). + */ +export function resolveIcon(icon: string): ShapeResult | null { + const raw = icon.trim(); + if (raw === "") return null; + let provider = ""; + let name = raw; + const colon = raw.indexOf(":"); + if (colon !== -1) { + provider = raw.slice(0, colon).trim().toLowerCase(); + name = raw.slice(colon + 1).trim(); + } + + if (provider === "aws") { + // Prefer an exact resIcon match from the catalog (carries the right size and + // any rebrand/blocklist note); if the underscore/space name doesn't hit, + // build the canonical service-icon style directly so it is never an empty box. + const results = searchShapes(name.replace(/_/g, " "), { limit: 5 }); + const aws4 = results.find((r) => r.style.includes("mxgraph.aws4")); + if (aws4) return aws4; + return { + style: awsServiceStyle(name.replace(/\s+/g, "_")), + w: 78, + h: 78, + title: name, + type: "vertex", + }; + } + + // Non-AWS or bare name: fuzzy search the catalog. Take the top vertex hit, + // but REJECT a weak match (the fuzzy scorer can prefix-match an unrelated + // stencil, e.g. "not..." -> "Notebook"); require the hit's title to actually + // share a meaningful token with the query, otherwise degrade to generic-by-kind. + const q = provider ? `${provider} ${name}` : name; + const results = searchShapes(q, { limit: 8 }); + const hit = results.find((r) => r.type !== "edge") ?? results[0]; + if (!hit) return null; + if (!isRelevantMatch(name, hit.title)) return null; + return hit; +} + +/** + * Whether a resolved stencil is a genuine match for the requested icon name (as + * opposed to a loose prefix hit on an unrelated shape). True if any 3+ char + * token of the query appears in the stencil title, or vice-versa. + */ +function isRelevantMatch(name: string, title: string): boolean { + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const qTokens = norm(name).split(/\s+/).filter((t) => t.length >= 3); + if (qTokens.length === 0) return true; // very short names: trust the scorer + const t = norm(title); + const tTokens = new Set(t.split(/\s+/)); + for (const qt of qTokens) { + if (tTokens.has(qt)) return true; + if (t.includes(qt)) return true; + for (const tt of tTokens) if (tt.length >= 3 && qt.includes(tt)) return true; + } + return false; +} + +/** + * Decide the final style + size for a node. When `icon` resolves, use the icon + * style (overlaid with a dark-preset font fix); otherwise a GENERIC shape by + * `kind` carrying the label. `resolved` reports whether an icon was found (used + * by the acceptance test that asserts no empty squares). + */ +export function resolveNodeStyle( + preset: PresetData, + node: GraphNode, +): { style: string; w: number; h: number; iconResolved: boolean } { + if (node.icon) { + const shape = resolveIcon(node.icon); + if (shape) { + return { + style: iconNodeStyle(preset, shape.style), + w: shape.w, + h: shape.h, + iconResolved: true, + }; + } + } + // Generic shape by kind, sized to the label so a long label never overflows. + const w = Math.max(120, estimateLabelWidth(node.label) + 32); + return { style: genericNodeStyle(preset, node.kind), w, h: 60, iconResolved: false }; +} + +/** Rough rendered width of the longest label line at 12px (~0.6em/glyph). */ +function estimateLabelWidth(label: string): number { + const lines = label.split(/\r?\n| |/i); + let longest = 0; + for (const l of lines) longest = Math.max(longest, l.trim().length); + return Math.ceil(longest * 12 * 0.6); +} + +// --- graph -> ELK-JSON ----------------------------------------------------- + +interface ElkNode { + id: string; + width?: number; + height?: number; + x?: number; + y?: number; + children?: ElkNode[]; + layoutOptions?: Record; +} +interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; +} +interface ElkGraph extends ElkNode { + edges?: ElkEdge[]; +} + +const ELK_DIRECTION: Record = { + LR: "RIGHT", + RL: "LEFT", + TB: "DOWN", + BT: "UP", +}; + +/** Sizes resolved per node id (from resolveNodeStyle), fed to the ELK mapper. */ +export interface NodeSize { + w: number; + h: number; +} + +/** + * Build the ELK graph from the semantic graph + resolved node sizes. Compound + * group nodes nest their members (a group may itself nest in another group). + * `only` restricts the graph to a subset of node ids (used by the incremental + * path to lay out ONLY the new nodes). Layout HINTS (`layer`/`sameLayerAs`/ + * `pinned`) are NOT encoded as ELK constraints here — ELK's constraint knobs are + * unreliable across versions — they are enforced deterministically AFTER layout + * by applyHints, which is exact and testable. + */ +export function graphToElk( + graph: Graph, + sizes: Map, + opts: { only?: Set } = {}, +): ElkGraph { + const direction = ELK_DIRECTION[graph.direction ?? "LR"] ?? "RIGHT"; + const only = opts.only; + const include = (id: string) => !only || only.has(id); + + const makeNode = (n: GraphNode): ElkNode => { + const size = sizes.get(n.id) ?? { w: 140, h: 60 }; + return { id: n.id, width: size.w, height: size.h }; + }; + + // Group children nest under their group node; ungrouped nodes are roots. + const groupNode = new Map(); + const usedGroups = new Set(); + for (const g of graph.groups ?? []) { + const size = sizes.get(g.id) ?? { w: 200, h: 150 }; + groupNode.set(g.id, { + id: g.id, + width: size.w, + height: size.h, + children: [], + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": direction, + "elk.padding": "[top=40,left=30,bottom=30,right=30]", + "elk.layered.spacing.nodeNodeBetweenLayers": "170", + "elk.spacing.nodeNode": "170", + }, + }); + } + const roots: ElkNode[] = []; + for (const n of graph.nodes) { + if (!include(n.id)) continue; + const en = makeNode(n); + if (n.group && groupNode.has(n.group)) { + groupNode.get(n.group)!.children!.push(en); + usedGroups.add(n.group); + } else { + roots.push(en); + } + } + // Nest group nodes into their parent group (a subnet inside a VPC); groups + // with no parent group become roots. Only groups that hold an included node. + const groupIdSet = new Set((graph.groups ?? []).map((g) => g.id)); + for (const g of graph.groups ?? []) { + if (only && !usedGroups.has(g.id)) continue; + const en = groupNode.get(g.id)!; + if (g.group && groupIdSet.has(g.group) && g.group !== g.id && (!only || usedGroups.has(g.group))) { + groupNode.get(g.group)!.children!.push(en); + } else { + roots.push(en); + } + } + + // Edges: endpoints may be nodes or groups; INCLUDE_CHILDREN spans the nesting. + const validIds = new Set([ + ...graph.nodes.filter((n) => include(n.id)).map((n) => n.id), + ...(graph.groups ?? []).map((g) => g.id), + ]); + const edges: ElkEdge[] = []; + (graph.edges ?? []).forEach((e, i) => { + if (!validIds.has(e.from) || !validIds.has(e.to)) return; + edges.push({ id: `e${i}`, sources: [e.from], targets: [e.to] }); + }); + + const rootOptions: Record = { + "elk.algorithm": "layered", + "elk.direction": direction, + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + "elk.layered.spacing.nodeNodeBetweenLayers": "170", + "elk.spacing.nodeNode": "170", + "elk.spacing.edgeNode": "40", + "elk.spacing.edgeEdge": "30", + "elk.padding": "[top=20,left=20,bottom=20,right=20]", + }; + + return { id: "root", layoutOptions: rootOptions, children: roots, edges }; +} + +/** + * Enforce the layout hints DETERMINISTICALLY on ELK's output (mutates `geo`): + * - `sameLayerAs`: snap the dependent node's LAYER-AXIS coordinate to its + * anchor's, so the pair lands in the same layer (x for LR/RL, y for TB/BT). + * `layer` groups nodes with the same index onto the same anchor coordinate. + * - `pinned`: override the node's coordinate with the exact pinned {x,y}. + * Applied only to top-level (ungrouped) nodes, whose ELK coords are absolute. + */ +export function applyHints( + graph: Graph, + geo: Map, +): void { + const dir = graph.direction ?? "LR"; + const layerAxis: "x" | "y" = dir === "TB" || dir === "BT" ? "y" : "x"; + // The perpendicular (cross-layer) axis: members snapped onto one layer must be + // spread along THIS axis so they don't stack onto the same point. + const crossAxis: "x" | "y" = layerAxis === "x" ? "y" : "x"; + const crossSize: "w" | "h" = crossAxis === "x" ? "w" : "h"; + const grouped = new Set( + graph.nodes.filter((n) => n.group).map((n) => n.id), + ); + + // sameLayerAs / layer: co-assign the layer-axis coordinate. + // Build the effective layer key per node, then pick a representative coord. + const layerKeyOf = new Map(); + const explicitLayer = new Map(); + for (const n of graph.nodes) if (n.layer != null) explicitLayer.set(n.id, n.layer); + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const resolveKey = (n: GraphNode): string | null => { + if (explicitLayer.has(n.id)) return `L${explicitLayer.get(n.id)}`; + const seen = new Set([n.id]); + let cur: GraphNode | undefined = n; + while (cur && cur.sameLayerAs != null && !seen.has(cur.sameLayerAs)) { + seen.add(cur.sameLayerAs); + const t = byId.get(cur.sameLayerAs); + if (!t) break; + if (explicitLayer.has(t.id)) return `L${explicitLayer.get(t.id)}`; + cur = t; + } + // A sameLayerAs chain with no explicit layer: key on the chain's root id. + if (n.sameLayerAs != null) { + let root = n.id; + const s2 = new Set([n.id]); + let c: GraphNode | undefined = n; + while (c && c.sameLayerAs != null && !s2.has(c.sameLayerAs)) { + s2.add(c.sameLayerAs); + root = c.sameLayerAs; + c = byId.get(c.sameLayerAs); + } + return `C${root}`; + } + return null; + }; + for (const n of graph.nodes) { + if (grouped.has(n.id)) continue; // group children are relative — skip + const key = resolveKey(n); + if (key) layerKeyOf.set(n.id, key); + } + // Group members of each layer key so we can snap AND spread them together. + const membersOf = new Map(); + for (const n of graph.nodes) { + const key = layerKeyOf.get(n.id); + if (key == null) continue; + if (!geo.has(n.id)) continue; + (membersOf.get(key) ?? membersOf.set(key, []).get(key)!).push(n.id); + } + // For each layer key: snap every member to the FIRST member's layer-axis coord, + // then SPREAD them along the perpendicular (cross-layer) axis with a >= + // MIN_SHAPE_GAP gap. Without the spread, a sameLayerAs chain whose nodes ELK + // happened to give the same cross-axis coordinate would collapse onto one point + // -> shape-overlap + edge-through-shape quality warnings (breaking the + // "0 warnings by construction" guarantee for these AUTO-positioned hints). We + // start from the members' minimum cross-axis coord and stack them with a gap + // of MIN_SHAPE_GAP beyond each shape's cross-axis size. + for (const [key, members] of membersOf) { + if (members.length === 0) continue; + // Snap layer-axis coord to the first member. + const repCoord = geo.get(members[0])![layerAxis]; + // Preserve the members' existing relative order along the cross axis so the + // spread stays visually stable, then re-lay them contiguously. + const sorted = [...members].sort( + (a, b) => geo.get(a)![crossAxis] - geo.get(b)![crossAxis], + ); + let cursor = geo.get(sorted[0])![crossAxis]; + for (const id of sorted) { + const g = geo.get(id)!; + g[layerAxis] = repCoord; + g[crossAxis] = cursor; + cursor += g[crossSize] + MIN_SHAPE_GAP; + } + void key; + } + + // pinned: exact override (wins over any layer snap). Explicit user coordinates + // are user intent, but CLAMP to non-negative so an out-of-bounds pin (e.g. + // x:-500) never renders off-canvas. Two user-pinned nodes at the same point is + // user error the server can't silently relocate — the assembler docstring + // documents that explicit pins are user-directed and MAY warn (see #423/#425 + // acceptance: the "0 quality-warnings by construction" guarantee is for + // AUTO-LAYOUT, not for coordinates the user pinned by hand). + for (const n of graph.nodes) { + if (!n.pinned) continue; + const px = Math.max(0, n.pinned.x); + const py = Math.max(0, n.pinned.y); + const g = geo.get(n.id); + if (g) { + g.x = px; + g.y = py; + } else { + const sz = { w: 140, h: 60 }; + geo.set(n.id, { x: px, y: py, w: sz.w, h: sz.h }); + } + } +} + +/** + * Incremental variant of applyHints: apply `pinned` only to NEW nodes (those + * absent from `existing`); an existing node's coordinates are NEVER changed + * (acceptance #3). sameLayerAs/layer snapping is intentionally skipped in the + * incremental path — moving a new node's layer axis could still be desired, but + * it must never move an existing cell, so we keep the incremental contract + * simple: existing cells are frozen, new pinned nodes honour their pin. + */ +export function applyHintsForNew( + graph: Graph, + geo: Map, + existing: Map, +): void { + for (const n of graph.nodes) { + if (existing.has(n.id)) continue; // never move an existing cell + if (!n.pinned) continue; + const px = Math.max(0, n.pinned.x); // clamp out-of-bounds pins non-negative + const py = Math.max(0, n.pinned.y); + const g = geo.get(n.id); + if (g) { + g.x = px; + g.y = py; + } else { + geo.set(n.id, { x: px, y: py, w: 140, h: 60 }); + } + } +} + +// --- layout runner --------------------------------------------------------- + +const ELK_TIMEOUT_MS = 5000; + +/** + * Run ELK over the mapped graph and return computed geometry per id (coords are + * parent-relative, matching mxGraph's convention for container children). On any + * ELK failure/timeout the returned map is empty and the caller falls back to a + * deterministic grid placement (so the write never fails on a layout hiccup). + */ +export async function runElk( + elk: ElkGraph, +): Promise> { + const geo = new Map(); + let timer: ReturnType | undefined; + try { + const Ctor: any = (ELK as any).default ?? ELK; + const inst = new Ctor(); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("ELK timed out")), ELK_TIMEOUT_MS); + }); + const laid = (await Promise.race([inst.layout(elk as any), timeout])) as ElkGraph; + const walk = (n: ElkNode) => { + if (n.id !== "root") { + geo.set(n.id, { + x: Math.round(n.x ?? 0), + y: Math.round(n.y ?? 0), + w: Math.round(n.width ?? 140), + h: Math.round(n.height ?? 60), + }); + } + for (const c of n.children ?? []) walk(c); + }; + walk(laid); + } catch { + return new Map(); // best-effort: empty -> caller uses fallback grid. + } finally { + if (timer) clearTimeout(timer); + } + return geo; +} + +// --- XML assembler --------------------------------------------------------- + +/** Order groups so a parent group always precedes its nested children. */ +function topoSortGroups(groups: GraphGroup[], groupIds: Set): GraphGroup[] { + const byId = new Map(groups.map((g) => [g.id, g])); + const out: GraphGroup[] = []; + const done = new Set(); + const visit = (g: GraphGroup, stack: Set) => { + if (done.has(g.id)) return; + if (stack.has(g.id)) return; // cycle guard + stack.add(g.id); + if (g.group && groupIds.has(g.group) && g.group !== g.id) { + const parent = byId.get(g.group); + if (parent) visit(parent, stack); + } + stack.delete(g.id); + if (!done.has(g.id)) { + done.add(g.id); + out.push(g); + } + }; + for (const g of groups) visit(g, new Set()); + return out; +} + +function xmlEscapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/\r\n|\r|\n/g, " "); // literal newline -> the linter-approved entity +} + +export interface AssembleResult { + modelXml: string; + iconsResolved: number; + iconsMissing: string[]; +} + +/** + * Assemble the final mxGraphModel XML from the graph + resolved styles/coords. + * Guarantees BY CONSTRUCTION that the #423 linter passes: + * - id=0 and id=1(parent=0) sentinels; + * - each node/group is vertex="1" (containers get container=1 via the style); + * - each edge is edge="1" with a child ; + * - group children set parent= and RELATIVE coords; an edge between + * two different parents is parent="1"; + * - labels are XML-escaped and any newline is . + * `geo` may be empty (ELK failed) — then a deterministic grid is used so the + * output is still valid and non-overlapping (>=170px stride). + * + * QUALITY-WARNING GUARANTEE: the "0 quality-warnings by construction" promise + * holds for AUTO-LAYOUT — ELK spacing plus applyHints' cross-axis spread for the + * server-positioned `layer`/`sameLayerAs` hints keep shapes >=MIN_SHAPE_GAP + * apart. It does NOT extend to explicit `pinned` coordinates: those are + * user-directed, so two nodes the user pins to the same/overlapping point are + * user error the server honours verbatim (only clamped non-negative) and MAY + * therefore produce a quality warning. + */ +export function assembleModel( + graph: Graph, + opts: { + preset: PresetData; + styles: Map; + geo: Map; + }, +): AssembleResult { + const { preset, styles, geo } = opts; + const groupIds = new Set((graph.groups ?? []).map((g) => g.id)); + const nodeById = new Map(graph.nodes.map((n) => [n.id, n])); + + // Fallback grid when ELK produced nothing: lay ungrouped nodes on a grid with + // a 190px stride (>150 gap). Grouped nodes/groups are placed inside their group. + const fallback = geo.size === 0; + const gridPos = (i: number) => ({ x: 40 + (i % 5) * 200, y: 40 + Math.floor(i / 5) * 140 }); + + const cells: string[] = ['', '']; + + // Groups first (they are parents of their members). A nested group sets + // parent=; emit parents before children so parent-exists holds. + let gi = 0; + const groupGeo = new Map(); + const orderedGroups = topoSortGroups(graph.groups ?? [], groupIds); + for (const g of orderedGroups) { + const gg = geo.get(g.id) ?? { ...gridPos(gi++), w: 320, h: 220 }; + groupGeo.set(g.id, gg); + const style = groupStyle(preset); + const gParent = g.group && groupIds.has(g.group) && g.group !== g.id ? g.group : "1"; + cells.push( + `` + + ``, + ); + } + + // Nodes. A grouped node's coords are RELATIVE to its group (ELK already + // returns child coords relative to the parent; for the fallback grid we place + // children on a small in-group grid). + let ungrouped = (graph.groups?.length ?? 0); + const inGroupIndex = new Map(); + let iconsResolved = 0; + const iconsMissing: string[] = []; + for (const n of graph.nodes) { + const st = styles.get(n.id)!; + if (n.icon) { + if (st.iconResolved) iconsResolved++; + else iconsMissing.push(n.id); + } + let x: number; + let y: number; + const g = geo.get(n.id); + if (g && !fallback) { + x = g.x; + y = g.y; + } else if (n.group && groupIds.has(n.group)) { + const k = inGroupIndex.get(n.group) ?? 0; + inGroupIndex.set(n.group, k + 1); + x = 30 + (k % 3) * 180; + y = 40 + Math.floor(k / 3) * 120; + } else { + const p = gridPos(ungrouped++); + x = p.x; + y = p.y; + } + const parent = n.group && groupIds.has(n.group) ? n.group : "1"; + cells.push( + `` + + ``, + ); + } + + // Edges. parent="1" whenever the two endpoints have different container + // parents (or either is a group); otherwise the shared group id. + (graph.edges ?? []).forEach((e, i) => { + const style = edgeStyle(preset, e.kind); + const fromNode = nodeById.get(e.from); + const toNode = nodeById.get(e.to); + const fromParent = fromNode?.group && groupIds.has(fromNode.group) ? fromNode.group : "1"; + const toParent = toNode?.group && groupIds.has(toNode.group) ? toNode.group : "1"; + const parent = fromParent === toParent ? fromParent : "1"; + const label = e.label ? ` value="${xmlEscapeAttr(e.label)}"` : ""; + cells.push( + `` + + ``, + ); + }); + + const modelAttrs = + 'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100" adaptiveColors="auto"'; + const modelXml = `${cells.join("")}`; + return { modelXml, iconsResolved, iconsMissing }; +} + +// --- incremental merge ----------------------------------------------------- + +let _mergeWindow: any = null; +function mergeWindow(): any { + if (!_mergeWindow) _mergeWindow = new JSDOM("").window; + return _mergeWindow; +} + +/** + * Merge the freshly-assembled graph XML with the EXISTING diagram model so an + * incremental "add a node" call never drops a hand-placed cell. `assembleModel` + * emits ONLY the passed graph's cells; on its own it would replace the whole + * model, wiping any existing cell the caller didn't re-list. This splices every + * existing cell that the graph does NOT re-list (preserved verbatim: coords, + * style, edges) into the assembled root: + * - id in the graph -> the graph's (re-laid) cell wins (already assembled; + * coords are frozen for existing ids via the incremental geo path); + * - id NOT in the graph -> the existing cell is preserved verbatim; + * - a graph node absent from the existing model -> added (offset clear). + * The sentinels ("0"/"1") come from the assembled model and are never doubled. + */ +function mergeExistingCells( + assembledXml: string, + existingModelXml: string, + graph: Graph, +): string { + const win = mergeWindow(); + const parser = new win.DOMParser(); + const existingDoc = parser.parseFromString(existingModelXml, "application/xml"); + if (existingDoc.getElementsByTagName("parsererror").length > 0) { + // Existing model unreadable: fall back to the assembled model alone (still a + // valid diagram — better than throwing on a corrupt prior file). + return assembledXml; + } + const assembledDoc = parser.parseFromString(assembledXml, "application/xml"); + const root = assembledDoc.getElementsByTagName("root")[0]; + if (!root) return assembledXml; + + // Ids the assembled model already emitted (graph nodes/groups/edges + sentinels). + const assembledIds = new Set(); + for (const el of Array.from(root.getElementsByTagName("mxCell")) as any[]) { + const id = el.getAttribute("id"); + if (id) assembledIds.add(id); + } + // The graph's own ids: any existing cell with one of these is superseded by the + // assembled version and must NOT be re-imported. + const graphIds = new Set([ + ...graph.nodes.map((n) => n.id), + ...(graph.groups ?? []).map((g) => g.id), + ]); + + const existingCells = Array.from( + existingDoc.getElementsByTagName("mxCell"), + ) as any[]; + for (const el of existingCells) { + const id = el.getAttribute("id") ?? ""; + if (id === "0" || id === "1") continue; // sentinels come from the assembled model + if (graphIds.has(id)) continue; // graph re-lists it -> assembled version wins + if (assembledIds.has(id)) continue; // id collision guard -> keep assembled + root.appendChild(assembledDoc.importNode(el, true)); + assembledIds.add(id); + } + + const ser = new win.XMLSerializer(); + return ser.serializeToString(assembledDoc.documentElement); +} + +// --- top-level: graph -> mxGraphModel XML ---------------------------------- + +export interface BuildFromGraphResult { + modelXml: string; + iconsResolved: number; + iconsMissing: string[]; + layout: LayoutMode; +} + +/** + * The full server-side pipeline: validate -> resolve styles/icons -> map to ELK + * -> run ELK (or fall back) -> assemble linter-clean XML. `existingCoords` is + * supplied for `layout:"incremental"` (the coordinates of the diagram's current + * cells, so they are preserved and only new nodes are placed). `existingModelXml` + * is the current diagram's full model XML — in incremental mode every existing + * cell the graph does NOT re-list is MERGED back in verbatim so a hand-placed + * cell is never dropped (WARNING #4). Pure — no network. + */ +export async function buildFromGraph( + graph: Graph, + layout: LayoutMode = "full", + existingCoords?: Map, + existingModelXml?: string, +): Promise { + validateGraph(graph); + const preset = getPreset(graph.preset); + + // Resolve every node's style + size (icon or generic-by-kind). + const styles = new Map< + string, + { style: string; w: number; h: number; iconResolved: boolean } + >(); + const sizes = new Map(); + for (const n of graph.nodes) { + const s = resolveNodeStyle(preset, n); + styles.set(n.id, s); + sizes.set(n.id, { w: s.w, h: s.h }); + } + // Group sizes: seed a min box; ELK computes the real size when it lays out. + for (const g of graph.groups ?? []) sizes.set(g.id, { w: 240, h: 180 }); + + let geo = new Map(); + + if (layout === "incremental" && existingCoords && existingCoords.size > 0) { + // INCREMENTAL: keep every existing cell's coords VERBATIM (acceptance #3 — + // never move a hand-arranged cell) and lay out ONLY the new nodes, then + // offset that block clear of the existing bbox so nothing overlaps. + for (const [id, c] of existingCoords) { + const sz = sizes.get(id) ?? { w: 140, h: 60 }; + geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h }); + } + const newIds = new Set( + graph.nodes.filter((n) => !existingCoords.has(n.id)).map((n) => n.id), + ); + if (newIds.size > 0) { + const elk = graphToElk(graph, sizes, { only: newIds }); + const laid = await runElk(elk); + // Place the new block below the existing content (a clear >=170px gap). + let maxY = 0; + for (const c of existingCoords.values()) maxY = Math.max(maxY, c.y); + const offsetY = maxY + 200; + for (const [id, g] of laid) { + if (newIds.has(id)) geo.set(id, { ...g, y: g.y + offsetY }); + else if (!geo.has(id)) geo.set(id, g); // a new group container + } + } + // Hints still apply to NEW pinned nodes only (existing ones stay put). + applyHintsForNew(graph, geo, existingCoords); + } else if (layout !== "none") { + const elk = graphToElk(graph, sizes); + geo = await runElk(elk); + applyHints(graph, geo); + } else if (existingCoords) { + // layout:"none" with prior coords -> keep them verbatim. + for (const [id, c] of existingCoords) { + const sz = sizes.get(id) ?? { w: 140, h: 60 }; + geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h }); + } + } + + const assembled = assembleModel(graph, { preset, styles, geo }); + // In incremental mode, splice back every existing cell the graph didn't + // re-list so an "add one node" call preserves the user's manual layout. + let modelXml = assembled.modelXml; + if ( + layout === "incremental" && + existingModelXml && + existingCoords && + existingCoords.size > 0 + ) { + modelXml = mergeExistingCells(modelXml, existingModelXml, graph); + } + return { + modelXml, + iconsResolved: assembled.iconsResolved, + iconsMissing: assembled.iconsMissing, + layout, + }; +} diff --git a/packages/mcp/src/lib/drawio-mermaid.ts b/packages/mcp/src/lib/drawio-mermaid.ts new file mode 100644 index 00000000..1d61947d --- /dev/null +++ b/packages/mcp/src/lib/drawio-mermaid.ts @@ -0,0 +1,347 @@ +// Pure Mermaid `flowchart` -> graph-JSON parser for `drawioFromMermaid` (issue +// #425, stage 3, OPTIONAL). The escape clause in the issue: convert WITHOUT +// Electron/draw.io-CLI, so a pure text parser only. It handles the common wiki +// flowchart subset — node shapes, labelled/dashed edges, subgraphs (-> groups), +// and the direction header — and emits a Graph the drawioFromGraph pipeline +// renders as an EDITABLE draw.io diagram. Anything beyond flowchart (sequence / +// class / state) throws a clear error so the model falls back to drawioFromGraph. +// +// DELIBERATELY NARROW: this is not a full Mermaid grammar (Mermaid's own parser +// is a 100KB+ browser dependency). It covers `flowchart`/`graph` with the node +// shapes and edge arrows that show up in practice; unusual syntax is skipped +// rather than mis-parsed, and a diagram that yields no nodes throws. + +import type { Graph, GraphNode, GraphEdge, GraphGroup } from "./drawio-graph.js"; + +export class MermaidParseError extends Error { + constructor(message: string) { + super(`drawioFromMermaid: ${message}`); + this.name = "MermaidParseError"; + } +} + +// Input-size bounds applied BEFORE parsing. Without them a pathological mermaid +// string (e.g. 300000 connection lines, or 20000 nested `subgraph`s) builds a +// huge intermediate node/edge/group structure that OOM-crashes the worker — the +// downstream validateGraph caps in drawio-graph can't help because the parser +// exhausts the heap constructing the intermediate FIRST. These caps reject the +// over-limit input fast, before a single line is parsed. +const MAX_MERMAID_CHARS = 200_000; // ~200 KB of source is far beyond any real diagram. +const MAX_MERMAID_LINES = 20_000; +const MAX_MERMAID_GROUPS = 500; // parity with drawio-graph's MAX_GRAPH_GROUPS. +// Per connection line, the number of chained nodes we will expand (`A-->B-->C`). +const MAX_CHAIN_NODES = 500; + +const DIRECTIONS: Record = { + LR: "LR", + RL: "RL", + TB: "TB", + TD: "TB", + BT: "BT", +}; + +/** + * Node-shape delimiters -> a semantic `kind`. Mermaid encodes shape in the + * bracket style; we map the common ones to the palette kinds so the diagram is + * colored meaningfully (a decision/diamond -> queue, a database cylinder -> db, + * a rounded/stadium -> service, a subroutine/hexagon -> gateway, default rect -> + * service). The label text lives between the delimiters. + */ +interface ShapeDef { + open: string; + close: string; + kind: string; +} +// Order matters: longer/multi-char delimiters first so "([" beats "(". +const SHAPES: ShapeDef[] = [ + { open: "([", close: "])", kind: "service" }, // stadium + { open: "[[", close: "]]", kind: "gateway" }, // subroutine + { open: "[(", close: ")]", kind: "db" }, // cylinder-ish / database + { open: "((", close: "))", kind: "external" }, // circle + { open: "{{", close: "}}", kind: "gateway" }, // hexagon + { open: "[", close: "]", kind: "service" }, // rectangle + { open: "(", close: ")", kind: "service" }, // rounded + { open: "{", close: "}", kind: "queue" }, // rhombus / decision + { open: ">", close: "]", kind: "external" }, // asymmetric flag +]; + +/** Strip Mermaid label quoting/escapes and normalise whitespace. */ +function cleanLabel(raw: string): string { + let s = raw.trim(); + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + s = s.slice(1, -1); + } + return s.replace(//gi, " ").replace(/\s+/g, " ").trim(); +} + +/** A single edge-arrow spec: its regex and the resulting edge `kind`. */ +interface ArrowDef { + re: RegExp; + kind: string; +} +// Dotted arrows (`-.->`) -> async; thick (`==>`) stay sync; normal `-->`/`---`. +// Each captures an optional `|label|` OR inline label between the two arrow +// halves. Applied to the segment between two node tokens. +const ARROWS: ArrowDef[] = [ + { re: /-\.->|-\.-/, kind: "async" }, + { re: /==>|===/, kind: "sync" }, + { re: /-->|---/, kind: "sync" }, +]; + +interface ParsedRef { + id: string; + node?: GraphNode; +} + +/** + * Parse a single node token like `A`, `A[Label]`, `db[(Orders)]`, `d{Choose}`. + * Returns the id and, when the token declares a shape/label, a GraphNode. + */ +function parseNodeToken(token: string): ParsedRef | null { + const t = token.trim(); + if (t === "") return null; + for (const shape of SHAPES) { + const oi = t.indexOf(shape.open); + if (oi <= 0) continue; + if (!t.endsWith(shape.close)) continue; + const id = t.slice(0, oi).trim(); + const label = cleanLabel(t.slice(oi + shape.open.length, t.length - shape.close.length)); + if (!id) return null; + return { id, node: { id, label: label || id, kind: shape.kind } }; + } + // Bare id (no shape declared here — may be defined elsewhere). + if (/^[A-Za-z0-9_.-]+$/.test(t)) return { id: t }; + return null; +} + +/** + * Split a connection line into [leftToken, arrowSegment, rightToken]. Returns + * null if the line has no arrow. The arrow segment may embed a label as + * `-->|text|` or `-- text -->`. + */ +function splitConnection( + line: string, +): { left: string; right: string; kind: string; label?: string } | null { + for (const arrow of ARROWS) { + // Find the arrow occurrence. Support a mid-arrow label: `A -- text --> B`. + const m = arrow.re.exec(line); + if (!m) continue; + const idx = m.index; + let left = line.slice(0, idx).trim(); + let rest = line.slice(idx + m[0].length).trim(); + let label: string | undefined; + // Pipe label: `-->|HTTPS| B`. + const pipe = /^\|([^|]*)\|\s*(.*)$/.exec(rest); + if (pipe) { + label = cleanLabel(pipe[1]); + rest = pipe[2].trim(); + } + // Mid-arrow label on the left side: `A -- text` before the arrow half. + const midLeft = /^(.*?)\s*--\s*(.+)$/.exec(left); + if (!label && midLeft && /-\.|--|==/.test(line.slice(0, idx))) { + // Only treat as a label when there's clearly text after `--`. + if (!/[\[\](){}]/.test(midLeft[2])) { + left = midLeft[1].trim(); + label = cleanLabel(midLeft[2]); + } + } + if (!left || !rest) return null; + return { left, right: rest, kind: arrow.kind, label }; + } + return null; +} + +/** + * Parse Mermaid flowchart text into a Graph. Handles the header + * (`flowchart LR` / `graph TD`), `subgraph [title] … end` blocks (-> groups), + * node declarations, and connection lines. Throws MermaidParseError for a + * non-flowchart diagram or when nothing parses. + */ +export function mermaidToGraph(mermaid: string): Graph { + if (typeof mermaid !== "string" || mermaid.trim() === "") { + throw new MermaidParseError("empty mermaid input"); + } + // Size guards FIRST — bound the raw input before building any intermediate. + if (mermaid.length > MAX_MERMAID_CHARS) { + throw new MermaidParseError( + `input is ${mermaid.length} chars (max ${MAX_MERMAID_CHARS}); split the diagram or use drawioFromGraph`, + ); + } + const rawLines = mermaid.split(/\r?\n/); + if (rawLines.length > MAX_MERMAID_LINES) { + throw new MermaidParseError( + `input has ${rawLines.length} lines (max ${MAX_MERMAID_LINES}); split the diagram or use drawioFromGraph`, + ); + } + const nodes = new Map(); + const groups: GraphGroup[] = []; + const edges: GraphEdge[] = []; + let direction: Graph["direction"] = "LR"; + let sawHeader = false; + + // Stack of active subgraph ids (nesting); the top is the current group. + const groupStack: string[] = []; + let anonGroup = 0; + + const ensureNode = (ref: ParsedRef) => { + const existing = nodes.get(ref.id); + if (ref.node) { + if (existing) { + // Fill in a label/kind if this token declared a shape and the prior didn't. + if (existing.label === existing.id && ref.node.label !== ref.node.id) + existing.label = ref.node.label; + if (!existing.kind) existing.kind = ref.node.kind; + } else { + nodes.set(ref.id, { ...ref.node }); + } + } else if (!existing) { + nodes.set(ref.id, { id: ref.id, label: ref.id, kind: "service" }); + } + // Assign to the current subgraph if inside one and not yet grouped. + const cur = groupStack[groupStack.length - 1]; + const n = nodes.get(ref.id)!; + if (cur && n.group == null) n.group = cur; + }; + + for (const raw of rawLines) { + let line = raw.trim(); + if (line === "" || line.startsWith("%%")) continue; // blank / comment + + // Header. + const header = /^(flowchart|graph)\s+([A-Za-z]{2})\b/.exec(line); + if (header) { + sawHeader = true; + const dir = DIRECTIONS[header[2].toUpperCase()]; + if (dir) direction = dir; + continue; + } + if (/^(sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|journey)\b/.test(line)) { + throw new MermaidParseError( + `only 'flowchart'/'graph' is supported (got '${line.split(/\s+/)[0]}'); use drawioFromGraph instead`, + ); + } + + // Subgraph open: `subgraph id [Title]` or `subgraph Title`. + const sg = /^subgraph\s+(.+)$/.exec(line); + if (sg) { + const spec = sg[1].trim(); + let id: string; + let label: string; + const bracket = /^([A-Za-z0-9_.-]+)\s*\[(.+)\]$/.exec(spec); + if (bracket) { + id = bracket[1]; + label = cleanLabel(bracket[2]); + } else if (/^[A-Za-z0-9_.-]+$/.test(spec)) { + id = spec; + label = spec; + } else { + id = `sg${anonGroup++}`; + label = cleanLabel(spec); + } + if (!groups.some((g) => g.id === id)) { + if (groups.length >= MAX_MERMAID_GROUPS) { + throw new MermaidParseError( + `too many subgraphs (max ${MAX_MERMAID_GROUPS}); use drawioFromGraph for a diagram this large`, + ); + } + groups.push({ id, label, kind: "group" }); + } + groupStack.push(id); + continue; + } + if (/^end\b/.test(line)) { + groupStack.pop(); + continue; + } + // `direction LR` inside a subgraph — apply to the top-level direction. + const innerDir = /^direction\s+([A-Za-z]{2})\b/.exec(line); + if (innerDir) { + const dir = DIRECTIONS[innerDir[1].toUpperCase()]; + if (dir) direction = dir; + continue; + } + // Style/class/click directives: ignore (no visual mapping in our palette). + if (/^(style|classDef|class|click|linkStyle)\b/.test(line)) continue; + + // Strip a trailing semicolon. + if (line.endsWith(";")) line = line.slice(0, -1).trim(); + + // Connection line (possibly chained: A --> B --> C). + const conn = splitConnection(line); + if (conn) { + // Handle a simple chain by re-splitting the right side. + let leftTok = conn.left; + let seg: typeof conn | null = conn; + let guard = 0; + while (seg) { + if (guard++ >= MAX_CHAIN_NODES) { + // Don't silently drop the tail of an over-long chain — surface it so + // the model knows the diagram was too large rather than getting a + // quietly-truncated result. + throw new MermaidParseError( + `a single connection chain exceeds ${MAX_CHAIN_NODES} nodes; split it or use drawioFromGraph`, + ); + } + const leftRef = parseNodeToken(leftTok); + // The right side may itself contain another arrow (a chain). + const nextSeg = splitConnection(seg.right); + const rightTokenStr = nextSeg ? seg.right.slice(0, splitIndex(seg.right)) : seg.right; + const rightRef = parseNodeToken(nextSeg ? nextSeg.left : seg.right); + if (leftRef && rightRef) { + ensureNode(leftRef); + ensureNode(rightRef); + edges.push({ + from: leftRef.id, + to: rightRef.id, + label: seg.label, + kind: seg.kind, + }); + } + if (!nextSeg) break; + leftTok = nextSeg.left; + seg = nextSeg; + void rightTokenStr; + } + continue; + } + + // Standalone node declaration `A[Label]` OR a bare member ref `C` inside a + // subgraph (which claims that node for the current group). + const nodeRef = parseNodeToken(line); + if (nodeRef && (nodeRef.node || groupStack.length > 0)) { + ensureNode(nodeRef); + continue; + } + // Unknown line: skip silently (robustness over strictness). + } + + if (!sawHeader && nodes.size === 0) { + throw new MermaidParseError( + "input does not look like a mermaid flowchart (no 'flowchart'/'graph' header and no nodes)", + ); + } + if (nodes.size === 0) { + throw new MermaidParseError("no nodes parsed from the flowchart"); + } + + const graph: Graph = { + nodes: Array.from(nodes.values()), + direction, + }; + if (groups.length > 0) graph.groups = groups; + if (edges.length > 0) graph.edges = edges; + return graph; +} + +/** Index of the first arrow in a segment (for chain splitting). */ +function splitIndex(s: string): number { + let best = -1; + for (const arrow of ARROWS) { + const m = arrow.re.exec(s); + if (m && (best === -1 || m.index < best)) best = m.index; + } + return best === -1 ? s.length : best; +} diff --git a/packages/mcp/src/lib/drawio-presets.ts b/packages/mcp/src/lib/drawio-presets.ts new file mode 100644 index 00000000..c95068d8 --- /dev/null +++ b/packages/mcp/src/lib/drawio-presets.ts @@ -0,0 +1,151 @@ +// Semantic color/line presets for the graph tools (issue #425, stage 3). The +// PALETTE is DATA (packages/mcp/data/drawio-presets.json), not code: a node +// `kind` maps to a { fillColor, strokeColor, fontColor } slot and an edge `kind` +// maps to line-style props, per named preset (`default` / `dark` / +// `colorblind-safe`). This module only loads that data and turns a slot into a +// draw.io style fragment. The INVARIANT of the graph tools is that the model +// never sees a style string — it names a `kind`, the server picks the slot. +// +// Loading mirrors drawio-shapes.ts: the JSON is read once via `import.meta.url` +// relative to the built module. That is why this module (and drawio-graph.ts +// which imports it) is reached ONLY through client.ts's ESM build and never +// value-imported into the zod-agnostic tool-specs.ts (which the in-app server +// type-checks under module:commonjs, where `import.meta` is a TS1343 error). + +import { readFileSync } from "node:fs"; + +/** A node color slot: the three draw.io color values for a `kind`. */ +export interface NodeSlot { + fillColor: string; + strokeColor: string; + fontColor: string; +} + +/** An edge line style: the extra style props appended for an edge `kind`. */ +export interface EdgeStyle { + props: string; +} + +export interface PresetData { + canvasDark: boolean; + okabeIto?: string[]; + nodes: Record; + edges: Record; + edgeDefault: { strokeColor: string; fontColor: string }; + group: { strokeColor: string; fontColor: string }; +} + +interface PresetsFile { + presets: Record; +} + +/** The three shipped preset names. */ +export const PRESET_NAMES = ["default", "dark", "colorblind-safe"] as const; +export type PresetName = (typeof PRESET_NAMES)[number]; + +/** Every node `kind` the base palette defines (also the generic-shape kinds). */ +export const NODE_KINDS = [ + "service", + "db", + "queue", + "gateway", + "error", + "external", + "security", +] as const; +export type NodeKind = (typeof NODE_KINDS)[number]; + +/** Edge `kind`s the palette styles; anything else falls back to `sync`. */ +export const EDGE_KINDS = ["sync", "async", "error"] as const; +export type EdgeKind = (typeof EDGE_KINDS)[number]; + +let _presets: Record | null = null; + +function presetsPath(): URL { + // build/lib/drawio-presets.js -> ../../data/drawio-presets.json + return new URL("../../data/drawio-presets.json", import.meta.url); +} + +/** Load + parse the bundled preset table once, then cache it. */ +export function loadPresets(): Record { + if (_presets) return _presets; + const json = readFileSync(presetsPath(), "utf-8"); + const parsed = JSON.parse(json) as PresetsFile; + _presets = parsed.presets; + return _presets; +} + +/** Resolve a preset by name, defaulting to `default` for an unknown name. */ +export function getPreset(name?: string): PresetData { + const presets = loadPresets(); + if (name && presets[name]) return presets[name]; + return presets["default"]; +} + +/** The slot for a node `kind` in a preset, falling back to `service`. */ +export function nodeSlot(preset: PresetData, kind?: string): NodeSlot { + if (kind && preset.nodes[kind]) return preset.nodes[kind]; + return preset.nodes["service"]; +} + +/** + * Build the draw.io style string for a GENERIC (no-icon) node of a given kind. + * A rounded rectangle carrying the slot's fill/stroke/font. `whiteSpace=wrap` + * and `html=1` let a long label wrap inside the shape (the assembler also sizes + * the shape to the label, so the linter's label-overflow warning never fires). + */ +export function genericNodeStyle(preset: PresetData, kind?: string): string { + const s = nodeSlot(preset, kind); + return ( + `rounded=1;whiteSpace=wrap;html=1;` + + `fillColor=${s.fillColor};strokeColor=${s.strokeColor};fontColor=${s.fontColor};` + ); +} + +/** + * Overlay the preset's node slot colors onto a resolved ICON style-string + * (from the shape catalog). An AWS/Azure icon carries its OWN mandatory + * fill/stroke (the category color / white outline) that MUST NOT be recolored, + * so for an icon we only ensure a readable fontColor when the preset is dark; + * otherwise the icon style is returned verbatim. Keeping the icon's own colors + * is deliberate: recoloring an AWS service icon breaks its category semantics. + */ +export function iconNodeStyle(preset: PresetData, iconStyle: string): string { + if (!preset.canvasDark) return iconStyle; + // On a dark canvas an icon's fontColor is usually a dark ink that vanishes; + // append a light fontColor (icons put their label BELOW the glyph, so this + // only affects the caption, never the glyph fill). + if (/fontColor=/.test(iconStyle)) { + return iconStyle.replace(/fontColor=[^;]*/, "fontColor=#e0e0e0"); + } + return iconStyle + (iconStyle.endsWith(";") ? "" : ";") + "fontColor=#e0e0e0;"; +} + +/** + * Build the draw.io style for an edge of a given `kind`. Base is an orthogonal + * connector (edgeStyle=orthogonalEdgeStyle) with rounded corners and an open + * arrowhead, plus the preset's default stroke/font, then the kind's extra props + * (dashed / colored) overlaid. An unknown kind falls back to `sync` (solid). + */ +export function edgeStyle(preset: PresetData, kind?: string): string { + const k = kind && preset.edges[kind] ? kind : "sync"; + const base = + `edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;` + + `strokeColor=${preset.edgeDefault.strokeColor};fontColor=${preset.edgeDefault.fontColor};`; + return base + preset.edges[k].props; +} + +/** + * Group (container) style: ALWAYS transparent (`fillColor=none;container=1;`) + * per the spec, carrying the preset's group stroke/font. `dropTarget=1` marks it + * a drop target in the editor; `verticalAlign=top;align=left;spacingLeft=8;` puts + * the group label in the top-left like draw.io's own boundary containers. + */ +export function groupStyle(preset: PresetData): string { + return ( + `rounded=0;whiteSpace=wrap;html=1;` + + `fillColor=none;container=1;dropTarget=1;collapsible=0;` + + `strokeColor=${preset.group.strokeColor};fontColor=${preset.group.fontColor};` + + `verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;` + ); +} diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index e7ec58ed..5eeb130e 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -41,7 +41,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + - "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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 -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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 -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (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) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment 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 -> createComment 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 -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown."; @@ -109,6 +109,9 @@ const TOOL_FAMILY: Record = { drawioGet: "EDIT", drawioCreate: "EDIT", drawioUpdate: "EDIT", + drawioEditCells: "EDIT", + drawioFromGraph: "EDIT", + drawioFromMermaid: "EDIT", drawioShapes: "EDIT", drawioGuide: "EDIT", docmostTransform: "EDIT", diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index fec7e9ab..44534ed1 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -100,6 +100,9 @@ export type DocmostClientLike = Pick< | 'drawioGet' | 'drawioCreate' | 'drawioUpdate' + | 'drawioEditCells' + | 'drawioFromGraph' + | 'drawioFromMermaid' | 'createComment' | 'resolveComment' >; @@ -2013,6 +2016,234 @@ export const SHARED_TOOL_SPECS = { ), }, + drawioEditCells: { + mcpName: 'drawioEditCells', + inAppKey: 'drawioEditCells', + description: + 'Make TARGETED, id-based edits to an existing draw.io diagram instead of ' + + 'resending the whole XML (a full-XML diff is fragile — draw.io reorders ' + + 'attributes). `operations` is an ordered list of: ' + + '{ op:"add", xml:"" } (append a new cell), ' + + '{ op:"update", cellId:"n3", xml:"" } (replace that ' + + 'cell; the id MUST stay the same), or { op:"delete", cellId:"n5" } — a ' + + 'delete CASCADES to the cell\'s container children AND to every edge whose ' + + 'source/target is deleted. Ids are STABLE across edits so diffs stay ' + + 'meaningful. `baseHash` is MANDATORY: pass the hash from the drawioGet you ' + + 'based the edit on; if the diagram changed since, the edit is refused with ' + + 'a conflict error — re-read with drawioGet and retry. The edited model goes ' + + 'through the same lint + quality-warning pipeline as drawioUpdate. `node` is ' + + 'the drawio node attrs.id or "#". Use this to tweak a diagram (move ' + + 'or restyle a few cells, add/remove nodes); to (re)generate a whole diagram ' + + 'from a description use drawioFromGraph.' + + DRAWIO_HARD_RULES, + tier: 'deferred', + catalogLine: + 'drawioEditCells — id-based add/update/delete edits to a draw.io diagram (cascade delete).', + buildShape: (z) => ({ + pageId: z.string().min(1), + node: z + .string() + .min(1) + .describe('The drawio node attrs.id, or "#" for a top-level block.'), + operations: z + .array( + z.object({ + op: z.enum(['add', 'update', 'delete']), + cellId: z + .string() + .optional() + .describe('Target cell id (required for update/delete).'), + xml: z + .string() + .optional() + .describe('The element (required for add/update).'), + }), + ) + .describe('Ordered add/update/delete operations keyed by cell id.'), + baseHash: z + .string() + .min(1) + .describe('The meta.hash from the drawioGet this edit is based on.'), + }), + execute: (client, { pageId, node, operations, baseHash }) => + client.drawioEditCells( + pageId as string, + node as string, + operations as any, + baseHash as string, + ), + }, + + drawioFromGraph: { + mcpName: 'drawioFromGraph', + inAppKey: 'drawioFromGraph', + description: + 'Build a draw.io diagram from a SEMANTIC graph — you describe nodes, groups ' + + 'and edges by MEANING and the server picks every coordinate, color and icon ' + + 'so the whole class of layout/icon mistakes (overlaps, edges through shapes, ' + + 'empty-box stencils) cannot happen. This is the PREFERRED tool for ' + + 'architecture / cloud / network diagrams. `graph` = { nodes:[{ id, label, ' + + 'kind?, icon?, group?, layer?, sameLayerAs?, pinned? }], groups?:[{ id, ' + + 'label, kind? }], edges?:[{ from, to, label?, kind? }] }. Node `kind` picks ' + + 'a palette color (service/db/queue/gateway/error/external/security); `icon` ' + + '(e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos") resolves to the exact ' + + 'verified stencil — an unknown icon degrades to a labelled generic shape, ' + + 'never an empty box. Edge `kind` sets the line style (sync=solid, ' + + 'async=dashed, error=red-dashed). Groups are TRANSPARENT containers. ' + + '`direction` (LR/RL/TB/BT) and `preset` (default/dark/colorblind-safe) tune ' + + 'the layout/palette. Layout hints: `layer` (column index), `sameLayerAs` ' + + '(align two nodes), `pinned:{x,y}` (fix a node). `layout`: "full" (default, ' + + 'auto-place everything), "incremental" (with `node`: keep the existing ' + + 'diagram\'s coordinates, place only new cells), "none" (no auto-layout). The ' + + 'result reports { iconsResolved, iconsMissing } so you can verify all icons ' + + 'resolved. For standard flowcharts you can also write Mermaid and call ' + + 'drawioFromMermaid; for exotic/wireframe diagrams use raw XML via drawioCreate.', + tier: 'deferred', + catalogLine: + 'drawioFromGraph — build a draw.io diagram from a semantic node/group/edge graph (server picks layout+icons).', + buildShape: (z) => { + const node = z.object({ + id: z.string().min(1), + label: z.string().min(1), + kind: z + .string() + .optional() + .describe( + 'Palette slot: service/db/queue/gateway/error/external/security.', + ), + icon: z + .string() + .optional() + .describe('Icon ref, e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos".'), + group: z.string().optional().describe('Id of the group (container) it sits in.'), + layer: z.number().optional().describe('Layer/column index hint (>=0).'), + sameLayerAs: z + .string() + .optional() + .describe('Put this node in the same layer as another node id.'), + pinned: z + .object({ x: z.number(), y: z.number() }) + .optional() + .describe('Fix the node at these exact coordinates.'), + }); + const group = z.object({ + id: z.string().min(1), + label: z.string().min(1), + kind: z.string().optional(), + }); + const edge = z.object({ + from: z.string().min(1), + to: z.string().min(1), + label: z.string().optional(), + kind: z + .string() + .optional() + .describe('sync (solid), async (dashed), error (red-dashed).'), + }); + return { + pageId: z.string().min(1), + graph: z + .object({ + nodes: z.array(node), + groups: z.array(group).optional(), + edges: z.array(edge).optional(), + direction: z.enum(['LR', 'RL', 'TB', 'BT']).optional(), + preset: z.enum(['default', 'dark', 'colorblind-safe']).optional(), + }) + .describe('The semantic graph: nodes, groups, edges.'), + 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).'), + direction: z + .enum(['LR', 'RL', 'TB', 'BT']) + .optional() + .describe('Layout direction (overrides graph.direction).'), + preset: z + .enum(['default', 'dark', 'colorblind-safe']) + .optional() + .describe('Color preset (overrides graph.preset).'), + layout: z + .enum(['none', 'full', 'incremental']) + .optional() + .describe( + '"full" (default) auto-places all; "incremental" (with node) keeps ' + + 'existing coords and places only new cells; "none" no auto-layout.', + ), + node: z + .string() + .optional() + .describe( + 'An existing diagram to (re)build into — required for layout:"incremental".', + ), + }; + }, + execute: ( + client, + { pageId, graph, position, anchorNodeId, anchorText, direction, preset, layout, node }, + ) => + client.drawioFromGraph( + pageId as string, + { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }, + graph as any, + direction as 'LR' | 'RL' | 'TB' | 'BT' | undefined, + preset as string | undefined, + layout as 'none' | 'full' | 'incremental' | undefined, + node as string | undefined, + ), + }, + + drawioFromMermaid: { + mcpName: 'drawioFromMermaid', + inAppKey: 'drawioFromMermaid', + description: + 'Convert Mermaid `flowchart` text into an EDITABLE draw.io diagram (LLMs ' + + 'write Mermaid reliably). Best for STANDARD flowcharts/decision trees: ' + + 'write the mermaid, the server parses it (pure parser — no browser/CLI), ' + + 'maps it to the same semantic pipeline as drawioFromGraph, and inserts a ' + + 'real draw.io diagram you can then refine with drawioEditCells. Node shapes ' + + 'map to palette colors (a `{decision}` -> yellow, a `[(db)]` -> green, etc.); ' + + '`subgraph … end` becomes a transparent group; dotted `-.->` edges become ' + + 'dashed. ONLY flowchart/graph is supported — for sequence/class diagrams, or ' + + 'for cloud/architecture diagrams with real service icons, use drawioFromGraph ' + + 'instead. `where` positions the block like insertNode.', + tier: 'deferred', + catalogLine: + 'drawioFromMermaid — turn Mermaid flowchart text into an editable draw.io diagram.', + buildShape: (z) => ({ + pageId: z.string().min(1), + mermaid: z + .string() + .min(1) + .describe('Mermaid flowchart source (flowchart/graph LR|TB|...).'), + 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).'), + preset: z + .enum(['default', 'dark', 'colorblind-safe']) + .optional() + .describe('Color preset.'), + }), + execute: (client, { pageId, mermaid, position, anchorNodeId, anchorText, preset }) => + client.drawioFromMermaid( + pageId as string, + { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }, + mermaid as string, + preset as string | undefined, + ), + }, + drawioShapes: { mcpName: 'drawioShapes', inAppKey: 'drawioShapes', diff --git a/packages/mcp/test/mock/drawio-graph-tools.test.mjs b/packages/mcp/test/mock/drawio-graph-tools.test.mjs new file mode 100644 index 00000000..e4f05f25 --- /dev/null +++ b/packages/mcp/test/mock/drawio-graph-tools.test.mjs @@ -0,0 +1,272 @@ +// Contract tests for the stage-3 drawio client methods (issue #425): +// drawioEditCells / drawioFromGraph / drawioFromMermaid. Same seam-override +// pattern as drawio-tools.test.mjs: a DocmostClient subclass stubs the I/O seams +// so the tool logic runs without a live Docmost / collab socket. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DocmostClient } from "../../build/client.js"; +import { + buildDrawioSvg, + normalizeXml, + mxHash, + decodeDrawioSvg, + parseCells, +} from "../../build/lib/drawio-xml.js"; + +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 svgFor(model, bbox = { width: 400, height: 300 }) { + return buildDrawioSvg(normalizeXml(model), "", bbox); +} + +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) { + const id = `att-${calls.uploads.length + 1}`; + calls.uploads.push({ pageId, fileName, svg: buffer.toString("utf-8") }); + return { id, fileName, fileSize: buffer.length }; + } + async fetchAttachmentText() { + return attachmentSvg; + } + mutatePage(pageId, token, apiUrl, transform) { + const clone = structuredClone(pageDoc ?? { type: "doc", content: [] }); + const doc = transform(clone); + 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; +} + +// A stored diagram: a group with two children and an edge. +const STORED = + "" + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ""; + +function drawioPageDoc() { + return { + type: "doc", + content: [ + { + type: "drawio", + attrs: { + id: "d1", src: "/api/files/att-1/diagram.drawio.svg", + attachmentId: "att-1", width: 400, height: 300, + }, + }, + ], + }; +} + +// --- drawioEditCells -------------------------------------------------------- + +test("drawioEditCells: applies ops and repoints the node (current baseHash)", async () => { + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + const baseHash = mxHash(normalizeXml(STORED)); + const res = await client.drawioEditCells( + "page1", + "d1", + [ + { + op: "update", + cellId: "a", + xml: + '' + + '', + }, + ], + baseHash, + ); + assert.equal(res.success, true); + assert.equal(calls.uploads.length, 1); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + assert.equal(cells.find((c) => c.id === "a").value, "Renamed"); + assert.equal(cells.find((c) => c.id === "b").value, "B"); // untouched + const n = findDrawio(calls.mutations[0].doc)[0]; + // The stub numbers uploads from 1; this edit is the first upload -> att-1. + assert.equal(n.attrs.attachmentId, "att-1"); +}); + +test("drawioEditCells: delete of the container cascades to children + edge", async () => { + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + const baseHash = mxHash(normalizeXml(STORED)); + await client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "grp" }], baseHash); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const ids = parseCells(written).filter((c) => c.id !== "0" && c.id !== "1").map((c) => c.id); + assert.deepEqual(ids, [], "grp + a + b + edge all cascaded away"); +}); + +test("drawioEditCells: stale baseHash -> conflict, no upload", async () => { + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + await assert.rejects( + () => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], "stale"), + /conflict/, + ); + assert.equal(calls.uploads.length, 0); +}); + +test("drawioEditCells: baseHash is mandatory", async () => { + const { client } = makeClient({ pageDoc: drawioPageDoc(), attachmentSvg: svgFor(STORED) }); + await assert.rejects( + () => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], ""), + /baseHash is mandatory/, + ); +}); + +// --- drawioFromGraph -------------------------------------------------------- + +test("drawioFromGraph: builds a diagram from a graph 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.drawioFromGraph( + "page1", + { position: "append" }, + { + nodes: [ + { id: "api", label: "API", kind: "gateway", icon: "aws:api_gateway", group: "vpc" }, + { id: "fn", label: "Handler", kind: "service", icon: "aws:lambda", group: "vpc" }, + { id: "db", label: "Orders", kind: "db", icon: "aws:dynamodb" }, + ], + groups: [{ id: "vpc", label: "VPC" }], + edges: [{ from: "api", to: "fn", kind: "sync" }, { from: "fn", to: "db", kind: "async" }], + }, + "LR", + "default", + ); + assert.equal(res.success, true); + assert.equal(res.nodeId, "#1"); + assert.equal(res.iconsMissing.length, 0, `unresolved: ${res.iconsMissing}`); + assert.equal(res.iconsResolved, 3); + // The uploaded model decodes back and carries the group + nodes. + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + assert.ok(cells.some((c) => c.id === "vpc")); + assert.ok(cells.some((c) => c.id === "api")); + // Group is transparent. + const vpc = cells.find((c) => c.id === "vpc"); + assert.equal(vpc.styleMap.fillColor, "none"); + assert.equal(vpc.styleMap.container, "1"); +}); + +test("drawioFromGraph: an invalid graph throws before any upload", async () => { + const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); + await assert.rejects( + () => client.drawioFromGraph("page1", { position: "append" }, { nodes: [] }), + /non-empty/, + ); + assert.equal(calls.uploads.length, 0); +}); + +test("drawioFromGraph incremental into an existing node keeps prior coords", async () => { + // The stored diagram has a,b at known coords; add a new node c incrementally. + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + const res = await client.drawioFromGraph( + "page1", + { position: "append" }, + { + nodes: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C new" }, + ], + edges: [{ from: "b", to: "c" }], + }, + undefined, + undefined, + "incremental", + "d1", // target the existing diagram + ); + assert.equal(res.success, true); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + const a = cells.find((c) => c.id === "a"); + const b = cells.find((c) => c.id === "b"); + // Existing coords preserved (the stored a/b absolute coords from STORED). + assert.equal(a.geometry.x, 10); + assert.equal(a.geometry.y, 10); + assert.equal(b.geometry.x, 10); + assert.equal(b.geometry.y, 90); + assert.ok(cells.some((c) => c.id === "c"), "new node c added"); +}); + +// --- drawioFromMermaid ------------------------------------------------------ + +test("drawioFromMermaid: converts a flowchart and inserts a diagram", async () => { + const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] }; + const { client, calls } = makeClient({ pageDoc }); + const res = await client.drawioFromMermaid( + "page1", + { position: "append" }, + "flowchart LR\n A[Start] --> B{Choose}\n B -->|yes| C[Done]\n B -->|no| D[Stop]", + ); + assert.equal(res.success, true); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + for (const id of ["A", "B", "C", "D"]) { + assert.ok(cells.some((c) => c.id === id), `node ${id} present`); + } +}); + +test("drawioFromMermaid: a non-flowchart is rejected, no upload", async () => { + const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); + await assert.rejects( + () => client.drawioFromMermaid("page1", { position: "append" }, "sequenceDiagram\n A->>B: x"), + /only 'flowchart'\/'graph' is supported/, + ); + assert.equal(calls.uploads.length, 0); +}); diff --git a/packages/mcp/test/unit/drawio-cell-ops.test.mjs b/packages/mcp/test/unit/drawio-cell-ops.test.mjs new file mode 100644 index 00000000..cbfaa6d0 --- /dev/null +++ b/packages/mcp/test/unit/drawio-cell-ops.test.mjs @@ -0,0 +1,128 @@ +// Unit tests for the drawioEditCells operations (issue #425, acceptance #4): +// add / update / delete applied to the parsed model, with a cascade delete that +// removes container children AND every connected edge. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { applyCellOps, CellOpsError } from "../../build/lib/drawio-cell-ops.js"; +import { parseCells } from "../../build/lib/drawio-xml.js"; + +const MODEL = + "" + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ""; + +const ids = (xml) => + parseCells(xml) + .filter((c) => c.id !== "0" && c.id !== "1") + .map((c) => c.id) + .sort(); + +test("update changes ONLY the targeted cell", () => { + const out = applyCellOps(MODEL, [ + { + op: "update", + cellId: "c1", + xml: + '' + + '', + }, + ]); + const cells = parseCells(out); + assert.equal(cells.find((c) => c.id === "c1").value, "Renamed"); + // Every OTHER cell is untouched. + assert.equal(cells.find((c) => c.id === "c2").value, "Child2"); + assert.equal(cells.find((c) => c.id === "out").value, "Outside"); + assert.deepEqual(ids(out), ids(MODEL)); +}); + +test("delete of a container removes its children AND the connected edges", () => { + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "grp" }]); + // grp + c1 + c2 gone (cascade to children); e1 (c1->out) and e2 (out->c2) + // gone (cascade to connected edges); "out" survives. + assert.deepEqual(ids(out), ["out"]); +}); + +test("delete of a leaf only cascades to its connected edges, not siblings", () => { + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "c1" }]); + // c1 gone + e1 (c1->out) gone; c2, out, grp, e2 survive. + assert.deepEqual(ids(out), ["c2", "e2", "grp", "out"]); +}); + +test("add appends a new cell", () => { + const out = applyCellOps(MODEL, [ + { + op: "add", + xml: + '' + + '', + }, + ]); + assert.ok(parseCells(out).some((c) => c.id === "new1")); +}); + +test("delete never removes the sentinels", () => { + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]); + const cells = parseCells(out); + assert.ok(cells.some((c) => c.id === "0")); + assert.ok(cells.some((c) => c.id === "1")); +}); + +test("errors: unknown update/delete target, duplicate add id, id mismatch", () => { + assert.throws( + () => applyCellOps(MODEL, [{ op: "update", cellId: "ghost", xml: '' }]), + /does not exist/, + ); + assert.throws( + () => applyCellOps(MODEL, [{ op: "delete", cellId: "ghost" }]), + /does not exist/, + ); + assert.throws( + () => applyCellOps(MODEL, [{ op: "add", xml: '' }]), + /already exists/, + ); + assert.throws( + () => + applyCellOps(MODEL, [ + { op: "update", cellId: "c1", xml: '' }, + ]), + /ids are stable/, + ); + assert.throws(() => applyCellOps(MODEL, []), CellOpsError); +}); + +test("an add op with two cells or a missing id is rejected", () => { + assert.throws( + () => applyCellOps(MODEL, [{ op: "add", xml: '' }]), + /exactly one /, + ); + assert.throws( + () => applyCellOps(MODEL, [{ op: "add", xml: '' }]), + /missing an id/, + ); +}); + +// --- SUGGESTION #5: sentinel cells are protected from delete ------------------ + +test("delete targeting a sentinel id is rejected (no wipe of the diagram body)", () => { + for (const sid of ["0", "1"]) { + assert.throws( + () => applyCellOps(MODEL, [{ op: "delete", cellId: sid }]), + (e) => e instanceof CellOpsError && /cannot delete sentinel cell/.test(e.message), + ); + } + // A normal delete still works and the sentinels remain intact. + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]); + const remaining = parseCells(out).map((c) => c.id); + assert.ok(remaining.includes("0") && remaining.includes("1"), "sentinels survive"); +}); diff --git a/packages/mcp/test/unit/drawio-graph.test.mjs b/packages/mcp/test/unit/drawio-graph.test.mjs new file mode 100644 index 00000000..7a473b26 --- /dev/null +++ b/packages/mcp/test/unit/drawio-graph.test.mjs @@ -0,0 +1,380 @@ +// Unit tests for the drawioFromGraph pipeline (issue #425, stage 3): the +// semantic graph -> ELK -> linter-clean XML assembler, plus the layout hints +// (pinned / sameLayerAs / layer) and incremental layout. Pure — no client, no +// network — so they run under `node --test` against the built lib. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildFromGraph, + validateGraph, + resolveNodeStyle, + GraphValidationError, +} from "../../build/lib/drawio-graph.js"; +import { getPreset } from "../../build/lib/drawio-presets.js"; +import { + prepareModel, + parseCells, + computeQualityWarnings, +} from "../../build/lib/drawio-xml.js"; + +function cellsOf(xml) { + return parseCells(xml).filter((c) => c.id !== "0" && c.id !== "1"); +} +function byId(xml) { + const m = new Map(); + for (const c of parseCells(xml)) m.set(c.id, c); + return m; +} + +// --- Acceptance #1: 15-node graph, 2 nested groups, AWS icons --------------- + +test("from_graph: 15+ nodes, 2 nested groups, AWS icons -> 0 lint errors, 0 warnings, all icons resolve", async () => { + const awsIcons = [ + "aws:lambda", "aws:dynamodb", "aws:api_gateway", "aws:s3", "aws:sqs", + "aws:sns", "aws:ec2", "aws:rds", "aws:cloudfront", "aws:elasticache", + "aws:kinesis", "aws:cognito", "aws:secrets_manager", "aws:cloudwatch", + ]; + const kinds = [ + "service", "db", "gateway", "service", "queue", "queue", "service", "db", + "gateway", "db", "queue", "security", "security", "external", + ]; + const nodes = []; + for (let i = 0; i < 14; i++) { + nodes.push({ + id: "n" + i, + label: "Node " + i, + kind: kinds[i], + icon: awsIcons[i], + group: i < 6 ? "sub1" : i < 10 ? "vpc1" : undefined, + }); + } + nodes.push({ id: "ext1", label: "External Service", kind: "external" }); + const graph = { + nodes, + groups: [ + { id: "vpc1", label: "VPC 10.0.0.0/16", kind: "vpc" }, + { id: "sub1", label: "Private Subnet", kind: "subnet", group: "vpc1" }, // NESTED + ], + edges: [ + { from: "n0", to: "n1", kind: "sync" }, + { from: "n1", to: "n2", kind: "async" }, + { from: "n2", to: "n3" }, + { from: "n3", to: "n7", kind: "sync" }, + { from: "n7", to: "n8" }, + { from: "n8", to: "ext1", kind: "error" }, + { from: "n4", to: "n5" }, + { from: "n10", to: "n11" }, + ], + direction: "LR", + preset: "default", + }; + const r = await buildFromGraph(graph, "full"); + + assert.equal(graph.nodes.length >= 15, true, "at least 15 nodes"); + // All icons resolved — NO empty squares. + assert.equal(r.iconsMissing.length, 0, `unresolved icons: ${r.iconsMissing}`); + assert.equal(r.iconsResolved, 14); + + // 0 lint errors + 0 quality-warnings. + const prepared = prepareModel(r.modelXml); + assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n")); + + // Groups are TRANSPARENT containers. + const cells = byId(r.modelXml); + for (const gid of ["vpc1", "sub1"]) { + const g = cells.get(gid); + assert.ok(g, `${gid} present`); + assert.equal(g.styleMap.container, "1", `${gid} container=1`); + assert.equal(g.styleMap.fillColor, "none", `${gid} fillColor=none`); + assert.equal(g.styleMap.dropTarget, "1", `${gid} dropTarget=1`); + } + // The nested group sub1's parent IS vpc1 (nesting honoured). + assert.equal(cells.get("sub1").parent, "vpc1"); + // A grouped node's parent is its group (relative coords). + assert.equal(cells.get("n0").parent, "sub1"); +}); + +test("from_graph: an UNKNOWN icon degrades to a labelled generic shape (never empty)", async () => { + const graph = { + nodes: [ + { id: "a", label: "Mystery", kind: "service", icon: "not:a-real-icon-xyz" }, + { id: "b", label: "Plain", kind: "db" }, + ], + edges: [{ from: "a", to: "b" }], + }; + const r = await buildFromGraph(graph, "full"); + const cells = byId(r.modelXml); + // The node still carries its label and a real (non-empty) style with a fill. + assert.equal(cells.get("a").value, "Mystery"); + assert.match(cells.get("a").style, /fillColor=/); + // It is reported as missing so the model can see the degradation. + assert.ok(r.iconsMissing.includes("a")); +}); + +// --- Acceptance #2: hints ----------------------------------------------------- + +test("from_graph: a pinned node stays at its exact coordinates", async () => { + const graph = { + nodes: [ + { id: "a", label: "A", pinned: { x: 40, y: 900 } }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ], + edges: [{ from: "a", to: "b" }, { from: "b", to: "c" }], + }; + const a = byId((await buildFromGraph(graph, "full")).modelXml).get("a"); + assert.equal(a.geometry.x, 40); + assert.equal(a.geometry.y, 900); +}); + +test("from_graph: a sameLayerAs pair lands in the same layer (equal layer-axis coord)", async () => { + const graph = { + nodes: [ + { id: "x", label: "X" }, + { id: "y", label: "Y", sameLayerAs: "x" }, + { id: "z", label: "Z" }, + ], + edges: [{ from: "z", to: "x" }, { from: "z", to: "y" }], + direction: "LR", // layer axis = x + }; + const cells = byId((await buildFromGraph(graph, "full")).modelXml); + assert.equal(cells.get("x").geometry.x, cells.get("y").geometry.x); +}); + +test("from_graph: an explicit layer index co-aligns nodes on the layer axis", async () => { + const graph = { + nodes: [ + { id: "p", label: "P", layer: 0 }, + { id: "q", label: "Q", layer: 0 }, + { id: "r", label: "R", layer: 1 }, + ], + edges: [{ from: "p", to: "r" }], + direction: "LR", + }; + const cells = byId((await buildFromGraph(graph, "full")).modelXml); + assert.equal(cells.get("p").geometry.x, cells.get("q").geometry.x); +}); + +test("from_graph: TB direction snaps sameLayerAs on the Y axis", async () => { + const graph = { + nodes: [ + { id: "x", label: "X" }, + { id: "y", label: "Y", sameLayerAs: "x" }, + { id: "z", label: "Z" }, + ], + edges: [{ from: "z", to: "x" }, { from: "z", to: "y" }], + direction: "TB", // layer axis = y + }; + const cells = byId((await buildFromGraph(graph, "full")).modelXml); + assert.equal(cells.get("x").geometry.y, cells.get("y").geometry.y); +}); + +// --- Acceptance #3: incremental never moves existing cells -------------------- + +test("from_graph incremental: adding a node does NOT move existing cells", async () => { + const existing = new Map([ + ["a", { x: 100, y: 100 }], + ["b", { x: 400, y: 100 }], + ]); + const graph = { + nodes: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "cnew", label: "New" }, + ], + edges: [{ from: "a", to: "b" }, { from: "b", to: "cnew" }], + }; + const cells = byId((await buildFromGraph(graph, "incremental", existing)).modelXml); + assert.deepEqual( + [cells.get("a").geometry.x, cells.get("a").geometry.y], + [100, 100], + ); + assert.deepEqual( + [cells.get("b").geometry.x, cells.get("b").geometry.y], + [400, 100], + ); + // The new node was placed and does not overlap the frozen block. + const cn = cells.get("cnew"); + assert.ok(cn.geometry.y >= 200, "new node placed clear of the existing block"); +}); + +// --- direction honoured ------------------------------------------------------- + +test("from_graph: LR vs RL flip the layout axis order", async () => { + const mk = (dir) => ({ + nodes: [{ id: "s", label: "S" }, { id: "t", label: "T" }], + edges: [{ from: "s", to: "t" }], + direction: dir, + }); + const lr = byId((await buildFromGraph(mk("LR"), "full")).modelXml); + // In LR the target sits to the RIGHT of the source. + assert.ok(lr.get("t").geometry.x > lr.get("s").geometry.x, "LR: t right of s"); + const rl = byId((await buildFromGraph(mk("RL"), "full")).modelXml); + assert.ok(rl.get("t").geometry.x < rl.get("s").geometry.x, "RL: t left of s"); +}); + +// --- validation --------------------------------------------------------------- + +test("validateGraph: rejects duplicate node ids, unknown group/edge refs", () => { + assert.throws( + () => validateGraph({ nodes: [{ id: "a", label: "A" }, { id: "a", label: "B" }] }), + GraphValidationError, + ); + assert.throws( + () => validateGraph({ nodes: [{ id: "a", label: "A", group: "ghost" }] }), + /unknown group/, + ); + assert.throws( + () => + validateGraph({ + nodes: [{ id: "a", label: "A" }], + edges: [{ from: "a", to: "ghost" }], + }), + /resolves to no node/, + ); + assert.throws(() => validateGraph({ nodes: [] }), /non-empty/); +}); + +test("resolveNodeStyle: kind maps to the preset palette slot for a generic node", () => { + const preset = getPreset("default"); + const s = resolveNodeStyle(preset, { id: "d", label: "DB", kind: "db" }); + assert.equal(s.iconResolved, false); + assert.match(s.style, /fillColor=#d5e8d4;strokeColor=#82b366/); +}); + +// --- the assembled XML is always linter-clean -------------------------------- + +test("from_graph: a plain cross-container-edge graph is linter-clean", async () => { + const graph = { + nodes: [ + { id: "a", label: "A", group: "g1" }, + { id: "b", label: "B", group: "g2" }, + ], + groups: [ + { id: "g1", label: "G1" }, + { id: "g2", label: "G2" }, + ], + edges: [{ from: "a", to: "b", label: "x" }], + }; + const r = await buildFromGraph(graph, "full"); + const prepared = prepareModel(r.modelXml); // throws on any lint error + assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n")); + // A cross-container edge is parented at the layer sentinel "1". + const edge = cellsOf(r.modelXml).find((c) => c.edge); + assert.equal(edge.parent, "1"); +}); + +// --- CRITICAL #1: edge / group caps reject FAST (no layout, no OOM) ----------- + +test("validateGraph: an over-limit EDGE count is rejected before any layout", () => { + // 2 nodes, 200000 edges: passes node validation, would OOM graphToElk/runElk. + const edges = []; + for (let i = 0; i < 200_000; i++) edges.push({ from: "a", to: "b" }); + const graph = { nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges }; + const t0 = Date.now(); + assert.throws( + () => validateGraph(graph), + (e) => e instanceof GraphValidationError && /200000 edges .*max 1000/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000, "must reject in well under a second (no layout)"); +}); + +test("validateGraph: an over-limit GROUP count is rejected fast", () => { + const groups = []; + for (let i = 0; i < 600; i++) groups.push({ id: "g" + i, label: "G" }); + const t0 = Date.now(); + assert.throws( + () => validateGraph({ nodes: [{ id: "a", label: "A" }], groups }), + (e) => e instanceof GraphValidationError && /600 groups .*max 500/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000); +}); + +test("validateGraph: exactly-at-cap edges/groups are accepted", () => { + const edges = []; + for (let i = 0; i < 1000; i++) edges.push({ from: "a", to: "b" }); + assert.doesNotThrow(() => + validateGraph({ nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges }), + ); +}); + +// --- WARNING #3: sameLayerAs spread -> 0 quality-warnings by construction ----- + +test("from_graph: a sameLayerAs chain of 5 yields 0 quality-warnings (cross-axis spread)", async () => { + const graph = { + nodes: [ + { id: "a", label: "A" }, + { id: "b", label: "B", sameLayerAs: "a" }, + { id: "c", label: "C", sameLayerAs: "b" }, + { id: "d", label: "D", sameLayerAs: "c" }, + { id: "e", label: "E", sameLayerAs: "d" }, + ], + edges: [{ from: "a", to: "b" }], + direction: "LR", + }; + const r = await buildFromGraph(graph, "full"); + const cells = parseCells(r.modelXml); + const warnings = computeQualityWarnings(cells); + assert.equal(warnings.length, 0, warnings.join("\n")); + // The four chained dependents share one layer-axis (x) coordinate... + const by = new Map(cells.map((c) => [c.id, c])); + const xs = ["b", "c", "d", "e"].map((id) => by.get(id).geometry.x); + assert.equal(new Set(xs).size, 1, "chained nodes must share the layer axis"); + // ...but are spread on the cross axis (y) with distinct coordinates. + const ys = ["b", "c", "d", "e"].map((id) => by.get(id).geometry.y); + assert.equal(new Set(ys).size, 4, "chained nodes must not stack on the cross axis"); +}); + +test("from_graph: pinned coords are honored verbatim; a negative pin is clamped non-negative", async () => { + const graph = { + nodes: [ + { id: "p1", label: "P1", pinned: { x: 100, y: 100 } }, + // Two user-pinned nodes at (nearly) the same point: user intent, honored. + { id: "p2", label: "P2", pinned: { x: -500, y: 100 } }, // out-of-bounds x -> clamped to 0 + ], + direction: "LR", + }; + const r = await buildFromGraph(graph, "full"); + const by = new Map(parseCells(r.modelXml).map((c) => [c.id, c])); + assert.equal(by.get("p1").geometry.x, 100); + assert.equal(by.get("p1").geometry.y, 100); + assert.equal(by.get("p2").geometry.x, 0, "negative pin x clamped to 0"); + assert.equal(by.get("p2").geometry.y, 100, "pin y honored"); + // A pinned overlap MAY warn — that's user-directed and documented; we only + // assert the coords are honored (the guarantee softening), not warning count. +}); + +// --- WARNING #4: incremental MERGE preserves unlisted existing cells ---------- + +test("from_graph incremental: an existing cell not in the new graph SURVIVES the add", async () => { + const existingModelXml = + '' + + '' + + '' + + '' + + '' + + ""; + const existingCoords = new Map([ + ["manual", { x: 900, y: 900 }], + ["old1", { x: 40, y: 40 }], + ]); + // The model sends ONLY the re-listed old1 + the new node — NOT "manual". + const graph = { + nodes: [ + { id: "old1", label: "Old One" }, + { id: "new1", label: "Added Node" }, + ], + edges: [{ from: "old1", to: "new1" }], + direction: "LR", + }; + const r = await buildFromGraph(graph, "incremental", existingCoords, existingModelXml); + const by = new Map(parseCells(r.modelXml).map((c) => [c.id, c])); + // The unlisted hand-placed cell survives, verbatim coords. + assert.ok(by.has("manual"), "unlisted existing cell must not be dropped"); + assert.equal(by.get("manual").geometry.x, 900); + assert.equal(by.get("manual").geometry.y, 900); + // The re-listed existing cell keeps its frozen coords; the new node is added. + assert.equal(by.get("old1").geometry.x, 40); + assert.equal(by.get("old1").geometry.y, 40); + assert.ok(by.has("new1"), "the newly added node is present"); +}); diff --git a/packages/mcp/test/unit/drawio-mermaid.test.mjs b/packages/mcp/test/unit/drawio-mermaid.test.mjs new file mode 100644 index 00000000..88da72dd --- /dev/null +++ b/packages/mcp/test/unit/drawio-mermaid.test.mjs @@ -0,0 +1,114 @@ +// Unit tests for the Mermaid flowchart -> graph parser (issue #425, acceptance +// #6, OPTIONAL). Verifies a flowchart with a branch + a subgraph parses to a +// graph the from_graph pipeline renders as valid, editable drawio, and that a +// non-flowchart diagram is rejected with a clear error. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mermaidToGraph, MermaidParseError } from "../../build/lib/drawio-mermaid.js"; +import { buildFromGraph } from "../../build/lib/drawio-graph.js"; +import { prepareModel } from "../../build/lib/drawio-xml.js"; + +test("flowchart with a branch + subgraph -> a valid, editable drawio", async () => { + const mm = `flowchart LR + A[Start] --> B{Decision} + B -->|yes| C[(Database)] + B -->|no| D[End] + subgraph backend [Backend Services] + C + E([Cache]) + end + D -.-> E`; + const graph = mermaidToGraph(mm); + + // Direction + nodes + a group + labelled/dashed edges parsed. + assert.equal(graph.direction, "LR"); + const nodeIds = graph.nodes.map((n) => n.id).sort(); + assert.deepEqual(nodeIds, ["A", "B", "C", "D", "E"]); + // The decision `{}` maps to the queue palette; the `[(db)]` to db. + assert.equal(graph.nodes.find((n) => n.id === "B").kind, "queue"); + assert.equal(graph.nodes.find((n) => n.id === "C").kind, "db"); + // The subgraph became a group and claimed its members. + assert.equal(graph.groups.length, 1); + assert.equal(graph.groups[0].id, "backend"); + assert.equal(graph.nodes.find((n) => n.id === "C").group, "backend"); + assert.equal(graph.nodes.find((n) => n.id === "E").group, "backend"); + // A pipe label and a dotted (async) edge. + const yes = graph.edges.find((e) => e.from === "B" && e.to === "C"); + assert.equal(yes.label, "yes"); + const dotted = graph.edges.find((e) => e.from === "D" && e.to === "E"); + assert.equal(dotted.kind, "async"); + + // The whole thing renders linter-clean. + const built = await buildFromGraph(graph, "full"); + const prepared = prepareModel(built.modelXml); + assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n")); +}); + +test("graph TD header sets a top-down direction", () => { + const g = mermaidToGraph("graph TD\n X --> Y"); + assert.equal(g.direction, "TB"); + assert.deepEqual(g.nodes.map((n) => n.id).sort(), ["X", "Y"]); +}); + +test("a chained connection A --> B --> C yields two edges", () => { + const g = mermaidToGraph("flowchart LR\n A[a] --> B[b] --> C[c]"); + const pairs = g.edges.map((e) => `${e.from}->${e.to}`).sort(); + assert.deepEqual(pairs, ["A->B", "B->C"]); +}); + +test("a non-flowchart diagram is rejected with a clear error", () => { + assert.throws( + () => mermaidToGraph("sequenceDiagram\n Alice->>Bob: Hi"), + /only 'flowchart'\/'graph' is supported/, + ); + assert.throws(() => mermaidToGraph(""), MermaidParseError); +}); + +// --- CRITICAL #2 / NIT: input-size bounds reject FAST (no OOM) ---------------- + +test("mermaidToGraph: an over-length input is rejected before parsing (fast)", () => { + const huge = "flowchart LR\n" + "A-->B\n".repeat(60_000); // ~360 KB > 200 KB cap + const t0 = Date.now(); + assert.throws( + () => mermaidToGraph(huge), + (e) => e instanceof MermaidParseError && /max 200000/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000, "must reject in well under a second (no parse)"); +}); + +test("mermaidToGraph: an over-line-count input is rejected fast", () => { + const many = "flowchart LR\n" + "A\n".repeat(25_000); // > 20000 line cap + const t0 = Date.now(); + assert.throws( + () => mermaidToGraph(many), + (e) => e instanceof MermaidParseError && /max 20000/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000); +}); + +test("mermaidToGraph: too many subgraphs is rejected", () => { + let src = "flowchart LR\n"; + for (let i = 0; i < 600; i++) src += `subgraph s${i}\nend\n`; + assert.throws( + () => mermaidToGraph(src), + (e) => e instanceof MermaidParseError && /too many subgraphs .*max 500/.test(e.message), + ); +}); + +test("mermaidToGraph: an over-long connection chain throws (NON-silent truncation)", () => { + const chain = + "flowchart LR\n" + + Array.from({ length: 600 }, (_, i) => "N" + i).join("-->"); + assert.throws( + () => mermaidToGraph(chain), + (e) => e instanceof MermaidParseError && /chain exceeds 500 nodes/.test(e.message), + ); +}); + +test("mermaidToGraph: a chain of 60 nodes parses (no silent 50-node truncation)", () => { + const chain = + "flowchart LR\n" + + Array.from({ length: 60 }, (_, i) => "N" + i).join("-->"); + const g = mermaidToGraph(chain); + assert.equal(g.nodes.length, 60, "all 60 chained nodes are kept"); +}); diff --git a/packages/mcp/test/unit/drawio-presets.test.mjs b/packages/mcp/test/unit/drawio-presets.test.mjs new file mode 100644 index 00000000..84a958e7 --- /dev/null +++ b/packages/mcp/test/unit/drawio-presets.test.mjs @@ -0,0 +1,117 @@ +// Snapshot + invariant tests for the semantic presets (issue #425, acceptance +// #5): every node kind has a style-string per preset, and colorblind-safe uses +// only the Okabe-Ito palette (no problematic color pairs). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getPreset, + genericNodeStyle, + edgeStyle, + groupStyle, + NODE_KINDS, + EDGE_KINDS, + PRESET_NAMES, +} from "../../build/lib/drawio-presets.js"; + +// The Okabe-Ito qualitative palette (8 colours distinguishable under the common +// colour-vision deficiencies). colorblind-safe MUST draw its strokes from here. +const OKABE_ITO = [ + "#000000", "#e69f00", "#56b4e9", "#009e73", + "#f0e442", "#0072b2", "#d55e00", "#cc79a7", +].map((s) => s.toLowerCase()); + +// The exact base-palette fills from the issue's table (a snapshot: a change to +// the default palette is a deliberate, reviewed edit — this catches accidents). +const DEFAULT_FILLS = { + service: "#dae8fc", + db: "#d5e8d4", + queue: "#fff2cc", + gateway: "#ffe6cc", + error: "#f8cecc", + external: "#f5f5f5", + security: "#e1d5e7", +}; +const DEFAULT_STROKES = { + service: "#6c8ebf", + db: "#82b366", + queue: "#d6b656", + gateway: "#d79b00", + error: "#b85450", + external: "#666666", + security: "#9673a6", +}; + +test("every node kind has a style-string in every preset", () => { + for (const p of PRESET_NAMES) { + const preset = getPreset(p); + for (const kind of NODE_KINDS) { + const style = genericNodeStyle(preset, kind); + assert.match(style, /fillColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a fill`); + assert.match(style, /strokeColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a stroke`); + assert.match(style, /fontColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a font`); + } + } +}); + +test("default preset matches the issue's palette snapshot", () => { + const preset = getPreset("default"); + for (const kind of NODE_KINDS) { + const style = genericNodeStyle(preset, kind); + assert.ok( + style.includes(`fillColor=${DEFAULT_FILLS[kind]}`), + `default/${kind} fill ${DEFAULT_FILLS[kind]}`, + ); + assert.ok( + style.includes(`strokeColor=${DEFAULT_STROKES[kind]}`), + `default/${kind} stroke ${DEFAULT_STROKES[kind]}`, + ); + } +}); + +test("colorblind-safe strokes come ONLY from the Okabe-Ito palette", () => { + const preset = getPreset("colorblind-safe"); + const usedStrokes = new Set(); + for (const kind of NODE_KINDS) { + const stroke = preset.nodes[kind].strokeColor.toLowerCase(); + assert.ok( + OKABE_ITO.includes(stroke), + `colorblind-safe/${kind} stroke ${stroke} is NOT Okabe-Ito`, + ); + usedStrokes.add(stroke); + } + // No two kinds share a stroke (each is a distinct, distinguishable hue) — the + // "no problematic color pairs" acceptance: distinct Okabe-Ito hues. + assert.equal( + usedStrokes.size, + NODE_KINDS.length, + "each kind gets a distinct Okabe-Ito stroke", + ); +}); + +test("edge kinds: sync solid, async dashed, error red-dashed", () => { + const preset = getPreset("default"); + const sync = edgeStyle(preset, "sync"); + const async_ = edgeStyle(preset, "async"); + const error = edgeStyle(preset, "error"); + assert.doesNotMatch(sync, /dashed=1/, "sync is solid"); + assert.match(async_, /dashed=1/, "async is dashed"); + assert.match(error, /dashed=1/, "error is dashed"); + assert.match(error, /strokeColor=#DD344C/i, "error is red"); + // An unknown edge kind falls back to sync (solid). + assert.doesNotMatch(edgeStyle(preset, "weird"), /dashed=1/); +}); + +test("group style is always transparent (fillColor=none;container=1;dropTarget=1)", () => { + for (const p of PRESET_NAMES) { + const style = groupStyle(getPreset(p)); + assert.match(style, /fillColor=none/, `${p} group transparent`); + assert.match(style, /container=1/, `${p} group is a container`); + assert.match(style, /dropTarget=1/, `${p} group is a drop target`); + } +}); + +test("EDGE_KINDS / NODE_KINDS constants match the palette", () => { + const preset = getPreset("default"); + for (const k of NODE_KINDS) assert.ok(preset.nodes[k], `node kind ${k}`); + for (const k of EDGE_KINDS) assert.ok(preset.edges[k], `edge kind ${k}`); +}); diff --git a/packages/mcp/test/unit/drawio-stage3-registration.test.mjs b/packages/mcp/test/unit/drawio-stage3-registration.test.mjs new file mode 100644 index 00000000..2b7221e5 --- /dev/null +++ b/packages/mcp/test/unit/drawio-stage3-registration.test.mjs @@ -0,0 +1,85 @@ +// Drift guards for the stage-3 drawio tools (issue #425): the three high-level +// tools must be in the shared registry, routed in SERVER_INSTRUCTIONS, expose +// the right schema fields, and carry an `execute` (they call CLIENT methods, so +// unlike drawioShapes/drawioGuide they are NOT inlineBothHosts). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { SERVER_INSTRUCTIONS } from "../../build/index.js"; +import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js"; + +const NEW = ["drawioEditCells", "drawioFromGraph", "drawioFromMermaid"]; + +test("the three stage-3 tools are in the shared registry (deferred, camelCase)", () => { + for (const name of NEW) { + const spec = SHARED_TOOL_SPECS[name]; + assert.ok(spec, `${name} missing from registry`); + assert.equal(spec.mcpName, name); + assert.equal(spec.inAppKey, name); + assert.equal(spec.tier, "deferred"); + } +}); + +test("the stage-3 tools carry an execute (client-backed, NOT inlineBothHosts)", () => { + for (const name of NEW) { + const spec = SHARED_TOOL_SPECS[name]; + assert.equal(typeof spec.execute, "function", `${name} needs an execute`); + assert.notEqual(spec.inlineBothHosts, true, `${name} must not be inlineBothHosts`); + } +}); + +test("the stage-3 tools are routed in SERVER_INSTRUCTIONS", () => { + for (const name of NEW) { + assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`); + } +}); + +test("drawioFromGraph exposes graph + direction/preset/layout params", () => { + const shape = SHARED_TOOL_SPECS.drawioFromGraph.buildShape(makeZodStub()); + for (const key of ["pageId", "graph", "position", "direction", "preset", "layout", "node"]) { + assert.ok(key in shape, `drawioFromGraph missing ${key}`); + } +}); + +test("drawioEditCells exposes operations + baseHash", () => { + const shape = SHARED_TOOL_SPECS.drawioEditCells.buildShape(makeZodStub()); + for (const key of ["pageId", "node", "operations", "baseHash"]) { + assert.ok(key in shape, `drawioEditCells missing ${key}`); + } +}); + +test("drawioFromMermaid exposes a mermaid param", () => { + const shape = SHARED_TOOL_SPECS.drawioFromMermaid.buildShape(makeZodStub()); + assert.ok("mermaid" in shape); +}); + +test("the hard-rules block is injected into edit_cells (raw ops) but NOT from_graph/from_mermaid", () => { + // edit_cells takes raw xml in add/update ops, so it surfaces the XML + // rules. from_graph/from_mermaid NEVER expose XML to the model (the whole point + // is the model never writes a style/coord), so the hard rules would be noise. + assert.match(SHARED_TOOL_SPECS.drawioEditCells.description, /sentinels are MANDATORY/); + assert.doesNotMatch(SHARED_TOOL_SPECS.drawioFromGraph.description, /sentinels are MANDATORY/); + assert.doesNotMatch(SHARED_TOOL_SPECS.drawioFromMermaid.description, /sentinels are MANDATORY/); +}); + +test("routing prose distinguishes from_graph (architectures) vs from_mermaid (standard)", () => { + // The EDIT-section routing sentence must mention the semantic tools' intents. + assert.match(SERVER_INSTRUCTIONS, /drawioFromGraph[\s\S]*architecture|architecture[\s\S]*drawioFromGraph/i); + assert.match(SERVER_INSTRUCTIONS, /drawioFromMermaid[\s\S]*flowchart|flowchart[\s\S]*drawioFromMermaid/i); +}); + +// Tiny zod stub (buildShape only calls string/number/enum/array/object + +// chained min/optional/describe — all return `this`; object() returns a chain +// too so nested schemas resolve). +function makeZodStub() { + const chain = new Proxy( + {}, + { get: (_t, p) => (p === "parse" ? () => ({}) : () => chain) }, + ); + return { + string: () => chain, + number: () => chain, + enum: () => chain, + array: () => chain, + object: () => chain, + }; +}