feat(mcp-server): bring the web app's drawing knowledge to MCP

- Add a drawing guide adapted from the web system prompt (layout, edge
  routing, styles, minimal style, editing rules), returned by
  start_session, a new get_drawing_guide tool and the diagram-workflow prompt
- Add get_shape_library with the 30 icon libraries; the build copies
  docs/shape-libraries into dist and CI checks the packed files
- Accept bare mxCell lists in create_new_diagram and add_page; the server
  adds the wrapper and root cells
- Send server instructions, shorten create_new_diagram's description to
  fit Claude Code's 2,048 character limit, and annotate every tool
- Fix dead links and the totals in docs/shape-libraries/README.md
This commit is contained in:
dayuan.jiang
2026-10-04 06:15:46 +09:00
parent 6d67a0ec69
commit 9de281627e
13 changed files with 499 additions and 119 deletions
+4
View File
@@ -62,6 +62,10 @@ jobs:
if: steps.version.outputs.publish == 'true'
run: npm test
- name: Build and check package contents
if: steps.version.outputs.publish == 'true'
run: npm run build && npm run check-package
- name: Publish to npm
if: steps.version.outputs.publish == 'true'
run: npm publish
+4
View File
@@ -38,6 +38,10 @@ jobs:
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
# Tests run from src/, so check the built npm package separately
- name: Build MCP server and check package contents
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
e2e:
name: E2E Tests
runs-on: ubuntu-latest
+2 -5
View File
@@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure
@@ -20,7 +19,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|---------|--------|--------|-------------|------|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
@@ -30,7 +28,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams
@@ -48,6 +45,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
## Enterprise Software
@@ -73,6 +71,5 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
**Total: 33 libraries, 4,281 shapes**
**Total: 30 libraries, 4,184 shapes**
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.4",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.4",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.31.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.4",
"version": "0.3.0",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
@@ -8,7 +8,8 @@
"next-ai-drawio-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"build": "tsc && node scripts/copy-assets.mjs",
"check-package": "node scripts/check-package.mjs",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
@@ -0,0 +1,17 @@
// Fail if the npm package would miss files the server reads at runtime.
// Tests run from src/ (tsx) and cannot notice a broken dist/ copy step.
// Run after `npm run build`.
import { execSync } from "node:child_process"
const REQUIRED = ["dist/index.js", "dist/shape-libraries/aws4.md"]
const [pack] = JSON.parse(
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
)
const files = new Set(pack.files.map((f) => f.path))
const missing = REQUIRED.filter((f) => !files.has(f))
if (missing.length > 0) {
console.error(`npm package is missing: ${missing.join(", ")}`)
process.exit(1)
}
console.log(`npm package OK (${files.size} files)`)
@@ -0,0 +1,17 @@
// Copy non-TypeScript assets into dist/ after tsc, so they ship in the npm
// package ("files": ["dist"]).
import { cpSync, mkdirSync, readdirSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
// Shape library docs live at the repository root, shared with the web app
const libSrc = join(pkg, "../../docs/shape-libraries")
const libDest = join(pkg, "dist/shape-libraries")
mkdirSync(libDest, { recursive: true })
for (const file of readdirSync(libSrc)) {
if (file.endsWith(".md") && file !== "README.md") {
cpSync(join(libSrc, file), join(libDest, file))
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Drawing guide for the model, returned by start_session, get_drawing_guide
* and the diagram-workflow prompt.
*
* Adapted from the web app's system prompt (lib/system-prompts.ts) and its
* tool descriptions (app/api/chat/route.ts). When drawing rules change there,
* update this file too.
*/
export const DRAWING_GUIDE = `# Draw.io drawing guide
## Workflow
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes.
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
## The XML you send
Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1".
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
Rules (XML that breaks them is rejected):
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
3. parent="1" for top-level shapes, parent="<container id>" for shapes inside a container.
4. Edge source and target must reference existing cell ids.
5. Escape special characters in attribute values: &lt; for <, &gt; for >, &amp; for &, &quot; for ".
6. NEVER include XML comments (<!-- -->). draw.io strips them.
7. In tool arguments (JSON), every " inside the XML must be escaped as \\".
Containers and swimlanes: children use the container id as parent and coordinates relative to the container.
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
## Layout
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
- Start near x=40, y=40 and keep elements grouped closely.
- For large diagrams, stack vertically or use a grid instead of spreading wide.
## Edge routing rules
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;".
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
Rule 7: Use natural connection points. Never connect at corners (both X and Y 0 or 1). Top-to-bottom flow: exitY=1 into entryY=0. Left-to-right flow: exitX=1 into entryX=0. Diagonal: the side closest to the target.
Before sending XML, check:
1. Does any edge cross a shape that is not its source or target? Add waypoints.
2. Do two edges share a path? Change their exit or entry points.
3. Is any connection point at a corner? Use the middle of a side.
4. Could moving shapes remove edge crossings? Revise the layout.
Two edges between the same nodes:
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (right, bottom) connects to Main (center, top) while Develop (center, middle) is in between, so the edge goes right to x=750 first, then up, and enters Main from the right:
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="750" y="80"/>
<mxPoint x="750" y="150"/>
</Array>
</mxGeometry>
</mxCell>
## Styles
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1;
- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right
- Animated connectors: add flowAnimation=1 to the edge style.
## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;".
- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children.
- Keep at least 50px between elements, and follow all edge routing rules strictly.
## Editing with edit_diagram
- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id.
- add inserts a new cell with a new id. One cell per operation.
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
- If the diagram is large, change it with edit_diagram instead of redrawing it.
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"40\\" y=\\"40\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
`
+120 -107
View File
@@ -26,6 +26,7 @@ import open from "open"
import { z } from "zod"
import type { DiagramOperation } from "./diagram-operations.js"
import { installDomPolyfill } from "./dom.js"
import { DRAWING_GUIDE } from "./drawing-guide.js"
import { editDiagram, targetPageXml } from "./edit-diagram.js"
import { checkEditGate } from "./edit-gate.js"
import { addHistory } from "./history.js"
@@ -51,7 +52,9 @@ import {
projectPage,
renamePageInDoc,
serializeMxfile,
wrapCellsInModel,
} from "./pages.js"
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.js"
import { validateAndFixXml } from "./xml-validation.js"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
@@ -83,10 +86,29 @@ let currentSession: {
const require = createRequire(import.meta.url)
const packageVersion: string = require("../package.json").version
const server = new McpServer({
name: "next-ai-drawio",
version: packageVersion,
})
// 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.
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.
- 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
@@ -149,7 +171,7 @@ function describeSelector(s: PageSelector): string {
return "first page"
}
// Register prompt with workflow guidance
// The same guide as start_session, for hosts that show prompts to the user
server.registerPrompt(
"diagram-workflow",
{
@@ -159,49 +181,67 @@ server.registerPrompt(
messages: [
{
role: "user",
content: {
type: "text",
text: `# Draw.io Diagram Workflow Guidelines
## Creating a New Diagram
1. Call start_session to open the browser preview
2. Use create_new_diagram with either a bare <mxGraphModel> (single page) or a full <mxfile> with one or more <diagram> children (multi-page)
## Opening an Existing .drawio File
- Use load_diagram with the file path — the server reads and decompresses the file itself; don't read it and pass the XML through create_new_diagram
- After loading, call get_diagram once before editing (you haven't seen the file's cell IDs yet)
## Working with Multiple Pages
- Use list_pages to discover existing pages (id, name, index)
- Use add_page to append a new page (without losing existing ones — unlike create_new_diagram which REPLACES everything)
- 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
## 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
## Important Notes
- 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: it is rejected with the current XML when the user edited the diagram since you last saw it
- Always use unique cell_ids within a page (cell ids "0" and "1" are reserved root sentinels and can repeat across pages)`,
},
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${Object.entries(SHAPE_LIBRARY_GROUPS)
.map(([group, names]) => `- ${group}: ${names.join(", ")}`)
.join("\n")}`,
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 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 {
@@ -227,7 +267,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`,
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.\n\n${DRAWING_GUIDE}`,
},
],
}
@@ -247,72 +287,26 @@ server.registerTool(
server.registerTool(
"create_new_diagram",
{
description: `Create a NEW diagram from XML. ONLY use this when creating a diagram from scratch.
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.
⚠️ 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.
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).
CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
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"/>.
When to use this tool:
- Creating a new diagram from scratch (no existing diagram, or wanting to wipe and start over)
- The user explicitly asks to "start over" or "create a new diagram"
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")
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):
<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>
The server auto-wraps this in <mxfile><diagram id="..." name="Page-1">...</diagram></mxfile>.
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):
- Keep all elements within x=0-800, y=0-600 (single page viewport)
- Start from margins (x=40, y=40), keep elements grouped closely
- Use unique IDs starting from "2" within each page (0 and 1 are reserved)
- 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`,
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: Either a complete <mxGraphModel> (legacy single-page) or a full <mxfile> with one or more <diagram> children (multi-page).",
"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 {
@@ -328,8 +322,10 @@ COMMON STYLES:
}
}
// Validate and auto-fix XML (works for both mxfile and mxGraphModel inputs).
let xml = inputXml
// Bare cells get the wrapper and root cells first: the strict
// parser rejects several top-level elements. Then validate and
// auto-fix (works for both mxfile and mxGraphModel inputs).
let xml = wrapCellsInModel(inputXml)
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
if (fixed) {
xml = fixed
@@ -357,7 +353,7 @@ COMMON STYLES:
content: [
{
type: "text",
text: "Error: XML must be either a <mxGraphModel> or an <mxfile> with one or more <diagram> children.",
text: "Error: XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
},
],
isError: true,
@@ -432,6 +428,7 @@ COMMON STYLES:
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. " +
@@ -444,6 +441,7 @@ server.registerTool(
"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 {
@@ -549,6 +547,7 @@ server.registerTool(
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" +
@@ -563,16 +562,13 @@ server.registerTool(
"- 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.\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.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\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 - 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" +
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
inputSchema: {
@@ -585,7 +581,11 @@ server.registerTool(
.describe(
"Operation to perform: add, update, or delete",
),
cell_id: z.string().describe("The id of the mxCell"),
cell_id: z
.string()
.describe(
"The id of the mxCell. Must match the id attribute in new_xml.",
),
new_xml: z
.string()
.optional()
@@ -596,6 +596,7 @@ server.registerTool(
)
.describe("Array of operations to apply"),
},
annotations: { openWorldHint: false },
},
async ({ operations, page_id, page_name, page_index }) => {
try {
@@ -744,6 +745,7 @@ server.registerTool(
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) — " +
@@ -753,6 +755,7 @@ server.registerTool(
inputSchema: {
...pageSelectorSchema,
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
async (input) => {
// Defensive: when every field is optional an MCP client could in
@@ -915,6 +918,7 @@ function exportViaBrowser(
server.registerTool(
"export_diagram",
{
title: "Export diagram",
description:
"Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " +
"The format is auto-detected from the file extension, or can be specified explicitly.\n\n" +
@@ -937,6 +941,7 @@ server.registerTool(
"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)
@@ -1222,9 +1227,11 @@ async function loadMxfileForMutation(): Promise<
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 {
@@ -1276,12 +1283,13 @@ server.registerTool(
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 <mxGraphModel> for the new page. If omitted, the page starts blank with the standard root sentinel cells ("0" and "1").\n\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
@@ -1301,9 +1309,10 @@ server.registerTool(
.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.',
"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
@@ -1322,7 +1331,7 @@ server.registerTool(
// 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
let cleanXml: string | undefined = xml && wrapCellsInModel(xml)
if (cleanXml) {
const { valid, error, fixed, fixes } =
validateAndFixXml(cleanXml)
@@ -1384,6 +1393,7 @@ server.registerTool(
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: {
@@ -1393,6 +1403,7 @@ server.registerTool(
.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 {
@@ -1464,11 +1475,13 @@ server.registerTool(
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
+32 -1
View File
@@ -81,6 +81,37 @@ function stripXmlDeclaration(xml: string): string {
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
}
const ROOT_CELLS = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
/**
* Turn a list of bare cells (optionally inside <root>) into a one-page
* <mxGraphModel>, adding the "0" and "1" root cells. The model then only
* writes its own cells, as in the web app (wrapWithMxFile in lib/utils.ts).
* Root cells the model wrote anyway are replaced, and trailing closing tags
* some providers append are dropped. <mxfile>, <mxGraphModel> and anything
* else are returned unchanged.
*/
export function wrapCellsInModel(xml: string): string {
let content = stripXmlDeclaration(xml.trim())
if (!/^<(mxCell|UserObject|object|root)[\s/>]/.test(content)) return xml
content = content.replace(/<\/?root>/g, "").trim()
// End of the last cell, counting wrapped cells (</UserObject>, </object>)
let end = -1
for (const close of ["/>", "</mxCell>", "</UserObject>", "</object>"]) {
const at = content.lastIndexOf(close)
if (at !== -1) end = Math.max(end, at + close.length)
}
if (end !== -1 && /^(\s*<\/[^>]+>)*\s*$/.test(content.slice(end))) {
content = content.slice(0, end)
}
content = content
.replace(/<mxCell[^>]*\bid=["']0["'][^>]*(?:\/>|><\/mxCell>)/g, "")
.replace(/<mxCell[^>]*\bid=["']1["'][^>]*(?:\/>|><\/mxCell>)/g, "")
.trim()
return `<mxGraphModel><root>${ROOT_CELLS}${content}</root></mxGraphModel>`
}
/**
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
* If the input is already an mxfile, returns it unchanged.
@@ -255,7 +286,7 @@ export function addPageToDoc(
}
inner = trimmed
} else {
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
inner = `<mxGraphModel><root>${ROOT_CELLS}</root></mxGraphModel>`
}
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
+62
View File
@@ -0,0 +1,62 @@
/**
* Shape and icon library docs (docs/shape-libraries/*.md), the same files
* the web app's get_shape_library tool reads (app/api/chat/route.ts).
*/
import { readFile } from "node:fs/promises"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
export const SHAPE_LIBRARY_GROUPS: Record<string, string[]> = {
Cloud: [
"aws4",
"azure2",
"gcp2",
"alibaba_cloud",
"openstack",
"salesforce",
],
Networking: ["cisco19", "network", "kubernetes", "vvd", "rack"],
Business: ["bpmn", "lean_mapping"],
General: ["flowchart", "basic", "arrows2", "infographic", "sitemap"],
"UI/Mockups": ["android", "material_design"],
Enterprise: ["citrix", "sap", "mscae", "atlassian"],
Engineering: ["fluidpower", "electrical", "pid", "cabinets", "floorplan"],
Icons: ["webicons"],
}
const LIBRARIES = new Set(Object.values(SHAPE_LIBRARY_GROUPS).flat())
const here = dirname(fileURLToPath(import.meta.url))
// The build copies the docs to dist/shape-libraries; running from src (tsx)
// reads them from the repository instead.
const LIBRARY_DIRS = [
join(here, "shape-libraries"),
resolve(here, "../../../docs/shape-libraries"),
]
export async function getShapeLibrary(
name: string,
): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
const library = name.trim().toLowerCase()
if (!LIBRARIES.has(library)) {
return {
ok: false,
error: `Library "${name}" not found. Available: ${Array.from(LIBRARIES).join(", ")}`,
}
}
for (const dir of LIBRARY_DIRS) {
try {
return {
ok: true,
text: await readFile(join(dir, `${library}.md`), "utf-8"),
}
} catch {
// Try the next location
}
}
return {
ok: false,
error: `Library "${library}" is missing from this installation.`,
}
}
@@ -39,8 +39,13 @@ const EXPECTED_TOOLS = [
"add_page",
"rename_page",
"delete_page",
"get_drawing_guide",
"get_shape_library",
]
// Claude Code truncates tool descriptions and server instructions here
const MAX_DESCRIPTION = 2048
let proc: ChildProcessWithoutNullStreams
let stdoutBuf = ""
const pending = new Map<
@@ -48,6 +53,7 @@ const pending = new Map<
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
>()
let nextId = 1
let initResp: any
function send(method: string, params: unknown, isNotification = false) {
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
@@ -92,7 +98,7 @@ beforeAll(async () => {
}
})
const initResp = await send("initialize", {
initResp = await send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "wiring-test", version: "0.0.0" },
@@ -107,7 +113,7 @@ afterAll(() => {
})
describe("MCP server wiring", () => {
it("registers all ten tools", async () => {
it("registers all tools", async () => {
const resp = await send("tools/list", {})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
const names: string[] = (resp.result?.tools ?? []).map(
@@ -139,4 +145,45 @@ describe("MCP server wiring", () => {
expect(props.id).toBeTruthy()
expect(props.xml).toBeTruthy()
})
it("keeps every description and the instructions within the host limit", async () => {
const resp = await send("tools/list", {})
for (const tool of resp.result.tools) {
expect(
tool.description.length,
`${tool.name} description length`,
).toBeLessThanOrEqual(MAX_DESCRIPTION)
}
const instructions: string = initResp.result.instructions
expect(instructions).toContain("start_session")
expect(instructions.length).toBeLessThanOrEqual(MAX_DESCRIPTION)
})
it("serves a shape library without a session", async () => {
const resp = await send("tools/call", {
name: "get_shape_library",
arguments: { library: "AWS4" },
})
expect(resp.result.isError).toBeFalsy()
expect(resp.result.content[0].text).toContain("mxgraph.aws4")
})
it("reports an unknown shape library with the available names", async () => {
const resp = await send("tools/call", {
name: "get_shape_library",
arguments: { library: "../secrets" },
})
expect(resp.result.isError).toBe(true)
expect(resp.result.content[0].text).toContain("kubernetes")
})
it("serves the drawing guide without a session", async () => {
const resp = await send("tools/call", {
name: "get_drawing_guide",
arguments: {},
})
const text: string = resp.result.content[0].text
expect(text).toContain("Edge routing rules")
expect(text.length).toBeLessThanOrEqual(15000)
})
})
@@ -0,0 +1,62 @@
/**
* Tests for wrapCellsInModel: the model may send only the mxCell elements of
* a page, like in the web app, and the server adds the wrapper and root cells.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
beforeAll(() => {
installDomPolyfill()
})
import { wrapCellsInModel } from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
const A = `<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
const B = `<mxCell id="3" value="B" vertex="1" parent="1"><mxGeometry x="200" y="0" width="80" height="40" as="geometry"/></mxCell>`
const ROOTS = `<mxCell id="0"/><mxCell id="1" parent="0"/>`
describe("wrapCellsInModel", () => {
it("wraps sibling cells so they pass validation", () => {
expect(validateAndFixXml(A + B).valid).toBe(false)
const wrapped = wrapCellsInModel(A + B)
expect(wrapped).toBe(
`<mxGraphModel><root>${ROOTS}${A}${B}</root></mxGraphModel>`,
)
expect(validateAndFixXml(wrapped).valid).toBe(true)
})
it("replaces root cells the model wrote itself", () => {
const wrapped = wrapCellsInModel(
`<mxCell id="0"></mxCell><mxCell id="1" parent="0"/>${A}`,
)
expect(wrapped.match(/id="0"/g)).toHaveLength(1)
expect(wrapped.match(/id="1"/g)).toHaveLength(1)
expect(wrapped).toContain(A)
})
it("unwraps a <root> and drops trailing provider tags", () => {
const wrapped = wrapCellsInModel(
`<root>${A}</root></invoke></function_calls>`,
)
expect(wrapped).toBe(
`<mxGraphModel><root>${ROOTS}${A}</root></mxGraphModel>`,
)
})
it("leaves <mxGraphModel> and <mxfile> input unchanged", () => {
const model = `<mxGraphModel><root>${ROOTS}${A}</root></mxGraphModel>`
const file = `<mxfile><diagram id="p" name="P">${model}</diagram></mxfile>`
expect(wrapCellsInModel(model)).toBe(model)
expect(wrapCellsInModel(file)).toBe(file)
})
it("keeps a UserObject cell at the end", () => {
const wrapped = `<UserObject id="u" label="U"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`
expect(wrapCellsInModel(A + wrapped)).toContain(wrapped)
expect(wrapCellsInModel(`${wrapped}</invoke>`)).toBe(
`<mxGraphModel><root>${ROOTS}${wrapped}</root></mxGraphModel>`,
)
})
})