mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
feat(mcp-server): screenshot attached to create_new_diagram and edit_diagram results (#983)
* feat(mcp-server): optional screenshot attached to create_new_diagram and edit_diagram results Both write tools take an optional boolean `screenshot`; the default comes from DRAWIO_AUTO_SCREENSHOT. The body of screenshot_diagram moved into captureScreenshot so the write tools can append the PNG and checklist to their result, or a "Screenshot skipped" note when the preview tab is not available. The preview page delays the PNG export by 600 ms right after loading a new version so icon images finish loading. * fix(mcp-server): review fixes for screenshot attached to create and edit results * fix(mcp-server): Codex review fixes for the screenshot on write results - A cleared canvas also gets the screenshot note when one was asked for - The note for a tab that never polled says it may not have connected yet * docs: list undo and custom drawing rules among the MCP features * test(mcp-server): truncation check with named styles and compact cells after the merge
This commit is contained in:
@@ -22,7 +22,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
||||
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
||||
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
||||
- After drawing or heavily editing a complex diagram, call screenshot_diagram once to see the result, and fix overlapping shapes and edges that cross shapes.
|
||||
- After drawing or heavily editing a complex diagram, pass screenshot: true on that create_new_diagram or edit_diagram call (or call screenshot_diagram) to see the result, and fix overlapping shapes and edges that cross shapes.
|
||||
- Drawing from a source: to draw from a document (PDF, Markdown, code, data), an image or screenshot, or a web page, first read it yourself (the attachment the user shared, or your own file-reading or web-fetch tools), then plan the layout and draw. This server never receives attachments; it only receives the XML you send. Extract the entities and relationships the diagram needs; do not copy the text into boxes.
|
||||
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes, colors and relative positions. Call screenshot_diagram afterwards and compare with the original.
|
||||
- After a successful create_new_diagram or edit_diagram call, do not describe the diagram; the user sees it in the preview. One short sentence at most.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
import open from "open"
|
||||
import { z } from "zod"
|
||||
import { expandCompactCells, foldCells } from "./compact-cells.ts"
|
||||
@@ -99,6 +100,9 @@ installDomPolyfill()
|
||||
// Server configuration
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT || "6002", 10),
|
||||
// Attach a screenshot to every create_new_diagram and edit_diagram
|
||||
// result unless the call says otherwise
|
||||
autoScreenshot: process.env.DRAWIO_AUTO_SCREENSHOT === "true",
|
||||
}
|
||||
|
||||
// Keep each session's latest diagram and its History on disk, so they
|
||||
@@ -166,7 +170,7 @@ Before using cloud or icon shapes (AWS, Azure, GCP, Kubernetes, Cisco, BPMN...),
|
||||
|
||||
To draw from a file, image or web page, read it yourself first; the server only receives XML. Open existing .drawio files with load_diagram.
|
||||
|
||||
After drawing a complex diagram, call screenshot_diagram once to see it, and fix overlapping shapes or edges that cross shapes.
|
||||
After drawing or heavily editing a complex diagram, pass screenshot: true on that create_new_diagram or edit_diagram call (or call screenshot_diagram) to see the result, and fix overlapping shapes and edges that cross shapes.
|
||||
|
||||
Tools:
|
||||
- create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full <mxfile> for several pages.
|
||||
@@ -226,6 +230,14 @@ const pageSelectorSchema = {
|
||||
),
|
||||
}
|
||||
|
||||
// The optional screenshot of create_new_diagram and edit_diagram
|
||||
const screenshotSchema = z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Also return a PNG of the result so you can check it for overlaps and edges crossing shapes. Needs the preview tab open and in front; adds 2 to 10 s. Default: the DRAWIO_AUTO_SCREENSHOT environment variable.",
|
||||
)
|
||||
|
||||
/**
|
||||
* Pull a clean PageSelector out of a tool's parsed input.
|
||||
* Returns an empty object when none of the page_* fields are set, so callers
|
||||
@@ -502,7 +514,7 @@ registerWriteTool(
|
||||
title: "Create new diagram",
|
||||
description: `Create a NEW diagram, REPLACING the whole document: every page and any unsaved user changes (the previous state stays in History). To add a tab use add_page; to change cells use edit_diagram.
|
||||
|
||||
Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape_library first. Follow the drawing guide returned by start_session (call get_drawing_guide if it is no longer in your context).
|
||||
Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape_library first. Follow the drawing guide returned by start_session (call get_drawing_guide if it is no longer in your context). Pass screenshot: true on the last call of a complex diagram to check the result.
|
||||
|
||||
Accepted xml:
|
||||
1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1":
|
||||
@@ -519,10 +531,11 @@ To clear the canvas to one blank page, send only the two root cells <mxCell id="
|
||||
.describe(
|
||||
"REQUIRED: the mxCell elements of one page, a bare <mxGraphModel>, or a full <mxfile> with one or more <diagram> pages.",
|
||||
),
|
||||
screenshot: screenshotSchema,
|
||||
},
|
||||
annotations: { openWorldHint: false },
|
||||
},
|
||||
async ({ xml: inputXml }) => {
|
||||
async ({ xml: inputXml, screenshot }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
@@ -596,26 +609,36 @@ To clear the canvas to one blank page, send only the two root cells <mxCell id="
|
||||
log.info(`Diagram content set successfully (${pageSummary})`)
|
||||
|
||||
// Only the root cells: the model asked for an empty canvas
|
||||
// (several empty pages get the page summary below)
|
||||
if (!hasCells(xml) && pages.length <= 1) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Canvas cleared: one blank page. The previous diagram is in History.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`,
|
||||
},
|
||||
],
|
||||
// (several empty pages get the page summary)
|
||||
const cleared = !hasCells(xml) && pages.length <= 1
|
||||
const content: CallToolResult["content"] = [
|
||||
{
|
||||
type: "text",
|
||||
text: cleared
|
||||
? "Canvas cleared: one blank page. The previous diagram is in History."
|
||||
: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`,
|
||||
},
|
||||
]
|
||||
// The page on screen is the new document's first page
|
||||
if (screenshot ?? config.autoScreenshot) {
|
||||
const shot = await captureScreenshot(
|
||||
currentSession.id,
|
||||
xml,
|
||||
{},
|
||||
AFTER_WRITE_NEXT,
|
||||
)
|
||||
content.push(
|
||||
...(shot.ok
|
||||
? shot.content
|
||||
: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Screenshot skipped: ${shot.note}`,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
return { content }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -856,10 +879,11 @@ registerWriteTool(
|
||||
}),
|
||||
)
|
||||
.describe("Array of operations to apply"),
|
||||
screenshot: screenshotSchema,
|
||||
},
|
||||
annotations: { openWorldHint: false },
|
||||
},
|
||||
async ({ operations, page_id, page_name, page_index }) => {
|
||||
async ({ operations, page_id, page_name, page_index, screenshot }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
@@ -998,14 +1022,36 @@ registerWriteTool(
|
||||
|
||||
log.info(`Diagram edited successfully`)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram edited successfully!\n\nApplied ${outcome.applied} operation(s) on ${describeSelector(pageSelector)}.`,
|
||||
},
|
||||
],
|
||||
const content: CallToolResult["content"] = [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram edited successfully!\n\nApplied ${outcome.applied} operation(s) on ${describeSelector(pageSelector)}.`,
|
||||
},
|
||||
]
|
||||
// Without a selector the edit went to the first page, while the
|
||||
// page on screen may be another tab: name the page, so the PNG
|
||||
// shows the edited one (a pageId export leaves the view alone)
|
||||
if (screenshot ?? config.autoScreenshot) {
|
||||
const shot = await captureScreenshot(
|
||||
currentSession.id,
|
||||
result,
|
||||
hasPageSelector(pageSelector)
|
||||
? pageSelector
|
||||
: { page_index: 0 },
|
||||
AFTER_WRITE_NEXT,
|
||||
)
|
||||
content.push(
|
||||
...(shot.ok
|
||||
? shot.content
|
||||
: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Screenshot skipped: ${shot.note}`,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
return { content }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1226,12 +1272,21 @@ function previewStalled(sessionId: string): boolean {
|
||||
return lastPolled !== undefined && Date.now() - lastPolled > 10_000
|
||||
}
|
||||
|
||||
/**
|
||||
* `next` is what the model does once the tab is in front. The default
|
||||
* suits screenshot_diagram and export_diagram; a write tool whose edit
|
||||
* already succeeded must not run again, so it points to screenshot_diagram.
|
||||
*/
|
||||
function previewStalledNote(sessionId: string, next = "then retry") {
|
||||
return `The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (http://localhost:${getServerPort()}?mcp=${sessionId}), ${next}.`
|
||||
}
|
||||
|
||||
function previewStalledError(sessionId: string) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error: The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (http://localhost:${getServerPort()}?mcp=${sessionId}), then retry.`,
|
||||
text: `Error: ${previewStalledNote(sessionId)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
@@ -1247,6 +1302,10 @@ function pageIdFor(xml: string, selector: PageSelector): string | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
// Stalled-tab advice after a successful create_new_diagram or edit_diagram:
|
||||
// "then retry" would make the model run the write again
|
||||
const AFTER_WRITE_NEXT = "then call screenshot_diagram to see the result"
|
||||
|
||||
// Screenshot size: Claude Desktop caps a tool result at about 150,000
|
||||
// characters, so retry smaller above 140,000 base64 characters. (Claude
|
||||
// Code 2.1 accepted a 240,000 character image in testing.)
|
||||
@@ -1260,8 +1319,90 @@ const SCREENSHOT_CHECKLIST = `Check this rendering of the diagram for:
|
||||
3. Text that is cut off, overlapping or too small to read (warning)
|
||||
4. Layout problems: cramped shapes, poor spacing or misalignment (warning)
|
||||
5. Rendering errors: missing, incomplete or broken elements, such as an icon that did not load (critical)
|
||||
Name the elements that have a problem, for example "the 'Login' box overlaps 'Register'".
|
||||
Give concrete fixes, for example "move 'Login' 50 px left".
|
||||
If there are critical issues, fix them with edit_diagram and take one more screenshot. Do at most two rounds of fixes. Minor cosmetic issues are fine, and diagrams with only 1 or 2 shapes pass unless something is clearly broken.`
|
||||
|
||||
/**
|
||||
* Have the preview tab render a page of xml as a PNG. Returns the image
|
||||
* block and the checklist for the model, or the reason there is no image
|
||||
* (a sentence without an "Error:" prefix: screenshot_diagram reports it as
|
||||
* an error, the write tools as a skipped screenshot).
|
||||
*/
|
||||
async function captureScreenshot(
|
||||
sessionId: string,
|
||||
xml: string,
|
||||
selector: PageSelector,
|
||||
/** What to do once a stalled tab is in front (see previewStalledNote) */
|
||||
next?: string,
|
||||
): Promise<
|
||||
| { ok: true; content: CallToolResult["content"] }
|
||||
| { ok: false; note: string }
|
||||
> {
|
||||
if (previewStalled(sessionId)) {
|
||||
return { ok: false, note: previewStalledNote(sessionId, next) }
|
||||
}
|
||||
// The tab never polled (right after start_session, or the state was
|
||||
// just restored from disk), so an export would only time out
|
||||
if (getState(sessionId)?.lastPolled === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
note: "The preview tab is not open, or has not connected yet.",
|
||||
}
|
||||
}
|
||||
if (!hasCells(xml)) {
|
||||
return { ok: false, note: "The diagram is empty." }
|
||||
}
|
||||
|
||||
let pageId: string | undefined
|
||||
let projectionXml: string | undefined
|
||||
if (hasPageSelector(selector)) {
|
||||
const doc = normalizeToMxfile(xml) ?? xml
|
||||
pageId = pageIdFor(doc, selector)
|
||||
// A page without an id: load just that page and capture it, as
|
||||
// export_diagram does
|
||||
if (!pageId) {
|
||||
const projection = projectPage(doc, selector)
|
||||
if (!projection.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
note: `Page ${describeSelector(selector)} not found.`,
|
||||
}
|
||||
}
|
||||
projectionXml = projection.xml
|
||||
}
|
||||
}
|
||||
|
||||
let data: string | undefined
|
||||
for (const width of SCREENSHOT_WIDTHS) {
|
||||
data = await exportViaBrowser(sessionId, "png", projectionXml, {
|
||||
width,
|
||||
pageId,
|
||||
})
|
||||
if (!data || data.length <= MAX_SCREENSHOT_CHARS) break
|
||||
}
|
||||
if (!data) {
|
||||
return {
|
||||
ok: false,
|
||||
note: "Screenshot timed out. Make sure the preview tab is open and in front.",
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: data.replace(/^data:image\/png;base64,/, ""),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Screenshot of ${hasPageSelector(selector) ? `page ${describeSelector(selector)}` : "the page on screen"}.\n\n${SCREENSHOT_CHECKLIST}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Tool: screenshot_diagram
|
||||
server.registerTool(
|
||||
"screenshot_diagram",
|
||||
@@ -1288,80 +1429,20 @@ server.registerTool(
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
if (previewStalled(currentSession.id)) {
|
||||
return previewStalledError(currentSession.id)
|
||||
}
|
||||
const xml =
|
||||
sessionState(currentSession.id)?.xml || currentSession.xml
|
||||
if (!hasCells(xml)) {
|
||||
const shot = await captureScreenshot(
|
||||
currentSession.id,
|
||||
xml,
|
||||
pickPageSelector({ page_id, page_name, page_index }),
|
||||
)
|
||||
if (!shot.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: "The diagram is empty." }],
|
||||
}
|
||||
}
|
||||
|
||||
const pageSelector = pickPageSelector({
|
||||
page_id,
|
||||
page_name,
|
||||
page_index,
|
||||
})
|
||||
let pageId: string | undefined
|
||||
let projectionXml: string | undefined
|
||||
if (hasPageSelector(pageSelector)) {
|
||||
const doc = normalizeToMxfile(xml) ?? xml
|
||||
pageId = pageIdFor(doc, pageSelector)
|
||||
// A page without an id: load just that page and capture
|
||||
// it, as export_diagram does
|
||||
if (!pageId) {
|
||||
const projection = projectPage(doc, pageSelector)
|
||||
if (!projection.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: Page ${describeSelector(pageSelector)} not found.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
projectionXml = projection.xml
|
||||
}
|
||||
}
|
||||
|
||||
let data: string | undefined
|
||||
// start_session may replace currentSession between the tries
|
||||
const sessionId = currentSession.id
|
||||
for (const width of SCREENSHOT_WIDTHS) {
|
||||
data = await exportViaBrowser(sessionId, "png", projectionXml, {
|
||||
width,
|
||||
pageId,
|
||||
})
|
||||
if (!data || data.length <= MAX_SCREENSHOT_CHARS) break
|
||||
}
|
||||
if (!data) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: Screenshot timed out. Make sure the preview tab is open and in front.",
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: `Error: ${shot.note}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: data.replace(/^data:image\/png;base64,/, ""),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Screenshot of ${hasPageSelector(pageSelector) ? `page ${describeSelector(pageSelector)}` : "the page on screen"}.\n\n${SCREENSHOT_CHECKLIST}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
return { content: shot.content }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -300,11 +300,13 @@ async function poll() {
|
||||
// The tab's own push still on its way is not loaded back: the
|
||||
// canvas may have moved on since (an undo), and its answer follows
|
||||
const ownPush = pushesInFlight.includes(s.xml);
|
||||
let justLoaded = false; // this poll put a new version on the canvas
|
||||
if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) {
|
||||
forceReload = false;
|
||||
projectionExportActive = false;
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
justLoaded = true;
|
||||
}
|
||||
// Handle sync request - server needs fresh state. After the load
|
||||
// above, so draw.io exports what it just loaded; never while a
|
||||
@@ -354,6 +356,10 @@ async function poll() {
|
||||
// Let draw.io render the loaded page before exporting
|
||||
// (same proven settle delay as the AI-preview path).
|
||||
setTimeout(fireExport, 600);
|
||||
} else if (justLoaded) {
|
||||
// A write with a screenshot: give the new diagram's external
|
||||
// icon images a moment to load before the PNG is taken
|
||||
setTimeout(fireExport, 600);
|
||||
} else {
|
||||
fireExport();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user