mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 11:39:50 +08:00
- The three XML examples (swimlanes, two edges, waypoints) that the web system prompt, the display_diagram description and the MCP drawing guide each had a copy of now live in packages/mcp-server/src/xml-examples.ts - The shape library group list and reader come from the MCP shape-library.ts; getShapeLibrary takes the folder, and the web route passes docs/shape-libraries under the working directory as before. Only the 30 known library names are read. Error texts differ slightly - next.config.ts traces docs/shape-libraries/*.md for /api/chat, since the read now happens in another module - Every prompt is byte-for-byte unchanged: the web system prompt for five model ids with and without minimal style, and the MCP drawing guide, compared before and after; the library list and swimlane example match the old tool description text exactly
1773 lines
70 KiB
JavaScript
1773 lines
70 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* MCP Server for Next AI Draw.io
|
|
*
|
|
* Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit
|
|
* draw.io diagrams with real-time browser preview.
|
|
*
|
|
* Uses an embedded HTTP server - no external dependencies required.
|
|
*
|
|
* Multi-page support
|
|
* ------------------
|
|
* The canonical in-memory shape for the session XML is always an <mxfile>
|
|
* containing one or more <diagram> pages. Legacy callers that pass a bare
|
|
* <mxGraphModel> to create_new_diagram are auto-wrapped into a single-page
|
|
* mxfile. All page-targeting parameters (page_id / page_name / page_index)
|
|
* on edit_diagram, get_diagram, and export_diagram are optional and default
|
|
* to the first page. See packages/mcp-server/src/pages.ts for the helper
|
|
* surface.
|
|
*/
|
|
|
|
import { createRequire } from "node:module"
|
|
import { homedir } from "node:os"
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
import open from "open"
|
|
import { z } from "zod"
|
|
import type { DiagramOperation } from "./diagram-operations.ts"
|
|
import { installDomPolyfill } from "./dom.ts"
|
|
import { DRAWING_GUIDE } from "./drawing-guide.ts"
|
|
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
|
|
import { checkEditGate } from "./edit-gate.ts"
|
|
import { addHistory } from "./history.ts"
|
|
import {
|
|
type ExportFormat,
|
|
type ExportOptions,
|
|
getServerPort,
|
|
getState,
|
|
onStateChange,
|
|
requestExport,
|
|
requestSync,
|
|
setState,
|
|
shutdown,
|
|
startHttpServer,
|
|
waitForSync,
|
|
} from "./http-server.ts"
|
|
import { parseDrawioFileContent } from "./load-diagram.ts"
|
|
import { log } from "./logger.ts"
|
|
import { prepareNewDiagram } from "./new-diagram.ts"
|
|
import {
|
|
addPageToDoc,
|
|
deletePageFromDoc,
|
|
findPageElement,
|
|
hasCells,
|
|
hasPageSelector,
|
|
listPagesFromDoc,
|
|
normalizeToMxfile,
|
|
type PageSelector,
|
|
parseMxfile,
|
|
projectPage,
|
|
renamePageInDoc,
|
|
serializeMxfile,
|
|
wrapCellsInModel,
|
|
} from "./pages.ts"
|
|
import { Autosaver, defaultDataDir } from "./persistence.ts"
|
|
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
|
|
import { validateAndFixXml } from "./xml-validation.ts"
|
|
|
|
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
|
|
installDomPolyfill()
|
|
|
|
// Server configuration
|
|
const config = {
|
|
port: parseInt(process.env.PORT || "6002", 10),
|
|
}
|
|
|
|
// Keep each session's latest diagram on disk, so it survives this process
|
|
const autosaver = new Autosaver(defaultDataDir())
|
|
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
|
|
|
|
// Session state (single session for simplicity)
|
|
let currentSession: {
|
|
id: string
|
|
xml: string
|
|
version: number
|
|
// The exact state-store XML the model last saw (get_diagram) or wrote
|
|
// itself (create/edit/page CRUD). The store only changes on server
|
|
// writes or browser pushes (user autosave / sync), so edit_diagram can
|
|
// detect unseen user edits by comparing the live store against this.
|
|
// Empty = no diagram context established yet.
|
|
lastSeenXml: string
|
|
} | null = null
|
|
|
|
// 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
|
|
|
|
// Hosts truncate instructions (Claude Code at 2,048 characters) and may show
|
|
// only the first 512, so the essentials come first. The full rules are in
|
|
// DRAWING_GUIDE, returned by start_session.
|
|
const INSTRUCTIONS = `next-ai-drawio creates and edits draw.io diagrams and shows them live in a browser preview, where the user can also edit them by hand.
|
|
|
|
Start with start_session: it opens the preview and its result contains the drawing guide (layout, edge routing and style rules). Follow the guide when drawing; call get_drawing_guide if it is no longer in your context.
|
|
|
|
Before using cloud or icon shapes (AWS, Azure, GCP, Kubernetes, Cisco, BPMN...), call get_shape_library and use the exact style names it returns. Never guess icon style names.
|
|
|
|
After drawing a complex diagram, call screenshot_diagram once to see it, and fix overlapping shapes or edges that cross shapes.
|
|
|
|
Tools:
|
|
- create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full <mxfile> for several pages.
|
|
- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry.
|
|
- get_diagram: read the current XML, including the user's manual edits.
|
|
- screenshot_diagram: see the rendered diagram as an image.
|
|
- load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths.
|
|
- list_pages, add_page, rename_page, delete_page: manage pages (tabs).`
|
|
|
|
const server = new McpServer(
|
|
{
|
|
name: "next-ai-drawio",
|
|
version: packageVersion,
|
|
},
|
|
{ instructions: INSTRUCTIONS },
|
|
)
|
|
|
|
// Shared Zod schema fragment for page-targeting parameters.
|
|
// Every multi-page-aware tool reuses these three optional fields so the LLM
|
|
// learns one consistent interface.
|
|
const pageSelectorSchema = {
|
|
page_id: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe(
|
|
"Target a page by its id (as returned by list_pages or add_page). Wins over page_name and page_index when multiple are set.",
|
|
),
|
|
page_name: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe(
|
|
'Target a page by its display name (e.g. "CNN"). Used only when page_id is not set.',
|
|
),
|
|
page_index: z
|
|
.number()
|
|
.int()
|
|
.nonnegative()
|
|
.optional()
|
|
.describe(
|
|
"Target a page by its 0-based tab index. Used only when page_id and page_name are not set.",
|
|
),
|
|
}
|
|
|
|
/**
|
|
* Pull a clean PageSelector out of a tool's parsed input.
|
|
* Returns an empty object when none of the page_* fields are set, so callers
|
|
* can simply pass it through to the lower layers (they treat empty as "first
|
|
* page" by convention).
|
|
*/
|
|
function pickPageSelector(input: {
|
|
page_id?: string
|
|
page_name?: string
|
|
page_index?: number
|
|
}): PageSelector {
|
|
const selector: PageSelector = {}
|
|
if (input.page_id) selector.page_id = input.page_id
|
|
if (input.page_name) selector.page_name = input.page_name
|
|
if (input.page_index !== undefined) selector.page_index = input.page_index
|
|
return selector
|
|
}
|
|
|
|
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
|
|
function expandHome(p: string): string {
|
|
if (p === "~") return homedir()
|
|
if (p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1)
|
|
return p
|
|
}
|
|
|
|
/** Format a selector for human-readable error messages. */
|
|
function describeSelector(s: PageSelector): string {
|
|
if (s.page_id) return `id="${s.page_id}"`
|
|
if (s.page_name) return `name="${s.page_name}"`
|
|
if (s.page_index !== undefined) return `index=${s.page_index}`
|
|
return "first page"
|
|
}
|
|
|
|
// The same guide as start_session, for hosts that show prompts to the user
|
|
server.registerPrompt(
|
|
"diagram-workflow",
|
|
{
|
|
description: "Guidelines for creating and editing draw.io diagrams",
|
|
},
|
|
() => ({
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: { type: "text", text: DRAWING_GUIDE },
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
|
|
// Tool: get_drawing_guide
|
|
server.registerTool(
|
|
"get_drawing_guide",
|
|
{
|
|
title: "Get drawing guide",
|
|
description:
|
|
"Return the drawing guide: XML format, layout, edge routing, style and editing rules. " +
|
|
"start_session already returns it; call this only if the guide is no longer in your context.",
|
|
inputSchema: {},
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async () => ({ content: [{ type: "text", text: DRAWING_GUIDE }] }),
|
|
)
|
|
|
|
// Tool: get_shape_library
|
|
server.registerTool(
|
|
"get_shape_library",
|
|
{
|
|
title: "Get shape library",
|
|
description:
|
|
"Get the style syntax and shape names of a draw.io icon library. Call this BEFORE drawing with " +
|
|
"cloud, network or other icon shapes, and use the exact names it returns; never guess them.\n\n" +
|
|
`Libraries:\n${SHAPE_LIBRARY_LIST}`,
|
|
inputSchema: {
|
|
library: z
|
|
.string()
|
|
.describe("Library name, e.g. aws4, kubernetes, flowchart"),
|
|
},
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async ({ library }) => {
|
|
const found = await getShapeLibrary(library)
|
|
return found.ok
|
|
? { content: [{ type: "text", text: found.text }] }
|
|
: {
|
|
content: [{ type: "text", text: `Error: ${found.error}` }],
|
|
isError: true,
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: start_session
|
|
server.registerTool(
|
|
"start_session",
|
|
{
|
|
title: "Start session",
|
|
description:
|
|
"Start a new diagram session and open the browser for real-time preview. " +
|
|
"Starts an embedded server and opens a browser window with draw.io. " +
|
|
"The browser will show diagram updates as they happen. " +
|
|
"The result includes the drawing guide; follow it when drawing.",
|
|
inputSchema: {},
|
|
annotations: { destructiveHint: false, openWorldHint: false },
|
|
},
|
|
async () => {
|
|
try {
|
|
// Start embedded HTTP server
|
|
const port = await startHttpServer(config.port)
|
|
|
|
// Create session
|
|
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
|
|
currentSession = {
|
|
id: sessionId,
|
|
xml: "",
|
|
version: 0,
|
|
lastSeenXml: "",
|
|
}
|
|
|
|
// Open browser
|
|
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
|
await open(browserUrl)
|
|
|
|
const savePath = autosaver.pathFor(sessionId)
|
|
const saveNote = savePath
|
|
? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue it in a later conversation, call start_session, then load_diagram with this path.`
|
|
: ""
|
|
|
|
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}\n\n${DRAWING_GUIDE}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("start_session failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: create_new_diagram
|
|
server.registerTool(
|
|
"create_new_diagram",
|
|
{
|
|
title: "Create new diagram",
|
|
description: `Create a NEW diagram, REPLACING the whole document: every page and any unsaved user changes (the previous state stays in History). To add a tab use add_page; to change cells use edit_diagram.
|
|
|
|
Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape_library first. Follow the drawing guide returned by start_session (call get_drawing_guide if it is no longer in your context).
|
|
|
|
Accepted xml:
|
|
1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1":
|
|
<mxCell id="2" value="Shape" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>
|
|
2) A bare <mxGraphModel> with <root> (one page).
|
|
3) A full <mxfile> with one or more <diagram> pages. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
|
|
|
|
Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent="1" for top-level shapes, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600.`,
|
|
inputSchema: {
|
|
xml: z
|
|
.string()
|
|
.describe(
|
|
"REQUIRED: the mxCell elements of one page, a bare <mxGraphModel>, or a full <mxfile> with one or more <diagram> pages.",
|
|
),
|
|
},
|
|
annotations: { openWorldHint: false },
|
|
},
|
|
async ({ xml: inputXml }) => {
|
|
try {
|
|
if (!currentSession) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No active session. Please call start_session first.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const prepared = prepareNewDiagram(inputXml)
|
|
if (!prepared.ok) {
|
|
log.error(prepared.error)
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Error: ${prepared.error}` },
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
if (prepared.fixes.length > 0) {
|
|
log.info(`XML auto-fixed: ${prepared.fixes.join(", ")}`)
|
|
}
|
|
// Every later tool can assume session.xml is an mxfile
|
|
const xml = prepared.xml
|
|
|
|
log.info(`Setting diagram content, ${xml.length} chars`)
|
|
|
|
// Sync from browser state first
|
|
const browserState = getState(currentSession.id)
|
|
if (browserState?.xml) {
|
|
currentSession.xml = browserState.xml
|
|
}
|
|
|
|
// Save user's state before AI overwrites (with cached SVG)
|
|
if (currentSession.xml) {
|
|
addHistory(
|
|
currentSession.id,
|
|
currentSession.xml,
|
|
browserState?.svg || "",
|
|
)
|
|
}
|
|
|
|
// Update session state
|
|
currentSession.xml = xml
|
|
currentSession.version++
|
|
|
|
// Push to embedded server state. The model just authored this
|
|
// exact XML, so record it as seen — edit_diagram may follow
|
|
// without a redundant get_diagram round-trip.
|
|
setState(currentSession.id, xml)
|
|
currentSession.lastSeenXml = xml
|
|
|
|
// Save AI result (no SVG yet - will be captured by browser)
|
|
addHistory(currentSession.id, xml, "")
|
|
|
|
// Report page count back to the caller so the LLM learns whether
|
|
// multi-page worked or fell back to single.
|
|
const doc = parseMxfile(xml)
|
|
const pages = doc ? listPagesFromDoc(doc) : []
|
|
const pageSummary =
|
|
pages.length > 1
|
|
? `${pages.length} pages: ${pages.map((p) => `${p.index}:${p.name}`).join(", ")}`
|
|
: pages.length === 1
|
|
? `1 page: ${pages[0].name}`
|
|
: "no pages parsed"
|
|
|
|
log.info(`Diagram content set successfully (${pageSummary})`)
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("create_new_diagram failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: load_diagram
|
|
server.registerTool(
|
|
"load_diagram",
|
|
{
|
|
title: "Load .drawio file",
|
|
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(
|
|
"Absolute path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project.",
|
|
),
|
|
},
|
|
annotations: { openWorldHint: false },
|
|
},
|
|
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(expandHome(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",
|
|
{
|
|
title: "Edit diagram",
|
|
description:
|
|
"Edit a specific page in the current diagram by ID-based operations (update/add/delete cells).\n\n" +
|
|
"All-or-nothing: if any operation fails, nothing is applied and every failure is listed.\n\n" +
|
|
"Freshness: the server remembers the last diagram state you have seen, and rejects this call " +
|
|
"only if the user edited the diagram in the browser since then. You do NOT need to call " +
|
|
"get_diagram before every edit: a rejected call changes nothing and includes the current XML " +
|
|
"of the page, so you can rebuild your operations and retry.\n\n" +
|
|
"Call get_diagram first only when you don't know the current diagram content (cell IDs, " +
|
|
"structure) — e.g. the diagram wasn't created in this conversation, or you're unsure your " +
|
|
"memory of it is accurate.\n\n" +
|
|
"Multi-page targeting:\n" +
|
|
"- page_id / page_name / page_index are optional; when all omitted, the FIRST page is targeted\n" +
|
|
"- Use list_pages to discover what pages exist\n\n" +
|
|
"Operations:\n" +
|
|
"- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" +
|
|
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
|
"- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" +
|
|
"For add/update, new_xml must be a complete mxCell element including mxGeometry. No XML comments. " +
|
|
'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' +
|
|
"Example - Add a rectangle on the default (first) page:\n" +
|
|
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
|
|
"Example - Delete a cell on the default page:\n" +
|
|
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
|
|
inputSchema: {
|
|
...pageSelectorSchema,
|
|
operations: z
|
|
.array(
|
|
z.object({
|
|
operation: z
|
|
.enum(["update", "add", "delete"])
|
|
.describe(
|
|
"Operation to perform: add, update, or delete",
|
|
),
|
|
cell_id: z
|
|
.string()
|
|
.describe(
|
|
"The id of the mxCell. Must match the id attribute in new_xml.",
|
|
),
|
|
new_xml: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Complete mxCell XML element (required for update/add)",
|
|
),
|
|
}),
|
|
)
|
|
.describe("Array of operations to apply"),
|
|
},
|
|
annotations: { openWorldHint: false },
|
|
},
|
|
async ({ operations, page_id, page_name, page_index }) => {
|
|
try {
|
|
if (!currentSession) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No active session. Please call start_session first.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
// Fetch latest state from browser. Re-normalise to mxfile: the
|
|
// embed/sync path can hand back a bare <mxGraphModel>, and adopting
|
|
// it verbatim would silently strip a multi-page document down to
|
|
// one page on the next write.
|
|
const browserState = getState(currentSession.id)
|
|
if (browserState?.xml) {
|
|
currentSession.xml =
|
|
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
|
log.info("Fetched latest diagram state from browser")
|
|
}
|
|
|
|
if (!currentSession.xml) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No diagram to edit. Please create a diagram first with create_new_diagram.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const pageSelector = pickPageSelector({
|
|
page_id,
|
|
page_name,
|
|
page_index,
|
|
})
|
|
|
|
// Enforce workflow: the model must have seen the current diagram
|
|
// state. Content comparison instead of a wall-clock timeout —
|
|
// slow reasoning between get_diagram and edit_diagram is fine as
|
|
// long as nothing changed in the browser meanwhile (#885).
|
|
const gate = checkEditGate(
|
|
currentSession.lastSeenXml,
|
|
browserState?.xml ?? "",
|
|
)
|
|
if (!gate.ok) {
|
|
log.warn(
|
|
gate.reason === "stale"
|
|
? "edit_diagram rejected: the browser has changes the model has not seen"
|
|
: "edit_diagram rejected: the model has not seen the diagram yet",
|
|
)
|
|
// The error carries the current page, so the model has now
|
|
// seen it and can retry without a get_diagram round-trip.
|
|
currentSession.lastSeenXml =
|
|
browserState?.xml || currentSession.xml
|
|
const reason =
|
|
gate.reason === "stale"
|
|
? "The diagram changed in the browser since you last saw it (e.g. manual user edits). No changes were made."
|
|
: "You have not seen this diagram yet, so no changes were made."
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nBuild your operations on this XML and retry.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
log.info(
|
|
`Editing diagram with ${operations.length} operation(s) on ${describeSelector(pageSelector)}`,
|
|
)
|
|
|
|
const outcome = editDiagram(
|
|
currentSession.xml,
|
|
operations as DiagramOperation[],
|
|
pageSelector,
|
|
)
|
|
if (!outcome.ok) {
|
|
log.warn(`Edit rejected: ${outcome.errors.join("; ")}`)
|
|
const text = outcome.pageError
|
|
? `Error: ${outcome.errors[0]}`
|
|
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nFix the operations against this XML and retry.`
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
isError: true,
|
|
}
|
|
}
|
|
if (outcome.fixes.length > 0) {
|
|
log.info(`new_xml auto-fixed: ${outcome.fixes.join("; ")}`)
|
|
}
|
|
const result = outcome.xml
|
|
|
|
// Save the pre-edit state for undo (with cached SVG from browser).
|
|
// Done only once the edit applied: a rejected edit returns above
|
|
// without leaving a phantom history entry.
|
|
addHistory(
|
|
currentSession.id,
|
|
currentSession.xml,
|
|
browserState?.svg || "",
|
|
)
|
|
|
|
// Update state
|
|
currentSession.xml = result
|
|
currentSession.version++
|
|
|
|
// Push to embedded server; the pushed XML is now the latest
|
|
// state the model has seen.
|
|
setState(currentSession.id, result)
|
|
currentSession.lastSeenXml = result
|
|
|
|
// Save AI result (no SVG yet - will be captured by browser)
|
|
addHistory(currentSession.id, result, "")
|
|
|
|
log.info(`Diagram edited successfully`)
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Diagram edited successfully!\n\nApplied ${outcome.applied} operation(s) on ${describeSelector(pageSelector)}.`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("edit_diagram failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: get_diagram
|
|
server.registerTool(
|
|
"get_diagram",
|
|
{
|
|
title: "Get diagram",
|
|
description:
|
|
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
|
|
"Call this when you don't know the current diagram content (cell IDs, pages, structure) — " +
|
|
"e.g. before editing a diagram you didn't create in this conversation, or after edit_diagram " +
|
|
"was rejected because the user changed the diagram in the browser.\n\n" +
|
|
"Returns the full <mxfile> by default. If a page selector is provided, returns just that page's <mxGraphModel> embedded in a one-page <mxfile> wrapper.",
|
|
inputSchema: {
|
|
...pageSelectorSchema,
|
|
},
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async (input) => {
|
|
// Defensive: when every field is optional an MCP client could in
|
|
// principle invoke us with no `arguments` field. The SDK's zod parse
|
|
// normally produces `{}` in that case, but we coalesce explicitly so
|
|
// a destructure of `undefined` can never throw before we reach the
|
|
// session-existence check.
|
|
const { page_id, page_name, page_index } = input ?? {}
|
|
try {
|
|
if (!currentSession) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No active session. Please call start_session first.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
// Request browser to push fresh state and wait for it
|
|
let staleNote = ""
|
|
const syncRequested = requestSync(currentSession.id)
|
|
if (syncRequested) {
|
|
const synced = await waitForSync(currentSession.id)
|
|
if (!synced) {
|
|
log.warn("get_diagram: sync timeout - state may be stale")
|
|
staleNote =
|
|
"\n\nNote: the browser did not respond, so this XML may not include the user's latest manual edits (is the preview tab open?)."
|
|
}
|
|
}
|
|
|
|
// Fetch latest state from browser, re-normalising to mxfile so a
|
|
// bare <mxGraphModel> pushed back by the embed/sync path doesn't
|
|
// strip page structure (see edit_diagram for the same guard).
|
|
const browserState = getState(currentSession.id)
|
|
if (browserState?.xml) {
|
|
currentSession.xml =
|
|
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
|
}
|
|
|
|
if (!currentSession.xml) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "No diagram exists yet. Use create_new_diagram to create one.",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
// The model is now looking at the current state. Record the raw
|
|
// store value — the gate's fast path is plain string equality
|
|
// against the store, with a structural comparison as fallback.
|
|
currentSession.lastSeenXml = browserState?.xml || currentSession.xml
|
|
|
|
const pageSelector = pickPageSelector({
|
|
page_id,
|
|
page_name,
|
|
page_index,
|
|
})
|
|
const doc = parseMxfile(currentSession.xml)
|
|
const pages = doc ? listPagesFromDoc(doc) : []
|
|
const pageList = pages.length
|
|
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
|
|
: "No <mxfile> wrapper detected (legacy single-page session)."
|
|
|
|
// No selector → return full mxfile
|
|
if (!hasPageSelector(pageSelector)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Current diagram XML:\n\n${currentSession.xml}\n\n${pageList}${staleNote}`,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
// Selector → return a single-page projection
|
|
const projection = projectPage(currentSession.xml, pageSelector)
|
|
if (!projection.ok) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
projection.reason === "parse"
|
|
? `Error: a page selector was given but the current session XML could not be parsed as a multi-page <mxfile> (it may be a legacy single-page document or malformed), so it has no addressable pages.\n\n${pageList}`
|
|
: `Error: Page ${describeSelector(pageSelector)} not found.\n\n${pageList}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("get_diagram failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// The browser bridge has one export slot per session, so export requests
|
|
// run one at a time: a concurrent call waits for the previous one.
|
|
let exportQueue: Promise<unknown> = Promise.resolve()
|
|
|
|
/**
|
|
* Ask the browser to export (optionally via a page projection) and poll for
|
|
* the resulting image data. Resolves to undefined on timeout.
|
|
*/
|
|
function exportViaBrowser(
|
|
sessionId: string,
|
|
format: ExportFormat,
|
|
projectionXml?: string,
|
|
options?: ExportOptions,
|
|
): Promise<string | undefined> {
|
|
const run = exportQueue.then(async () => {
|
|
requestExport(sessionId, format, projectionXml, options)
|
|
|
|
// A projection export does an extra load + render round-trip in the
|
|
// browser, so give it a longer window. Re-read the live store entry
|
|
// each tick: setState() (from a concurrent autosave or tool call)
|
|
// replaces the Map entry with a new object, so a captured reference
|
|
// would go stale and never observe the browser's exportData.
|
|
const timeoutMs = projectionXml ? 15000 : 10000
|
|
const start = Date.now()
|
|
let exportData: string | undefined
|
|
while (Date.now() - start < timeoutMs) {
|
|
exportData = getState(sessionId)?.exportData
|
|
if (exportData) break
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
}
|
|
const live = getState(sessionId)
|
|
if (live) {
|
|
live.exportData = undefined
|
|
live.exportFormat = undefined
|
|
live.exportXml = undefined
|
|
live.exportOptions = undefined
|
|
}
|
|
return exportData
|
|
})
|
|
exportQueue = run.catch(() => {})
|
|
return run
|
|
}
|
|
|
|
/**
|
|
* True when the preview tab polled before but has gone quiet. Browsers
|
|
* slow down timers in background tabs (Chrome: about once a minute after
|
|
* 5 minutes hidden), so an export would just time out.
|
|
*/
|
|
function previewStalled(sessionId: string): boolean {
|
|
const lastPolled = getState(sessionId)?.lastPolled
|
|
return lastPolled !== undefined && Date.now() - lastPolled > 10_000
|
|
}
|
|
|
|
function previewStalledError(sessionId: string) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: `Error: The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (http://localhost:${getServerPort()}?mcp=${sessionId}), then retry.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
/** The <diagram id> of the page a selector targets, for draw.io's pageId. */
|
|
function pageIdFor(xml: string, selector: PageSelector): string | undefined {
|
|
const doc = parseMxfile(xml)
|
|
return (
|
|
(doc && findPageElement(doc, selector)?.element.getAttribute("id")) ||
|
|
undefined
|
|
)
|
|
}
|
|
|
|
// Screenshot size: Claude Desktop caps a tool result at about 150,000
|
|
// characters, so retry smaller above 140,000 base64 characters. (Claude
|
|
// Code 2.1 accepted a 240,000 character image in testing.)
|
|
const SCREENSHOT_WIDTHS = [1000, 700]
|
|
const MAX_SCREENSHOT_CHARS = 140_000
|
|
|
|
// Adapted from the web app's vision check (lib/validation-prompts.ts)
|
|
const SCREENSHOT_CHECKLIST = `Check this rendering of the diagram for:
|
|
1. Overlapping shapes that cover each other or their labels (critical)
|
|
2. Edges crossing shapes that are not their source or target (critical)
|
|
3. Text that is cut off, overlapping or too small to read (warning)
|
|
4. Layout problems: cramped shapes, poor spacing or misalignment (warning)
|
|
5. Rendering errors: missing, incomplete or broken elements, such as an icon that did not load (critical)
|
|
If there are critical issues, fix them with edit_diagram and take one more screenshot. Do at most two rounds of fixes. Minor cosmetic issues are fine, and diagrams with only 1 or 2 shapes pass unless something is clearly broken.`
|
|
|
|
// Tool: screenshot_diagram
|
|
server.registerTool(
|
|
"screenshot_diagram",
|
|
{
|
|
title: "Screenshot diagram",
|
|
description:
|
|
"Render the diagram in the preview and return it as a PNG image, so you can see your own result. " +
|
|
"Call this once after drawing or heavily editing a complex diagram, then fix overlaps and edges that cross shapes. " +
|
|
"Without a page selector it shows the page on screen. Needs the preview tab to be open.",
|
|
inputSchema: { ...pageSelectorSchema },
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async (input) => {
|
|
const { page_id, page_name, page_index } = input ?? {}
|
|
try {
|
|
if (!currentSession) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No active session. Please call start_session first.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
if (previewStalled(currentSession.id)) {
|
|
return previewStalledError(currentSession.id)
|
|
}
|
|
const xml = getState(currentSession.id)?.xml || currentSession.xml
|
|
if (!hasCells(xml)) {
|
|
return {
|
|
content: [{ type: "text", text: "The diagram is empty." }],
|
|
}
|
|
}
|
|
|
|
const pageSelector = pickPageSelector({
|
|
page_id,
|
|
page_name,
|
|
page_index,
|
|
})
|
|
let pageId: string | undefined
|
|
let projectionXml: string | undefined
|
|
if (hasPageSelector(pageSelector)) {
|
|
const doc = normalizeToMxfile(xml) ?? xml
|
|
pageId = pageIdFor(doc, pageSelector)
|
|
// A page without an id: load just that page and capture
|
|
// it, as export_diagram does
|
|
if (!pageId) {
|
|
const projection = projectPage(doc, pageSelector)
|
|
if (!projection.ok) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: Page ${describeSelector(pageSelector)} not found.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
projectionXml = projection.xml
|
|
}
|
|
}
|
|
|
|
let data: string | undefined
|
|
for (const width of SCREENSHOT_WIDTHS) {
|
|
data = await exportViaBrowser(
|
|
currentSession.id,
|
|
"png",
|
|
projectionXml,
|
|
{ width, pageId },
|
|
)
|
|
if (!data || data.length <= MAX_SCREENSHOT_CHARS) break
|
|
}
|
|
if (!data) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: Screenshot timed out. Make sure the preview tab is open and in front.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
return {
|
|
content: [
|
|
{
|
|
type: "image",
|
|
data: data.replace(/^data:image\/png;base64,/, ""),
|
|
mimeType: "image/png",
|
|
},
|
|
{
|
|
type: "text",
|
|
text: `Screenshot of ${hasPageSelector(pageSelector) ? `page ${describeSelector(pageSelector)}` : "the page on screen"}.\n\n${SCREENSHOT_CHECKLIST}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("screenshot_diagram failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: export_diagram
|
|
server.registerTool(
|
|
"export_diagram",
|
|
{
|
|
title: "Export diagram",
|
|
description:
|
|
"Export the current diagram to a file. Supports .drawio (XML), .png, .svg, and .drawio.svg (an SVG with the diagram embedded, which draw.io can open and edit again). " +
|
|
"The format is auto-detected from the file extension, or can be specified explicitly.\n\n" +
|
|
"Multi-page behaviour:\n" +
|
|
"- .drawio with NO page selector: writes the full <mxfile> (all pages).\n" +
|
|
"- .drawio with a page selector: writes a single-page <mxfile> containing only that page.\n" +
|
|
"- .png / .svg with NO page selector: exports the currently active page in the browser.\n" +
|
|
"- .png with a page selector: renders that page without changing what the user sees.\n" +
|
|
"- .svg / .drawio.svg with a page selector: temporarily loads that page into the browser, captures it, then restores the full document (the user sees a brief flicker).",
|
|
inputSchema: {
|
|
...pageSelectorSchema,
|
|
path: z
|
|
.string()
|
|
.describe(
|
|
"Absolute file path to save to (e.g. /Users/me/diagram.drawio, ~/diagram.png). Relative paths resolve against the MCP server's working directory, which is often not your project.",
|
|
),
|
|
format: z
|
|
.enum(["drawio", "png", "svg", "drawio.svg"])
|
|
.optional()
|
|
.describe(
|
|
"Export format. If omitted, detected from file extension. Defaults to drawio.",
|
|
),
|
|
},
|
|
annotations: { openWorldHint: false },
|
|
},
|
|
async ({ path: rawPath, format, page_id, page_name, page_index }) => {
|
|
const path = expandHome(rawPath)
|
|
try {
|
|
if (!currentSession) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No active session. Please call start_session first.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
// Fetch latest state, re-normalised to mxfile so a page
|
|
// selector works on a bare <mxGraphModel> pushed by the browser
|
|
const browserState = getState(currentSession.id)
|
|
if (browserState?.xml) {
|
|
currentSession.xml =
|
|
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
|
}
|
|
|
|
if (!currentSession.xml) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: No diagram to export. Please create a diagram first.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const pageSelector = pickPageSelector({
|
|
page_id,
|
|
page_name,
|
|
page_index,
|
|
})
|
|
|
|
const fs = await import("node:fs/promises")
|
|
const nodePath = await import("node:path")
|
|
|
|
// Detect format from extension if not specified
|
|
const lowerPath = path.toLowerCase()
|
|
const detectedFormat =
|
|
format ||
|
|
(lowerPath.endsWith(".drawio.svg")
|
|
? "drawio.svg"
|
|
: lowerPath.endsWith(".png")
|
|
? "png"
|
|
: lowerPath.endsWith(".svg")
|
|
? "svg"
|
|
: "drawio")
|
|
|
|
// .drawio path - write XML directly (no browser round-trip).
|
|
if (detectedFormat === "drawio") {
|
|
let filePath = path
|
|
if (!filePath.toLowerCase().endsWith(".drawio")) {
|
|
filePath = `${filePath}.drawio`
|
|
}
|
|
const absolutePath = nodePath.resolve(filePath)
|
|
|
|
let outXml = currentSession.xml
|
|
if (hasPageSelector(pageSelector)) {
|
|
const projection = projectPage(
|
|
currentSession.xml,
|
|
pageSelector,
|
|
)
|
|
if (!projection.ok) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
projection.reason === "parse"
|
|
? "Error: Cannot parse current session XML as <mxfile>; cannot project a single page."
|
|
: `Error: Page ${describeSelector(pageSelector)} not found for export.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
outXml = projection.xml
|
|
}
|
|
|
|
await fs.writeFile(absolutePath, outXml, "utf-8")
|
|
log.info(`Diagram exported to ${absolutePath}`)
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${outXml.length} characters`,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
// PNG or SVG: request browser to export via iframe. Replace a
|
|
// known extension that does not match the format.
|
|
let filePath = path
|
|
const suffix = `.${detectedFormat}`
|
|
if (!lowerPath.endsWith(suffix)) {
|
|
const known = [".drawio.svg", ".drawio", ".png", ".svg"].find(
|
|
(e) => lowerPath.endsWith(e),
|
|
)
|
|
if (known) filePath = filePath.slice(0, -known.length)
|
|
filePath = `${filePath}${suffix}`
|
|
}
|
|
const absolutePath = nodePath.resolve(filePath)
|
|
// draw.io's name for an SVG with the diagram embedded
|
|
const browserFormat =
|
|
detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat
|
|
|
|
const state = getState(currentSession.id)
|
|
if (!state) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: Session state not found. Is the browser open?",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
if (previewStalled(currentSession.id)) {
|
|
return previewStalledError(currentSession.id)
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Page-targeted PNG/SVG export.
|
|
//
|
|
// drawio's JSON embed protocol has no working `selectPage` action,
|
|
// so to export a specific page we build a single-page <mxfile>
|
|
// projection and hand it to the browser bridge alongside the export
|
|
// request. The bridge loads the projection, waits for draw.io's own
|
|
// render, exports, then reloads the user's real document — entirely
|
|
// browser-side. The canonical session state is never mutated here,
|
|
// so there is no restore race and no concurrent-edit clobbering.
|
|
// -----------------------------------------------------------------
|
|
// PNG: draw.io renders any page by id, without touching the
|
|
// page on screen. SVG export has no page option, so it still
|
|
// needs the projection below.
|
|
let projectionXml: string | undefined
|
|
let pngPageId: string | undefined
|
|
if (hasPageSelector(pageSelector) && detectedFormat === "png") {
|
|
pngPageId = pageIdFor(currentSession.xml, pageSelector)
|
|
}
|
|
if (hasPageSelector(pageSelector) && !pngPageId) {
|
|
const projection = projectPage(currentSession.xml, pageSelector)
|
|
if (!projection.ok) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
projection.reason === "parse"
|
|
? "Error: Cannot parse current session XML as <mxfile>; cannot target page for export."
|
|
: `Error: Page ${describeSelector(pageSelector)} not found for export.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
projectionXml = projection.xml
|
|
}
|
|
|
|
const exportData = await exportViaBrowser(
|
|
currentSession.id,
|
|
browserFormat,
|
|
projectionXml,
|
|
pngPageId ? { pageId: pngPageId } : undefined,
|
|
)
|
|
|
|
if (!exportData) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: projectionXml
|
|
? "Error: Export timed out after loading the single-page projection. The browser may be closed or unresponsive."
|
|
: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
// Decode and write
|
|
if (detectedFormat === "png") {
|
|
const base64 = exportData.replace(
|
|
/^data:image\/png;base64,/,
|
|
"",
|
|
)
|
|
await fs.writeFile(absolutePath, Buffer.from(base64, "base64"))
|
|
} else {
|
|
let svgContent = exportData
|
|
if (svgContent.startsWith("data:image/svg+xml;base64,")) {
|
|
const base64 = svgContent.replace(
|
|
/^data:image\/svg\+xml;base64,/,
|
|
"",
|
|
)
|
|
svgContent = Buffer.from(base64, "base64").toString("utf-8")
|
|
}
|
|
await fs.writeFile(absolutePath, svgContent, "utf-8")
|
|
}
|
|
|
|
const stat = await fs.stat(absolutePath)
|
|
log.info(
|
|
`Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`,
|
|
)
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("export_diagram failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Shared helper for page-CRUD tools.
|
|
* Loads the latest session XML, normalises to mxfile if needed, returns a
|
|
* parsed Document the caller can mutate, plus a writer that persists.
|
|
*/
|
|
async function loadMxfileForMutation(): Promise<
|
|
| { ok: true; doc: Document; writeBack: (newDoc: Document) => void }
|
|
| { ok: false; message: string }
|
|
> {
|
|
if (!currentSession) {
|
|
return {
|
|
ok: false,
|
|
message: "No active session. Please call start_session first.",
|
|
}
|
|
}
|
|
// Pull latest from browser so we don't clobber autosaved changes.
|
|
const browserState = getState(currentSession.id)
|
|
if (browserState?.xml) {
|
|
currentSession.xml = browserState.xml
|
|
}
|
|
if (!currentSession.xml) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"No diagram exists yet. Use create_new_diagram first, then page tools.",
|
|
}
|
|
}
|
|
// Make sure the in-memory shape is canonical mxfile before any CRUD.
|
|
const normalized = normalizeToMxfile(currentSession.xml)
|
|
if (!normalized) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"Current session XML is neither <mxGraphModel> nor <mxfile>; cannot perform page operations.",
|
|
}
|
|
}
|
|
currentSession.xml = normalized
|
|
|
|
const doc = parseMxfile(currentSession.xml)
|
|
if (!doc) {
|
|
return {
|
|
ok: false,
|
|
message: "Failed to parse current session XML as <mxfile>.",
|
|
}
|
|
}
|
|
const sessionRef = currentSession
|
|
return {
|
|
ok: true,
|
|
doc,
|
|
writeBack: (newDoc: Document) => {
|
|
const newXml = serializeMxfile(newDoc)
|
|
// The store may hold user edits the model has not seen yet.
|
|
const sawLatest = checkEditGate(
|
|
sessionRef.lastSeenXml,
|
|
browserState?.xml ?? "",
|
|
).ok
|
|
// Save history before overwriting so the user can undo.
|
|
addHistory(sessionRef.id, sessionRef.xml, browserState?.svg || "")
|
|
sessionRef.xml = newXml
|
|
sessionRef.version++
|
|
setState(sessionRef.id, newXml)
|
|
// The model just wrote this exact state. If it had seen the state
|
|
// it built on, mark the result as seen so edit_diagram needs no
|
|
// extra get_diagram; otherwise edit_diagram must ask for one.
|
|
sessionRef.lastSeenXml = sawLatest ? newXml : ""
|
|
addHistory(sessionRef.id, newXml, "")
|
|
},
|
|
}
|
|
}
|
|
|
|
// Tool: list_pages
|
|
server.registerTool(
|
|
"list_pages",
|
|
{
|
|
title: "List pages",
|
|
description:
|
|
"List every page (tab) in the current diagram. Returns each page's id, name, 0-based index, and cell count. Use this to discover what pages exist before targeting one with edit_diagram, get_diagram, export_diagram, rename_page, or delete_page.",
|
|
inputSchema: {},
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async () => {
|
|
try {
|
|
const loaded = await loadMxfileForMutation()
|
|
if (!loaded.ok) {
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Error: ${loaded.message}` },
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
const pages = listPagesFromDoc(loaded.doc)
|
|
if (pages.length === 0) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "No pages in document.",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
const lines = pages.map(
|
|
(p) =>
|
|
` [${p.index}] id="${p.id}" name="${p.name}" cells=${p.cellCount}`,
|
|
)
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Pages (${pages.length}):\n${lines.join("\n")}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("list_pages failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: add_page
|
|
server.registerTool(
|
|
"add_page",
|
|
{
|
|
title: "Add page",
|
|
description:
|
|
'Append a new page (tab) to the current diagram WITHOUT touching existing pages or unsaved user changes. Use this when the user wants "another diagram alongside" — e.g. "add a CNN page" — instead of create_new_diagram which wipes everything.\n\n' +
|
|
"Inputs:\n" +
|
|
"- name: optional display name for the tab (defaults to Page-N where N = existing-page-count + 1)\n" +
|
|
"- id: optional explicit page id; if omitted the server generates a short alphanumeric id\n" +
|
|
'- xml: optional starting content: the mxCell elements of the page (root cells "0" and "1" are added), or a bare <mxGraphModel>. If omitted, the page starts blank.\n\n' +
|
|
"Returns the new page's id, name, and index so the caller can immediately target it with edit_diagram.",
|
|
inputSchema: {
|
|
name: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'Optional display name for the new tab (e.g. "CNN"). Defaults to "Page-N".',
|
|
),
|
|
id: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe(
|
|
"Optional explicit page id. If omitted the server generates one. Must be unique across pages.",
|
|
),
|
|
xml: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Optional starting content: the mxCell elements of the page, or a bare <mxGraphModel>. If omitted the page starts blank.",
|
|
),
|
|
},
|
|
annotations: { destructiveHint: false, openWorldHint: false },
|
|
},
|
|
async (input) => {
|
|
// All three fields optional — coalesce so a no-args call doesn't
|
|
// crash on destructure before we surface a proper MCP error.
|
|
const { name, id, xml } = input ?? {}
|
|
try {
|
|
const loaded = await loadMxfileForMutation()
|
|
if (!loaded.ok) {
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Error: ${loaded.message}` },
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
// If caller provided XML, validate it before splicing it in so we
|
|
// never get a half-broken mxfile written to the session.
|
|
let cleanXml: string | undefined = xml && wrapCellsInModel(xml)
|
|
if (cleanXml) {
|
|
const { valid, error, fixed, fixes } =
|
|
validateAndFixXml(cleanXml)
|
|
if (fixed) {
|
|
cleanXml = fixed
|
|
log.info(
|
|
`add_page: starting XML auto-fixed: ${fixes.join(", ")}`,
|
|
)
|
|
}
|
|
if (!valid && error) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: starting xml validation failed - ${error}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
let info
|
|
try {
|
|
info = addPageToDoc(loaded.doc, { id, name, xml: cleanXml })
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${msg}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
loaded.writeBack(loaded.doc)
|
|
log.info(
|
|
`Added page id=${info.id} name="${info.name}" index=${info.index}`,
|
|
)
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Page added.\n\nid=${info.id}\nname=${info.name}\nindex=${info.index}\ncells=${info.cellCount}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("add_page failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: rename_page
|
|
server.registerTool(
|
|
"rename_page",
|
|
{
|
|
title: "Rename page",
|
|
description:
|
|
"Rename an existing page (tab). At least one of page_id / page_name / page_index is required to identify which page to rename. The new_name becomes the visible tab label in the editor.",
|
|
inputSchema: {
|
|
...pageSelectorSchema,
|
|
new_name: z
|
|
.string()
|
|
.min(1)
|
|
.describe("The new display name for the page tab."),
|
|
},
|
|
annotations: { destructiveHint: false, openWorldHint: false },
|
|
},
|
|
async ({ new_name, page_id, page_name, page_index }) => {
|
|
try {
|
|
const loaded = await loadMxfileForMutation()
|
|
if (!loaded.ok) {
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Error: ${loaded.message}` },
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const pageSelector = pickPageSelector({
|
|
page_id,
|
|
page_name,
|
|
page_index,
|
|
})
|
|
if (!hasPageSelector(pageSelector)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: rename_page requires one of page_id, page_name, or page_index to identify the page.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const ok = renamePageInDoc(loaded.doc, pageSelector, new_name)
|
|
if (!ok) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: Page ${describeSelector(pageSelector)} not found.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
loaded.writeBack(loaded.doc)
|
|
log.info(
|
|
`Renamed page ${describeSelector(pageSelector)} → "${new_name}"`,
|
|
)
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Page ${describeSelector(pageSelector)} renamed to "${new_name}".`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("rename_page failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Tool: delete_page
|
|
server.registerTool(
|
|
"delete_page",
|
|
{
|
|
title: "Delete page",
|
|
description:
|
|
"Delete a page (tab) from the current diagram. At least one of page_id / page_name / page_index is required. Refuses to delete the last remaining page — the editor needs at least one tab.",
|
|
inputSchema: {
|
|
...pageSelectorSchema,
|
|
},
|
|
annotations: { openWorldHint: false },
|
|
},
|
|
async (input) => {
|
|
// All three fields are optional — coalesce so a no-args call returns
|
|
// a clean error message instead of crashing on destructure.
|
|
const { page_id, page_name, page_index } = input ?? {}
|
|
try {
|
|
const loaded = await loadMxfileForMutation()
|
|
if (!loaded.ok) {
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Error: ${loaded.message}` },
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const pageSelector = pickPageSelector({
|
|
page_id,
|
|
page_name,
|
|
page_index,
|
|
})
|
|
if (!hasPageSelector(pageSelector)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Error: delete_page requires one of page_id, page_name, or page_index to identify the page.",
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
const outcome = deletePageFromDoc(loaded.doc, pageSelector)
|
|
if (!outcome.ok) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${outcome.reason}.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
|
|
loaded.writeBack(loaded.doc)
|
|
log.info(
|
|
`Deleted page id=${outcome.deletedId} index=${outcome.deletedIndex}`,
|
|
)
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Page deleted (id=${outcome.deletedId}, was at index ${outcome.deletedIndex}).`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error)
|
|
log.error("delete_page failed:", message)
|
|
return {
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Graceful shutdown handler
|
|
let isShuttingDown = false
|
|
function gracefulShutdown(reason: string) {
|
|
if (isShuttingDown) return
|
|
isShuttingDown = true
|
|
log.info(`Shutting down: ${reason}`)
|
|
autosaver.flush()
|
|
shutdown()
|
|
process.exit(0)
|
|
}
|
|
|
|
// Handle stdin close (primary method - works on all platforms including Windows)
|
|
process.stdin.on("close", () => gracefulShutdown("stdin closed"))
|
|
process.stdin.on("end", () => gracefulShutdown("stdin ended"))
|
|
|
|
// Handle signals (may not work reliably on Windows)
|
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"))
|
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"))
|
|
|
|
// Handle broken pipe (writing to closed stdout)
|
|
process.stdout.on("error", (err) => {
|
|
if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") {
|
|
gracefulShutdown("stdout error")
|
|
}
|
|
})
|
|
|
|
// Start the MCP server
|
|
async function main() {
|
|
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
|
|
|
|
const transport = new StdioServerTransport()
|
|
await server.connect(transport)
|
|
|
|
log.info("MCP server running on stdio")
|
|
}
|
|
|
|
main().catch((error) => {
|
|
log.error("Fatal error:", error)
|
|
process.exit(1)
|
|
})
|