Compare commits

..

6 Commits

Author SHA1 Message Date
dayuan.jiang
345381e61a fix(cascade-delete): recursively collect edge children (labels)
- Change from cellsToDelete.add(edgeId) to collectDescendants(edgeId)
- Fixes orphaned edge labels causing draw.io to crash/clear canvas
- Edge labels (parent=edgeId) are now deleted with their parent edge
2025-12-30 00:02:37 +09:00
dayuan.jiang
853f30ba89 chore(mcp): bump version to 0.1.9 2025-12-29 23:46:13 +09:00
dayuan.jiang
8fc6a5396a fix: add root cell protection and sync MCP server cascade delete
- Add protection for root cells '0' and '1' to prevent full diagram wipe
- Sync MCP server with main app's cascade delete logic
- Both lib/utils.ts and packages/mcp-server now have identical delete behavior
2025-12-29 23:35:24 +09:00
dayuan.jiang
acf3bc7e42 fix(edit_diagram): implement cascade delete for children and edges
- Add automatic cascade deletion when deleting a cell
- Recursively delete all child cells (parent attribute references)
- Delete all edges referencing deleted cells (source/target)
- Skip silently if cell already deleted (handles AI redundant ops)
- Update prompts to inform AI about cascade behavior

Fixes #450
2025-12-29 23:17:04 +09:00
Dayuan Jiang
c2aa7f49be fix(mcp): rename display_diagram to create_new_diagram (#449)
Rename tool to be less ambiguous and help AI models correctly
provide the required xml parameter on subsequent calls.

Closes #445
2025-12-29 15:15:13 +09:00
Dayuan Jiang
30b30550d9 chore: clean up root folder by relocating config files (#448)
* chore: clean up root folder by moving config files

- Move renovate.json to .github/renovate.json
- Move electron-builder.yml to electron/electron-builder.yml
- Move electron.d.ts to electron/electron.d.ts
- Delete proxy.ts (unused dead code)
- Update package.json dist scripts with --config flag
- Use tsconfig.json files array for electron.d.ts (bypasses exclude)

Reduces git-tracked root files from 23 to 19.

* chore: regenerate package-lock.json to fix CI

* fix: regenerate package-lock.json with cross-platform deps
2025-12-29 14:30:25 +09:00
16 changed files with 21657 additions and 21589 deletions

View File

@@ -29,7 +29,7 @@ jobs:
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm install
- name: Type check - name: Type check
run: npx tsc --noEmit run: npx tsc --noEmit

View File

@@ -9,7 +9,7 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm install
# Stage 2: Build application # Stage 2: Build application
FROM node:24-alpine AS builder FROM node:24-alpine AS builder

View File

@@ -605,7 +605,7 @@ Notes:
Operations: Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml. - update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml. - add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell by its id. Only cell_id is needed. - delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml must be a complete mxCell element including mxGeometry. For update/add, new_xml must be a complete mxCell element including mxGeometry.
@@ -614,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
Example - Add a rectangle: Example - Add a rectangle:
{"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>"}]} {"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>"}]}
Example - Delete a cell: Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`, {"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array( .array(

View File

@@ -276,7 +276,7 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
**Operations:** **Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml. - **update**: Replace an existing cell. Provide cell_id and new_xml.
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml. - **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- **delete**: Remove a cell. Only cell_id is needed. - **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
**Input Format:** **Input Format:**
\`\`\`json \`\`\`json
@@ -301,9 +301,9 @@ Add new shape:
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\` \`\`\`
Delete cell: Delete container (children & edges auto-deleted):
\`\`\`json \`\`\`json
{"operations": [{"operation": "delete", "cell_id": "5"}]} {"operations": [{"operation": "delete", "cell_id": "2"}]}
\`\`\` \`\`\`
**Error Recovery:** **Error Recovery:**

View File

@@ -633,32 +633,77 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) // Protect root cells from deletion
if (!existingCell) { if (op.cell_id === "0" || op.cell_id === "1") {
errors.push({ errors.push({
type: "delete", type: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cannot delete root cell "${op.cell_id}"`,
}) })
continue continue
} }
// Check for edges referencing this cell (warning only, still delete) const existingCell = cellMap.get(op.cell_id)
const referencingEdges = root.querySelectorAll( if (!existingCell) {
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`, // Cell not found - might have been cascade-deleted by a previous operation
) // Skip silently instead of erroring (AI may redundantly list children/edges)
if (referencingEdges.length > 0) { continue
const edgeIds = Array.from(referencingEdges) }
.map((e) => e.getAttribute("id"))
.join(", ") // Cascade delete: collect all cells to delete (children + edges + self)
console.warn( const cellsToDelete = new Set<string>()
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
// Recursive function to find all descendants
const collectDescendants = (cellId: string) => {
if (cellsToDelete.has(cellId)) return
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {
const childId = child.getAttribute("id")
if (childId && childId !== "0" && childId !== "1") {
collectDescendants(childId)
}
})
}
// Collect the target cell and all its descendants
collectDescendants(op.cell_id)
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
// Log what will be deleted
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
) )
} }
// Remove the node // Delete all collected cells
existingCell.parentNode?.removeChild(existingCell) for (const cellId of cellsToDelete) {
cellMap.delete(op.cell_id) const cell = cellMap.get(cellId)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
} }
} }

42926
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,11 +21,11 @@
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/", "electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
"electron:start": "npx cross-env NODE_ENV=development npx electron .", "electron:start": "npx cross-env NODE_ENV=development npx electron .",
"electron:prepare": "node scripts/prepare-electron-build.mjs", "electron:prepare": "node scripts/prepare-electron-build.mjs",
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder", "dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac", "dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --win", "dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --linux", "dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux" "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^4.0.1",

View File

@@ -97,7 +97,7 @@ Use the standard MCP configuration with:
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `start_session` | Opens browser with real-time diagram preview | | `start_session` | Opens browser with real-time diagram preview |
| `display_diagram` | Create a new diagram from XML | | `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) | | `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML | | `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file | | `export_diagram` | Save diagram to a `.drawio` file |

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.6", "version": "0.1.10",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -182,32 +182,77 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) // Protect root cells from deletion
if (!existingCell) { if (op.cell_id === "0" || op.cell_id === "1") {
errors.push({ errors.push({
type: "delete", type: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cannot delete root cell "${op.cell_id}"`,
}) })
continue continue
} }
// Check for edges referencing this cell (warning only, still delete) const existingCell = cellMap.get(op.cell_id)
const referencingEdges = root.querySelectorAll( if (!existingCell) {
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`, // Cell not found - might have been cascade-deleted by a previous operation
) // Skip silently instead of erroring (AI may redundantly list children/edges)
if (referencingEdges.length > 0) { continue
const edgeIds = Array.from(referencingEdges) }
.map((e) => e.getAttribute("id"))
.join(", ") // Cascade delete: collect all cells to delete (children + edges + self)
console.warn( const cellsToDelete = new Set<string>()
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
// Recursive function to find all descendants
const collectDescendants = (cellId: string) => {
if (cellsToDelete.has(cellId)) return
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {
const childId = child.getAttribute("id")
if (childId && childId !== "0" && childId !== "1") {
collectDescendants(childId)
}
})
}
// Collect the target cell and all its descendants
collectDescendants(op.cell_id)
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
// Log what will be deleted
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
) )
} }
// Remove the node // Delete all collected cells
existingCell.parentNode?.removeChild(existingCell) for (const cellId of cellsToDelete) {
cellMap.delete(op.cell_id) const cell = cellMap.get(cellId)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
} }
} }

View File

@@ -78,7 +78,7 @@ server.prompt(
## Creating a New Diagram ## Creating a New Diagram
1. Call start_session to open the browser preview 1. Call start_session to open the browser preview
2. Use display_diagram with complete mxGraphModel XML to create a new diagram 2. Use create_new_diagram with complete mxGraphModel XML to create a new diagram
## Adding Elements to Existing Diagram ## Adding Elements to Existing Diagram
1. Use edit_diagram with "add" operation 1. Use edit_diagram with "add" operation
@@ -91,7 +91,7 @@ server.prompt(
3. For update, provide the cell_id and complete new mxCell XML 3. For update, provide the cell_id and complete new mxCell XML
## Important Notes ## Important Notes
- display_diagram REPLACES the entire diagram - only use for new diagrams - create_new_diagram REPLACES the entire diagram - only use for new diagrams
- edit_diagram PRESERVES user's manual changes (fetches browser state first) - edit_diagram PRESERVES user's manual changes (fetches browser state first)
- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`, - Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`,
}, },
@@ -150,19 +150,59 @@ server.registerTool(
}, },
) )
// Tool: display_diagram // Tool: create_new_diagram
server.registerTool( server.registerTool(
"display_diagram", "create_new_diagram",
{ {
description: description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely.
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
"Use this for creating new diagrams from scratch. " + CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
"To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " +
"You should generate valid draw.io/mxGraph XML format.", When to use this tool:
- Creating a new diagram from scratch
- Replacing the current diagram with a completely different one
- Major structural changes that require regenerating the diagram
When to use edit_diagram instead:
- Small modifications to existing diagram
- Adding/removing individual elements
- Changing labels, colors, or positions
XML FORMAT - Full mxGraphModel structure:
<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>
LAYOUT CONSTRAINTS:
- 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" (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`,
inputSchema: { inputSchema: {
xml: z xml: z
.string() .string()
.describe("The draw.io XML to display (mxGraphModel format)"), .describe(
"REQUIRED: The complete mxGraphModel XML. Must always be provided.",
),
}, },
}, },
async ({ xml: inputXml }) => { async ({ xml: inputXml }) => {
@@ -199,7 +239,7 @@ server.registerTool(
} }
} }
log.info(`Displaying diagram, ${xml.length} chars`) log.info(`Setting diagram content, ${xml.length} chars`)
// Sync from browser state first // Sync from browser state first
const browserState = getState(currentSession.id) const browserState = getState(currentSession.id)
@@ -226,20 +266,20 @@ server.registerTool(
// Save AI result (no SVG yet - will be captured by browser) // Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, xml, "") addHistory(currentSession.id, xml, "")
log.info(`Diagram displayed successfully`) log.info(`Diagram content set successfully`)
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`, text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
}, },
], ],
} }
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
log.error("display_diagram failed:", message) log.error("create_new_diagram failed:", message)
return { return {
content: [{ type: "text", text: `Error: ${message}` }], content: [{ type: "text", text: `Error: ${message}` }],
isError: true, isError: true,
@@ -340,7 +380,7 @@ server.registerTool(
content: [ content: [
{ {
type: "text", type: "text",
text: "Error: No diagram to edit. Please create a diagram first with display_diagram.", text: "Error: No diagram to edit. Please create a diagram first with create_new_diagram.",
}, },
], ],
isError: true, isError: true,
@@ -474,7 +514,7 @@ server.registerTool(
content: [ content: [
{ {
type: "text", type: "text",
text: "No diagram exists yet. Use display_diagram to create one.", text: "No diagram exists yet. Use create_new_diagram to create one.",
}, },
], ],
} }

View File

@@ -1,63 +0,0 @@
import { match as matchLocale } from "@formatjs/intl-localematcher"
import Negotiator from "negotiator"
import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"
import { i18n } from "./lib/i18n/config"
function getLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
// @ts-expect-error locales are readonly
const locales: string[] = i18n.locales
// Use negotiator and intl-localematcher to get best locale
const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales,
)
const locale = matchLocale(languages, locales, i18n.defaultLocale)
return locale
}
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Skip API routes, static files, and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname)
) {
return
}
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) =>
!pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
)
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request)
// Redirect to localized path
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url,
),
)
}
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

View File

@@ -22,6 +22,7 @@
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },
"files": ["electron/electron.d.ts"],
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",