Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 197 additions & 15 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const server = new McpServer({
version: "0.1.2",
})

// Register prompt with workflow guidance
// Register prompt with workflow guidelines
server.prompt(
"diagram-workflow",
"Guidelines for creating and editing draw.io diagrams",
Expand All @@ -89,7 +89,7 @@ server.prompt(
## 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
3. For update, provide the cell_id and complete new_xml

## Important Notes
- create_new_diagram REPLACES the entire diagram - only use for new diagrams
Expand Down Expand Up @@ -541,16 +541,75 @@ server.registerTool(
},
)

/**
* Decodes a base64 data URL to a Buffer
* Supports: data:image/svg+xml;base64, data:image/png;base64, etc.
*/
function decodeDataUrl(dataUrl: string): Buffer | null {
if (!dataUrl || !dataUrl.startsWith("data:")) {
return null
}

const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
if (!match) {
return null
}

const mimeType = match[1]
const base64Data = match[2]

Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mimeType variable is extracted but never used. Consider removing it or adding validation to ensure only expected MIME types (image/svg+xml, image/png) are processed.

Suggested change
// Only process expected image MIME types
if (mimeType !== "image/svg+xml" && mimeType !== "image/png") {
return null
}

Copilot uses AI. Check for mistakes.
try {
return Buffer.from(base64Data, "base64")
} catch {
return null
}
}

/**
* Detects export format from file extension
*/
type ExportFormat = "drawio" | "svg" | "png" | "drawio-svg"

function detectExportFormat(path: string): ExportFormat {
const lowerPath = path.toLowerCase()
if (lowerPath.endsWith(".svg")) {
return "svg"
}
if (lowerPath.endsWith(".png")) {
return "png"
}
if (lowerPath.endsWith(".drawio.svg")) {
return "drawio-svg"
}
if (lowerPath.endsWith(".drawio.png")) {
return "drawio-svg" // PNG with embedded XML
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the file path ends with .drawio.png, the function returns 'drawio-svg' instead of a PNG-specific format. However, the code later handles this format by saving SVG data. This creates confusion between the format name and actual behavior. Consider either: 1) Creating a separate 'drawio-png' format type and handling it explicitly, or 2) Adding a comment in the switch statement explaining why PNG embeds use the SVG code path.

Copilot uses AI. Check for mistakes.
}
return "drawio"
}

// Tool: export_diagram
server.registerTool(
"export_diagram",
{
description: "Export the current diagram to a .drawio file.",
description: `Export the current diagram to a file.

Supported formats:
- .drawio - XML format (default, editable in draw.io)
- .svg - Standalone SVG image
- .png - PNG image
- .drawio.svg - SVG with embedded draw.io XML (editable + viewable)
- .drawio.png - PNG with embedded draw.io XML

Examples:
- export_diagram({ path: "./diagram.drawio" }) - XML format
- export_diagram({ path: "./diagram.svg" }) - SVG image
- export_diagram({ path: "./diagram.png" }) - PNG image
- export_diagram({ path: "./diagram.drawio.svg" }) - Hybrid format`,
inputSchema: {
path: z
.string()
.describe(
"File path to save the diagram (e.g., ./diagram.drawio)",
"File path to save the diagram. Format is detected from extension (.drawio, .svg, .png, .drawio.svg, .drawio.png)",
),
},
},
Expand Down Expand Up @@ -590,22 +649,145 @@ server.registerTool(
const nodePath = await import("node:path")

let filePath = path
if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio`
const format = detectExportFormat(filePath)

// Add extension if not present
const ext = nodePath.extname(filePath)
if (!ext) {
switch (format) {
case "svg":
case "drawio-svg":
filePath = `${filePath}.svg`
break
case "png":
filePath = `${filePath}.png`
break
default:
filePath = `${filePath}.drawio`
}
}

Comment on lines +652 to 669
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The format is detected before adding the extension, but format detection relies on the file extension. This creates a circular dependency: if no extension exists, detectExportFormat() returns 'drawio' by default, so this switch will only hit the default case. The extension addition logic should be based on explicit format specification or occur before format detection.

Suggested change
const format = detectExportFormat(filePath)
// Add extension if not present
const ext = nodePath.extname(filePath)
if (!ext) {
switch (format) {
case "svg":
case "drawio-svg":
filePath = `${filePath}.svg`
break
case "png":
filePath = `${filePath}.png`
break
default:
filePath = `${filePath}.drawio`
}
}
// Ensure the file has an extension before detecting the export format
const ext = nodePath.extname(filePath)
if (!ext) {
// Default to .drawio when no extension is provided
filePath = `${filePath}.drawio`
}
const format = detectExportFormat(filePath)

Copilot uses AI. Check for mistakes.
const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")

log.info(`Diagram exported to ${absolutePath}`)
switch (format) {
case "svg": {
// Export pure SVG
const svgBuffer = browserState?.svg
? decodeDataUrl(browserState.svg)
: null

if (!svgBuffer) {
return {
content: [
{
type: "text",
text: "Error: No SVG data available. The browser may not have rendered the diagram yet. Try saving again in a few seconds.",
},
],
isError: true,
}
}

await fs.writeFile(absolutePath, svgBuffer)
log.info(`SVG exported to ${absolutePath}`)
return {
content: [
{
type: "text",
text: `SVG exported successfully!\n\nFile: ${absolutePath}\nSize: ${svgBuffer.length} bytes`,
},
],
}
}

return {
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
},
],
case "png": {
// Export PNG (use SVG if PNG not available, or request PNG export)
// For now, we'll use SVG as fallback since PNG may not be cached
const svgBuffer = browserState?.svg
? decodeDataUrl(browserState.svg)
: null

if (!svgBuffer) {
return {
content: [
{
type: "text",
text: "Error: No image data available. The browser may not have rendered the diagram yet. Try saving as .svg instead, or save again in a few seconds.",
},
],
isError: true,
}
}

// Note: SVG is saved but user requested PNG - inform them
await fs.writeFile(absolutePath, svgBuffer)
log.info(`Image exported to ${absolutePath} (SVG format)`)
Comment on lines +723 to +724
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a user requests .png export, the code saves SVG data to a .png file extension. This creates a file with incorrect extension that won't open properly in image viewers. Either convert the SVG to actual PNG format, or change the file extension to .svg before saving and inform the user of the extension change.

Copilot uses AI. Check for mistakes.
return {
content: [
{
type: "text",
text: `Image exported successfully!\n\nFile: ${absolutePath}\nSize: ${svgBuffer.length} bytes\n\nNote: PNG export not fully supported yet. SVG was saved instead. You can convert SVG to PNG using any image converter.`,
},
],
}
}

case "drawio-svg": {
// Export SVG with embedded draw.io XML (hybrid format)
const svgBuffer = browserState?.svg
? decodeDataUrl(browserState.svg)
: null

if (!svgBuffer) {
return {
content: [
{
type: "text",
text: "Error: No SVG data available. The browser may not have rendered the diagram yet.",
},
],
isError: true,
}
}

let svgContent = svgBuffer.toString("utf-8")

// Embed the draw.io XML as a comment for editability
const xmlComment = `<!-- Draw.io XML:\n${currentSession.xml}\n-->`

// Insert XML comment after the opening <svg> tag
const svgInsertIndex = svgContent.indexOf(">") + 1
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using indexOf('>') to find the SVG tag insertion point is fragile. It will incorrectly match the first > character, which could be in an XML declaration (<?xml version='1.0'?>) or attribute value. Use a more robust approach like matching /<svg[^>]*>/ to find the actual opening SVG tag.

Suggested change
const svgInsertIndex = svgContent.indexOf(">") + 1
const svgTagMatch = svgContent.match(/<svg[^>]*>/i)
let svgInsertIndex: number
if (svgTagMatch && typeof svgTagMatch.index === "number") {
svgInsertIndex = svgTagMatch.index + svgTagMatch[0].length
} else {
// Fallback to previous behavior if <svg> tag is not found
svgInsertIndex = svgContent.indexOf(">") + 1
}

Copilot uses AI. Check for mistakes.
svgContent =
svgContent.slice(0, svgInsertIndex) +
xmlComment +
svgContent.slice(svgInsertIndex)

await fs.writeFile(absolutePath, svgContent, "utf-8")
log.info(`Hybrid SVG exported to ${absolutePath}`)
return {
content: [
{
type: "text",
text: `Hybrid SVG exported successfully!\n\nFile: ${absolutePath}\nSize: ${svgContent.length} bytes\n\nThis file can be:\n- Viewed as an image in browsers/viewers\n- Edited in draw.io (XML embedded in comment)`,
},
],
}
}

case "drawio":
default: {
// Export pure drawio XML
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 =
Expand Down
Loading