mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
feat: add MCP server package for npx distribution (#284)
* feat: add MCP server package for npx distribution - Self-contained MCP server with embedded HTTP server - Real-time browser preview via draw.io iframe - Tools: start_session, display_diagram, edit_diagram, get_diagram, export_diagram - Port retry limit (6002-6020) and session TTL cleanup (1 hour) - Published as @next-ai-drawio/mcp-server on npm * chore: bump version to 0.1.2 * docs: add MCP server section to README (preview feature) * docs: add multi-client installation instructions for MCP server * fix: exclude packages from Next.js build * docs: use @latest instead of -y flag for npx (match Playwright MCP style) * chore: bump version to 0.4.3 and add release notes * chore: remove release notes * feat: add MCP server notice to example panel
This commit is contained in:
219
packages/mcp-server/src/diagram-operations.ts
Normal file
219
packages/mcp-server/src/diagram-operations.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* ID-based diagram operations
|
||||
* Copied from lib/utils.ts to avoid cross-package imports
|
||||
*/
|
||||
|
||||
export interface DiagramOperation {
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
export interface OperationError {
|
||||
type: "update" | "add" | "delete"
|
||||
cellId: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApplyOperationsResult {
|
||||
result: string
|
||||
errors: OperationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||
* This replaces the text-matching approach with direct DOM manipulation.
|
||||
*
|
||||
* @param xmlContent - The full mxfile XML content
|
||||
* @param operations - Array of operations to apply
|
||||
* @returns Object with result XML and any errors
|
||||
*/
|
||||
export function applyDiagramOperations(
|
||||
xmlContent: string,
|
||||
operations: DiagramOperation[],
|
||||
): ApplyOperationsResult {
|
||||
const errors: OperationError[] = []
|
||||
|
||||
// Parse the XML
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlContent, "text/xml")
|
||||
|
||||
// Check for parse errors
|
||||
const parseError = doc.querySelector("parsererror")
|
||||
if (parseError) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `XML parse error: ${parseError.textContent}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root element (inside mxGraphModel)
|
||||
const root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of cell IDs to elements
|
||||
const cellMap = new Map<string, Element>()
|
||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
if (id) cellMap.set(id, cell)
|
||||
})
|
||||
|
||||
// Process each operation
|
||||
for (const op of operations) {
|
||||
if (op.type === "update") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" not found`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!op.new_xml) {
|
||||
errors.push({
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml is required for update operation",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the new XML
|
||||
const newDoc = parser.parseFromString(
|
||||
`<wrapper>${op.new_xml}</wrapper>`,
|
||||
"text/xml",
|
||||
)
|
||||
const newCell = newDoc.querySelector("mxCell")
|
||||
if (!newCell) {
|
||||
errors.push({
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml must contain an mxCell element",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate ID matches
|
||||
const newCellId = newCell.getAttribute("id")
|
||||
if (newCellId !== op.cell_id) {
|
||||
errors.push({
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Import and replace the node
|
||||
const importedNode = doc.importNode(newCell, true)
|
||||
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
||||
|
||||
// Update the map with the new element
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.type === "add") {
|
||||
// Check if ID already exists
|
||||
if (cellMap.has(op.cell_id)) {
|
||||
errors.push({
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" already exists`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!op.new_xml) {
|
||||
errors.push({
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml is required for add operation",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the new XML
|
||||
const newDoc = parser.parseFromString(
|
||||
`<wrapper>${op.new_xml}</wrapper>`,
|
||||
"text/xml",
|
||||
)
|
||||
const newCell = newDoc.querySelector("mxCell")
|
||||
if (!newCell) {
|
||||
errors.push({
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml must contain an mxCell element",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate ID matches
|
||||
const newCellId = newCell.getAttribute("id")
|
||||
if (newCellId !== op.cell_id) {
|
||||
errors.push({
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Import and append the node
|
||||
const importedNode = doc.importNode(newCell, true)
|
||||
root.appendChild(importedNode)
|
||||
|
||||
// Add to map
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.type === "delete") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
type: "delete",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" not found`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for edges referencing this cell (warning only, still delete)
|
||||
const referencingEdges = root.querySelectorAll(
|
||||
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
|
||||
)
|
||||
if (referencingEdges.length > 0) {
|
||||
const edgeIds = Array.from(referencingEdges)
|
||||
.map((e) => e.getAttribute("id"))
|
||||
.join(", ")
|
||||
console.warn(
|
||||
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the node
|
||||
existingCell.parentNode?.removeChild(existingCell)
|
||||
cellMap.delete(op.cell_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize back to string
|
||||
const serializer = new XMLSerializer()
|
||||
const result = serializer.serializeToString(doc)
|
||||
|
||||
return { result, errors }
|
||||
}
|
||||
384
packages/mcp-server/src/http-server.ts
Normal file
384
packages/mcp-server/src/http-server.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Embedded HTTP Server for MCP
|
||||
*
|
||||
* Serves a static HTML page with draw.io embed and handles state sync.
|
||||
* This eliminates the need for an external Next.js app.
|
||||
*/
|
||||
|
||||
import http from "node:http"
|
||||
import { log } from "./logger.js"
|
||||
|
||||
interface SessionState {
|
||||
xml: string
|
||||
version: number
|
||||
lastUpdated: Date
|
||||
}
|
||||
|
||||
// In-memory state store (shared with MCP server in same process)
|
||||
export const stateStore = new Map<string, SessionState>()
|
||||
|
||||
let server: http.Server | null = null
|
||||
let serverPort: number = 6002
|
||||
const MAX_PORT = 6020 // Don't retry beyond this port
|
||||
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
/**
|
||||
* Get state for a session
|
||||
*/
|
||||
export function getState(sessionId: string): SessionState | undefined {
|
||||
return stateStore.get(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set state for a session
|
||||
*/
|
||||
export function setState(sessionId: string, xml: string): number {
|
||||
const existing = stateStore.get(sessionId)
|
||||
const newVersion = (existing?.version || 0) + 1
|
||||
|
||||
stateStore.set(sessionId, {
|
||||
xml,
|
||||
version: newVersion,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
return newVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the embedded HTTP server
|
||||
*/
|
||||
export function startHttpServer(port: number = 6002): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (server) {
|
||||
resolve(serverPort)
|
||||
return
|
||||
}
|
||||
|
||||
serverPort = port
|
||||
server = http.createServer(handleRequest)
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
if (port >= MAX_PORT) {
|
||||
reject(
|
||||
new Error(
|
||||
`No available ports in range 6002-${MAX_PORT}`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
log.info(`Port ${port} in use, trying ${port + 1}`)
|
||||
server = null
|
||||
startHttpServer(port + 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, () => {
|
||||
serverPort = port
|
||||
log.info(`Embedded HTTP server running on http://localhost:${port}`)
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
export function stopHttpServer(): void {
|
||||
if (server) {
|
||||
server.close()
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions
|
||||
*/
|
||||
function cleanupExpiredSessions(): void {
|
||||
const now = Date.now()
|
||||
for (const [sessionId, state] of stateStore) {
|
||||
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
||||
stateStore.delete(sessionId)
|
||||
log.info(`Cleaned up expired session: ${sessionId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup every 5 minutes
|
||||
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
||||
|
||||
/**
|
||||
* Get the current server port
|
||||
*/
|
||||
export function getServerPort(): number {
|
||||
return serverPort
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP requests
|
||||
*/
|
||||
function handleRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||
|
||||
// CORS headers for local development
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Route handling
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
serveHtml(req, res, url)
|
||||
} else if (
|
||||
url.pathname === "/api/state" ||
|
||||
url.pathname === "/api/mcp/state"
|
||||
) {
|
||||
handleStateApi(req, res, url)
|
||||
} else if (
|
||||
url.pathname === "/api/health" ||
|
||||
url.pathname === "/api/mcp/health"
|
||||
) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ status: "ok", mcp: true }))
|
||||
} else {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the HTML page with draw.io embed
|
||||
*/
|
||||
function serveHtml(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
url: URL,
|
||||
): void {
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(getHtmlPage(sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state API requests
|
||||
*/
|
||||
function handleStateApi(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
url: URL,
|
||||
): void {
|
||||
if (req.method === "GET") {
|
||||
const sessionId = url.searchParams.get("sessionId")
|
||||
if (!sessionId) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||
return
|
||||
}
|
||||
|
||||
const state = stateStore.get(sessionId)
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
xml: state?.xml || null,
|
||||
version: state?.version || 0,
|
||||
lastUpdated: state?.lastUpdated?.toISOString() || null,
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
let body = ""
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const { sessionId, xml } = JSON.parse(body)
|
||||
if (!sessionId) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||
return
|
||||
}
|
||||
|
||||
const version = setState(sessionId, xml)
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, version }))
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
res.writeHead(405)
|
||||
res.end("Method Not Allowed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the HTML page with draw.io embed
|
||||
*/
|
||||
function getHtmlPage(sessionId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||
#header {
|
||||
padding: 8px 16px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#header .session { color: #888; font-size: 12px; }
|
||||
#header .status { font-size: 12px; }
|
||||
#header .status.connected { color: #4ade80; }
|
||||
#header .status.disconnected { color: #f87171; }
|
||||
#drawio { flex: 1; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="header">
|
||||
<div>
|
||||
<strong>Draw.io MCP</strong>
|
||||
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
|
||||
</div>
|
||||
<div id="status" class="status disconnected">Connecting...</div>
|
||||
</div>
|
||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sessionId = "${sessionId}";
|
||||
const iframe = document.getElementById('drawio');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
let currentVersion = 0;
|
||||
let isDrawioReady = false;
|
||||
let pendingXml = null;
|
||||
let lastLoadedXml = null;
|
||||
|
||||
// Listen for messages from draw.io
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.origin !== 'https://embed.diagrams.net') return;
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleDrawioMessage(msg);
|
||||
} catch (e) {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
});
|
||||
|
||||
function handleDrawioMessage(msg) {
|
||||
if (msg.event === 'init') {
|
||||
isDrawioReady = true;
|
||||
statusEl.textContent = 'Ready';
|
||||
statusEl.className = 'status connected';
|
||||
|
||||
// Load pending XML if any
|
||||
if (pendingXml) {
|
||||
loadDiagram(pendingXml);
|
||||
pendingXml = null;
|
||||
}
|
||||
} else if (msg.event === 'save') {
|
||||
// User saved - push to state
|
||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
||||
pushState(msg.xml);
|
||||
}
|
||||
} else if (msg.event === 'export') {
|
||||
// Export completed
|
||||
if (msg.data) {
|
||||
pushState(msg.data);
|
||||
}
|
||||
} else if (msg.event === 'autosave') {
|
||||
// Autosave - push to state
|
||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
||||
pushState(msg.xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadDiagram(xml) {
|
||||
if (!isDrawioReady) {
|
||||
pendingXml = xml;
|
||||
return;
|
||||
}
|
||||
|
||||
lastLoadedXml = xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({
|
||||
action: 'load',
|
||||
xml: xml,
|
||||
autosave: 1
|
||||
}), '*');
|
||||
}
|
||||
|
||||
async function pushState(xml) {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
currentVersion = result.version;
|
||||
lastLoadedXml = xml;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to push state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollState() {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!response.ok) return;
|
||||
|
||||
const state = await response.json();
|
||||
|
||||
if (state.version && state.version > currentVersion && state.xml) {
|
||||
currentVersion = state.version;
|
||||
loadDiagram(state.xml);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to poll state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling if we have a session
|
||||
if (sessionId) {
|
||||
pollState();
|
||||
setInterval(pollState, 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
476
packages/mcp-server/src/index.ts
Normal file
476
packages/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
#!/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.
|
||||
*
|
||||
* Uses an embedded HTTP server - no external dependencies required.
|
||||
*/
|
||||
|
||||
// Setup DOM polyfill for Node.js (required for XML operations)
|
||||
import { DOMParser } from "linkedom"
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
|
||||
// Create XMLSerializer polyfill using outerHTML
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: any): string {
|
||||
if (node.outerHTML !== undefined) {
|
||||
return node.outerHTML
|
||||
}
|
||||
if (node.documentElement) {
|
||||
return node.documentElement.outerHTML
|
||||
}
|
||||
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"
|
||||
import {
|
||||
getServerPort,
|
||||
getState,
|
||||
setState,
|
||||
startHttpServer,
|
||||
} from "./http-server.js"
|
||||
import { log } from "./logger.js"
|
||||
|
||||
// Server configuration
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT || "6002"),
|
||||
}
|
||||
|
||||
// Session state (single session for simplicity)
|
||||
let currentSession: {
|
||||
id: string
|
||||
xml: string
|
||||
version: number
|
||||
} | null = null
|
||||
|
||||
// Create MCP server
|
||||
const server = new McpServer({
|
||||
name: "next-ai-drawio",
|
||||
version: "0.1.2",
|
||||
})
|
||||
|
||||
// Register prompt with workflow guidance
|
||||
server.prompt(
|
||||
"diagram-workflow",
|
||||
"Guidelines for creating and editing draw.io diagrams",
|
||||
() => ({
|
||||
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 display_diagram with complete mxGraphModel XML to create a new diagram
|
||||
|
||||
## Adding Elements to Existing Diagram
|
||||
1. Use edit_diagram with "add" operation
|
||||
2. Provide a unique cell_id and complete mxCell XML
|
||||
3. No need to call get_diagram first - the server fetches latest state automatically
|
||||
|
||||
## Modifying or Deleting Existing Elements
|
||||
1. FIRST call get_diagram to see current cell IDs and structure
|
||||
2. THEN call edit_diagram with "update" or "delete" operations
|
||||
3. For update, provide the cell_id and complete new mxCell XML
|
||||
|
||||
## Important Notes
|
||||
- display_diagram REPLACES the entire diagram - only use for new diagrams
|
||||
- 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")`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// Tool: start_session
|
||||
server.registerTool(
|
||||
"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.",
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
// Start embedded HTTP server
|
||||
const port = await startHttpServer(config.port)
|
||||
|
||||
// Create session
|
||||
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
|
||||
currentSession = {
|
||||
id: sessionId,
|
||||
xml: "",
|
||||
version: 0,
|
||||
}
|
||||
|
||||
// Open browser
|
||||
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
||||
await open(browserUrl)
|
||||
|
||||
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} 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. " +
|
||||
"Use this for creating new diagrams from scratch. " +
|
||||
"To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " +
|
||||
"You should generate valid draw.io/mxGraph XML format.",
|
||||
inputSchema: {
|
||||
xml: z
|
||||
.string()
|
||||
.describe("The draw.io XML to display (mxGraphModel format)"),
|
||||
},
|
||||
},
|
||||
async ({ xml }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Displaying diagram, ${xml.length} chars`)
|
||||
|
||||
// Update session state
|
||||
currentSession.xml = xml
|
||||
currentSession.version++
|
||||
|
||||
// Push to embedded server state
|
||||
setState(currentSession.id, xml)
|
||||
|
||||
log.info(`Diagram displayed successfully`)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} 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:
|
||||
"Edit the current diagram by ID-based operations (update/add/delete cells). " +
|
||||
"ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" +
|
||||
"IMPORTANT workflow:\n" +
|
||||
"- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" +
|
||||
"- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" +
|
||||
"Operations:\n" +
|
||||
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\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.",
|
||||
inputSchema: {
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z
|
||||
.enum(["update", "add", "delete"])
|
||||
.describe("Operation type"),
|
||||
cell_id: z.string().describe("The id of the mxCell"),
|
||||
new_xml: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Complete mxCell XML element (required for update/add)",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe("Array of operations to apply"),
|
||||
},
|
||||
},
|
||||
async ({ operations }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch latest state from browser
|
||||
const browserState = getState(currentSession.id)
|
||||
if (browserState?.xml) {
|
||||
currentSession.xml = browserState.xml
|
||||
log.info("Fetched latest diagram state from browser")
|
||||
}
|
||||
|
||||
if (!currentSession.xml) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No diagram to edit. Please create a diagram first with display_diagram.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
||||
|
||||
// Apply operations
|
||||
const { result, errors } = applyDiagramOperations(
|
||||
currentSession.xml,
|
||||
operations as DiagramOperation[],
|
||||
)
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map((e) => `${e.type} ${e.cellId}: ${e.message}`)
|
||||
.join("\n")
|
||||
log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`)
|
||||
}
|
||||
|
||||
// Update state
|
||||
currentSession.xml = result
|
||||
currentSession.version++
|
||||
|
||||
// Push to embedded server
|
||||
setState(currentSession.id, result)
|
||||
|
||||
log.info(`Diagram edited successfully`)
|
||||
|
||||
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
||||
const errorMsg =
|
||||
errors.length > 0
|
||||
? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}`
|
||||
: ""
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: successMsg + errorMsg,
|
||||
},
|
||||
],
|
||||
}
|
||||
} 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:
|
||||
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
|
||||
"Call this BEFORE edit_diagram if you need to update or delete existing elements, " +
|
||||
"so you can see the current cell IDs and structure.",
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch latest state from browser
|
||||
const browserState = getState(currentSession.id)
|
||||
if (browserState?.xml) {
|
||||
currentSession.xml = browserState.xml
|
||||
}
|
||||
|
||||
if (!currentSession.xml) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No diagram exists yet. Use display_diagram to create one.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Current diagram XML:\n\n${currentSession.xml}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
log.error("get_diagram failed:", message)
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Tool: export_diagram
|
||||
server.registerTool(
|
||||
"export_diagram",
|
||||
{
|
||||
description: "Export the current diagram to a .drawio file.",
|
||||
inputSchema: {
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
"File path to save the diagram (e.g., ./diagram.drawio)",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ path }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch latest state
|
||||
const browserState = getState(currentSession.id)
|
||||
if (browserState?.xml) {
|
||||
currentSession.xml = browserState.xml
|
||||
}
|
||||
|
||||
if (!currentSession.xml) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No diagram to export. Please create a diagram first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const fs = await import("node:fs/promises")
|
||||
const nodePath = await import("node:path")
|
||||
|
||||
let filePath = path
|
||||
if (!filePath.endsWith(".drawio")) {
|
||||
filePath = `${filePath}.drawio`
|
||||
}
|
||||
|
||||
const absolutePath = nodePath.resolve(filePath)
|
||||
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
||||
|
||||
log.info(`Diagram exported to ${absolutePath}`)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
log.error("export_diagram failed:", message)
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Start the MCP server
|
||||
async function main() {
|
||||
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
|
||||
log.info("MCP server running on stdio")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
log.error("Fatal error:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
24
packages/mcp-server/src/logger.ts
Normal file
24
packages/mcp-server/src/logger.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Logger for MCP server
|
||||
*
|
||||
* CRITICAL: MCP servers communicate via STDIO (stdin/stdout).
|
||||
* Using console.log() will corrupt the JSON-RPC protocol messages.
|
||||
* ALL logging MUST use console.error() which writes to stderr.
|
||||
*/
|
||||
|
||||
export const log = {
|
||||
info: (msg: string, ...args: unknown[]) => {
|
||||
console.error(`[MCP-DrawIO] [INFO] ${msg}`, ...args)
|
||||
},
|
||||
error: (msg: string, ...args: unknown[]) => {
|
||||
console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)
|
||||
},
|
||||
debug: (msg: string, ...args: unknown[]) => {
|
||||
if (process.env.DEBUG === "true") {
|
||||
console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)
|
||||
}
|
||||
},
|
||||
warn: (msg: string, ...args: unknown[]) => {
|
||||
console.error(`[MCP-DrawIO] [WARN] ${msg}`, ...args)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user