2025-12-17 14:50:07 +09:00
#!/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.
2025-12-21 17:41:27 +09:00
*
* Uses an embedded HTTP server - no external dependencies required.
2026-06-16 05:45:50 +05:30
*
* 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.
2025-12-17 14:50:07 +09:00
*/
// Setup DOM polyfill for Node.js (required for XML operations)
import { DOMParser } from "linkedom"
;( globalThis as any ). DOMParser = DOMParser
2025-12-21 17:41:27 +09:00
// Create XMLSerializer polyfill using outerHTML
2025-12-17 14:50:07 +09:00
class XMLSerializerPolyfill {
serializeToString ( node : any ) : string {
2025-12-21 17:41:27 +09:00
if ( node . outerHTML !== undefined ) {
return node . outerHTML
}
if ( node . documentElement ) {
return node . documentElement . outerHTML
}
2025-12-17 14:50:07 +09:00
return ""
}
}
;( globalThis as any ). XMLSerializer = XMLSerializerPolyfill
2026-07-12 19:54:42 +09:00
import { createRequire } from "node:module"
2025-12-17 14:50:07 +09:00
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 {
applyDiagramOperations ,
type DiagramOperation ,
} from "./diagram-operations.js"
2026-07-12 13:33:20 +07:00
import { checkEditGate } from "./edit-gate.js"
2025-12-21 16:11:49 +09:00
import { addHistory } from "./history.js"
2025-12-21 19:38:35 +09:00
import {
getState ,
2026-06-16 05:45:50 +05:30
requestExport ,
2025-12-21 19:38:35 +09:00
requestSync ,
setState ,
2025-12-31 18:38:20 +09:00
shutdown ,
2025-12-21 19:38:35 +09:00
startHttpServer ,
waitForSync ,
} from "./http-server.js"
2026-07-12 19:54:42 +09:00
import { parseDrawioFileContent } from "./load-diagram.js"
2025-12-17 14:50:07 +09:00
import { log } from "./logger.js"
2026-06-16 05:45:50 +05:30
import {
addPageToDoc ,
deletePageFromDoc ,
hasPageSelector ,
listPagesFromDoc ,
normalizeToMxfile ,
type PageSelector ,
parseMxfile ,
projectPage ,
renamePageInDoc ,
serializeMxfile ,
} from "./pages.js"
2025-12-21 00:32:51 +09:00
import { validateAndFixXml } from "./xml-validation.js"
2025-12-17 14:50:07 +09:00
2025-12-21 17:41:27 +09:00
// Server configuration
const config = {
2026-01-01 14:45:46 +09:00
port : parseInt ( process . env . PORT || "6002" , 10 ),
2025-12-21 17:41:27 +09:00
}
2025-12-17 14:50:07 +09:00
2025-12-21 17:41:27 +09:00
// Session state (single session for simplicity)
let currentSession : {
id : string
xml : string
version : number
2026-07-12 13:33:20 +07:00
// 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
2025-12-21 17:41:27 +09:00
} | null = null
2025-12-17 14:50:07 +09:00
2026-07-12 19:54:42 +09:00
// 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
2025-12-21 17:41:27 +09:00
const server = new McpServer ({
name : "next-ai-drawio" ,
2026-07-12 19:54:42 +09:00
version : packageVersion ,
2025-12-21 17:41:27 +09:00
})
2025-12-17 14:50:07 +09:00
2026-06-16 05:45:50 +05:30
// 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
}
/** 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"
}
2025-12-21 17:41:27 +09:00
// Register prompt with workflow guidance
2025-12-17 14:50:07 +09:00
server . prompt (
"diagram-workflow" ,
"Guidelines for creating and editing draw.io diagrams" ,
() => ({
messages : [
{
role : "user" ,
content : {
type : "text" ,
2025-12-21 16:58:41 +09:00
text : `# Draw.io Diagram Workflow Guidelines
2025-12-17 14:50:07 +09:00
## Creating a New Diagram
2025-12-21 16:58:41 +09:00
1. Call start_session to open the browser preview
2026-06-16 05:45:50 +05:30
2. Use create_new_diagram with either a bare <mxGraphModel> (single page) or a full <mxfile> with one or more <diagram> children (multi-page)
2025-12-17 14:50:07 +09:00
2026-07-12 19:54:42 +09:00
## 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)
2026-06-16 05:45:50 +05:30
## 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)
- Use rename_page / delete_page for management
- edit_diagram, get_diagram, and export_diagram all accept optional page_id / page_name / page_index — when omitted they target the first page
2026-07-12 13:33:20 +07:00
## Editing a Page (add / update / delete cells)
1. Call edit_diagram with your operations, optionally with a page selector
2. If you don't know the current cell IDs or structure, call get_diagram first
3. For add/update, provide the cell_id and complete mxCell XML
4. No need to call get_diagram before every edit: the server rejects the edit (with no side effects) if the user changed the diagram in the browser since you last saw it, and tells you to call get_diagram once and retry
2025-12-17 14:50:07 +09:00
2025-12-21 16:58:41 +09:00
## Important Notes
2026-06-16 05:45:50 +05:30
- create_new_diagram REPLACES the entire document, including ALL pages - only use for new diagrams. Use add_page to add a tab without losing existing content.
- edit_diagram PRESERVES the user's manual changes (fetches browser state first)
- Always use unique cell_ids within a page (cell ids "0" and "1" are reserved root sentinels and can repeat across pages)` ,
2025-12-17 14:50:07 +09:00
},
},
],
}),
)
// Tool: start_session
server . registerTool (
"start_session" ,
{
description :
2025-12-21 16:58:41 +09:00
"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." ,
2025-12-17 14:50:07 +09:00
inputSchema : {},
},
async () => {
try {
2025-12-21 17:41:27 +09:00
// Start embedded HTTP server
2025-12-17 14:50:07 +09:00
const port = await startHttpServer ( config . port )
2025-12-21 17:41:27 +09:00
// Create session
const sessionId = `mcp- ${ Date . now (). toString ( 36 ) } - ${ Math . random (). toString ( 36 ). substring ( 2 , 8 ) } `
currentSession = {
id : sessionId ,
xml : "" ,
version : 0 ,
2026-07-12 13:33:20 +07:00
lastSeenXml : "" ,
2025-12-21 17:41:27 +09:00
}
// Open browser
2025-12-17 14:50:07 +09:00
const browserUrl = `http://localhost: ${ port } ?mcp= ${ sessionId } `
await open ( browserUrl )
log . info ( `Started session ${ sessionId } , browser at ${ browserUrl } ` )
return {
content : [
{
type : "text" ,
2025-12-21 16:58:41 +09:00
text : `Session started successfully!\n\nSession ID: ${ sessionId } \nBrowser URL: ${ browserUrl } \n\nThe browser will now show real-time diagram updates.` ,
2025-12-17 14:50:07 +09:00
},
],
}
} 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 ,
}
}
},
)
2025-12-29 15:15:13 +09:00
// Tool: create_new_diagram
2025-12-17 14:50:07 +09:00
server . registerTool (
2025-12-29 15:15:13 +09:00
"create_new_diagram" ,
2025-12-17 14:50:07 +09:00
{
2026-06-16 05:45:50 +05:30
description : `Create a NEW diagram from XML. ONLY use this when creating a diagram from scratch.
2026-04-10 17:18:24 +09:00
2026-06-16 05:45:50 +05:30
⚠️ DESTRUCTIVE: This tool REPLACES the entire document, INCLUDING every existing page/tab and any unsaved user changes. To add a tab without losing existing content, use add_page instead. To modify cells on an existing page, use edit_diagram.
2025-12-29 15:15:13 +09:00
CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
When to use this tool:
2026-06-16 05:45:50 +05:30
- Creating a new diagram from scratch (no existing diagram, or wanting to wipe and start over)
2026-04-10 17:18:24 +09:00
- The user explicitly asks to "start over" or "create a new diagram"
2025-12-29 15:15:13 +09:00
2026-06-16 05:45:50 +05:30
When to use add_page instead:
- The user wants ANOTHER tab/page alongside what's already there (e.g. "add a CNN diagram on a new page")
2025-12-29 15:15:13 +09:00
2026-06-16 05:45:50 +05:30
When to use edit_diagram instead:
- ANY modifications to an existing page's cells (add/remove/move shapes, change labels, etc.)
ACCEPTED XML SHAPES:
1) Bare mxGraphModel (single-page, legacy):
2025-12-29 15:15:13 +09:00
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Shape" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
2026-06-16 05:45:50 +05:30
The server auto-wraps this in <mxfile><diagram id="..." name="Page-1">...</diagram></mxfile>.
2025-12-29 15:15:13 +09:00
2026-06-16 05:45:50 +05:30
2) Full mxfile (one or more pages):
<mxfile host="app.diagrams.net">
<diagram id="page-1" name="Architecture">
<mxGraphModel><root>...</root></mxGraphModel>
</diagram>
<diagram id="page-2" name="Sequence">
<mxGraphModel><root>...</root></mxGraphModel>
</diagram>
</mxfile>
Each <diagram> becomes a tab in the embedded editor. Cell ids "0" and "1" are reserved root sentinels and MUST repeat in every page's <root>.
LAYOUT CONSTRAINTS (per page):
2025-12-29 15:15:13 +09:00
- Keep all elements within x=0-800, y=0-600 (single page viewport)
- Start from margins (x=40, y=40), keep elements grouped closely
2026-06-16 05:45:50 +05:30
- Use unique IDs starting from "2" within each page (0 and 1 are reserved)
2025-12-29 15:15:13 +09:00
- Set parent="1" for top-level shapes
- Space shapes 150-200px apart for clear edge routing
EDGE ROUTING RULES:
- Never let multiple edges share the same path - use different exitY/entryY values
- For bidirectional connections (A↔B), use OPPOSITE sides
- Always specify exitX, exitY, entryX, entryY explicitly in edge style
- Route edges AROUND obstacles using waypoints (add 20-30px clearance)
- Use natural connection points based on flow (not corners)
COMMON STYLES:
- Shapes: rounded=1; fillColor=#hex; strokeColor=#hex
- Edges: endArrow=classic; edgeStyle=orthogonalEdgeStyle; curved=1
- Text: fontSize=14; fontStyle=1 (bold); align=center` ,
2025-12-17 14:50:07 +09:00
inputSchema : {
xml : z
. string ()
2025-12-29 15:15:13 +09:00
. describe (
2026-06-16 05:45:50 +05:30
"REQUIRED: Either a complete <mxGraphModel> (legacy single-page) or a full <mxfile> with one or more <diagram> children (multi-page)." ,
2025-12-29 15:15:13 +09:00
),
2025-12-17 14:50:07 +09:00
},
},
2025-12-21 00:32:51 +09:00
async ({ xml : inputXml }) => {
2025-12-17 14:50:07 +09:00
try {
if ( ! currentSession ) {
return {
content : [
{
type : "text" ,
2025-12-21 16:58:41 +09:00
text : "Error: No active session. Please call start_session first." ,
2025-12-17 14:50:07 +09:00
},
],
isError : true ,
}
}
2026-06-16 05:45:50 +05:30
// Validate and auto-fix XML (works for both mxfile and mxGraphModel inputs).
2025-12-21 00:32:51 +09:00
let xml = inputXml
const { valid , error , fixed , fixes } = validateAndFixXml ( xml )
if ( fixed ) {
xml = fixed
log . info ( `XML auto-fixed: ${ fixes . join ( ", " ) } ` )
}
if ( ! valid && error ) {
2025-12-21 17:41:27 +09:00
log . error ( `XML validation failed: ${ error } ` )
2025-12-21 00:32:51 +09:00
return {
content : [
{
type : "text" ,
text : `Error: XML validation failed - ${ error } ` ,
},
],
isError : true ,
}
}
2026-06-16 05:45:50 +05:30
// Normalise to the canonical mxfile shape so every later tool can
// assume "session.xml is always an mxfile". Bare <mxGraphModel>
// inputs are wrapped into a single-page mxfile here.
const normalized = normalizeToMxfile ( xml )
if ( ! normalized ) {
return {
content : [
{
type : "text" ,
text : "Error: XML must be either a <mxGraphModel> or an <mxfile> with one or more <diagram> children." ,
},
],
isError : true ,
}
}
xml = normalized
2025-12-29 15:15:13 +09:00
log . info ( `Setting diagram content, ${ xml . length } chars` )
2025-12-21 17:41:27 +09:00
2025-12-21 16:58:41 +09:00
// 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)
2025-12-21 16:09:14 +09:00
if ( currentSession . xml ) {
2025-12-21 16:58:41 +09:00
addHistory (
currentSession . id ,
currentSession . xml ,
browserState ? . svg || "" ,
)
2025-12-21 16:09:14 +09:00
}
2025-12-21 17:41:27 +09:00
// Update session state
2025-12-17 14:50:07 +09:00
currentSession . xml = xml
2025-12-21 17:41:27 +09:00
currentSession . version ++
2026-07-12 13:33:20 +07:00
// 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.
2025-12-17 14:50:07 +09:00
setState ( currentSession . id , xml )
2026-07-12 13:33:20 +07:00
currentSession . lastSeenXml = xml
2025-12-17 14:50:07 +09:00
2025-12-21 17:41:27 +09:00
// Save AI result (no SVG yet - will be captured by browser)
2025-12-21 16:58:41 +09:00
addHistory ( currentSession . id , xml , "" )
2025-12-21 16:09:14 +09:00
2026-06-16 05:45:50 +05:30
// 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 } )` )
2025-12-17 14:50:07 +09:00
return {
content : [
{
type : "text" ,
2026-06-16 05:45:50 +05:30
text : `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${ xml . length } characters\n ${ pageSummary } ` ,
2025-12-17 14:50:07 +09:00
},
],
}
} catch ( error ) {
const message =
error instanceof Error ? error . message : String ( error )
2025-12-29 15:15:13 +09:00
log . error ( "create_new_diagram failed:" , message )
2025-12-17 14:50:07 +09:00
return {
content : [{ type : "text" , text : `Error: ${ message } ` }],
2026-07-12 19:54:42 +09:00
isError : true ,
}
}
},
)
// 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 } ` }],
2025-12-17 14:50:07 +09:00
isError : true ,
}
}
},
)
// Tool: edit_diagram
server . registerTool (
"edit_diagram" ,
{
description :
2026-06-16 05:45:50 +05:30
"Edit a specific page in the current diagram by ID-based operations (update/add/delete cells).\n\n" +
2026-07-12 13:33:20 +07:00
"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 — if your view is stale, the call is rejected (with no side " +
"effects) and the error tells you to call get_diagram once 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" +
2026-06-16 05:45:50 +05:30
"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" +
2025-12-17 14:50:07 +09:00
"Operations:\n" +
2026-06-16 05:45:50 +05:30
"- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml.\n" +
2025-12-21 17:41:27 +09:00
"- 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.\n\n" +
2025-12-25 13:19:04 +09:00
"For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" +
2026-06-16 05:45:50 +05:30
"Example - Add a rectangle on the default (first) page:\n" +
2025-12-25 13:19:04 +09:00
'{"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' +
2026-06-16 05:45:50 +05:30
"Example - Add a cell on a specific page by name:\n" +
'{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" ... />"}]}\n\n' +
"Example - Update a cell on page index 1:\n" +
'{"page_index": 1, "operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" .../>"}]}\n\n' +
"Example - Delete a cell on the default page:\n" +
2025-12-25 13:19:04 +09:00
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}' ,
2025-12-17 14:50:07 +09:00
inputSchema : {
2026-06-16 05:45:50 +05:30
... pageSelectorSchema ,
2025-12-17 14:50:07 +09:00
operations : z
. array (
z . object ({
2025-12-25 13:19:04 +09:00
operation : z
2025-12-21 17:41:27 +09:00
. enum ([ "update" , "add" , "delete" ])
2025-12-25 13:19:04 +09:00
. describe (
"Operation to perform: add, update, or delete" ,
),
2025-12-21 17:41:27 +09:00
cell_id : z . string (). describe ( "The id of the mxCell" ),
new_xml : z
. string ()
. optional ()
. describe (
"Complete mxCell XML element (required for update/add)" ,
),
2025-12-17 14:50:07 +09:00
}),
)
2025-12-21 17:41:27 +09:00
. describe ( "Array of operations to apply" ),
2025-12-17 14:50:07 +09:00
},
},
2026-06-16 05:45:50 +05:30
async ({ operations , page_id , page_name , page_index }) => {
2025-12-17 14:50:07 +09:00
try {
if ( ! currentSession ) {
return {
content : [
{
type : "text" ,
2025-12-21 16:58:41 +09:00
text : "Error: No active session. Please call start_session first." ,
2025-12-17 14:50:07 +09:00
},
],
isError : true ,
}
}
2026-06-16 05:45:50 +05:30
// 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.
2025-12-17 14:50:07 +09:00
const browserState = getState ( currentSession . id )
if ( browserState ? . xml ) {
2026-06-16 05:45:50 +05:30
currentSession . xml =
normalizeToMxfile ( browserState . xml ) ?? browserState . xml
2025-12-21 17:41:27 +09:00
log . info ( "Fetched latest diagram state from browser" )
2025-12-17 14:50:07 +09:00
}
if ( ! currentSession . xml ) {
return {
content : [
{
type : "text" ,
2025-12-29 15:15:13 +09:00
text : "Error: No diagram to edit. Please create a diagram first with create_new_diagram." ,
2025-12-17 14:50:07 +09:00
},
],
isError : true ,
}
}
2026-07-12 13:33:20 +07:00
// 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 called with unseen browser changes - rejecting to prevent data loss"
: "edit_diagram called without get_diagram - rejecting to prevent data loss" ,
)
return {
content : [
{
type : "text" ,
text :
gate . reason === "stale"
? "Error: The diagram changed in the browser since you last fetched it (e.g. manual user edits).\n\n" +
"Call get_diagram to see the latest state, then rebuild your edit operations on top of it."
: "Error: You must call get_diagram first before edit_diagram.\n\n" +
"This ensures you have the latest diagram state including any manual edits the user made in the browser. " +
"Please call get_diagram, then use that XML to construct your edit operations." ,
},
],
isError : true ,
}
}
2026-06-16 05:45:50 +05:30
const pageSelector = pickPageSelector ({
page_id ,
page_name ,
page_index ,
})
log . info (
`Editing diagram with ${ operations . length } operation(s) on ${ describeSelector ( pageSelector ) } ` ,
2025-12-21 16:58:41 +09:00
)
2025-12-17 14:50:07 +09:00
2025-12-21 17:41:27 +09:00
// Validate and auto-fix new_xml for each operation
2025-12-21 00:32:51 +09:00
const validatedOps = operations . map (( op ) => {
if ( op . new_xml ) {
2025-12-21 17:41:27 +09:00
const { valid , error , fixed , fixes } = validateAndFixXml (
op . new_xml ,
)
2025-12-21 00:32:51 +09:00
if ( fixed ) {
log . info (
2025-12-25 13:19:04 +09:00
`Operation ${ op . operation } ${ op . cell_id } : XML auto-fixed: ${ fixes . join ( ", " ) } ` ,
2025-12-21 00:32:51 +09:00
)
return { ... op , new_xml : fixed }
}
2025-12-21 17:41:27 +09:00
if ( ! valid && error ) {
log . warn (
2025-12-25 13:19:04 +09:00
`Operation ${ op . operation } ${ op . cell_id } : XML validation failed: ${ error } ` ,
2025-12-21 17:41:27 +09:00
)
}
2025-12-21 00:32:51 +09:00
}
return op
})
2026-06-16 05:45:50 +05:30
// Apply operations on the targeted page
2025-12-17 14:50:07 +09:00
const { result , errors } = applyDiagramOperations (
currentSession . xml ,
2025-12-21 00:32:51 +09:00
validatedOps as DiagramOperation [],
2026-06-16 05:45:50 +05:30
pageSelector ,
2025-12-17 14:50:07 +09:00
)
2025-12-21 17:41:27 +09:00
if ( errors . length > 0 ) {
const errorMessages = errors
. map (( e ) => ` ${ e . type } ${ e . cellId } : ${ e . message } ` )
. join ( "\n" )
log . warn ( `Edit had ${ errors . length } error(s): ${ errorMessages } ` )
}
2026-06-16 05:45:50 +05:30
// A page-level error (empty cellId — e.g. the selector matched no
// page, or the page had no <root>) means NOTHING was applied and
// `result` is the unchanged input. Surface it as a hard error
// instead of persisting a no-op and reporting success, so the
// caller doesn't build on a wrong assumption.
const pageError = errors . find (( e ) => e . cellId === "" )
if ( pageError ) {
return {
content : [
{
type : "text" ,
text : `Error: ${ pageError . message } ` ,
},
],
isError : true ,
}
}
// Save the pre-edit state for undo (with cached SVG from browser).
// Done only now that we know the edit applied — a page-level error
// returns above without leaving a phantom history entry.
addHistory (
currentSession . id ,
currentSession . xml ,
browserState ? . svg || "" ,
)
2025-12-21 17:41:27 +09:00
// Update state
2025-12-17 14:50:07 +09:00
currentSession . xml = result
2025-12-21 17:41:27 +09:00
currentSession . version ++
2026-07-12 13:33:20 +07:00
// Push to embedded server; the pushed XML is now the latest
// state the model has seen.
2025-12-17 14:50:07 +09:00
setState ( currentSession . id , result )
2026-07-12 13:33:20 +07:00
currentSession . lastSeenXml = result
2025-12-17 14:50:07 +09:00
2025-12-21 17:41:27 +09:00
// Save AI result (no SVG yet - will be captured by browser)
2025-12-21 16:58:41 +09:00
addHistory ( currentSession . id , result , "" )
2025-12-21 16:09:14 +09:00
2025-12-21 17:41:27 +09:00
log . info ( `Diagram edited successfully` )
2025-12-17 14:50:07 +09:00
2026-06-16 05:45:50 +05:30
const successMsg = `Diagram edited successfully!\n\nApplied ${ operations . length } operation(s) on ${ describeSelector ( pageSelector ) } .`
2025-12-21 17:41:27 +09:00
const errorMsg =
2025-12-17 14:50:07 +09:00
errors . length > 0
2025-12-21 17:41:27 +09:00
? `\n\nWarnings:\n ${ errors . map (( e ) => `- ${ e . type } ${ e . cellId } : ${ e . message } ` ). join ( "\n" ) } `
2025-12-17 14:50:07 +09:00
: ""
2025-12-21 17:41:27 +09:00
return {
content : [
{
type : "text" ,
text : successMsg + errorMsg ,
},
],
}
2025-12-17 14:50:07 +09:00
} 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" ,
{
description :
2025-12-21 17:41:27 +09:00
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
2026-07-12 13:33:20 +07:00
"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" +
2026-06-16 05:45:50 +05:30
"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 ,
},
2025-12-17 14:50:07 +09:00
},
2026-06-16 05:45:50 +05:30
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 ?? {}
2025-12-17 14:50:07 +09:00
try {
if ( ! currentSession ) {
return {
content : [
{
type : "text" ,
2025-12-21 16:58:41 +09:00
text : "Error: No active session. Please call start_session first." ,
2025-12-17 14:50:07 +09:00
},
],
isError : true ,
}
}
2025-12-21 19:38:35 +09:00
// Request browser to push fresh state and wait for it
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" )
}
}
2026-06-16 05:45:50 +05:30
// 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).
2025-12-17 14:50:07 +09:00
const browserState = getState ( currentSession . id )
if ( browserState ? . xml ) {
2026-06-16 05:45:50 +05:30
currentSession . xml =
normalizeToMxfile ( browserState . xml ) ?? browserState . xml
2025-12-17 14:50:07 +09:00
}
if ( ! currentSession . xml ) {
return {
content : [
{
type : "text" ,
2025-12-29 15:15:13 +09:00
text : "No diagram exists yet. Use create_new_diagram to create one." ,
2025-12-17 14:50:07 +09:00
},
],
}
}
2026-07-12 13:33:20 +07:00
// 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
2026-06-16 05:45:50 +05:30
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 } ` ,
},
],
}
}
// 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 ,
}
}
2025-12-17 14:50:07 +09:00
return {
content : [
{
type : "text" ,
2026-06-16 05:45:50 +05:30
text : `Page ${ projection . index } (" ${ projection . name } "):\n\n ${ projection . xml } \n\n ${ pageList } ` ,
2025-12-17 14:50:07 +09:00
},
],
}
} catch ( error ) {
const message =
error instanceof Error ? error . message : String ( error )
2025-12-21 17:41:27 +09:00
log . error ( "get_diagram failed:" , message )
2025-12-17 14:50:07 +09:00
return {
content : [{ type : "text" , text : `Error: ${ message } ` }],
isError : true ,
}
}
},
)
// Tool: export_diagram
server . registerTool (
"export_diagram" ,
{
2026-02-07 12:55:09 +09:00
description :
"Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " +
2026-06-16 05:45:50 +05:30
"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 / .svg with a page selector: temporarily loads a single-page projection of that page into the browser, captures the rendered image, then restores the full document. The user will see a brief tab-flicker (~1-2s) but the exported image is guaranteed to be the requested page." ,
2025-12-17 14:50:07 +09:00
inputSchema : {
2026-06-16 05:45:50 +05:30
... pageSelectorSchema ,
2025-12-21 17:41:27 +09:00
path : z
. string ()
. describe (
2026-02-07 12:55:09 +09:00
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)" ,
),
format : z
. enum ([ "drawio" , "png" , "svg" ])
. optional ()
. describe (
"Export format. If omitted, detected from file extension. Defaults to drawio." ,
2025-12-21 17:41:27 +09:00
),
2025-12-17 14:50:07 +09:00
},
},
2026-06-16 05:45:50 +05:30
async ({ path , format , page_id , page_name , page_index }) => {
2025-12-17 14:50:07 +09:00
try {
if ( ! currentSession ) {
return {
content : [
{
type : "text" ,
2025-12-21 16:58:41 +09:00
text : "Error: No active session. Please call start_session first." ,
2025-12-17 14:50:07 +09:00
},
],
isError : true ,
}
}
2025-12-21 17:41:27 +09:00
// Fetch latest state
2025-12-17 14:50:07 +09:00
const browserState = getState ( currentSession . id )
if ( browserState ? . xml ) {
currentSession . xml = browserState . xml
}
if ( ! currentSession . xml ) {
return {
content : [
2025-12-21 17:41:27 +09:00
{
type : "text" ,
text : "Error: No diagram to export. Please create a diagram first." ,
},
2025-12-17 14:50:07 +09:00
],
isError : true ,
}
}
2026-06-16 05:45:50 +05:30
const pageSelector = pickPageSelector ({
page_id ,
page_name ,
page_index ,
})
2025-12-17 14:50:07 +09:00
const fs = await import ( "node:fs/promises" )
const nodePath = await import ( "node:path" )
2026-02-07 12:55:09 +09:00
// Detect format from extension if not specified
const ext = nodePath . extname ( path ). toLowerCase ()
const detectedFormat =
format ||
( ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio" )
2026-06-16 05:45:50 +05:30
// .drawio path - write XML directly (no browser round-trip).
2026-02-07 12:55:09 +09:00
if ( detectedFormat === "drawio" ) {
let filePath = path
if ( ! filePath . endsWith ( ".drawio" )) {
filePath = ` ${ filePath } .drawio`
}
const absolutePath = nodePath . resolve ( filePath )
2026-06-16 05:45:50 +05:30
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" )
2026-02-07 12:55:09 +09:00
log . info ( `Diagram exported to ${ absolutePath } ` )
return {
content : [
{
type : "text" ,
2026-06-16 05:45:50 +05:30
text : `Diagram exported successfully!\n\nFile: ${ absolutePath } \nSize: ${ outXml . length } characters` ,
2026-02-07 12:55:09 +09:00
},
],
}
2025-12-21 17:41:27 +09:00
}
2025-12-17 14:50:07 +09:00
2026-02-07 12:55:09 +09:00
// PNG or SVG: request browser to export via iframe
let filePath = path
if ( ext !== `. ${ detectedFormat } ` ) {
if ( ext === ".drawio" || ext === ".png" || ext === ".svg" ) {
filePath = filePath . slice ( 0 , - ext . length )
}
filePath = ` ${ filePath } . ${ detectedFormat } `
}
2025-12-17 14:50:07 +09:00
const absolutePath = nodePath . resolve ( filePath )
2026-02-07 12:55:09 +09:00
const state = getState ( currentSession . id )
if ( ! state ) {
return {
content : [
{
type : "text" ,
text : "Error: Session state not found. Is the browser open?" ,
},
],
isError : true ,
}
}
2025-12-17 14:50:07 +09:00
2026-06-16 05:45:50 +05:30
// -----------------------------------------------------------------
// 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.
// -----------------------------------------------------------------
let projectionXml : string | undefined
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 target page for export."
: `Error: Page ${ describeSelector ( pageSelector ) } not found for export.` ,
},
],
isError : true ,
}
}
projectionXml = projection . xml
}
// Ask the browser to export (optionally via a page projection) and
// poll for the resulting image data.
requestExport (
currentSession . id ,
detectedFormat as "png" | "svg" ,
projectionXml ,
)
// 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
2026-02-07 12:55:09 +09:00
const start = Date . now ()
2026-06-16 05:45:50 +05:30
let exportData : string | undefined
2026-02-07 12:55:09 +09:00
while ( Date . now () - start < timeoutMs ) {
2026-06-16 05:45:50 +05:30
exportData = getState ( currentSession . id ) ? . exportData
if ( exportData ) break
2026-02-07 12:55:09 +09:00
await new Promise (( r ) => setTimeout ( r , 200 ))
}
2026-06-16 05:45:50 +05:30
const live = getState ( currentSession . id )
if ( live ) {
live . exportData = undefined
live . exportFormat = undefined
live . exportXml = undefined
}
2026-02-07 12:55:09 +09:00
if ( ! exportData ) {
return {
content : [
{
type : "text" ,
2026-06-16 05:45:50 +05:30
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." ,
2026-02-07 12:55:09 +09:00
},
],
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)` ,
)
2025-12-17 14:50:07 +09:00
return {
content : [
{
type : "text" ,
2026-02-07 12:55:09 +09:00
text : `Diagram exported successfully!\n\nFile: ${ absolutePath } \nFormat: ${ detectedFormat } \nSize: ${ stat . size } bytes` ,
2025-12-17 14:50:07 +09:00
},
],
}
} catch ( error ) {
const message =
error instanceof Error ? error . message : String ( error )
2025-12-21 17:41:27 +09:00
log . error ( "export_diagram failed:" , message )
2025-12-17 14:50:07 +09:00
return {
content : [{ type : "text" , text : `Error: ${ message } ` }],
isError : true ,
}
}
},
)
2026-06-16 05:45:50 +05:30
/**
* 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 )
// 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 )
2026-07-12 13:33:20 +07:00
// The model just wrote this exact state, so mark it as seen —
// subsequent edit_diagram calls don't need a redundant
// get_diagram round-trip.
sessionRef . lastSeenXml = newXml
2026-06-16 05:45:50 +05:30
addHistory ( sessionRef . id , newXml , "" )
},
}
}
// Tool: list_pages
server . registerTool (
"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 : {},
},
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" ,
{
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 <mxGraphModel> for the new page. If omitted, the page starts blank with the standard root sentinel cells ("0" and "1").\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 <mxGraphModel> XML for the new page. Must include <root> with id="0" and id="1" cells. If omitted the page starts blank.' ,
),
},
},
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
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" ,
{
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." ),
},
},
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" ,
{
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 ,
},
},
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 ,
}
}
},
)
2025-12-31 18:38:20 +09:00
// Graceful shutdown handler
let isShuttingDown = false
function gracefulShutdown ( reason : string ) {
if ( isShuttingDown ) return
isShuttingDown = true
log . info ( `Shutting down: ${ reason } ` )
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" )
}
})
2025-12-21 17:41:27 +09:00
// Start the MCP server
2025-12-17 14:50:07 +09:00
async function main () {
2025-12-21 17:41:27 +09:00
log . info ( "Starting MCP server for Next AI Draw.io (embedded mode)..." )
2025-12-17 14:50:07 +09:00
const transport = new StdioServerTransport ()
await server . connect ( transport )
2025-12-21 17:41:27 +09:00
log . info ( "MCP server running on stdio" )
2025-12-17 14:50:07 +09:00
}
main (). catch (( error ) => {
2025-12-21 17:41:27 +09:00
log . error ( "Fatal error:" , error )
2025-12-17 14:50:07 +09:00
process . exit ( 1 )
})