2025-12-17 14:50:07 +09:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
/**
|
|
|
|
|
* MCP Server for Next AI Draw.io
|
|
|
|
|
*
|
|
|
|
|
* Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit
|
|
|
|
|
* draw.io diagrams with real-time browser preview.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Setup DOM polyfill for Node.js (required for XML operations)
|
|
|
|
|
import { DOMParser } from "linkedom"
|
|
|
|
|
;(globalThis as any).DOMParser = DOMParser
|
|
|
|
|
|
|
|
|
|
class XMLSerializerPolyfill {
|
|
|
|
|
serializeToString(node: any): string {
|
2025-12-21 16:11:49 +09:00
|
|
|
if (node.outerHTML !== undefined) return node.outerHTML
|
|
|
|
|
if (node.documentElement) return node.documentElement.outerHTML
|
2025-12-17 14:50:07 +09:00
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
|
|
|
|
|
|
|
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
|
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
|
|
import open from "open"
|
|
|
|
|
import { z } from "zod"
|
|
|
|
|
import {
|
|
|
|
|
applyDiagramOperations,
|
|
|
|
|
type DiagramOperation,
|
|
|
|
|
} from "./diagram-operations.js"
|
2025-12-21 16:11:49 +09:00
|
|
|
import { addHistory } from "./history.js"
|
|
|
|
|
import { getState, setState, startHttpServer } from "./http-server.js"
|
2025-12-17 14:50:07 +09:00
|
|
|
import { log } from "./logger.js"
|
2025-12-21 00:32:51 +09:00
|
|
|
import { validateAndFixXml } from "./xml-validation.js"
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
const config = { port: parseInt(process.env.PORT || "6002") }
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
let currentSession: { id: string; xml: string } | null = null
|
|
|
|
|
|
|
|
|
|
const server = new McpServer({ name: "next-ai-drawio", version: "0.1.2" })
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Workflow guidance prompt
|
2025-12-17 14:50:07 +09:00
|
|
|
server.prompt(
|
|
|
|
|
"diagram-workflow",
|
|
|
|
|
"Guidelines for creating and editing draw.io diagrams",
|
|
|
|
|
() => ({
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: "user",
|
|
|
|
|
content: {
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: `# Draw.io Diagram Workflow
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
## Creating a New Diagram
|
2025-12-21 16:11:49 +09:00
|
|
|
1. Call start_session to open browser preview
|
|
|
|
|
2. Use display_diagram with mxGraphModel XML
|
|
|
|
|
|
|
|
|
|
## Adding Elements
|
|
|
|
|
Use edit_diagram with "add" operation - provide cell_id and new_xml
|
|
|
|
|
|
|
|
|
|
## Modifying/Deleting Elements
|
|
|
|
|
1. Call get_diagram to see current cell IDs
|
|
|
|
|
2. Use edit_diagram with "update" or "delete" operations
|
|
|
|
|
|
|
|
|
|
## Notes
|
|
|
|
|
- display_diagram REPLACES entire diagram
|
|
|
|
|
- edit_diagram PRESERVES user's manual changes`,
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tool: start_session
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"start_session",
|
|
|
|
|
{
|
|
|
|
|
description:
|
2025-12-21 16:11:49 +09:00
|
|
|
"Start a new diagram session and open browser for real-time preview.",
|
2025-12-17 14:50:07 +09:00
|
|
|
inputSchema: {},
|
|
|
|
|
},
|
|
|
|
|
async () => {
|
|
|
|
|
try {
|
|
|
|
|
const port = await startHttpServer(config.port)
|
|
|
|
|
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
|
2025-12-21 16:11:49 +09:00
|
|
|
currentSession = { id: sessionId, xml: "" }
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
|
|
|
|
await open(browserUrl)
|
|
|
|
|
|
|
|
|
|
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: `Session started!\n\nSession ID: ${sessionId}\nBrowser: ${browserUrl}`,
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message =
|
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
|
log.error("start_session failed:", message)
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tool: display_diagram
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"display_diagram",
|
|
|
|
|
{
|
|
|
|
|
description:
|
|
|
|
|
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
|
2025-12-21 16:11:49 +09:00
|
|
|
"Use edit_diagram to add elements to existing diagram.",
|
2025-12-17 14:50:07 +09:00
|
|
|
inputSchema: {
|
|
|
|
|
xml: z
|
|
|
|
|
.string()
|
|
|
|
|
.describe("The draw.io XML to display (mxGraphModel format)"),
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-12-21 00:32:51 +09:00
|
|
|
async ({ xml: inputXml }) => {
|
2025-12-17 14:50:07 +09:00
|
|
|
try {
|
|
|
|
|
if (!currentSession) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: "Error: No active session. Call start_session first.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 00:32:51 +09:00
|
|
|
let xml = inputXml
|
|
|
|
|
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
|
|
|
|
if (fixed) {
|
|
|
|
|
xml = fixed
|
|
|
|
|
log.info(`XML auto-fixed: ${fixes.join(", ")}`)
|
|
|
|
|
}
|
|
|
|
|
if (!valid && error) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
|
text: `Error: XML validation failed - ${error}`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Save current state before replacing
|
2025-12-21 16:09:14 +09:00
|
|
|
if (currentSession.xml) {
|
2025-12-21 16:11:49 +09:00
|
|
|
addHistory(currentSession.id, currentSession.xml)
|
2025-12-21 16:09:14 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
currentSession.xml = xml
|
|
|
|
|
setState(currentSession.id, xml)
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Save new state
|
|
|
|
|
addHistory(currentSession.id, xml)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info(`Displayed diagram, ${xml.length} chars`)
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: `Diagram displayed! (${xml.length} chars)`,
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message =
|
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
|
log.error("display_diagram failed:", message)
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tool: edit_diagram
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"edit_diagram",
|
|
|
|
|
{
|
|
|
|
|
description:
|
2025-12-21 16:11:49 +09:00
|
|
|
"Edit diagram by operations (update/add/delete cells). " +
|
|
|
|
|
"Fetches latest browser state first, preserving user's changes.\n\n" +
|
2025-12-17 14:50:07 +09:00
|
|
|
"Operations:\n" +
|
2025-12-21 16:11:49 +09:00
|
|
|
"- add: Add new cell (cell_id + new_xml)\n" +
|
|
|
|
|
"- update: Replace cell (cell_id + new_xml)\n" +
|
|
|
|
|
"- delete: Remove cell (cell_id only)",
|
2025-12-17 14:50:07 +09:00
|
|
|
inputSchema: {
|
|
|
|
|
operations: z
|
|
|
|
|
.array(
|
|
|
|
|
z.object({
|
2025-12-21 16:11:49 +09:00
|
|
|
type: z.enum(["update", "add", "delete"]),
|
|
|
|
|
cell_id: z.string(),
|
|
|
|
|
new_xml: z.string().optional(),
|
2025-12-17 14:50:07 +09:00
|
|
|
}),
|
|
|
|
|
)
|
2025-12-21 16:11:49 +09:00
|
|
|
.describe("Operations to apply"),
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ operations }) => {
|
|
|
|
|
try {
|
|
|
|
|
if (!currentSession) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: "Error: No active session. Call start_session first.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Fetch latest from browser
|
2025-12-17 14:50:07 +09:00
|
|
|
const browserState = getState(currentSession.id)
|
|
|
|
|
if (browserState?.xml) {
|
|
|
|
|
currentSession.xml = browserState.xml
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentSession.xml) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: "Error: No diagram to edit. Use display_diagram first.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Save before editing
|
|
|
|
|
addHistory(currentSession.id, currentSession.xml)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Validate operations
|
2025-12-21 00:32:51 +09:00
|
|
|
const validatedOps = operations.map((op) => {
|
|
|
|
|
if (op.new_xml) {
|
2025-12-21 16:11:49 +09:00
|
|
|
const { fixed, fixes } = validateAndFixXml(op.new_xml)
|
2025-12-21 00:32:51 +09:00
|
|
|
if (fixed) {
|
|
|
|
|
log.info(
|
2025-12-21 16:11:49 +09:00
|
|
|
`${op.type} ${op.cell_id}: auto-fixed: ${fixes.join(", ")}`,
|
2025-12-21 00:32:51 +09:00
|
|
|
)
|
|
|
|
|
return { ...op, new_xml: fixed }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return op
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
const { result, errors } = applyDiagramOperations(
|
|
|
|
|
currentSession.xml,
|
2025-12-21 00:32:51 +09:00
|
|
|
validatedOps as DiagramOperation[],
|
2025-12-17 14:50:07 +09:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
currentSession.xml = result
|
|
|
|
|
setState(currentSession.id, result)
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Save after editing
|
|
|
|
|
addHistory(currentSession.id, result)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info(`Edited diagram: ${operations.length} operation(s)`)
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
const msg = `Applied ${operations.length} operation(s).`
|
|
|
|
|
const warn =
|
2025-12-17 14:50:07 +09:00
|
|
|
errors.length > 0
|
2025-12-21 16:11:49 +09:00
|
|
|
? `\nWarnings: ${errors.map((e) => `${e.type} ${e.cellId}: ${e.message}`).join(", ")}`
|
2025-12-17 14:50:07 +09:00
|
|
|
: ""
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
return { content: [{ type: "text", text: msg + warn }] }
|
2025-12-17 14:50:07 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
const message =
|
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
|
log.error("edit_diagram failed:", message)
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tool: get_diagram
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"get_diagram",
|
|
|
|
|
{
|
|
|
|
|
description:
|
2025-12-21 16:11:49 +09:00
|
|
|
"Get current diagram XML (fetches latest from browser). " +
|
|
|
|
|
"Call before edit_diagram to see cell IDs.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
async () => {
|
|
|
|
|
try {
|
|
|
|
|
if (!currentSession) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: "Error: No active session. Call start_session first.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const browserState = getState(currentSession.id)
|
|
|
|
|
if (browserState?.xml) {
|
|
|
|
|
currentSession.xml = browserState.xml
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentSession.xml) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: "No diagram yet. Use display_diagram to create one.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: `Current diagram:\n\n${currentSession.xml}`,
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message =
|
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tool: export_diagram
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"export_diagram",
|
|
|
|
|
{
|
2025-12-21 16:11:49 +09:00
|
|
|
description: "Export diagram to .drawio file.",
|
2025-12-17 14:50:07 +09:00
|
|
|
inputSchema: {
|
2025-12-21 16:11:49 +09:00
|
|
|
path: z.string().describe("File path (e.g., ./diagram.drawio)"),
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ path }) => {
|
|
|
|
|
try {
|
|
|
|
|
if (!currentSession) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: "Error: No active session. Call start_session first.",
|
2025-12-17 14:50:07 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const browserState = getState(currentSession.id)
|
|
|
|
|
if (browserState?.xml) {
|
|
|
|
|
currentSession.xml = browserState.xml
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentSession.xml) {
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
2025-12-21 16:11:49 +09:00
|
|
|
{ type: "text", text: "Error: No diagram to export." },
|
2025-12-17 14:50:07 +09:00
|
|
|
],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fs = await import("node:fs/promises")
|
|
|
|
|
const nodePath = await import("node:path")
|
|
|
|
|
|
|
|
|
|
let filePath = path
|
2025-12-21 16:11:49 +09:00
|
|
|
if (!filePath.endsWith(".drawio")) filePath += ".drawio"
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
const absolutePath = nodePath.resolve(filePath)
|
|
|
|
|
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info(`Exported to ${absolutePath}`)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
2025-12-21 16:11:49 +09:00
|
|
|
text: `Exported to ${absolutePath}`,
|
2025-12-21 16:09:14 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message =
|
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
|
|
|
isError: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// Start server
|
2025-12-17 14:50:07 +09:00
|
|
|
async function main() {
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info("Starting MCP server...")
|
2025-12-17 14:50:07 +09:00
|
|
|
const transport = new StdioServerTransport()
|
|
|
|
|
await server.connect(transport)
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info("MCP server running")
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((error) => {
|
2025-12-21 16:11:49 +09:00
|
|
|
log.error("Fatal:", error)
|
2025-12-17 14:50:07 +09:00
|
|
|
process.exit(1)
|
|
|
|
|
})
|