refactor(mcp-server): remove dead code and fold repeated tool plumbing

- index.ts: registerTool() does the session check, try/catch and the
  write queue for all 15 tools; fail()/ok(), syncFromBrowser(),
  commitDocument() and describePages() replace the copied blocks; drop
  pickPageSelector, currentSession.version, the input ?? {} defaults;
  add_page reuses prepareNewModel() split out of prepareNewDiagram
- xml-validation.ts: drop the hand-written syntax checks that saxes
  already reports, MAX_XML_SIZE and the stale header
- http-server.ts: one pendingExport object, no unread response fields,
  no CORS/OPTIONS branch for same-origin requests, fewer exports
- diagram-operations parseNewCell(), addHistory returns void,
  normalizeToMxfile without host, foldCells without layer, expandStyles
  removed, unused preview.css rule removed, no .d.ts output
- xml-examples.ts styleRules() shares the Styles paragraphs with the
  web prompt
- tests: setupFiles installs the DOM polyfill once, server-wiring uses
  the SDK client, mcp-core.test.ts drops the cases MCP tests already
  cover
This commit is contained in:
dayuan.jiang
2026-10-12 08:25:47 +09:00
parent 165175be20
commit fc8ffc5655
35 changed files with 1068 additions and 2185 deletions
+2 -2
View File
@@ -158,7 +158,7 @@ function plainGeometry(body: string): Map<string, string> | null {
* missing one. Everything else, including wrapped cells' extra data and * missing one. Everything else, including wrapped cells' extra data and
* edge labels, is left as written. * edge labels, is left as written.
*/ */
export function foldCells(xml: string, layer = "1"): string { export function foldCells(xml: string): string {
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => { return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
if (body === undefined) return block if (body === undefined) return block
const attrs = readAttributes(attrText) const attrs = readAttributes(attrText)
@@ -174,7 +174,7 @@ export function foldCells(xml: string, layer = "1"): string {
(a) => (a) =>
a.name !== "vertex" && a.name !== "vertex" &&
a.name !== "edge" && a.name !== "edge" &&
!(a.name === "parent" && a.value === layer), !(a.name === "parent" && a.value === "1"),
) )
if (byName.get("vertex") === "1" && byName.get("edge") !== "1") { if (byName.get("vertex") === "1" && byName.get("edge") !== "1") {
if (geometryKeys.join() !== "height,width,x,y") return block if (geometryKeys.join() !== "height,width,x,y") return block
+36 -97
View File
@@ -79,6 +79,32 @@ function cellAttr(cell: Element, name: string): string | null {
return inner?.getAttribute(name) ?? null return inner?.getAttribute(name) ?? null
} }
/**
* The cell an add or update operation sends in new_xml, imported into doc,
* or the error message for the model.
*/
function parseNewCell(
op: DiagramOperation,
parser: DOMParser,
doc: Document,
): Element | string {
if (!op.new_xml) return `new_xml is required for ${op.operation} operation`
// A cut-off cell (its XML still streaming in a preview) would parse in
// Chrome to a cell without its geometry
const wrapped = `<wrapper>${op.new_xml}</wrapper>`
const syntaxError = getXmlSyntaxError(wrapped)
if (syntaxError) return `new_xml is not well-formed XML: ${syntaxError}`
const newCell = parser
.parseFromString(wrapped, "text/xml")
.querySelector(CELL_SELECTOR)
if (!newCell) return "new_xml must contain an mxCell element"
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
return `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`
}
return doc.importNode(newCell, true)
}
/** /**
* Apply diagram operations (update/add/delete) using ID-based lookup. * Apply diagram operations (update/add/delete) using ID-based lookup.
* *
@@ -207,63 +233,19 @@ export function applyDiagramOperations(
continue continue
} }
if (!op.new_xml) { const newCell = parseNewCell(op, parser, doc)
if (typeof newCell === "string") {
errors.push({ errors.push({
type: "update", type: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml is required for update operation", message: newCell,
}) })
continue continue
} }
// A cut-off cell (its XML still streaming in a preview) would existingCell.parentNode?.replaceChild(newCell, existingCell)
// parse in Chrome to a cell without its geometry cellMap.set(op.cell_id, newCell)
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector(CELL_SELECTOR)
if (!newCell) {
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and replace the node
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
// Update the map with the new element
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "add") { } else if (op.operation === "add") {
// Check if ID already exists
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
type: "add", type: "add",
@@ -273,61 +255,18 @@ export function applyDiagramOperations(
continue continue
} }
if (!op.new_xml) { const newCell = parseNewCell(op, parser, doc)
if (typeof newCell === "string") {
errors.push({ errors.push({
type: "add", type: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml is required for add operation", message: newCell,
}) })
continue continue
} }
// A cut-off cell (its XML still streaming in a preview) would root.appendChild(newCell)
// parse in Chrome to a cell without its geometry cellMap.set(op.cell_id, newCell)
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector(CELL_SELECTOR)
if (!newCell) {
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and append the node
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.operation === "delete") {
// Protect root cells from deletion // Protect root cells from deletion
if (op.cell_id === "0" || op.cell_id === "1") { if (op.cell_id === "0" || op.cell_id === "1") {
+1 -1
View File
@@ -25,7 +25,7 @@ const escapeChars = (text: string, chars: RegExp) =>
* Serialize a linkedom node as XML. Attribute values escape tabs and line * Serialize a linkedom node as XML. Attribute values escape tabs and line
* breaks too, so multi-line labels (value="a&#xa;b") survive a round trip. * breaks too, so multi-line labels (value="a&#xa;b") survive a round trip.
*/ */
export function serializeXml(node: Node): string { function serializeXml(node: Node): string {
switch (node.nodeType) { switch (node.nodeType) {
case 9: { case 9: {
// Document // Document
+10 -6
View File
@@ -2,19 +2,23 @@
* Drawing guide for the model, returned by start_session, get_drawing_guide * Drawing guide for the model, returned by start_session, get_drawing_guide
* and the diagram-workflow prompt. * and the diagram-workflow prompt.
* *
* Adapted from the web app's system prompt (lib/system-prompts.ts) and its * The XML examples and the style rules are shared with the web app's system
* tool descriptions (app/api/chat/route.ts). When drawing rules change there, * prompt (xml-examples.ts). The other rules are adapted from that prompt
* update this file too. * (lib/system-prompts.ts) and the web app's tool descriptions
* (app/api/chat/route.ts); when they change there, update this file too.
*/ */
import { import {
indent, indent,
STYLE_CLASS_EXAMPLE, STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE, SWIMLANE_EXAMPLE,
styleRules,
TWO_EDGES_EXAMPLE, TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE, WAYPOINT_EXAMPLE,
} from "./xml-examples.ts" } from "./xml-examples.ts"
const styles = styleRules("create_new_diagram or add_page", "the server")
export const DRAWING_GUIDE = `# Draw.io drawing guide export const DRAWING_GUIDE = `# Draw.io drawing guide
## Workflow ## Workflow
@@ -84,12 +88,12 @@ Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (r
${indent(WAYPOINT_EXAMPLE)} ${indent(WAYPOINT_EXAMPLE)}
## Styles ## Styles
Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each create_new_diagram or add_page call defines the names it uses. The server expands the names, so the saved file is standard draw.io XML. ${styles.namedStyles}
${indent(STYLE_CLASS_EXAMPLE)} ${indent(STYLE_CLASS_EXAMPLE)}
- NEVER write html=1 or whiteSpace=wrap: the server adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use &lt;br&gt; for a line break and &lt;b&gt; for bold, never \\n; a literal < or > in a label is written &amp;lt; or &amp;gt;. - ${styles.noHtml}
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings. - ${styles.noDefaults}
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex - Shapes: rounded=1, fillColor=#hex, strokeColor=#hex
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle - Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center or right - Text: fontSize=14, fontStyle=1 (bold), align=center or right
+2 -2
View File
@@ -1,4 +1,4 @@
export const DRAWIO_THEMES = [ const DRAWIO_THEMES = [
"kennedy", "kennedy",
"atlas", "atlas",
"dark", "dark",
@@ -7,7 +7,7 @@ export const DRAWIO_THEMES = [
"simple", "simple",
] as const ] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number] type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme { export function isDrawioTheme(value: unknown): value is DrawioTheme {
return ( return (
+2 -3
View File
@@ -18,7 +18,7 @@ export interface HistoryEntry {
let nextEntryId = 0 let nextEntryId = 0
const historyStore = new Map<string, HistoryEntry[]>() const historyStore = new Map<string, HistoryEntry[]>()
export function addHistory(sessionId: string, xml: string, svg = ""): number { export function addHistory(sessionId: string, xml: string, svg = ""): void {
let history = historyStore.get(sessionId) let history = historyStore.get(sessionId)
if (!history) { if (!history) {
history = [] history = []
@@ -31,7 +31,7 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
const last = history[history.length - 1] const last = history[history.length - 1]
if (last && last.xml === xml) { if (last && last.xml === xml) {
if (svg && !last.svg) last.svg = svg if (svg && !last.svg) last.svg = svg
return history.length - 1 return
} }
history.push({ id: nextEntryId++, xml, svg }) history.push({ id: nextEntryId++, xml, svg })
@@ -42,7 +42,6 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
} }
log.debug(`History: session=${sessionId}, entries=${history.length}`) log.debug(`History: session=${sessionId}, entries=${history.length}`)
return history.length - 1
} }
export function getHistory(sessionId: string): HistoryEntry[] { export function getHistory(sessionId: string): HistoryEntry[] {
+25 -48
View File
@@ -146,13 +146,18 @@ interface SessionState {
lastPolled?: number // Last browser poll; an open tab keeps the session alive lastPolled?: number // Last browser poll; an open tab keeps the session alive
svg?: string // Cached SVG from last browser save svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds syncRequested?: number // Timestamp when sync requested, cleared when browser responds
exportFormat?: ExportFormat // Set by MCP tool to request browser export pendingExport?: PendingExport // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
exportId?: number // Number of the pending export, echoed with its result
exportData?: string // Base64/SVG data returned by browser after export exportData?: string // Base64/SVG data returned by browser after export
} }
/** An export the browser is asked to make, sent to the preview page as is */
interface PendingExport {
id: number // Number of the export, echoed with its result
format: ExportFormat
xml?: string // Single-page projection to load before a page-targeted export
options?: ExportOptions // Extra draw.io export parameters (PNG only)
}
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */ /** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
export type ExportFormat = "png" | "svg" | "xmlsvg" export type ExportFormat = "png" | "svg" | "xmlsvg"
@@ -165,7 +170,7 @@ export interface ExportOptions {
pageId?: string pageId?: string
} }
export const stateStore = new Map<string, SessionState>() const stateStore = new Map<string, SessionState>()
let server: http.Server | null = null let server: http.Server | null = null
let serverPort = 6002 let serverPort = 6002
@@ -220,10 +225,7 @@ export function setState(
svg || svg ||
(existing && existing.xml === xml ? existing.svg : undefined), (existing && existing.xml === xml ? existing.svg : undefined),
syncRequested: undefined, // Clear sync request when browser pushes state syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request pendingExport: existing?.pendingExport, // Preserve pending export request
exportXml: existing?.exportXml, // Preserve pending projection
exportOptions: existing?.exportOptions,
exportId: existing?.exportId,
exportData: existing?.exportData, // Preserve export result exportData: existing?.exportData, // Preserve export result
}) })
log.debug(`State updated: session=${sessionId}, version=${newVersion}`) log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
@@ -266,12 +268,14 @@ export function requestExport(
const state = stateStore.get(sessionId) const state = stateStore.get(sessionId)
if (!state) return false if (!state) return false
state.exportData = undefined state.exportData = undefined
state.exportXml = projectionXml // The browser sends the id back with the result, so a late result of an
state.exportOptions = options
state.exportFormat = format
// The browser sends this back with the result, so a late result of an
// export that timed out is not taken for this one // export that timed out is not taken for this one
state.exportId = ++lastExportId state.pendingExport = {
id: ++lastExportId,
format,
xml: projectionXml,
options,
}
return true return true
} }
@@ -340,7 +344,7 @@ export function startHttpServer(port = 6002): Promise<number> {
}) })
} }
export function stopHttpServer(): void { function stopHttpServer(): void {
if (server) { if (server) {
server.close() server.close()
server = null server = null
@@ -421,19 +425,6 @@ function routeRequest(
return return
} }
const requestOrigin = req.headers.origin
if (requestOrigin === `http://localhost:${serverPort}`) {
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
}
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
if (url.pathname === "/" || url.pathname === "/index.html") { if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || "" const sessionId = url.searchParams.get("mcp") || ""
if (sessionId && !isValidSessionId(sessionId)) { if (sessionId && !isValidSessionId(sessionId)) {
@@ -497,10 +488,7 @@ function handleStateApi(
stateId: state?.stateId ?? null, stateId: state?.stateId ?? null,
blank: !!state?.blank, blank: !!state?.blank,
syncRequested: !!state?.syncRequested, syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null, export: state?.pendingExport ?? null,
exportXml: state?.exportXml || null,
exportOptions: state?.exportOptions || null,
exportId: state?.exportId ?? null,
}), }),
) )
} else if (req.method === "POST") { } else if (req.method === "POST") {
@@ -519,12 +507,9 @@ function handleStateApi(
// Browser is returning export data (png/svg) // Browser is returning export data (png/svg)
if (data.exportData !== undefined) { if (data.exportData !== undefined) {
const state = stateStore.get(sessionId) const state = stateStore.get(sessionId)
if (state && data.exportId === state.exportId) { if (state && data.exportId === state.pendingExport?.id) {
state.exportData = data.exportData state.exportData = data.exportData
state.exportFormat = undefined state.pendingExport = undefined
state.exportXml = undefined
state.exportOptions = undefined
state.exportId = undefined
log.debug( log.debug(
`Export data received for session=${sessionId}`, `Export data received for session=${sessionId}`,
) )
@@ -554,11 +539,7 @@ function handleStateApi(
"Content-Type": "application/json", "Content-Type": "application/json",
}) })
res.end( res.end(
JSON.stringify({ JSON.stringify({ error: "Session was recreated" }),
error: "Session was recreated",
stateChanged: true,
version: current.version,
}),
) )
return return
} }
@@ -579,7 +560,6 @@ function handleStateApi(
res.end( res.end(
JSON.stringify({ JSON.stringify({
error: "Diagram changed on the server", error: "Diagram changed on the server",
version: current.version,
savedToHistory: saved, savedToHistory: saved,
}), }),
) )
@@ -614,7 +594,6 @@ function handleStateApi(
res.end( res.end(
JSON.stringify({ JSON.stringify({
error: "Diagram changed on the server", error: "Diagram changed on the server",
version: current?.version,
savedToHistory, savedToHistory,
}), }),
) )
@@ -669,7 +648,6 @@ function handleHistoryApi(
id: entry.id, id: entry.id,
svg: entry.svg, svg: entry.svg,
})), })),
count: history.length,
}), }),
) )
} }
@@ -737,15 +715,14 @@ function handleRestoreApi(
return return
} }
const newVersion = restoreHistoryEntry(sessionId, id) if (restoreHistoryEntry(sessionId, id) === null) {
if (newVersion === null) {
res.writeHead(404, { "Content-Type": "application/json" }) res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Entry not found" })) res.end(JSON.stringify({ error: "Entry not found" }))
return return
} }
res.writeHead(200, { "Content-Type": "application/json" }) res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, newVersion })) res.end(JSON.stringify({ success: true }))
} catch { } catch {
res.writeHead(400, { "Content-Type": "application/json" }) res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" })) res.end(JSON.stringify({ error: "Invalid JSON" }))
File diff suppressed because it is too large Load Diff
+24 -16
View File
@@ -121,21 +121,12 @@ export function truncatedCellError(input: string): string | null {
return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.` return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.`
} }
/**
* The named style definitions are taken out first (style-classes.ts). Bare
* cells then get the wrapper and root cells, since the strict parser rejects
* several top-level elements. Then the XML is validated and auto-fixed while
* it is still a bare model, where duplicate ids are renamed. Compact cells
* (compact-cells.ts) are expanded, the names expanded and the default styles
* added on the fixed XML, so repaired cells get them too, and finally it is
* turned into an <mxfile>.
*/
/** /**
* Take the named style definitions out of the model's XML (style-classes.ts). * Take the named style definitions out of the model's XML (style-classes.ts).
* Returns the error for the model when a definition never closed or when * Returns the error for the model when a definition never closed or when
* nothing but definitions was sent. * nothing but definitions was sent.
*/ */
export function takeStyleDefinitions(input: string): { function takeStyleDefinitions(input: string): {
classes: StyleClasses classes: StyleClasses
xml: string xml: string
error: string | null error: string | null
@@ -152,10 +143,17 @@ export function takeStyleDefinitions(input: string): {
return { classes, xml, error } return { classes, xml, error }
} }
export function prepareNewDiagram( /**
input: string, * The model of one page from the XML the model wrote (also the starting
page: { pageId?: string; pageName?: string } = {}, * content of add_page). The named style definitions are taken out first
): NewDiagram { * (style-classes.ts). Bare cells then get the wrapper and root cells, since
* the strict parser rejects several top-level elements. Then the XML is
* validated and auto-fixed while it is still a bare model, where duplicate
* ids are renamed. Compact cells (compact-cells.ts) are expanded, the names
* expanded and the default styles added on the fixed XML, so repaired cells
* get them too.
*/
export function prepareNewModel(input: string): NewDiagram {
const { const {
classes, classes,
xml: cells, xml: cells,
@@ -182,12 +180,22 @@ export function prepareNewDiagram(
error: `XML validation failed after expanding the cells - ${rewriteError}`, error: `XML validation failed after expanding the cells - ${rewriteError}`,
} }
} }
const normalized = normalizeToMxfile(xml, page) return { ok: true, xml, fixes }
}
/** prepareNewModel, and the model turned into an <mxfile> */
export function prepareNewDiagram(
input: string,
page: { pageId?: string; pageName?: string } = {},
): NewDiagram {
const model = prepareNewModel(input)
if (!model.ok) return model
const normalized = normalizeToMxfile(model.xml, page)
if (!normalized) { if (!normalized) {
return { return {
ok: false, ok: false,
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.", error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
} }
} }
return { ok: true, xml: normalized, fixes } return { ok: true, xml: normalized, fixes: model.fixes }
} }
+2 -3
View File
@@ -147,7 +147,7 @@ export function wrapCellsInModel(xml: string): string {
*/ */
export function normalizeToMxfile( export function normalizeToMxfile(
xml: string, xml: string,
opts: { pageId?: string; pageName?: string; host?: string } = {}, opts: { pageId?: string; pageName?: string } = {},
): string | null { ): string | null {
const trimmed = xml.trim() const trimmed = xml.trim()
if (!trimmed) return null if (!trimmed) return null
@@ -156,9 +156,8 @@ export function normalizeToMxfile(
const pageId = opts.pageId || generatePageId() const pageId = opts.pageId || generatePageId()
const pageName = opts.pageName || "Page-1" const pageName = opts.pageName || "Page-1"
const host = opts.host || "app.diagrams.net"
const inner = stripXmlDeclaration(trimmed) const inner = stripXmlDeclaration(trimmed)
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>` return `<mxfile host="app.diagrams.net"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
} }
/** /**
@@ -19,7 +19,6 @@ html, body { width: 100%; height: 100%; overflow: hidden; }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
overflow: hidden; overflow: hidden;
} }
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
#header .title { #header .title {
font-size: 15px; font-weight: 600; color: #1a1a2e; font-size: 15px; font-weight: 600; color: #1a1a2e;
letter-spacing: -0.3px; letter-spacing: -0.3px;
+7 -7
View File
@@ -328,17 +328,17 @@ async function poll() {
// Plain export: capture whatever tab is currently displayed. // Plain export: capture whatever tab is currently displayed.
// //
// Page-targeted export: the server sends a single-page <mxfile> // Page-targeted export: the server sends a single-page <mxfile>
// projection in s.exportXml. We load it into the iframe, let // projection in s.export.xml. We load it into the iframe, let
// draw.io render it, export, then reload the user's real // draw.io render it, export, then reload the user's real
// document — all browser-side. The canonical session state is // document — all browser-side. The canonical session state is
// never mutated, so there is no server-side restore race and no // never mutated, so there is no server-side restore race and no
// dependence on poll timing. autosave is suppressed while the // dependence on poll timing. autosave is suppressed while the
// projection is showing (see projectionExportActive guard). // projection is showing (see projectionExportActive guard).
if (s.exportFormat && !pendingMcpExport && isReady) { if (s.export && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat; pendingMcpExport = s.export.format;
const seq = ++mcpExportSeq; const seq = ++mcpExportSeq;
mcpExportId = s.exportId; mcpExportId = s.export.id;
const extra = s.exportOptions || {}; const extra = s.export.options || {};
const fireExport = () => { const fireExport = () => {
// mcpExport carries this export's number and is echoed // mcpExport carries this export's number and is echoed
// back in msg.message (see the handler). PNG: width // back in msg.message (see the handler). PNG: width
@@ -349,10 +349,10 @@ async function poll() {
: { action: 'export', format: pendingMcpExport, mcpExport: seq }; : { action: 'export', format: pendingMcpExport, mcpExport: seq };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*'); iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
}; };
if (s.exportXml) { if (s.export.xml) {
projectionExportActive = true; projectionExportActive = true;
// Load the projection without touching lastXml/server state. // Load the projection without touching lastXml/server state.
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*'); iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.export.xml, autosave: 0 }), '*');
// Let draw.io render the loaded page before exporting // Let draw.io render the loaded page before exporting
// (same proven settle delay as the AI-preview path). // (same proven settle delay as the AI-preview path).
setTimeout(fireExport, 600); setTimeout(fireExport, 600);
+1 -1
View File
@@ -7,7 +7,7 @@ import { readFile } from "node:fs/promises"
import { dirname, join, resolve } from "node:path" import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
export const SHAPE_LIBRARY_GROUPS: Record<string, string[]> = { const SHAPE_LIBRARY_GROUPS: Record<string, string[]> = {
Cloud: [ Cloud: [
"aws4", "aws4",
"azure2", "azure2",
-6
View File
@@ -224,9 +224,3 @@ export function addDefaultStyles(
return `${current}${separator}${added.join(";")};` return `${current}${separator}${added.join(";")};`
}) })
} }
/** Expand the named styles of a model's XML and add the default styles */
export function expandStyles(xml: string): string {
const { classes, xml: cells } = readStyleClasses(xml)
return addDefaultStyles(applyStyleClasses(cells, classes))
}
+18
View File
@@ -30,6 +30,24 @@ export const STYLE_CLASS_EXAMPLE = `<mxStyle name="step" value="fillColor=#dae8f
<mxCell id="5" style="down;" source="2" target="3"/> <mxCell id="5" style="down;" source="2" target="3"/>
<mxCell id="6" style="down;" source="3" target="4"/>` <mxCell id="6" style="down;" source="3" target="4"/>`
/**
* The style rules shared by the drawing guide and the web app's system
* prompt. They differ only in the tool whose call defines named styles
* ("create_new_diagram or add_page", "display_diagram") and in who expands
* them ("the server", "the app").
*/
export function styleRules(tool: string, actor: string) {
const actorSentence = actor.charAt(0).toUpperCase() + actor.slice(1)
return {
/** Named styles, defined once and used like CSS classes */
namedStyles: `Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each ${tool} call defines the names it uses. ${actorSentence} expands the names, so the saved file is standard draw.io XML.`,
/** html=1 and whiteSpace=wrap are added automatically */
noHtml: `NEVER write html=1 or whiteSpace=wrap: ${actor} adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use &lt;br&gt; for a line break and &lt;b&gt; for bold, never \\n; a literal < or > in a label is written &amp;lt; or &amp;gt;.`,
/** draw.io's own defaults are not written */
noDefaults: `Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.`,
}
}
/** Indent every line, for an indented code block in Markdown */ /** Indent every line, for an indented code block in Markdown */
export const indent = (text: string, prefix = " ") => export const indent = (text: string, prefix = " ") =>
text text
+10 -135
View File
@@ -1,6 +1,6 @@
/** /**
* XML Validation and Auto-Fix for draw.io diagrams * XML validation and auto-fix for draw.io diagrams, shared by the MCP server
* Copied from lib/utils.ts to avoid cross-package imports * and the web app (contexts/diagram-context.tsx)
*/ */
import { readAttributes } from "./xml-attributes.ts" import { readAttributes } from "./xml-attributes.ts"
@@ -10,9 +10,6 @@ import { getXmlSyntaxError } from "./xml-syntax.ts"
// Constants // Constants
// ============================================================================ // ============================================================================
/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */
const MAX_XML_SIZE = 1_000_000
/** Structural attributes that should not be duplicated in draw.io */ /** Structural attributes that should not be duplicated in draw.io */
const STRUCTURAL_ATTRS = [ const STRUCTURAL_ATTRS = [
"edge", "edge",
@@ -23,9 +20,6 @@ const STRUCTURAL_ATTRS = [
"connectable", "connectable",
] ]
/** Valid XML entity names */
const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
/** Element names draw.io understands (case-sensitive) */ /** Element names draw.io understands (case-sensitive) */
const VALID_DRAWIO_TAGS = new Set([ const VALID_DRAWIO_TAGS = new Set([
"mxfile", "mxfile",
@@ -261,77 +255,6 @@ function checkDuplicateIds(xml: string): string | null {
return null return null
} }
/** Check for tag mismatches using parsed tags */
function checkTagMismatches(xml: string): string | null {
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
const tags = parseXmlTags(xmlWithoutComments)
const tagStack: string[] = []
for (const { tagName, isClosing, isSelfClosing } of tags) {
if (isClosing) {
if (tagStack.length === 0) {
return `Invalid XML: Closing tag </${tagName}> without matching opening tag`
}
const expected = tagStack.pop()
if (expected?.toLowerCase() !== tagName.toLowerCase()) {
return `Invalid XML: Expected closing tag </${expected}> but found </${tagName}>`
}
} else if (!isSelfClosing) {
tagStack.push(tagName)
}
}
if (tagStack.length > 0) {
return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}`
}
return null
}
/** Check for invalid character references */
function checkCharacterReferences(xml: string): string | null {
const charRefPattern = /&#x?[^;]+;?/g
let charMatch
while ((charMatch = charRefPattern.exec(xml)) !== null) {
const ref = charMatch[0]
if (ref.startsWith("&#x")) {
if (!ref.endsWith(";")) {
return `Invalid XML: Missing semicolon after hex reference: ${ref}`
}
const hexDigits = ref.substring(3, ref.length - 1)
if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) {
return `Invalid XML: Invalid hex character reference: ${ref}`
}
} else if (ref.startsWith("&#")) {
if (!ref.endsWith(";")) {
return `Invalid XML: Missing semicolon after decimal reference: ${ref}`
}
const decDigits = ref.substring(2, ref.length - 1)
if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) {
return `Invalid XML: Invalid decimal character reference: ${ref}`
}
}
}
return null
}
/** Check for invalid entity references */
function checkEntityReferences(xml: string): string | null {
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g
if (bareAmpPattern.test(xmlWithoutComments)) {
return "Invalid XML: Found unescaped & character(s). Replace & with &amp;"
}
const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g
let entityMatch
while (
(entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null
) {
if (!VALID_ENTITIES.has(entityMatch[1])) {
return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)`
}
}
return null
}
/** Check for nested mxCell tags using regex */ /** Check for nested mxCell tags using regex */
function checkNestedMxCells(xml: string): string | null { function checkNestedMxCells(xml: string): string | null {
// Quoted values may hold ">", so the tag ends at the first ">" outside them // Quoted values may hold ">", so the tag ends at the first ">" outside them
@@ -427,13 +350,6 @@ export function validateMxCellStructure(
xml: string, xml: string,
opts: { strict?: boolean } = {}, opts: { strict?: boolean } = {},
): string | null { ): string | null {
// Size check for performance
if (xml.length > MAX_XML_SIZE) {
console.warn(
`[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`,
)
}
// 0. DOM-based checks. Syntax errors are caught by the strict check at // 0. DOM-based checks. Syntax errors are caught by the strict check at
// the end: linkedom's DOMParser never reports them. // the end: linkedom's DOMParser never reports them.
try { try {
@@ -464,66 +380,24 @@ export function validateMxCellStructure(
) )
} }
// 1. Check for CDATA wrapper (invalid at document root) // 1. Check for duplicate structural attributes
if (/^\s*<!\[CDATA\[/.test(xml)) {
return "Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end"
}
// 2. Check for duplicate structural attributes
const dupAttrError = checkDuplicateAttributes(xml) const dupAttrError = checkDuplicateAttributes(xml)
if (dupAttrError) { if (dupAttrError) {
return dupAttrError return dupAttrError
} }
// 3. Check for unescaped < in attribute values // 2. Check for duplicate IDs
const attrValuePattern = /=\s*"([^"]*)"/g
let attrValMatch
while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {
const value = attrValMatch[1]
if (/</.test(value) && !/&lt;/.test(value)) {
return "Invalid XML: Unescaped < character in attribute values. Replace < with &lt;"
}
}
// 4. Check for duplicate IDs
const dupIdError = checkDuplicateIds(xml) const dupIdError = checkDuplicateIds(xml)
if (dupIdError) { if (dupIdError) {
return dupIdError return dupIdError
} }
// 5. Check for tag mismatches // 3. Check for empty id attributes on mxCell
const tagMismatchError = checkTagMismatches(xml)
if (tagMismatchError) {
return tagMismatchError
}
// 6. Check invalid character references
const charRefError = checkCharacterReferences(xml)
if (charRefError) {
return charRefError
}
// 7. Check for invalid comment syntax (-- inside comments)
const commentPattern = /<!--([\s\S]*?)-->/g
let commentMatch
while ((commentMatch = commentPattern.exec(xml)) !== null) {
if (/--/.test(commentMatch[1])) {
return "Invalid XML: Comment contains -- (double hyphen) which is not allowed"
}
}
// 8. Check for unescaped entity references and invalid entity names
const entityError = checkEntityReferences(xml)
if (entityError) {
return entityError
}
// 9. Check for empty id attributes on mxCell
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
return "Invalid XML: Found mxCell element(s) with empty id attribute" return "Invalid XML: Found mxCell element(s) with empty id attribute"
} }
// 10. Check for nested mxCell tags // 4. Check for nested mxCell tags
const nestedCellError = checkNestedMxCells(xml) const nestedCellError = checkNestedMxCells(xml)
if (nestedCellError) { if (nestedCellError) {
return nestedCellError return nestedCellError
@@ -539,9 +413,10 @@ export function validateMxCellStructure(
} }
} }
// 11. Strict XML syntax check, run last so the checks above can give // 5. Strict XML syntax check (saxes), which reports every
// more specific messages. Catches what they miss, e.g. duplicate or // well-formedness error: unbalanced tags, bad character or entity
// unquoted attributes, which make draw.io refuse to load the diagram. // references, an unescaped < or &, duplicate or unquoted attributes.
// Any of them makes draw.io refuse to load the diagram.
const syntaxError = getXmlSyntaxError(xml) const syntaxError = getXmlSyntaxError(xml)
if (syntaxError) { if (syntaxError) {
return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (&lt; for <, &amp; for &, &quot; for "), quote every attribute value, and do not repeat an attribute.` return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (&lt; for <, &amp; for &, &quot; for "), quote every attribute value, and do not repeat an attribute.`
@@ -5,13 +5,7 @@
*/ */
import { deflateRawSync } from "node:zlib" import { deflateRawSync } from "node:zlib"
import { beforeAll, describe, expect, it, vi } from "vitest" import { describe, expect, it, vi } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { applyDiagramOperations } from "../src/diagram-operations.ts" import { applyDiagramOperations } from "../src/diagram-operations.ts"
const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>` const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
@@ -2,13 +2,7 @@
* Tests for the all-or-nothing edit_diagram core (src/edit-diagram.ts). * Tests for the all-or-nothing edit_diagram core (src/edit-diagram.ts).
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { editDiagram, targetPageXml } from "../src/edit-diagram.ts" import { editDiagram, targetPageXml } from "../src/edit-diagram.ts"
import { validateMxCellStructure } from "../src/xml-validation.ts" import { validateMxCellStructure } from "../src/xml-validation.ts"
+1 -7
View File
@@ -9,13 +9,7 @@
* reads as a user edit. * reads as a user edit.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { import {
checkEditGate, checkEditGate,
contentFingerprint, contentFingerprint,
+1 -7
View File
@@ -2,8 +2,7 @@
* Tests for the diagram history: the versions restore_version can go to. * Tests for the diagram history: the versions restore_version can go to.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { import {
addHistory, addHistory,
clearHistory, clearHistory,
@@ -12,11 +11,6 @@ import {
otherVersions, otherVersions,
} from "../src/history.ts" } from "../src/history.ts"
beforeAll(() => {
// XML parsing for the content fingerprints
installDomPolyfill()
})
const page = (cellId: string) => const page = (cellId: string) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>` `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
+7 -10
View File
@@ -8,7 +8,6 @@
import http from "node:http" import http from "node:http"
import { afterAll, beforeAll, describe, expect, it } from "vitest" import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { addHistory, getHistory } from "../src/history.ts" import { addHistory, getHistory } from "../src/history.ts"
import { import {
drawioEmbedParams, drawioEmbedParams,
@@ -28,8 +27,6 @@ import {
let port = 0 let port = 0
beforeAll(async () => { beforeAll(async () => {
// XML parsing, as the server installs it at startup
installDomPolyfill()
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000)) port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
}) })
@@ -330,15 +327,15 @@ describe("export requests", () => {
const poll = JSON.parse( const poll = JSON.parse(
(await request(`/api/state?sessionId=${id}`)).body, (await request(`/api/state?sessionId=${id}`)).body,
) )
expect(poll.exportFormat).toBe("png") expect(poll.export.format).toBe("png")
expect(poll.exportOptions).toEqual({ width: 1000, pageId: "p2" }) expect(poll.export.options).toEqual({ width: 1000, pageId: "p2" })
await postJson("/api/state", { await postJson("/api/state", {
sessionId: id, sessionId: id,
exportData: "data:image/png;base64,AAAA", exportData: "data:image/png;base64,AAAA",
exportId: poll.exportId, exportId: poll.export.id,
}) })
expect(getState(id)?.exportOptions).toBeUndefined() expect(getState(id)?.pendingExport).toBeUndefined()
}) })
it("ignores a late result of an export that already timed out", async () => { it("ignores a late result of an export that already timed out", async () => {
@@ -353,10 +350,10 @@ describe("export requests", () => {
await postJson("/api/state", { await postJson("/api/state", {
sessionId: id, sessionId: id,
exportData: "data:image/png;base64,LATE", exportData: "data:image/png;base64,LATE",
exportId: first.exportId, exportId: first.export.id,
}) })
expect(getState(id)?.exportData).toBeUndefined() expect(getState(id)?.exportData).toBeUndefined()
expect(getState(id)?.exportFormat).toBe("svg") expect(getState(id)?.pendingExport?.format).toBe("svg")
}) })
}) })
@@ -388,7 +385,7 @@ describe("a session state recreated after it was lost", () => {
stateId, stateId,
}) })
expect(res.status).toBe(409) expect(res.status).toBe(409)
expect(JSON.parse(res.body).stateChanged).toBe(true) expect(JSON.parse(res.body).error).toBe("Session was recreated")
// The saved file was recovered first and is kept // The saved file was recovered first and is kept
expect(getState("mcp-sid-restart")?.xml).toBe(SAVED) expect(getState("mcp-sid-restart")?.xml).toBe(SAVED)
} }
+1 -9
View File
@@ -15,15 +15,7 @@
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface. * (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
// Install the DOM polyfill exactly as index.ts does at runtime — the
// helpers under test rely on it.
beforeAll(() => {
installDomPolyfill()
})
import { applyDiagramOperations } from "../src/diagram-operations.ts" import { applyDiagramOperations } from "../src/diagram-operations.ts"
import { import {
addPageToDoc, addPageToDoc,
@@ -4,13 +4,7 @@
* never sent. * never sent.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { import {
isMxCellXmlComplete, isMxCellXmlComplete,
prepareNewDiagram, prepareNewDiagram,
@@ -15,14 +15,9 @@ import {
} from "node:fs" } from "node:fs"
import { homedir, tmpdir } from "node:os" import { homedir, tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { afterEach, beforeAll, describe, expect, it } from "vitest" import { afterEach, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { Autosaver, defaultDataDir } from "../src/persistence.ts" import { Autosaver, defaultDataDir } from "../src/persistence.ts"
beforeAll(() => {
installDomPolyfill()
})
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>` const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const BLANK = `<mxfile><diagram id="page-1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const BLANK = `<mxfile><diagram id="page-1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+78 -164
View File
@@ -1,7 +1,7 @@
/** /**
* Server-wiring test: boot the actual MCP stdio server (from source via tsx) * Server-wiring test: boot the actual MCP stdio server (from source via tsx)
* and drive it the way a real MCP client does — initialize handshake, * and drive it with the SDK's own client — initialize handshake, tools/list,
* tools/list — to catch registration/schema regressions that the unit tests * tools/call — to catch registration/schema regressions that the unit tests
* (which import helpers directly) can't see. * (which import helpers directly) can't see.
* *
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT * This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
@@ -13,11 +13,13 @@
* window via open(). The browser bridge is covered by the Playwright e2e suite. * window via open(). The browser bridge is covered by the Playwright e2e suite.
*/ */
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { afterAll, beforeAll, describe, expect, it } from "vitest" import { afterAll, beforeAll, describe, expect, it } from "vitest"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -60,115 +62,69 @@ const MAX_DESCRIPTION = 2048
// developer's real ~/.next-ai-drawio/instructions.md must not leak in // developer's real ~/.next-ai-drawio/instructions.md must not leak in
const CUSTOM_RULE = "Always use blue fill #dae8fc." const CUSTOM_RULE = "Always use blue fill #dae8fc."
let proc: ChildProcessWithoutNullStreams let client: Client
let stdoutBuf = ""
const pending = new Map<
number,
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
>()
let nextId = 1
let initResp: any
function send(method: string, params: unknown, isNotification = false) { const listTools = async () => (await client.listTools()).tools
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params } const findTool = async (name: string) =>
if (!isNotification) msg.id = nextId++ (await listTools()).find((t) => t.name === name)
proc.stdin.write(`${JSON.stringify(msg)}\n`) const properties = async (name: string) =>
if (isNotification) return Promise.resolve(undefined) (await findTool(name))?.inputSchema?.properties ?? {}
return new Promise<any>((resolve, reject) => {
const id = msg.id as number /** A tool call's result; the SDK throws on a JSON-RPC error */
const timeout = setTimeout(() => { const callTool = async (name: string, args: Record<string, unknown>) =>
pending.delete(id) (await client.callTool({ name, arguments: args })) as CallToolResult
reject(new Error(`Timed out waiting for response to ${method}`)) const textOf = (result: CallToolResult) =>
}, 15000) (result.content[0] as { text: string }).text
pending.set(id, { resolve, reject, timeout })
})
}
beforeAll(async () => { beforeAll(async () => {
writeFileSync(path.join(dataDir, "instructions.md"), CUSTOM_RULE) writeFileSync(path.join(dataDir, "instructions.md"), CUSTOM_RULE)
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM) writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
proc = spawn(tsxBin, [entry], { client = new Client({ name: "wiring-test", version: "0.0.0" })
stdio: ["pipe", "pipe", "pipe"], await client.connect(
env: { ...process.env, DRAWIO_DATA_DIR: dataDir }, new StdioClientTransport({
}) as ChildProcessWithoutNullStreams command: tsxBin,
args: [entry],
proc.stdout.on("data", (chunk: Buffer) => { env: { ...process.env, DRAWIO_DATA_DIR: dataDir } as Record<
stdoutBuf += chunk.toString() string,
const lines = stdoutBuf.split("\n") string
stdoutBuf = lines.pop() || "" >,
for (const line of lines) { // The server's log lines
const trimmed = line.trim() stderr: "ignore",
if (!trimmed) continue }),
let msg: any )
try { expect(client.getServerVersion()?.name).toBeTruthy()
msg = JSON.parse(trimmed)
} catch {
// Non-JSON-RPC log line — ignore.
continue
}
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
if (p) {
clearTimeout(p.timeout)
pending.delete(msg.id)
p.resolve(msg)
}
}
})
initResp = await send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "wiring-test", version: "0.0.0" },
})
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
expect(initResp.result?.serverInfo?.name).toBeTruthy()
await send("notifications/initialized", {}, true)
}, 30000) }, 30000)
afterAll(() => { afterAll(async () => {
proc?.kill("SIGTERM") await client?.close()
rmSync(dataDir, { recursive: true, force: true }) rmSync(dataDir, { recursive: true, force: true })
}) })
describe("MCP server wiring", () => { describe("MCP server wiring", () => {
it("registers all tools", async () => { it("registers all tools", async () => {
const resp = await send("tools/list", {}) const names = (await listTools()).map((t) => t.name)
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
const names: string[] = (resp.result?.tools ?? []).map(
(t: { name: string }) => t.name,
)
for (const expected of EXPECTED_TOOLS) { for (const expected of EXPECTED_TOOLS) {
expect(names, `missing tool: ${expected}`).toContain(expected) expect(names, `missing tool: ${expected}`).toContain(expected)
} }
}) })
it("advertises page-selector params on edit_diagram", async () => { it("advertises page-selector params on edit_diagram", async () => {
const resp = await send("tools/list", {}) const props = await properties("edit_diagram")
const edit = resp.result.tools.find(
(t: { name: string }) => t.name === "edit_diagram",
)
const props = edit?.inputSchema?.properties ?? {}
expect(props.page_id).toBeTruthy() expect(props.page_id).toBeTruthy()
expect(props.page_name).toBeTruthy() expect(props.page_name).toBeTruthy()
expect(props.page_index).toBeTruthy() expect(props.page_index).toBeTruthy()
}) })
it("advertises session_id on start_session", async () => { it("advertises session_id on start_session", async () => {
const resp = await send("tools/list", {}) const start = await findTool("start_session")
const start = resp.result.tools.find(
(t: { name: string }) => t.name === "start_session",
)
expect(start?.inputSchema?.properties?.session_id).toBeTruthy() expect(start?.inputSchema?.properties?.session_id).toBeTruthy()
expect(start?.inputSchema?.required ?? []).not.toContain("session_id") expect(start?.inputSchema?.required ?? []).not.toContain("session_id")
}) })
it("lists the saved diagrams with their pages, without a session", async () => { it("lists the saved diagrams with their pages, without a session", async () => {
const resp = await send("tools/call", { const result = await callTool("list_saved_diagrams", {})
name: "list_saved_diagrams", expect(result.isError).toBeFalsy()
arguments: {}, const text = textOf(result)
})
expect(resp.result.isError).toBeFalsy()
const text: string = resp.result.content[0].text
const line = text.split("\n").find((l) => l.startsWith(SAVED_ID)) const line = text.split("\n").find((l) => l.startsWith(SAVED_ID))
expect(line, text).toBeTruthy() expect(line, text).toBeTruthy()
expect(line).toContain("pages: Flow (3 cells)") expect(line).toContain("pages: Flow (3 cells)")
@@ -177,81 +133,59 @@ describe("MCP server wiring", () => {
}) })
it("advertises screenshot on create_new_diagram and edit_diagram", async () => { it("advertises screenshot on create_new_diagram and edit_diagram", async () => {
const resp = await send("tools/list", {})
for (const name of ["create_new_diagram", "edit_diagram"]) { for (const name of ["create_new_diagram", "edit_diagram"]) {
const tool = resp.result.tools.find( const props = await properties(name)
(t: { name: string }) => t.name === name, const screenshot = props.screenshot as { type?: string } | undefined
) expect(screenshot?.type, `${name} screenshot`).toBe("boolean")
const props = tool?.inputSchema?.properties ?? {}
expect(props.screenshot?.type, `${name} screenshot`).toBe("boolean")
} }
}) })
it("advertises name/id/xml on add_page", async () => { it("advertises name/id/xml on add_page", async () => {
const resp = await send("tools/list", {}) const props = await properties("add_page")
const addPage = resp.result.tools.find(
(t: { name: string }) => t.name === "add_page",
)
const props = addPage?.inputSchema?.properties ?? {}
expect(props.name).toBeTruthy() expect(props.name).toBeTruthy()
expect(props.id).toBeTruthy() expect(props.id).toBeTruthy()
expect(props.xml).toBeTruthy() expect(props.xml).toBeTruthy()
}) })
it("advertises steps_back on restore_version", async () => { it("advertises steps_back on restore_version", async () => {
const resp = await send("tools/list", {}) const props = await properties("restore_version")
const restore = resp.result.tools.find( expect(props.steps_back).toBeTruthy()
(t: { name: string }) => t.name === "restore_version",
)
expect(restore?.inputSchema?.properties?.steps_back).toBeTruthy()
}) })
it("refuses restore_version without a session", async () => { it("refuses restore_version without a session", async () => {
const resp = await send("tools/call", { const result = await callTool("restore_version", {})
name: "restore_version", expect(result.isError).toBe(true)
arguments: {}, expect(textOf(result)).toContain("start_session")
})
expect(resp.result.isError).toBe(true)
expect(resp.result.content[0].text).toContain("start_session")
}) })
it("keeps every description and the instructions within the host limit", async () => { it("keeps every description and the instructions within the host limit", async () => {
const resp = await send("tools/list", {}) for (const tool of await listTools()) {
for (const tool of resp.result.tools) {
expect( expect(
tool.description.length, tool.description?.length,
`${tool.name} description length`, `${tool.name} description length`,
).toBeLessThanOrEqual(MAX_DESCRIPTION) ).toBeLessThanOrEqual(MAX_DESCRIPTION)
} }
const instructions: string = initResp.result.instructions const instructions = client.getInstructions() ?? ""
expect(instructions).toContain("start_session") expect(instructions).toContain("start_session")
expect(instructions.length).toBeLessThanOrEqual(MAX_DESCRIPTION) expect(instructions.length).toBeLessThanOrEqual(MAX_DESCRIPTION)
}) })
it("serves a shape library without a session", async () => { it("serves a shape library without a session", async () => {
const resp = await send("tools/call", { const result = await callTool("get_shape_library", { library: "AWS4" })
name: "get_shape_library", expect(result.isError).toBeFalsy()
arguments: { library: "AWS4" }, expect(textOf(result)).toContain("mxgraph.aws4")
})
expect(resp.result.isError).toBeFalsy()
expect(resp.result.content[0].text).toContain("mxgraph.aws4")
}) })
it("reports an unknown shape library with the available names", async () => { it("reports an unknown shape library with the available names", async () => {
const resp = await send("tools/call", { const result = await callTool("get_shape_library", {
name: "get_shape_library", library: "../secrets",
arguments: { library: "../secrets" },
}) })
expect(resp.result.isError).toBe(true) expect(result.isError).toBe(true)
expect(resp.result.content[0].text).toContain("kubernetes") expect(textOf(result)).toContain("kubernetes")
}) })
it("serves the drawing guide without a session", async () => { it("serves the drawing guide without a session", async () => {
const resp = await send("tools/call", { const text = textOf(await callTool("get_drawing_guide", {}))
name: "get_drawing_guide",
arguments: {},
})
const text: string = resp.result.content[0].text
expect(text).toContain("Edge routing rules") expect(text).toContain("Edge routing rules")
expect(text).toContain("do not describe the diagram") expect(text).toContain("do not describe the diagram")
expect(text).toContain("clear the canvas") expect(text).toContain("clear the canvas")
@@ -259,31 +193,21 @@ describe("MCP server wiring", () => {
}) })
it("appends the user's instructions.md to the drawing guide", async () => { it("appends the user's instructions.md to the drawing guide", async () => {
const resp = await send("tools/call", { const text = textOf(await callTool("get_drawing_guide", {}))
name: "get_drawing_guide",
arguments: {},
})
const text: string = resp.result.content[0].text
expect( expect(
text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`), text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`),
).toBe(true) ).toBe(true)
}) })
it("advertises topic on get_drawing_guide", async () => { it("advertises topic on get_drawing_guide", async () => {
const resp = await send("tools/list", {}) const props = await properties("get_drawing_guide")
const guide = resp.result.tools.find( expect(props.topic).toBeTruthy()
(t: { name: string }) => t.name === "get_drawing_guide",
)
expect(guide?.inputSchema?.properties?.topic).toBeTruthy()
}) })
it("serves the table XML reference with topic=tables", async () => { it("serves the table XML reference with topic=tables", async () => {
const resp = await send("tools/call", { const result = await callTool("get_drawing_guide", { topic: "tables" })
name: "get_drawing_guide", expect(result.isError).toBeFalsy()
arguments: { topic: "tables" }, const text = textOf(result)
})
expect(resp.result.isError).toBeFalsy()
const text: string = resp.result.content[0].text
expect(text).toContain("shape=table") expect(text).toContain("shape=table")
expect(text).not.toContain("Edge routing rules") expect(text).not.toContain("Edge routing rules")
}) })
@@ -291,10 +215,7 @@ describe("MCP server wiring", () => {
describe("load_diagram dual-source arguments", () => { describe("load_diagram dual-source arguments", () => {
it("advertises both optional 'path' and 'xml' sources", async () => { it("advertises both optional 'path' and 'xml' sources", async () => {
const resp = await send("tools/list", {}) const load = await findTool("load_diagram")
const load = resp.result.tools.find(
(t: { name: string }) => t.name === "load_diagram",
)
const props = load?.inputSchema?.properties ?? {} const props = load?.inputSchema?.properties ?? {}
expect(props.path).toBeTruthy() expect(props.path).toBeTruthy()
expect(props.xml).toBeTruthy() expect(props.xml).toBeTruthy()
@@ -307,32 +228,25 @@ describe("load_diagram dual-source arguments", () => {
// Argument validation fires before the session check: no session // Argument validation fires before the session check: no session
// exists in this harness, so a both-args call must report the // exists in this harness, so a both-args call must report the
// mutual-exclusion error, not "No active session". // mutual-exclusion error, not "No active session".
const resp = await send("tools/call", { const result = await callTool("load_diagram", {
name: "load_diagram", path: "/tmp/x.drawio",
arguments: { path: "/tmp/x.drawio", xml: "<mxfile/>" }, xml: "<mxfile/>",
}) })
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined() expect(result.isError).toBe(true)
expect(resp.result?.isError).toBe(true) expect(textOf(result)).toContain("not both")
expect(resp.result?.content?.[0]?.text).toContain("not both")
}) })
it("rejects passing neither 'path' nor 'xml'", async () => { it("rejects passing neither 'path' nor 'xml'", async () => {
const resp = await send("tools/call", { const result = await callTool("load_diagram", {})
name: "load_diagram", expect(result.isError).toBe(true)
arguments: {}, expect(textOf(result)).toContain("either 'path'")
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("either 'path'")
}) })
it("accepts 'xml' alone as a source (fails only on the missing session)", async () => { it("accepts 'xml' alone as a source (fails only on the missing session)", async () => {
const resp = await send("tools/call", { const result = await callTool("load_diagram", {
name: "load_diagram", xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
arguments: {
xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
},
}) })
expect(resp.result?.isError).toBe(true) expect(result.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("No active session") expect(textOf(result)).toContain("No active session")
}) })
}) })
+5
View File
@@ -0,0 +1,5 @@
// The DOMParser and XMLSerializer globals the XML helpers need, installed
// before every test file as index.ts does at startup
import { installDomPolyfill } from "../src/dom.ts"
installDomPolyfill()
+17 -7
View File
@@ -3,13 +3,7 @@
* a page, like in the web app, and the server adds the wrapper and root cells. * a page, like in the web app, and the server adds the wrapper and root cells.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { prepareNewDiagram, reservedIdError } from "../src/new-diagram.ts" import { prepareNewDiagram, reservedIdError } from "../src/new-diagram.ts"
import { hasCells, wrapCellsInModel } from "../src/pages.ts" import { hasCells, wrapCellsInModel } from "../src/pages.ts"
import { validateAndFixXml } from "../src/xml-validation.ts" import { validateAndFixXml } from "../src/xml-validation.ts"
@@ -100,6 +94,22 @@ describe("hasCells", () => {
hasCells(`<mxCell id = "0"/><mxCell id = "1" parent="0"/>`), hasCells(`<mxCell id = "0"/><mxCell id = "1" parent="0"/>`),
).toBe(false) ).toBe(false)
}) })
it("does not mistake ids that start with 0 or 1 for root cells", () => {
expect(
hasCells(
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="10"/>',
),
).toBe(true)
})
it("counts a cell wrapped in a UserObject", () => {
expect(
hasCells(
'<mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="u" link="x"><mxCell vertex="1" parent="1"/></UserObject>',
),
).toBe(true)
})
}) })
describe("reservedIdError", () => { describe("reservedIdError", () => {
@@ -4,13 +4,7 @@
* whether draw.io renders it as intended is checked by hand. * whether draw.io renders it as intended is checked by hand.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { prepareNewDiagram } from "../src/new-diagram.ts" import { prepareNewDiagram } from "../src/new-diagram.ts"
import { hasCells } from "../src/pages.ts" import { hasCells } from "../src/pages.ts"
import { XML_REFERENCE } from "../src/xml-reference.ts" import { XML_REFERENCE } from "../src/xml-reference.ts"
@@ -7,15 +7,9 @@
* leave valid parts of the document untouched. * leave valid parts of the document untouched.
*/ */
import { beforeAll, describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { getXmlSyntaxError } from "../src/xml-syntax.ts"
beforeAll(() => {
installDomPolyfill()
})
import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts" import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts"
import { getXmlSyntaxError } from "../src/xml-syntax.ts"
import { validateAndFixXml } from "../src/xml-validation.ts" import { validateAndFixXml } from "../src/xml-validation.ts"
/** Bare model with the root cells plus the given cells. */ /** Bare model with the root cells plus the given cells. */
-2
View File
@@ -9,8 +9,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
+1
View File
@@ -4,6 +4,7 @@ export default defineConfig({
test: { test: {
include: ["tests/**/*.test.ts"], include: ["tests/**/*.test.ts"],
environment: "node", environment: "node",
setupFiles: ["tests/setup.ts"],
// The package source uses Node16 module resolution with explicit .js // The package source uses Node16 module resolution with explicit .js
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping // extensions in imports. Vitest+esbuild handles the .ts→.js mapping
// transparently, so no extra alias config is needed. // transparently, so no extra alias config is needed.
+3 -2
View File
@@ -299,8 +299,9 @@ describe("review round: cells the model may write", () => {
expect(outcome.xml).toContain( expect(outcome.xml).toContain(
'<mxCell id="3" value="B" vertex="1" parent="L1"', '<mxCell id="3" value="B" vertex="1" parent="L1"',
) )
expect(foldCells(file, "L1")).toContain( // Folding keeps a parent other than the default layer "1"
'<mxCell id="2" value="Start" style="rounded=1;" x="40" y="40" w="120" h="60"/>', expect(foldCells(file)).toContain(
'<mxCell id="2" value="Start" style="rounded=1;" parent="L1" x="40" y="40" w="120" h="60"/>',
) )
}) })
+1 -176
View File
@@ -14,11 +14,7 @@ import {
wrapCellsInModel, wrapCellsInModel,
} from "@/packages/mcp-server/src/pages.ts" } from "@/packages/mcp-server/src/pages.ts"
import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts" import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts"
import { import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
autoFixXml,
validateAndFixXml,
validateMxCellStructure,
} from "@/packages/mcp-server/src/xml-validation.ts"
const box = (id: string, parent = "1") => const box = (id: string, parent = "1") =>
`<mxCell id="${id}" value="${id}" vertex="1" parent="${parent}"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>` `<mxCell id="${id}" value="${id}" vertex="1" parent="${parent}"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
@@ -93,177 +89,6 @@ describe("MCP diagram modules with a browser DOM", () => {
}) })
}) })
// Repair cases fixed in the web app's own copy before it moved here
const page = (id: string, cells: string) =>
`<diagram name="${id}" id="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
describe("duplicate ids in multi-page documents", () => {
const shape = (id: string, value = "Box") =>
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
it("accepts the same ids on different pages", () => {
const xml = `<mxfile>${page("p1", shape("2"))}${page("p2", shape("2"))}</mxfile>`
expect(validateMxCellStructure(xml)).toBeNull()
})
it("still reports duplicate ids within one page", () => {
const xml = `<mxfile>${page("p1", shape("2") + shape("2"))}${page("p2", "")}</mxfile>`
expect(validateMxCellStructure(xml)).toMatch(/duplicate cell ID/i)
})
it("does not rename the root cells of other pages when fixing", () => {
const xml = `<mxfile>${page("p1", shape("2", "R&D"))}${page("p2", shape("3"))}</mxfile>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
expect(result.fixed).not.toContain("_dup")
expect(result.fixed).toContain("R&amp;D")
})
it("renames a duplicate id in a bare model, as display_diagram has", () => {
// In an <mxfile> the duplicate is reported instead (above)
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${shape("d") + shape("d")}</root></mxGraphModel>`
const { fixed } = autoFixXml(xml)
expect(fixed).toContain('<mxCell id="d" ')
expect(fixed).toContain('<mxCell id="d_dup1" ')
})
})
describe("autoFixXml", () => {
it("does not insert a space at the start of style values", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="R&D" style="rounded=1;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel>`
const { fixed } = autoFixXml(xml)
expect(fixed).toContain('style="rounded=1;whiteSpace=wrap;"')
})
it("adds a missing space between attributes", () => {
const xml = `<mxCell id="2" vertex="1"parent="1"/>`
expect(autoFixXml(xml).fixed).toContain('vertex="1" parent="1"')
})
it("keeps &quot; inside rich text labels", () => {
const label = "&lt;font color=&quot;#ff0000&quot;&gt;Hello&lt;/font&gt;"
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="${label}" style="html=1;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="3" value="Q&A" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
expect(result.fixed).toContain(`value="${label}"`)
})
it("fixes an attribute delimited by &quot;", () => {
const xml = `<mxCell id="2" dashPattern=&quot;1 1;&quot; vertex="1" parent="1"/>`
expect(autoFixXml(xml).fixed).toContain('dashPattern="1 1;"')
})
it("keeps cells written on one line next to multi-line cells", () => {
const xml = `<mxGraphModel><root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Q&A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="e1" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="3" value="B" vertex="1" parent="1">
<mxGeometry x="200" y="0" width="80" height="40" as="geometry"/>
</mxCell>
</root></mxGraphModel>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
for (const id of ["2", "e1", "3"]) {
expect(result.fixed).toContain(`<mxCell id="${id}"`)
}
})
it("keeps object and UserObject wrappers", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="2" label="Docs" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><object id="3" label="A&B" owner="me"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></object></root></mxGraphModel>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
expect(result.fixed).toContain('<UserObject id="2"')
expect(result.fixed).toContain('<object id="3"')
})
})
describe("hasCells (was isMinimalDiagram in the web app)", () => {
it("returns true for empty diagram", () => {
const xml = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
expect(hasCells(xml)).toBe(false)
})
it("returns false for diagram with content", () => {
const xml =
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="Hello"/>'
expect(hasCells(xml)).toBe(true)
})
it("handles whitespace correctly", () => {
const xml = ' <mxCell id="0"/> <mxCell id="1" parent="0"/> '
expect(hasCells(xml)).toBe(false)
})
it("returns false for a shape drawn in draw.io with a random id", () => {
const xml =
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="xY3kQ9-1" value="" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="10" y="10" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>'
expect(hasCells(xml)).toBe(true)
})
it("does not mistake ids that start with 0 or 1 for root cells", () => {
const xml =
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="10"/>'
expect(hasCells(xml)).toBe(true)
})
it("counts a cell wrapped in a UserObject", () => {
const xml =
'<mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="u" link="x"><mxCell vertex="1" parent="1"/></UserObject>'
expect(hasCells(xml)).toBe(true)
})
})
describe("applyDiagramOperations with wrapped cells", () => {
const xml = `<mxfile><diagram id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="5" label="Docs" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="6" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="e1" edge="1" parent="1" source="5" target="6"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
it("deletes a wrapped cell and its edges", () => {
const { result, errors } = applyDiagramOperations(xml, [
{ operation: "delete", cell_id: "5" },
{ operation: "delete", cell_id: "e1" },
])
expect(errors).toEqual([])
expect(result).not.toContain("UserObject")
expect(result).not.toContain('id="e1"')
expect(result).toContain('id="6"')
})
it("rejects adding a cell with the id of a wrapped cell", () => {
const { errors } = applyDiagramOperations(xml, [
{
operation: "add",
cell_id: "5",
new_xml: '<mxCell id="5" vertex="1" parent="1"/>',
},
])
expect(errors[0]?.message).toContain("already exists")
})
it("updates a wrapped cell", () => {
const { result, errors } = applyDiagramOperations(xml, [
{
operation: "update",
cell_id: "5",
new_xml:
'<UserObject id="5" label="New" link="https://example.org"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>',
},
])
expect(errors).toEqual([])
expect(result).toContain('label="New"')
expect(result).not.toContain('label="Docs"')
})
it("reports deleting a cell that does not exist", () => {
const { errors } = applyDiagramOperations(xml, [
{ operation: "delete", cell_id: "missing" },
])
expect(errors[0]?.message).toContain("not found")
})
})
describe("edit_diagram on one page of a multi-page file", () => { describe("edit_diagram on one page of a multi-page file", () => {
// Page A has an edge "g"; on page B, "g" is a shape on layer "L" // Page A has an edge "g"; on page B, "g" is a shape on layer "L"
const twoPages = `<mxfile><diagram id="pa" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("s")}${box("t")}<mxCell id="g" edge="1" parent="1" source="s" target="t"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="pb" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="L" parent="0"/>${box("g", "L")}</root></mxGraphModel></diagram></mxfile>` const twoPages = `<mxfile><diagram id="pa" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("s")}${box("t")}<mxCell id="g" edge="1" parent="1" source="s" target="t"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="pb" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="L" parent="0"/>${box("g", "L")}</root></mxGraphModel></diagram></mxfile>`
+1 -1
View File
@@ -445,7 +445,7 @@ describe("MCP preview History and a recreated session", () => {
} }
const historyList = { const historyList = {
status: 200, status: 200,
body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 }, body: { entries: [{ id: 5, index: 0, svg: "" }] },
} }
const modalOpen = () => const modalOpen = () =>
document.getElementById("history-modal")?.classList.contains("open") document.getElementById("history-modal")?.classList.contains("open")
+6 -1
View File
@@ -9,10 +9,15 @@ import {
import { import {
addDefaultStyles, addDefaultStyles,
applyStyleClasses, applyStyleClasses,
expandStyles,
readStyleClasses, readStyleClasses,
} from "@/packages/mcp-server/src/style-classes.ts" } from "@/packages/mcp-server/src/style-classes.ts"
/** The named styles of a model's XML expanded, with the default styles */
const expandStyles = (xml: string) => {
const { classes, xml: cells } = readStyleClasses(xml)
return addDefaultStyles(applyStyleClasses(cells, classes))
}
const geometry = const geometry =
'<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>' '<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>'
const cell = (id: string, style: string, extra = "") => const cell = (id: string, style: string, extra = "") =>