diff --git a/packages/mcp-server/drawio-files.txt b/packages/mcp-server/drawio-files.txt new file mode 100644 index 00000000..5afded21 --- /dev/null +++ b/packages/mcp-server/drawio-files.txt @@ -0,0 +1,34 @@ +# Files copied from draw.war into dist/drawio by scripts/fetch-drawio.mjs +# (the preview page embeds this copy from its own origin). `*` matches within +# a path segment, `**` across segments. +# +# Always included: the language files, the image icon libraries (loaded one +# icon at a time, as diagrams use them), the stylesheets, and the licenses of +# the icon sets. +img/LICENSE +img/lib/** +mxgraph/css/** +resources/dia*.txt +shapes/LICENSE +stencils/LICENSE +styles/** + +# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update +images/droptarget.png +images/github-logo.svg +images/logo-flat-small.png +images/sidebar-general.png +images/spin.gif +img/clipart/Gear_128x128.png +index.html +js/PostConfig.js +js/PreConfig.js +js/app.min.js +js/bootstrap.js +js/extensions.min.js +js/main.js +js/shapes-14-6-5.min.js +js/stencils.min.js +math4/es5/drawio-mathjax.min.js +mxgraph/images/maximize.gif +shortcuts.svg diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 9349eb6f..7919c195 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -8,7 +8,7 @@ "next-ai-drawio-mcp": "./dist/index.js" }, "scripts": { - "build": "tsc && node scripts/copy-assets.mjs", + "build": "tsc && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs", "check-package": "node scripts/check-package.mjs", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", diff --git a/packages/mcp-server/scripts/check-drawio-files.mjs b/packages/mcp-server/scripts/check-drawio-files.mjs new file mode 100644 index 00000000..480692d0 --- /dev/null +++ b/packages/mcp-server/scripts/check-drawio-files.mjs @@ -0,0 +1,474 @@ +#!/usr/bin/env node +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs" +/** + * Guard for drawio-files.txt: opens the embedded editor from a FULL draw.io + * copy in headless Chromium, uses it the way the preview page does (every + * theme the user can switch to, diagrams with shapes from the icon libraries, + * the format panel, the shape sidebar, the menus, the page tabs, the export + * and edit dialogs, exports) and records every file the editor requests. + * + * node scripts/check-drawio-files.mjs [--drawio ] [--update] + * + * The full copy is --drawio, else the web app's public/drawio. Without + * --update it fails when the editor requested a file the list does not + * have; with --update it rewrites the requested section of the list. + * Needs Playwright from the repository root (npm ci at the root). + */ +import http from "node:http" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { + formatFileList, + parseFileList, + readFileList, +} from "./drawio-file-list.mjs" + +const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..") +const LIST = path.join(PKG, "drawio-files.txt") +const args = process.argv.slice(2) +const update = args.includes("--update") +const drawioDir = path.resolve( + args.includes("--drawio") + ? args[args.indexOf("--drawio") + 1] + : path.join(PKG, "../../public/drawio"), +) +if (!existsSync(path.join(drawioDir, "index.html"))) { + console.error( + `No draw.io copy at ${drawioDir}. Run "npm run dev" once at the repository root (it downloads one into public/drawio), or pass --drawio .`, + ) + process.exit(2) +} +const playwrightEntry = path.join( + PKG, + "../../node_modules/playwright/index.mjs", +) +if (!existsSync(playwrightEntry)) { + console.error("Playwright is missing: run npm ci at the repository root.") + process.exit(2) +} +const { chromium } = await import(pathToFileURL(playwrightEntry).href) + +// The themes a user can switch to under Extras > Theme (themes=1) +const THEMES = ["kennedy", "min", "atlas", "dark", "sketch", "simple"] + +const MIME = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".gif": "image/gif", + ".json": "application/json", + ".xml": "text/xml", + ".txt": "text/plain", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".ico": "image/x-icon", +} + +// The page that embeds the editor, with the preview page's iframe query +const parentPage = (ui) => ` + +` + +// Every path the editor asked for, in order; and the ones the full copy lacks +const requested = [] +const notFound = new Set() +const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/") { + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(parentPage(url.searchParams.get("ui") || "kennedy")) + return + } + const rel = path.posix.normalize( + decodeURIComponent(url.pathname.replace(/^\/drawio\//, "")), + ) + const file = path.join(drawioDir, rel) + let stat + try { + stat = statSync(file) + } catch { + notFound.add(rel) + res.writeHead(404) + res.end() + return + } + if (!requested.includes(rel)) requested.push(rel) + res.writeHead(200, { + "Content-Type": MIME[path.extname(file)] || "application/octet-stream", + "Content-Length": stat.size, + }) + res.end(readFileSync(file)) +}) +await new Promise((r) => server.listen(0, "127.0.0.1", r)) +const port = server.address().port + +const mb = (n) => (n / 1048576).toFixed(2) +let seen = 0 +function report(step) { + const added = requested.slice(seen) + seen = requested.length + if (added.length === 0) return + const bytes = added.reduce( + (n, f) => n + statSync(path.join(drawioDir, f)).size, + 0, + ) + const names = + added.slice(0, 5).join(", ") + (added.length > 5 ? ", ..." : "") + console.log( + ` ${step}: +${added.length} files, +${mb(bytes)} MB [${names}]`, + ) +} + +// Diagrams: a plain shape, one shape from each icon library, and the styles +// that make draw.io load code on demand (hand-drawn look, math) +const cell = (id, style, x) => + `` +const doc = (cells, attrs = "") => + `${cells}` +const LIBS = { + plain: "rounded=1;whiteSpace=wrap;html=1;", + aws4: "shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.a1_instance;fillColor=#ED7100;strokeColor=#ffffff;", + gcp2: "shape=mxgraph.gcp2.a7_power;fillColor=#4285F4;strokeColor=none;", + kubernetes: + "shape=mxgraph.kubernetes.icon;prIcon=api;fillColor=#326CE5;strokeColor=none;", + cisco19: + "shape=mxgraph.cisco19.rect;prIcon=aci;fillColor=#00bceb;strokeColor=none;", + flowchart: + "shape=mxgraph.flowchart.annotation_1;fillColor=#d5e8d4;strokeColor=#82b366;", + webicons: + "shape=mxgraph.webicons.adfty;fillColor=#3b5998;strokeColor=none;", + bpmn: "shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;", + networks: + "shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;", + azure2_image: + "image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;", + sketch: "rounded=1;whiteSpace=wrap;html=1;sketch=1;", +} +const MATH_DOC = doc( + cell("math", "rounded=1;whiteSpace=wrap;html=1;", 40).replace( + 'value="math"', + 'value="$$x^2$$"', + ), + 'math="1"', +) + +// Dialogs and toggles, by draw.io action name (Actions.js); each is closed +// again right after it opened. Left out on purpose, to keep the package +// small: Insert > Template (5.6 MB of templates and previews) and the +// Insert > Advanced tools that load code when used (Mermaid, PlantUML). +const ACTIONS = [ + "editData", + "editDiagram", + "editStyle", + "editGeometry", + "editTooltip", + "editLink", + "insertImage", + "exportPng", + "exportSvg", + "exportJpg", + "exportWebp", + "exportPdf", + "exportXml", + "exportHtml", + "exportVsdx", + "embedHtml", + "embedImage", + "embedSvg", + "embedIframe", + "about", + "pageSetup", + "print", + "findReplace", + "tags", + "layers", + "outline", + "shapes", + "scratchpad", + "plugins", + "properties", + "fromText", + "csv", + "runLayout", + "insertFreehand", + "comments", + "customZoom", + "renamePage", + "format", + "format", + "toggleShapes", + "toggleShapes", + "darkMode", + "lightMode", + "autoMode", + "highContrast", + "highContrast", + "mathematicalTypesetting", + "mathematicalTypesetting", + "keyboardShortcuts", +] +// Enough for the other themes, whose dialogs are the same code +const LIGHT_ACTIONS = ["editData", "exportPng", "shapes", "format", "format"] + +const wait = (page, ms) => page.waitForTimeout(ms) + +/** + * Open the editor in one theme and use it. The default theme gets the full + * tour; the others the parts that differ by theme (chrome, menus, panels). + */ +async function drive(browser, ui, full) { + console.log(`theme ${ui}${full ? "" : " (light)"}`) + const page = await browser.newPage({ + viewport: { width: 1300, height: 900 }, + }) + page.on("popup", (p) => p.close().catch(() => {})) + await page.goto(`http://localhost:${port}/?ui=${ui}`) + await page.waitForFunction(() => window.ready === true, null, { + timeout: 60000, + }) + await wait(page, 2000) + report("editor ready") + + const load = (xml) => + page.evaluate( + (x) => window.send({ action: "load", xml: x, autosave: 1 }), + xml, + ) + let x = 40 + for (const [name, style] of Object.entries(LIBS)) { + await load(doc(cell(name, style, (x += 100)))) + await wait(page, full ? 1500 : 500) + report(`load ${name}`) + if (!full) break + } + if (full) { + await load(MATH_DOC) + await wait(page, 2500) + report("load math") + } + + // The editor instance: in embed mode draw.io calls a plugin callback + // registered after startup right away + const frame = page.frames().find((f) => f.url().includes("/drawio/")) + await frame.evaluate(() => { + window.Draw.loadPlugin((u) => { + window.__ui = u + }) + window.__ui.editor.graph.selectAll() + }) + const closeAll = async () => { + await frame.evaluate(() => { + const ui = window.__ui + while (ui.dialogs && ui.dialogs.length > 0) ui.hideDialog() + ui.hideCurrentMenu?.() + }) + await page.keyboard.press("Escape") + await wait(page, 100) + } + + let opened = 0 + const actions = full ? ACTIONS : LIGHT_ACTIONS + for (const name of actions) { + const result = await frame.evaluate((n) => { + const ui = window.__ui + const action = ui.actions.get(n) + if (!action) return "missing" + try { + action.funct() + } catch (e) { + return `failed: ${e.message}` + } + return ui.dialog || ui.dialogs?.length ? "dialog" : "ok" + }, name) + if (result === "dialog") opened++ + if (result !== "ok" && result !== "dialog") { + console.log(` action ${name}: ${result}`) + } + await wait(page, 400) + report(`action ${name}`) + await closeAll() + } + console.log(` ${opened} of ${actions.length} actions opened a dialog`) + + // Menus: the visible menubar items, the toolbar buttons that open a menu + // in the other themes, and every menu rendered with its submenus + const menubarItems = frame.locator(".geMenubar .geItem:visible") + const menubarCount = await menubarItems.count() + for (let i = 0; i < menubarCount; i++) { + await menubarItems + .nth(i) + .click({ timeout: 2000 }) + .catch(() => {}) + await wait(page, 300) + await closeAll() + } + let toolbarCount = 0 + for (const title of [ + "Diagram", + "Insert", + "Shapes", + "Pages", + "View", + "Table", + "Layout", + ]) { + const button = frame.locator(`a[title="${title}"]:visible`).first() + if ((await button.count()) === 0) continue + toolbarCount++ + await button.click({ timeout: 2000 }).catch(() => {}) + await wait(page, 300) + await closeAll() + } + const failed = await frame.evaluate(() => { + const ui = window.__ui + const failed = [] + for (const name of Object.keys(ui.menus.menus)) { + const menu = new window.mxPopupMenu() + menu.init() + document.body.appendChild(menu.div) + try { + ui.menus.get(name).funct(menu, null) + } catch { + failed.push(name) + } + menu.destroy() + } + return failed + }) + await wait(page, 500) + console.log( + ` menus: ${menubarCount} menubar items, ${toolbarCount} toolbar menus clicked` + + (failed.length ? `; not rendered: ${failed.join(", ")}` : ""), + ) + report("menus") + + // Format panel tabs, with a shape selected + await frame.evaluate(() => window.__ui.editor.graph.selectAll()) + for (const tab of ["Style", "Text", "Arrange", "Diagram"]) { + const el = frame + .locator(`.geFormatContainer div:text-is("${tab}")`) + .first() + if ((await el.count()) > 0) + await el.click({ timeout: 2000 }).catch(() => {}) + await wait(page, 200) + } + report("format panel tabs") + + // Shape sidebar: open every palette, then search + await frame.evaluate(() => { + const ui = window.__ui + for (const title of document.querySelectorAll( + ".geSidebarContainer .geTitle", + )) { + title.click() + } + ui.sidebar.searchShapes?.("server") + }) + await wait(page, full ? 2500 : 1000) + report("sidebar palettes and search") + + // Pages: insert, switch, duplicate, remove, and the tab bar + for (const name of [ + "insertPage", + "nextPage", + "previousPage", + "duplicatePage", + "removePage", + ]) { + await frame.evaluate((n) => window.__ui.actions.get(n)?.funct(), name) + await wait(page, 150) + await closeAll() + } + for (const title of ["Insert Page", "Pages"]) { + const el = frame.locator(`a[title="${title}"]:visible`).first() + if ((await el.count()) > 0) + await el.click({ timeout: 2000 }).catch(() => {}) + await wait(page, 300) + await closeAll() + } + report("page tabs") + + if (full) { + for (const format of ["png", "svg", "xmlsvg", "xml"]) { + await page.evaluate( + (f) => window.send({ action: "export", format: f, scale: 2 }), + format, + ) + await wait(page, 1500) + report(`export ${format}`) + } + const exports = await page.evaluate(() => window.exports) + if (exports < 4) console.log(` only ${exports} of 4 exports answered`) + } + await page.close() +} + +const browser = await chromium.launch() +try { + for (const ui of THEMES) await drive(browser, ui, ui === THEMES[0]) +} finally { + await browser.close() + server.close() +} + +if (notFound.size > 0) { + console.log( + `\nNot in the full copy either (ignored): ${[...notFound].join(", ")}`, + ) +} + +// Compare with the list +const list = readFileList(LIST) +const missing = requested.filter((f) => !list.matches(f)).sort() + +function* walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) yield* walk(full) + else yield path.relative(drawioDir, full).split(path.sep).join("/") + } +} +const summarize = (fileList) => { + let count = 0 + let bytes = 0 + for (const rel of walk(drawioDir)) { + if (!fileList.matches(rel)) continue + count++ + bytes += statSync(path.join(drawioDir, rel)).size + } + return `${count} files, ${mb(bytes)} MB unpacked` +} + +if (update) { + // Only the head decides what is already covered by "always include" + const always = parseFileList(list.head) + const kept = requested.filter((f) => !always.matches(f)) + writeFileSync(LIST, formatFileList(list.head, kept)) + console.log(`\nWrote ${LIST}: ${summarize(readFileList(LIST))}`) +} else if (missing.length > 0) { + console.error( + `\nThe editor requested ${missing.length} files that drawio-files.txt does not list:\n ${missing.join("\n ")}\nRun with --update to add them.`, + ) + process.exit(1) +} else { + console.log(`\ndrawio-files.txt is complete: ${summarize(list)}`) +} diff --git a/packages/mcp-server/scripts/drawio-file-list.mjs b/packages/mcp-server/scripts/drawio-file-list.mjs new file mode 100644 index 00000000..a4483817 --- /dev/null +++ b/packages/mcp-server/scripts/drawio-file-list.mjs @@ -0,0 +1,78 @@ +/** + * drawio-files.txt: the files copied from draw.war into dist/drawio. + * + * One path per line; `*` matches within a path segment and `**` across + * segments; `#` starts a comment. Everything above the marker line is kept + * as it is ("always include"); the paths below it are what the embedded + * editor requested in a guard run, rewritten by check-drawio-files.mjs + * --update. + */ +import fs from "node:fs" + +export const MARKER = + "# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update" + +function toRegExp(pattern) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&") + const source = escaped + .replace(/\*\*/g, "\0") + .replace(/\*/g, "[^/]*") + .replace(/\0/g, ".*") + return new RegExp(`^${source}$`) +} + +const entries = (text) => + text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + +export function parseFileList(text) { + const at = text.indexOf(MARKER) + if (at < 0) throw new Error(`drawio-files.txt has no "${MARKER}" line`) + const head = text.slice(0, at + MARKER.length) + const always = entries(head) + const requested = entries(text.slice(at + MARKER.length)) + + const exact = new Set() + const patterns = [] + for (const entry of [...always, ...requested]) { + if (entry.includes("*")) { + patterns.push({ text: entry, re: toRegExp(entry), hit: false }) + } else { + exact.add(entry) + } + } + + return { + head, + always, + requested, + /** Whether the list names this file (remembers which patterns hit) */ + matches(name) { + if (exact.has(name)) return true + let found = false + for (const p of patterns) { + if (p.re.test(name)) p.hit = found = true + } + return found + }, + /** Entries that matched none of `seen` after matches() ran on them */ + unmatched(seen) { + return [ + ...[...exact].filter((name) => !seen.has(name)), + ...patterns.filter((p) => !p.hit).map((p) => p.text), + ] + }, + } +} + +export function readFileList(file) { + return parseFileList(fs.readFileSync(file, "utf8")) +} + +/** The list text with a new requested section (sorted, unique) */ +export function formatFileList(head, requested) { + const body = [...new Set(requested)].sort().join("\n") + return `${head}\n${body}\n` +} diff --git a/packages/mcp-server/scripts/fetch-drawio.mjs b/packages/mcp-server/scripts/fetch-drawio.mjs new file mode 100644 index 00000000..7017808a --- /dev/null +++ b/packages/mcp-server/scripts/fetch-drawio.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Build step: put a trimmed copy of draw.io into dist/drawio, so the preview + * page can embed the editor from its own origin (same-origin editor access, + * and no internet needed at runtime). + * + * Downloads the pinned draw.war (see src/drawio-version.json) into a cache + * directory (DRAWIO_WAR_CACHE, default /next-ai-drawio), checks its + * SHA-256, and extracts only the files listed in drawio-files.txt. Set + * DRAWIO_WAR= to use a local war without downloading. The list is kept + * up to date by scripts/check-drawio-files.mjs. + */ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { + readDrawioVersion, + readZipEntries, + sha256, +} from "../../../scripts/drawio-zip.mjs" +import { readFileList } from "./drawio-file-list.mjs" + +const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..") +const DEST = path.join(PKG, "dist/drawio") +const { version, sha256: expectedSha, downloadUrl } = readDrawioVersion() + +function log(message) { + console.log(`[fetch-drawio] ${message}`) +} + +/** The draw.war as a buffer, from DRAWIO_WAR, the cache, or GitHub */ +async function readWar() { + const verified = (buf, source) => { + const actual = sha256(buf) + if (actual !== expectedSha) { + throw new Error( + `${source} is not draw.io ${version} (SHA-256 ${actual})`, + ) + } + return buf + } + + if (process.env.DRAWIO_WAR) { + log(`Using ${process.env.DRAWIO_WAR}`) + return verified(fs.readFileSync(process.env.DRAWIO_WAR), "DRAWIO_WAR") + } + + const cacheDir = + process.env.DRAWIO_WAR_CACHE || path.join(os.tmpdir(), "next-ai-drawio") + const cached = path.join(cacheDir, `draw-${version}.war`) + if (fs.existsSync(cached)) { + try { + return verified(fs.readFileSync(cached), cached) + } catch (error) { + log(`${error.message}; downloading again`) + fs.rmSync(cached, { force: true }) + } + } + + log(`Downloading draw.io ${version} ...`) + const res = await fetch(downloadUrl) + if (!res.ok) throw new Error(`HTTP ${res.status} for ${downloadUrl}`) + const buf = verified(Buffer.from(await res.arrayBuffer()), downloadUrl) + fs.mkdirSync(cacheDir, { recursive: true }) + const tmp = `${cached}.tmp-${process.pid}` + fs.writeFileSync(tmp, buf) + fs.renameSync(tmp, cached) + log(`Cached at ${cached}`) + return buf +} + +/** Apache-2.0 text with a notice naming draw.io, for dist/drawio/LICENSE */ +function licenseText(warLicense) { + const notice = + `The files in this directory are an unmodified subset of draw.io ${version}\n` + + "(https://github.com/jgraph/drawio), Copyright (c) JGraph Ltd, redistributed\n" + + "under the Apache License, Version 2.0 reproduced below. The icon sets under\n" + + "img/lib and the stencils built into the scripts have additional terms, see\n" + + "img/LICENSE, shapes/LICENSE and stencils/LICENSE.\n\n" + if (warLicense) return notice + warLicense + // The standard terms, without this project's own copyright notice + const apache = fs.readFileSync(path.join(PKG, "../../LICENSE"), "utf8") + const end = "END OF TERMS AND CONDITIONS" + return notice + apache.slice(0, apache.indexOf(end) + end.length) + "\n" +} + +async function main() { + const list = readFileList(path.join(PKG, "drawio-files.txt")) + const buf = await readWar() + + const tmp = `${DEST}.tmp-${process.pid}` + fs.rmSync(tmp, { recursive: true, force: true }) + const written = new Set() + let bytes = 0 + let warLicense = null + for (const { name, data } of readZipEntries(buf)) { + // Server-side parts of the war; never shipped, whatever the list says + if (/^(WEB-INF|META-INF)\//.test(name)) continue + if (name === "LICENSE") warLicense = data.toString("utf8") + if (!list.matches(name)) continue + const target = path.join(tmp, name) + if (!target.startsWith(tmp + path.sep)) { + throw new Error(`Unsafe path in archive: ${name}`) + } + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, data) + written.add(name) + bytes += data.length + } + + const missing = list.unmatched(written) + if (missing.length > 0) { + throw new Error( + `drawio-files.txt names files that draw.io ${version} does not have:\n ${missing.join("\n ")}\n` + + "Run scripts/check-drawio-files.mjs --update to refresh the list.", + ) + } + + fs.writeFileSync(path.join(tmp, "LICENSE"), licenseText(warLicense)) + fs.writeFileSync(path.join(tmp, ".version"), `${version}\n`) + fs.rmSync(DEST, { recursive: true, force: true }) + fs.renameSync(tmp, DEST) + log( + `Installed ${written.size} files (${(bytes / 1048576).toFixed(1)} MB) into dist/drawio`, + ) +} + +main().catch((error) => { + console.error(`[fetch-drawio] ${error.stack || error}`) + process.exit(1) +})