From 4c7b2cc0c5a5ac98ff6c9f3829689f68a2a8c561 Mon Sep 17 00:00:00 2001 From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:27:11 +0900 Subject: [PATCH] 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/ 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/ 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 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 --- .github/workflows/publish-mcp.yml | 27 +- .github/workflows/test.yml | 94 +- .gitignore | 2 + README.md | 2 + app/[lang]/layout.tsx | 5 +- biome.json | 1 + components/canvas/canvas-stage.tsx | 75 +- .../{chat => canvas}/compare-dialog.tsx | 18 +- components/canvas/locale-context.tsx | 29 + components/canvas/selection-ask.tsx | 6 +- components/canvas/version-card.tsx | 217 ++ components/{chat => canvas}/version-strip.tsx | 38 +- components/canvas/versions-context.tsx | 72 + components/chat/chat-engine.tsx | 5 + components/chat/chat-panel.tsx | 24 +- components/chat/chat-versions.tsx | 46 + components/chat/tool-activity.tsx | 237 +-- components/chat/use-versions.ts | 14 +- components/workspace/workspace.tsx | 15 +- contexts/diagram-context.tsx | 91 +- docs/cn/README_CN.md | 2 + docs/cn/offline-deployment.md | 2 +- docs/en/offline-deployment.md | 2 +- docs/ja/README_JA.md | 2 + docs/ja/offline-deployment.md | 2 +- lib/diagram-diff.ts | 8 +- lib/drawio/drawio-config.ts | 71 +- lib/drawio/editor-bridge.ts | 92 +- lib/i18n/dictionaries/en.json | 15 + lib/i18n/dictionaries/ja.json | 15 + lib/i18n/dictionaries/zh-Hant.json | 15 + lib/i18n/dictionaries/zh.json | 15 + lib/version-text.ts | 2 +- package-lock.json | 1 + package.json | 1 + packages/claude-plugin/README.md | 13 +- packages/mcp-server/README.md | 42 +- packages/mcp-server/drawio-files.txt | 40 + packages/mcp-server/package-lock.json | 4 +- packages/mcp-server/package.json | 6 +- packages/mcp-server/playwright.config.ts | 16 + packages/mcp-server/scripts/build-shell.mjs | 79 + .../mcp-server/scripts/check-drawio-files.mjs | 533 +++++ packages/mcp-server/scripts/check-package.mjs | 40 +- .../mcp-server/scripts/drawio-file-list.mjs | 78 + packages/mcp-server/scripts/fetch-drawio.mjs | 132 ++ packages/mcp-server/shell/canvas-shell.tsx | 159 ++ packages/mcp-server/shell/dictionary.ts | 46 + packages/mcp-server/shell/index.html | 31 + packages/mcp-server/shell/main.tsx | 28 + packages/mcp-server/shell/mcp-sync-core.ts | 964 +++++++++ .../mcp-server/shell/node-versions-source.ts | 308 +++ packages/mcp-server/shell/runtime-config.ts | 64 + packages/mcp-server/shell/shell.css | 20 + packages/mcp-server/shell/theme.ts | 61 + packages/mcp-server/shell/tsconfig.json | 10 + packages/mcp-server/shell/use-mcp-sync.ts | 132 ++ packages/mcp-server/shell/versions-panel.tsx | 50 + packages/mcp-server/src/drawing-guide.ts | 3 +- packages/mcp-server/src/drawio-version.json | 5 + packages/mcp-server/src/history.ts | 90 +- packages/mcp-server/src/http-server.ts | 628 +++++- packages/mcp-server/src/index.ts | 122 +- packages/mcp-server/src/new-diagram.ts | 25 +- packages/mcp-server/src/persistence.ts | 52 +- packages/mcp-server/src/preview/index.html | 6 +- packages/mcp-server/src/preview/preview.js | 33 +- packages/mcp-server/src/selection.ts | 124 ++ packages/mcp-server/tests/e2e/shell.spec.ts | 582 ++++++ packages/mcp-server/tests/history.test.ts | 87 + packages/mcp-server/tests/http-server.test.ts | 724 ++++++- packages/mcp-server/tests/persistence.test.ts | 74 +- packages/mcp-server/tests/selection.test.ts | 165 ++ .../mcp-server/tests/server-wiring.test.ts | 60 +- packages/mcp-server/tests/wrap-cells.test.ts | 18 + scripts/drawio-zip.mjs | 67 + scripts/fetch-drawio.mjs | 59 +- stores/canvas-store.ts | 4 + stores/versions-store.ts | 26 +- tests/unit/canvas-import-boundary.test.ts | 162 ++ tests/unit/diagram-context-exports.test.tsx | 207 ++ tests/unit/mcp-node-versions.test.tsx | 693 +++++++ tests/unit/mcp-preview-history.test.ts | 1 + tests/unit/mcp-preview-recovery.test.ts | 60 +- tests/unit/mcp-shell-config.test.ts | 160 ++ tests/unit/mcp-sync-core.test.ts | 1792 +++++++++++++++++ tests/unit/redesign-helpers.test.ts | 10 +- tests/unit/version-card.test.tsx | 143 ++ 88 files changed, 9740 insertions(+), 561 deletions(-) rename components/{chat => canvas}/compare-dialog.tsx (92%) create mode 100644 components/canvas/locale-context.tsx create mode 100644 components/canvas/version-card.tsx rename components/{chat => canvas}/version-strip.tsx (69%) create mode 100644 components/canvas/versions-context.tsx create mode 100644 components/chat/chat-versions.tsx create mode 100644 packages/mcp-server/drawio-files.txt create mode 100644 packages/mcp-server/playwright.config.ts create mode 100644 packages/mcp-server/scripts/build-shell.mjs create mode 100644 packages/mcp-server/scripts/check-drawio-files.mjs create mode 100644 packages/mcp-server/scripts/drawio-file-list.mjs create mode 100644 packages/mcp-server/scripts/fetch-drawio.mjs create mode 100644 packages/mcp-server/shell/canvas-shell.tsx create mode 100644 packages/mcp-server/shell/dictionary.ts create mode 100644 packages/mcp-server/shell/index.html create mode 100644 packages/mcp-server/shell/main.tsx create mode 100644 packages/mcp-server/shell/mcp-sync-core.ts create mode 100644 packages/mcp-server/shell/node-versions-source.ts create mode 100644 packages/mcp-server/shell/runtime-config.ts create mode 100644 packages/mcp-server/shell/shell.css create mode 100644 packages/mcp-server/shell/theme.ts create mode 100644 packages/mcp-server/shell/tsconfig.json create mode 100644 packages/mcp-server/shell/use-mcp-sync.ts create mode 100644 packages/mcp-server/shell/versions-panel.tsx create mode 100644 packages/mcp-server/src/drawio-version.json create mode 100644 packages/mcp-server/src/selection.ts create mode 100644 packages/mcp-server/tests/e2e/shell.spec.ts create mode 100644 packages/mcp-server/tests/selection.test.ts create mode 100644 scripts/drawio-zip.mjs create mode 100644 tests/unit/canvas-import-boundary.test.ts create mode 100644 tests/unit/diagram-context-exports.test.tsx create mode 100644 tests/unit/mcp-node-versions.test.tsx create mode 100644 tests/unit/mcp-shell-config.test.ts create mode 100644 tests/unit/mcp-sync-core.test.ts create mode 100644 tests/unit/version-card.test.tsx diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 6b816c45..d3061404 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -1,7 +1,8 @@ name: Publish MCP Server # Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing -# (no token, no OTP). Triggers when packages/mcp-server changes on main; +# (no token, no OTP). Triggers when packages/mcp-server or the web app code +# bundled into the canvas shell (scripts/build-shell.mjs) changes on main; # skips silently if the package.json version is already on npm — so a # release is just "bump the version in a PR and merge". on: @@ -10,6 +11,16 @@ on: - main paths: - "packages/mcp-server/**" + # The canvas shell is bundled from these with the root's lockfile + - "components/**" + - "contexts/**" + - "hooks/**" + - "lib/**" + - "stores/**" + - "app/globals.css" + - "package-lock.json" + # The package's build scripts import the root's scripts/drawio-zip.mjs + - "scripts/**" workflow_dispatch: permissions: @@ -35,7 +46,9 @@ jobs: with: node-version: 24 cache: "npm" - cache-dependency-path: packages/mcp-server/package-lock.json + cache-dependency-path: | + package-lock.json + packages/mcp-server/package-lock.json registry-url: "https://registry.npmjs.org" # Trusted publishing requires npm >= 11.5.1 @@ -54,6 +67,13 @@ jobs: echo "publish=true" >> "$GITHUB_OUTPUT" fi + # The canvas shell (scripts/build-shell.mjs) is bundled from the web + # app's components with the root's esbuild, Tailwind and React + - name: Install repository dependencies + if: steps.version.outputs.publish == 'true' + working-directory: . + run: npm ci + - name: Install dependencies if: steps.version.outputs.publish == 'true' run: npm ci @@ -62,6 +82,9 @@ jobs: if: steps.version.outputs.publish == 'true' run: npm test + # The build downloads the pinned draw.war from GitHub releases + # (scripts/fetch-drawio.mjs) and bundles a trimmed draw.io into + # dist/drawio; check-package confirms it is in the tarball - name: Build and check package contents if: steps.version.outputs.publish == 'true' run: npm run build && npm run check-package diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc2d7798..0645bff7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,20 +28,104 @@ jobs: - name: Run unit tests run: npm run test -- --run - # The MCP server package ships its own vitest because its DOM polyfill - # (linkedom) needs `environment: node`, while the root vitest uses jsdom - # for the Next.js app. Install + run its tests separately so CI catches - # multi-page mxfile regressions. + # The MCP server package: its own vitest (its DOM polyfill, linkedom, needs + # `environment: node`, while the root vitest uses jsdom for the Next.js + # app), the npm package as it is built, the draw.io file list, and the + # canvas shell in a browser. + mcp-shell: + name: MCP Server & Shell + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + # HEAD^1 is the base branch for the version bump reminder below + fetch-depth: 2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: | + package-lock.json + packages/mcp-server/package-lock.json + + # The shell is bundled from the web app's components with the root's + # esbuild, Tailwind and React; Playwright comes from the root too + - name: Install dependencies + run: npm ci + - name: Install MCP server dependencies run: npm --prefix packages/mcp-server ci + - name: Cache Playwright browsers + uses: actions/cache@v5 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + + - name: Install Playwright deps (cached) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + - name: Run MCP server unit tests run: npm --prefix packages/mcp-server test - # Tests run from src/, so check the built npm package separately + # Tests run from src/, so check the built npm package separately. The + # build downloads the pinned draw.war and bundles a trimmed draw.io. - name: Build MCP server and check package contents run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package + # The trimmed draw.io is cut from this list; the guard drives the full + # copy (public/drawio) in Chromium and fails when the editor requests a + # file the list does not have + - name: Download the full draw.io + run: node scripts/fetch-drawio.mjs + + - name: Check the MCP draw.io file list + run: npm --prefix packages/mcp-server run check-drawio + + # The canvas shell in a browser, from the package as npm installs it: + # the packed tarball goes into an empty directory, its dist/index.js is + # started over stdio and headless Chromium opens the URL start_session + # returns (packages/mcp-server/tests/e2e) + - name: Run the MCP shell E2E tests on the packed tarball + run: | + mkdir -p "$RUNNER_TEMP/mcp-pack" "$RUNNER_TEMP/mcp-install" + npm pack ./packages/mcp-server --pack-destination "$RUNNER_TEMP/mcp-pack" + npm --prefix "$RUNNER_TEMP/mcp-install" install "$RUNNER_TEMP"/mcp-pack/*.tgz + MCP_SERVER_ENTRY="$RUNNER_TEMP/mcp-install/node_modules/@next-ai-drawio/mcp-server/dist/index.js" \ + npm --prefix packages/mcp-server run test:e2e + env: + CI: true + + # The shell bundles the web app's canvas and the root's dependencies, + # so a change under these paths (the ones publish-mcp.yml triggers on) + # reaches users with the next MCP release, and publish-mcp.yml only + # publishes a new version number + - name: Remind to bump the MCP version when bundled code changed + if: github.event_name == 'pull_request' + run: | + changed=$(git diff --name-only HEAD^1 HEAD -- components contexts hooks lib stores app/globals.css package-lock.json scripts packages/mcp-server) + base_version=$(git show HEAD^1:packages/mcp-server/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") + head_version=$(node -p "require('./packages/mcp-server/package.json').version") + if [ -n "$changed" ] && [ "$base_version" = "$head_version" ]; then + echo "::warning::Code bundled into the MCP server changed without a version bump in packages/mcp-server/package.json (still ${head_version}). Bump it if the change should be published." + fi + + - name: Upload test results + uses: actions/upload-artifact@v6 + if: always() + with: + name: playwright-report-mcp-shell + path: packages/mcp-server/test-results/ + retention-days: 7 + e2e: # Six jobs at once, each with a sixth of the tests and one worker: the # same conditions per test, a fraction of the time diff --git a/.gitignore b/.gitignore index 0e8010a0..6893a058 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ packages/*/dist /coverage /playwright-report/ /test-results/ +packages/*/playwright-report/ +packages/*/test-results/ # next.js /.next/ diff --git a/README.md b/README.md index bc9731ea..ba378c38 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features: - The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more) +- The preview is the same canvas as the web app: the AI's changes are outlined, one Ctrl+Z takes them back, and the AI can read the shapes you select +- The draw.io editor ships inside the package, so after install the preview works offline (`DRAWIO_BASE_URL` points it at an external draw.io instead) - A screenshot tool, so the AI can look at the rendered diagram and fix it - Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg` - Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx index bc2fd112..2e09e6b9 100644 --- a/app/[lang]/layout.tsx +++ b/app/[lang]/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next" import { Instrument_Sans, JetBrains_Mono } from "next/font/google" import { notFound } from "next/navigation" import Script from "next/script" +import { LocaleProvider } from "@/components/canvas/locale-context" import { AppToaster } from "@/components/workspace/app-toaster" import { DiagramProvider } from "@/contexts/diagram-context" import { DictionaryProvider } from "@/hooks/use-dictionary" @@ -184,7 +185,9 @@ export default async function RootLayout({ className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`} > - {children} + + {children} + diff --git a/biome.json b/biome.json index bf56b8b0..b5c2eeff 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,7 @@ "**", "!public", "!packages/mcp-server/src/preview", + "!packages/mcp-server/shell/index.html", "!lib/model-catalog.json" ] }, diff --git a/components/canvas/canvas-stage.tsx b/components/canvas/canvas-stage.tsx index 2cb1617e..93c616e9 100644 --- a/components/canvas/canvas-stage.tsx +++ b/components/canvas/canvas-stage.tsx @@ -1,11 +1,14 @@ -import { useParams } from "next/navigation" import { useEffect, useRef, useState } from "react" import { DrawioFrame } from "@/components/canvas/drawio-frame" -import { SelectionAsk } from "@/components/canvas/selection-ask" -import { useChatEngine } from "@/components/chat/chat-engine" +import { useLocale } from "@/components/canvas/locale-context" import { useDiagram } from "@/contexts/diagram-context" import { useDictionary } from "@/hooks/use-dictionary" -import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config" +import { + DRAWIO_CSS, + type DrawioSource, + getDrawioSrc, + webDrawioSource, +} from "@/lib/drawio/drawio-config" import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge" import { i18n, type Locale } from "@/lib/i18n/config" import { STORAGE_KEYS } from "@/lib/storage" @@ -13,28 +16,42 @@ import { cn } from "@/lib/utils" import { useCanvasStore } from "@/stores/canvas-store" import { useSettingsStore } from "@/stores/settings-store" +/** Size of the canvas area, for overlays that stay inside it */ +export interface CanvasSize { + width: number + height: number +} + /** * The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and - * the "ask AI" button laid over it (only when the page can drive draw.io - * directly, not with an external cross-origin draw.io). + * an overlay laid over it (the web app's "ask AI" button; only when the + * page can drive draw.io directly, not with an external cross-origin + * draw.io). */ export function CanvasStage({ className, style, hideOverlays = false, + overlay, + drawioSource, + waitForSavedLocale = false, }: { className?: string style?: React.CSSProperties - /** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */ + /** Hide the overlay (e.g. while the start screen covers the canvas) */ hideOverlays?: boolean + /** Rendered over the canvas once draw.io is ready */ + overlay?: (size: CanvasSize) => React.ReactNode + /** Where the editor comes from; none: the web app's own copy (the MCP + * shell passes what its server configured) */ + drawioSource?: DrawioSource + /** Web app: the page is about to switch to the language picked last + * time (useSavedLocale in the workspace); do not load draw.io in the + * URL's language meanwhile */ + waitForSavedLocale?: boolean }) { const dict = useDictionary() - const params = useParams<{ lang: string }>() - const lang = ( - i18n.locales.includes(params.lang as Locale) - ? params.lang - : i18n.defaultLocale - ) as Locale + const lang = useLocale() const { drawioRef, handleDiagramAutoSave, @@ -43,7 +60,6 @@ export function CanvasStage({ resetDrawioReady, isDrawioReady, } = useDiagram() - const engine = useChatEngine() const isDark = useSettingsStore((s) => s.isDark) const hasEditor = useCanvasStore((s) => s.hasEditor) // Canvas width when the chat panel starts sliding in or out @@ -53,20 +69,24 @@ export function CanvasStage({ // null until draw.io tells us whether we can drive it directly const [editorAccess, setEditorAccess] = useState(null) const stageRef = useRef(null) - const [size, setSize] = useState({ width: 0, height: 0 }) + const [size, setSize] = useState({ width: 0, height: 0 }) useEffect(() => { - // The page is about to switch to the language picked last time - // (useSavedLocale): load draw.io once, in that language - const saved = localStorage.getItem(STORAGE_KEYS.locale) - if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) { - return + // Load draw.io once, in the language the page will have + if (waitForSavedLocale) { + const saved = localStorage.getItem(STORAGE_KEYS.locale) + if ( + saved && + saved !== lang && + i18n.locales.includes(saved as Locale) + ) { + return + } } - const isElectron = !!(window as { electronAPI?: unknown }).electronAPI // The theme class is set before the page renders; the settings store // may not have read it yet const dark = document.documentElement.classList.contains("dark") - setSrc(getDrawioSrc({ lang, isElectron, dark })) + setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark)) }, [lang]) // A new iframe starts over: draw.io reports load and access again @@ -82,8 +102,7 @@ export function CanvasStage({ if (editorAccess !== false || !src) return const startedDark = new URL(src).searchParams.get("dark") === "1" if (startedDark === isDark) return - const isElectron = !!(window as { electronAPI?: unknown }).electronAPI - setSrc(getDrawioSrc({ lang, isElectron, dark: isDark })) + setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark)) }, [isDark, editorAccess, lang, src]) useEffect(() => { @@ -159,13 +178,7 @@ export function CanvasStage({ )} - {showOverlays && ( -