feat(mcp-server): the preview is the web app's canvas: same-origin draw.io, version cards, get_selection (#984)

* refactor(canvas): decouple the canvas from the chat engine

The canvas components will be reused by the MCP server's browser shell,
which has no chat and no Next.js. They now read everything they need
from small contexts and the canvas store instead of the chat engine:

- components/canvas/versions-context.tsx: VersionsProvider and
  useVersionsContext give the version cards, the strip and the compare
  dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy,
  canUndo, canRedo, restore, undo, redo). The chat fills it from the
  versions store and the engine in components/chat/chat-versions.tsx.
- stores/canvas-store.ts: isBusy and busyReason; the chat engine sets
  them while a turn runs, SelectionAsk and the version UI read them.
- components/canvas/version-card.tsx: the visual version card and the
  thumbnail, out of tool-activity.tsx; the chat's card composes it and
  adds its "Show XML" link and code panel.
- compare-dialog.tsx and version-strip.tsx move to components/canvas;
  the strip takes the minimum number of versions to show as a prop, the
  chat panel computes it from the cards it has.
- components/canvas/locale-context.tsx: LocaleProvider and useLocale,
  fed by the [lang] layout; CanvasStage no longer uses next/navigation.
  The overlay (SelectionAsk) is a slot and the wait for the saved
  language is a prop, so the shell can leave both out.
- lib/version-text.ts: describeChanges is now describeChangeSummary, so
  it can be imported next to the MCP core's describeChanges.

* test(unit): canvas import boundary and the shared version card

The boundary test scans the canvas modules' imports and bundles the canvas components with esbuild; both fail on anything from components/chat, the tool handlers, next/navigation, next/font, next/script, next/headers or a server-only module. The card test renders VersionCard and VersionStrip on a fake VersionsSource.

* refactor(scripts): pin the draw.io release in one json file and share the zip reader

* feat(mcp-server): bundle a trimmed draw.io into dist/drawio at build time

scripts/fetch-drawio.mjs downloads the pinned draw.war into a cache
(DRAWIO_WAR_CACHE, or DRAWIO_WAR for a local file), checks its SHA-256
and extracts the files named in drawio-files.txt plus a LICENSE with the
Apache-2.0 text. scripts/check-drawio-files.mjs drives the embedded
editor in headless Chromium from a full copy, records every requested
file and checks or rewrites (--update) the list.

* feat(mcp-server): serve the bundled draw.io same-origin behind an api token

GET /drawio/<path> serves dist/drawio with a MIME table, a day of
caching and nosniff; paths are normalized and never reach WEB-INF or
META-INF. The preview embeds /drawio/index.html when the copy exists and
DRAWIO_BASE_URL is unset, else the external draw.io as before (and
start_session says so). Every /api request must carry the per-process
X-Drawio-Token the page gets in its HTML; pages send
frame-ancestors 'self' and nosniff.

* build(mcp-server): require the bundled draw.io in the package and cap the tarball at 40 MB

* fix(mcp-server): review fixes for the preview token and the draw.io static files

- The preview page fetches a fresh copy of itself and retries once when an
  API request is refused with 403: another MCP process, with its own token,
  now answers on this port, and the recovery logic (recoverState) needs its
  polls to go through. The page is sent with Cache-Control: no-store.
- draw.io files are served with an ETag and Cache-Control: no-cache instead
  of a 24 hour max-age: their names do not change between versions, so a
  package upgrade must reach the browser on the next preview. HEAD and
  If-None-Match (304) are answered.
- The file read stream goes through stream.pipeline, so a read error no
  longer ends the MCP process and a client that leaves mid-download no
  longer leaks the file handle.

* fix(mcp-server): review fixes for the draw.io file list and its guard

- The list now ships the templates of Insert > Template, the PlantUML
  parser of Insert > Advanced and the template dialog's icon: the menu
  items were shown but failed with 404s. 15.1 MB packed; the tarball cap
  goes from 40 MB to 20 MB, where it still catches a list that grew by a
  whole js/ directory.
- The guard drives both features, fails when the copy it runs against
  lacks files the editor asked for or when an export does not answer,
  checks that the copy is the pinned draw.io version, and runs in CI
  (npm run check-drawio, one E2E shard) so a list regression cannot reach
  a release.

* docs: review fixes for the draw.io version pointer and the preview's embedding

- The offline deployment guides point at packages/mcp-server/src/
  drawio-version.json for the draw.war version; scripts/fetch-drawio.mjs
  no longer names it.
- The MCP README says what the bundled draw.io copy includes, and that the
  preview page cannot be shown inside an editor's built-in browser since it
  is served with frame-ancestors 'self'.

* refactor(canvas): review fixes for the busy flag, the comparison note and the bundle size check

- busyReason had no reader: the chat engine sets isBusy through the store's
  generic set, like every other flag.
- The isSameDocument comment says the two "same document" rules disagree
  in both directions, so neither is a subset of the other.
- The import boundary test bundles minified with one pako (the MCP core
  resolves its own copy) and caps the canvas core at 160 KB (134 KB now).

* fix(mcp-server): name exports at random and stamp the draw.io version into ETags

- Export requests carried a per-process counter. The preview page retries a
  result refused with 403 against the process that took over the port, so a
  late result of the old process's export could be taken for the new
  process's export with the same number. The id is a random UUID now.
- The ETag of a bundled draw.io file now starts with the version the fetch
  script stamps into dist/drawio/.version. An install that keeps the
  archive's dates (npm does not) would otherwise answer 304 for a changed
  file of the same size after an upgrade.

* docs(mcp-server): mermaid is in the bundled draw.io, which has no image proxy

- Mermaid's converter is in js/extensions.min.js, which the editor loads at
  startup, so the README no longer lists it as left out; the org chart
  layout (js/orgchart.min.js) is the example of a feature that is.
- Note that the bundled copy, like the web app's, has no /drawio/proxy:
  images from other websites, including those in some templates, are left
  out of exports and thumbnails. The guard's comment names this as a known
  limitation it does not cover.

* refactor(canvas): getDrawioSrc takes the editor's source, and CanvasStage can be given one

* feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild

The shell (packages/mcp-server/shell) renders DrawioFrame inside the
shared providers without the chat: its config comes from
window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the
theme is kept under an mcp: localStorage key, and system fonts stand in
for the web fonts. scripts/build-shell.mjs bundles it into dist/shell
with the root's esbuild and Tailwind; check-package caps shell.js at
1.5 MB and shell.css at 300 KB.

* feat(mcp-server): serve the canvas shell at /shell/ behind DRAWIO_PREVIEW_UI

GET /shell/ fills the page template's {{CONFIG_JSON}} with the session,
the API token, where draw.io comes from and the host's editor settings,
with the classic page's security headers; /shell/<file> serves the built
files like the draw.io copy. start_session opens the shell when
DRAWIO_PREVIEW_UI=shell; the classic page stays the default.

* test(mcp-server): open the built shell with playwright

npm run test:e2e starts dist/index.js over the MCP SDK with
DRAWIO_PREVIEW_UI=shell, opens the URL start_session returns in headless
Chromium, and checks that the bundled draw.io loads without console
errors and that the theme toggle switches draw.io in place.

* ci: install the root dependencies before building the mcp package

The shell build needs the root's esbuild, Tailwind and React.

* feat(canvas): DiagramProvider exports with options and shows a transient document

requestExport(request, timeoutMs) runs one draw.io export with the
request's own parameters (a PNG's pageId and width) and resolves with its
answer (data, or xml for format "xml"); null when draw.io does not answer.
showTransient(xml) puts a document on the canvas for an export only: it is
not recorded, its autosaves are ignored, and the next loadDiagram brings
the user back to the page they were on. The MCP's canvas shell answers the
server's export requests with both.

* feat(mcp-server): the shell syncs with the server like the classic page

shell/mcp-sync-core.ts ports preview.js's protocol without the page or
React: polling GET /api/state every 2 s with the state id and version,
pushes of the user's edits with their base version and state, 409 and 413
handling, recovery of a recreated session (the tab's copy goes to History),
the token refresh after a 403, sync and export requests (PNG by page id,
SVG of another page through a transient one-page projection with autosave
ignored, the 600 ms wait for icons), thumbnails for History, and History
reads and restores that name the state the list belongs to.

A server write goes on the canvas as one undo step (loadDiagram commit,
with the change marked) when decideLoad finds it changes only the page on
screen, keeps the file variables and needs no load-only setting; the other
pages are compared with the MCP core's contentFingerprint. draw.io's own
re-serialized copy of such a write is recognized with isSameDocument and
not pushed back as an edit.

shell/use-mcp-sync.ts mounts the sync once inside DiagramProvider, reads
draw.io's autosave and load messages, and reports the connection state,
which the shell's status bar shows; notices come as toasts from the
dictionaries.

* test(mcp-server): port the preview protocol tests to the sync core

The recovery, thumbnail, size limit and History cases of
tests/unit/mcp-preview-recovery.test.ts, driven against mcp-sync-core.ts
with a stubbed server and canvas, plus the stale 409 recovery, an export
request answered once, a projection that ignores edits and restores the
document, the thumbnail of a write loaded before draw.io was ready, writes
taken in place as commits (draw.io's own copy is not pushed), and the
commit-or-load decision table of decideLoad.

* test(mcp-server): e2e of the shell's sync with the server

The shell connects, shows what create_new_diagram draws, marks an
edit_diagram change and takes it back with one Ctrl+Z (which get_diagram
then reflects), pushes a shape inserted in the editor, serves
screenshot_diagram, and shows another page only for its SVG export before
the user's page comes back.

* fix(mcp-server): review fixes for the shell's sync core

A recovery loads the server's diagram in full and waits for draw.io's
load report, as the classic page does: whether the canvas takes a write
in place is decided inside DiagramProvider, and an autosave of the
canvas being replaced went to the server as an edit when the decision
was wrong. A paper size change alone is pushed again: autosaves are
compared as documents only for draw.io's own copy of a committed write.
A projection stays on screen until draw.io reports the restore load
(5 s at most), the whole export ends in 15 s even when its result POST
hangs, and an export in flight is dropped when draw.io starts over. A
refused poll shows the tab offline. With several pages and the page on
screen unknown, a write loads in full. hasLoadOnlySettings and the
highlight of an AI change are shared with the editor bridge.

* fix(mcp-server): review fixes for the preview URL and the theme menu

The stalled-tab note names the page start_session opened (the shell
with DRAWIO_PREVIEW_UI=shell) instead of the classic page. With the
shell not built, start_session opens the classic page and the server
says so at startup, as it does for a missing dist/drawio. BROWSER=none
skips the system browser, as Vite and CRA dev servers do: the e2e tests
set it. Without a fixed DRAWIO_UI, the shell's draw.io URL carries
themes=1 so Extras > Theme is offered, as on the classic page.

* fix(mcp-server): review fixes for the shell's build

tsc -p shell checked nothing: the inherited exclude dropped every shell
file; the shell's tsconfig now excludes node_modules only. Tailwind scans
app-toaster.tsx too, so the sync notices look like the web app's toasts.
The bundle keeps its dependencies' license comments (esbuild appends them
at the end). postcss, which build-shell.mjs loads from the root, is a
declared devDependency.

* ci: run the shell's browser tests on the packed tarball, publish on shared code changes

The e2e job's first shard builds the MCP package, packs it, installs the
tarball in an empty directory and runs packages/mcp-server/tests/e2e
against the installed dist/index.js (MCP_SERVER_ENTRY), so a shell that
builds but fails in the browser, or a tarball missing a runtime
dependency, fails the PR. Its traces go with the Playwright report. The
publish workflow also triggers on the web app code the shell is bundled
from (components, contexts, hooks, lib, stores, globals.css) and the
root lockfile.

* fix(canvas): ignore autosaves of the document a pending full load replaces

An autosave that arrives after loadDiagram sent a full load, and before draw.io reports it, is of the canvas being replaced (draw.io reports each load, in order). Until now it overwrote chartXMLRef: in the MCP shell, a late autosave of a one-page projection replaced the cached document, and a remount of an external draw.io then put the projection on the canvas as the whole document.

* fix(mcp-server): review fixes for the shell's commits and export timeout

A write taken in place that leaves the canvas as it is gets no autosave from draw.io, so the marker for its copy stayed armed and ate the user's next paper-size edit: arm it only when the write changed the document. The 15 s export timeout now retires the export's sequence number, so a result POST that answers late no longer restores the document a second time.

* ci: create the tarball directory before npm pack

npm pack does not create its --pack-destination; on a fresh runner the step failed with ENOENT before the packed shell tests ran.

* feat(mcp-server): history entries name their maker, and /api/history the entry on the canvas

Each History entry records who made it (a user edit, a recovering tab's
copy, a restored copy; none for the AI's writes), when, and its page
count. GET /api/history now also returns each entry's XML, the first entry
with the same content (a restore adds a copy of an older one), the state
the ids belong to, and the entry whose content the canvas shows, by the
rule restore_version uses. The shell's version cards read these; the
classic page keeps using index, id and svg.

* feat(mcp-server): version cards in the shell from the server's History

The shell's VersionsSource (shell/node-versions-source.ts) reads GET
/api/history through the sync and shows one version per distinct content
(a restore's copy folds into the entry it copies; the blank page is none),
numbered as they first appeared, with what changed since the one before.
The entry the server says is on the canvas marks the version; undo and
redo of the newest version restore the one before it, or it again, through
POST /api/restore, so hand edits stay as versions of their own. The cards
sit in a panel beside the canvas (toggle in the header); the strip and
Compare come from the shared canvas components. The sync tells listeners
once per server version, and when History changed without one, so the
list is read again only then.

* test(mcp-server): e2e of the shell's version cards

In a session of its own: three writes give three cards; undoing the newest
restores the one before on the server too, redo brings it back, a hand edit
turns the undo into a restore, and restoring the first version keeps that
edit as a version of its own, which restore_version also finds.

* feat(mcp-server): get_selection reads the cells the user selected in the shell

The server asks the preview tab for the selection the way it asks for an export (a random request id in GET /api/state, the answer in a POST with that id, 10 s to answer). The shell answers through the editor bridge with each cell's id, label, an edge's ends, a shape's geometry and the page on screen; without a same-origin editor it says so, and the tool names the external draw.io. The classic page cannot answer, so the tool says that at once. screenshot_diagram's description now says a page selector renders that page without changing the page on screen (PNG exports by pageId already did).

* test(mcp-server): selection requests, get_selection texts, and page screenshots that leave the view alone

Unit tests for the request plumbing (random id, one answer, timeout), the tool's texts (cells, nothing selected, external draw.io) and the shell's side (one answer per request, after draw.io is up, never during a projection); the wiring test starts a session with BROWSER=none to see the no-tab message. Shell e2e: cells selected in the editor reach get_selection; a PNG of another page differs from the page on screen, which stays.

* feat(mcp-server): the canvas shell is the default preview, the classic page behind DRAWIO_PREVIEW_UI=classic

* docs: the MCP preview is the web app's canvas; get_selection, DRAWIO_BASE_URL and the classic page

* chore(mcp-server): version 0.4.0; check-package requires the draw.io version stamp

* ci: an mcp-shell job builds the package, runs its tests, the draw.io file guard and the shell e2e

* fix(mcp-server): review fixes for the version cards and History

The shell's version cards (shell/node-versions-source.ts):
- a version's change and undo target are the state it replaced, the
  History entry right before its first copy, not the card before it: after
  a restore those differ, and undo went to the wrong version (and not
  where restore_version steps_back=1 goes)
- a card restores the newest copy of its content, as restore_version
  does, so page settings the user changed (a "user" copy) are kept
- a blank page after a drawing is a clear of the canvas, a version of its
  own; only the blank page before any drawing is hidden
- numbers and changes are keyed by content, not by the first copy's id,
  so a version keeps them when its first copy drops out of the server's
  20-entry buffer; the caches start over for another server state (the
  process restarted: entry ids name other content)

The server's History (src/history.ts):
- firstCopyIds compares each entry with the first of every group only: a
  bare model matches any page name, so "same content" is not transitive,
  and a card could show one document and restore another
- the time and pages fields had no reader; pages parsed every XML once
  more on every write

Reading History (src/http-server.ts, shell/mcp-sync-core.ts):
- GET /api/state and a push's answer carry a History key (entry count,
  newest id, the entry on the canvas); the shell reads History again only
  when it changes, so a hand edit no longer downloads every entry's XML and
  thumbnail
- a failed History read is told again at the next poll
- a History list from a state the poll has not seen yet is dropped

* fix(mcp-server): review fixes for get_selection

Server (src/http-server.ts, src/index.ts, src/selection.ts,
src/new-diagram.ts):
- overlapping get_selection calls take turns (readSelection, one slot per
  session as the export slot) instead of replacing each other's request,
  which left one of them with a false "tab not in front" timeout
- an answer is taken only while a request is pending (both ids undefined
  compared equal)
- the tool sees the page start_session actually opens: with dist/shell
  missing the classic page is in use, which never answers, so the tool
  says so instead of timing out
- the result lists at most 100 cells and counts the rest: a whole large
  diagram selected would fill the model's context
- every <diagram> the model sends without an id gets one, so the page id
  the shell reports exists in the server's document

Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts,
contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts):
- an answer whose POST failed is sent again at the next poll
- no answer while a full load has yet to reach the editor: it still shows
  the previous document, whose cells and pages the answer would name
- a hidden tab (the same session open twice) answers a poll later, so the
  tab in front answers first; alone, it still answers within the timeout
- a cell's container is reported by the model's isLayer, not by comparing
  with the default parent, which is the group the user entered

* fix(mcp-server): review fixes for the shell page

- a download button in the header opens the web app's export dialog
  (.drawio, .png, .svg, .drawio.svg), which the classic page had and the
  shell lacked when it became the default
- the shell asks draw.io for the custom library menu (libraries=1), as
  the classic page did; the web app keeps libraries=0
- the newest card no longer shows "Rendering preview" for good: the sync
  takes the thumbnail of a diagram the server recovered from its file
  (saved without pictures) while the canvas kept it, and of a write whose
  picture was skipped because an edit came first, once the canvas shows
  the write again
- e2e: the get_selection test covers a shape in a container the user
  entered; a download test saves a .drawio file

* ci: the version bump reminder watches every path publish-mcp.yml does, and compares the version field

The reminder step left out the root lockfile and scripts/, which change
what is bundled into the shell and trigger the publish workflow; and it
took any change of packages/mcp-server/package.json (a dependency bump)
for a version bump. It now compares the version field between the two
commits.

* fix(mcp-server): get_selection lists every selected id, with detail for the first 100

* fix(mcp-server): a hidden tab's delayed selection answer checks the canvas again first

* fix(mcp-server): the version cards keep only the current History in memory, picture the newest copy, and keep a clear the buffer scrolled to

* fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart

* fix(mcp-server): the download waits for a page export's projection to end, and the dialog's styles are bundled

* fix(mcp-server): bundle open.html and js/open.js, the picker of Open Library from > Browser

* fix(mcp-server): the shell shows the versions once, in the side panel

* test(mcp-server): get_selection wiring test expects the classic page's answer when the shell is not built
This commit is contained in:
Dayuan Jiang
2026-10-11 21:27:11 +09:00
committed by GitHub
parent 07f3e3c2a3
commit 4c7b2cc0c5
88 changed files with 9740 additions and 561 deletions
+2 -1
View File
@@ -19,6 +19,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
## 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, 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.
- When the user refers to what they selected in the preview ("this box", "these arrows"), call get_selection: it returns the selected cells' ids, labels and page, ready for edit_diagram.
- 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).
@@ -28,7 +29,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
- 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.
- For artistic requests (a cat, a logo, a scene), compose the picture from standard shapes and connectors while keeping it clear.
- To clear the canvas to one blank page, call create_new_diagram with only the two root cells <mxCell id="0"/><mxCell id="1" parent="0"/>; the previous diagram stays in History.
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
- The preview keeps the last 20 versions (a snapshot before every AI change) and can restore any of them. You can make changes freely; nothing is lost.
- If the user asks to undo or go back, call restore_version instead of re-sending the earlier XML from memory.
## The XML you send
@@ -0,0 +1,5 @@
{
"version": "v32.0.2",
"sha256": "3cb8abec8e9bfc7504760c9cdc9194ecf7e8de178aa2a1d668801c32ecf1a1a7",
"downloadUrl": "https://github.com/jgraph/drawio/releases/download/v32.0.2/draw.war"
}
+81 -9
View File
@@ -9,16 +9,30 @@ import { isMxGraphModel } from "./pages.ts"
const MAX_HISTORY = 20
/**
* Who made an entry's diagram: an edit in the browser ("user"), a tab's
* copy kept when the server recovered the session ("recover"), a restored
* copy of an older entry ("restore"). The AI's writes and entries loaded
* from the saved file carry none.
*/
export type HistorySource = "user" | "recover" | "restore"
export interface HistoryEntry {
id: number // Stable across shifts of the circular buffer
xml: string
svg: string
source?: HistorySource
}
let nextEntryId = 0
const historyStore = new Map<string, HistoryEntry[]>()
export function addHistory(sessionId: string, xml: string, svg = ""): number {
export function addHistory(
sessionId: string,
xml: string,
svg = "",
source?: HistorySource,
): number {
let history = historyStore.get(sessionId)
if (!history) {
history = []
@@ -34,7 +48,12 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
return history.length - 1
}
history.push({ id: nextEntryId++, xml, svg })
history.push({
id: nextEntryId++,
xml,
svg,
...(source && { source }),
})
// Circular buffer
if (history.length > MAX_HISTORY) {
@@ -75,7 +94,7 @@ export function otherVersions(
const result: HistoryEntry[] = []
const history = getHistory(sessionId)
for (let i = history.length - 1; i >= 0; i--) {
const version = versionOf(history[i].xml)
const version = entryVersion(history[i])
if (kept.some((seen) => sameVersion(seen, version))) continue
kept.push(version)
result.push(history[i])
@@ -87,12 +106,57 @@ export function otherVersions(
export function isInHistory(sessionId: string, xml: string): boolean {
const version = versionOf(xml)
return getHistory(sessionId).some((entry) =>
sameVersion(version, versionOf(entry.xml)),
sameVersion(version, entryVersion(entry)),
)
}
/**
* The entry the canvas shows: the newest one with the content of xml (the
* rule of otherVersions), or null when the canvas is in none of them
*/
export function currentEntryId(sessionId: string, xml: string): number | null {
const version = versionOf(xml)
const history = getHistory(sessionId)
for (let i = history.length - 1; i >= 0; i--) {
if (sameVersion(version, entryVersion(history[i]))) {
return history[i].id
}
}
return null
}
/**
* For every entry, the id of the oldest entry with the same content (its
* own when no earlier one has it). A restore adds a copy of an older entry;
* the shell shows one version per content, where its first copy is, and
* restores the newest copy (as restore_version picks it). An entry is
* compared with the first of each group, never with later copies: a bare
* model matches any page name, so "same" is not transitive, and every
* entry must name a first copy that has its content.
*/
export function firstCopyIds(sessionId: string): Map<number, number> {
const result = new Map<number, number>()
const firsts: { version: Version; id: number }[] = []
for (const entry of getHistory(sessionId)) {
const version = entryVersion(entry)
let first = firsts.find((f) => sameVersion(f.version, version))
if (!first) {
first = { version, id: entry.id }
firsts.push(first)
}
result.set(entry.id, first.id)
}
return result
}
interface Version {
bare: boolean
named: string
cells: string
}
/** A diagram's fingerprints, with and without its page names */
function versionOf(xml: string) {
function versionOf(xml: string): Version {
return {
bare: isMxGraphModel(xml),
named: contentFingerprint(xml),
@@ -100,12 +164,20 @@ function versionOf(xml: string) {
}
}
// An entry's XML never changes: its fingerprints are computed once
const entryVersions = new WeakMap<HistoryEntry, Version>()
function entryVersion(entry: HistoryEntry): Version {
let version = entryVersions.get(entry)
if (!version) {
version = versionOf(entry.xml)
entryVersions.set(entry, version)
}
return version
}
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
// can send one) has no page name, so names count only when both have them
function sameVersion(
a: ReturnType<typeof versionOf>,
b: ReturnType<typeof versionOf>,
): boolean {
function sameVersion(a: Version, b: Version): boolean {
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
}
+557 -71
View File
@@ -3,10 +3,11 @@
* Serves draw.io embed with state sync and history UI
*/
import { randomUUID } from "node:crypto"
import { readFileSync } from "node:fs"
import { randomBytes, randomUUID } from "node:crypto"
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"
import http from "node:http"
import { dirname, join } from "node:path"
import { dirname, extname, join, posix, resolve, sep } from "node:path"
import { pipeline } from "node:stream"
import { fileURLToPath } from "node:url"
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
@@ -49,16 +50,123 @@ import { contentFingerprint } from "./edit-gate.ts"
import {
addHistory,
clearHistory,
currentEntryId,
firstCopyIds,
getHistory,
getHistoryEntry,
updateLastHistorySvg,
} from "./history.ts"
import { log } from "./logger.ts"
import { BLANK_MXFILE } from "./pages.ts"
import { parseSelectionAnswer, type SelectionAnswer } from "./selection.ts"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
// Configurable draw.io embed URL for private deployments. Set, it replaces
// the bundled copy (see drawioDir below).
export const DRAWIO_BASE_URL =
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
const EXTERNAL_DRAWIO = !!process.env.DRAWIO_BASE_URL
// Every /api request must carry this token in the X-Drawio-Token header.
// The page reads it from its HTML. Another website cannot add a custom
// header without a CORS preflight, which this server never grants, so its
// requests stop here whatever Host and Origin they manage to send.
const API_TOKEN = randomBytes(16).toString("hex")
export function getApiToken(): string {
return API_TOKEN
}
// The trimmed draw.io copy that scripts/fetch-drawio.mjs puts in dist/drawio,
// next to the compiled server; run from src with tsx, it is one level up
const HERE = dirname(fileURLToPath(import.meta.url))
let drawioDir: string | null =
[join(HERE, "drawio"), join(HERE, "../dist/drawio")].find((dir) =>
existsSync(join(dir, "index.html")),
) ?? null
let drawioVersion = readDrawioVersion(drawioDir)
/** For tests: serve draw.io from this directory (null: no bundled copy) */
export function setDrawioDir(dir: string | null): void {
drawioDir = dir
drawioVersion = readDrawioVersion(dir)
}
// The canvas shell (the web app's canvas without the chat), built by
// scripts/build-shell.mjs into dist/shell
let shellDir: string | null =
[join(HERE, "shell"), join(HERE, "../dist/shell")].find((dir) =>
existsSync(join(dir, "index.html")),
) ?? null
/** For tests: serve the shell from this directory (null: not built) */
export function setShellDir(dir: string | null): void {
shellDir = dir
}
// This package's version, stamped into the shell files' ETags (see
// readDrawioVersion for why)
const PACKAGE_VERSION: string = JSON.parse(
readFileSync(join(HERE, "../package.json"), "utf8"),
).version
/**
* The preview page start_session opens: the canvas shell (shell/, the web
* app's canvas), or with DRAWIO_PREVIEW_UI=classic the classic page
* (src/preview), kept for one more release.
*/
export type PreviewUi = "classic" | "shell"
export function previewUiFromEnv(
env: NodeJS.ProcessEnv = process.env,
): PreviewUi {
return (env.DRAWIO_PREVIEW_UI ?? "").toLowerCase() === "classic"
? "classic"
: "shell"
}
export const PREVIEW_UI: PreviewUi = previewUiFromEnv()
/**
* The page start_session actually opens: the classic page stands in while
* the shell is not built (a run from the sources), as embed.diagrams.net
* does for a missing dist/drawio
*/
export function activePreviewUi(ui: PreviewUi = PREVIEW_UI): PreviewUi {
return ui === "shell" && shellDir === null ? "classic" : ui
}
export function previewUrl(
port: number,
sessionId: string,
ui: PreviewUi = PREVIEW_UI,
): string {
const path = activePreviewUi(ui) === "shell" ? "/shell/" : ""
return `http://localhost:${port}${path}?mcp=${sessionId}`
}
/**
* The draw.io version the fetch script stamps into the copy. It is part of
* every file's ETag: an install that keeps the archive's dates (npm does
* not) would otherwise hand out the old ETag for a changed file of the
* same size after an upgrade.
*/
function readDrawioVersion(dir: string | null): string {
if (!dir) return ""
try {
return readFileSync(join(dir, ".version"), "utf8").trim()
} catch {
return ""
}
}
/**
* Whether the preview embeds draw.io from its own origin (the bundled copy),
* which lets the page call the editor directly. False with DRAWIO_BASE_URL,
* or when the bundled copy is missing.
*/
export function isSameOriginDrawio(): boolean {
return !EXTERNAL_DRAWIO && drawioDir !== null
}
// Extract origin (scheme + host + port) from URL for postMessage security check
function getOrigin(url: string): string {
@@ -149,8 +257,10 @@ interface SessionState {
exportFormat?: ExportFormat // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
exportId?: number // Number of the pending export, echoed with its result
exportId?: string // Random id of the pending export, echoed with its result
exportData?: string // Base64/SVG data returned by browser after export
selectionId?: string // Random id of the pending get_selection request
selection?: SelectionAnswer // The page's answer to it
}
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
@@ -225,6 +335,8 @@ export function setState(
exportOptions: existing?.exportOptions,
exportId: existing?.exportId,
exportData: existing?.exportData, // Preserve export result
selectionId: existing?.selectionId, // Preserve pending selection request
selection: existing?.selection,
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
if (notify) stateListener?.(sessionId, xml)
@@ -243,7 +355,7 @@ export function keepInHistory(sessionId: string, xml: string, svg = ""): void {
if (state && !state.userEdited && last && last.xml === state.serverXml) {
return
}
addHistory(sessionId, xml, svg)
addHistory(sessionId, xml, svg, state?.userEdited ? "user" : undefined)
}
/**
@@ -270,13 +382,13 @@ export function requestExport(
state.exportOptions = options
state.exportFormat = format
// The browser sends this back with the result, so a late result of an
// export that timed out is not taken for this one
state.exportId = ++lastExportId
// export that timed out is not taken for this one. Random rather than
// counted: a counter restarts with the process, and the page retries a
// result refused with 403 against the process that took over the port
state.exportId = randomUUID()
return true
}
let lastExportId = 0
export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (state) {
@@ -302,6 +414,95 @@ export async function waitForSync(
return false // Timeout
}
/**
* Ask the preview tab which cells the user has selected (the get_selection
* tool). Answered through the poll like an export: the tab sees selectionId
* in GET /api/state and POSTs its reading with that id. Returns false when
* the session is unknown.
*/
export function requestSelection(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (!state) return false
state.selection = undefined
// Random, as exportId: a late answer to an earlier request, or one meant
// for the process that had this port before, is not taken for this one
state.selectionId = randomUUID()
return true
}
/** The tab's answer to the pending selection request, or null in time */
export async function waitForSelection(
sessionId: string,
timeoutMs = 10000,
): Promise<SelectionAnswer | null> {
const start = Date.now()
let answer: SelectionAnswer | undefined
while (Date.now() - start < timeoutMs) {
// Re-read the store entry each tick: setState replaces it
answer = stateStore.get(sessionId)?.selection
if (answer) break
await new Promise((r) => setTimeout(r, 100))
}
const state = stateStore.get(sessionId)
if (state) {
state.selection = undefined
state.selectionId = undefined
}
if (!answer) log.warn(`Selection timeout for session=${sessionId}`)
return answer ?? null
}
// One selection slot per session, as the export slot: overlapping
// get_selection calls take turns instead of replacing each other's request
let selectionQueue: Promise<unknown> = Promise.resolve()
/** requestSelection + waitForSelection, one call at a time; null when the
* session is unknown or the tab does not answer in time */
export function readSelection(
sessionId: string,
timeoutMs = 10000,
): Promise<SelectionAnswer | null> {
const run = selectionQueue.then(() => {
if (!requestSelection(sessionId)) return null
return waitForSelection(sessionId, timeoutMs)
})
selectionQueue = run.catch(() => {})
return run
}
/**
* What the version cards depend on: History's entries (their count and the
* newest id) and the entry the canvas shows. The shell reads History again
* only when this changes, so a hand edit (a new version, the same History)
* costs no download of every entry
*/
function historyKeyOf(sessionId: string, state?: SessionState): string {
const history = getHistory(sessionId)
const current =
state && history.length > 0
? currentEntryId(sessionId, state.xml)
: null
return `${history.length}:${history.at(-1)?.id ?? -1}:${current}`
}
/** POST /api/state with a selection: the tab's answer to requestSelection */
function handleSelectionResult(
sessionId: string,
data: { selectionId?: unknown; selection?: unknown },
): void {
const state = stateStore.get(sessionId)
// Only the pending request's answer (none pending: none is taken)
if (!state?.selectionId || data.selectionId !== state.selectionId) {
log.debug(`Ignored a late selection answer for session=${sessionId}`)
return
}
const answer = parseSelectionAnswer(data.selection)
if (!answer) return
state.selection = answer
state.selectionId = undefined
log.debug(`Selection received for session=${sessionId}`)
}
export function startHttpServer(port = 6002): Promise<number> {
return new Promise((resolve, reject) => {
if (server) {
@@ -335,6 +536,16 @@ export function startHttpServer(port = 6002): Promise<number> {
server.listen(port, "127.0.0.1", () => {
serverPort = port
log.info(`HTTP server running on http://localhost:${port}`)
if (!EXTERNAL_DRAWIO && drawioDir === null) {
log.warn(
"No bundled draw.io (dist/drawio missing); the preview loads it from embed.diagrams.net",
)
}
if (PREVIEW_UI === "shell" && activePreviewUi() === "classic") {
log.warn(
"The canvas shell is not built (dist/shell missing); start_session opens the classic page",
)
}
resolve(port)
})
})
@@ -425,7 +636,10 @@ function routeRequest(
if (requestOrigin === `http://localhost:${serverPort}`) {
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, X-Drawio-Token",
)
}
if (req.method === "OPTIONS") {
@@ -434,30 +648,43 @@ function routeRequest(
return
}
if (url.pathname.startsWith("/drawio/")) {
serveStaticFile(
req,
res,
drawioDir,
url.pathname.slice("/drawio/".length),
drawioVersion,
)
return
}
if (url.pathname === "/shell") {
res.writeHead(302, { Location: `/shell/${url.search}` })
res.end()
return
}
if (url.pathname.startsWith("/shell/")) {
const rest = url.pathname.slice("/shell/".length)
if (rest === "" || rest === "index.html") {
servePage(req, res, url, "/shell/", getShellPage)
} else {
serveStaticFile(req, res, shellDir, rest, PACKAGE_VERSION)
}
return
}
if (
url.pathname.startsWith("/api/") &&
req.headers["x-drawio-token"] !== API_TOKEN
) {
res.writeHead(403)
res.end("Forbidden")
return
}
if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
if (sessionId && !isValidSessionId(sessionId)) {
res.writeHead(400)
res.end("Invalid session id")
return
}
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, {
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
})
res.end()
return
}
}
ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId))
servePage(req, res, url, "/", getHtmlPage)
} else if (url.pathname === "/api/state") {
handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") {
@@ -472,6 +699,60 @@ function routeRequest(
}
}
/**
* A preview page (the classic one at "/", the shell at "/shell/") for the
* session in ?mcp=<id>; without one, the most recent session's.
*/
function servePage(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
pagePath: string,
render: (sessionId: string) => string | null,
): void {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
const sessionId = url.searchParams.get("mcp") || ""
if (sessionId && !isValidSessionId(sessionId)) {
res.writeHead(400)
res.end("Invalid session id")
return
}
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, {
Location: `${pagePath}?mcp=${encodeURIComponent(recentSessionId)}`,
})
res.end()
return
}
}
const html = render(sessionId)
if (html === null) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
res.end(
"The canvas shell is not built (dist/shell is missing); run npm run build in packages/mcp-server",
)
return
}
ensureSessionStateInitialized(sessionId)
// The page holds this process's token: never served from a cache
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
...HTML_SECURITY_HEADERS,
})
res.end(req.method === "HEAD" ? undefined : html)
}
function handleStateApi(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -501,6 +782,8 @@ function handleStateApi(
exportXml: state?.exportXml || null,
exportOptions: state?.exportOptions || null,
exportId: state?.exportId ?? null,
selectionId: state?.selectionId ?? null,
historyKey: historyKeyOf(sessionId, state),
}),
)
} else if (req.method === "POST") {
@@ -538,6 +821,14 @@ function handleStateApi(
return
}
// The tab is answering a selection request (get_selection)
if (data.selection !== undefined) {
handleSelectionResult(sessionId, data)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
return
}
// A push can come before the tab's first poll after a
// restart: recover the saved file first, so it is compared
// with that and never overwrites it unseen
@@ -569,7 +860,12 @@ function handleStateApi(
!!data.xml &&
data.xml !== current.xml
if (saved) {
addHistory(sessionId, data.xml, data.svg || "")
addHistory(
sessionId,
data.xml,
data.svg || "",
"recover",
)
// Saved with the History, as after any change
stateListener?.(sessionId, current.xml)
}
@@ -606,7 +902,7 @@ function handleStateApi(
} else if (typeof data.xml === "string" && data.xml) {
// A user edit lost the race with an AI write. Keep
// it in history so the user can restore it.
addHistory(sessionId, data.xml, data.svg || "")
addHistory(sessionId, data.xml, data.svg || "", "user")
savedToHistory = true
if (current) stateListener?.(sessionId, current.xml)
}
@@ -630,7 +926,13 @@ function handleStateApi(
const saved = stateStore.get(sessionId)
if (saved && data.source !== "sync") saved.userEdited = true
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
res.end(
JSON.stringify({
success: true,
version,
historyKey: historyKeyOf(sessionId, saved),
}),
)
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
@@ -660,7 +962,12 @@ function handleHistoryApi(
return
}
// The classic page reads index, id and svg; the shell's version cards
// the rest: the document, who made it, which earlier entry it is a copy
// of, the state the ids belong to, and the entry the canvas shows
const history = getHistory(sessionId)
const firstIds = firstCopyIds(sessionId)
const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
@@ -668,8 +975,13 @@ function handleHistoryApi(
index: i,
id: entry.id,
svg: entry.svg,
xml: entry.xml,
source: entry.source ?? null,
firstId: firstIds.get(entry.id),
})),
count: history.length,
stateId: state?.stateId ?? null,
currentId: state ? currentEntryId(sessionId, state.xml) : null,
}),
)
}
@@ -698,7 +1010,7 @@ export function restoreHistoryEntry(
keepInHistory(sessionId, current.xml, current.svg)
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
addHistory(sessionId, entry.xml, entry.svg, "restore")
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
return newVersion
@@ -795,8 +1107,121 @@ function handleHistorySvgApi(
})
}
// Pages may be framed only by this origin, and nothing is content-sniffed
const HTML_SECURITY_HEADERS = {
"Content-Security-Policy": "frame-ancestors 'self'",
"X-Content-Type-Options": "nosniff",
}
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".gif": "image/gif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".txt": "text/plain; charset=utf-8",
".xml": "text/xml; charset=utf-8",
".json": "application/json",
".ico": "image/x-icon",
".wasm": "application/wasm",
}
/**
* GET /drawio/<path> or /shell/<path>: a file of the bundled draw.io copy or
* of the built shell. `stamp` names the files' version in their ETags.
*/
function serveStaticFile(
req: http.IncomingMessage,
res: http.ServerResponse,
dir: string | null,
rawPath: string,
stamp: string,
): void {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
let rel: string
try {
rel = decodeURIComponent(rawPath)
} catch {
res.writeHead(400)
res.end("Bad Request")
return
}
// One normalized path inside the directory; the war's server-side parts
// are never served, whatever was extracted
const normalized = posix.normalize(rel)
const file = dir ? resolve(dir, normalized) : null
if (
!dir ||
!file ||
/[\\\0]/.test(rel) ||
/(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) ||
!file.startsWith(dir + sep)
) {
res.writeHead(404)
res.end("Not Found")
return
}
let size: number
let etag: string
try {
const stat = statSync(file)
if (!stat.isFile()) throw new Error("not a file")
size = stat.size
etag = `"${stamp}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
} catch {
res.writeHead(404)
res.end("Not Found")
return
}
// The file names do not change between draw.io versions, so the browser
// may keep a copy but asks before using it (a 304 from localhost is
// cheap); after a package upgrade the next preview gets the new files
const cacheHeaders = {
ETag: etag,
"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff",
}
if (req.headers["if-none-match"] === etag) {
res.writeHead(304, cacheHeaders)
res.end()
return
}
const ext = extname(file).toLowerCase()
res.writeHead(200, {
...cacheHeaders,
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
"Content-Length": size,
...(ext === ".html" ? HTML_SECURITY_HEADERS : {}),
})
if (req.method === "HEAD") {
res.end()
return
}
// pipeline closes both ends when either fails or goes away (a tab closed
// mid-download), so no file handle leaks and no error goes unhandled; a
// client that left early is routine, not worth a log line
pipeline(createReadStream(file), res, () => {})
}
/** Where the iframe loads the editor from (without its query) */
function drawioEditorUrl(): string {
return isSameOriginDrawio()
? "/drawio/index.html"
: `${normalizeUrl(DRAWIO_BASE_URL)}/`
}
// The preview page lives in src/preview (the build copies it to dist/preview)
const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview")
const PREVIEW_DIR = join(HERE, "preview")
let previewTemplate: string | null = null
function loadPreviewTemplate(): string {
@@ -810,47 +1235,108 @@ function loadPreviewTemplate(): string {
return previewTemplate
}
/** A JSON string literal that is safe inside a <script> element */
const scriptJson = (value: string) =>
/** JSON that is safe inside a <script> element ("<" escaped) */
const scriptJson = (value: unknown) =>
JSON.stringify(value).replace(/</g, "\\u003c")
/**
* The configurable tail of the draw.io iframe query: dark mode, and the
* language and theme when the host config fixes them (DRAWIO_DARK,
* DRAWIO_LANG, DRAWIO_UI). lang and ui are left out unless set, because
* draw.io hides its Extras > Language / Theme submenu once they are given.
* The editor settings the host config fixes: theme (DRAWIO_UI), language
* (DRAWIO_LANG) and dark mode (DRAWIO_DARK). ui and lang are "" unless set,
* because draw.io hides its Extras > Language / Theme submenu once they
* are given.
*/
function hostDrawioSettings(env: NodeJS.ProcessEnv): {
ui: string
lang: string
dark: "dark" | "light" | "auto"
} {
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
return {
ui: isDrawioTheme(ui) ? ui : "",
lang: /^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang) ? lang : "",
dark: ["1", "true", "dark"].includes(dark)
? "dark"
: ["0", "false", "light"].includes(dark)
? "light"
: // draw.io takes ui=dark as dark mode only when no dark
// parameter is present, and the pages always send one
!dark && ui === "dark"
? "dark"
: "auto",
}
}
/**
* The configurable tail of the classic page's draw.io iframe query: dark
* mode, and the language and theme when the host config fixes them.
*/
export function drawioEmbedParams(
env: NodeJS.ProcessEnv = process.env,
): string {
const settings = hostDrawioSettings(env)
const params = new URLSearchParams()
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
if (["1", "true", "dark"].includes(dark)) {
params.set("dark", "1")
} else if (["0", "false", "light"].includes(dark)) {
params.set("dark", "0")
} else {
// draw.io takes ui=dark as dark mode only when no dark parameter is
// present, and this page always sends one
params.set("dark", !dark && ui === "dark" ? "1" : "auto")
}
if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
if (isDrawioTheme(ui)) params.set("ui", ui)
params.set(
"dark",
settings.dark === "auto"
? "auto"
: settings.dark === "dark"
? "1"
: "0",
)
if (settings.lang) params.set("lang", settings.lang)
if (settings.ui) params.set("ui", settings.ui)
return params.toString()
}
/**
* What the shell page gets as window.__MCP_CONFIG__ (shell/runtime-config.ts
* reads it): the session, the API token, where draw.io comes from and the
* host's editor settings.
*/
export function shellConfig(
sessionId: string,
env: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const settings = hostDrawioSettings(env)
return {
sessionId,
token: API_TOKEN,
apiBase: "/api",
drawioBaseUrl: drawioEditorUrl(),
drawioUi: settings.ui,
drawioLang: settings.lang,
drawioDark: settings.dark,
lang: env.DRAWIO_LANG ?? "",
}
}
/** The shell page, or null when the shell is not built */
function getShellPage(sessionId: string): string | null {
if (!shellDir) return null
const template = readFileSync(join(shellDir, "index.html"), "utf8")
return template.replace("{{CONFIG_JSON}}", () =>
scriptJson(shellConfig(sessionId)),
)
}
function getHtmlPage(sessionId: string): string {
return loadPreviewTemplate()
.replace("{{SESSION_BADGE}}", () =>
sessionId
? `<span class="session">${sessionId.slice(-8)}</span>`
: "",
)
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
return (
loadPreviewTemplate()
.replace("{{SESSION_BADGE}}", () =>
sessionId
? `<span class="session">${sessionId.slice(-8)}</span>`
: "",
)
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
.replace("{{DRAWIO_URL}}", () => drawioEditorUrl())
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
// Same origin: the page fills in its own origin
.replace("{{ORIGIN_JSON}}", () =>
scriptJson(isSameOriginDrawio() ? "" : DRAWIO_ORIGIN),
)
.replace("{{TOKEN_JSON}}", () => scriptJson(API_TOKEN))
)
}
+114 -8
View File
@@ -48,13 +48,18 @@ import {
otherVersions,
} from "./history.ts"
import {
activePreviewUi,
DRAWIO_BASE_URL,
type ExportFormat,
type ExportOptions,
getServerPort,
getState,
isSameOriginDrawio,
keepInHistory,
onSessionRecreate,
onStateChange,
previewUrl,
readSelection,
requestExport,
requestSync,
restoreHistoryEntry,
@@ -89,6 +94,7 @@ import {
wrapCellsInModel,
} from "./pages.ts"
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
import { describeSelection } from "./selection.ts"
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
import { XML_REFERENCE } from "./xml-reference.ts"
@@ -119,7 +125,9 @@ onSessionRecreate((sessionId) => {
const loaded = parseDrawioFileContent(saved)
// History comes back with the diagram (addHistory drops consecutive
// duplicates)
for (const x of autosaver.loadHistory(sessionId)) addHistory(sessionId, x)
for (const e of autosaver.loadHistory(sessionId)) {
addHistory(sessionId, e.xml, "", e.source)
}
return loaded.ok ? loaded.xml : saved
})
@@ -176,6 +184,7 @@ 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.
- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry; also used to draw a large diagram in parts.
- get_diagram: read the current XML, including the user's manual edits.
- get_selection: the cells the user has selected in the preview, when they say "this box" or "these arrows".
- screenshot_diagram: see the rendered diagram as an image.
- load_diagram, export_diagram: open or save .drawio or .drawio.svg files and export .png or .svg. Use absolute paths.
- list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=<id>.
@@ -379,9 +388,10 @@ registerWriteTool(
lastSeenXml: "",
}
// Open browser
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
await open(browserUrl)
// Open the browser. BROWSER=none (the convention of CRA and Vite
// dev servers) skips it: the e2e tests and headless hosts set it
const browserUrl = previewUrl(port, sessionId)
if (process.env.BROWSER !== "none") await open(browserUrl)
// A saved diagram comes back from its file. lastSeenXml stays
// empty, so edit_diagram asks for get_diagram first.
@@ -407,6 +417,12 @@ registerWriteTool(
? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue this diagram in a later conversation, call start_session with session_id=${sessionId}. list_saved_diagrams lists older diagrams.`
: ""
const rulesNote = `\n\nYour own drawing rules: write them in ${customInstructionsPath()} (Markdown, up to 5000 characters, read on every call).`
// The bundled draw.io is served from the preview's own origin;
// an external one (DRAWIO_BASE_URL, or a build without the
// copy) cannot be reached by the page's scripts
const originNote = isSameOriginDrawio()
? ""
: `\n\nThe preview loads draw.io from ${DRAWIO_BASE_URL} (another origin): same-origin editor features are off.`
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
@@ -414,7 +430,7 @@ registerWriteTool(
content: [
{
type: "text",
text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`,
text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${originNote}${saveNote}${rulesNote}\n\n${guideText()}`,
},
],
}
@@ -619,7 +635,9 @@ To clear the canvas to one blank page, send only the two root cells <mxCell id="
: `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
// The page on screen: the new document's first page after a
// full load, or the page the user was viewing when the write
// went on the canvas in place (both are the model's own work)
if (screenshot ?? config.autoScreenshot) {
const shot = await captureScreenshot(
currentSession.id,
@@ -1218,6 +1236,94 @@ server.registerTool(
},
)
// Tool: get_selection
server.registerTool(
"get_selection",
{
title: "Get selection",
description:
"Return the cells the user has selected in the preview: their ids and labels, for edges the source and target ids, for shapes the position and size, and the page they are on. " +
'Call this when the user refers to what they selected ("this box", "these arrows", "the selected shapes"), then act on the ids with edit_diagram. ' +
"Says when nothing is selected. Needs the preview tab to be open.",
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
async () => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
const sessionId = currentSession.id
// The classic preview page never answers: it has no access to
// the editor (the shell reads it through the same-origin frame).
// It is also what opens while the shell is not built
if (activePreviewUi() !== "shell") {
return {
content: [
{
type: "text",
text: "The classic preview page cannot read the selection (DRAWIO_PREVIEW_UI=classic, or the canvas shell is not built: run npm run build in packages/mcp-server). Ask the user which shapes they mean, or call get_diagram.",
},
],
}
}
if (previewStalled(sessionId)) {
return previewStalledError(sessionId)
}
if (sessionState(sessionId)?.lastPolled === undefined) {
return {
content: [
{
type: "text",
text: "Error: The preview tab is not open, or has not connected yet.",
},
],
isError: true,
}
}
const answer = await readSelection(sessionId)
if (!answer) {
return {
content: [
{
type: "text",
text: "Error: Reading the selection timed out. Make sure the preview tab is open and in front.",
},
],
isError: true,
}
}
return {
content: [
{
type: "text",
text: describeSelection(
answer,
isSameOriginDrawio() ? null : DRAWIO_BASE_URL,
),
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("get_selection failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// The browser bridge has one export slot per session, so export requests
// run one at a time: a concurrent call waits for the previous one.
let exportQueue: Promise<unknown> = Promise.resolve()
@@ -1278,7 +1384,7 @@ function previewStalled(sessionId: string): boolean {
* 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}.`
return `The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (${previewUrl(getServerPort(), sessionId)}), ${next}.`
}
function previewStalledError(sessionId: string) {
@@ -1411,7 +1517,7 @@ server.registerTool(
description:
"Render the diagram in the preview and return it as a PNG image, so you can see your own result. " +
"Call this once after drawing or heavily editing a complex diagram, then fix overlaps and edges that cross shapes. " +
"Without a page selector it shows the page on screen. Needs the preview tab to be open.",
"Without a page selector it shows the page on screen; with one it renders that page without changing what the user sees. Needs the preview tab to be open.",
inputSchema: { ...pageSelectorSchema },
annotations: { readOnlyHint: true, openWorldHint: false },
},
+23 -2
View File
@@ -3,7 +3,14 @@
* and the web app's display_diagram tool.
*/
import { expandCompactCells } from "./compact-cells.ts"
import { hasCells, normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
import {
generatePageId,
hasCells,
normalizeToMxfile,
parseMxfile,
serializeMxfile,
wrapCellsInModel,
} from "./pages.ts"
import {
addDefaultStyles,
applyStyleClasses,
@@ -182,12 +189,26 @@ export function prepareNewDiagram(
error: `XML validation failed after expanding the cells - ${rewriteError}`,
}
}
const normalized = normalizeToMxfile(xml, page)
let normalized = normalizeToMxfile(xml, page)
if (!normalized) {
return {
ok: false,
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
}
}
// A <diagram> the model sent without an id gets one here. draw.io would
// otherwise make its own, which the server never sees, and get_selection
// would then name a page edit_diagram cannot find
const doc = parseMxfile(normalized)
if (doc) {
let added = false
doc.querySelectorAll("diagram").forEach((d) => {
if (!d.getAttribute("id")) {
d.setAttribute("id", generatePageId())
added = true
}
})
if (added) normalized = serializeMxfile(doc)
}
return { ok: true, xml: normalized, fixes }
}
+41 -11
View File
@@ -3,8 +3,9 @@
* diagram survives the MCP process (hosts start a new one when a
* conversation is resumed). Like the web app's IndexedDB sessions
* (lib/session-storage.ts): saved 1 second after the last change, at most
* 50 kept. The session's History (the XML of its last 20 versions, without
* the thumbnails) is saved next to it as <session-id>.history.json.
* 50 kept. The session's History (the XML of its last 20 versions and who
* made each, without the thumbnails) is saved next to it as
* <session-id>.history.json.
*/
import {
@@ -21,6 +22,7 @@ import {
import { homedir } from "node:os"
import { join } from "node:path"
import { contentFingerprint } from "./edit-gate.ts"
import type { HistorySource } from "./history.ts"
import { log } from "./logger.ts"
import { BLANK_MXFILE, hasCells } from "./pages.ts"
@@ -33,6 +35,16 @@ const isBlank = (xml: string) =>
const DELAY_MS = 1000
const MAX_FILES = 50
/** A History entry as the file keeps it */
export interface SavedHistoryEntry {
xml: string
source?: HistorySource
}
const SOURCES: readonly string[] = ["user", "recover", "restore"]
const isSource = (v: unknown): v is HistorySource =>
typeof v === "string" && SOURCES.includes(v)
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
export function expandHome(p: string): string {
if (p === "~") return homedir()
@@ -70,7 +82,7 @@ export class Autosaver {
// The session's History entries, saved next to its diagram
private history: (
sessionId: string,
) => { id: number; xml: string }[] = () => [],
) => { id: number; xml: string; source?: HistorySource }[] = () => [],
) {}
/** Path of a session's file, or null when saving is off. */
@@ -83,16 +95,28 @@ export class Autosaver {
return this.dir ? join(this.dir, `${sessionId}.history.json`) : null
}
/** The XML of the session's saved History entries, oldest first. */
loadHistory(sessionId: string): string[] {
/** The session's saved History entries, oldest first. */
loadHistory(sessionId: string): SavedHistoryEntry[] {
const path = this.historyPathFor(sessionId)
if (!path) return []
try {
const entries: unknown = JSON.parse(readFileSync(path, "utf-8"))
return Array.isArray(entries) &&
entries.every((e) => typeof e === "string")
? entries
: []
if (!Array.isArray(entries)) return []
const result: SavedHistoryEntry[] = []
for (const e of entries) {
// Files of earlier versions hold the XML strings alone
if (typeof e === "string") {
result.push({ xml: e })
} else if (e && typeof e.xml === "string") {
result.push({
xml: e.xml,
...(isSource(e.source) && { source: e.source }),
})
} else {
return []
}
}
return result
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
log.warn(`Could not read the saved History ${path}: ${error}`)
@@ -218,7 +242,9 @@ export class Autosaver {
// Saved with the diagram, after the same delay, so the History entry
// that follows every tool write is included. The thumbnails are left
// out (large); the History grid shows the entry's number instead.
// out (large); the History grid shows the entry's number instead. Who
// made an entry goes with it: the shell's cards tell a clear of the
// canvas from a restore of one by it.
private writeHistory(sessionId: string): void {
const path = this.historyPathFor(sessionId)
const entries = this.history(sessionId)
@@ -226,7 +252,11 @@ export class Autosaver {
const key = `${entries.length}:${entries[entries.length - 1].id}`
if (this.historyKeys.get(sessionId) === key) return
try {
const json = JSON.stringify(entries.map((e) => e.xml))
const saved: SavedHistoryEntry[] = entries.map((e) => ({
xml: e.xml,
...(e.source && { source: e.source }),
}))
const json = JSON.stringify(saved)
writeFileSync(`${path}.tmp`, json, "utf-8")
renameSync(`${path}.tmp`, path)
this.historyKeys.set(sessionId, key)
+4 -2
View File
@@ -40,7 +40,7 @@
</button>
</div>
</div>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
<iframe id="drawio" src="{{DRAWIO_URL}}?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
</div>
<div id="notice" role="status"></div>
<div id="history-modal">
@@ -85,7 +85,9 @@
</div>
<script>
const sessionId = {{SESSION_JSON}};
const DRAWIO_ORIGIN = {{ORIGIN_JSON}};
// Empty when draw.io is served from this origin
const DRAWIO_ORIGIN = {{ORIGIN_JSON}} || location.origin;
let API_TOKEN = {{TOKEN_JSON}};
</script>
<script>
{{SCRIPT}}
+27 -6
View File
@@ -1,4 +1,25 @@
const iframe = document.getElementById('drawio');
// Every API request carries the token the server wrote into this page. A 403
// means another MCP process (with its own token) now answers on this port:
// read its token from a fresh copy of this page and retry once, so the next
// poll can recover the session instead of being refused forever.
let tokenRefresh = null;
function refreshToken() {
if (!tokenRefresh) {
tokenRefresh = fetch(location.href, { cache: 'no-store' })
.then((r) => r.text())
.then((html) => { const m = html.match(/API_TOKEN = "([0-9a-f]+)"/); if (m) API_TOKEN = m[1]; })
.catch(() => {})
.finally(() => { tokenRefresh = null; });
}
return tokenRefresh;
}
async function api(url, init = {}) {
const send = () => fetch(url, { ...init, headers: { ...(init.headers || {}), 'X-Drawio-Token': API_TOKEN } });
let r = await send();
if (r.status === 403) { await refreshToken(); r = await send(); }
return r;
}
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
// The server state this tab is in step with (see stateId in http-server.ts);
// null until the first poll
@@ -82,7 +103,7 @@ window.addEventListener('message', (e) => {
// result: a poll answered before that still sees the
// request and would start the same export again.
const seq = msg.message.mcpExport;
fetch('/api/state', {
api('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
@@ -126,7 +147,7 @@ window.addEventListener('message', (e) => {
const t = thumbExport;
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
thumbExport = null;
fetch('/api/history-svg', {
api('/api/history-svg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
@@ -184,7 +205,7 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
if (!sessionId) return;
pushesInFlight.push(xml);
try {
const r = await fetch('/api/state', {
const r = await api('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
@@ -282,7 +303,7 @@ async function poll() {
if (!sessionId) return;
const seq = ++pollSeq;
try {
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
const r = await api('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
// An older answer than one already handled (the interval, the 409
@@ -469,7 +490,7 @@ historyBtn.onclick = async () => {
// A list for a state the server recreated meanwhile has old ids
const sid = stateId;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
const r = await api('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
if (sid !== stateId) return;
@@ -529,7 +550,7 @@ restoreBtn.onclick = async () => {
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring...';
try {
const r = await fetch('/api/restore', {
const r = await api('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
+124
View File
@@ -0,0 +1,124 @@
/**
* The cells the user has selected in the preview, as the page reads them
* from the editor (shell/use-mcp-sync.ts) and the get_selection tool
* reports them. The shape is shared with the shell; only plain data, so the
* server can take it from a POST as it is.
*/
export interface SelectedCellInfo {
id: string
/** The label as text, HTML removed and long ones cut */
label: string
edge: boolean
source?: string
target?: string
/** The cell's geometry as the XML has it (relative to its parent) */
geometry?: { x: number; y: number; width: number; height: number }
/** The container the cell is in, when it is not on the layer itself */
parent?: string
}
export interface SelectionAnswer {
/** The page on screen */
pageId: string | null
pageName: string | null
cells: SelectedCellInfo[]
/** The page cannot reach the editor (an external draw.io) */
unavailable?: boolean
}
/** A POST body's selection, if it has the shape above; else null */
export function parseSelectionAnswer(value: unknown): SelectionAnswer | null {
if (!value || typeof value !== "object") return null
const given = value as Record<string, unknown>
if (given.unavailable === true) {
return { pageId: null, pageName: null, cells: [], unavailable: true }
}
if (!Array.isArray(given.cells)) return null
const text = (v: unknown) => (typeof v === "string" ? v : null)
const cells: SelectedCellInfo[] = []
for (const raw of given.cells) {
if (!raw || typeof raw !== "object") continue
const c = raw as Record<string, unknown>
if (typeof c.id !== "string" || !c.id) continue
const cell: SelectedCellInfo = {
id: c.id,
label: text(c.label) ?? "",
edge: c.edge === true,
}
if (text(c.source)) cell.source = c.source as string
if (text(c.target)) cell.target = c.target as string
if (text(c.parent)) cell.parent = c.parent as string
const g = c.geometry as Record<string, unknown> | undefined
if (
g &&
typeof g === "object" &&
[g.x, g.y, g.width, g.height].every(
(n) => typeof n === "number" && Number.isFinite(n),
)
) {
cell.geometry = {
x: g.x as number,
y: g.y as number,
width: g.width as number,
height: g.height as number,
}
}
cells.push(cell)
}
return { pageId: text(given.pageId), pageName: text(given.pageName), cells }
}
/** describeSelection details at most this many cells; the rest by id only */
export const MAX_LISTED_CELLS = 100
/**
* The get_selection result text. `externalDrawio` names the draw.io origin
* when the preview loads it from another origin, where the page cannot
* read the editor. A selection of a whole large diagram would fill the
* model's context with labels and geometry: the first cells get them, the
* rest are listed by id alone (an edit on the selection needs every id).
*/
export function describeSelection(
answer: SelectionAnswer,
externalDrawio: string | null,
): string {
if (answer.unavailable) {
return externalDrawio
? `The preview loads draw.io from ${externalDrawio} (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.`
: "The preview could not read the editor's selection. Ask the user which shapes they mean, or call get_diagram."
}
const page = answer.pageName
? `page "${answer.pageName}"${answer.pageId ? ` (page_id="${answer.pageId}")` : ""}`
: "the page on screen"
if (answer.cells.length === 0) {
return `Nothing is selected on ${page}. Ask the user to select the shapes they mean in the preview, or call get_diagram.`
}
const listed = answer.cells.slice(0, MAX_LISTED_CELLS)
const lines = listed.map((cell) => {
const label = cell.label ? `"${cell.label}"` : "(no label)"
if (cell.edge) {
const ends =
cell.source || cell.target
? ` from ${cell.source ? `"${cell.source}"` : "nothing"} to ${cell.target ? `"${cell.target}"` : "nothing"}`
: " (not connected)"
return `- id="${cell.id}" edge ${label}${ends}`
}
const g = cell.geometry
const where = g
? ` at x=${g.x} y=${g.y} w=${g.width} h=${g.height}`
: ""
const parent = cell.parent ? ` inside "${cell.parent}"` : ""
return `- id="${cell.id}" shape ${label}${where}${parent}`
})
const rest = answer.cells.slice(MAX_LISTED_CELLS)
if (rest.length > 0) {
lines.push(
`- and ${rest.length} more cells, listed by id only: ${rest.map((c) => `"${c.id}"`).join(", ")}`,
)
}
const count =
answer.cells.length === 1 ? "1 cell" : `${answer.cells.length} cells`
const target = answer.pageId ? ` (page_id="${answer.pageId}")` : ""
return `${count} selected on ${page}:\n${lines.join("\n")}\n\nUse these ids with edit_diagram${target}; call get_diagram for their full XML.`
}