mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat: add PNG/SVG export to MCP server (#687)
* feat: add PNG/SVG export support to MCP server export_diagram tool Previously export_diagram only supported .drawio XML files. This adds PNG and SVG export by leveraging the existing browser sync mechanism: the MCP tool sets an exportFormat flag on the session state, the browser detects it via polling and triggers an iframe export, then POSTs the result back as exportData which the tool reads and writes to disk. * fix: address PR review feedback for export feature - Validate exportData is a string in POST /api/state - Update lastUpdated in setExportFormat to prevent session expiry - Gate export postMessage on isReady to avoid lost messages - Remove unused fmt variable - Fix double extension when path has a different supported extension * fix: resolve high severity npm audit vulnerabilities Run npm audit fix to update @aws-sdk and @smithy transitive dependencies that had high severity advisories, which was failing the CI security audit step. * fix: address second round of PR review feedback - Add 8s timeout for pendingMcpExport to prevent permanent blocking - Move export trigger after version update in poll() to export latest diagram - Return 404 when session not found for exportData POST - Sync browser state before .drawio export to avoid stale XML - Handle URL-encoded SVG data URIs in addition to base64 * fix: address third round of PR review feedback - Sync browser state before PNG/SVG export (not just drawio) - Add 10MB body size limit on POST /api/state - Validate export response format matches request to prevent race conditions * refactor: remove over-engineered defensive code from export feature Strip unnecessary validation/guards added from Copilot review that don't make sense for a localhost-only MCP server: body size limit, type validation, 404 for missing session, lastUpdated refresh, URL-encoded SVG handling. Also deduplicate requestSync call. * refactor: keep original drawio export path unchanged Don't restructure the existing drawio logic - just add png/svg as a separate branch after it. * refactor: remove redundant helper functions, inline state access Remove setExportFormat/getExportData/clearExportData wrappers that were each called once. Access state fields directly via getState(). * chore: bump mcp-server version to 0.1.16
This commit is contained in:
3122
package-lock.json
generated
3122
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.15",
|
"version": "0.1.16",
|
||||||
"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",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ interface SessionState {
|
|||||||
lastUpdated: Date
|
lastUpdated: Date
|
||||||
svg?: string // Cached SVG from last browser save
|
svg?: string // Cached SVG from last browser save
|
||||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||||
|
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||||
|
exportData?: string // Base64/SVG data returned by browser after export
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stateStore = new Map<string, SessionState>()
|
export const stateStore = new Map<string, SessionState>()
|
||||||
@@ -91,6 +93,8 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
|||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||||
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||||
|
exportData: existing?.exportData, // Preserve export result
|
||||||
})
|
})
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
@@ -255,6 +259,7 @@ function handleStateApi(
|
|||||||
xml: state?.xml || null,
|
xml: state?.xml || null,
|
||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
syncRequested: !!state?.syncRequested,
|
syncRequested: !!state?.syncRequested,
|
||||||
|
exportFormat: state?.exportFormat || null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -264,13 +269,30 @@ function handleStateApi(
|
|||||||
})
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, xml, svg } = JSON.parse(body)
|
const data = JSON.parse(body)
|
||||||
|
const { sessionId } = data
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const version = setState(sessionId, xml, svg)
|
|
||||||
|
// Browser is returning export data (png/svg)
|
||||||
|
if (data.exportData !== undefined) {
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
|
if (state) {
|
||||||
|
state.exportData = data.exportData
|
||||||
|
state.exportFormat = undefined
|
||||||
|
log.debug(
|
||||||
|
`Export data received for session=${sessionId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = setState(sessionId, data.xml, data.svg)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, version }))
|
res.end(JSON.stringify({ success: true, version }))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -638,6 +660,7 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
let pendingSvgExport = null;
|
let pendingSvgExport = null;
|
||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
|
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||||
@@ -653,6 +676,23 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
// Fallback if export doesn't respond
|
// Fallback if export doesn't respond
|
||||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||||
} else if (msg.event === 'export' && msg.data) {
|
} else if (msg.event === 'export' && msg.data) {
|
||||||
|
// Handle MCP server export request (png/svg)
|
||||||
|
// Verify the response matches the requested format to avoid capturing
|
||||||
|
// unrelated exports (autosave SVG, sync XML)
|
||||||
|
if (pendingMcpExport) {
|
||||||
|
const d = msg.data;
|
||||||
|
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
|
||||||
|
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
||||||
|
if (isPng || isSvg) {
|
||||||
|
pendingMcpExport = null;
|
||||||
|
fetch('/api/state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, exportData: d })
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||||
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||||
const dl = pendingDownload;
|
const dl = pendingDownload;
|
||||||
@@ -732,11 +772,21 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
pendingSyncExport = true;
|
pendingSyncExport = true;
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||||
}
|
}
|
||||||
// Load new diagram from server
|
// Load new diagram from server (before export, so we export latest)
|
||||||
if (s.version > currentVersion && s.xml) {
|
if (s.version > currentVersion && s.xml) {
|
||||||
currentVersion = s.version;
|
currentVersion = s.version;
|
||||||
loadDiagram(s.xml, true);
|
loadDiagram(s.xml, true);
|
||||||
}
|
}
|
||||||
|
// Handle export request from MCP server (png/svg) - after version update
|
||||||
|
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||||
|
pendingMcpExport = s.exportFormat;
|
||||||
|
const exportOpts = s.exportFormat === 'png'
|
||||||
|
? { action: 'export', format: 'png', scale: 2 }
|
||||||
|
: { action: 'export', format: 'svg' };
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||||
|
// Timeout: reset if draw.io never responds
|
||||||
|
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -546,16 +546,24 @@ server.registerTool(
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
"export_diagram",
|
"export_diagram",
|
||||||
{
|
{
|
||||||
description: "Export the current diagram to a .drawio file.",
|
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.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
path: z
|
path: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"File path to save the diagram (e.g., ./diagram.drawio)",
|
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)",
|
||||||
|
),
|
||||||
|
format: z
|
||||||
|
.enum(["drawio", "png", "svg"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Export format. If omitted, detected from file extension. Defaults to drawio.",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ path }) => {
|
async ({ path, format }) => {
|
||||||
try {
|
try {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return {
|
return {
|
||||||
@@ -590,21 +598,107 @@ server.registerTool(
|
|||||||
const fs = await import("node:fs/promises")
|
const fs = await import("node:fs/promises")
|
||||||
const nodePath = await import("node:path")
|
const nodePath = await import("node:path")
|
||||||
|
|
||||||
let filePath = path
|
// Detect format from extension if not specified
|
||||||
if (!filePath.endsWith(".drawio")) {
|
const ext = nodePath.extname(path).toLowerCase()
|
||||||
filePath = `${filePath}.drawio`
|
const detectedFormat =
|
||||||
|
format ||
|
||||||
|
(ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio")
|
||||||
|
|
||||||
|
// Original .drawio export path (unchanged logic)
|
||||||
|
if (detectedFormat === "drawio") {
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PNG or SVG: request browser to export via iframe
|
||||||
|
let filePath = path
|
||||||
|
if (ext !== `.${detectedFormat}`) {
|
||||||
|
if (ext === ".drawio" || ext === ".png" || ext === ".svg") {
|
||||||
|
filePath = filePath.slice(0, -ext.length)
|
||||||
|
}
|
||||||
|
filePath = `${filePath}.${detectedFormat}`
|
||||||
|
}
|
||||||
const absolutePath = nodePath.resolve(filePath)
|
const absolutePath = nodePath.resolve(filePath)
|
||||||
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
|
||||||
|
|
||||||
log.info(`Diagram exported to ${absolutePath}`)
|
const state = getState(currentSession.id)
|
||||||
|
if (!state) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Error: Session state not found. Is the browser open?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.exportFormat = detectedFormat as "png" | "svg"
|
||||||
|
state.exportData = undefined
|
||||||
|
|
||||||
|
// Wait for browser to produce the export data
|
||||||
|
const timeoutMs = 10000
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (state.exportData) break
|
||||||
|
await new Promise((r) => setTimeout(r, 200))
|
||||||
|
}
|
||||||
|
const exportData = state.exportData as string | undefined
|
||||||
|
state.exportData = undefined
|
||||||
|
state.exportFormat = undefined
|
||||||
|
|
||||||
|
if (!exportData) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and write
|
||||||
|
if (detectedFormat === "png") {
|
||||||
|
const base64 = exportData.replace(
|
||||||
|
/^data:image\/png;base64,/,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
await fs.writeFile(absolutePath, Buffer.from(base64, "base64"))
|
||||||
|
} else {
|
||||||
|
let svgContent = exportData
|
||||||
|
if (svgContent.startsWith("data:image/svg+xml;base64,")) {
|
||||||
|
const base64 = svgContent.replace(
|
||||||
|
/^data:image\/svg\+xml;base64,/,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
svgContent = Buffer.from(base64, "base64").toString("utf-8")
|
||||||
|
}
|
||||||
|
await fs.writeFile(absolutePath, svgContent, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(absolutePath)
|
||||||
|
log.info(
|
||||||
|
`Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
|
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user