mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
* feat(mcp): add multi-page (mxfile) support
The MCP server's write path could only address a single drawio page even
though the underlying .drawio file format and the embedded editor both
natively support multi-page documents. A user asking for "a second page
with a CNN diagram" would hit the validator with the error
"Expected closing tag </root> but found </mxCell>" because the validator
assumed input was a bare <mxGraphModel> and could not walk past the
<mxfile><diagram>...</diagram></mxfile> wrapper.
This patch closes the gap end to end:
* New helper module `pages.ts` centralises page CRUD (normalize, parse,
list, find, add, rename, delete) so every layer agrees that the
canonical in-memory shape is always <mxfile>. normalizeToMxfile and
addPageToDoc both strip any leading <?xml ?> declaration before
embedding a fragment inside <diagram> (the declaration is only valid
at document start). addPageToDoc explicitly rejects full <mxfile>
inputs so a caller cannot accidentally nest a document inside a page.
* `xml-validation.ts` now detects an <mxfile> root and scopes the
duplicate-id check per <diagram>. The legacy regex check would
otherwise reject every multi-page doc, because cells "0" and "1"
repeat in each page's <root> by design. The DOM-parse path is gated
by a cheap regex pre-check so legacy bare <mxGraphModel> callers
don't pay any extra cost. The autoFix duplicate-id rename step is
also guarded against mxfile inputs — renaming those sentinel cells
would silently break drawio's parent references.
* `diagram-operations.ts` accepts an optional PageSelector. For
<mxfile> input it resolves the page first and scopes all
querySelectorAll calls to that page's <root>, so a delete on page 2's
cell "2" no longer touches page 1's cell "2".
* `create_new_diagram` accepts either a bare <mxGraphModel> (legacy,
auto-wrapped into a single-page mxfile) or a full <mxfile> with N
diagrams. All existing single-page callers keep working unchanged.
* `edit_diagram`, `get_diagram`, and `export_diagram` gain optional
`page_id` / `page_name` / `page_index` parameters. When omitted they
target the first page — the "active by convention" default. Tool
handlers with all-optional input schemas coalesce missing arguments
via `input ?? {}` so a no-args MCP invocation can't crash on
destructure before reaching the session-existence check.
* New tools: `list_pages`, `add_page`, `rename_page`, `delete_page`.
* Page-targeted PNG/SVG export uses a "load + export + restore" dance:
the server projects the target page into a single-page <mxfile>,
pushes it into the transient state so the browser reloads the iframe
with just that page, waits for drawio to render (~3s), triggers the
export, captures the data, and then restores the original multi-page
document. The dance is wrapped in `try/finally` so the restore runs
unconditionally — even if an exception is thrown mid-dance, the
user's multi-tab view is recovered before the function returns.
The earlier attempt to use drawio's `selectPage` postMessage was a
no-op because drawio's JSON embed protocol does not expose that
action — silently exporting whatever tab happened to be active. The
load-export-restore approach trades a brief visible tab-flicker for
correctness: the exported image is guaranteed to match the requested
page.
* Tool description strings reflect the multi-page semantics so the LLM
client learns the new contract.
* Package version bumped 0.2.0 → 0.3.0 (additive surface — four new
tools, three extended input schemas, canonical XML shape change).
* CI: `.github/workflows/test.yml` gains an explicit install + vitest
run for the mcp-server package so the new multi-page invariants are
covered by automation, not just local runs.
Backward compatibility: every existing single-page caller continues to
work without modification. The session.xml shape is normalised on every
write, removing the wrapper-injection hack from the .drawio download
path.
Tests: 43 unit tests under `packages/mcp-server/tests/multi-page.test.ts`
pin the validator's mxfile path, the page-scoped operations, the XML
declaration-prefix handling for both normalizeToMxfile and addPageToDoc,
addPageToDoc's rejection of full <mxfile> inputs, the single-page
projection used by export_diagram (a direct regression test for the
selectPage bug — two distinct page selectors must produce visually
different projections), and the Transformer + CNN motivating scenario.
A `tests/smoke.mjs` smoke test drives the built `dist/index.js` over
JSON-RPC and asserts all 9 tools register with the right input schemas.
Root vitest suite (107 tests) still green.
* fix(mcp): rewrite page-targeted export browser-side; harden edit/get
The page-targeted PNG/SVG export never worked: export_diagram swapped the
live session to a single-page projection, slept 3s, then wrote the export
flag onto a state object that setState() had already replaced in the store
Map — so the browser never saw the request and every such export timed out.
The swap+restore also clobbered concurrent edits.
Move the projection entirely browser-side: requestExport() hands a single
-page <mxfile> to the bridge via state.exportXml; the bridge loads it,
lets draw.io render, exports, then reloads the user's real document. The
canonical session state is never mutated, so there is no restore race and
no fixed-delay guessing. The export poll now re-reads the live store entry
each tick instead of a captured reference. autosave is suppressed and the
version-bump reload is skipped while a projection is on screen; if no real
document was captured, restore forces a server reload rather than leaving
the iframe stuck on the projection.
Also:
- edit_diagram now returns isError on a page-level failure (selector matched
no page / page has no <root>) instead of reporting success-with-warnings
and persisting a no-op; the pre-edit history snapshot is taken only after
that gate so a failed edit leaves no phantom undo entry.
- edit_diagram/get_diagram re-normalise browser-pushed xml to mxfile so a
bare <mxGraphModel> can't silently strip a multi-page document.
- get_diagram now errors (instead of silently returning the full doc) when a
selector is given but the session isn't a parseable mxfile.
- page_id / page_name / add_page.id get .min(1) so empty strings can't
silently target the first page.
- Extract pages.ts:projectPage(), collapsing three copies of the
parse→find→serialise projection logic in index.ts.
- Replace the never-in-CI tests/smoke.mjs with tests/server-wiring.test.ts,
which boots the server from source via tsx and runs under the existing
vitest CI step.
* chore(mcp): set version to 0.2.1 for release
---------
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
/**
|
|
* Multi-page (mxfile) helpers for draw.io diagrams.
|
|
*
|
|
* The on-disk and embed-protocol shape of a draw.io document is:
|
|
*
|
|
* <mxfile host="...">
|
|
* <diagram id="..." name="...">
|
|
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
|
|
* </diagram>
|
|
* ...one or more <diagram> children...
|
|
* </mxfile>
|
|
*
|
|
* This module centralises page CRUD so that index.ts, xml-validation.ts,
|
|
* and diagram-operations.ts can all agree on:
|
|
* - what "the canonical in-memory shape" is (always mxfile),
|
|
* - how to find a page (id, name, or index),
|
|
* - how to add/rename/delete pages without re-parsing ad-hoc.
|
|
*/
|
|
|
|
import { DOMParser } from "linkedom"
|
|
|
|
export interface PageInfo {
|
|
id: string
|
|
name: string
|
|
index: number
|
|
cellCount: number
|
|
}
|
|
|
|
/** Selector used by all multi-page-aware tools. All fields optional. */
|
|
export interface PageSelector {
|
|
page_id?: string
|
|
page_name?: string
|
|
page_index?: number
|
|
}
|
|
|
|
/** True if the selector targets a specific page (any field set). */
|
|
export function hasPageSelector(s?: PageSelector | null): boolean {
|
|
if (!s) return false
|
|
return (
|
|
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Generate a short page id similar in shape to drawio's auto-assigned ids.
|
|
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
|
|
* uses short ids; collisions are still astronomically unlikely for one session.
|
|
*/
|
|
export function generatePageId(): string {
|
|
const a = Math.random().toString(36).substring(2, 10)
|
|
const b = Math.random().toString(36).substring(2, 6)
|
|
return `${a}-${b}`
|
|
}
|
|
|
|
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
|
export function isMxFile(xml: string): boolean {
|
|
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
|
}
|
|
|
|
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
|
|
export function isMxGraphModel(xml: string): boolean {
|
|
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
|
|
}
|
|
|
|
function escapeAttr(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
}
|
|
|
|
/**
|
|
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
|
|
* only permits the declaration at the very start of a document, so embedding
|
|
* a declaration inside another element produces invalid XML. Callers must
|
|
* strip before splicing a fragment into a wrapper.
|
|
*/
|
|
function stripXmlDeclaration(xml: string): string {
|
|
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
|
|
}
|
|
|
|
/**
|
|
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
|
* If the input is already an mxfile, returns it unchanged.
|
|
* If the input is neither shape, returns null so the caller can surface a clear error.
|
|
*
|
|
* Strips any leading <?xml ?> declaration before embedding — a declaration is
|
|
* only valid at the very start of a document, never inside a <diagram>.
|
|
*/
|
|
export function normalizeToMxfile(
|
|
xml: string,
|
|
opts: { pageId?: string; pageName?: string; host?: string } = {},
|
|
): string | null {
|
|
const trimmed = xml.trim()
|
|
if (!trimmed) return null
|
|
if (isMxFile(trimmed)) return trimmed
|
|
if (!isMxGraphModel(trimmed)) return null
|
|
|
|
const pageId = opts.pageId || generatePageId()
|
|
const pageName = opts.pageName || "Page-1"
|
|
const host = opts.host || "app.diagrams.net"
|
|
const inner = stripXmlDeclaration(trimmed)
|
|
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
|
}
|
|
|
|
/**
|
|
* Parse an mxfile XML string. Returns null on parse error or if the root
|
|
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
|
|
*/
|
|
export function parseMxfile(xml: string): Document | null {
|
|
try {
|
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
if (doc.querySelector("parsererror")) return null
|
|
if (doc.documentElement?.tagName !== "mxfile") return null
|
|
return doc as unknown as Document
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
|
|
export function serializeMxfile(doc: Document): string {
|
|
const serializer = new XMLSerializer()
|
|
return serializer.serializeToString(doc)
|
|
}
|
|
|
|
export type PageProjection =
|
|
| { ok: true; xml: string; index: number; name: string }
|
|
| { ok: false; reason: "parse" | "notfound" }
|
|
|
|
/**
|
|
* Project a single page out of an mxfile string into a standalone one-page
|
|
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
|
|
* share one parse → find → serialise path.
|
|
*
|
|
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
|
|
* or { ok:false, reason:"notfound" } if the selector matches no page.
|
|
*/
|
|
export function projectPage(
|
|
xml: string,
|
|
selector: PageSelector,
|
|
): PageProjection {
|
|
const doc = parseMxfile(xml)
|
|
if (!doc) return { ok: false, reason: "parse" }
|
|
const found = findPageElement(doc, selector)
|
|
if (!found) return { ok: false, reason: "notfound" }
|
|
const serializer = new XMLSerializer()
|
|
return {
|
|
ok: true,
|
|
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
|
|
index: found.index,
|
|
name: found.element.getAttribute("name") || "",
|
|
}
|
|
}
|
|
|
|
/** Walk every <diagram> child of <mxfile> and return summary info. */
|
|
export function listPagesFromDoc(doc: Document): PageInfo[] {
|
|
const diagrams = doc.querySelectorAll("diagram")
|
|
const result: PageInfo[] = []
|
|
diagrams.forEach((d, idx) => {
|
|
const root = d.querySelector("root")
|
|
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
|
|
result.push({
|
|
id: d.getAttribute("id") || "",
|
|
name: d.getAttribute("name") || `Page-${idx + 1}`,
|
|
index: idx,
|
|
cellCount,
|
|
})
|
|
})
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Resolve a page selector to its <diagram> element.
|
|
* Resolution order: page_id → page_name → page_index → default (first page).
|
|
*
|
|
* When no selector field is set we return the first page — the "active page
|
|
* by convention" mentioned in §3.4 of the design doc.
|
|
*/
|
|
export function findPageElement(
|
|
doc: Document,
|
|
selector?: PageSelector,
|
|
): { element: Element; index: number } | null {
|
|
const diagrams = Array.from(doc.querySelectorAll("diagram"))
|
|
if (diagrams.length === 0) return null
|
|
|
|
if (!hasPageSelector(selector)) {
|
|
return { element: diagrams[0], index: 0 }
|
|
}
|
|
|
|
if (selector?.page_id) {
|
|
for (let i = 0; i < diagrams.length; i++) {
|
|
if (diagrams[i].getAttribute("id") === selector.page_id) {
|
|
return { element: diagrams[i], index: i }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
if (selector?.page_name) {
|
|
for (let i = 0; i < diagrams.length; i++) {
|
|
if (diagrams[i].getAttribute("name") === selector.page_name) {
|
|
return { element: diagrams[i], index: i }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
if (selector && selector.page_index !== undefined) {
|
|
const idx = selector.page_index
|
|
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
|
|
return { element: diagrams[idx], index: idx }
|
|
}
|
|
return null
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
|
|
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
|
|
*
|
|
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
|
|
* end up nested inside <diagram>, which is malformed. We reject the mxfile
|
|
* shape explicitly and strip any <?xml ?> declaration (only valid at
|
|
* document start, never inside <diagram>).
|
|
*
|
|
* Returns the new PageInfo. Throws if the requested id collides or the xml
|
|
* shape is wrong.
|
|
*/
|
|
export function addPageToDoc(
|
|
doc: Document,
|
|
opts: { id?: string; name?: string; xml?: string } = {},
|
|
): PageInfo {
|
|
const existing = listPagesFromDoc(doc)
|
|
const id = opts.id || generatePageId()
|
|
if (existing.some((p) => p.id === id)) {
|
|
throw new Error(`Page id "${id}" already exists`)
|
|
}
|
|
const name = opts.name || `Page-${existing.length + 1}`
|
|
|
|
let inner: string
|
|
if (opts.xml?.trim()) {
|
|
const trimmed = stripXmlDeclaration(opts.xml.trim())
|
|
if (isMxFile(trimmed)) {
|
|
throw new Error(
|
|
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
|
|
)
|
|
}
|
|
if (!isMxGraphModel(trimmed)) {
|
|
throw new Error(
|
|
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
|
|
)
|
|
}
|
|
inner = trimmed
|
|
} else {
|
|
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
|
}
|
|
|
|
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
|
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
|
|
if (tempDoc.querySelector("parsererror")) {
|
|
throw new Error(
|
|
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
|
|
)
|
|
}
|
|
const newDiagram = tempDoc.querySelector("diagram")
|
|
if (!newDiagram) {
|
|
throw new Error("Failed to construct <diagram> element for new page")
|
|
}
|
|
|
|
const imported = doc.importNode(newDiagram, true) as Element
|
|
doc.documentElement.appendChild(imported)
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
index: existing.length,
|
|
cellCount: imported.querySelectorAll("mxCell").length,
|
|
}
|
|
}
|
|
|
|
/** Rename the page matched by selector. Returns true on success. */
|
|
export function renamePageInDoc(
|
|
doc: Document,
|
|
selector: PageSelector,
|
|
newName: string,
|
|
): boolean {
|
|
const found = findPageElement(doc, selector)
|
|
if (!found) return false
|
|
found.element.setAttribute("name", newName)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Delete a page. Refuses to delete the last remaining page — the embed needs
|
|
* at least one diagram to render anything, and silently recreating one would
|
|
* be surprising behaviour for an MCP caller.
|
|
*/
|
|
export function deletePageFromDoc(
|
|
doc: Document,
|
|
selector: PageSelector,
|
|
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
|
|
const pages = listPagesFromDoc(doc)
|
|
if (pages.length <= 1) {
|
|
return { ok: false, reason: "Cannot delete the only remaining page" }
|
|
}
|
|
const found = findPageElement(doc, selector)
|
|
if (!found) {
|
|
return { ok: false, reason: "Page not found" }
|
|
}
|
|
const id = found.element.getAttribute("id") || ""
|
|
const index = found.index
|
|
found.element.parentNode?.removeChild(found.element)
|
|
return { ok: true, deletedId: id, deletedIndex: index }
|
|
}
|