Compare commits

...
Author SHA1 Message Date
Dayuan Jiang 3d1a020b35 docs(mcp): mention the xml source of load_diagram in the tool table (#978) 2026-10-11 12:05:18 +09:00
caoxiaole07andcaoxiaole07 8d690503d4 feat(mcp): load_diagram accepts inline 'xml' content as an alternative to 'path' (#946)
Agents frequently hold .drawio content in memory (another tool's output,
a repository read, an API response) and previously had to write it to a
temporary file just so load_diagram could read it back. Add a mutually
exclusive 'xml' argument that goes through the same parser as the 'path'
branch, so both plain XML and draw.io's compressed save format work.

Edit-gate semantics by source:
- 'path': unchanged — the model has not seen the content, one
  get_diagram round-trip is still required before editing.
- plain 'xml': the model supplied the exact content (same rationale as
  create_new_diagram), so it is recorded as seen and can be edited
  immediately.
- compressed 'xml': the session stores the decompressed form, which the
  model cannot derive from the compressed input — the gate is kept.

Argument validation (mutual exclusion / presence) fires before the
session check so callers get useful errors regardless of session state.
parseDrawioFileContent now reports whether any page was decompressed,
which drives the gate decision. The diagram-workflow prompt is updated
to document the new argument.

Co-authored-by: caoxiaole07 <[email protected]>
2026-10-11 10:03:13 +09:00
renovate[bot] 4b4f36e374 chore(deps): update dependency @biomejs/biome to v2.5.15 (#931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 08:33:56 +09:00
Dayuan Jiang a9091e96f1 ci: remind and close issues that wait for the reporter (#977)
Issues labelled "wait for confrim" get a reminder and the "no response"
label after 15 days without activity, and are closed as not planned after
15 more. A reply from the reporter removes both labels.
2026-10-11 08:02:28 +09:00
Dayuan Jiang 213b672e92 test(e2e): deselect by clicking a corner away from the shape (#975)
The test clicked the canvas at (10, 10) to deselect. When the click on the
shape before it needed retries, Playwright scrolled the shape into the
container's top-left corner, so the next click landed on the shape and the
selection stayed (seen on the main run of cd5352c and on #973). The new
clickEmptyCanvas helper clicks the corner farthest from the shape.
Reproduced locally by scrolling the shape into the corner: the old click
keeps the selection, the helper clears it.
2026-10-10 23:53:30 +09:00
10 changed files with 291 additions and 81 deletions
@@ -0,0 +1,55 @@
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
# days without activity the issue gets a reminder and the "no response"
# label; after 15 more days it is closed. A reply from the reporter removes
# both labels, so the issue shows up as needing the maintainer again.
name: Wait for confirmation
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
issue_comment:
types: [created]
permissions:
issues: write
jobs:
remind-and-close:
if: github.event_name != 'issue_comment'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-issue-labels: "wait for confrim"
days-before-issue-stale: 15
days-before-issue-close: 15
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: "no response"
stale-issue-message: >-
Still there? We asked for more information 15 days ago. A reply
would help; if we hear nothing in another 15 days this issue will
be closed. It can be reopened any time.
close-issue-message: >-
No reply in 30 days, so this issue is closed for now. If the
problem is still there, reply or reopen it.
close-issue-reason: not_planned
remove-stale-when-updated: true
reporter-replied:
if: >-
github.event_name == 'issue_comment' &&
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
github.event.comment.user.type != 'Bot' &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
steps:
- name: The reporter answered, so the issue is the maintainer's again
run: >-
gh issue edit "$NUMBER" --repo "$REPO"
--remove-label "wait for confrim" --remove-label "no response"
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
+36 -36
View File
@@ -75,7 +75,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7", "@biomejs/biome": "2.5.15",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -2093,9 +2093,9 @@
} }
}, },
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz",
"integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==", "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"bin": { "bin": {
@@ -2109,20 +2109,20 @@
"url": "https://opencollective.com/biome" "url": "https://opencollective.com/biome"
}, },
"optionalDependencies": { "optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-arm64": "2.5.15",
"@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.15",
"@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.15",
"@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.15",
"@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64": "2.5.15",
"@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.15",
"@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.15",
"@biomejs/cli-win32-x64": "2.5.7" "@biomejs/cli-win32-x64": "2.5.15"
} }
}, },
"node_modules/@biomejs/cli-darwin-arm64": { "node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz",
"integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==", "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2137,9 +2137,9 @@
} }
}, },
"node_modules/@biomejs/cli-darwin-x64": { "node_modules/@biomejs/cli-darwin-x64": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz",
"integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==", "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2154,9 +2154,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64": { "node_modules/@biomejs/cli-linux-arm64": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz",
"integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==", "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2174,9 +2174,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64-musl": { "node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz",
"integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==", "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2194,9 +2194,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64": { "node_modules/@biomejs/cli-linux-x64": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz",
"integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==", "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2214,9 +2214,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64-musl": { "node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz",
"integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==", "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2234,9 +2234,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-arm64": { "node_modules/@biomejs/cli-win32-arm64": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz",
"integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==", "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2251,9 +2251,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-x64": { "node_modules/@biomejs/cli-win32-x64": {
"version": "2.5.7", "version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz",
"integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==", "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
+1 -1
View File
@@ -110,7 +110,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7", "@biomejs/biome": "2.5.15",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
+1 -1
View File
@@ -123,7 +123,7 @@ Use the standard MCP configuration with:
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted | | `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` | | `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough | | `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) | | `load_diagram` | Load a `.drawio` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing | | `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
| `get_diagram` | Get the current diagram XML, including your edits in the browser | | `get_diagram` | Get the current diagram XML, including your edits in the browser |
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram | | `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
+1 -1
View File
@@ -18,7 +18,7 @@ import {
export const DRAWING_GUIDE = `# Draw.io drawing guide export const DRAWING_GUIDE = `# Draw.io drawing guide
## Workflow ## Workflow
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file. - create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes. - 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. - 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). - 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).
+86 -35
View File
@@ -455,21 +455,54 @@ registerWriteTool(
{ {
title: "Load .drawio file", title: "Load .drawio file",
description: description:
"Load a .drawio file from disk into the current session, REPLACING the entire diagram (all pages). " + "Load a .drawio diagram into the current session, REPLACING the entire diagram (all pages). " +
"The server reads the file directly — you do NOT need to read the file yourself or pass its XML through create_new_diagram. " + "Provide ONE of two mutually exclusive sources: 'path' (the server reads the file from disk — you do NOT need to read the file yourself or pass its XML through create_new_diagram) " +
"Handles both plain-XML and draw.io's compressed save format.\n\n" + "or 'xml' (the raw file content you already have — from another tool, a repository read, or an API response — so no temporary file needs to be written first). " +
"After loading, call get_diagram before edit_diagram — you haven't seen the file's cell IDs or structure yet.", "Both accept plain XML and draw.io's compressed save format.\n\n" +
"After loading from 'path' (or from compressed 'xml'), call get_diagram before edit_diagram — you haven't seen the file's cell IDs yet. " +
"Plain-XML 'xml' content you supplied yourself is already known and can be edited immediately.",
inputSchema: { inputSchema: {
path: z path: z
.string() .string()
.optional()
.describe( .describe(
"Absolute path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project.", "Path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project. Mutually exclusive with 'xml'.",
),
xml: z
.string()
.optional()
.describe(
"Raw .drawio file content: a plain <mxfile>/<mxGraphModel>, or draw.io's compressed save format. Use when the content is already in hand (another tool's output, a repository read, an API response). Mutually exclusive with 'path'.",
), ),
}, },
annotations: { openWorldHint: false }, annotations: { openWorldHint: false },
}, },
async ({ path }) => { async ({ path, xml: inlineXml }) => {
try { try {
// Argument validation comes before the session check: a bad
// argument is a caller error and should be reported as such.
if (path !== undefined && inlineXml !== undefined) {
return {
content: [
{
type: "text",
text: "Error: Provide either 'path' or 'xml', not both.",
},
],
isError: true,
}
}
if (path === undefined && inlineXml === undefined) {
return {
content: [
{
type: "text",
text: "Error: Provide either 'path' (a .drawio file to read) or 'xml' (the file's content).",
},
],
isError: true,
}
}
if (!currentSession) { if (!currentSession) {
return { return {
content: [ content: [
@@ -482,29 +515,37 @@ registerWriteTool(
} }
} }
const fs = await import("node:fs/promises") // Exactly one of path/xml is present (validated above).
const nodePath = await import("node:path") let content = ""
const absolutePath = nodePath.resolve(expandHome(path)) let sourceLabel = ""
if (inlineXml !== undefined) {
content = inlineXml
sourceLabel = "inline XML"
} else if (path !== undefined) {
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
const absolutePath = nodePath.resolve(expandHome(path))
let content: string try {
try { // A pipe or device could be read forever, and the other
// A pipe or device could be read forever, and the other // write tools wait for this one
// write tools wait for this one if (!(await fs.stat(absolutePath)).isFile()) {
if (!(await fs.stat(absolutePath)).isFile()) { throw new Error("not a regular file")
throw new Error("not a regular file") }
} content = await fs.readFile(absolutePath, "utf-8")
content = await fs.readFile(absolutePath, "utf-8") } catch (e) {
} catch (e) { const msg = e instanceof Error ? e.message : String(e)
const msg = e instanceof Error ? e.message : String(e) return {
return { content: [
content: [ {
{ type: "text",
type: "text", text: `Error: Cannot read file ${absolutePath}: ${msg}`,
text: `Error: Cannot read file ${absolutePath}: ${msg}`, },
}, ],
], isError: true,
isError: true, }
} }
sourceLabel = absolutePath
} }
const loaded = parseDrawioFileContent(content) const loaded = parseDrawioFileContent(content)
@@ -517,7 +558,7 @@ registerWriteTool(
const xml = loaded.xml const xml = loaded.xml
log.info( log.info(
`Loading diagram from ${absolutePath} (${xml.length} chars)`, `Loading diagram from ${sourceLabel} (${xml.length} chars)`,
) )
// Save the user's current state before replacing (same flow as // Save the user's current state before replacing (same flow as
@@ -537,10 +578,17 @@ registerWriteTool(
currentSession.xml = xml currentSession.xml = xml
currentSession.version++ currentSession.version++
setState(currentSession.id, xml) setState(currentSession.id, xml)
// Deliberately NOT marking the loaded XML as seen: the model only // Edit-gate semantics by source:
// supplied a path, so it doesn't know the file's cell IDs. The // - 'path': the model only supplied a path, so it doesn't know
// edit gate will require one get_diagram before edits. // the file's cell IDs — keep the gate (one get_diagram first).
currentSession.lastSeenXml = "" // - plain 'xml': the model supplied the exact content, same
// rationale as create_new_diagram — record it as seen.
// - compressed 'xml': the session now holds the decompressed
// form, which the model cannot derive from the compressed
// input — keep the gate.
const markSeen =
inlineXml !== undefined && !loaded.hadCompressedPages
currentSession.lastSeenXml = markSeen ? xml : ""
addHistory(currentSession.id, xml, "") addHistory(currentSession.id, xml, "")
@@ -551,13 +599,17 @@ registerWriteTool(
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
: "no pages parsed" : "no pages parsed"
log.info(`Diagram loaded from file (${pageSummary})`) log.info(`Diagram loaded (${pageSummary})`)
const gateHint = markSeen
? ""
: "\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet."
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Diagram loaded from ${absolutePath}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet.`, text: `Diagram loaded from ${sourceLabel}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}${gateHint}`,
}, },
], ],
} }
@@ -572,7 +624,6 @@ registerWriteTool(
} }
}, },
) )
// Tool: edit_diagram // Tool: edit_diagram
registerWriteTool( registerWriteTool(
"edit_diagram", "edit_diagram",
+6 -2
View File
@@ -18,7 +18,7 @@ import {
import { getXmlSyntaxError } from "./xml-syntax.ts" import { getXmlSyntaxError } from "./xml-syntax.ts"
export type LoadResult = export type LoadResult =
| { ok: true; xml: string } | { ok: true; xml: string; hadCompressedPages: boolean }
| { ok: false; error: string } | { ok: false; error: string }
/** /**
@@ -101,5 +101,9 @@ export function parseDrawioFileContent(content: string): LoadResult {
decompressedAny = true decompressedAny = true
} }
// Nothing changed — keep the file's own serialisation. // Nothing changed — keep the file's own serialisation.
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed } return {
ok: true,
xml: decompressedAny ? serializeMxfile(doc) : trimmed,
hadCompressedPages: decompressedAny,
}
} }
+37 -2
View File
@@ -54,7 +54,11 @@ describe("decompressPageContent", () => {
describe("parseDrawioFileContent", () => { describe("parseDrawioFileContent", () => {
it("passes a plain-XML mxfile through unchanged", () => { it("passes a plain-XML mxfile through unchanged", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE) const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE }) expect(r).toEqual({
ok: true,
xml: PLAIN_MXFILE,
hadCompressedPages: false,
})
}) })
it("wraps a bare mxGraphModel into a one-page mxfile", () => { it("wraps a bare mxGraphModel into a one-page mxfile", () => {
@@ -103,7 +107,11 @@ describe("parseDrawioFileContent", () => {
it("keeps empty pages as-is", () => { it("keeps empty pages as-is", () => {
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>` const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
const r = parseDrawioFileContent(withEmpty) const r = parseDrawioFileContent(withEmpty)
expect(r).toEqual({ ok: true, xml: withEmpty }) expect(r).toEqual({
ok: true,
xml: withEmpty,
hadCompressedPages: false,
})
}) })
it("rejects empty files", () => { it("rejects empty files", () => {
@@ -124,3 +132,30 @@ describe("parseDrawioFileContent", () => {
if (!r.ok) expect(r.error).toContain('"Broken"') if (!r.ok) expect(r.error).toContain('"Broken"')
}) })
}) })
describe("hadCompressedPages", () => {
it("is false for a plain-XML mxfile", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(false)
})
it("is false for a bare mxGraphModel", () => {
const r = parseDrawioFileContent(MODEL_XML)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(false)
})
it("is true for a fully compressed mxfile", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
it("is true for a mixed plain/compressed file", () => {
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
const r = parseDrawioFileContent(mixed)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
})
@@ -188,3 +188,51 @@ describe("MCP server wiring", () => {
expect(text.length).toBeLessThanOrEqual(15000) expect(text.length).toBeLessThanOrEqual(15000)
}) })
}) })
describe("load_diagram dual-source arguments", () => {
it("advertises both optional 'path' and 'xml' sources", async () => {
const resp = await send("tools/list", {})
const load = resp.result.tools.find(
(t: { name: string }) => t.name === "load_diagram",
)
const props = load?.inputSchema?.properties ?? {}
expect(props.path).toBeTruthy()
expect(props.xml).toBeTruthy()
const required: string[] = load?.inputSchema?.required ?? []
expect(required).not.toContain("path")
expect(required).not.toContain("xml")
})
it("rejects passing both 'path' and 'xml'", async () => {
// Argument validation fires before the session check: no session
// exists in this harness, so a both-args call must report the
// mutual-exclusion error, not "No active session".
const resp = await send("tools/call", {
name: "load_diagram",
arguments: { path: "/tmp/x.drawio", xml: "<mxfile/>" },
})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("not both")
})
it("rejects passing neither 'path' nor 'xml'", async () => {
const resp = await send("tools/call", {
name: "load_diagram",
arguments: {},
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("either 'path'")
})
it("accepts 'xml' alone as a source (fails only on the missing session)", async () => {
const resp = await send("tools/call", {
name: "load_diagram",
arguments: {
xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
},
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("No active session")
})
})
+20 -3
View File
@@ -108,6 +108,25 @@ async function boxOf(locator: Locator) {
return box return box
} }
/**
* Click the canvas where no shape is, to deselect: the corner of the
* container farthest from the shape. A fixed spot near the top-left corner
* is not safe, as a click on a shape may scroll it into that corner.
*/
async function clickEmptyCanvas(page: Page, shape: Locator) {
const container = getIframeContent(page).locator(".geDiagramContainer")
const box = await boxOf(container)
const shapeBox = await boxOf(shape)
const shapeX = shapeBox.x + shapeBox.width / 2
const shapeY = shapeBox.y + shapeBox.height / 2
await container.click({
position: {
x: shapeX < box.x + box.width / 2 ? box.width - 20 : 20,
y: shapeY < box.y + box.height / 2 ? box.height - 20 : 20,
},
})
}
/** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */ /** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */
function drawioUndo(page: Page) { function drawioUndo(page: Page) {
return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]') return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]')
@@ -4512,9 +4531,7 @@ test.describe("Edge cases", () => {
}) })
await sendMessage(page, "Make this red") await sendMessage(page, "Make this red")
// Deselected while the diagram exports // Deselected while the diagram exports
await canvas await clickEmptyCanvas(page, shape)
.locator(".geDiagramContainer")
.click({ position: { x: 10, y: 10 } })
await expect(chip).toHaveCount(0) await expect(chip).toHaveCount(0)
await page.evaluate(() => (window as any).__releaseExports()) await page.evaluate(() => (window as any).__releaseExports())
await waitForCompleteCount(page, 2) await waitForCompleteCount(page, 2)