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 6ec47895..c90a969e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,20 +38,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 05470c14..ede4a684 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" @@ -181,7 +182,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 8b40dcb0..b6901738 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(() => { @@ -158,13 +177,7 @@ export function CanvasStage({ )} - {showOverlays && ( -