diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ec6b88a6..83f65401 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -20,15 +20,61 @@ npm run lint # Check lint errors npm run check # Run all checks (CI) ``` -Pre-commit hooks via Husky will run Biome automatically on staged files. +Git hooks via Husky run automatically: +- **Pre-commit**: Biome (format/lint) + TypeScript type check +- **Pre-push**: Unit tests For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save. +## Testing + +Run tests before submitting PRs: + +```bash +npm run test # Unit tests (Vitest) +npm run test:e2e # E2E tests (Playwright) +``` + +E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`. + +To run a specific test file: +```bash +npx playwright test tests/e2e/diagram-generation.spec.ts +``` + +To run tests with UI mode: +```bash +npx playwright test --ui +``` + +## Before You Start + +For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR. + ## Pull Requests 1. Create a feature branch -2. Make changes and ensure `npm run check` passes -3. Submit PR against `main` with a clear description +2. Make changes (pre-commit runs lint + type check automatically) +3. Run E2E tests with `npm run test:e2e` +4. Push (pre-push runs unit tests automatically) +5. Submit PR against `main` with a clear description + +CI will run the full test suite on your PR. + +## Using AI Tools + +AI-assisted contributions are welcome. But please **review the output before opening a PR**: + +1. **Review the code** — understand what was generated, don't just commit blindly +2. **Write a PR description** — explain what changed and why +3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing +4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind + +## Code Review + +This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR: +- **Valid suggestions**: Please address them in your code. +- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them. ## Issues diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 00000000..5eb21438 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,24 @@ +--- +name: Enhancement +about: Suggest an improvement to existing functionality +title: '[Enhancement] ' +labels: enhancement +assignees: '' +--- + +> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas. + +## Current Behavior +Describe how the feature currently works. + +## Proposed Enhancement +How you'd like this to be improved. + +## Motivation +Why this enhancement would be beneficial. + +## Screenshots / Mockups +If applicable, add screenshots or mockups to illustrate the proposed changes. + +## Additional Context +Any other information about the enhancement request. diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..6d7eb58b --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "schedule": ["after 10am on the first day of the month"], + "timezone": "Asia/Tokyo", + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch"], + "matchPackagePatterns": ["*"], + "groupName": "minor and patch dependencies", + "automerge": true + }, + { + "matchUpdateTypes": ["major"], + "matchPackagePatterns": ["*"], + "groupName": "major dependencies", + "automerge": false + }, + { + "matchPackagePatterns": ["@ai-sdk/*"], + "groupName": "AI SDK packages" + }, + { + "matchPackagePatterns": ["@radix-ui/*"], + "groupName": "Radix UI packages" + }, + { + "matchPackagePatterns": ["electron", "electron-builder"], + "groupName": "Electron packages", + "automerge": false + }, + { + "matchPackagePatterns": ["@ai-sdk/*", "ai", "next"], + "groupName": "Core framework packages", + "automerge": false + }, + { + "matchPackageNames": ["@biomejs/biome"], + "groupName": "Biome", + "automerge": false + } + ], + "vulnerabilityAlerts": { + "enabled": true + } +} diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml new file mode 100644 index 00000000..ada9eba8 --- /dev/null +++ b/.github/workflows/auto-format.yml @@ -0,0 +1,56 @@ +name: Auto Format + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: write + +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Run Biome format + # Pin to the version in package.json so CI matches local/pre-commit + # (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs). + run: npx @biomejs/biome@2.5.7 check --write --no-errors-on-unmatched . + + - name: Check for changes + id: changes + run: | + if git diff --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + # For fork PRs, just fail if formatting is needed (can't push to forks) + - name: Fail if fork PR needs formatting + if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes." + git diff --stat + exit 1 + + # For same-repo PRs, commit and push the changes + - name: Commit changes + if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + git add . + git commit -m "style: auto-format with Biome" + git push origin HEAD:${{ github.head_ref }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0566d3f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Type check + run: npx tsc --noEmit + + - name: Lint check + run: npm run check + + - name: Build + run: npm run build + diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 6bfe9c84..66ac4e7d 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -54,20 +54,24 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name != 'pull_request' }} + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 + build-args: | + NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true # Push to AWS ECR for App Runner auto-deploy - name: Configure AWS credentials if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -87,4 +91,3 @@ jobs: docker pull ghcr.io/${REPO_LOWER}:latest docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest - diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml new file mode 100644 index 00000000..9e8ef191 --- /dev/null +++ b/.github/workflows/electron-release.yml @@ -0,0 +1,113 @@ +name: Electron Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g., v0.4.5)" + required: false + +jobs: + # Mac and Linux: Build and publish directly (no signing needed) + build-mac-linux: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: mac + - os: ubuntu-latest + platform: linux + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "npm" + + - name: Download draw.io static files for offline use + run: | + rm -rf public/drawio + git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio + mkdir -p public/drawio + cp -r /tmp/drawio/src/main/webapp/* public/drawio/ + rm -rf public/drawio/WEB-INF + rm -rf public/drawio/META-INF + + - name: Install dependencies + run: npm install + + - name: Build and publish + run: npm run dist:${{ matrix.platform }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Windows: Build, sign with SignPath, then publish + build-windows: + permissions: + contents: write + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "npm" + + - name: Download draw.io static files for offline use + shell: bash + run: | + rm -rf public/drawio + git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio + mkdir -p public/drawio + cp -r /tmp/drawio/src/main/webapp/* public/drawio/ + rm -rf public/drawio/WEB-INF + rm -rf public/drawio/META-INF + + - name: Install dependencies + run: npm install + + # Build WITHOUT publishing + - name: Build Windows app + run: npm run dist:win:build + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload unsigned artifacts for signing + uses: actions/upload-artifact@v6 + id: upload-unsigned + with: + name: windows-unsigned + path: release/*.exe + retention-days: 1 + + - name: Sign with SignPath + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5' + project-slug: 'next-ai-draw-io' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'windows-exe' + github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: release-signed + + - name: Upload signed artifacts to release + uses: softprops/action-gh-release@v2 + with: + files: release-signed/*.exe + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml new file mode 100644 index 00000000..6b816c45 --- /dev/null +++ b/.github/workflows/publish-mcp.yml @@ -0,0 +1,71 @@ +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; +# 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: + push: + branches: + - main + paths: + - "packages/mcp-server/**" + workflow_dispatch: + +permissions: + contents: read + id-token: write # OIDC token for npm trusted publishing + +concurrency: + group: publish-mcp + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/mcp-server + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "npm" + cache-dependency-path: packages/mcp-server/package-lock.json + registry-url: "https://registry.npmjs.org" + + # Trusted publishing requires npm >= 11.5.1 + - name: Update npm + run: npm install -g npm@latest + + - name: Check if version is already published + id: version + run: | + LOCAL=$(node -p "require('./package.json').version") + if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then + echo "Version ${LOCAL} already on npm - nothing to publish" + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "Version ${LOCAL} not on npm - publishing" + echo "publish=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + if: steps.version.outputs.publish == 'true' + run: npm ci + + - name: Test + if: steps.version.outputs.publish == 'true' + run: npm test + + - name: Build and check package contents + if: steps.version.outputs.publish == 'true' + run: npm run build && npm run check-package + + - name: Publish to npm + if: steps.version.outputs.publish == 'true' + run: npm publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..bc8376c2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,89 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint-and-unit: + name: Lint & Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run check + + - 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. + - name: Install MCP server dependencies + run: npm --prefix packages/mcp-server ci + + - name: Run MCP server unit tests + run: npm --prefix packages/mcp-server test + + # Tests run from src/, so check the built npm package separately + - name: Build MCP server and check package contents + run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm 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: Build app + run: npm run build + + - name: Run E2E tests + run: npm run test:e2e + env: + CI: true + + - name: Upload test results + uses: actions/upload-artifact@v6 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 28fd6acd..0e8010a0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ packages/*/dist # testing /coverage +/playwright-report/ +/test-results/ # next.js /.next/ @@ -50,3 +52,30 @@ push-via-ec2.sh .wrangler/ .env*.local +# Electron +/dist-electron/ +/release/ +/electron-standalone/ +# Draw.io static files (downloaded during CI build) +public/drawio/ +*.dmg +*.exe +*.AppImage +*.deb +*.rpm +*.snap + +CLAUDE.md +.spec-workflow + +# edgeone +.edgeone +opencode.json +ai-models.json + +# local backups +*.bak +.gstack/ + +# admin panel settings (contains secrets) +data/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..ef9088d0 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npx lint-staged +npx tsc --noEmit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..2674c5f2 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +# Skip if node_modules not installed (e.g., on EC2 push server) +if [ -d "node_modules" ]; then + npm run test -- --run +fi diff --git a/Dockerfile b/Dockerfile index d8612428..037b48c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Multi-stage Dockerfile for Next.js # Stage 1: Install dependencies -FROM node:20-alpine AS deps +FROM node:24-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app @@ -9,10 +9,11 @@ WORKDIR /app COPY package.json package-lock.json* ./ # Install dependencies -RUN npm ci +ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1 +RUN npm install # Stage 2: Build application -FROM node:20-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app # Copy node_modules from deps stage @@ -26,11 +27,24 @@ ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL} +# Build-time argument to show About link and Notice icon +ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false +ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE} + +# Build-time argument for subdirectory deployment (e.g., /nextaidrawio) +ARG NEXT_PUBLIC_BASE_PATH="" +ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH} + +# Control sponsorship and self-hosting messaging in quota notifications. +# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups. +ARG NEXT_PUBLIC_SELFHOSTED="" +ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}" + # Build Next.js application (standalone mode) RUN npm run build # Stage 3: Production runtime -FROM node:20-alpine AS runner +FROM node:24-alpine AS runner WORKDIR /app ENV NODE_ENV=production @@ -47,6 +61,9 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Writable dir for admin panel settings (data/settings.json) +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + USER nextjs EXPOSE 3000 diff --git a/README.md b/README.md index 42903b60..5c94015a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **AI-Powered Diagram Creation Tool - Chat, Draw, Visualize** -English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md) +English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) @@ -19,6 +19,18 @@ English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md) A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization. +> Note: Thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model! + +

+ + + + Atlas Cloud + + +

+ +> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan). https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 @@ -26,20 +38,27 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 ## Table of Contents -- [Next AI Draw.io ](#next-ai-drawio-) +- [Next AI Draw.io](#next-ai-drawio) - [Table of Contents](#table-of-contents) - [Examples](#examples) - [Features](#features) - - [MCP Server (Preview)](#mcp-server-preview) + - [MCP Server](#mcp-server) + - [Claude Code CLI](#claude-code-cli) - [Getting Started](#getting-started) - [Try it Online](#try-it-online) - - [Run with Docker (Recommended)](#run-with-docker-recommended) + - [Desktop Application](#desktop-application) + - [Run with Docker](#run-with-docker) - [Installation](#installation) - [Deployment](#deployment) + - [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages) + - [Deploy on Vercel](#deploy-on-vercel) + - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Multi-Provider Support](#multi-provider-support) + - [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration) + - [Admin Panel](#admin-panel) - [How It Works](#how-it-works) - - [Project Structure](#project-structure) - [Support \& Contact](#support--contact) + - [FAQ](#faq) - [Star History](#star-history) ## Examples @@ -57,24 +76,24 @@ Here are some example prompts and their generated diagrams: - GCP architecture diagram
-

Prompt: Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.

- GCP Architecture Diagram + RAG Technique Diagram
+

Prompt: Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion

+ RAG Architecture Diagram - AWS architecture diagram
-

Prompt: Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.

- AWS Architecture Diagram + Authentication using React and AWS
+

Prompt: Generate authentication process using React with **AWS**. Use Serverless architecture.

+ Authentication Architecture Diagram - Azure architecture diagram
-

Prompt: Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.

- Azure Architecture Diagram + Open Innovation
+

Prompt: Create visualization of Henry Chesbrough's Open Innovation model.

+ Open Innovation Diagram - Cat sketch prompt
+ Cat sketch

Prompt: Draw a cute cat for me.

Cat Drawing @@ -93,9 +112,7 @@ Here are some example prompts and their generated diagrams: - **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure) - **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization -## MCP Server (Preview) - -> **Preview Feature**: This feature is experimental and may not stable. +## MCP Server Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). @@ -121,6 +138,13 @@ Then ask Claude to create diagrams: The diagram appears in your browser in real-time! +The MCP server includes most of the web app's drawing features: + +- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more) +- A screenshot tool, so the AI can check 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 you can continue a diagram after a restart + See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations. ## Getting Started @@ -131,39 +155,19 @@ No installation needed! Try the app directly on our demo site: [![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) -> Note: Due to high traffic, the demo site currently uses minimax-m2. For best results, we recommend self-hosting with Claude Sonnet 4.5 or Claude Opus 4.5. + > **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server. -### Run with Docker (Recommended) +### Desktop Application -If you just want to run it locally, the best way is to use Docker. +Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases): -First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/) +Supported platforms: Windows, macOS, Linux. -Then run: +### Run with Docker -```bash -docker run -d -p 3000:3000 \ - -e AI_PROVIDER=openai \ - -e AI_MODEL=gpt-4o \ - -e OPENAI_API_KEY=your_api_key \ - ghcr.io/dayuanjiang/next-ai-draw-io:latest -``` - -Or use an env file: - -```bash -cp env.example .env -# Edit .env with your configuration -docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest -``` - -Open [http://localhost:3000](http://localhost:3000) in your browser. - -Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options. - -> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./docs/offline-deployment.md) for configuration options. +[Go to Docker Guide](./docs/en/docker.md) ### Installation @@ -172,73 +176,85 @@ Replace the environment variables with your preferred AI provider configuration. ```bash git clone https://github.com/DayuanJiang/next-ai-draw-io cd next-ai-draw-io -``` - -2. Install dependencies: - -```bash npm install -``` - -3. Configure your AI provider: - -Create a `.env.local` file in the root directory: - -```bash cp env.example .env.local ``` -Edit `.env.local` and configure your chosen provider: +See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider. -- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow) -- Set `AI_MODEL` to the specific model you want to use -- Add the required API keys for your provider -- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models). -- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords. - -> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option. - -See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider. - -4. Run the development server: +2. Run the development server: ```bash npm run dev ``` -5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. +3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application. ## Deployment -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js. +### Deploy to EdgeOne Pages -Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/). + +Deploy by this button: + +[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) + +Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details. + +Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai). + +### Deploy on Vercel -Or you can deploy by this button. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) -Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file. +The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file. + +See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +### Deploy on Cloudflare Workers + +[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md) + ## Multi-Provider Support +- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) - AWS Bedrock (default) - OpenAI - Anthropic - Google AI +- Google Vertex AI - Azure OpenAI - Ollama - OpenRouter +- AIHubMix - DeepSeek - SiliconFlow +- ModelScope +- SGLang +- Vercel AI Gateway +- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) + All providers except AWS Bedrock and OpenRouter support custom endpoints. -📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider. +📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider. + +### Server-Side Multi-Model Configuration + +Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`. + +### Admin Panel + +Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`. + +📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes. **Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. -Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. +Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. ## How It Works @@ -251,33 +267,23 @@ The application uses the following technologies: Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly. -## Project Structure - -``` -app/ # Next.js App Router - api/chat/ # Chat API endpoint with AI tools - page.tsx # Main page with DrawIO embed -components/ # React components - chat-panel.tsx # Chat interface with diagram control - chat-input.tsx # User input component with file upload - history-dialog.tsx # Diagram version history viewer - ui/ # UI components (buttons, cards, etc.) -contexts/ # React context providers - diagram-context.tsx # Global diagram state management -lib/ # Utility functions and helpers - ai-providers.ts # Multi-provider AI configuration - utils.ts # XML processing and conversion utilities -public/ # Static assets including example images -``` ## Support & Contact +**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models! + +**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan). + If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site! For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at: - Email: me[at]jiang.jp +## FAQ + +See [FAQ](./docs/en/FAQ.md) for common issues and solutions. + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) diff --git a/app/about/cn/page.tsx b/app/[lang]/about/cn/page.tsx similarity index 55% rename from app/about/cn/page.tsx rename to app/[lang]/about/cn/page.tsx index 6c74ae61..bacd18e9 100644 --- a/app/about/cn/page.tsx +++ b/app/[lang]/about/cn/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next" -import Image from "next/image" import Link from "next/link" import { FaGithub } from "react-icons/fa" +import Image from "@/components/image-with-basepath" export const metadata: Metadata = { title: "关于 - Next AI Draw.io", @@ -10,18 +10,7 @@ export const metadata: Metadata = { keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"], } -function formatNumber(num: number): string { - if (num >= 1000) { - return `${num / 1000}k` - } - return num.toString() -} - export default function AboutCN() { - const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20 - const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000 - const tpmLimit = Number(process.env.TPM_LIMIT) || 50000 - return (
{/* Navigation */} @@ -72,147 +61,73 @@ export default function AboutCN() {

AI驱动的图表创建工具 - 对话、绘制、可视化

-
- - English - - | - - 中文 - - | - - 日本語 - -
-
-
+
+
{/* Header */}

- 模型变更与用量限制{" "} - - (或者说:我的钱包顶不住了) - + 由字节跳动豆包提供支持

{/* Story */}

- 大家对这个项目的热情太高了——看来大家都真的很喜欢画图!但这也带来了一个幸福的烦恼:我们经常触发出上游 - AI 接口的频率限制 - (TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。 -

-

- 由于使用量过高,我已将模型从 Claude 更换为{" "} + 好消息!感谢{" "} + + 字节跳动豆包 + + 的慷慨赞助,演示站点现已接入强大的{" "} - minimax-m2 - - ,以降低成本。 -

-

- 作为一个 + glm-4.7 + {" "} + 模型,图表生成效果更佳!点击链接注册即可领取{" "} - 独立开发者 + 50万免费Token - ,目前的 API - 费用全是我自己在掏腰包(纯属为爱发电)。为了保证服务能细水长流,同时也为了避免我个人陷入财务危机,我还设置了以下临时用量限制: + ,适用于所有模型!

- {/* Limits Cards */} -
-
-
- Token 用量 -
-
- {formatNumber(tpmLimit)} - - /分钟 - -
-
- {formatNumber(dailyTokenLimit)} - - /天 - -
-
-
-
- 每日请求数 -
-
- {dailyRequestLimit} -
-
- 次 -
-
-
- - {/* Divider */} -
-
+ {/* Invite Poster */} + {/* Bring Your Own Key */} -
+

使用自己的 API Key

- 您可以使用自己的 API Key - 来绕过这些限制。点击聊天面板中的设置图标即可配置您的 - Provider 和 API Key。 + 您也可以使用自己的 API + Key,支持多种服务商。点击聊天面板中的设置图标即可配置。

您的 Key 仅保存在浏览器本地,不会被存储在服务器上。

- - {/* Divider */} -
-
-
- - {/* Sponsorship CTA */} -
-

- 寻求赞助 (求大佬捞一把) -

-

- 要想彻底解除这些限制,扩容后端是唯一的办法。我正在积极寻求 - AI API 提供商或云平台的赞助。 -

-

- 作为回报(无论是额度支持还是资金支持),我将在 - GitHub 仓库和 Live Demo - 网站的显眼位置展示贵公司的 Logo - 作为平台赞助商。 -

- - 联系我 - -
@@ -260,92 +175,106 @@ export default function AboutCN() {

- {/* Animated Transformer */} + {/* ResNet50 Architecture */}

- 动画Transformer连接器 + ResNet50模型架构动画

- 提示词: 给我一个带有 - 动画连接器的Transformer架构图。 + Prompt: Give me an{" "} + animated architecture diagram + of the ResNet50 model.

- 带动画连接器的Transformer架构 +
+ ResNet50模型架构图 +
- {/* Cloud Architecture Grid */} + {/* Diagram Grid */}

- GCP架构图 + RAG技术图

- 提示词: 使用 - GCP图标 - 生成一个GCP架构图。用户连接到托管在实例上的前端。 + Prompt: Generate a RAG + architecture diagram for{" "} + chat application. Use + connected diagram for data ingestion

- GCP架构图 +
+ RAG架构图 +

- AWS架构图 + React和AWS认证流程

- 提示词: 使用 - AWS图标 - 生成一个AWS架构图。用户连接到托管在实例上的前端。 + Prompt: Generate + authentication process using React with{" "} + AWS. Use Serverless + architecture.

- AWS架构图 +
+ 认证架构图 +

- Azure架构图 + 敏捷Scrum流程

- 提示词: 使用 - Azure图标 - 生成一个Azure架构图。用户连接到托管在实例上的前端。 + Prompt: Generate agile + scrum workflow diagram for software + development team.

- Azure架构图 +
+ 敏捷Scrum流程图 +

- 猫咪素描 + 开放式创新

- 提示词:{" "} - 给我画一只可爱的猫。 + Prompt: Create + visualization of Henry Chesbrough's + Open Innovation model.

- 猫咪绘图 +
+ 开放式创新图 +
@@ -377,6 +306,16 @@ export default function AboutCN() { 多提供商支持
    +
  • + + 字节跳动豆包 + +
  • AWS Bedrock(默认)
  • OpenAI / OpenAI兼容API(通过{" "} @@ -384,10 +323,13 @@ export default function AboutCN() {
  • Anthropic
  • Google AI
  • +
  • Google Vertex AI
  • Azure OpenAI
  • Ollama
  • OpenRouter
  • DeepSeek
  • +
  • SiliconFlow
  • +
  • ModelScope

注意:claude-sonnet-4-5{" "} @@ -395,18 +337,21 @@ export default function AboutCN() {

{/* Support */} -
-

- 支持与联系 -

- -
+function handleHistoryApi( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, +): void { + if (req.method !== "GET") { + res.writeHead(405) + res.end("Method Not Allowed") + return + } - - -` +function getHtmlPage(sessionId: string): string { + return loadPreviewTemplate() + .replace("{{SESSION_BADGE}}", () => + sessionId + ? `${sessionId.slice(-8)}` + : "", + ) + .replaceAll("{{DISABLED}}", sessionId ? "" : "disabled") + .replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL)) + .replace("{{SESSION_JSON}}", () => scriptJson(sessionId)) + .replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN)) } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index f91ceb4a..fb8939b9 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -6,45 +6,94 @@ * draw.io diagrams with real-time browser preview. * * Uses an embedded HTTP server - no external dependencies required. + * + * Multi-page support + * ------------------ + * The canonical in-memory shape for the session XML is always an + * containing one or more pages. Legacy callers that pass a bare + * to create_new_diagram are auto-wrapped into a single-page + * mxfile. All page-targeting parameters (page_id / page_name / page_index) + * on edit_diagram, get_diagram, and export_diagram are optional and default + * to the first page. See packages/mcp-server/src/pages.ts for the helper + * surface. */ -// Setup DOM polyfill for Node.js (required for XML operations) -import { DOMParser } from "linkedom" -;(globalThis as any).DOMParser = DOMParser - -// Create XMLSerializer polyfill using outerHTML -class XMLSerializerPolyfill { - serializeToString(node: any): string { - if (node.outerHTML !== undefined) { - return node.outerHTML - } - if (node.documentElement) { - return node.documentElement.outerHTML - } - return "" - } -} -;(globalThis as any).XMLSerializer = XMLSerializerPolyfill - +import { createRequire } from "node:module" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import open from "open" import { z } from "zod" +import type { DiagramOperation } from "./diagram-operations.ts" +import { installDomPolyfill } from "./dom.ts" +import { DRAWING_GUIDE } from "./drawing-guide.ts" +import { editDiagram, targetPageXml } from "./edit-diagram.ts" +import { checkEditGate, markPageSeen } from "./edit-gate.ts" +import { createExclusive } from "./exclusive.ts" +import { addHistory } from "./history.ts" import { - applyDiagramOperations, - type DiagramOperation, -} from "./diagram-operations.js" -import { + type ExportFormat, + type ExportOptions, getServerPort, getState, + keepInHistory, + onSessionRecreate, + onStateChange, + requestExport, + requestSync, + restoreSavedSession, setState, + shutdown, startHttpServer, -} from "./http-server.js" -import { log } from "./logger.js" + waitForSync, +} from "./http-server.ts" +import { parseDrawioFileContent } from "./load-diagram.ts" +import { log } from "./logger.ts" +import { prepareNewDiagram, reservedIdError } from "./new-diagram.ts" +import { + addPageToDoc, + deletePageFromDoc, + findPageElement, + hasCells, + hasPageSelector, + listPagesFromDoc, + normalizeToMxfile, + type PageSelector, + parseMxfile, + projectPage, + renamePageInDoc, + serializeMxfile, + wrapCellsInModel, +} from "./pages.ts" +import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts" +import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts" +import { validateAndFixXml } from "./xml-validation.ts" + +// DOMParser/XMLSerializer globals for the XML helpers (Node has neither) +installDomPolyfill() // Server configuration const config = { - port: parseInt(process.env.PORT || "6002"), + port: parseInt(process.env.PORT || "6002", 10), +} + +// Keep each session's latest diagram on disk, so it survives this process +const autosaver = new Autosaver(defaultDataDir()) +onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml)) +onSessionRecreate((sessionId) => autosaver.load(sessionId)) + +// A one-page view that does not count for the whole document (edit-gate.ts) +const OTHER_PAGES_UNSEEN = + "You have not seen the other pages in their current state." + +/** + * The browser's state of a session. After it expired (or the process + * restarted) the saved file comes back first, so a tool never builds on an + * older copy and then overwrites the file. Call it before requestSync or + * requestExport, which need the state. + */ +function sessionState(sessionId: string) { + restoreSavedSession(sessionId) + return getState(sessionId) } // Session state (single session for simplicity) @@ -52,59 +101,184 @@ let currentSession: { id: string xml: string version: number + // The exact state-store XML the model last saw (get_diagram) or wrote + // itself (create/edit/page CRUD). The store only changes on server + // writes or browser pushes (user autosave / sync), so edit_diagram can + // detect unseen user edits by comparing the live store against this. + // Empty = no diagram context established yet. + lastSeenXml: string } | null = null -// Create MCP server -const server = new McpServer({ - name: "next-ai-drawio", - version: "0.1.2", -}) +// Create MCP server. The version reported in the MCP handshake is read from +// package.json so it can never drift from the published npm version again +// (it sat hardcoded at stale values for most of this package's history). +// Both src/ (tsx dev) and dist/ (published build) live one level below the +// package root, so the relative path works in either runtime. +const require = createRequire(import.meta.url) +const packageVersion: string = require("../package.json").version -// Register prompt with workflow guidance -server.prompt( +// Hosts truncate instructions (Claude Code at 2,048 characters) and may show +// only the first 512, so the essentials come first. The full rules are in +// DRAWING_GUIDE, returned by start_session. +const INSTRUCTIONS = `next-ai-drawio creates and edits draw.io diagrams and shows them live in a browser preview, where the user can also edit them by hand. + +Start with start_session: it opens the preview and its result contains the drawing guide (layout, edge routing and style rules). Follow the guide when drawing; call get_drawing_guide if it is no longer in your context. + +Before using cloud or icon shapes (AWS, Azure, GCP, Kubernetes, Cisco, BPMN...), call get_shape_library and use the exact style names it returns. Never guess icon style names. + +After drawing a complex diagram, call screenshot_diagram once to see it, and fix overlapping shapes or edges that cross shapes. + +Tools: +- create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full 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. +- get_diagram: read the current XML, including the user's manual edits. +- screenshot_diagram: see the rendered diagram as an image. +- load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths. +- list_pages, add_page, rename_page, delete_page: manage pages (tabs).` + +const server = new McpServer( + { + name: "next-ai-drawio", + version: packageVersion, + }, + { instructions: INSTRUCTIONS }, +) + +// The tools that write the diagram, and start_session, run one at a time: +// two writes at once would both build on the same document, and the second +// would drop the first one's change. start_session in the queue keeps a +// session switch from landing in the middle of a write. +const exclusive = createExclusive() +const registerWriteTool = ((name: string, config: any, handler: any) => + server.registerTool( + name, + config, + exclusive(handler), + )) as typeof server.registerTool + +// Shared Zod schema fragment for page-targeting parameters. +// Every multi-page-aware tool reuses these three optional fields so the LLM +// learns one consistent interface. +const pageSelectorSchema = { + page_id: z + .string() + .min(1) + .optional() + .describe( + "Target a page by its id (as returned by list_pages or add_page). Wins over page_name and page_index when multiple are set.", + ), + page_name: z + .string() + .min(1) + .optional() + .describe( + 'Target a page by its display name (e.g. "CNN"). Used only when page_id is not set.', + ), + page_index: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Target a page by its 0-based tab index. Used only when page_id and page_name are not set.", + ), +} + +/** + * Pull a clean PageSelector out of a tool's parsed input. + * Returns an empty object when none of the page_* fields are set, so callers + * can simply pass it through to the lower layers (they treat empty as "first + * page" by convention). + */ +function pickPageSelector(input: { + page_id?: string + page_name?: string + page_index?: number +}): PageSelector { + const selector: PageSelector = {} + if (input.page_id) selector.page_id = input.page_id + if (input.page_name) selector.page_name = input.page_name + if (input.page_index !== undefined) selector.page_index = input.page_index + return selector +} + +/** Format a selector for human-readable error messages. */ +function describeSelector(s: PageSelector): string { + if (s.page_id) return `id="${s.page_id}"` + if (s.page_name) return `name="${s.page_name}"` + if (s.page_index !== undefined) return `index=${s.page_index}` + return "first page" +} + +// The same guide as start_session, for hosts that show prompts to the user +server.registerPrompt( "diagram-workflow", - "Guidelines for creating and editing draw.io diagrams", + { + description: "Guidelines for creating and editing draw.io diagrams", + }, () => ({ messages: [ { role: "user", - content: { - type: "text", - text: `# Draw.io Diagram Workflow Guidelines - -## Creating a New Diagram -1. Call start_session to open the browser preview -2. Use display_diagram with complete mxGraphModel XML to create a new diagram - -## Adding Elements to Existing Diagram -1. Use edit_diagram with "add" operation -2. Provide a unique cell_id and complete mxCell XML -3. No need to call get_diagram first - the server fetches latest state automatically - -## Modifying or Deleting Existing Elements -1. FIRST call get_diagram to see current cell IDs and structure -2. THEN call edit_diagram with "update" or "delete" operations -3. For update, provide the cell_id and complete new mxCell XML - -## Important Notes -- display_diagram REPLACES the entire diagram - only use for new diagrams -- edit_diagram PRESERVES user's manual changes (fetches browser state first) -- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`, - }, + content: { type: "text", text: DRAWING_GUIDE }, }, ], }), ) -// Tool: start_session +// Tool: get_drawing_guide server.registerTool( + "get_drawing_guide", + { + title: "Get drawing guide", + description: + "Return the drawing guide: XML format, layout, edge routing, style and editing rules. " + + "start_session already returns it; call this only if the guide is no longer in your context.", + inputSchema: {}, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async () => ({ content: [{ type: "text", text: DRAWING_GUIDE }] }), +) + +// Tool: get_shape_library +server.registerTool( + "get_shape_library", + { + title: "Get shape library", + description: + "Get the style syntax and shape names of a draw.io icon library. Call this BEFORE drawing with " + + "cloud, network or other icon shapes, and use the exact names it returns; never guess them.\n\n" + + `Libraries:\n${SHAPE_LIBRARY_LIST}`, + inputSchema: { + library: z + .string() + .describe("Library name, e.g. aws4, kubernetes, flowchart"), + }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async ({ library }) => { + const found = await getShapeLibrary(library) + return found.ok + ? { content: [{ type: "text", text: found.text }] } + : { + content: [{ type: "text", text: `Error: ${found.error}` }], + isError: true, + } + }, +) + +// Tool: start_session +registerWriteTool( "start_session", { + title: "Start session", description: "Start a new diagram session and open the browser for real-time preview. " + "Starts an embedded server and opens a browser window with draw.io. " + - "The browser will show diagram updates as they happen.", + "The browser will show diagram updates as they happen. " + + "The result includes the drawing guide; follow it when drawing.", inputSchema: {}, + annotations: { destructiveHint: false, openWorldHint: false }, }, async () => { try { @@ -117,19 +291,25 @@ server.registerTool( id: sessionId, xml: "", version: 0, + lastSeenXml: "", } // Open browser const browserUrl = `http://localhost:${port}?mcp=${sessionId}` await open(browserUrl) + const savePath = autosaver.pathFor(sessionId) + const saveNote = savePath + ? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue it in a later conversation, call start_session, then load_diagram with this path.` + : "" + log.info(`Started session ${sessionId}, browser at ${browserUrl}`) return { content: [ { type: "text", - text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`, + text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}\n\n${DRAWING_GUIDE}`, }, ], } @@ -145,22 +325,32 @@ server.registerTool( }, ) -// Tool: display_diagram -server.registerTool( - "display_diagram", +// Tool: create_new_diagram +registerWriteTool( + "create_new_diagram", { - description: - "Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " + - "Use this for creating new diagrams from scratch. " + - "To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " + - "You should generate valid draw.io/mxGraph XML format.", + title: "Create new diagram", + description: `Create a NEW diagram, REPLACING the whole document: every page and any unsaved user changes (the previous state stays in History). To add a tab use add_page; to change cells use edit_diagram. + +Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape_library first. Follow the drawing guide returned by start_session (call get_drawing_guide if it is no longer in your context). + +Accepted xml: +1) Only the mxCell elements of one page (recommended). The server adds , , and the root cells "0" and "1": + +2) A bare with (one page). +3) A full with one or more pages. Every page's must start with . + +Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent="1" for top-level shapes, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600.`, inputSchema: { xml: z .string() - .describe("The draw.io XML to display (mxGraphModel format)"), + .describe( + "REQUIRED: the mxCell elements of one page, a bare , or a full with one or more pages.", + ), }, + annotations: { openWorldHint: false }, }, - async ({ xml }) => { + async ({ xml: inputXml }) => { try { if (!currentSession) { return { @@ -174,29 +364,201 @@ server.registerTool( } } - log.info(`Displaying diagram, ${xml.length} chars`) + const prepared = prepareNewDiagram(inputXml) + if (!prepared.ok) { + log.error(prepared.error) + return { + content: [ + { type: "text", text: `Error: ${prepared.error}` }, + ], + isError: true, + } + } + if (prepared.fixes.length > 0) { + log.info(`XML auto-fixed: ${prepared.fixes.join(", ")}`) + } + // Every later tool can assume session.xml is an mxfile + const xml = prepared.xml + + log.info(`Setting diagram content, ${xml.length} chars`) + + // Sync from browser state first + const browserState = sessionState(currentSession.id) + if (browserState?.xml) { + currentSession.xml = browserState.xml + } + + // Save user's state before AI overwrites (with cached SVG) + if (currentSession.xml) { + keepInHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) + } // Update session state currentSession.xml = xml currentSession.version++ - // Push to embedded server state + // Push to embedded server state. The model just authored this + // exact XML, so record it as seen — edit_diagram may follow + // without a redundant get_diagram round-trip. setState(currentSession.id, xml) + currentSession.lastSeenXml = xml - log.info(`Diagram displayed successfully`) + // Save AI result (no SVG yet - will be captured by browser) + addHistory(currentSession.id, xml, "") + + // Report page count back to the caller so the LLM learns whether + // multi-page worked or fell back to single. + const doc = parseMxfile(xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageSummary = + pages.length > 1 + ? `${pages.length} pages: ${pages.map((p) => `${p.index}:${p.name}`).join(", ")}` + : pages.length === 1 + ? `1 page: ${pages[0].name}` + : "no pages parsed" + + log.info(`Diagram content set successfully (${pageSummary})`) return { content: [ { type: "text", - text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`, + text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`, }, ], } } catch (error) { const message = error instanceof Error ? error.message : String(error) - log.error("display_diagram failed:", message) + log.error("create_new_diagram failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: load_diagram +registerWriteTool( + "load_diagram", + { + title: "Load .drawio file", + description: + "Load a .drawio file from disk into the current session, REPLACING the entire diagram (all pages). " + + "The server reads the file directly — you do NOT need to read the file yourself or pass its XML through create_new_diagram. " + + "Handles both plain-XML and draw.io's compressed save format.\n\n" + + "After loading, call get_diagram before edit_diagram — you haven't seen the file's cell IDs or structure yet.", + inputSchema: { + path: z + .string() + .describe( + "Absolute path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project.", + ), + }, + annotations: { openWorldHint: false }, + }, + async ({ path }) => { + try { + if (!currentSession) { + return { + content: [ + { + type: "text", + text: "Error: No active session. Please call start_session first.", + }, + ], + isError: true, + } + } + + const fs = await import("node:fs/promises") + const nodePath = await import("node:path") + const absolutePath = nodePath.resolve(expandHome(path)) + + let content: string + try { + // A pipe or device could be read forever, and the other + // write tools wait for this one + if (!(await fs.stat(absolutePath)).isFile()) { + throw new Error("not a regular file") + } + content = await fs.readFile(absolutePath, "utf-8") + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { + content: [ + { + type: "text", + text: `Error: Cannot read file ${absolutePath}: ${msg}`, + }, + ], + isError: true, + } + } + + const loaded = parseDrawioFileContent(content) + if (!loaded.ok) { + return { + content: [{ type: "text", text: `Error: ${loaded.error}` }], + isError: true, + } + } + const xml = loaded.xml + + log.info( + `Loading diagram from ${absolutePath} (${xml.length} chars)`, + ) + + // Save the user's current state before replacing (same flow as + // create_new_diagram). + const browserState = sessionState(currentSession.id) + if (browserState?.xml) { + currentSession.xml = browserState.xml + } + if (currentSession.xml) { + keepInHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) + } + + currentSession.xml = xml + currentSession.version++ + setState(currentSession.id, xml) + // Deliberately NOT marking the loaded XML as seen: the model only + // supplied a path, so it doesn't know the file's cell IDs. The + // edit gate will require one get_diagram before edits. + currentSession.lastSeenXml = "" + + addHistory(currentSession.id, xml, "") + + const doc = parseMxfile(xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageSummary = + pages.length > 0 + ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` + : "no pages parsed" + + log.info(`Diagram loaded from file (${pageSummary})`) + + return { + content: [ + { + type: "text", + text: `Diagram loaded from ${absolutePath}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet.`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("load_diagram failed:", message) return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, @@ -206,28 +568,48 @@ server.registerTool( ) // Tool: edit_diagram -server.registerTool( +registerWriteTool( "edit_diagram", { + title: "Edit diagram", description: - "Edit the current diagram by ID-based operations (update/add/delete cells). " + - "ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" + - "IMPORTANT workflow:\n" + - "- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" + - "- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" + + "Edit a specific page in the current diagram by ID-based operations (update/add/delete cells).\n\n" + + "All-or-nothing: if any operation fails, nothing is applied and every failure is listed.\n\n" + + "Freshness: the server remembers the last diagram state you have seen, and rejects this call " + + "only if the user edited the diagram in the browser since then. You do NOT need to call " + + "get_diagram before every edit: a rejected call changes nothing and includes the current XML " + + "of the page, so you can rebuild your operations and retry.\n\n" + + "Call get_diagram first only when you don't know the current diagram content (cell IDs, " + + "structure) — e.g. the diagram wasn't created in this conversation, or you're unsure your " + + "memory of it is accurate.\n\n" + + "Multi-page targeting:\n" + + "- page_id / page_name / page_index are optional; when all omitted, the FIRST page is targeted\n" + + "- Use list_pages to discover what pages exist\n\n" + "Operations:\n" + - "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + + "- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + - "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + - "For add/update, new_xml must be a complete mxCell element including mxGeometry.", + "- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" + + "For add/update, new_xml must be a complete mxCell element including mxGeometry. No XML comments. " + + 'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' + + "Example - Add a rectangle on the default (first) page:\n" + + '{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]}\n\n' + + "Example - Delete a cell on the default page:\n" + + '{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}', inputSchema: { + ...pageSelectorSchema, operations: z .array( z.object({ - type: z + operation: z .enum(["update", "add", "delete"]) - .describe("Operation type"), - cell_id: z.string().describe("The id of the mxCell"), + .describe( + "Operation to perform: add, update, or delete", + ), + cell_id: z + .string() + .describe( + "The id of the mxCell. Must match the id attribute in new_xml.", + ), new_xml: z .string() .optional() @@ -238,8 +620,9 @@ server.registerTool( ) .describe("Array of operations to apply"), }, + annotations: { openWorldHint: false }, }, - async ({ operations }) => { + async ({ operations, page_id, page_name, page_index }) => { try { if (!currentSession) { return { @@ -253,10 +636,14 @@ server.registerTool( } } - // Fetch latest state from browser - const browserState = getState(currentSession.id) + // Fetch latest state from browser. Re-normalise to mxfile: the + // embed/sync path can hand back a bare , and adopting + // it verbatim would silently strip a multi-page document down to + // one page on the next write. + const browserState = sessionState(currentSession.id) if (browserState?.xml) { - currentSession.xml = browserState.xml + currentSession.xml = + normalizeToMxfile(browserState.xml) ?? browserState.xml log.info("Fetched latest diagram state from browser") } @@ -265,48 +652,113 @@ server.registerTool( content: [ { type: "text", - text: "Error: No diagram to edit. Please create a diagram first with display_diagram.", + text: "Error: No diagram to edit. Please create a diagram first with create_new_diagram.", }, ], isError: true, } } - log.info(`Editing diagram with ${operations.length} operation(s)`) + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) - // Apply operations - const { result, errors } = applyDiagramOperations( - currentSession.xml, - operations as DiagramOperation[], + // Enforce workflow: the model must have seen the current diagram + // state. Content comparison instead of a wall-clock timeout — + // slow reasoning between get_diagram and edit_diagram is fine as + // long as nothing changed in the browser meanwhile (#885). + const gate = checkEditGate( + currentSession.lastSeenXml, + browserState?.xml ?? "", + ) + if (!gate.ok) { + log.warn( + gate.reason === "stale" + ? "edit_diagram rejected: the browser has changes the model has not seen" + : "edit_diagram rejected: the model has not seen the diagram yet", + ) + // The error carries the current page, so the model has now + // seen it and can retry without a get_diagram round-trip, + // unless other pages changed too. + const liveXml = browserState?.xml || currentSession.xml + currentSession.lastSeenXml = markPageSeen( + currentSession.lastSeenXml, + liveXml, + pageSelector, + ) + const reason = + gate.reason === "stale" + ? "The diagram changed in the browser since you last saw it (e.g. manual user edits). No changes were made." + : "You have not seen this diagram yet, so no changes were made." + const next = + currentSession.lastSeenXml === liveXml + ? "Build your operations on this XML and retry." + : `${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector, then retry.` + return { + content: [ + { + type: "text", + text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\n${next}`, + }, + ], + isError: true, + } + } + + log.info( + `Editing diagram with ${operations.length} operation(s) on ${describeSelector(pageSelector)}`, ) - if (errors.length > 0) { - const errorMessages = errors - .map((e) => `${e.type} ${e.cellId}: ${e.message}`) - .join("\n") - log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`) + const outcome = editDiagram( + currentSession.xml, + operations as DiagramOperation[], + pageSelector, + ) + if (!outcome.ok) { + log.warn(`Edit rejected: ${outcome.errors.join("; ")}`) + const text = outcome.pageError + ? `Error: ${outcome.errors[0]}` + : `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nFix the operations against this XML and retry.` + return { + content: [{ type: "text", text }], + isError: true, + } } + if (outcome.fixes.length > 0) { + log.info(`new_xml auto-fixed: ${outcome.fixes.join("; ")}`) + } + const result = outcome.xml + + // Save the pre-edit state for undo (with cached SVG from browser). + // Done only once the edit applied: a rejected edit returns above + // without leaving a phantom history entry. + keepInHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) // Update state currentSession.xml = result currentSession.version++ - // Push to embedded server + // Push to embedded server; the pushed XML is now the latest + // state the model has seen. setState(currentSession.id, result) + currentSession.lastSeenXml = result + + // Save AI result (no SVG yet - will be captured by browser) + addHistory(currentSession.id, result, "") log.info(`Diagram edited successfully`) - const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).` - const errorMsg = - errors.length > 0 - ? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}` - : "" - return { content: [ { type: "text", - text: successMsg + errorMsg, + text: `Diagram edited successfully!\n\nApplied ${outcome.applied} operation(s) on ${describeSelector(pageSelector)}.`, }, ], } @@ -326,12 +778,25 @@ server.registerTool( server.registerTool( "get_diagram", { + title: "Get diagram", description: "Get the current diagram XML (fetches latest from browser, including user's manual edits). " + - "Call this BEFORE edit_diagram if you need to update or delete existing elements, " + - "so you can see the current cell IDs and structure.", + "Call this when you don't know the current diagram content (cell IDs, pages, structure) — " + + "e.g. before editing a diagram you didn't create in this conversation, or after edit_diagram " + + "was rejected because the user changed the diagram in the browser.\n\n" + + "Returns the full by default. If a page selector is provided, returns just that page's embedded in a one-page wrapper.", + inputSchema: { + ...pageSelectorSchema, + }, + annotations: { readOnlyHint: true, openWorldHint: false }, }, - async () => { + async (input) => { + // Defensive: when every field is optional an MCP client could in + // principle invoke us with no `arguments` field. The SDK's zod parse + // normally produces `{}` in that case, but we coalesce explicitly so + // a destructure of `undefined` can never throw before we reach the + // session-existence check. + const { page_id, page_name, page_index } = input ?? {} try { if (!currentSession) { return { @@ -345,28 +810,103 @@ server.registerTool( } } - // Fetch latest state from browser - const browserState = getState(currentSession.id) - if (browserState?.xml) { - currentSession.xml = browserState.xml + // start_session may replace currentSession while this waits + const session = currentSession + // Request browser to push fresh state and wait for it (an + // expired session first gets its saved file back to sync) + let staleNote = "" + restoreSavedSession(session.id) + const syncRequested = requestSync(session.id) + if (syncRequested) { + const synced = await waitForSync(session.id) + if (!synced) { + log.warn("get_diagram: sync timeout - state may be stale") + staleNote = + "\n\nNote: the browser did not respond, so this XML may not include the user's latest manual edits (is the preview tab open?)." + } } - if (!currentSession.xml) { + // Fetch latest state from browser, re-normalising to mxfile so a + // bare pushed back by the embed/sync path doesn't + // strip page structure (see edit_diagram for the same guard). + const browserState = sessionState(session.id) + if (browserState?.xml) { + session.xml = + normalizeToMxfile(browserState.xml) ?? browserState.xml + } + + if (!session.xml) { return { content: [ { type: "text", - text: "No diagram exists yet. Use display_diagram to create one.", + text: "No diagram exists yet. Use create_new_diagram to create one.", }, ], } } + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + + // The model is now looking at the current state. Record the raw + // store value — the gate's fast path is plain string equality + // against the store, with a structural comparison as fallback. + const liveXml = browserState?.xml || session.xml + const doc = parseMxfile(session.xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageList = pages.length + ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` + : "No wrapper detected (legacy single-page session)." + + // No selector → return full mxfile + if (!hasPageSelector(pageSelector)) { + session.lastSeenXml = liveXml + return { + content: [ + { + type: "text", + text: `Current diagram XML:\n\n${session.xml}\n\n${pageList}${staleNote}`, + }, + ], + } + } + + // Selector → return a single-page projection + const projection = projectPage(session.xml, pageSelector) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: + projection.reason === "parse" + ? `Error: a page selector was given but the current session XML could not be parsed as a multi-page (it may be a legacy single-page document or malformed), so it has no addressable pages.\n\n${pageList}` + : `Error: Page ${describeSelector(pageSelector)} not found.\n\n${pageList}`, + }, + ], + isError: true, + } + } + // One page shown counts for all only if the others are as the + // model saw them last + session.lastSeenXml = markPageSeen( + session.lastSeenXml, + liveXml, + pageSelector, + ) + const otherPagesNote = + session.lastSeenXml === liveXml + ? "" + : `\n\nNote: ${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector before editing.` return { content: [ { type: "text", - text: `Current diagram XML:\n\n${currentSession.xml}`, + text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}${otherPagesNote}`, }, ], } @@ -382,20 +922,240 @@ server.registerTool( }, ) +// 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 = Promise.resolve() + +/** + * Ask the browser to export (optionally via a page projection) and poll for + * the resulting image data. Resolves to undefined on timeout. + */ +function exportViaBrowser( + sessionId: string, + format: ExportFormat, + projectionXml?: string, + options?: ExportOptions, +): Promise { + const run = exportQueue.then(async () => { + requestExport(sessionId, format, projectionXml, options) + + // A projection export does an extra load + render round-trip in the + // browser, so give it a longer window. Re-read the live store entry + // each tick: setState() (from a concurrent autosave or tool call) + // replaces the Map entry with a new object, so a captured reference + // would go stale and never observe the browser's exportData. + const timeoutMs = projectionXml ? 15000 : 10000 + const start = Date.now() + let exportData: string | undefined + while (Date.now() - start < timeoutMs) { + exportData = getState(sessionId)?.exportData + if (exportData) break + await new Promise((r) => setTimeout(r, 200)) + } + const live = getState(sessionId) + if (live) { + live.exportData = undefined + live.exportFormat = undefined + live.exportXml = undefined + live.exportOptions = undefined + live.exportId = undefined + } + return exportData + }) + exportQueue = run.catch(() => {}) + return run +} + +/** + * True when the preview tab polled before but has gone quiet. Browsers + * slow down timers in background tabs (Chrome: about once a minute after + * 5 minutes hidden), so an export would just time out. + */ +function previewStalled(sessionId: string): boolean { + const lastPolled = getState(sessionId)?.lastPolled + return lastPolled !== undefined && Date.now() - lastPolled > 10_000 +} + +function previewStalledError(sessionId: string) { + return { + content: [ + { + type: "text" as const, + text: `Error: The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (http://localhost:${getServerPort()}?mcp=${sessionId}), then retry.`, + }, + ], + isError: true, + } +} + +/** The of the page a selector targets, for draw.io's pageId. */ +function pageIdFor(xml: string, selector: PageSelector): string | undefined { + const doc = parseMxfile(xml) + return ( + (doc && findPageElement(doc, selector)?.element.getAttribute("id")) || + undefined + ) +} + +// Screenshot size: Claude Desktop caps a tool result at about 150,000 +// characters, so retry smaller above 140,000 base64 characters. (Claude +// Code 2.1 accepted a 240,000 character image in testing.) +const SCREENSHOT_WIDTHS = [1000, 700] +const MAX_SCREENSHOT_CHARS = 140_000 + +// Adapted from the web app's vision check (lib/validation-prompts.ts) +const SCREENSHOT_CHECKLIST = `Check this rendering of the diagram for: +1. Overlapping shapes that cover each other or their labels (critical) +2. Edges crossing shapes that are not their source or target (critical) +3. Text that is cut off, overlapping or too small to read (warning) +4. Layout problems: cramped shapes, poor spacing or misalignment (warning) +5. Rendering errors: missing, incomplete or broken elements, such as an icon that did not load (critical) +If there are critical issues, fix them with edit_diagram and take one more screenshot. Do at most two rounds of fixes. Minor cosmetic issues are fine, and diagrams with only 1 or 2 shapes pass unless something is clearly broken.` + +// Tool: screenshot_diagram +server.registerTool( + "screenshot_diagram", + { + title: "Screenshot diagram", + 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.", + inputSchema: { ...pageSelectorSchema }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async (input) => { + const { page_id, page_name, page_index } = input ?? {} + try { + if (!currentSession) { + return { + content: [ + { + type: "text", + text: "Error: No active session. Please call start_session first.", + }, + ], + isError: true, + } + } + if (previewStalled(currentSession.id)) { + return previewStalledError(currentSession.id) + } + const xml = + sessionState(currentSession.id)?.xml || currentSession.xml + if (!hasCells(xml)) { + return { + content: [{ type: "text", text: "The diagram is empty." }], + } + } + + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + let pageId: string | undefined + let projectionXml: string | undefined + if (hasPageSelector(pageSelector)) { + const doc = normalizeToMxfile(xml) ?? xml + pageId = pageIdFor(doc, pageSelector) + // A page without an id: load just that page and capture + // it, as export_diagram does + if (!pageId) { + const projection = projectPage(doc, pageSelector) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: `Error: Page ${describeSelector(pageSelector)} not found.`, + }, + ], + isError: true, + } + } + projectionXml = projection.xml + } + } + + let data: string | undefined + // start_session may replace currentSession between the tries + const sessionId = currentSession.id + for (const width of SCREENSHOT_WIDTHS) { + data = await exportViaBrowser(sessionId, "png", projectionXml, { + width, + pageId, + }) + if (!data || data.length <= MAX_SCREENSHOT_CHARS) break + } + if (!data) { + return { + content: [ + { + type: "text", + text: "Error: Screenshot timed out. Make sure the preview tab is open and in front.", + }, + ], + isError: true, + } + } + return { + content: [ + { + type: "image", + data: data.replace(/^data:image\/png;base64,/, ""), + mimeType: "image/png", + }, + { + type: "text", + text: `Screenshot of ${hasPageSelector(pageSelector) ? `page ${describeSelector(pageSelector)}` : "the page on screen"}.\n\n${SCREENSHOT_CHECKLIST}`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("screenshot_diagram failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + // Tool: export_diagram server.registerTool( "export_diagram", { - description: "Export the current diagram to a .drawio file.", + title: "Export diagram", + description: + "Export the current diagram to a file. Supports .drawio (XML), .png, .svg, and .drawio.svg (an SVG with the diagram embedded, which draw.io can open and edit again). " + + "The format is auto-detected from the file extension, or can be specified explicitly.\n\n" + + "Multi-page behaviour:\n" + + "- .drawio with NO page selector: writes the full (all pages).\n" + + "- .drawio with a page selector: writes a single-page containing only that page.\n" + + "- .png / .svg with NO page selector: exports the currently active page in the browser.\n" + + "- .png with a page selector: renders that page without changing what the user sees.\n" + + "- .svg / .drawio.svg with a page selector: temporarily loads that page into the browser, captures it, then restores the full document (the user sees a brief flicker).", inputSchema: { + ...pageSelectorSchema, path: z .string() .describe( - "File path to save the diagram (e.g., ./diagram.drawio)", + "Absolute file path to save to (e.g. /Users/me/diagram.drawio, ~/diagram.png). Relative paths resolve against the MCP server's working directory, which is often not your project.", + ), + format: z + .enum(["drawio", "png", "svg", "drawio.svg"]) + .optional() + .describe( + "Export format. If omitted, detected from file extension. Defaults to drawio.", ), }, + annotations: { openWorldHint: false }, }, - async ({ path }) => { + async ({ path: rawPath, format, page_id, page_name, page_index }) => { + const path = expandHome(rawPath) try { if (!currentSession) { return { @@ -409,13 +1169,48 @@ server.registerTool( } } - // Fetch latest state - const browserState = getState(currentSession.id) - if (browserState?.xml) { - currentSession.xml = browserState.xml + // start_session may replace currentSession while this waits + const session = currentSession + + // Detect format from extension if not specified + const lowerPath = path.toLowerCase() + const detectedFormat = + format || + (lowerPath.endsWith(".drawio.svg") + ? "drawio.svg" + : lowerPath.endsWith(".png") + ? "png" + : lowerPath.endsWith(".svg") + ? "svg" + : "drawio") + + // The .drawio file is written from the state, so get the + // user's latest edits into it first, as get_diagram does (the + // images are made by the browser from its canvas) + let syncNote = "" + if (detectedFormat === "drawio") { + restoreSavedSession(session.id) + if (!requestSync(session.id)) { + syncNote = + "\n\nNote: the preview was not reachable, so the file may not include the user's latest manual edits." + } else if (!(await waitForSync(session.id))) { + log.warn( + "export_diagram: sync timeout - state may be stale", + ) + syncNote = + "\n\nNote: the browser did not respond, so the file may not include the user's latest manual edits (is the preview tab open?)." + } } - if (!currentSession.xml) { + // Fetch latest state, re-normalised to mxfile so a page + // selector works on a bare pushed by the browser + const browserState = sessionState(session.id) + if (browserState?.xml) { + session.xml = + normalizeToMxfile(browserState.xml) ?? browserState.xml + } + + if (!session.xml) { return { content: [ { @@ -427,24 +1222,174 @@ server.registerTool( } } + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + const fs = await import("node:fs/promises") const nodePath = await import("node:path") - let filePath = path - if (!filePath.endsWith(".drawio")) { - filePath = `${filePath}.drawio` + // .drawio path - write XML directly (no browser round-trip). + if (detectedFormat === "drawio") { + let filePath = path + if (!filePath.toLowerCase().endsWith(".drawio")) { + filePath = `${filePath}.drawio` + } + const absolutePath = nodePath.resolve(filePath) + + let outXml = session.xml + if (hasPageSelector(pageSelector)) { + const projection = projectPage(session.xml, pageSelector) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: + projection.reason === "parse" + ? "Error: Cannot parse current session XML as ; cannot project a single page." + : `Error: Page ${describeSelector(pageSelector)} not found for export.`, + }, + ], + isError: true, + } + } + outXml = projection.xml + } + + await fs.writeFile(absolutePath, outXml, "utf-8") + log.info(`Diagram exported to ${absolutePath}`) + return { + content: [ + { + type: "text", + text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${outXml.length} characters${syncNote}`, + }, + ], + } } + // PNG or SVG: request browser to export via iframe. Replace a + // known extension that does not match the format. + let filePath = path + const suffix = `.${detectedFormat}` + if (!lowerPath.endsWith(suffix)) { + const known = [".drawio.svg", ".drawio", ".png", ".svg"].find( + (e) => lowerPath.endsWith(e), + ) + if (known) filePath = filePath.slice(0, -known.length) + filePath = `${filePath}${suffix}` + } const absolutePath = nodePath.resolve(filePath) - await fs.writeFile(absolutePath, currentSession.xml, "utf-8") + // draw.io's name for an SVG with the diagram embedded + const browserFormat = + detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat - log.info(`Diagram exported to ${absolutePath}`) + const state = sessionState(session.id) + if (!state) { + return { + content: [ + { + type: "text", + text: "Error: Session state not found. Is the browser open?", + }, + ], + isError: true, + } + } + if (previewStalled(session.id)) { + return previewStalledError(session.id) + } + // ----------------------------------------------------------------- + // Page-targeted PNG/SVG export. + // + // drawio's JSON embed protocol has no working `selectPage` action, + // so to export a specific page we build a single-page + // projection and hand it to the browser bridge alongside the export + // request. The bridge loads the projection, waits for draw.io's own + // render, exports, then reloads the user's real document — entirely + // browser-side. The canonical session state is never mutated here, + // so there is no restore race and no concurrent-edit clobbering. + // ----------------------------------------------------------------- + // PNG: draw.io renders any page by id, without touching the + // page on screen. SVG export has no page option, so it still + // needs the projection below. + let projectionXml: string | undefined + let pngPageId: string | undefined + if (hasPageSelector(pageSelector) && detectedFormat === "png") { + pngPageId = pageIdFor(session.xml, pageSelector) + } + if (hasPageSelector(pageSelector) && !pngPageId) { + const projection = projectPage(session.xml, pageSelector) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: + projection.reason === "parse" + ? "Error: Cannot parse current session XML as ; cannot target page for export." + : `Error: Page ${describeSelector(pageSelector)} not found for export.`, + }, + ], + isError: true, + } + } + projectionXml = projection.xml + } + + const exportData = await exportViaBrowser( + session.id, + browserFormat, + projectionXml, + pngPageId ? { pageId: pngPageId } : undefined, + ) + + if (!exportData) { + return { + content: [ + { + type: "text", + text: projectionXml + ? "Error: Export timed out after loading the single-page projection. The browser may be closed or unresponsive." + : "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.", + }, + ], + isError: true, + } + } + + // Decode and write + if (detectedFormat === "png") { + const base64 = exportData.replace( + /^data:image\/png;base64,/, + "", + ) + await fs.writeFile(absolutePath, Buffer.from(base64, "base64")) + } else { + let svgContent = exportData + if (svgContent.startsWith("data:image/svg+xml;base64,")) { + const base64 = svgContent.replace( + /^data:image\/svg\+xml;base64,/, + "", + ) + svgContent = Buffer.from(base64, "base64").toString("utf-8") + } + await fs.writeFile(absolutePath, svgContent, "utf-8") + } + + const stat = await fs.stat(absolutePath) + log.info( + `Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`, + ) return { content: [ { type: "text", - text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`, + text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`, }, ], } @@ -460,6 +1405,442 @@ server.registerTool( }, ) +/** + * Shared helper for page-CRUD tools. + * Loads the latest session XML, normalises to mxfile if needed, returns a + * parsed Document the caller can mutate, plus a writer that persists. + */ +async function loadMxfileForMutation(): Promise< + | { ok: true; doc: Document; writeBack: (newDoc: Document) => void } + | { ok: false; message: string } +> { + if (!currentSession) { + return { + ok: false, + message: "No active session. Please call start_session first.", + } + } + // Pull latest from browser so we don't clobber autosaved changes. + const browserState = sessionState(currentSession.id) + if (browserState?.xml) { + currentSession.xml = browserState.xml + } + if (!currentSession.xml) { + return { + ok: false, + message: + "No diagram exists yet. Use create_new_diagram first, then page tools.", + } + } + // Make sure the in-memory shape is canonical mxfile before any CRUD. + const normalized = normalizeToMxfile(currentSession.xml) + if (!normalized) { + return { + ok: false, + message: + "Current session XML is neither nor ; cannot perform page operations.", + } + } + currentSession.xml = normalized + + const doc = parseMxfile(currentSession.xml) + if (!doc) { + return { + ok: false, + message: "Failed to parse current session XML as .", + } + } + const sessionRef = currentSession + return { + ok: true, + doc, + writeBack: (newDoc: Document) => { + const newXml = serializeMxfile(newDoc) + // The store may hold user edits the model has not seen yet. + const sawLatest = checkEditGate( + sessionRef.lastSeenXml, + browserState?.xml ?? "", + ).ok + // Save history before overwriting so the user can undo. + keepInHistory( + sessionRef.id, + sessionRef.xml, + browserState?.svg || "", + ) + sessionRef.xml = newXml + sessionRef.version++ + setState(sessionRef.id, newXml) + // The model just wrote this exact state. If it had seen the state + // it built on, mark the result as seen so edit_diagram needs no + // extra get_diagram; otherwise edit_diagram must ask for one. + sessionRef.lastSeenXml = sawLatest ? newXml : "" + addHistory(sessionRef.id, newXml, "") + }, + } +} + +// Tool: list_pages +server.registerTool( + "list_pages", + { + title: "List pages", + description: + "List every page (tab) in the current diagram. Returns each page's id, name, 0-based index, and cell count. Use this to discover what pages exist before targeting one with edit_diagram, get_diagram, export_diagram, rename_page, or delete_page.", + inputSchema: {}, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async () => { + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + const pages = listPagesFromDoc(loaded.doc) + if (pages.length === 0) { + return { + content: [ + { + type: "text", + text: "No pages in document.", + }, + ], + } + } + const lines = pages.map( + (p) => + ` [${p.index}] id="${p.id}" name="${p.name}" cells=${p.cellCount}`, + ) + return { + content: [ + { + type: "text", + text: `Pages (${pages.length}):\n${lines.join("\n")}`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("list_pages failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: add_page +registerWriteTool( + "add_page", + { + title: "Add page", + description: + 'Append a new page (tab) to the current diagram WITHOUT touching existing pages or unsaved user changes. Use this when the user wants "another diagram alongside" — e.g. "add a CNN page" — instead of create_new_diagram which wipes everything.\n\n' + + "Inputs:\n" + + "- name: optional display name for the tab (defaults to Page-N where N = existing-page-count + 1)\n" + + "- id: optional explicit page id; if omitted the server generates a short alphanumeric id\n" + + '- xml: optional starting content: the mxCell elements of the page (root cells "0" and "1" are added), or a bare . If omitted, the page starts blank.\n\n' + + "Returns the new page's id, name, and index so the caller can immediately target it with edit_diagram.", + inputSchema: { + name: z + .string() + .optional() + .describe( + 'Optional display name for the new tab (e.g. "CNN"). Defaults to "Page-N".', + ), + id: z + .string() + .min(1) + .optional() + .describe( + "Optional explicit page id. If omitted the server generates one. Must be unique across pages.", + ), + xml: z + .string() + .optional() + .describe( + "Optional starting content: the mxCell elements of the page, or a bare . If omitted the page starts blank.", + ), + }, + annotations: { destructiveHint: false, openWorldHint: false }, + }, + async (input) => { + // All three fields optional — coalesce so a no-args call doesn't + // crash on destructure before we surface a proper MCP error. + const { name, id, xml } = input ?? {} + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + + // If caller provided XML, validate it before splicing it in so we + // never get a half-broken mxfile written to the session. + const reserved = xml && reservedIdError(xml) + if (reserved) { + return { + content: [{ type: "text", text: `Error: ${reserved}` }], + isError: true, + } + } + let cleanXml: string | undefined = xml && wrapCellsInModel(xml) + if (cleanXml) { + const { valid, error, fixed, fixes } = + validateAndFixXml(cleanXml) + if (fixed) { + cleanXml = fixed + log.info( + `add_page: starting XML auto-fixed: ${fixes.join(", ")}`, + ) + } + if (!valid && error) { + return { + content: [ + { + type: "text", + text: `Error: starting xml validation failed - ${error}`, + }, + ], + isError: true, + } + } + } + + let info + try { + info = addPageToDoc(loaded.doc, { id, name, xml: cleanXml }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { + content: [{ type: "text", text: `Error: ${msg}` }], + isError: true, + } + } + + loaded.writeBack(loaded.doc) + log.info( + `Added page id=${info.id} name="${info.name}" index=${info.index}`, + ) + return { + content: [ + { + type: "text", + text: `Page added.\n\nid=${info.id}\nname=${info.name}\nindex=${info.index}\ncells=${info.cellCount}`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("add_page failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: rename_page +registerWriteTool( + "rename_page", + { + title: "Rename page", + description: + "Rename an existing page (tab). At least one of page_id / page_name / page_index is required to identify which page to rename. The new_name becomes the visible tab label in the editor.", + inputSchema: { + ...pageSelectorSchema, + new_name: z + .string() + .min(1) + .describe("The new display name for the page tab."), + }, + annotations: { destructiveHint: false, openWorldHint: false }, + }, + async ({ new_name, page_id, page_name, page_index }) => { + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + if (!hasPageSelector(pageSelector)) { + return { + content: [ + { + type: "text", + text: "Error: rename_page requires one of page_id, page_name, or page_index to identify the page.", + }, + ], + isError: true, + } + } + + const ok = renamePageInDoc(loaded.doc, pageSelector, new_name) + if (!ok) { + return { + content: [ + { + type: "text", + text: `Error: Page ${describeSelector(pageSelector)} not found.`, + }, + ], + isError: true, + } + } + + loaded.writeBack(loaded.doc) + log.info( + `Renamed page ${describeSelector(pageSelector)} → "${new_name}"`, + ) + return { + content: [ + { + type: "text", + text: `Page ${describeSelector(pageSelector)} renamed to "${new_name}".`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("rename_page failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: delete_page +registerWriteTool( + "delete_page", + { + title: "Delete page", + description: + "Delete a page (tab) from the current diagram. At least one of page_id / page_name / page_index is required. Refuses to delete the last remaining page — the editor needs at least one tab.", + inputSchema: { + ...pageSelectorSchema, + }, + annotations: { openWorldHint: false }, + }, + async (input) => { + // All three fields are optional — coalesce so a no-args call returns + // a clean error message instead of crashing on destructure. + const { page_id, page_name, page_index } = input ?? {} + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + if (!hasPageSelector(pageSelector)) { + return { + content: [ + { + type: "text", + text: "Error: delete_page requires one of page_id, page_name, or page_index to identify the page.", + }, + ], + isError: true, + } + } + + const outcome = deletePageFromDoc(loaded.doc, pageSelector) + if (!outcome.ok) { + return { + content: [ + { + type: "text", + text: `Error: ${outcome.reason}.`, + }, + ], + isError: true, + } + } + + loaded.writeBack(loaded.doc) + log.info( + `Deleted page id=${outcome.deletedId} index=${outcome.deletedIndex}`, + ) + return { + content: [ + { + type: "text", + text: `Page deleted (id=${outcome.deletedId}, was at index ${outcome.deletedIndex}).`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("delete_page failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Graceful shutdown handler +let isShuttingDown = false +function gracefulShutdown(reason: string) { + if (isShuttingDown) return + isShuttingDown = true + log.info(`Shutting down: ${reason}`) + autosaver.flush() + shutdown() + process.exit(0) +} + +// Handle stdin close (primary method - works on all platforms including Windows) +process.stdin.on("close", () => gracefulShutdown("stdin closed")) +process.stdin.on("end", () => gracefulShutdown("stdin ended")) + +// Handle signals (may not work reliably on Windows) +process.on("SIGINT", () => gracefulShutdown("SIGINT")) +process.on("SIGTERM", () => gracefulShutdown("SIGTERM")) + +// Handle broken pipe (writing to closed stdout) +process.stdout.on("error", (err) => { + if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") { + gracefulShutdown("stdout error") + } +}) + // Start the MCP server async function main() { log.info("Starting MCP server for Next AI Draw.io (embedded mode)...") diff --git a/packages/mcp-server/src/load-diagram.ts b/packages/mcp-server/src/load-diagram.ts new file mode 100644 index 00000000..f1609854 --- /dev/null +++ b/packages/mcp-server/src/load-diagram.ts @@ -0,0 +1,105 @@ +/** + * File-loading helpers for the load_diagram tool. + * + * A .drawio file is an whose children hold each page's + * either as plain XML or — draw.io's default save format — + * compressed: encodeURIComponent(xml) → raw deflate → base64 as the + * diagram's text content. The rest of the server assumes plain XML inside + * every , so loading decompresses all pages up front. + */ +import { inflateRaw } from "pako" +import { + isMxFile, + isMxGraphModel, + normalizeToMxfile, + parseMxfile, + serializeMxfile, +} from "./pages.ts" +import { getXmlSyntaxError } from "./xml-syntax.ts" + +export type LoadResult = + | { ok: true; xml: string } + | { ok: false; error: string } + +/** + * Decode one compressed page body (base64 → raw deflate → URI-decode). + * Returns null if the text isn't in that format. + */ +export function decompressPageContent(compressed: string): string | null { + try { + // atob and pako work in Node and in the browser + const bytes = Uint8Array.from(atob(compressed.trim()), (c) => + c.charCodeAt(0), + ) + const inflated = inflateRaw(bytes, { to: "string" }) + try { + return decodeURIComponent(inflated) + } catch { + // Not URI-encoded (older files) — the inflated text is the XML. + return inflated + } + } catch { + return null + } +} + +/** + * Parse the content of a .drawio file into the canonical session shape: + * an whose every page holds plain XML. Accepts a + * bare (wrapped into a one-page mxfile) and decompresses + * any compressed pages. + */ +export function parseDrawioFileContent(content: string): LoadResult { + let trimmed = content.trim() + if (!trimmed) return { ok: false, error: "File is empty." } + + if (isMxGraphModel(trimmed)) { + const normalized = normalizeToMxfile(trimmed) + if (!normalized) { + return { ok: false, error: "Failed to parse XML." } + } + // Parsed below like any , so a broken model is an error + trimmed = normalized + } + if (!isMxFile(trimmed)) { + return { + ok: false, + error: "Not a draw.io file: expected an or root element.", + } + } + const doc = parseMxfile(trimmed) + if (!doc) return { ok: false, error: "Failed to parse XML." } + + let decompressedAny = false + for (const d of Array.from(doc.querySelectorAll("diagram"))) { + if (d.querySelector("mxGraphModel")) continue + const text = (d.textContent || "").trim() + if (!text) continue // an empty page is valid + const pageLabel = + d.getAttribute("name") || d.getAttribute("id") || "unnamed" + const xml = decompressPageContent(text) + if (!xml || !isMxGraphModel(xml)) { + return { + ok: false, + error: `Page "${pageLabel}" has content that is neither plain XML nor draw.io's compressed format.`, + } + } + const inner = new DOMParser().parseFromString(xml, "text/xml") + if ( + getXmlSyntaxError(xml) || + inner.documentElement?.tagName !== "mxGraphModel" + ) { + return { + ok: false, + error: `Page "${pageLabel}" decompressed but its XML failed to parse.`, + } + } + d.textContent = "" + d.appendChild( + doc.importNode(inner.documentElement as unknown as Node, true), + ) + decompressedAny = true + } + // Nothing changed — keep the file's own serialisation. + return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed } +} diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts index 400372c7..22db4ab3 100644 --- a/packages/mcp-server/src/logger.ts +++ b/packages/mcp-server/src/logger.ts @@ -14,7 +14,8 @@ export const log = { console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args) }, debug: (msg: string, ...args: unknown[]) => { - if (process.env.DEBUG === "true") { + // process is missing when the web app runs this code in the browser + if (typeof process !== "undefined" && process.env.DEBUG === "true") { console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args) } }, diff --git a/packages/mcp-server/src/new-diagram.ts b/packages/mcp-server/src/new-diagram.ts new file mode 100644 index 00000000..2cb7b82b --- /dev/null +++ b/packages/mcp-server/src/new-diagram.ts @@ -0,0 +1,70 @@ +/** + * A whole new diagram written by the model, for the create_new_diagram tool + * and the web app's display_diagram tool. + */ +import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts" +import { readAttributes } from "./xml-attributes.ts" +import { validateAndFixXml } from "./xml-validation.ts" + +export type NewDiagram = + | { ok: true; xml: string; fixes: string[] } + | { ok: false; error: string } + +/** + * Bare cells get the root cells "0" and "1". A shape or edge with one of + * these ids would be renamed as a duplicate, breaking its edges. Its id may + * be on a or wrapper. Returns the error for the model, + * or null. + */ +export function reservedIdError(input: string): string | null { + if (/<(mxGraphModel|mxfile)\b/.test(input)) return null + // Each opening tag with its attributes; quoted values are read as a + // whole, so text such as label="id='1'" is not an attribute + const tags = input.matchAll( + /<(mxCell|UserObject|object)\b((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/g, + ) + for (const [, tag, attrText] of tags) { + const attrs = new Map( + readAttributes(attrText).map((a) => [a.name, a.value]), + ) + const id = attrs.get("id") + if (id !== "0" && id !== "1") continue + // A wrapper's id is its cell's; an mxCell counts as a shape or edge + if ( + tag !== "mxCell" || + attrs.get("vertex") === "1" || + attrs.get("edge") === "1" + ) { + return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".' + } + } + return null +} + +/** + * Bare cells get the wrapper and root cells first, since the strict parser + * rejects several top-level elements. Then the XML is validated and + * auto-fixed while it is still a bare model, where duplicate ids are + * renamed, and finally turned into an . + */ +export function prepareNewDiagram( + input: string, + page: { pageId?: string; pageName?: string } = {}, +): NewDiagram { + const reserved = reservedIdError(input) + if (reserved) return { ok: false, error: reserved } + let xml = wrapCellsInModel(input) + const { valid, error, fixed, fixes } = validateAndFixXml(xml) + if (fixed) xml = fixed + if (!valid) { + return { ok: false, error: `XML validation failed - ${error}` } + } + const normalized = normalizeToMxfile(xml, page) + if (!normalized) { + return { + ok: false, + error: "XML must be the mxCell elements of one page, a , or an with one or more children.", + } + } + return { ok: true, xml: normalized, fixes } +} diff --git a/packages/mcp-server/src/pages.ts b/packages/mcp-server/src/pages.ts new file mode 100644 index 00000000..1b83e55f --- /dev/null +++ b/packages/mcp-server/src/pages.ts @@ -0,0 +1,374 @@ +/** + * Multi-page (mxfile) helpers for draw.io diagrams. + * + * The on-disk and embed-protocol shape of a draw.io document is: + * + * + * + * ... + * + * ...one or more children... + * + * + * This module centralises page CRUD so that index.ts, xml-validation.ts, + * and diagram-operations.ts can all agree on: + * - what "the canonical in-memory shape" is (always mxfile), + * - how to find a page (id, name, or index), + * - how to add/rename/delete pages without re-parsing ad-hoc. + */ + +import { readAttributes } from "./xml-attributes.ts" +import { getXmlSyntaxError } from "./xml-syntax.ts" + +export interface PageInfo { + id: string + name: string + index: number + cellCount: number +} + +/** Selector used by all multi-page-aware tools. All fields optional. */ +export interface PageSelector { + page_id?: string + page_name?: string + page_index?: number +} + +/** True if the selector targets a specific page (any field set). */ +export function hasPageSelector(s?: PageSelector | null): boolean { + if (!s) return false + return ( + Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined + ) +} + +/** + * Generate a short page id similar in shape to drawio's auto-assigned ids. + * Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself + * uses short ids; collisions are still astronomically unlikely for one session. + */ +export function generatePageId(): string { + const a = Math.random().toString(36).substring(2, 10) + const b = Math.random().toString(36).substring(2, 6) + return `${a}-${b}` +} + +/** + * Any cell besides the root cells "0" and "1", or a page in draw.io's + * compressed format (text instead of a model), which is not checked further + */ +export const hasCells = (xml: string) => + /<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|]*>\s*[^\s<]/.test( + xml, + ) + +/** Cheap regex check — does the XML start with an root? */ +export function isMxFile(xml: string): boolean { + return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) +} + +/** Cheap regex check — does the XML start with a bare ? */ +export function isMxGraphModel(xml: string): boolean { + return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) +} + +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} + +/** + * Strip a leading declaration from an XML string. The XML spec + * only permits the declaration at the very start of a document, so embedding + * a declaration inside another element produces invalid XML. Callers must + * strip before splicing a fragment into a wrapper. + */ +function stripXmlDeclaration(xml: string): string { + return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "") +} + +const ROOT_CELLS = '' + +/** A one-page document with only the root cells */ +export const BLANK_MXFILE = `${ROOT_CELLS}` + +/** + * Turn a list of bare cells (optionally inside ) into a one-page + * , adding the "0" and "1" root cells. The model then only + * writes its own cells, as in the web app (wrapWithMxFile in lib/utils.ts). + * Root cells the model wrote anyway are replaced, and comments or text + * before the first cell and trailing closing tags some providers append + * are dropped. , and anything else are returned + * unchanged. + */ +export function wrapCellsInModel(xml: string): string { + let content = stripXmlDeclaration(xml.trim()) + const start = content.search(/<(mxCell|UserObject|object|root)[\s/>]/) + if (start === -1) return xml + // Only comments and plain text may come before the first cell + if (!/^(?:|[^<])*$/.test(content.slice(0, start))) { + return xml + } + + content = content + .slice(start) + .replace(/<\/?root>/g, "") + .trim() + // End of the last cell, counting wrapped cells (, ) + let end = -1 + for (const close of ["/>", "", "", ""]) { + const at = content.lastIndexOf(close) + if (at !== -1) end = Math.max(end, at + close.length) + } + if (end !== -1 && /^(\s*<\/[^>]+>)*\s*$/.test(content.slice(end))) { + content = content.slice(0, end) + } + // The root cells come with the wrapper (a label holding id='1' is not + // an id) + content = content + .replace(/]*?(?:\/>|>\s*<\/mxCell>)/g, (cell) => { + const id = readAttributes(cell).find((a) => a.name === "id")?.value + return id === "0" || id === "1" ? "" : cell + }) + .trim() + return `${ROOT_CELLS}${content}` +} + +/** + * Wrap a bare XML string in .... + * If the input is already an mxfile, returns it unchanged. + * If the input is neither shape, returns null so the caller can surface a clear error. + * + * Strips any leading declaration before embedding — a declaration is + * only valid at the very start of a document, never inside a . + */ +export function normalizeToMxfile( + xml: string, + opts: { pageId?: string; pageName?: string; host?: string } = {}, +): string | null { + const trimmed = xml.trim() + if (!trimmed) return null + if (isMxFile(trimmed)) return trimmed + if (!isMxGraphModel(trimmed)) return null + + const pageId = opts.pageId || generatePageId() + const pageName = opts.pageName || "Page-1" + const host = opts.host || "app.diagrams.net" + const inner = stripXmlDeclaration(trimmed) + return `${inner}` +} + +/** + * Parse an mxfile XML string. Returns null on parse error or if the root + * isn't — callers are expected to have run normalizeToMxfile first. + */ +export function parseMxfile(xml: string): Document | null { + try { + if (getXmlSyntaxError(xml)) return null + const doc = new DOMParser().parseFromString(xml, "text/xml") + if (doc.documentElement?.tagName !== "mxfile") return null + return doc as unknown as Document + } catch { + return null + } +} + +/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */ +export function serializeMxfile(doc: Document): string { + const serializer = new XMLSerializer() + return serializer.serializeToString(doc) +} + +export type PageProjection = + | { ok: true; xml: string; index: number; name: string } + | { ok: false; reason: "parse" | "notfound" } + +/** + * Project a single page out of an mxfile string into a standalone one-page + * . Used by get_diagram and export_diagram so the three call sites + * share one parse → find → serialise path. + * + * Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile, + * or { ok:false, reason:"notfound" } if the selector matches no page. + */ +export function projectPage( + xml: string, + selector: PageSelector, +): PageProjection { + const doc = parseMxfile(xml) + if (!doc) return { ok: false, reason: "parse" } + const found = findPageElement(doc, selector) + if (!found) return { ok: false, reason: "notfound" } + const serializer = new XMLSerializer() + return { + ok: true, + xml: `${serializer.serializeToString(found.element)}`, + index: found.index, + name: found.element.getAttribute("name") || "", + } +} + +/** Walk every child of and return summary info. */ +export function listPagesFromDoc(doc: Document): PageInfo[] { + const diagrams = doc.querySelectorAll("diagram") + const result: PageInfo[] = [] + diagrams.forEach((d, idx) => { + const root = d.querySelector("root") + const cellCount = root ? root.querySelectorAll("mxCell").length : 0 + result.push({ + id: d.getAttribute("id") || "", + name: d.getAttribute("name") || `Page-${idx + 1}`, + index: idx, + cellCount, + }) + }) + return result +} + +/** + * Resolve a page selector to its element. + * Resolution order: page_id → page_name → page_index → default (first page). + * + * When no selector field is set we return the first page — the "active page + * by convention" mentioned in §3.4 of the design doc. + */ +export function findPageElement( + doc: Document, + selector?: PageSelector, +): { element: Element; index: number } | null { + const diagrams = Array.from(doc.querySelectorAll("diagram")) + if (diagrams.length === 0) return null + + if (!hasPageSelector(selector)) { + return { element: diagrams[0], index: 0 } + } + + if (selector?.page_id) { + for (let i = 0; i < diagrams.length; i++) { + if (diagrams[i].getAttribute("id") === selector.page_id) { + return { element: diagrams[i], index: i } + } + } + return null + } + if (selector?.page_name) { + for (let i = 0; i < diagrams.length; i++) { + if (diagrams[i].getAttribute("name") === selector.page_name) { + return { element: diagrams[i], index: i } + } + } + return null + } + if (selector && selector.page_index !== undefined) { + const idx = selector.page_index + if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) { + return { element: diagrams[idx], index: idx } + } + return null + } + + return null +} + +/** + * Append a new to the mxfile doc. The new page's model defaults to + * an empty . + * + * `opts.xml` must be a BARE — passing a full would + * end up nested inside , which is malformed. We reject the mxfile + * shape explicitly and strip any declaration (only valid at + * document start, never inside ). + * + * Returns the new PageInfo. Throws if the requested id collides or the xml + * shape is wrong. + */ +export function addPageToDoc( + doc: Document, + opts: { id?: string; name?: string; xml?: string } = {}, +): PageInfo { + const existing = listPagesFromDoc(doc) + const id = opts.id || generatePageId() + if (existing.some((p) => p.id === id)) { + throw new Error(`Page id "${id}" already exists`) + } + const name = opts.name || `Page-${existing.length + 1}` + + let inner: string + if (opts.xml?.trim()) { + const trimmed = stripXmlDeclaration(opts.xml.trim()) + if (isMxFile(trimmed)) { + throw new Error( + "addPageToDoc: opts.xml must be a bare ; received a full . Extract the target diagram's first.", + ) + } + if (!isMxGraphModel(trimmed)) { + throw new Error( + "addPageToDoc: opts.xml must be a bare .", + ) + } + inner = trimmed + } else { + inner = `${ROOT_CELLS}` + } + + const snippet = `${inner}` + if (getXmlSyntaxError(snippet)) { + throw new Error( + "Failed to parse new page xml — make sure it is a valid ", + ) + } + const tempDoc = new DOMParser().parseFromString(snippet, "text/xml") + const newDiagram = tempDoc.querySelector("diagram") + if (!newDiagram) { + throw new Error("Failed to construct element for new page") + } + + const imported = doc.importNode(newDiagram, true) as Element + doc.documentElement.appendChild(imported) + + return { + id, + name, + index: existing.length, + cellCount: imported.querySelectorAll("mxCell").length, + } +} + +/** Rename the page matched by selector. Returns true on success. */ +export function renamePageInDoc( + doc: Document, + selector: PageSelector, + newName: string, +): boolean { + const found = findPageElement(doc, selector) + if (!found) return false + found.element.setAttribute("name", newName) + return true +} + +/** + * Delete a page. Refuses to delete the last remaining page — the embed needs + * at least one diagram to render anything, and silently recreating one would + * be surprising behaviour for an MCP caller. + */ +export function deletePageFromDoc( + doc: Document, + selector: PageSelector, +): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } { + // Match first, so a wrong selector reports "not found" even on a + // one-page document + const found = findPageElement(doc, selector) + if (!found) { + return { ok: false, reason: "Page not found" } + } + if (listPagesFromDoc(doc).length <= 1) { + return { ok: false, reason: "Cannot delete the only remaining page" } + } + const id = found.element.getAttribute("id") || "" + const index = found.index + found.element.parentNode?.removeChild(found.element) + return { ok: true, deletedId: id, deletedIndex: index } +} diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts new file mode 100644 index 00000000..2ba44b44 --- /dev/null +++ b/packages/mcp-server/src/persistence.ts @@ -0,0 +1,161 @@ +/** + * Auto-save of each session's latest diagram as a plain .drawio file, so a + * 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. History is not saved. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { contentFingerprint } from "./edit-gate.ts" +import { log } from "./logger.ts" +import { BLANK_MXFILE, hasCells } from "./pages.ts" + +// The blank page the browser shows before any drawing (page names count: +// empty pages the user named or added are kept) +const isBlank = (xml: string) => + !hasCells(xml) && + contentFingerprint(xml) === contentFingerprint(BLANK_MXFILE) + +const DELAY_MS = 1000 +const MAX_FILES = 50 + +/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */ +export function expandHome(p: string): string { + if (p === "~") return homedir() + if (p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1) + return p +} + +/** DRAWIO_DATA_DIR, default ~/.next-ai-drawio; "off" disables saving. */ +export function defaultDataDir(): string | null { + const dir = process.env.DRAWIO_DATA_DIR + if (dir === "off") return null + return dir ? expandHome(dir) : join(homedir(), ".next-ai-drawio") +} + +/** The file surely does not exist (not merely out of reach) */ +function isGone(path: string): boolean { + try { + statSync(path) + return false + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + } +} + +export class Autosaver { + private pending = new Map< + string, + { xml: string; timer: ReturnType } + >() + + constructor( + private dir: string | null, + private delayMs = DELAY_MS, + private maxFiles = MAX_FILES, + ) {} + + /** Path of a session's file, or null when saving is off. */ + pathFor(sessionId: string): string | null { + return this.dir ? join(this.dir, `${sessionId}.drawio`) : null + } + + // Saved files that could not be read back: never written over, since + // the session then shows something else than what they hold. Cleared + // once the file is read, or is surely gone (a folder without permission + // also makes a file look missing). + private unreadable = new Set() + + /** The session's saved diagram, or null. */ + load(sessionId: string): string | null { + const path = this.pathFor(sessionId) + if (!path) return null + try { + const xml = readFileSync(path, "utf-8") + this.unreadable.delete(path) + return xml + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.unreadable.delete(path) + return null + } + log.warn(`Could not read the saved diagram ${path}: ${error}`) + this.unreadable.add(path) + return null + } + } + + schedule(sessionId: string, xml: string): void { + if (!this.dir) return + const previous = this.pending.get(sessionId) + if (previous) clearTimeout(previous.timer) + const timer = setTimeout(() => this.write(sessionId), this.delayMs) + timer.unref?.() + this.pending.set(sessionId, { xml, timer }) + } + + /** Write every pending save now (on shutdown). */ + flush(): void { + for (const [sessionId, { timer }] of this.pending) { + clearTimeout(timer) + this.write(sessionId) + } + } + + private write(sessionId: string): void { + const entry = this.pending.get(sessionId) + this.pending.delete(sessionId) + const path = this.pathFor(sessionId) + if (!entry || !this.dir || !path) return + if (this.unreadable.has(path)) { + // Deleted meanwhile: nothing left to protect + if (isGone(path)) { + this.unreadable.delete(path) + } else { + log.warn( + `Not saving ${path}: it could not be read, so it may hold work this session does not show`, + ) + return + } + } + try { + const isNew = !existsSync(path) + // A blank page the browser shows before any drawing: nothing to keep + if (isNew && isBlank(entry.xml)) return + mkdirSync(this.dir, { recursive: true }) + // Write to a temporary file first so a crash never leaves half a file + writeFileSync(`${path}.tmp`, entry.xml, "utf-8") + renameSync(`${path}.tmp`, path) + if (isNew) this.removeOldest() + } catch (error) { + log.warn(`Auto-save failed for ${path}: ${error}`) + } + } + + private removeOldest(): void { + if (!this.dir) return + const dir = this.dir + // Only our own session files (mcp-")) { + if (cellStack.length > 0) cellStack.pop() + } else if (!tag.endsWith("/>")) { + const isLabelOrGeometry = + /\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag) + if (!isLabelOrGeometry) { + cellStack.push(cellMatch.index) + if (cellStack.length > 1) { + return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements." + } + } + } + } + return null +} + +/** Check for element names draw.io does not know (e.g. a lowercase ) */ +function checkUnknownElements(xml: string): string | null { + const tags = parseXmlTags(xml.replace(//g, "")) + for (const { tagName } of tags) { + if (!VALID_DRAWIO_TAGS.has(tagName)) { + return `Invalid XML: Unknown element <${tagName}>. draw.io only understands ${Array.from(VALID_DRAWIO_TAGS).join(", ")} (names are case-sensitive).` + } + } + return null +} + +/** + * Find elements without an "as" attribute outside . draw.io rejects them with "Could not add object mxPoint". + */ +function findOrphanMxPoints( + xml: string, +): Array<{ start: number; end: number }> { + const arrays: Array<[number, number]> = [] + // (?, which has no points inside + for (const m of xml.matchAll(/]*(?[\s\S]*?<\/Array>/g)) { + arrays.push([m.index, m.index + m[0].length]) + } + const orphans: Array<{ start: number; end: number }> = [] + for (const m of xml.matchAll(/]*?(?:\/>|>\s*<\/mxPoint>)/g)) { + if (/\sas\s*=/.test(m[0])) continue + if (arrays.some(([s, e]) => m.index > s && m.index < e)) continue + orphans.push({ start: m.index, end: m.index + m[0].length }) + } + return orphans +} + +// ============================================================================ +// Main Validation Function +// ============================================================================ + +/** + * Validates draw.io XML structure for common issues + * Uses DOM parsing + additional regex checks for high accuracy + * @param xml - The XML string to validate + * @param opts.strict - Also reject unknown element names and orphan + * s. Used for XML the model wrote, not for files or browser state. + * @returns null if valid, error message string if invalid + */ +/** The first non-blank text under el, skipping a page's compressed data */ +function findTextBetweenTags(el: Element | null): string | null { + if (!el) return null + // A with only text holds the page compressed + const compressed = el.tagName === "diagram" && el.children.length === 0 + for (const node of Array.from(el.childNodes)) { + if (node.nodeType === 1) { + const text = findTextBetweenTags(node as Element) + if (text) return text + } else if ( + // Text, or a CDATA section (draw.io reads it as text too) + (node.nodeType === 3 || node.nodeType === 4) && + !compressed + ) { + const text = node.textContent?.trim() + if (text) return text.slice(0, 40) + } + } + return null +} + +export function validateMxCellStructure( + xml: string, + opts: { strict?: boolean } = {}, +): string | null { + // Size check for performance + if (xml.length > MAX_XML_SIZE) { + console.warn( + `[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`, + ) + } + + // 0. DOM-based checks. Syntax errors are caught by the strict check at + // the end: linkedom's DOMParser never reports them. + try { + const parser = new DOMParser() + const doc = parser.parseFromString(xml, "text/xml") + + // DOM-based checks for nested mxCell + const allCells = doc.querySelectorAll("mxCell") + for (const cell of allCells) { + if (cell.parentElement?.tagName === "mxCell") { + const id = cell.getAttribute("id") || "unknown" + return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` + } + } + + // draw.io reads any text inside a page as compressed page data and + // then fails to open the page + if (!doc.querySelector("parsererror")) { + const text = findTextBetweenTags(doc.documentElement) + if (text) { + return `Invalid XML: Found text "${text}" between tags. Labels belong in the value attribute; remove any other text between tags.` + } + } + } catch (error) { + console.warn( + "[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:", + error, + ) + } + + // 1. Check for CDATA wrapper (invalid at document root) + if (/^\s* from end" + } + + // 2. Check for duplicate structural attributes + const dupAttrError = checkDuplicateAttributes(xml) + if (dupAttrError) { + return dupAttrError + } + + // 3. Check for unescaped < in attribute values + const attrValuePattern = /=\s*"([^"]*)"/g + let attrValMatch + while ((attrValMatch = attrValuePattern.exec(xml)) !== null) { + const value = attrValMatch[1] + if (//g + let commentMatch + while ((commentMatch = commentPattern.exec(xml)) !== null) { + if (/--/.test(commentMatch[1])) { + return "Invalid XML: Comment contains -- (double hyphen) which is not allowed" + } + } + + // 8. Check for unescaped entity references and invalid entity names + const entityError = checkEntityReferences(xml) + if (entityError) { + return entityError + } + + // 9. Check for empty id attributes on mxCell + if (/]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { + return "Invalid XML: Found mxCell element(s) with empty id attribute" + } + + // 10. Check for nested mxCell tags + const nestedCellError = checkNestedMxCells(xml) + if (nestedCellError) { + return nestedCellError + } + + if (opts.strict) { + const unknownError = checkUnknownElements(xml) + if (unknownError) { + return unknownError + } + if (findOrphanMxPoints(xml).length > 0) { + return 'Invalid XML: Found without an "as" attribute outside . Put waypoints inside or remove the point.' + } + } + + // 11. Strict XML syntax check, run last so the checks above can give + // more specific messages. Catches what they miss, e.g. duplicate or + // unquoted attributes, which make draw.io refuse to load the diagram. + const syntaxError = getXmlSyntaxError(xml) + if (syntaxError) { + return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (< for <, & for &, " for "), quote every attribute value, and do not repeat an attribute.` + } + + return null +} + +// ============================================================================ +// Auto-Fix Function +// ============================================================================ + +/** + * Attempts to auto-fix common XML issues in draw.io diagrams + * @param xml - The XML string to fix + * @returns Object with fixed XML and list of fixes applied + */ +export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { + let fixed = xml + const fixes: string[] = [] + + // 0. Fix JSON-escaped XML + if (/=\\"/.test(fixed)) { + fixed = fixed.replace(/\\"/g, '"') + fixed = fixed.replace(/\\n/g, "\n") + fixes.push("Fixed JSON-escaped XML") + } + + // 0b. Literal \n, \t or \r between tags, from escaping the XML twice + const unescaped = fixed.replace(/>(?:\s|\\[nrt])+ + gap.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, ""), + ) + if (unescaped !== fixed) { + fixed = unescaped + fixes.push("Replaced literal \\n between tags with line breaks") + } + + // 1. Remove CDATA wrapper + if (/^\s*\s*$/, "") + fixes.push("Removed CDATA wrapper") + } + + // 2. Remove text before XML declaration or root element + const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i) + if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) { + fixed = fixed.substring(xmlStart) + fixes.push("Removed text before XML root") + } + + // 3. Fix duplicate attributes + let dupAttrFixed = false + const structural = new Set(STRUCTURAL_ATTRS) + fixed = fixed.replace(/<[^>]+>/g, (tag) => { + // Keep the first of each, drop the later ones + const seen = new Set() + let newTag = "" + let last = 0 + for (const attr of readAttributes(tag)) { + if (!structural.has(attr.name)) continue + if (!seen.has(attr.name)) { + seen.add(attr.name) + continue + } + newTag += tag.slice(last, attr.start) + last = attr.end + dupAttrFixed = true + } + return newTag + tag.slice(last) + }) + if (dupAttrFixed) { + fixes.push("Removed duplicate structural attributes") + } + + // 4. Fix unescaped & characters + const ampersandPattern = + /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g + if (ampersandPattern.test(fixed)) { + fixed = fixed.replace( + /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g, + "&", + ) + fixes.push("Escaped unescaped & characters") + } + + // 5. Fix invalid entity names (double-escaping) + const invalidEntities = [ + { pattern: /&quot;/g, replacement: """, name: "&quot;" }, + { pattern: /&lt;/g, replacement: "<", name: "&lt;" }, + { pattern: /&gt;/g, replacement: ">", name: "&gt;" }, + { pattern: /&apos;/g, replacement: "'", name: "&apos;" }, + { pattern: /&amp;/g, replacement: "&", name: "&amp;" }, + ] + for (const { pattern, replacement, name } of invalidEntities) { + if (pattern.test(fixed)) { + fixed = fixed.replace(pattern, replacement) + fixes.push(`Fixed double-escaped entity ${name}`) + } + } + + // 6. Fix malformed attribute quotes (name="value"). Quoted + // values are matched first and kept, so " inside a rich-text + // label like value="<font style="...">" is left alone. + let quotesFixed = false + fixed = replaceInOpeningTags(fixed, (tag) => + tag.replace( + /("[^"]*"|'[^']*')|(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g, + (match, quoted, name, value) => { + if (quoted) return match + quotesFixed = true + return `${name}="${value}"` + }, + ), + ) + if (quotesFixed) { + fixes.push("Fixed malformed attribute quotes") + } + + // 7. Fix malformed closing tags + const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g + if (malformedClosingTag.test(fixed)) { + fixed = fixed.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "") + fixes.push("Fixed malformed closing tags") + } + + // 8. Fix missing space between attributes (id="2"vertex="1"). Every + // quoted value is consumed whole, so quotes always pair up within one + // attribute. + let spaceAdded = false + fixed = replaceInOpeningTags(fixed, (tag) => + tag.replace( + /("[^"]*"|'[^']*')([a-zA-Z_:])?/g, + (match, quoted, next) => { + if (!next) return match + spaceAdded = true + return `${quoted} ${next}` + }, + ), + ) + if (spaceAdded) { + fixes.push("Added missing space between attributes") + } + + // 9. Fix unescaped quotes in style color values + const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)="#/ + if (quotedColorPattern.test(fixed)) { + fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)="#/g, ";$1=#") + fixes.push("Removed quotes around color values in style") + } + + // 10. Fix unescaped < and > in attribute values + // < is required to be escaped, > is not strictly required but we escape for consistency + const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g + let attrMatch + let hasUnescapedLt = false + while ((attrMatch = attrPattern.exec(fixed)) !== null) { + if (!attrMatch[3].startsWith("<")) { + hasUnescapedLt = true + break + } + } + if (hasUnescapedLt) { + fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => { + const escaped = value.replace(//g, ">") + return `="${escaped}"` + }) + fixes.push("Escaped <> characters in attribute values") + } + + // 11. Fix invalid hex character references + const invalidHexRefs: string[] = [] + fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => { + if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) { + return match + } + invalidHexRefs.push(match) + return "" + }) + if (invalidHexRefs.length > 0) { + fixes.push( + `Removed ${invalidHexRefs.length} invalid hex character reference(s)`, + ) + } + + // 12. Fix invalid decimal character references + const invalidDecRefs: string[] = [] + fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => { + if (/^[0-9]+$/.test(dec) && dec.length > 0) { + return match + } + invalidDecRefs.push(match) + return "" + }) + if (invalidDecRefs.length > 0) { + fixes.push( + `Removed ${invalidDecRefs.length} invalid decimal character reference(s)`, + ) + } + + // 13. Fix invalid comment syntax + fixed = fixed.replace(//g, (match, content) => { + if (/--/.test(content)) { + let fixedContent = content + while (/--/.test(fixedContent)) { + fixedContent = fixedContent.replace(/--/g, "-") + } + fixes.push("Fixed invalid comment syntax") + return `` + } + return match + }) + + // 14. Fix tags to + const hasCellTags = /<\/?Cell[\s>]/i.test(fixed) + if (hasCellTags) { + fixed = fixed.replace(//gi, "") + fixed = fixed.replace(/<\/Cell>/gi, "") + fixes.push("Fixed tags to ") + } + + // 15. Fix closing tag typos and wrong tag case, e.g. (MUST run + // before foreign tag removal, which would otherwise delete them) + const before15 = fixed + fixed = fixed.replace(/<\/mxElement>/gi, "") + if (fixed !== before15) { + fixes.push("Fixed typo to ") + } + for (const name of ["mxCell", "mxGeometry", "mxPoint", "mxGraphModel"]) { + let changed = false + fixed = fixed.replace( + new RegExp(`<(/?)${name}(?=[\\s/>])`, "gi"), + (match, slash) => { + const right = `<${slash}${name}` + if (match !== right) changed = true + return right + }, + ) + if (changed) { + fixes.push(`Fixed tag case of <${name}>`) + } + } + + // 16. Remove non-draw.io tags (after the case fixes above). Removes only + // the exact tag occurrences and skips quoted attribute values, so a stray + // never takes with it and inside + // value="..." stays. + const isInsideQuotesFor16 = createQuoteTracker(fixed) + const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g + let foreignMatch + const foreignTags = new Set() + const foreignTagPositions: Array<{ start: number; end: number }> = [] + while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) { + const tagName = foreignMatch[1] + if (VALID_DRAWIO_TAGS.has(tagName)) continue + if (isInsideQuotesFor16(foreignMatch.index)) continue + foreignTags.add(tagName) + foreignTagPositions.push({ + start: foreignMatch.index, + end: foreignMatch.index + foreignMatch[0].length, + }) + } + if (foreignTagPositions.length > 0) { + // Remove from the end so earlier positions stay valid + for (const { start, end } of foreignTagPositions.reverse()) { + fixed = fixed.slice(0, start) + fixed.slice(end) + } + fixes.push( + `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, + ) + } + + // 16b. Remove orphan s (no "as" attribute, not inside + // ), which draw.io refuses to load + const orphanPoints = findOrphanMxPoints(fixed) + if (orphanPoints.length > 0) { + for (const { start, end } of orphanPoints.reverse()) { + fixed = fixed.slice(0, start) + fixed.slice(end) + } + fixes.push(`Removed ${orphanPoints.length} orphan (s)`) + } + + // 17. Fix unclosed tags + const tagStack: string[] = [] + const parsedTags = parseXmlTags(fixed) + + for (const { tagName, isClosing, isSelfClosing } of parsedTags) { + if (isClosing) { + const lastIdx = tagStack.lastIndexOf(tagName) + if (lastIdx !== -1) { + tagStack.splice(lastIdx, 1) + } + } else if (!isSelfClosing) { + tagStack.push(tagName) + } + } + + if (tagStack.length > 0) { + const tagsToClose: string[] = [] + for (const tagName of tagStack.reverse()) { + const openCount = ( + fixed.match(new RegExp(`<${tagName}[\\s>]`, "gi")) || [] + ).length + const closeCount = ( + fixed.match(new RegExp(``, "gi")) || [] + ).length + if (openCount > closeCount) { + tagsToClose.push(tagName) + } + } + if (tagsToClose.length > 0) { + const closingTags = tagsToClose.map((t) => ``).join("\n") + fixed = fixed.trimEnd() + "\n" + closingTags + fixes.push( + `Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(", ")}`, + ) + } + } + + // 18. Remove extra closing tags. Counts only draw.io tags outside quoted + // attribute values (value="Title" holds HTML, not elements). + const tagCounts = new Map< + string, + { opens: number; closes: number; selfClosing: number } + >() + const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + const isInsideQuotesFor18 = createQuoteTracker(fixed) + let tagCountMatch + while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) { + if (isInsideQuotesFor18(tagCountMatch.index)) continue + const fullMatch = tagCountMatch[0] + const tagPart = tagCountMatch[1] + const isClosing = tagPart.startsWith("/") + const isSelfClosing = fullMatch.endsWith("/>") + const tagName = isClosing ? tagPart.slice(1) : tagPart + if (!VALID_DRAWIO_TAGS.has(tagName)) continue + + let counts = tagCounts.get(tagName) + if (!counts) { + counts = { opens: 0, closes: 0, selfClosing: 0 } + tagCounts.set(tagName, counts) + } + if (isClosing) { + counts.closes++ + } else if (isSelfClosing) { + counts.selfClosing++ + } else { + counts.opens++ + } + } + + for (const [tagName, counts] of tagCounts) { + const extraCloses = counts.closes - counts.opens + if (extraCloses > 0) { + let removed = 0 + const closeTagPattern = new RegExp(``, "g") + const matches = [...fixed.matchAll(closeTagPattern)] + for ( + let i = matches.length - 1; + i >= 0 && removed < extraCloses; + i-- + ) { + const match = matches[i] + const idx = match.index ?? 0 + fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length) + removed++ + } + if (removed > 0) { + fixes.push( + `Removed ${removed} extra closing tag(s)`, + ) + } + } + } + + // 19. Remove trailing garbage after last XML tag + const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g + let lastValidTagEnd = -1 + let closingMatch + while ((closingMatch = closingTagPattern.exec(fixed)) !== null) { + lastValidTagEnd = closingMatch.index + closingMatch[0].length + } + if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) { + const trailing = fixed.slice(lastValidTagEnd).trim() + if (trailing) { + fixed = fixed.slice(0, lastValidTagEnd) + fixes.push("Removed trailing garbage after last XML tag") + } + } + + // 20. Fix nested mxCell by flattening + const lines = fixed.split("\n") + let newLines: string[] = [] + let nestedFixed = 0 + let extraClosingToRemove = 0 + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const nextLine = lines[i + 1] + + if ( + nextLine && + /") && + !nextLine.includes("/>") + ) { + const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] + const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] + + if (id1 && id1 === id2) { + nestedFixed++ + extraClosingToRemove++ + continue + } + } + + if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) { + extraClosingToRemove-- + continue + } + + newLines.push(line) + } + + if (nestedFixed > 0) { + fixed = newLines.join("\n") + fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`) + } + + // 21. Fix true nested mxCell (different IDs). Runs only when the nesting + // check finds real nesting, because this line-based rewrite can break + // valid cells written over several lines. + const lines2 = checkNestedMxCells(fixed) ? fixed.split("\n") : [] + newLines = [] + let trueNestedFixed = 0 + let cellDepth = 0 + let pendingCloseRemoval = 0 + + for (let i = 0; i < lines2.length; i++) { + const line = lines2[i] + const trimmed = line.trim() + + // A line holding a whole cell (...) opens nothing + const isOpenCell = + /") && + !trimmed.endsWith("") + const isCloseCell = trimmed === "" + + if (isOpenCell) { + if (cellDepth > 0) { + const indent = line.match(/^(\s*)/)?.[1] || "" + newLines.push(indent + "") + trueNestedFixed++ + pendingCloseRemoval++ + } + cellDepth = 1 + newLines.push(line) + } else if (isCloseCell) { + if (pendingCloseRemoval > 0) { + pendingCloseRemoval-- + } else { + cellDepth = Math.max(0, cellDepth - 1) + newLines.push(line) + } + } else { + newLines.push(line) + } + } + + if (trueNestedFixed > 0) { + fixed = newLines.join("\n") + fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`) + } + + // 22. Fix duplicate IDs by appending suffix. + // Skipped for multi-page documents — cell ids "0" and "1" repeat + // across pages legitimately (every page has its own with id="0"/"1" + // sentinel cells). Renaming them would break drawio's parent references. + // For mxfile inputs, duplicate-id validation is page-scoped in + // checkDuplicateIds() and a true duplicate produces a hard error rather + // than a silent rename. + if (!/]/i.test(fixed)) { + const seenIds = new Map() + const duplicateIds: string[] = [] + + const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi + let idMatch + while ((idMatch = idPattern.exec(fixed)) !== null) { + const id = idMatch[1] + seenIds.set(id, (seenIds.get(id) || 0) + 1) + } + + for (const [id, count] of seenIds) { + if (count > 1) duplicateIds.push(id) + } + + if (duplicateIds.length > 0) { + const idCounters = new Map() + // Rebuild from the captured parts so only the value changes (an id + // like "d" or "i" also occurs in the attribute name itself) + fixed = fixed.replace( + /(\bid\s*=\s*["'])([^"']+)(["'])/gi, + (match, before, id, after) => { + if (!duplicateIds.includes(id)) return match + + const count = idCounters.get(id) || 0 + idCounters.set(id, count + 1) + + if (count === 0) return match + + return `${before}${id}_dup${count}${after}` + }, + ) + fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) + } + } + + // 23. Fix empty id attributes + let emptyIdCount = 0 + fixed = fixed.replace( + /]*)\sid\s*=\s*["']\s*["']([^>]*)>/g, + (_match, before, after) => { + emptyIdCount++ + const newId = `cell_${Date.now()}_${emptyIdCount}` + return `` + }, + ) + if (emptyIdCount > 0) { + fixes.push(`Generated ${emptyIdCount} missing ID(s)`) + } + + return { fixed, fixes } +} + +// ============================================================================ +// Combined Validation and Fix +// ============================================================================ + +/** + * Validates XML and attempts to fix if invalid. By default runs the strict + * checks (unknown elements, orphan mxPoints), meant for XML the model wrote. + * Pass strict: false for a diagram that also holds the user's own content. + * @param xml - The XML string to validate and potentially fix + * @returns Object with validation result, fixed XML if applicable, and fixes applied + */ +export function validateAndFixXml( + xml: string, + { strict = true }: { strict?: boolean } = {}, +): { + valid: boolean + error: string | null + fixed: string | null + fixes: string[] +} { + // First validation attempt + let error = validateMxCellStructure(xml, { strict }) + + if (!error) { + return { valid: true, error: null, fixed: null, fixes: [] } + } + + // Try to fix + const { fixed, fixes } = autoFixXml(xml) + + // Validate the fixed version + error = validateMxCellStructure(fixed, { strict }) + + if (!error) { + return { valid: true, error: null, fixed, fixes } + } + + // Still invalid after fixes + return { + valid: false, + error, + fixed: fixes.length > 0 ? fixed : null, + fixes, + } +} diff --git a/packages/mcp-server/tests/diagram-operations.test.ts b/packages/mcp-server/tests/diagram-operations.test.ts new file mode 100644 index 00000000..77d4de13 --- /dev/null +++ b/packages/mcp-server/tests/diagram-operations.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for edit_diagram operations on cells that draw.io wraps in + * or (cells with links, tooltips or custom data). + * The id sits on the wrapper; the inner mxCell has none. + */ + +import { deflateRawSync } from "node:zlib" +import { beforeAll, describe, expect, it, vi } from "vitest" +import { installDomPolyfill } from "../src/dom.ts" + +beforeAll(() => { + installDomPolyfill() +}) + +import { applyDiagramOperations } from "../src/diagram-operations.ts" + +const DOC = `` + +describe("wrapped cells", () => { + it("deletes a UserObject cell with its edges and children", () => { + const { result, errors } = applyDiagramOperations(DOC, [ + { operation: "delete", cell_id: "a" }, + ]) + expect(errors).toEqual([]) + expect(result).not.toContain('id="a"') + expect(result).not.toContain('id="e1"') + expect(result).not.toContain('id="child"') + expect(result).toContain('id="b"') + }) + + it("cascades to a wrapped edge when deleting a plain cell", () => { + const { result, errors } = applyDiagramOperations(DOC, [ + { operation: "delete", cell_id: "b" }, + { operation: "delete", cell_id: "e1" }, + ]) + // e1 was already removed by the cascade, so no warning for it + expect(errors).toEqual([]) + expect(result).not.toContain('id="e1"') + expect(result).toContain('id="a"') + }) + + it("warns when deleting a cell that does not exist", () => { + const { errors } = applyDiagramOperations(DOC, [ + { operation: "delete", cell_id: "missing" }, + ]) + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ type: "delete", cellId: "missing" }) + }) + + it("updates a UserObject cell", () => { + const { result, errors } = applyDiagramOperations(DOC, [ + { + operation: "update", + cell_id: "a", + new_xml: ``, + }, + ]) + expect(errors).toEqual([]) + expect(result).toContain('label="A2"') + expect(result.match(/id="a"/g)).toHaveLength(1) + }) + + it("refuses to add a cell whose id a UserObject already uses", () => { + const { errors } = applyDiagramOperations(DOC, [ + { + operation: "add", + cell_id: "a", + new_xml: ``, + }, + ]) + expect(errors[0]?.message).toContain("already exists") + }) +}) + +describe("cascade delete logging", () => { + it("does not write cascade logs to stdout (the JSON-RPC channel)", () => { + const plain = `` + const spy = vi.spyOn(console, "log").mockImplementation(() => {}) + const { result } = applyDiagramOperations(plain, [ + { operation: "delete", cell_id: "x" }, + ]) + expect(result).not.toContain('id="e"') + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) +}) + +describe("pages without a ", () => { + const ADD_A = { + operation: "add" as const, + cell_id: "a", + new_xml: ``, + } + + it("treats an empty page as a blank page", () => { + const doc = `` + const { result, errors } = applyDiagramOperations(doc, [ADD_A]) + expect(errors).toEqual([]) + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain(' { + const model = `` + const compressed = deflateRawSync( + Buffer.from(encodeURIComponent(model)), + ).toString("base64") + const doc = `${compressed}` + const { result, errors } = applyDiagramOperations(doc, [ADD_A]) + expect(errors).toEqual([]) + expect(result).not.toContain(compressed) + expect(result).toContain(' { + const doc = `not base64 !!` + const { errors } = applyDiagramOperations(doc, [ADD_A]) + expect(errors[0]?.cellId).toBe("") + expect(errors[0]?.message).toContain("could not be decompressed") + }) +}) + +describe("a wrapped mxCell with its wrapper's id", () => { + const doc = `` + + it("deletes the whole wrapper", () => { + const { result, errors } = applyDiagramOperations(doc, [ + { operation: "delete", cell_id: "u" }, + ]) + expect(errors).toEqual([]) + expect(result).not.toContain("UserObject") + }) + + it("replaces the wrapper on update", () => { + const { result, errors } = applyDiagramOperations(doc, [ + { + operation: "update", + cell_id: "u", + new_xml: ``, + }, + ]) + expect(errors).toEqual([]) + expect(result.match(/ { + installDomPolyfill() +}) + +import { editDiagram, targetPageXml } from "../src/edit-diagram.ts" +import { validateMxCellStructure } from "../src/xml-validation.ts" + +const cell = (id: string, extra = "") => + `` + +const page = (id: string, cells: string) => + `${cells}` + +const DOC = `${page("p1", cell("a") + cell("b"))}` + +describe("editDiagram", () => { + it("applies every operation and counts them", () => { + const out = editDiagram( + DOC, + [ + { operation: "add", cell_id: "c", new_xml: cell("c") }, + { operation: "delete", cell_id: "b" }, + ], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.applied).toBe(2) + expect(out.xml).toContain('id="c"') + expect(out.xml).not.toContain('id="b"') + }) + + it("applies nothing when one operation fails", () => { + const out = editDiagram( + DOC, + [ + { operation: "add", cell_id: "c", new_xml: cell("c") }, + { operation: "delete", cell_id: "missing" }, + ], + {}, + ) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.pageError).toBe(false) + expect(out.errors).toEqual([ + 'delete missing: Cell with id="missing" not found', + ]) + }) + + it("rejects new_xml that is still invalid after auto-fix", () => { + const out = editDiagram( + DOC, + [ + { + operation: "update", + cell_id: "a", + new_xml: ``, + }, + ], + {}, + ) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.errors[0]).toMatch(/^update a: invalid new_xml: /) + }) + + it("rejects several cells in one new_xml", () => { + const out = editDiagram( + DOC, + [ + { + operation: "add", + cell_id: "c", + new_xml: cell("c") + cell("d"), + }, + ], + {}, + ) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.errors[0]).toContain("exactly one cell") + }) + + it("accepts a UserObject that wraps one mxCell", () => { + const wrapped = `` + const out = editDiagram( + DOC, + [{ operation: "add", cell_id: "u", new_xml: wrapped }], + {}, + ) + expect(out.ok).toBe(true) + }) + + it("is not blocked by a problem on another page", () => { + // Page p2 has a duplicate cell id, which fails validation + const doc = `${page("p1", cell("a"))}${page("p2", cell("x") + cell("x"))}` + expect(validateMxCellStructure(doc)).not.toBeNull() + const out = editDiagram( + doc, + [{ operation: "add", cell_id: "c", new_xml: cell("c") }], + { page_id: "p1" }, + ) + expect(out.ok).toBe(true) + }) + + it("fixes a literal \\n between tags, as gpt-5-mini sends it", () => { + const newXml = `\\n \\n` + const out = editDiagram( + DOC, + [{ operation: "add", cell_id: "c", new_xml: newXml }], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.xml).toContain('value="Reset password"') + expect(out.xml).not.toContain("\\n") + }) + + it("reports a missing page as a page-level error", () => { + const out = editDiagram(DOC, [{ operation: "delete", cell_id: "a" }], { + page_id: "nope", + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.pageError).toBe(true) + }) +}) + +describe("targetPageXml", () => { + it("returns only the selected page", () => { + const doc = `${page("p1", cell("a"))}${page("p2", cell("z"))}` + const xml = targetPageXml(doc, { page_id: "p2" }) + expect(xml).toContain('id="z"') + expect(xml).not.toContain('id="a"') + }) +}) + +describe("labels an edit does not touch", () => { + it("keep their line breaks and spaces as draw.io reads them", () => { + // A literal line break in an attribute reads as a space; is a + // real line break + const labels = + `` + + `` + const out = editDiagram( + `${page("p1", labels)}`, + [{ operation: "add", cell_id: "c", new_xml: cell("c") }], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.xml).toContain(`value="Hello world"`) + expect(out.xml).toContain(`value="Line 1 Line 2"`) + }) +}) diff --git a/packages/mcp-server/tests/edit-gate.test.ts b/packages/mcp-server/tests/edit-gate.test.ts new file mode 100644 index 00000000..66df1fce --- /dev/null +++ b/packages/mcp-server/tests/edit-gate.test.ts @@ -0,0 +1,170 @@ +/** + * Unit tests for the edit_diagram workflow gate (edit-gate.ts). + * + * The gate replaced the old 30-second wall-clock rule (#885): an edit is + * allowed when the model has seen the current browser state, no matter how + * long ago — and rejected when the browser state moved since. "Seen" is + * judged structurally, so draw.io's re-serialisation of the same content + * (attribute order, whitespace, viewport attributes, wrapper shape) never + * reads as a user edit. + */ + +import { beforeAll, describe, expect, it } from "vitest" +import { installDomPolyfill } from "../src/dom.ts" + +beforeAll(() => { + installDomPolyfill() +}) + +import { + checkEditGate, + contentFingerprint, + markPageSeen, +} from "../src/edit-gate.ts" + +const XML_A = `` + +// The same document as draw.io re-serialises it on autosave: different host, +// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered +// cell attributes, pretty-printed whitespace. +const XML_A_RESERIALIZED = ` + + + + + + + + + + + +` + +// A real user edit: box1 moved to a different position. +const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"') + +// Bare mxGraphModel with identical page content to XML_A. +const XML_A_BARE = `` + +describe("checkEditGate", () => { + it("rejects when no diagram context was ever established", () => { + expect(checkEditGate("", XML_A)).toEqual({ + ok: false, + reason: "no-context", + }) + }) + + it("allows when the browser state is exactly what the model saw", () => { + expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true }) + }) + + it("allows when the browser state is a re-serialisation of the same content", () => { + expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true }) + }) + + it("rejects when a cell actually changed", () => { + expect(checkEditGate(XML_A, XML_B)).toEqual({ + ok: false, + reason: "stale", + }) + }) + + it("rejects a real edit even when wrapped in re-serialisation noise", () => { + const movedAndReserialized = XML_A_RESERIALIZED.replace( + 'x="40" y="40"', + 'x="300" y="200"', + ) + expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({ + ok: false, + reason: "stale", + }) + }) + + it("allows when the store has no live entry to compare against", () => { + expect(checkEditGate(XML_A, "")).toEqual({ ok: true }) + }) + + // A bare push carries no page name, so the gate must not + // compare the invented "Page-1" wrapper name against the real one. + it("allows a bare mxGraphModel push when the page has a custom name", () => { + const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"') + expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true }) + }) + + it("still rejects a bare mxGraphModel push whose cells changed", () => { + const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"') + const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"') + expect(checkEditGate(seenRenamed, bareMoved)).toEqual({ + ok: false, + reason: "stale", + }) + }) +}) + +describe("contentFingerprint", () => { + it("is invariant under draw.io re-serialisation", () => { + expect(contentFingerprint(XML_A)).toBe( + contentFingerprint(XML_A_RESERIALIZED), + ) + }) + + it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => { + expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A)) + }) + + it("changes when a cell attribute changes", () => { + expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B)) + }) + + it("changes when a page is renamed", () => { + const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"') + expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed)) + }) + + it("changes when a page is added", () => { + const twoPages = XML_A.replace( + "", + ``, + ) + expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages)) + }) + + it("falls back to the raw string for unparseable input", () => { + expect(contentFingerprint("not xml at all")).toBe("not xml at all") + }) +}) + +describe("markPageSeen", () => { + const page = (id: string, label: string) => + `` + const doc = (a: string, b: string) => + `${page("A", a)}${page("B", b)}` + + it("counts the whole document as seen when the other pages are unchanged", () => { + const seen = doc("a1", "b1") + const live = doc("a2", "b1") + expect(markPageSeen(seen, live, { page_id: "A" })).toBe(live) + }) + + it("does not count a changed page the model was not shown", () => { + // The user edited page B; the model looked at page A only + const seen = doc("a1", "b1") + const live = doc("a1", "b2") + const marked = markPageSeen(seen, live, { page_id: "A" }) + expect(marked).toBe(seen) + expect(checkEditGate(marked, live).ok).toBe(false) + }) + + it("does not count other pages when the record of what was seen is empty", () => { + // Empty also after load_diagram or a page tool on unseen changes, + // when the model may still remember an older copy of the pages + const live = doc("a1", "b1") + expect(markPageSeen("", live, { page_id: "A" })).toBe("") + }) + + it("counts a one-page document as seen from its only page", () => { + const live = `${page("A", "a1")}` + expect(markPageSeen("", live, { page_id: "A" })).toBe(live) + }) +}) diff --git a/packages/mcp-server/tests/exclusive.test.ts b/packages/mcp-server/tests/exclusive.test.ts new file mode 100644 index 00000000..dce451ae --- /dev/null +++ b/packages/mcp-server/tests/exclusive.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for the queue the write tools run in (index.ts registerWriteTool). + */ +import { describe, expect, it } from "vitest" +import { createExclusive } from "../src/exclusive.ts" + +const extra = (signal = new AbortController().signal) => ({ signal }) +const tick = () => new Promise((r) => setTimeout(r, 5)) + +describe("createExclusive", () => { + it("runs the calls one at a time, in order", async () => { + const exclusive = createExclusive() + const events: string[] = [] + // Reads the document, waits, then writes it back + const addPage = exclusive(async (args: { name: string }, _extra) => { + events.push(`read ${args.name}`) + await tick() + events.push(`write ${args.name}`) + return { content: [] } + }) + await Promise.all([ + addPage({ name: "A" }, extra()), + addPage({ name: "B" }, extra()), + ]) + expect(events).toEqual(["read A", "write A", "read B", "write B"]) + }) + + it("goes on after a call that threw", async () => { + const exclusive = createExclusive() + const failing = exclusive(async (_extra: unknown) => { + throw new Error("broken") + }) + const working = exclusive(async (_extra: unknown) => ({ content: [] })) + const first = failing(extra()) + const second = working(extra()) + await expect(first).rejects.toThrow("broken") + await expect(second).resolves.toEqual({ content: [] }) + }) + + it("skips a call cancelled while it waited", async () => { + const exclusive = createExclusive() + let ran = false + const slow = exclusive(async (_extra: unknown) => { + await tick() + return { content: [] } + }) + const deletePage = exclusive(async (_extra: unknown) => { + ran = true + return { content: [] } + }) + const cancel = new AbortController() + const first = slow(extra()) + const second = deletePage(extra(cancel.signal)) + cancel.abort() + await first + expect(await second).toMatchObject({ isError: true }) + expect(ran).toBe(false) + }) +}) diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts new file mode 100644 index 00000000..1f4f2bca --- /dev/null +++ b/packages/mcp-server/tests/http-server.test.ts @@ -0,0 +1,710 @@ +/** + * Tests for the embedded HTTP server (browser bridge). + * + * The server runs in-process on a random high port (never 6002, which is + * also the default port of the Next.js dev server). Requests go through + * node:http so tests can set raw paths and Host/Origin headers. + */ + +import http from "node:http" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { installDomPolyfill } from "../src/dom.ts" +import { addHistory, getHistory } from "../src/history.ts" +import { + getState, + keepInHistory, + onSessionRecreate, + requestExport, + requestSync, + setState, + shutdown, + startHttpServer, + waitForSync, +} from "../src/http-server.ts" + +let port = 0 + +beforeAll(async () => { + // XML parsing, as the server installs it at startup + installDomPolyfill() + port = await startHttpServer(40000 + Math.floor(Math.random() * 10000)) +}) + +afterAll(() => { + shutdown() +}) + +interface Response { + status: number + headers: http.IncomingHttpHeaders + body: string +} + +/** Send a request; `body` may be split into several writes. */ +function request( + path: string, + opts: { + method?: string + headers?: Record + body?: Buffer[] + } = {}, +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + path, + method: opts.method ?? "GET", + headers: { host: `localhost:${port}`, ...opts.headers }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on("data", (c: Buffer) => chunks.push(c)) + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString("utf8"), + }), + ) + }, + ) + req.on("error", reject) + const parts = opts.body ?? [] + // Pause between parts so the server reads them as separate chunks + const writeNext = (i: number) => { + if (i >= parts.length) return req.end() + req.write(parts[i]) + setTimeout(() => writeNext(i + 1), 30) + } + writeNext(0) + }) +} + +const postJson = (path: string, data: unknown, headers = {}) => + request(path, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: [Buffer.from(JSON.stringify(data))], + }) + +describe("session id in the page URL", () => { + it("rejects a session id that could inject script", async () => { + const res = await request(`/?mcp=${encodeURIComponent('";alert(1)//')}`) + expect(res.status).toBe(400) + expect(res.body).not.toContain("alert") + }) + + it("writes a valid session id into the page script as a JSON string", async () => { + const res = await request("/?mcp=mcp-test-page") + expect(res.status).toBe(200) + expect(res.body).toContain('const sessionId = "mcp-test-page";') + }) +}) + +describe("requests that used to crash the process", () => { + it("answers 400 for a path that is not a valid URL", async () => { + const res = await request("//") + expect(res.status).toBe(400) + // The server is still alive + expect((await request("/api/state?sessionId=mcp-alive")).status).toBe( + 200, + ) + }) + + it("never creates sessions with ids unsafe for the Location header", async () => { + const badId = "mcp-中" + await request(`/api/state?sessionId=${encodeURIComponent(badId)}`) + expect(getState(badId)).toBeUndefined() + const post = await postJson("/api/state", { + sessionId: badId, + xml: "", + }) + expect(post.status).toBe(400) + expect(getState(badId)).toBeUndefined() + + const res = await request("/") + expect([200, 302]).toContain(res.status) + }) +}) + +describe("request origin checks", () => { + it("refuses a foreign Host header (DNS rebinding)", async () => { + const res = await request("/api/state?sessionId=mcp-alive", { + headers: { host: `evil.example:${port}` }, + }) + expect(res.status).toBe(403) + }) + + it("refuses writes from another website", async () => { + const res = await postJson( + "/api/state", + { sessionId: "mcp-csrf", xml: "" }, + { origin: "https://evil.example" }, + ) + expect(res.status).toBe(403) + expect(getState("mcp-csrf")).toBeUndefined() + }) + + it("accepts writes from the page itself", async () => { + const res = await postJson( + "/api/state", + { sessionId: "mcp-same-origin", xml: "" }, + { origin: `http://localhost:${port}` }, + ) + expect(res.status).toBe(200) + // Opened as 127.0.0.1, or through a forwarded port: Origin and + // Host name the same host + for (const host of [`127.0.0.1:${port}`, "localhost:7000"]) { + const page = await postJson( + "/api/state", + { sessionId: "mcp-same-origin", xml: "" }, + { origin: `http://${host}`, host }, + ) + expect(page.status).toBe(200) + } + }) + + it("refuses writes from a page on another localhost port", async () => { + // A plain text POST needs no CORS preflight, so the server must + // refuse it itself + setState("mcp-other-port", "kept") + for (const path of ["/api/state", "/api/history-svg"]) { + const res = await postJson( + path, + { + sessionId: "mcp-other-port", + xml: "replaced", + svg: "x", + }, + { origin: "http://localhost:3000" }, + ) + expect(res.status).toBe(403) + } + expect(getState("mcp-other-port")?.xml).toBe("kept") + expect(getState("mcp-other-port")?.svg).toBeUndefined() + }) +}) + +describe("POST /api/state", () => { + it("refuses a push without xml and keeps the diagram", async () => { + setState("mcp-no-xml", "kept") + const res = await postJson("/api/state", { + sessionId: "mcp-no-xml", + baseVersion: 99, + }) + expect(res.status).toBe(400) + expect(getState("mcp-no-xml")?.xml).toBe("kept") + }) + + it("decodes UTF-8 characters split across body chunks", async () => { + const xml = `${"数据".repeat(30000)}` + const body = Buffer.from(JSON.stringify({ sessionId: "mcp-utf8", xml })) + // Cut inside a 3-byte character + const cut = body.indexOf(Buffer.from("数")) + 1 + const res = await request("/api/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: [body.subarray(0, cut), body.subarray(cut)], + }) + expect(res.status).toBe(200) + expect(getState("mcp-utf8")?.xml).toBe(xml) + }) + + it("rejects a browser push based on a version older than an AI write", async () => { + const id = "mcp-conflict" + setState(id, "user v1", undefined, true) + const aiVersion = setState(id, "AI edit") + + const stale = await postJson("/api/state", { + sessionId: id, + xml: "user edit on old version", + baseVersion: aiVersion - 1, + }) + expect(stale.status).toBe(409) + expect(getState(id)?.xml).toBe("AI edit") + + // Pushes based on the AI version are accepted, including a second + // push sent before the first one's response updated the browser + for (const xml of ["a", "b"]) { + const ok = await postJson("/api/state", { + sessionId: id, + xml, + baseVersion: aiVersion, + }) + expect(ok.status).toBe(200) + expect(getState(id)?.xml).toBe(xml) + } + }) + + it("keeps a rejected user edit in history", async () => { + const id = "mcp-conflict-history" + setState(id, "user v1", undefined, true) + const aiVersion = setState(id, "AI edit") + const before = getHistory(id).length + + const stale = await postJson("/api/state", { + sessionId: id, + xml: "lost user edit", + baseVersion: aiVersion - 1, + }) + expect(stale.status).toBe(409) + expect(JSON.parse(stale.body).savedToHistory).toBe(true) + const history = getHistory(id) + expect(history).toHaveLength(before + 1) + expect(history.at(-1)?.xml).toBe("lost user edit") + }) + + it("ends a pending sync when the sync reply is older than an AI write", async () => { + const id = "mcp-stale-sync" + setState(id, "before", undefined, true) + const aiVersion = setState(id, "AI edit") + requestSync(id) + const before = getHistory(id).length + + // The browser exported its old diagram, then loaded the AI write + const stale = await postJson("/api/state", { + sessionId: id, + xml: "before", + baseVersion: aiVersion - 1, + source: "sync", + }) + expect(stale.status).toBe(409) + expect(JSON.parse(stale.body).savedToHistory).toBe(false) + expect(getState(id)?.xml).toBe("AI edit") + expect(getState(id)?.syncRequested).toBeUndefined() + expect(getHistory(id)).toHaveLength(before) + expect(await waitForSync(id, 200)).toBe(true) + }) + + it("ignores a sync reply older than a user edit saved meanwhile", async () => { + const id = "mcp-late-sync" + const version = setState(id, "A", undefined, true) + requestSync(id) + // The user's edit is saved before the sync reply arrives + const edit = await postJson("/api/state", { + sessionId: id, + xml: "B", + baseVersion: version, + }) + expect(edit.status).toBe(200) + const late = await postJson("/api/state", { + sessionId: id, + xml: "A", + baseVersion: version, + source: "sync", + }) + expect(late.status).toBe(409) + expect(getState(id)?.xml).toBe("B") + }) +}) + +describe("export requests", () => { + it("hands draw.io export options to the page and clears them after", async () => { + const id = "mcp-export-options" + setState(id, "x") + requestExport(id, "png", undefined, { width: 1000, pageId: "p2" }) + const poll = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + expect(poll.exportFormat).toBe("png") + expect(poll.exportOptions).toEqual({ width: 1000, pageId: "p2" }) + + await postJson("/api/state", { + sessionId: id, + exportData: "data:image/png;base64,AAAA", + exportId: poll.exportId, + }) + expect(getState(id)?.exportOptions).toBeUndefined() + }) + + it("ignores a late result of an export that already timed out", async () => { + const id = "mcp-export-late" + setState(id, "x") + requestExport(id, "png") + const first = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + // The server gave up on the first export and asked for the next + requestExport(id, "svg") + await postJson("/api/state", { + sessionId: id, + exportData: "data:image/png;base64,LATE", + exportId: first.exportId, + }) + expect(getState(id)?.exportData).toBeUndefined() + expect(getState(id)?.exportFormat).toBe("svg") + }) +}) + +describe("a session state recreated after it was lost", () => { + const SAVED = `` + const getJson = async (id: string) => + JSON.parse((await request(`/api/state?sessionId=${id}`)).body) + + it("names each state, and says when it was made blank", async () => { + const first = await getJson("mcp-sid-blank") + expect(first.stateId).toMatch(/^[0-9a-f-]{36}$/) + expect(first.blank).toBe(true) + setState("mcp-sid-blank", "AI write") + const after = await getJson("mcp-sid-blank") + // Same state, no longer blank + expect(after.stateId).toBe(first.stateId) + expect(after.blank).toBe(false) + }) + + it("refuses a push made for another state, also before any poll", async () => { + // The MCP process restarted; the tab's push comes before its poll + onSessionRecreate((id) => (id === "mcp-sid-restart" ? SAVED : null)) + try { + for (const stateId of ["from-before", null]) { + const res = await postJson("/api/state", { + sessionId: "mcp-sid-restart", + xml: "tab's old copy", + baseVersion: 7, + stateId, + }) + expect(res.status).toBe(409) + expect(JSON.parse(res.body).stateChanged).toBe(true) + // The saved file was recovered first and is kept + expect(getState("mcp-sid-restart")?.xml).toBe(SAVED) + } + } finally { + onSessionRecreate(() => null) + } + }) + + it("accepts a push for the current state", async () => { + const { stateId, version } = await getJson("mcp-sid-ok") + const res = await postJson("/api/state", { + sessionId: "mcp-sid-ok", + xml: "user edit", + baseVersion: version, + stateId, + }) + expect(res.status).toBe(200) + expect(getState("mcp-sid-ok")?.xml).toBe("user edit") + }) + + it("keeps a recovering tab's copy in history, never on the canvas", async () => { + setState("mcp-sid-recover", SAVED) + const { stateId, version } = await getJson("mcp-sid-recover") + const before = getHistory("mcp-sid-recover").length + const res = await postJson("/api/state", { + sessionId: "mcp-sid-recover", + xml: "what the tab showed", + baseVersion: version, + stateId, + source: "recover", + }) + expect(res.status).toBe(409) + expect(JSON.parse(res.body).savedToHistory).toBe(true) + expect(getState("mcp-sid-recover")?.xml).toBe(SAVED) + expect(getHistory("mcp-sid-recover")).toHaveLength(before + 1) + expect(getHistory("mcp-sid-recover").at(-1)?.xml).toBe( + "what the tab showed", + ) + }) + + it("keeps the old rules for a tab from an older version", async () => { + // Its pushes have no stateId field + const version = setState("mcp-sid-legacy", "AI") + const res = await postJson("/api/state", { + sessionId: "mcp-sid-legacy", + xml: "edit", + baseVersion: version, + }) + expect(res.status).toBe(200) + }) +}) + +describe("preview page", () => { + it("shows the saved diagram of a session whose state expired", async () => { + const saved = `` + onSessionRecreate((id) => (id === "mcp-expired" ? saved : null)) + try { + await request("/?mcp=mcp-expired") + expect(getState("mcp-expired")?.xml).toBe(saved) + await request("/?mcp=mcp-never-saved") + expect(getState("mcp-never-saved")?.xml).not.toContain("kept") + } finally { + onSessionRecreate(() => null) + } + }) + + it("serves scripts that parse, with every placeholder filled", async () => { + const res = await request("/?mcp=mcp-test-script") + expect(res.body).not.toContain("{{") + // Both scripts share one global scope in the page + const scripts = [...res.body.matchAll(/