From 4b072283202d3fe4869acd847ee897ad1165d73d Mon Sep 17 00:00:00 2001 From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:54:42 +0900 Subject: [PATCH] feat(mcp): add load_diagram tool to load .drawio files into the session (#893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): add load_diagram tool to load .drawio files into the session Loading a file previously required the agent to read the file itself and pass the entire XML through create_new_diagram - wasteful for large diagrams and impossible for draw.io's compressed save format. load_diagram takes a file path; the server reads it, decompresses any compressed pages (base64 -> raw deflate -> URI-decode, per page), and replaces the session document. The loaded XML is deliberately NOT marked as seen by the edit gate: the model only supplied a path, so it must call get_diagram once before editing. * chore(mcp): version 0.2.3 * fix(mcp): report package.json version in the MCP handshake The McpServer metadata version was a separate hardcoded string that never matched the published version (stuck at 0.1.2, then 0.3.0 while npm shipped 0.2.x). Read it from package.json at startup instead — works from both src/ (tsx) and dist/ (published build). --- packages/mcp-server/README.md | 7 +- packages/mcp-server/package-lock.json | 4 +- packages/mcp-server/package.json | 2 +- packages/mcp-server/src/index.ts | 134 +++++++++++++++++- packages/mcp-server/src/load-diagram.ts | 101 +++++++++++++ .../mcp-server/tests/load-diagram.test.ts | 126 ++++++++++++++++ .../mcp-server/tests/server-wiring.test.ts | 1 + 7 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 packages/mcp-server/src/load-diagram.ts create mode 100644 packages/mcp-server/tests/load-diagram.test.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 4298689..9e19b86 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -116,9 +116,14 @@ Use the standard MCP configuration with: |------|-------------| | `start_session` | Opens browser with real-time diagram preview | | `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) | +| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) | | `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) | | `get_diagram` | Get the current diagram XML | -| `export_diagram` | Save diagram to a `.drawio` file | +| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file | +| `list_pages` | List every page (tab) with id, name, index, and cell count | +| `add_page` | Append a new page without touching existing ones | +| `rename_page` | Rename a page | +| `delete_page` | Delete a page (refuses to delete the last one) | ## How It Works diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index c5cc6c1..45ca846 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 6069ba1..3143eb2 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 1b0ae59..5a18095 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -36,6 +36,7 @@ class XMLSerializerPolyfill { } ;(globalThis as any).XMLSerializer = XMLSerializerPolyfill +import { createRequire } from "node:module" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import open from "open" @@ -55,6 +56,7 @@ import { startHttpServer, waitForSync, } from "./http-server.js" +import { parseDrawioFileContent } from "./load-diagram.js" import { log } from "./logger.js" import { addPageToDoc, @@ -88,10 +90,17 @@ let currentSession: { lastSeenXml: string } | null = null -// Create MCP server +// Create MCP server. The version reported in the MCP handshake is read from +// package.json so it can never drift from the published npm version again +// (it sat hardcoded at stale values for most of this package's history). +// Both src/ (tsx dev) and dist/ (published build) live one level below the +// package root, so the relative path works in either runtime. +const require = createRequire(import.meta.url) +const packageVersion: string = require("../package.json").version + const server = new McpServer({ name: "next-ai-drawio", - version: "0.3.0", + version: packageVersion, }) // Shared Zod schema fragment for page-targeting parameters. @@ -164,6 +173,10 @@ server.prompt( 1. Call start_session to open the browser preview 2. Use create_new_diagram with either a bare (single page) or a full with one or more children (multi-page) +## Opening an Existing .drawio File +- Use load_diagram with the file path — the server reads and decompresses the file itself; don't read it and pass the XML through create_new_diagram +- After loading, call get_diagram once before editing (you haven't seen the file's cell IDs yet) + ## Working with Multiple Pages - Use list_pages to discover existing pages (id, name, index) - Use add_page to append a new page (without losing existing ones — unlike create_new_diagram which REPLACES everything) @@ -421,6 +434,123 @@ COMMON STYLES: }, ) +// Tool: load_diagram +server.registerTool( + "load_diagram", + { + description: + "Load a .drawio file from disk into the current session, REPLACING the entire diagram (all pages). " + + "The server reads the file directly — you do NOT need to read the file yourself or pass its XML through create_new_diagram. " + + "Handles both plain-XML and draw.io's compressed save format.\n\n" + + "After loading, call get_diagram before edit_diagram — you haven't seen the file's cell IDs or structure yet.", + inputSchema: { + path: z + .string() + .describe( + "Path to the .drawio file to load (e.g., ./diagram.drawio)", + ), + }, + }, + async ({ path }) => { + try { + if (!currentSession) { + return { + content: [ + { + type: "text", + text: "Error: No active session. Please call start_session first.", + }, + ], + isError: true, + } + } + + const fs = await import("node:fs/promises") + const nodePath = await import("node:path") + const absolutePath = nodePath.resolve(path) + + let content: string + try { + content = await fs.readFile(absolutePath, "utf-8") + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { + content: [ + { + type: "text", + text: `Error: Cannot read file ${absolutePath}: ${msg}`, + }, + ], + isError: true, + } + } + + const loaded = parseDrawioFileContent(content) + if (!loaded.ok) { + return { + content: [{ type: "text", text: `Error: ${loaded.error}` }], + isError: true, + } + } + const xml = loaded.xml + + log.info( + `Loading diagram from ${absolutePath} (${xml.length} chars)`, + ) + + // Save the user's current state before replacing (same flow as + // create_new_diagram). + const browserState = getState(currentSession.id) + if (browserState?.xml) { + currentSession.xml = browserState.xml + } + if (currentSession.xml) { + addHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) + } + + currentSession.xml = xml + currentSession.version++ + setState(currentSession.id, xml) + // Deliberately NOT marking the loaded XML as seen: the model only + // supplied a path, so it doesn't know the file's cell IDs. The + // edit gate will require one get_diagram before edits. + currentSession.lastSeenXml = "" + + addHistory(currentSession.id, xml, "") + + const doc = parseMxfile(xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageSummary = + pages.length > 0 + ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` + : "no pages parsed" + + log.info(`Diagram loaded from file (${pageSummary})`) + + return { + content: [ + { + type: "text", + text: `Diagram loaded from ${absolutePath}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet.`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("load_diagram failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + // Tool: edit_diagram server.registerTool( "edit_diagram", diff --git a/packages/mcp-server/src/load-diagram.ts b/packages/mcp-server/src/load-diagram.ts new file mode 100644 index 0000000..19663b9 --- /dev/null +++ b/packages/mcp-server/src/load-diagram.ts @@ -0,0 +1,101 @@ +/** + * File-loading helpers for the load_diagram tool. + * + * A .drawio file is an whose children hold each page's + * either as plain XML or — draw.io's default save format — + * compressed: encodeURIComponent(xml) → raw deflate → base64 as the + * diagram's text content. The rest of the server assumes plain XML inside + * every , so loading decompresses all pages up front. + */ +import { inflateRawSync } from "node:zlib" +import { DOMParser } from "linkedom" +import { + isMxFile, + isMxGraphModel, + normalizeToMxfile, + parseMxfile, + serializeMxfile, +} from "./pages.js" + +export type LoadResult = + | { ok: true; xml: string } + | { ok: false; error: string } + +/** + * Decode one compressed page body (base64 → raw deflate → URI-decode). + * Returns null if the text isn't in that format. + */ +export function decompressPageContent(compressed: string): string | null { + try { + const inflated = inflateRawSync( + Buffer.from(compressed.trim(), "base64"), + ).toString("utf-8") + try { + return decodeURIComponent(inflated) + } catch { + // Not URI-encoded (older files) — the inflated text is the XML. + return inflated + } + } catch { + return null + } +} + +/** + * Parse the content of a .drawio file into the canonical session shape: + * an whose every page holds plain XML. Accepts a + * bare (wrapped into a one-page mxfile) and decompresses + * any compressed pages. + */ +export function parseDrawioFileContent(content: string): LoadResult { + const trimmed = content.trim() + if (!trimmed) return { ok: false, error: "File is empty." } + + if (isMxGraphModel(trimmed)) { + const normalized = normalizeToMxfile(trimmed) + return normalized + ? { ok: true, xml: normalized } + : { ok: false, error: "Failed to parse XML." } + } + if (!isMxFile(trimmed)) { + return { + ok: false, + error: "Not a draw.io file: expected an or root element.", + } + } + const doc = parseMxfile(trimmed) + if (!doc) return { ok: false, error: "Failed to parse XML." } + + let decompressedAny = false + for (const d of Array.from(doc.querySelectorAll("diagram"))) { + if (d.querySelector("mxGraphModel")) continue + const text = (d.textContent || "").trim() + if (!text) continue // an empty page is valid + const pageLabel = + d.getAttribute("name") || d.getAttribute("id") || "unnamed" + const xml = decompressPageContent(text) + if (!xml || !isMxGraphModel(xml)) { + return { + ok: false, + error: `Page "${pageLabel}" has content that is neither plain XML nor draw.io's compressed format.`, + } + } + const inner = new DOMParser().parseFromString(xml, "text/xml") + if ( + inner.querySelector("parsererror") || + inner.documentElement?.tagName !== "mxGraphModel" + ) { + return { + ok: false, + error: `Page "${pageLabel}" decompressed but its XML failed to parse.`, + } + } + d.textContent = "" + d.appendChild( + doc.importNode(inner.documentElement as unknown as Node, true), + ) + decompressedAny = true + } + // Nothing changed — keep the file's own serialisation. + return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed } +} diff --git a/packages/mcp-server/tests/load-diagram.test.ts b/packages/mcp-server/tests/load-diagram.test.ts new file mode 100644 index 0000000..e2036b0 --- /dev/null +++ b/packages/mcp-server/tests/load-diagram.test.ts @@ -0,0 +1,126 @@ +/** + * Unit tests for load_diagram's file parsing (load-diagram.ts). + * + * A .drawio file stores each page's either as plain XML or + * as draw.io's compressed default (encodeURIComponent → raw deflate → + * base64 text content). The loader must produce the canonical session + * shape: an whose every page is plain XML. + */ + +import { deflateRawSync } from "node:zlib" +import { DOMParser } from "linkedom" +import { beforeAll, describe, expect, it } from "vitest" + +// Install the DOM polyfills exactly as index.ts does at runtime. +beforeAll(() => { + ;(globalThis as any).DOMParser = DOMParser + class XMLSerializerPolyfill { + serializeToString(node: any): string { + if (node.outerHTML !== undefined) return node.outerHTML + if (node.documentElement) return node.documentElement.outerHTML + return "" + } + } + ;(globalThis as any).XMLSerializer = XMLSerializerPolyfill +}) + +import { + decompressPageContent, + parseDrawioFileContent, +} from "../src/load-diagram.js" + +const MODEL_XML = `` + +/** Compress a page body exactly the way draw.io does when saving. */ +function drawioCompress(xml: string): string { + return deflateRawSync( + Buffer.from(encodeURIComponent(xml), "utf-8"), + ).toString("base64") +} + +const PLAIN_MXFILE = `${MODEL_XML}` +const COMPRESSED_MXFILE = `${drawioCompress(MODEL_XML)}` + +describe("decompressPageContent", () => { + it("round-trips draw.io's compressed format", () => { + expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML) + }) + + it("handles non-URI-encoded legacy payloads", () => { + const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString( + "base64", + ) + expect(decompressPageContent(legacy)).toBe(MODEL_XML) + }) + + it("returns null for garbage", () => { + expect(decompressPageContent("not base64 deflate")).toBeNull() + }) +}) + +describe("parseDrawioFileContent", () => { + it("passes a plain-XML mxfile through unchanged", () => { + const r = parseDrawioFileContent(PLAIN_MXFILE) + expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE }) + }) + + it("wraps a bare mxGraphModel into a one-page mxfile", () => { + const r = parseDrawioFileContent(MODEL_XML) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.xml).toContain(" { + const r = parseDrawioFileContent(COMPRESSED_MXFILE) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.xml).toContain(" { + const mixed = `${MODEL_XML}${drawioCompress(MODEL_XML)}` + const r = parseDrawioFileContent(mixed) + expect(r.ok).toBe(true) + if (r.ok) { + const doc = new DOMParser().parseFromString(r.xml, "text/xml") + const diagrams = Array.from( + doc.querySelectorAll("diagram"), + ) as Element[] + expect(diagrams).toHaveLength(2) + for (const d of diagrams) { + expect(d.querySelector("mxGraphModel")).not.toBeNull() + } + } + }) + + it("keeps empty pages as-is", () => { + const withEmpty = `${MODEL_XML}` + const r = parseDrawioFileContent(withEmpty) + expect(r).toEqual({ ok: true, xml: withEmpty }) + }) + + it("rejects empty files", () => { + const r = parseDrawioFileContent(" ") + expect(r.ok).toBe(false) + }) + + it("rejects non-drawio content", () => { + const r = parseDrawioFileContent("") + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toContain("Not a draw.io file") + }) + + it("rejects a page whose content is neither XML nor compressed", () => { + const bad = `!!! not a diagram !!!` + const r = parseDrawioFileContent(bad) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toContain('"Broken"') + }) +}) diff --git a/packages/mcp-server/tests/server-wiring.test.ts b/packages/mcp-server/tests/server-wiring.test.ts index 29daa36..bd8f641 100644 --- a/packages/mcp-server/tests/server-wiring.test.ts +++ b/packages/mcp-server/tests/server-wiring.test.ts @@ -31,6 +31,7 @@ const tsxBin = path.resolve( const EXPECTED_TOOLS = [ "start_session", "create_new_diagram", + "load_diagram", "edit_diagram", "get_diagram", "export_diagram",