diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 2cee715c..6b816c45 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8be1c4b..bc8376c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/docs/shape-libraries/README.md b/docs/shape-libraries/README.md index 44ef00e4..bea4557e 100644 --- a/docs/shape-libraries/README.md +++ b/docs/shape-libraries/README.md @@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.."` | 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.."` |---------|--------|--------|-------------|------| | 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 | 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 | 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 | 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** diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index d45f49fc..43d48c5d 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.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", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index df4060bd..b972f8a1 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.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", diff --git a/packages/mcp-server/scripts/check-package.mjs b/packages/mcp-server/scripts/check-package.mjs new file mode 100644 index 00000000..df37bf11 --- /dev/null +++ b/packages/mcp-server/scripts/check-package.mjs @@ -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)`) diff --git a/packages/mcp-server/scripts/copy-assets.mjs b/packages/mcp-server/scripts/copy-assets.mjs new file mode 100644 index 00000000..65664ccd --- /dev/null +++ b/packages/mcp-server/scripts/copy-assets.mjs @@ -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)) + } +} diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts new file mode 100644 index 00000000..f36f2b48 --- /dev/null +++ b/packages/mcp-server/src/drawing-guide.ts @@ -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 , , and the root cells id="0" and id="1". + + + + + + + + +Several pages at once (create_new_diagram only): send a full with one per page. Every page's must start with . + +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="" for shapes inside a container. +4. Edge source and target must reference existing cell ids. +5. Escape special characters in attribute values: < for <, > for >, & for &, " 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. + + + + + + + + + + + + + + + + + +## 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: + + + + + + + + +Waypoints go inside 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: + + + + + + + + + + +## 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": ""}]} + {"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": ""}]} + {"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. +` diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 1d70cac8..240e858e 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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 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 (single page) or a full with one or more children (multi-page) - -## Opening an Existing .drawio File -- Use load_diagram with the file path — the server reads and decompresses the file itself; don't read it and pass the XML through create_new_diagram -- After loading, call get_diagram once before editing (you haven't seen the file's cell IDs yet) - -## Working with Multiple Pages -- Use list_pages to discover existing pages (id, name, index) -- Use add_page to append a new page (without losing existing ones — unlike create_new_diagram which REPLACES everything) -- 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 , , and the root cells "0" and "1": + +2) A bare with (one page). +3) A full with one or more pages. Every page's must start with . -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): - - - - - - - - - -The server auto-wraps this in .... - -2) Full mxfile (one or more pages): - - - ... - - - ... - - -Each becomes a tab in the embedded editor. Cell ids "0" and "1" are reserved root sentinels and MUST repeat in every page's . - -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 (legacy single-page) or a full with one or more children (multi-page).", + "REQUIRED: the mxCell elements of one page, a bare , or a full with one or more 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 or an with one or more children.", + text: "Error: XML must be the mxCell elements of one page, a , or an with one or more 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": ""}]}\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": ""}]}\n\n' + - "Example - Update a cell on page index 1:\n" + - '{"page_index": 1, "operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]}\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 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 . 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 XML for the new page. Must include 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 . 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 diff --git a/packages/mcp-server/src/pages.ts b/packages/mcp-server/src/pages.ts index 447788aa..5cdfaabe 100644 --- a/packages/mcp-server/src/pages.ts +++ b/packages/mcp-server/src/pages.ts @@ -81,6 +81,37 @@ function stripXmlDeclaration(xml: string): string { return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "") } +const ROOT_CELLS = '' + +/** + * Turn a list of bare cells (optionally inside ) into a one-page + * , 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. , 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 (, ) + let end = -1 + for (const close of ["/>", "", "", ""]) { + 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(/]*\bid=["']0["'][^>]*(?:\/>|><\/mxCell>)/g, "") + .replace(/]*\bid=["']1["'][^>]*(?:\/>|><\/mxCell>)/g, "") + .trim() + return `${ROOT_CELLS}${content}` +} + /** * Wrap a bare XML string in .... * If the input is already an mxfile, returns it unchanged. @@ -255,7 +286,7 @@ export function addPageToDoc( } inner = trimmed } else { - inner = `` + inner = `${ROOT_CELLS}` } const snippet = `${inner}` diff --git a/packages/mcp-server/src/shape-library.ts b/packages/mcp-server/src/shape-library.ts new file mode 100644 index 00000000..11dff479 --- /dev/null +++ b/packages/mcp-server/src/shape-library.ts @@ -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 = { + 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.`, + } +} diff --git a/packages/mcp-server/tests/server-wiring.test.ts b/packages/mcp-server/tests/server-wiring.test.ts index 3e50dad8..babcc3ae 100644 --- a/packages/mcp-server/tests/server-wiring.test.ts +++ b/packages/mcp-server/tests/server-wiring.test.ts @@ -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 = { 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) + }) }) diff --git a/packages/mcp-server/tests/wrap-cells.test.ts b/packages/mcp-server/tests/wrap-cells.test.ts new file mode 100644 index 00000000..73024010 --- /dev/null +++ b/packages/mcp-server/tests/wrap-cells.test.ts @@ -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 = `` +const B = `` +const ROOTS = `` + +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( + `${ROOTS}${A}${B}`, + ) + expect(validateAndFixXml(wrapped).valid).toBe(true) + }) + + it("replaces root cells the model wrote itself", () => { + const wrapped = wrapCellsInModel( + `${A}`, + ) + expect(wrapped.match(/id="0"/g)).toHaveLength(1) + expect(wrapped.match(/id="1"/g)).toHaveLength(1) + expect(wrapped).toContain(A) + }) + + it("unwraps a and drops trailing provider tags", () => { + const wrapped = wrapCellsInModel( + `${A}`, + ) + expect(wrapped).toBe( + `${ROOTS}${A}`, + ) + }) + + it("leaves and input unchanged", () => { + const model = `${ROOTS}${A}` + const file = `${model}` + expect(wrapCellsInModel(model)).toBe(model) + expect(wrapCellsInModel(file)).toBe(file) + }) + + it("keeps a UserObject cell at the end", () => { + const wrapped = `` + expect(wrapCellsInModel(A + wrapped)).toContain(wrapped) + expect(wrapCellsInModel(`${wrapped}`)).toBe( + `${ROOTS}${wrapped}`, + ) + }) +})