From 4a17c47b543383466afed8c41e9293fe98bbfce9 Mon Sep 17 00:00:00 2001 From: yctimlin Date: Thu, 12 Feb 2026 18:09:56 +0800 Subject: [PATCH] Feat/canvas toolkit v2 (#41) * feat: enhance Excalidraw MCP with advanced canvas toolkit features - Rename skill to `excalidraw-skill` with expanded playbook and cheatsheet. - Add new MCP tools for iterative refinement: `describe_scene` and `get_canvas_screenshot`. - Implement layout tools (`align_elements`, `distribute_elements`) and `duplicate_elements`. - Add file I/O support for `.excalidraw` JSON and image export (PNG/SVG). - Introduce named snapshots for canvas state management. - Add server-side element CRUD and WebSocket handlers for real-time sync. - Normalize `points` format for arrows and lines. * docs: update README with v2.0 features and official MCP comparison * feat: implement arrow binding and edge-to-edge routing * fix: enhance security with path sanitization and improve export error handling * feat: add viewport control, design guide, and excalidraw.com URL export * feat: enhance excalidraw.com export with proper scene formatting and labels --- README.md | 85 +- frontend/src/App.tsx | 219 ++- skills/excalidraw-mcp/SKILL.md | 66 - .../excalidraw-mcp/references/cheatsheet.md | 51 - skills/excalidraw-skill/SKILL.md | 106 ++ .../excalidraw-skill/references/cheatsheet.md | 139 ++ .../scripts/clear-canvas.cjs | 9 +- .../scripts/create-element.cjs | 0 .../scripts/delete-element.cjs | 0 .../scripts/export-elements.cjs | 0 .../scripts/healthcheck.cjs | 0 .../scripts/import-elements.cjs | 0 .../scripts/update-element.cjs | 0 src/index.ts | 1340 ++++++++++++++++- src/server.ts | 494 +++++- src/types.ts | 49 +- 16 files changed, 2300 insertions(+), 258 deletions(-) delete mode 100644 skills/excalidraw-mcp/SKILL.md delete mode 100644 skills/excalidraw-mcp/references/cheatsheet.md create mode 100644 skills/excalidraw-skill/SKILL.md create mode 100644 skills/excalidraw-skill/references/cheatsheet.md rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/clear-canvas.cjs (73%) rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/create-element.cjs (100%) rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/delete-element.cjs (100%) rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/export-elements.cjs (100%) rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/healthcheck.cjs (100%) rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/import-elements.cjs (100%) rename skills/{excalidraw-mcp => excalidraw-skill}/scripts/update-element.cjs (100%) diff --git a/README.md b/README.md index 3bc399e..f9f903b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Keywords: Excalidraw agent skill, Excalidraw MCP server, AI diagramming, Claude - [Demo](#demo) - [What It Is](#what-it-is) +- [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp) - [What's New](#whats-new) - [Quick Start (Local)](#quick-start-local) - [Quick Start (Docker)](#quick-start-docker) @@ -33,7 +34,7 @@ Keywords: Excalidraw agent skill, Excalidraw MCP server, AI diagramming, Claude - [OpenCode](#opencode) - [Antigravity (Google)](#antigravity-google) - [Agent Skill (Optional)](#agent-skill-optional) -- [MCP Tools (High Level)](#mcp-tools-high-level) +- [MCP Tools (23 Total)](#mcp-tools-23-total) - [Testing](#testing) - [Troubleshooting](#troubleshooting) - [Known Issues / TODO](#known-issues--todo) @@ -46,9 +47,39 @@ This repo contains two separate processes: - Canvas server: web UI + REST API + WebSocket updates (default `http://localhost:3000`) - MCP server: exposes MCP tools over stdio; syncs to the canvas via `EXPRESS_SERVER_URL` +## How We Differ from the Official Excalidraw MCP + +Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mcp) — it's great for quick, prompt-to-diagram generation rendered inline in chat. We solve a different problem. + +| | Official Excalidraw MCP | This Project | +|---|---|---| +| **Approach** | Prompt in, diagram out (one-shot) | Programmatic element-level control (23 tools) | +| **State** | Stateless — each call is independent | Persistent live canvas with real-time sync | +| **Element CRUD** | No | Full create / read / update / delete per element | +| **AI sees the canvas** | No | `describe_scene` (structured text) + `get_canvas_screenshot` (image) | +| **Iterative refinement** | No — regenerate the whole diagram | Draw → look → adjust → look again, element by element | +| **Layout tools** | No | `align_elements`, `distribute_elements`, `group / ungroup` | +| **File I/O** | No | `export_scene` / `import_scene` (.excalidraw JSON) | +| **Snapshot & rollback** | No | `snapshot_scene` / `restore_snapshot` | +| **Mermaid conversion** | No | `create_from_mermaid` | +| **Live canvas UI** | Rendered inline in chat | Standalone Excalidraw app synced via WebSocket | +| **Multi-agent** | Single user | Multiple agents can draw on the same canvas concurrently | + +**TL;DR** — The official MCP generates diagrams. We give AI agents a full canvas toolkit to build, inspect, and iteratively refine diagrams — including the ability to see what they drew. + ## What's New -- Agent skill: `skills/excalidraw-mcp/` (portable instructions + helper scripts for export/import and repeatable CRUD) +### v2.0 — Canvas Toolkit + +- 10 new MCP tools: `get_element`, `clear_canvas`, `export_scene`, `import_scene`, `export_to_image`, `duplicate_elements`, `snapshot_scene`, `restore_snapshot`, `describe_scene`, `get_canvas_screenshot` (23 tools total) +- **Closed feedback loop**: AI can now inspect the canvas (`describe_scene`) and see it (`get_canvas_screenshot` returns an image) — enabling iterative refinement +- **File I/O**: export/import full `.excalidraw` JSON files +- **Snapshots**: save and restore named canvas states +- Fixed all previously known issues: `align_elements` / `distribute_elements` fully implemented, points type normalization, removed invalid `label` type, removed HTTP transport dead code, `ungroup_elements` now errors on failure + +### v1.x + +- Agent skill: `skills/excalidraw-skill/` (portable instructions + helper scripts for export/import and repeatable CRUD) - Better testing loop: MCP Inspector CLI examples + browser screenshot checks (`agent-browser`) - Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed (`main.tsx`) @@ -328,7 +359,7 @@ Config location: `~/.gemini/antigravity/mcp_config.json` ## Agent Skill (Optional) -This repo includes a skill at `skills/excalidraw-mcp/` that provides: +This repo includes a skill at `skills/excalidraw-skill/` that provides: - **Workflow playbook** (`SKILL.md`): step-by-step guidance for drawing, refining, and exporting diagrams - **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference @@ -340,26 +371,26 @@ The skill complements the MCP server by giving your AI agent structured workflow ```bash mkdir -p ~/.codex/skills -cp -R skills/excalidraw-mcp ~/.codex/skills/excalidraw-mcp +cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill ``` -To update an existing installation, remove the old folder first (`rm -rf ~/.codex/skills/excalidraw-mcp`) then re-copy. +To update an existing installation, remove the old folder first (`rm -rf ~/.codex/skills/excalidraw-skill`) then re-copy. ### Install The Skill (Claude Code) **User-level** (available across all your projects): ```bash mkdir -p ~/.claude/skills -cp -R skills/excalidraw-mcp ~/.claude/skills/excalidraw-mcp +cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill ``` **Project-level** (scoped to a specific project, can be committed to the repo): ```bash mkdir -p /path/to/your/project/.claude/skills -cp -R skills/excalidraw-mcp /path/to/your/project/.claude/skills/excalidraw-mcp +cp -R skills/excalidraw-skill /path/to/your/project/.claude/skills/excalidraw-skill ``` -Then invoke the skill in Claude Code with `/excalidraw-mcp`. +Then invoke the skill in Claude Code with `/excalidraw-skill`. To update an existing installation, remove the old folder first then re-copy. @@ -368,9 +399,9 @@ To update an existing installation, remove the old folder first then re-copy. All scripts respect `EXPRESS_SERVER_URL` (default `http://localhost:3000`) or accept `--url`. ```bash -EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/healthcheck.cjs -EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/export-elements.cjs --out diagram.elements.json -EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/import-elements.cjs --in diagram.elements.json --mode batch +EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/healthcheck.cjs +EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/export-elements.cjs --out diagram.elements.json +EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/import-elements.cjs --in diagram.elements.json --mode batch ``` ### When The Skill Is Useful @@ -380,21 +411,20 @@ EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/impo - Automated smoke tests: create/update/delete a known element to validate a deployment. - Repeatable diagrams: keep a library of element JSON snippets and import them. -See `skills/excalidraw-mcp/SKILL.md` and `skills/excalidraw-mcp/references/cheatsheet.md`. +See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`. -## MCP Tools (High Level) +## MCP Tools (23 Total) -The MCP server exposes tools such as: +| Category | Tools | +|---|---| +| **Element CRUD** | `create_element`, `get_element`, `update_element`, `delete_element`, `query_elements`, `batch_create_elements`, `duplicate_elements` | +| **Layout** | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` | +| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` | +| **File I/O** | `export_scene`, `import_scene`, `export_to_image`, `create_from_mermaid` | +| **State Management** | `clear_canvas`, `snapshot_scene`, `restore_snapshot` | +| **Resources** | `get_resource` | -- `create_element`, `update_element`, `delete_element` -- `query_elements`, `get_resource` -- `batch_create_elements` -- `align_elements`, `distribute_elements` -- `group_elements`, `ungroup_elements` -- `lock_elements`, `unlock_elements` -- `create_from_mermaid` (frontend converts Mermaid to Excalidraw elements) - -The full tool list and schemas are discoverable via MCP Inspector (`tools/list`) or by reading `src/index.ts`. +Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`. ## Testing @@ -441,13 +471,10 @@ agent-browser screenshot /tmp/canvas.png ## Known Issues / TODO -The following issues are known and tracked for future improvement: +All previously listed bugs have been fixed in v2.0. Remaining items: -- [ ] **`align_elements` / `distribute_elements` are stubs**: These tools log and return success but do not actually move elements. Implementation needed in `src/index.ts:782-806`. -- [ ] **`points` type mismatch**: The Zod schema expects `[{x, y}]` objects but Excalidraw expects `[[x, y]]` tuples. This may cause issues when creating arrows/lines via MCP. See `src/index.ts:187` vs `src/types.ts:64`. -- [ ] **`label` element type incomplete**: The `label` type is defined in `EXCALIDRAW_ELEMENT_TYPES` but has no corresponding interface in the type union. See `src/types.ts:109,118`. -- [ ] **HTTP transport mode placeholder**: `MCP_TRANSPORT_MODE=http` is accepted but falls back to stdio. See `src/index.ts:989-996`. -- [ ] **`ungroup_elements` silent success**: Returns success even when no elements are ungrouped (e.g., elements not found). See `src/index.ts:765-769`. +- [ ] **Persistent storage**: Elements are stored in-memory — restarting the server clears everything. Use `export_scene` / snapshots as a workaround. +- [ ] **Image export requires a browser**: `export_to_image` and `get_canvas_screenshot` rely on the frontend doing the actual rendering. The canvas UI must be open in a browser. Contributions welcome! diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da2ee53..28a7e7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,9 @@ import { Excalidraw, convertToExcalidrawElements, CaptureUpdateAction, - ExcalidrawImperativeAPI + ExcalidrawImperativeAPI, + exportToBlob, + exportToSvg } from '@excalidraw/excalidraw' import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter' @@ -39,6 +41,12 @@ interface ServerElement { boundElements?: any[] | null; containerId?: string | null; locked?: boolean; + // Arrow element binding + start?: { id: string }; + end?: { id: string }; + strokeStyle?: string; + endArrowhead?: string; + startArrowhead?: string; } interface WebSocketMessage { @@ -240,13 +248,24 @@ function App(): JSX.Element { case 'element_created': if (data.element) { const cleanedNewElement = cleanElementForExcalidraw(data.element) - // Preserve server IDs so later update/delete websocket events can match by id. - const newElement = convertToExcalidrawElements([cleanedNewElement], { regenerateIds: false }) - const updatedElementsAfterCreate = [...currentElements, ...newElement] - excalidrawAPI.updateScene({ - elements: updatedElementsAfterCreate, - captureUpdate: CaptureUpdateAction.NEVER - }) + const hasBindings = (cleanedNewElement as any).start || (cleanedNewElement as any).end + if (hasBindings) { + // Bound arrow: re-convert all elements together so bindings resolve + const allElements = [...currentElements, cleanedNewElement] as any[] + const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false }) + excalidrawAPI.updateScene({ + elements: convertedAll, + captureUpdate: CaptureUpdateAction.NEVER + }) + } else { + // Preserve server IDs so later update/delete websocket events can match by id. + const newElement = convertToExcalidrawElements([cleanedNewElement], { regenerateIds: false }) + const updatedElementsAfterCreate = [...currentElements, ...newElement] + excalidrawAPI.updateScene({ + elements: updatedElementsAfterCreate, + captureUpdate: CaptureUpdateAction.NEVER + }) + } } break @@ -278,13 +297,24 @@ function App(): JSX.Element { case 'elements_batch_created': if (data.elements) { const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) - // Preserve server IDs so later update/delete websocket events can match by id. - const batchElements = convertToExcalidrawElements(cleanedBatchElements, { regenerateIds: false }) - const updatedElementsAfterBatch = [...currentElements, ...batchElements] - excalidrawAPI.updateScene({ - elements: updatedElementsAfterBatch, - captureUpdate: CaptureUpdateAction.NEVER - }) + const hasBoundArrows = cleanedBatchElements.some((el: any) => el.start || el.end) + if (hasBoundArrows) { + // Convert ALL elements together so arrow bindings resolve to target shapes + const allElements = [...currentElements, ...cleanedBatchElements] as any[] + const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false }) + excalidrawAPI.updateScene({ + elements: convertedAll, + captureUpdate: CaptureUpdateAction.NEVER + }) + } else { + // Preserve server IDs so later update/delete websocket events can match by id. + const batchElements = convertToExcalidrawElements(cleanedBatchElements, { regenerateIds: false }) + const updatedElementsAfterBatch = [...currentElements, ...batchElements] + excalidrawAPI.updateScene({ + elements: updatedElementsAfterBatch, + captureUpdate: CaptureUpdateAction.NEVER + }) + } } break @@ -297,6 +327,165 @@ function App(): JSX.Element { console.log(`Server sync status: ${data.count} elements`) break + case 'canvas_cleared': + console.log('Canvas cleared by server') + excalidrawAPI.updateScene({ + elements: [], + captureUpdate: CaptureUpdateAction.NEVER + }) + break + + case 'export_image_request': + console.log('Received image export request', data) + if (data.requestId) { + try { + const elements = excalidrawAPI.getSceneElements() + const appState = excalidrawAPI.getAppState() + const files = excalidrawAPI.getFiles() + + if (data.format === 'svg') { + const svg = await exportToSvg({ + elements, + appState: { + ...appState, + exportBackground: data.background !== false + }, + files + }) + const svgString = new XMLSerializer().serializeToString(svg) + await fetch('/api/export/image/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + format: 'svg', + data: svgString + }) + }) + } else { + const blob = await exportToBlob({ + elements, + appState: { + ...appState, + exportBackground: data.background !== false + }, + files, + mimeType: 'image/png' + }) + const reader = new FileReader() + reader.onload = async () => { + try { + const resultString = reader.result as string + const base64 = resultString?.split(',')[1] + if (!base64) { + throw new Error('Could not extract base64 data from result') + } + await fetch('/api/export/image/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + format: 'png', + data: base64 + }) + }) + } catch (readerError) { + console.error('Image export (FileReader) failed:', readerError) + await fetch('/api/export/image/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + error: (readerError as Error).message + }) + }).catch(() => {}) + } + } + reader.onerror = async () => { + console.error('FileReader error:', reader.error) + await fetch('/api/export/image/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + error: reader.error?.message || 'FileReader failed' + }) + }).catch(() => {}) + } + reader.readAsDataURL(blob) + } + console.log('Image export completed for request', data.requestId) + } catch (exportError) { + console.error('Image export failed:', exportError) + await fetch('/api/export/image/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + error: (exportError as Error).message + }) + }) + } + } + break + + case 'set_viewport': + console.log('Received viewport control request', data) + if (data.requestId) { + try { + if (data.scrollToContent) { + const allElements = excalidrawAPI.getSceneElements() + if (allElements.length > 0) { + excalidrawAPI.scrollToContent(allElements, { fitToViewport: true, animate: true }) + } + } else if (data.scrollToElementId) { + const allElements = excalidrawAPI.getSceneElements() + const targetElement = allElements.find(el => el.id === data.scrollToElementId) + if (targetElement) { + excalidrawAPI.scrollToContent([targetElement], { fitToViewport: false, animate: true }) + } else { + throw new Error(`Element ${data.scrollToElementId} not found`) + } + } else { + // Direct zoom/scroll control + const appState: any = {} + if (data.zoom !== undefined) { + appState.zoom = { value: data.zoom } + } + if (data.offsetX !== undefined) { + appState.scrollX = data.offsetX + } + if (data.offsetY !== undefined) { + appState.scrollY = data.offsetY + } + if (Object.keys(appState).length > 0) { + excalidrawAPI.updateScene({ appState }) + } + } + + await fetch('/api/viewport/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + success: true, + message: 'Viewport updated' + }) + }) + } catch (viewportError) { + console.error('Viewport control failed:', viewportError) + await fetch('/api/viewport/result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requestId: data.requestId, + error: (viewportError as Error).message + }) + }).catch(() => {}) + } + } + break + case 'mermaid_convert': console.log('Received Mermaid conversion request from MCP') if (data.mermaidDiagram) { diff --git a/skills/excalidraw-mcp/SKILL.md b/skills/excalidraw-mcp/SKILL.md deleted file mode 100644 index 74d9236..0000000 --- a/skills/excalidraw-mcp/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: excalidraw-mcp -description: Create, edit, and export live Excalidraw diagrams using mcp-excalidraw-server (MCP tools + canvas REST API). Use when an agent needs to draw/lay out diagrams, convert Mermaid to Excalidraw, query/update/delete elements, or export/import elements from a running canvas server (EXPRESS_SERVER_URL, default http://localhost:3000). ---- - -# Excalidraw MCP - -## Overview - -Create and refine diagrams on a live Excalidraw canvas via MCP tools, with helper scripts for export/import workflows. - -## Quick Start - -- Ensure the canvas server is reachable at `EXPRESS_SERVER_URL` (default `http://localhost:3000`). -- Use MCP tools for interactive diagram edits; use `scripts/*.cjs` for file-ish workflows (export/import/clear/health). -- For detailed endpoint/tool reference, read `references/cheatsheet.md`. - -## Workflow: Draw A Diagram (From Empty Canvas) - -1. Confirm canvas is up: - - Run `node scripts/healthcheck.cjs` (or GET `/health`). -2. Optional: clear the canvas: - - Run `node scripts/clear-canvas.cjs`. -3. Create shapes first (rectangles/diamonds/ellipses), using `create_element`. -4. Put text on shapes by setting the shape’s `text` field (do not create a separate text element unless you need standalone text). -5. Create arrows/lines after both endpoints exist. -6. Use `align_elements` / `distribute_elements` after rough placement; group only after layout stabilizes. - -## Workflow: Refine An Existing Diagram - -1. Discover what’s already there: - - Prefer `get_resource` with `resource: "elements"` or `query_elements`. -2. Identify targets by stable signals (id, type, label text), not by exact x/y. -3. Update with `update_element` (move/resize/colors/text) or delete with `delete_element`. -4. If deletes/updates “don’t work”, check: - - You’re pointing to the right `EXPRESS_SERVER_URL`. - - The element id exists on the canvas (use `get_resource` / `GET /api/elements/:id`). - - The element isn’t locked (use `unlock_elements` first). - -## Workflow: Export / Import (Repository-Friendly) - -- Export current elements to a JSON file: - - `node scripts/export-elements.cjs --out diagram.elements.json` -- Import elements (append) using batch create: - - `node scripts/import-elements.cjs --in diagram.elements.json --mode batch` -- Import elements (overwrite canvas) using sync: - - `node scripts/import-elements.cjs --in diagram.elements.json --mode sync` - -Notes: -- `--mode sync` clears the canvas and then writes the provided elements (good for “make canvas match this file”). -- If you want stable ids across updates, keep ids in the exported JSON; if you want fresh ids, regenerate before importing. - -## Workflow: CRUD Smoke Test (Create → Update → Delete) - -1. Clear: - - `node scripts/clear-canvas.cjs` -2. Create a large visible rectangle + label: - - Use `node scripts/create-element.cjs` twice (rectangle + text). -3. Update: - - Move the rectangle with `node scripts/update-element.cjs`. -4. Delete: - - Remove both with `node scripts/delete-element.cjs`. - -## References - -- `references/cheatsheet.md`: MCP tool list + REST API endpoints + payload shapes diff --git a/skills/excalidraw-mcp/references/cheatsheet.md b/skills/excalidraw-mcp/references/cheatsheet.md deleted file mode 100644 index 05fc8fe..0000000 --- a/skills/excalidraw-mcp/references/cheatsheet.md +++ /dev/null @@ -1,51 +0,0 @@ -# Excalidraw MCP Cheatsheet - -## Defaults - -- Canvas base URL: `EXPRESS_SERVER_URL` (default `http://localhost:3000`) -- Canvas health: `GET /health` - -## MCP Tools (Server-Side) - -Tool names are defined in `src/index.ts`. - -- `create_element`: create a shape/text/arrow/line -- `update_element`: update an element by `id` -- `delete_element`: delete an element by `id` -- `query_elements`: query by `type` and/or exact-match filters -- `get_resource`: `scene` | `library` | `theme` | `elements` -- `group_elements` / `ungroup_elements` -- `align_elements` / `distribute_elements` -- `lock_elements` / `unlock_elements` -- `create_from_mermaid`: send Mermaid diagram to canvas for frontend conversion -- `batch_create_elements`: create many elements in one call - -Notes: -- For shapes, set the `text` field to place text inside the shape (backend converts to `label.text`). -- Prefer creating shapes first, then arrows, then alignment/grouping. - -## Canvas REST API (HTTP) - -Read/write primitives (used by the MCP server and helper scripts): - -- `GET /api/elements` -> `{ success, elements, count }` -- `GET /api/elements/:id` -> `{ success, element }` -- `POST /api/elements` -> `{ success, element }` -- `PUT /api/elements/:id` -> `{ success, element }` -- `DELETE /api/elements/:id` -> `{ success, message }` -- `GET /api/elements/search?type=...&key=value` -> `{ success, elements, count }` -- `POST /api/elements/batch` -> `{ success, elements, count }` -- `POST /api/elements/from-mermaid` -> triggers websocket conversion on the frontend -- `POST /api/elements/sync` -> clears stored elements, then writes provided ones (overwrite import) - -## Skill Scripts - -All scripts accept `--url ` (defaults to `EXPRESS_SERVER_URL`). - -- `node scripts/healthcheck.cjs` -- `node scripts/clear-canvas.cjs` -- `node scripts/export-elements.cjs --out diagram.elements.json` -- `node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync` -- `node scripts/create-element.cjs --data '{...}'` -- `node scripts/update-element.cjs --id --data '{...}'` -- `node scripts/delete-element.cjs --id ` diff --git a/skills/excalidraw-skill/SKILL.md b/skills/excalidraw-skill/SKILL.md new file mode 100644 index 0000000..09259f7 --- /dev/null +++ b/skills/excalidraw-skill/SKILL.md @@ -0,0 +1,106 @@ +--- +name: excalidraw-skill +description: Programmatic canvas toolkit for creating, editing, and refining Excalidraw diagrams via MCP tools with real-time canvas sync. Use when an agent needs to (1) draw or lay out diagrams on a live canvas, (2) iteratively refine diagrams using describe_scene and get_canvas_screenshot to see its own work, (3) export/import .excalidraw files or PNG/SVG images, (4) save/restore canvas snapshots, (5) convert Mermaid to Excalidraw, or (6) perform element-level CRUD, alignment, distribution, grouping, duplication, and locking. Requires a running canvas server (EXPRESS_SERVER_URL, default http://localhost:3000). +--- + +# Excalidraw Skill + +## Quick Start + +1. Ensure canvas server is reachable at `EXPRESS_SERVER_URL` (default `http://localhost:3000`). +2. Open the canvas URL in a browser (required for image export/screenshot). +3. Use MCP tools for all diagram operations; use `scripts/*.cjs` for CLI workflows. +4. For full tool/endpoint reference, read `references/cheatsheet.md`. + +## Workflow: Draw A Diagram + +1. **Call `read_diagram_guide`** first to load design best practices (colors, sizing, layout, anti-patterns). +2. Confirm canvas: `node scripts/healthcheck.cjs` or `GET /health`. +3. Optional: `clear_canvas` to start fresh. +4. Use `batch_create_elements` with all shapes AND arrows in one call. +5. **Assign custom `id` to shapes** (e.g. `"id": "auth-svc"`). Set `text` field to label shapes. +6. **Bind arrows to shapes** using `startElementId` / `endElementId` — arrows auto-route to element edges. +7. `align_elements` / `distribute_elements` after rough placement. +8. `set_viewport` with `scrollToContent: true` to auto-fit the diagram in view. +9. `describe_scene` to verify layout. `get_canvas_screenshot` to visually check. + +### Arrow Binding (Recommended) + +Use `startElementId` and `endElementId` on arrows to bind them to shapes. The server automatically calculates edge-to-edge routing with proper gaps. Example: +```json +{"elements": [ + {"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 120, "height": 60, "text": "Service A"}, + {"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 120, "height": 60, "text": "Service B"}, + {"type": "arrow", "x": 0, "y": 0, "startElementId": "svc-a", "endElementId": "svc-b", "text": "calls"} +]} +``` +Arrows without `startElementId`/`endElementId` use manual `x`, `y`, `points` coordinates. + +## Workflow: Iterative Refinement (Key Differentiator) + +The feedback loop that makes this skill unique: + +1. `describe_scene` -- read what's on the canvas (types, positions, labels, connections). +2. Decide what to change based on the description. +3. Apply changes (`update_element`, `align_elements`, `create_element`, etc.). +4. `get_canvas_screenshot` -- visually verify the result (returns PNG to multimodal AI). +5. Repeat until satisfied. + +Example flow: +``` +create_element (rectangles, arrows) → describe_scene → "layout is cramped" +→ distribute_elements → get_canvas_screenshot → "arrow misaligned" +→ update_element → get_canvas_screenshot → "looks good" +→ export_scene --filePath architecture.excalidraw +``` + +## Workflow: Refine An Existing Diagram + +1. `describe_scene` to understand current state. +2. Identify targets by id, type, or label text (not x/y coordinates). +3. `update_element` to move/resize/recolor, `delete_element` to remove. +4. `get_canvas_screenshot` to verify changes visually. +5. If updates fail: check element id exists (`get_element`), element isn't locked (`unlock_elements`). + +## Workflow: File I/O (Diagrams-as-Code) + +- Export to .excalidraw format: `export_scene` with optional `filePath`. +- Import from .excalidraw: `import_scene` with `mode: "replace"` or `"merge"`. +- Export to image: `export_to_image` with `format: "png"` or `"svg"` (requires browser open). +- CLI export: `node scripts/export-elements.cjs --out diagram.elements.json` +- CLI import: `node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync` + +## Workflow: Snapshots (Save/Restore Canvas State) + +1. `snapshot_scene` with a name before risky changes. +2. Make changes, `describe_scene` / `get_canvas_screenshot` to evaluate. +3. `restore_snapshot` to rollback if needed. + +## Workflow: Duplication + +- `duplicate_elements` with `elementIds` and optional `offsetX`/`offsetY` (default 20,20). +- Useful for creating repeated patterns or copying existing layouts. + +## Points Format for Arrows/Lines + +The `points` field accepts both formats: +- Tuple: `[[0, 0], [100, 50]]` +- Object: `[{"x": 0, "y": 0}, {"x": 100, "y": 50}]` + +Both are normalized to tuples automatically. + +## Workflow: Share Diagram (excalidraw.com URL) + +1. Create your diagram using any of the above workflows. +2. `export_to_excalidraw_url` — uploads encrypted scene, returns a shareable URL. +3. Share the URL — anyone can open it in excalidraw.com to view and edit. + +## Workflow: Viewport Control + +- `set_viewport` with `scrollToContent: true` — auto-fit all elements (zoom-to-fit). +- `set_viewport` with `scrollToElementId: "my-element"` — center view on a specific element. +- `set_viewport` with `zoom: 1.5, offsetX: 100, offsetY: 200` — manual camera control. + +## References + +- `references/cheatsheet.md`: Complete MCP tool list (26 tools) + REST API endpoints + payload shapes. diff --git a/skills/excalidraw-skill/references/cheatsheet.md b/skills/excalidraw-skill/references/cheatsheet.md new file mode 100644 index 0000000..c3f1c17 --- /dev/null +++ b/skills/excalidraw-skill/references/cheatsheet.md @@ -0,0 +1,139 @@ +# Excalidraw Skill Cheatsheet + +## Defaults + +- Canvas base URL: `EXPRESS_SERVER_URL` (default `http://localhost:3000`) +- Canvas health: `GET /health` + +## MCP Tools (26 total) + +### Element CRUD + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `create_element` | Create shape/text/arrow/line | `type`, `x`, `y` | +| `get_element` | Get single element by ID | `id` | +| `update_element` | Update element properties | `id` | +| `delete_element` | Delete element | `id` | +| `query_elements` | Query by type/filters | (optional) `type`, `filter` | +| `batch_create_elements` | Create many at once | `elements[]` | +| `duplicate_elements` | Clone with offset | `elementIds[]`, (optional) `offsetX`, `offsetY` | + +### Layout & Organization + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `align_elements` | Align to left/center/right/top/middle/bottom | `elementIds[]`, `alignment` | +| `distribute_elements` | Even spacing horizontal/vertical | `elementIds[]`, `direction` | +| `group_elements` | Group elements | `elementIds[]` | +| `ungroup_elements` | Ungroup | `groupId` | +| `lock_elements` | Lock elements | `elementIds[]` | +| `unlock_elements` | Unlock elements | `elementIds[]` | + +### Scene Awareness (Iterative Refinement) + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `describe_scene` | AI-readable scene description (types, positions, labels, connections, bounding box) | (none) | +| `get_canvas_screenshot` | Returns PNG image of canvas for visual verification | (optional) `background` | +| `get_resource` | Get scene/library/theme/elements | `resource` | + +### File I/O & Export + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `export_scene` | Export to .excalidraw JSON | (optional) `filePath` | +| `import_scene` | Import from .excalidraw JSON | `mode` ("replace"\|"merge"), `filePath` or `data` | +| `export_to_image` | Export to PNG/SVG (needs browser) | `format` ("png"\|"svg"), (optional) `filePath`, `background` | +| `export_to_excalidraw_url` | Upload & get shareable excalidraw.com URL | (none) | + +### State Management + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `clear_canvas` | Remove all elements | (none) | +| `snapshot_scene` | Save named snapshot | `name` | +| `restore_snapshot` | Restore from snapshot | `name` | + +### Viewport & Camera + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `set_viewport` | Control camera: zoom-to-fit, center on element, manual zoom/scroll (needs browser) | (optional) `scrollToContent`, `scrollToElementId`, `zoom`, `offsetX`, `offsetY` | + +### Design Guide + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `read_diagram_guide` | Get design best practices (colors, sizing, layout, anti-patterns) | (none) | + +### Conversion + +| Tool | Description | Required params | +|------|-------------|-----------------| +| `create_from_mermaid` | Mermaid diagram to Excalidraw | `mermaidDiagram` | + +Notes: +- For shapes, set `text` field to place text inside (backend converts to `label.text`). +- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects. +- Prefer creating shapes first, then arrows, then alignment/grouping. + +## Canvas REST API (HTTP) + +### Elements + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/elements` | List all elements | +| `GET` | `/api/elements/:id` | Get element by ID | +| `POST` | `/api/elements` | Create element | +| `PUT` | `/api/elements/:id` | Update element | +| `DELETE` | `/api/elements/:id` | Delete element | +| `DELETE` | `/api/elements/clear` | Clear all elements | +| `GET` | `/api/elements/search?type=...` | Search with filters | +| `POST` | `/api/elements/batch` | Batch create | +| `POST` | `/api/elements/sync` | Overwrite import (clear + write) | +| `POST` | `/api/elements/from-mermaid` | Mermaid conversion via frontend | + +### Export + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/export/image` | Request image export (needs frontend) | +| `POST` | `/api/export/image/result` | Frontend posts export result back | + +### Viewport + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/viewport` | Set viewport/camera (needs frontend) | +| `POST` | `/api/viewport/result` | Frontend posts viewport result back | + +### Snapshots + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/snapshots` | Save snapshot `{name}` | +| `GET` | `/api/snapshots` | List snapshots | +| `GET` | `/api/snapshots/:name` | Get snapshot by name | + +### System + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/health` | Health check | +| `GET` | `/api/sync/status` | Memory/WebSocket stats | + +## Skill Scripts + +All scripts accept `--url ` (defaults to `EXPRESS_SERVER_URL`). + +```bash +node scripts/healthcheck.cjs +node scripts/clear-canvas.cjs +node scripts/export-elements.cjs --out diagram.elements.json +node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync +node scripts/create-element.cjs --data '{...}' +node scripts/update-element.cjs --id --data '{...}' +node scripts/delete-element.cjs --id +``` diff --git a/skills/excalidraw-mcp/scripts/clear-canvas.cjs b/skills/excalidraw-skill/scripts/clear-canvas.cjs similarity index 73% rename from skills/excalidraw-mcp/scripts/clear-canvas.cjs rename to skills/excalidraw-skill/scripts/clear-canvas.cjs index 718d9d6..1ab72a9 100644 --- a/skills/excalidraw-mcp/scripts/clear-canvas.cjs +++ b/skills/excalidraw-skill/scripts/clear-canvas.cjs @@ -20,11 +20,8 @@ async function main() { const { url } = parseArgs(process.argv.slice(2)); const baseUrl = url.replace(/\/$/, ""); - // Use the sync endpoint as a fast "clear" primitive (clears server storage). - const res = await fetch(`${baseUrl}/api/elements/sync`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ elements: [], timestamp: new Date().toISOString() }), + const res = await fetch(`${baseUrl}/api/elements/clear`, { + method: "DELETE", }); const json = await res.json().catch(() => null); @@ -32,7 +29,7 @@ async function main() { throw new Error(`Failed to clear canvas: ${res.status} ${res.statusText} ${json?.error ? `- ${json.error}` : ""}`); } - console.log("Cleared canvas"); + console.log(`Cleared canvas (${json.count} elements removed)`); } main().catch((err) => { diff --git a/skills/excalidraw-mcp/scripts/create-element.cjs b/skills/excalidraw-skill/scripts/create-element.cjs similarity index 100% rename from skills/excalidraw-mcp/scripts/create-element.cjs rename to skills/excalidraw-skill/scripts/create-element.cjs diff --git a/skills/excalidraw-mcp/scripts/delete-element.cjs b/skills/excalidraw-skill/scripts/delete-element.cjs similarity index 100% rename from skills/excalidraw-mcp/scripts/delete-element.cjs rename to skills/excalidraw-skill/scripts/delete-element.cjs diff --git a/skills/excalidraw-mcp/scripts/export-elements.cjs b/skills/excalidraw-skill/scripts/export-elements.cjs similarity index 100% rename from skills/excalidraw-mcp/scripts/export-elements.cjs rename to skills/excalidraw-skill/scripts/export-elements.cjs diff --git a/skills/excalidraw-mcp/scripts/healthcheck.cjs b/skills/excalidraw-skill/scripts/healthcheck.cjs similarity index 100% rename from skills/excalidraw-mcp/scripts/healthcheck.cjs rename to skills/excalidraw-skill/scripts/healthcheck.cjs diff --git a/skills/excalidraw-mcp/scripts/import-elements.cjs b/skills/excalidraw-skill/scripts/import-elements.cjs similarity index 100% rename from skills/excalidraw-mcp/scripts/import-elements.cjs rename to skills/excalidraw-skill/scripts/import-elements.cjs diff --git a/skills/excalidraw-mcp/scripts/update-element.cjs b/skills/excalidraw-skill/scripts/update-element.cjs similarity index 100% rename from skills/excalidraw-mcp/scripts/update-element.cjs rename to skills/excalidraw-skill/scripts/update-element.cjs diff --git a/src/index.ts b/src/index.ts index 356ea80..4ed861d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ process.env.NODE_DISABLE_COLORS = '1'; process.env.NO_COLOR = '1'; import { fileURLToPath } from "url"; +import { deflateSync } from 'zlib'; +import { webcrypto } from 'crypto'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -15,9 +17,11 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import dotenv from 'dotenv'; +import fs from 'fs'; +import path from 'path'; import logger from './utils/logger.js'; -import { - generateId, +import { + generateId, EXCALIDRAW_ELEMENT_TYPES, ServerElement, ExcalidrawElementType, @@ -28,6 +32,21 @@ import fetch from 'node-fetch'; // Load environment variables dotenv.config(); +// Safe file path validation to prevent path traversal attacks +const ALLOWED_EXPORT_DIR = process.env.EXCALIDRAW_EXPORT_DIR || process.cwd(); + +function sanitizeFilePath(filePath: string): string { + const resolved = path.resolve(filePath); + const allowedDir = path.resolve(ALLOWED_EXPORT_DIR); + if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) { + throw new Error( + `Path traversal blocked: "${filePath}" resolves outside the allowed directory "${allowedDir}". ` + + `Set EXCALIDRAW_EXPORT_DIR to change the allowed base directory.` + ); + } + return resolved; +} + // Express server configuration const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000'; const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true @@ -177,14 +196,28 @@ const sceneState: SceneState = { groups: new Map() }; +// Points schema: accept both {x, y} objects and [x, y] tuples +const PointObjectSchema = z.object({ x: z.number(), y: z.number() }); +const PointTupleSchema = z.tuple([z.number(), z.number()]); +const PointSchema = z.union([PointObjectSchema, PointTupleSchema]); + +// Normalize points to [x, y] tuple format that Excalidraw expects +function normalizePoints(points: Array<{ x: number; y: number } | [number, number]>): [number, number][] { + return points.map(p => { + if (Array.isArray(p)) return p as [number, number]; + return [p.x, p.y] as [number, number]; + }); +} + // Schema definitions using zod const ElementSchema = z.object({ + id: z.string().optional(), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), x: z.number(), y: z.number(), width: z.number().optional(), height: z.number().optional(), - points: z.array(z.object({ x: z.number(), y: z.number() })).optional(), + points: z.array(PointSchema).optional(), backgroundColor: z.string().optional(), strokeColor: z.string().optional(), strokeWidth: z.number().optional(), @@ -194,7 +227,12 @@ const ElementSchema = z.object({ fontSize: z.number().optional(), fontFamily: z.string().optional(), groupIds: z.array(z.string()).optional(), - locked: z.boolean().optional() + locked: z.boolean().optional(), + strokeStyle: z.string().optional(), + startElementId: z.string().optional(), + endElementId: z.string().optional(), + endArrowhead: z.string().optional(), + startArrowhead: z.string().optional(), }); const ElementIdSchema = z.object({ @@ -228,17 +266,111 @@ const ResourceSchema = z.object({ resource: z.enum(['scene', 'library', 'theme', 'elements']) }); +// Diagram design guide — injected into LLM context via read_diagram_guide tool +const DIAGRAM_DESIGN_GUIDE = `# Excalidraw Diagram Design Guide + +## Color Palette + +### Stroke Colors (use for borders & text) +| Name | Hex | Use for | +|---------|-----------|-----------------------------| +| Black | #1e1e1e | Default text & borders | +| Red | #e03131 | Errors, warnings, critical | +| Green | #2f9e44 | Success, approved, healthy | +| Blue | #1971c2 | Primary actions, links | +| Purple | #9c36b5 | Services, middleware | +| Orange | #e8590c | Async, queues, events | +| Cyan | #0c8599 | Data stores, databases | +| Gray | #868e96 | Annotations, secondary | + +### Fill Colors (use for backgroundColor — pastel fills) +| Name | Hex | Pairs with stroke | +|--------------|-----------|-------------------| +| Light Red | #ffc9c9 | #e03131 | +| Light Green | #b2f2bb | #2f9e44 | +| Light Blue | #a5d8ff | #1971c2 | +| Light Purple | #eebefa | #9c36b5 | +| Light Orange | #ffd8a8 | #e8590c | +| Light Cyan | #99e9f2 | #0c8599 | +| Light Gray | #e9ecef | #868e96 | +| White | #ffffff | #1e1e1e | + +## Sizing Rules + +- **Minimum shape size**: width >= 120px, height >= 60px +- **Font sizes**: body text >= 16, titles/headers >= 20, small labels >= 14 +- **Padding**: leave at least 20px inside shapes for text breathing room +- **Arrow length**: minimum 80px between connected shapes +- **Consistent sizing**: keep same-role shapes identical dimensions + +## Layout Patterns + +- **Grid snap**: align to 20px grid for clean layouts +- **Spacing**: 40–80px gap between adjacent shapes +- **Flow direction**: top-to-bottom (vertical) or left-to-right (horizontal) +- **Hierarchy**: important nodes larger or higher; left-to-right = temporal order +- **Grouping**: cluster related elements visually; use background rectangles as zones + +## Arrow Binding Best Practices + +- **Always bind**: use \`startElementId\` / \`endElementId\` to connect arrows to shapes +- **Dashed arrows**: use \`strokeStyle: "dashed"\` for async, optional, or event flows +- **Dotted arrows**: use \`strokeStyle: "dotted"\` for weak dependencies or annotations +- **Arrowheads**: default "arrow" for directed flow; "dot" for data stores; null for lines +- **Label arrows**: set \`text\` on arrows to describe the relationship (e.g., "HTTP", "publishes") + +## Diagram Type Templates + +### Architecture Diagram +- Shapes: 160×80 rectangles for services, 120×60 for small components +- Colors: different fill per layer (frontend=blue, backend=purple, data=cyan) +- Arrows: solid for sync calls, dashed for async/events +- Zones: large light-gray background rectangles with 20px fontSize labels + +### Flowchart +- Shapes: 140×70 rectangles for steps, 100×100 diamonds for decisions +- Flow: top-to-bottom, 60px vertical spacing +- Colors: green start, red end, blue for process steps +- Arrows: solid, with "Yes"/"No" labels from diamonds + +### ER Diagram +- Shapes: 180×40 per entity (wider for attribute lists) +- Layout: 80px between entities +- Arrows: use start/end arrowheads to show cardinality +- Colors: light-blue fill for entities, no fill for junction tables + +## Anti-Patterns to Avoid + +1. **Overlapping elements** — always leave gaps; use distribute_elements +2. **Cramped spacing** — minimum 40px between shapes +3. **Tiny fonts** — never below 14px; prefer 16+ +4. **Manual arrow coordinates** — always use startElementId/endElementId binding +5. **Too many colors** — limit to 3–4 fill colors per diagram +6. **Inconsistent sizes** — same-role shapes should be same width/height +7. **No labels** — every shape and meaningful arrow should have text +8. **Flat layouts** — use zones/groups to create visual hierarchy + +## Drawing Order (Recommended) + +1. **Background zones** — large rectangles with light fill, low opacity +2. **Primary shapes** — services, entities, steps (with labels via \`text\`) +3. **Arrows** — connect shapes using binding IDs +4. **Annotations** — standalone text elements for notes, titles +5. **Refinement** — align, distribute, adjust spacing, screenshot to verify +`; + // Tool definitions const tools: Tool[] = [ { name: 'create_element', - description: 'Create a new Excalidraw element', + description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges).', inputSchema: { type: 'object', properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) + id: { type: 'string', description: 'Custom element ID (optional, auto-generated if omitted). Use with startElementId/endElementId in batch_create_elements.' }, + type: { + type: 'string', + enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) }, x: { type: 'number' }, y: { type: 'number' }, @@ -247,11 +379,16 @@ const tools: Tool[] = [ backgroundColor: { type: 'string' }, strokeColor: { type: 'string' }, strokeWidth: { type: 'number' }, + strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' }, roughness: { type: 'number' }, opacity: { type: 'number' }, text: { type: 'string' }, fontSize: { type: 'number' }, - fontFamily: { type: 'string' } + fontFamily: { type: 'string' }, + startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' }, + endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' }, + endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' }, + startArrowhead: { type: 'string', description: 'Arrowhead style at start: arrow, bar, dot, triangle, or null' } }, required: ['type', 'x', 'y'] } @@ -263,9 +400,9 @@ const tools: Tool[] = [ type: 'object', properties: { id: { type: 'string' }, - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) + type: { + type: 'string', + enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) }, x: { type: 'number' }, y: { type: 'number' }, @@ -274,6 +411,7 @@ const tools: Tool[] = [ backgroundColor: { type: 'string' }, strokeColor: { type: 'string' }, strokeWidth: { type: 'number' }, + strokeStyle: { type: 'string' }, roughness: { type: 'number' }, opacity: { type: 'number' }, text: { type: 'string' }, @@ -451,7 +589,7 @@ const tools: Tool[] = [ }, { name: 'batch_create_elements', - description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams', + description: 'Create multiple Excalidraw elements at once. For arrows, use startElementId/endElementId to bind arrows to shapes — Excalidraw auto-routes to element edges. Assign custom id to shapes so arrows can reference them.', inputSchema: { type: 'object', properties: { @@ -460,9 +598,10 @@ const tools: Tool[] = [ items: { type: 'object', properties: { - type: { - type: 'string', - enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) + id: { type: 'string', description: 'Custom element ID. Arrows can reference this via startElementId/endElementId.' }, + type: { + type: 'string', + enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) }, x: { type: 'number' }, y: { type: 'number' }, @@ -471,11 +610,16 @@ const tools: Tool[] = [ backgroundColor: { type: 'string' }, strokeColor: { type: 'string' }, strokeWidth: { type: 'number' }, + strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' }, roughness: { type: 'number' }, opacity: { type: 'number' }, text: { type: 'string' }, fontSize: { type: 'number' }, - fontFamily: { type: 'string' } + fontFamily: { type: 'string' }, + startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' }, + endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' }, + endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' }, + startArrowhead: { type: 'string', description: 'Arrowhead style at start: arrow, bar, dot, triangle, or null' } }, required: ['type', 'x', 'y'] } @@ -483,6 +627,195 @@ const tools: Tool[] = [ }, required: ['elements'] } + }, + { + name: 'get_element', + description: 'Get a single Excalidraw element by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'The element ID' } + }, + required: ['id'] + } + }, + { + name: 'clear_canvas', + description: 'Clear all elements from the canvas', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'export_scene', + description: 'Export the current canvas to .excalidraw JSON format. Optionally write to a file.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Optional file path to write the .excalidraw JSON file' + } + } + } + }, + { + name: 'import_scene', + description: 'Import elements from a .excalidraw JSON file or raw JSON data', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to a .excalidraw JSON file' + }, + data: { + type: 'string', + description: 'Raw .excalidraw JSON string (alternative to filePath)' + }, + mode: { + type: 'string', + enum: ['replace', 'merge'], + description: '"replace" clears canvas first, "merge" appends to existing elements' + } + }, + required: ['mode'] + } + }, + { + name: 'export_to_image', + description: 'Export the current canvas to PNG or SVG image. Requires the canvas frontend to be open in a browser.', + inputSchema: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['png', 'svg'], + description: 'Image format' + }, + filePath: { + type: 'string', + description: 'Optional file path to save the image' + }, + background: { + type: 'boolean', + description: 'Include background in export (default: true)' + } + }, + required: ['format'] + } + }, + { + name: 'duplicate_elements', + description: 'Duplicate elements with a configurable offset', + inputSchema: { + type: 'object', + properties: { + elementIds: { + type: 'array', + items: { type: 'string' }, + description: 'IDs of elements to duplicate' + }, + offsetX: { type: 'number', description: 'Horizontal offset (default: 20)' }, + offsetY: { type: 'number', description: 'Vertical offset (default: 20)' } + }, + required: ['elementIds'] + } + }, + { + name: 'snapshot_scene', + description: 'Save a named snapshot of the current canvas state for later restoration', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name for this snapshot' + } + }, + required: ['name'] + } + }, + { + name: 'restore_snapshot', + description: 'Restore the canvas from a previously saved named snapshot', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the snapshot to restore' + } + }, + required: ['name'] + } + }, + { + name: 'describe_scene', + description: 'Get an AI-readable description of the current canvas: element types, positions, connections, labels, spatial layout, and bounding box. Use this to understand what is on the canvas before making changes.', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'get_canvas_screenshot', + description: 'Take a screenshot of the current canvas and return it as an image. Requires the canvas frontend to be open in a browser. Use this to visually verify what the diagram looks like.', + inputSchema: { + type: 'object', + properties: { + background: { + type: 'boolean', + description: 'Include background in screenshot (default: true)' + } + } + } + }, + { + name: 'read_diagram_guide', + description: 'Returns a comprehensive design guide for creating beautiful Excalidraw diagrams: color palette, sizing rules, layout patterns, arrow binding best practices, diagram templates, and anti-patterns. Call this before creating diagrams to produce professional results.', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'export_to_excalidraw_url', + description: 'Export the current canvas to a shareable excalidraw.com URL. The diagram is encrypted and uploaded; anyone with the URL can view it. Returns the shareable link.', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'set_viewport', + description: 'Control the canvas viewport (camera). Auto-fit all elements, center on a specific element, or set zoom/scroll directly. Requires the canvas frontend open in a browser.', + inputSchema: { + type: 'object', + properties: { + scrollToContent: { + type: 'boolean', + description: 'Auto-fit all elements in view (zoom-to-fit)' + }, + scrollToElementId: { + type: 'string', + description: 'Center the view on a specific element by ID' + }, + zoom: { + type: 'number', + description: 'Zoom level (0.1–10, where 1 = 100%)' + }, + offsetX: { + type: 'number', + description: 'Horizontal scroll offset' + }, + offsetY: { + type: 'number', + description: 'Vertical scroll offset' + } + } + } } ]; @@ -490,8 +823,8 @@ const tools: Tool[] = [ const server = new Server( { name: "mcp-excalidraw-server", - version: "1.0.2", - description: "Advanced MCP server for Excalidraw with real-time canvas" + version: "2.0.0", + description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync" }, { capabilities: { @@ -531,18 +864,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const params = ElementSchema.parse(args); logger.info('Creating element via MCP', { type: params.type }); - const id = generateId(); + const { startElementId, endElementId, id: customId, ...elementProps } = params; + const id = customId || generateId(); const element: ServerElement = { id, - ...params, + ...elementProps, + points: elementProps.points ? normalizePoints(elementProps.points) : undefined, + // Convert binding IDs to Excalidraw's start/end format + ...(startElementId ? { start: { id: startElementId } } : {}), + ...(endElementId ? { end: { id: endElementId } } : {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1 }; + // For bound arrows without explicit points, set a default + if ((startElementId || endElementId) && !elementProps.points) { + (element as any).points = [[0, 0], [100, 0]]; + } + // Convert text to label format for Excalidraw const excalidrawElement = convertTextToLabel(element); - + // Create element directly on HTTP server (no local storage) const canvasElement = await createElementOnCanvas(excalidrawElement); @@ -566,14 +909,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) case 'update_element': { const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args); - const { id, ...updates } = params; - + const { id, points: rawPoints, ...updates } = params; + if (!id) throw new Error('Element ID is required'); // Build update payload with timestamp and version increment const updatePayload: Partial & { id: string } = { id, ...updates, + points: rawPoints ? normalizePoints(rawPoints) : undefined, updatedAt: new Date().toISOString() }; @@ -765,7 +1109,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const successCount = results.filter(result => result !== null).length; if (successCount === 0) { - logger.warn('Failed to ungroup any elements: HTTP server unavailable or elements not found'); + throw new Error('Failed to ungroup: no elements were updated (elements may not exist on canvas)'); } logger.info('Ungrouping elements', { groupId, elementIds, successCount }); @@ -782,11 +1126,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) case 'align_elements': { const params = AlignElementsSchema.parse(args); const { elementIds, alignment } = params; - - // Implementation would align elements based on the specified alignment logger.info('Aligning elements', { elementIds, alignment }); - - const result = { aligned: true, elementIds, alignment }; + + // Fetch all elements + const elementsToAlign: ServerElement[] = []; + for (const id of elementIds) { + const el = await getElementFromCanvas(id); + if (el) elementsToAlign.push(el); + } + + if (elementsToAlign.length < 2) { + throw new Error('Need at least 2 elements to align'); + } + + // Calculate alignment target + let updateFn: (el: ServerElement) => { x?: number; y?: number }; + switch (alignment) { + case 'left': { + const minX = Math.min(...elementsToAlign.map(el => el.x)); + updateFn = () => ({ x: minX }); + break; + } + case 'right': { + const maxRight = Math.max(...elementsToAlign.map(el => el.x + (el.width || 0))); + updateFn = (el) => ({ x: maxRight - (el.width || 0) }); + break; + } + case 'center': { + const centers = elementsToAlign.map(el => el.x + (el.width || 0) / 2); + const avgCenter = centers.reduce((a, b) => a + b, 0) / centers.length; + updateFn = (el) => ({ x: avgCenter - (el.width || 0) / 2 }); + break; + } + case 'top': { + const minY = Math.min(...elementsToAlign.map(el => el.y)); + updateFn = () => ({ y: minY }); + break; + } + case 'bottom': { + const maxBottom = Math.max(...elementsToAlign.map(el => el.y + (el.height || 0))); + updateFn = (el) => ({ y: maxBottom - (el.height || 0) }); + break; + } + case 'middle': { + const middles = elementsToAlign.map(el => el.y + (el.height || 0) / 2); + const avgMiddle = middles.reduce((a, b) => a + b, 0) / middles.length; + updateFn = (el) => ({ y: avgMiddle - (el.height || 0) / 2 }); + break; + } + } + + // Apply updates + const updatePromises = elementsToAlign.map(async (el) => { + const coords = updateFn(el); + return await updateElementOnCanvas({ id: el.id, ...coords }); + }); + const results = await Promise.all(updatePromises); + const successCount = results.filter(r => r).length; + + if (successCount === 0) { + throw new Error('Failed to align any elements: HTTP server unavailable'); + } + + const result = { aligned: true, elementIds, alignment, successCount }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; @@ -795,11 +1197,50 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) case 'distribute_elements': { const params = DistributeElementsSchema.parse(args); const { elementIds, direction } = params; - - // Implementation would distribute elements based on the specified direction logger.info('Distributing elements', { elementIds, direction }); - - const result = { distributed: true, elementIds, direction }; + + // Fetch all elements + const elementsToDist: ServerElement[] = []; + for (const id of elementIds) { + const el = await getElementFromCanvas(id); + if (el) elementsToDist.push(el); + } + + if (elementsToDist.length < 3) { + throw new Error('Need at least 3 elements to distribute'); + } + + if (direction === 'horizontal') { + // Sort by x position + elementsToDist.sort((a, b) => a.x - b.x); + const first = elementsToDist[0]!; + const last = elementsToDist[elementsToDist.length - 1]!; + const totalSpan = (last.x + (last.width || 0)) - first.x; + const totalElementWidth = elementsToDist.reduce((sum, el) => sum + (el.width || 0), 0); + const gap = (totalSpan - totalElementWidth) / (elementsToDist.length - 1); + + let currentX = first.x; + for (const el of elementsToDist) { + await updateElementOnCanvas({ id: el.id, x: currentX }); + currentX += (el.width || 0) + gap; + } + } else { + // Sort by y position + elementsToDist.sort((a, b) => a.y - b.y); + const first = elementsToDist[0]!; + const last = elementsToDist[elementsToDist.length - 1]!; + const totalSpan = (last.y + (last.height || 0)) - first.y; + const totalElementHeight = elementsToDist.reduce((sum, el) => sum + (el.height || 0), 0); + const gap = (totalSpan - totalElementHeight) / (elementsToDist.length - 1); + + let currentY = first.y; + for (const el of elementsToDist) { + await updateElementOnCanvas({ id: el.id, y: currentY }); + currentY += (el.height || 0) + gap; + } + } + + const result = { distributed: true, elementIds, direction, count: elementsToDist.length }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; @@ -916,50 +1357,816 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) logger.info('Batch creating elements via MCP', { count: params.elements.length }); const createdElements: ServerElement[] = []; - - // Create each element with unique ID + for (const elementData of params.elements) { - const id = generateId(); + const { startElementId, endElementId, id: customId, ...elementProps } = elementData; + const id = customId || generateId(); const element: ServerElement = { id, - ...elementData, + ...elementProps, + points: elementProps.points ? normalizePoints(elementProps.points) : undefined, + // Convert binding IDs to Excalidraw's start/end format + ...(startElementId ? { start: { id: startElementId } } : {}), + ...(endElementId ? { end: { id: endElementId } } : {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1 }; - - // Convert text to label format for Excalidraw + + // For bound arrows without explicit points, set a default + if ((startElementId || endElementId) && !elementProps.points) { + (element as any).points = [[0, 0], [100, 0]]; + } + const excalidrawElement = convertTextToLabel(element); createdElements.push(excalidrawElement); } - - // Create all elements directly on HTTP server (no local storage) + const canvasElements = await batchCreateElementsOnCanvas(createdElements); - + if (!canvasElements) { throw new Error('Failed to batch create elements: HTTP server unavailable'); } - + const result = { success: true, elements: canvasElements, count: canvasElements.length, syncedToCanvas: true }; - - logger.info('Batch elements created via MCP and synced to canvas', { + + logger.info('Batch elements created via MCP and synced to canvas', { count: result.count, - synced: result.syncedToCanvas + synced: result.syncedToCanvas }); - + return { - content: [{ - type: 'text', - text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}` + content: [{ + type: 'text', + text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}` }] }; } - + + case 'get_element': { + const params = ElementIdSchema.parse(args); + const { id } = params; + + const element = await getElementFromCanvas(id); + if (!element) { + throw new Error(`Element ${id} not found`); + } + + return { + content: [{ type: 'text', text: JSON.stringify(element, null, 2) }] + }; + } + + case 'clear_canvas': { + logger.info('Clearing canvas via MCP'); + + const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as ApiResponse; + + return { + content: [{ + type: 'text', + text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}` + }] + }; + } + + case 'export_scene': { + const params = z.object({ + filePath: z.string().optional() + }).parse(args || {}); + + logger.info('Exporting scene via MCP'); + + const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); + if (!response.ok) { + throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as ApiResponse; + const sceneElements = data.elements || []; + + const excalidrawScene = { + type: 'excalidraw', + version: 2, + source: 'mcp-excalidraw-server', + elements: sceneElements, + appState: { + viewBackgroundColor: '#ffffff', + gridSize: null + } + }; + + const jsonString = JSON.stringify(excalidrawScene, null, 2); + + if (params.filePath) { + const safePath = sanitizeFilePath(params.filePath); + fs.writeFileSync(safePath, jsonString, 'utf-8'); + return { + content: [{ + type: 'text', + text: `Scene exported to ${safePath} (${sceneElements.length} elements)` + }] + }; + } + + return { + content: [{ + type: 'text', + text: jsonString + }] + }; + } + + case 'import_scene': { + const params = z.object({ + filePath: z.string().optional(), + data: z.string().optional(), + mode: z.enum(['replace', 'merge']) + }).parse(args); + + logger.info('Importing scene via MCP', { mode: params.mode }); + + let sceneData: any; + if (params.filePath) { + const safeImportPath = sanitizeFilePath(params.filePath); + const fileContent = fs.readFileSync(safeImportPath, 'utf-8'); + sceneData = JSON.parse(fileContent); + } else if (params.data) { + sceneData = JSON.parse(params.data); + } else { + throw new Error('Either filePath or data must be provided'); + } + + // Extract elements from .excalidraw format or raw array + const importElements: ServerElement[] = Array.isArray(sceneData) + ? sceneData + : (sceneData.elements || []); + + if (importElements.length === 0) { + throw new Error('No elements found in the import data'); + } + + // If replace mode, clear first + if (params.mode === 'replace') { + await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' }); + } + + // Batch create the imported elements + const elementsToCreate = importElements.map(el => ({ + ...el, + id: el.id || generateId(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1 + })); + + const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate); + + return { + content: [{ + type: 'text', + text: `Imported ${elementsToCreate.length} elements (mode: ${params.mode})\n\n✅ Synced to canvas` + }] + }; + } + + case 'export_to_image': { + const params = z.object({ + format: z.enum(['png', 'svg']), + filePath: z.string().optional(), + background: z.boolean().optional() + }).parse(args); + + logger.info('Exporting to image via MCP', { format: params.format }); + + const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + format: params.format, + background: params.background ?? true + }) + }); + + if (!response.ok) { + const errorData = await response.json() as ApiResponse; + throw new Error(errorData.error || `Export failed: ${response.status}`); + } + + const result = await response.json() as { success: boolean; format: string; data: string }; + + if (params.filePath) { + const safeImagePath = sanitizeFilePath(params.filePath); + if (params.format === 'svg') { + fs.writeFileSync(safeImagePath, result.data, 'utf-8'); + } else { + fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64')); + } + return { + content: [{ + type: 'text', + text: `Image exported to ${safeImagePath} (format: ${params.format})` + }] + }; + } + + return { + content: [{ + type: 'text', + text: params.format === 'svg' + ? result.data + : `Base64 ${params.format} data (${result.data.length} chars). Use filePath to save to disk.` + }] + }; + } + + case 'duplicate_elements': { + const params = z.object({ + elementIds: z.array(z.string()), + offsetX: z.number().optional(), + offsetY: z.number().optional() + }).parse(args); + + const offsetX = params.offsetX ?? 20; + const offsetY = params.offsetY ?? 20; + + logger.info('Duplicating elements via MCP', { count: params.elementIds.length }); + + const duplicates: ServerElement[] = []; + for (const id of params.elementIds) { + const original = await getElementFromCanvas(id); + if (!original) { + logger.warn(`Element ${id} not found, skipping duplicate`); + continue; + } + + const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original; + const duplicate: ServerElement = { + ...rest, + id: generateId(), + x: original.x + offsetX, + y: original.y + offsetY, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1 + }; + duplicates.push(duplicate); + } + + if (duplicates.length === 0) { + throw new Error('No elements could be duplicated (none found)'); + } + + const canvasElements = await batchCreateElementsOnCanvas(duplicates); + + return { + content: [{ + type: 'text', + text: `Duplicated ${duplicates.length} elements (offset: ${offsetX}, ${offsetY})\n\n${JSON.stringify(canvasElements, null, 2)}\n\n✅ Synced to canvas` + }] + }; + } + + case 'snapshot_scene': { + const params = z.object({ name: z.string() }).parse(args); + logger.info('Saving snapshot via MCP', { name: params.name }); + + const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: params.name }) + }); + + if (!response.ok) { + throw new Error(`Failed to save snapshot: ${response.status} ${response.statusText}`); + } + + const result = await response.json() as any; + + return { + content: [{ + type: 'text', + text: `Snapshot "${params.name}" saved (${result.elementCount} elements)\n\n${JSON.stringify(result, null, 2)}` + }] + }; + } + + case 'restore_snapshot': { + const params = z.object({ name: z.string() }).parse(args); + logger.info('Restoring snapshot via MCP', { name: params.name }); + + // Fetch the snapshot + const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`); + if (!response.ok) { + throw new Error(`Snapshot "${params.name}" not found`); + } + + const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } }; + + // Clear current canvas + await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' }); + + // Restore elements + const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements); + + return { + content: [{ + type: 'text', + text: `Snapshot "${params.name}" restored (${data.snapshot.elements.length} elements)\n\n✅ Canvas updated` + }] + }; + } + + case 'describe_scene': { + logger.info('Describing scene via MCP'); + + const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); + if (!response.ok) { + throw new Error(`Failed to fetch elements: ${response.status}`); + } + + const data = await response.json() as ApiResponse; + const allElements = data.elements || []; + + if (allElements.length === 0) { + return { + content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }] + }; + } + + // Count by type + const typeCounts: Record = {}; + for (const el of allElements) { + typeCounts[el.type] = (typeCounts[el.type] || 0) + 1; + } + + // Bounding box + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of allElements) { + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + maxX = Math.max(maxX, el.x + (el.width || 0)); + maxY = Math.max(maxY, el.y + (el.height || 0)); + } + + // Build element descriptions sorted top-to-bottom, left-to-right + const sorted = [...allElements].sort((a, b) => { + const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50); + return rowDiff !== 0 ? rowDiff : a.x - b.x; + }); + + const elementDescs: string[] = []; + for (const el of sorted) { + const parts: string[] = []; + parts.push(`[${el.id}] ${el.type}`); + parts.push(`at (${Math.round(el.x)}, ${Math.round(el.y)})`); + if (el.width || el.height) { + parts.push(`size ${Math.round(el.width || 0)}x${Math.round(el.height || 0)}`); + } + if (el.text) parts.push(`text: "${el.text}"`); + if (el.label?.text) parts.push(`label: "${el.label.text}"`); + if (el.backgroundColor && el.backgroundColor !== 'transparent') { + parts.push(`bg: ${el.backgroundColor}`); + } + if (el.strokeColor && el.strokeColor !== '#000000') { + parts.push(`stroke: ${el.strokeColor}`); + } + if (el.locked) parts.push('(locked)'); + if (el.groupIds && el.groupIds.length > 0) { + parts.push(`groups: [${el.groupIds.join(', ')}]`); + } + elementDescs.push(` ${parts.join(' | ')}`); + } + + // Find connections (arrows) + const arrows = allElements.filter(el => el.type === 'arrow'); + const connectionDescs: string[] = []; + for (const arrow of arrows) { + const arrowAny = arrow as any; + if (arrowAny.startBinding?.elementId || arrowAny.endBinding?.elementId) { + const from = arrowAny.startBinding?.elementId || '?'; + const to = arrowAny.endBinding?.elementId || '?'; + connectionDescs.push(` ${from} --> ${to} (arrow: ${arrow.id})`); + } + } + + // Build description + const lines: string[] = []; + lines.push(`## Canvas Description`); + lines.push(`Total elements: ${allElements.length}`); + lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`); + lines.push(`Bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`); + lines.push(''); + lines.push('### Elements (top-to-bottom, left-to-right):'); + lines.push(...elementDescs); + + if (connectionDescs.length > 0) { + lines.push(''); + lines.push('### Connections:'); + lines.push(...connectionDescs); + } + + // Groups + const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0); + if (groupedElements.length > 0) { + const groupMap: Record = {}; + for (const el of groupedElements) { + for (const gid of (el.groupIds || [])) { + if (!groupMap[gid]) groupMap[gid] = []; + groupMap[gid]!.push(el.id); + } + } + lines.push(''); + lines.push('### Groups:'); + for (const [gid, ids] of Object.entries(groupMap)) { + lines.push(` Group ${gid}: [${ids.join(', ')}]`); + } + } + + return { + content: [{ type: 'text', text: lines.join('\n') }] + }; + } + + case 'get_canvas_screenshot': { + const params = z.object({ + background: z.boolean().optional() + }).parse(args || {}); + + logger.info('Taking canvas screenshot via MCP'); + + const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + format: 'png', + background: params.background ?? true + }) + }); + + if (!response.ok) { + const errorData = await response.json() as ApiResponse; + throw new Error(errorData.error || `Screenshot failed: ${response.status}`); + } + + const result = await response.json() as { success: boolean; format: string; data: string }; + + return { + content: [ + { + type: 'image' as const, + data: result.data, + mimeType: 'image/png' + }, + { + type: 'text', + text: 'Canvas screenshot captured. This is what the diagram currently looks like.' + } + ] + }; + } + + case 'read_diagram_guide': { + return { + content: [{ type: 'text', text: DIAGRAM_DESIGN_GUIDE }] + }; + } + + case 'export_to_excalidraw_url': { + logger.info('Exporting to excalidraw.com URL'); + + // 1. Fetch current scene elements + const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); + if (!urlExportResponse.ok) { + throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`); + } + const urlExportData = await urlExportResponse.json() as ApiResponse; + const urlExportElements = urlExportData.elements || []; + + if (urlExportElements.length === 0) { + throw new Error('Canvas is empty — nothing to export'); + } + + // 2. Clean elements: strip server metadata, add Excalidraw defaults, + // generate bound text elements, and resolve arrow bindings + const cleanedExportElements: Record[] = []; + const boundTextElements: Record[] = []; + let indexCounter = 0; + + function makeBaseElement(el: any, rest: any): Record { + return { + ...rest, + angle: rest.angle ?? 0, + strokeColor: rest.strokeColor ?? '#1e1e1e', + backgroundColor: rest.backgroundColor ?? 'transparent', + fillStyle: rest.fillStyle ?? 'solid', + strokeWidth: rest.strokeWidth ?? 2, + strokeStyle: rest.strokeStyle ?? 'solid', + roughness: rest.roughness ?? 1, + opacity: rest.opacity ?? 100, + groupIds: rest.groupIds ?? [], + frameId: rest.frameId ?? null, + index: rest.index ?? `a${indexCounter++}`, + roundness: rest.roundness ?? ( + el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse' + ? { type: 3 } : null + ), + seed: rest.seed ?? Math.floor(Math.random() * 2147483647), + version: rest.version ?? 1, + versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647), + isDeleted: false, + boundElements: rest.boundElements ?? null, + updated: Date.now(), + link: rest.link ?? null, + locked: rest.locked ?? false + }; + } + + for (const el of urlExportElements) { + // Strip server-only fields + const { + createdAt, updatedAt, syncedAt, source: _src, + syncTimestamp, label, start, end, text, + version: _ver, + ...rest + } = el as any; + + const base = makeBaseElement(el, rest); + + // Standalone text elements: keep text directly + if (el.type === 'text') { + base.text = text ?? ''; + base.originalText = text ?? ''; + base.fontSize = rest.fontSize ?? 20; + base.fontFamily = rest.fontFamily ?? 1; + base.textAlign = rest.textAlign ?? 'center'; + base.verticalAlign = rest.verticalAlign ?? 'middle'; + base.autoResize = rest.autoResize ?? true; + base.lineHeight = rest.lineHeight ?? 1.25; + base.containerId = rest.containerId ?? null; + cleanedExportElements.push(base); + continue; + } + + // Arrows: server already resolved bindings (start/end → startBinding/endBinding + positions) + if (el.type === 'arrow' || el.type === 'line') { + base.points = rest.points ?? [[0, 0], [100, 0]]; + base.lastCommittedPoint = null; + // Preserve server-resolved bindings with fixedPoint for excalidraw.com + if (rest.startBinding) { + base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null }; + } else { + base.startBinding = null; + } + if (rest.endBinding) { + base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null }; + } else { + base.endBinding = null; + } + base.startArrowhead = rest.startArrowhead ?? null; + base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null); + base.elbowed = rest.elbowed ?? false; + } + + // Generate bound text element for label on shapes and arrows + const labelText = label?.text || text; + if (labelText) { + const textId = `${base.id}-label`; + // Add binding reference to parent + base.boundElements = [ + ...(Array.isArray(base.boundElements) ? base.boundElements : []), + { type: 'text', id: textId } + ]; + + // Compute text position: centered in shape, or at arrow midpoint + let textX: number, textY: number, textW: number, textH: number; + const isArrow = el.type === 'arrow' || el.type === 'line'; + + if (isArrow) { + // Position at midpoint of arrow path + const pts = base.points || [[0, 0], [100, 0]]; + const lastPt = pts[pts.length - 1]; + const midX = base.x + (lastPt[0] / 2); + const midY = base.y + (lastPt[1] / 2); + const labelW = Math.max(labelText.length * 10, 60); + textX = midX - labelW / 2; + textY = midY - 12; + textW = labelW; + textH = 24; + } else { + // Center inside shape container + const containerW = base.width ?? 160; + const containerH = base.height ?? 80; + textX = base.x + 10; + textY = base.y + containerH / 4; + textW = containerW - 20; + textH = containerH / 2; + } + + boundTextElements.push({ + id: textId, + type: 'text', + x: textX, + y: textY, + width: textW, + height: textH, + angle: 0, + strokeColor: isArrow ? '#1e1e1e' : base.strokeColor, + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: `a${indexCounter++}`, + roundness: null, + seed: Math.floor(Math.random() * 2147483647), + version: 1, + versionNonce: Math.floor(Math.random() * 2147483647), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + text: labelText, + originalText: labelText, + fontSize: isArrow ? 14 : (rest.fontSize ?? 16), + fontFamily: rest.fontFamily ?? 1, + textAlign: 'center', + verticalAlign: 'middle', + autoResize: true, + lineHeight: 1.25, + containerId: base.id + }); + } + + cleanedExportElements.push(base); + } + + // Patch shapes' boundElements to include connected arrows + const shapeBoundArrows = new Map(); + for (const el of cleanedExportElements) { + if (el.startBinding?.elementId) { + const arr = shapeBoundArrows.get(el.startBinding.elementId) || []; + arr.push({ type: 'arrow', id: el.id }); + shapeBoundArrows.set(el.startBinding.elementId, arr); + } + if (el.endBinding?.elementId) { + const arr = shapeBoundArrows.get(el.endBinding.elementId) || []; + arr.push({ type: 'arrow', id: el.id }); + shapeBoundArrows.set(el.endBinding.elementId, arr); + } + } + for (const el of cleanedExportElements) { + const arrowBindings = shapeBoundArrows.get(el.id); + if (arrowBindings) { + el.boundElements = [ + ...(Array.isArray(el.boundElements) ? el.boundElements : []), + ...arrowBindings + ]; + } + } + + // Append all bound text elements after their parents + cleanedExportElements.push(...boundTextElements); + + // Build .excalidraw scene JSON + const excalidrawScene = { + type: 'excalidraw', + version: 2, + source: 'https://excalidraw.com', + elements: cleanedExportElements, + appState: { + viewBackgroundColor: '#ffffff', + gridSize: null + }, + files: {} + }; + const sceneJson = JSON.stringify(excalidrawScene); + const dataBytes = new TextEncoder().encode(sceneJson); + + // Excalidraw's concatBuffers: [4-byte version=1][4-byte len][chunk]... + function concatBuffers(...bufs: Uint8Array[]): Uint8Array { + let total = 4; // version header + for (const b of bufs) total += 4 + b.length; + const out = new Uint8Array(total); + const dv = new DataView(out.buffer); + dv.setUint32(0, 1); // CONCAT_BUFFERS_VERSION = 1 + let off = 4; + for (const b of bufs) { + dv.setUint32(off, b.length); + off += 4; + out.set(b, off); + off += b.length; + } + return out; + } + + const encoder = new TextEncoder(); + + // 3. Inner data: concatBuffers(fileMetadata, dataJSON) + const fileMetadata = encoder.encode('{}'); + const innerData = concatBuffers(fileMetadata, dataBytes); + + // 4. Compress with zlib deflate + const compressed = deflateSync(Buffer.from(innerData)); + + // 5. Encrypt with AES-GCM 128-bit key + const cryptoKey = await webcrypto.subtle.generateKey( + { name: 'AES-GCM', length: 128 }, + true, + ['encrypt'] + ); + + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + const encrypted = await webcrypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + compressed + ); + + // 6. Outer payload: concatBuffers(encodingMeta, iv, ciphertext) + const encodingMeta = encoder.encode(JSON.stringify({ + version: 2, + compression: 'pako@1', + encryption: 'AES-GCM' + })); + const ciphertext = new Uint8Array(encrypted); + const payload = concatBuffers(encodingMeta, iv, ciphertext); + + // 7. POST to excalidraw.com JSON store + const uploadResponse = await fetch('https://json.excalidraw.com/api/v2/post/', { + method: 'POST', + body: Buffer.from(payload) + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload to excalidraw.com failed: ${uploadResponse.status} ${uploadResponse.statusText}`); + } + + const uploadResult = await uploadResponse.json() as { id: string }; + + // 8. Export key as JWK to get the "k" field + const jwk = await webcrypto.subtle.exportKey('jwk', cryptoKey); + + // 9. Build shareable URL + const shareUrl = `https://excalidraw.com/#json=${uploadResult.id},${jwk.k}`; + + return { + content: [{ + type: 'text', + text: `Diagram exported to excalidraw.com!\n\nShareable URL: ${shareUrl}\n\nAnyone with this link can view and edit the diagram.` + }] + }; + } + + case 'set_viewport': { + const viewportParams = z.object({ + scrollToContent: z.boolean().optional(), + scrollToElementId: z.string().optional(), + zoom: z.number().min(0.1).max(10).optional(), + offsetX: z.number().optional(), + offsetY: z.number().optional() + }).parse(args || {}); + + logger.info('Setting viewport via MCP', viewportParams); + + const viewportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/viewport`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(viewportParams) + }); + + if (!viewportResponse.ok) { + const viewportError = await viewportResponse.json() as ApiResponse; + throw new Error(viewportError.error || `Viewport request failed: ${viewportResponse.status}`); + } + + const viewportResult = await viewportResponse.json() as { success: boolean; message?: string }; + + return { + content: [{ + type: 'text', + text: `Viewport updated successfully.\n\n${JSON.stringify(viewportResult, null, 2)}` + }] + }; + } + default: throw new Error(`Unknown tool: ${name}`); } @@ -978,34 +2185,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools }; }); -// Start server with transport based on mode +// Start server async function runServer(): Promise { try { logger.info('Starting Excalidraw MCP server...'); - - const transportMode = process.env.MCP_TRANSPORT_MODE || 'stdio'; - let transport; - - if (transportMode === 'http') { - const port = parseInt(process.env.PORT || '3000', 10); - const host = process.env.HOST || 'localhost'; - - logger.info(`Starting HTTP server on ${host}:${port}`); - // Here you would create an HTTP transport - // This is a placeholder - actual HTTP transport implementation would need to be added - transport = new StdioServerTransport(); // Fallback to stdio for now - } else { - // Default to stdio transport - transport = new StdioServerTransport(); - } - - // Add a debug message before connecting - logger.debug('Connecting to transport...'); - + + const transport = new StdioServerTransport(); + logger.debug('Connecting to stdio transport...'); + await server.connect(transport); - logger.info(`Excalidraw MCP server running on ${transportMode}`); - - // Keep the process running + logger.info('Excalidraw MCP server running on stdio'); + process.stdin.resume(); } catch (error) { logger.error('Error starting server:', error); diff --git a/src/server.ts b/src/server.ts index c3abc2d..02beff1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,9 +6,10 @@ import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import logger from './utils/logger.js'; -import { +import { elements, - generateId, + snapshots, + generateId, EXCALIDRAW_ELEMENT_TYPES, ServerElement, ExcalidrawElementType, @@ -18,7 +19,8 @@ import { ElementDeletedMessage, BatchCreatedMessage, SyncStatusMessage, - InitialElementsMessage + InitialElementsMessage, + Snapshot } from './types.js'; import { z } from 'zod'; import WebSocket from 'ws'; @@ -35,7 +37,7 @@ const wss = new WebSocketServer({ server }); // Middleware app.use(cors()); -app.use(express.json()); +app.use(express.json({ limit: '10mb' })); // Serve static files from the build directory const staticDir = path.join(__dirname, '../dist'); @@ -98,6 +100,7 @@ const CreateElementSchema = z.object({ backgroundColor: z.string().optional(), strokeColor: z.string().optional(), strokeWidth: z.number().optional(), + strokeStyle: z.string().optional(), roughness: z.number().optional(), opacity: z.number().optional(), text: z.string().optional(), @@ -107,7 +110,13 @@ const CreateElementSchema = z.object({ fontSize: z.number().optional(), fontFamily: z.string().optional(), groupIds: z.array(z.string()).optional(), - locked: z.boolean().optional() + locked: z.boolean().optional(), + // Arrow-specific properties + points: z.any().optional(), + start: z.object({ id: z.string() }).optional(), + end: z.object({ id: z.string() }).optional(), + startArrowhead: z.string().nullable().optional(), + endArrowhead: z.string().nullable().optional(), }); const UpdateElementSchema = z.object({ @@ -120,6 +129,7 @@ const UpdateElementSchema = z.object({ backgroundColor: z.string().optional(), strokeColor: z.string().optional(), strokeWidth: z.number().optional(), + strokeStyle: z.string().optional(), roughness: z.number().optional(), opacity: z.number().optional(), text: z.string().optional(), @@ -129,7 +139,15 @@ const UpdateElementSchema = z.object({ fontSize: z.number().optional(), fontFamily: z.string().optional(), groupIds: z.array(z.string()).optional(), - locked: z.boolean().optional() + locked: z.boolean().optional(), + points: z.array(z.union([ + z.tuple([z.number(), z.number()]), + z.object({ x: z.number(), y: z.number() }) + ])).optional(), + start: z.object({ id: z.string() }).optional(), + end: z.object({ id: z.string() }).optional(), + startArrowhead: z.string().nullable().optional(), + endArrowhead: z.string().nullable().optional(), }); // API Routes @@ -240,6 +258,33 @@ app.put('/api/elements/:id', (req: Request, res: Response) => { } }); +// Clear all elements (must be before /:id route) +app.delete('/api/elements/clear', (req: Request, res: Response) => { + try { + const count = elements.size; + elements.clear(); + + broadcast({ + type: 'canvas_cleared', + timestamp: new Date().toISOString() + }); + + logger.info(`Canvas cleared: ${count} elements removed`); + + res.json({ + success: true, + message: `Cleared ${count} elements`, + count + }); + } catch (error) { + logger.error('Error clearing canvas:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + // Delete element app.delete('/api/elements/:id', (req: Request, res: Response) => { try { @@ -349,20 +394,152 @@ app.get('/api/elements/:id', (req: Request, res: Response) => { } }); +// Helper: compute edge point for an element given a direction toward a target +function computeEdgePoint( + el: ServerElement, + targetCenterX: number, + targetCenterY: number +): { x: number; y: number } { + const cx = el.x + (el.width || 0) / 2; + const cy = el.y + (el.height || 0) / 2; + const dx = targetCenterX - cx; + const dy = targetCenterY - cy; + + if (el.type === 'diamond') { + // Diamond edge: use diamond geometry (rotated square) + const hw = (el.width || 0) / 2; + const hh = (el.height || 0) / 2; + if (dx === 0 && dy === 0) return { x: cx, y: cy + hh }; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + // Scale factor to reach diamond edge + const scale = (absDx / hw + absDy / hh) > 0 + ? 1 / (absDx / hw + absDy / hh) + : 1; + return { x: cx + dx * scale, y: cy + dy * scale }; + } + + if (el.type === 'ellipse') { + // Ellipse edge: parametric intersection + const a = (el.width || 0) / 2; + const b = (el.height || 0) / 2; + if (dx === 0 && dy === 0) return { x: cx, y: cy + b }; + const angle = Math.atan2(dy, dx); + return { x: cx + a * Math.cos(angle), y: cy + b * Math.sin(angle) }; + } + + // Rectangle: find intersection with edges + const hw = (el.width || 0) / 2; + const hh = (el.height || 0) / 2; + if (dx === 0 && dy === 0) return { x: cx, y: cy + hh }; + const angle = Math.atan2(dy, dx); + const tanA = Math.tan(angle); + // Check if ray intersects top/bottom edge or left/right edge + if (Math.abs(tanA * hw) <= hh) { + // Intersects left or right edge + const signX = dx >= 0 ? 1 : -1; + return { x: cx + signX * hw, y: cy + signX * hw * tanA }; + } else { + // Intersects top or bottom edge + const signY = dy >= 0 ? 1 : -1; + return { x: cx + signY * hh / tanA, y: cy + signY * hh }; + } +} + +// Helper: resolve arrow bindings in a batch +function resolveArrowBindings(batchElements: ServerElement[]): void { + const elementMap = new Map(); + batchElements.forEach(el => elementMap.set(el.id, el)); + + // Also check existing elements for cross-batch references + elements.forEach((el, id) => { + if (!elementMap.has(id)) elementMap.set(id, el); + }); + + for (const el of batchElements) { + if (el.type !== 'arrow' && el.type !== 'line') continue; + const startRef = (el as any).start as { id: string } | undefined; + const endRef = (el as any).end as { id: string } | undefined; + + if (!startRef && !endRef) continue; + + const startEl = startRef ? elementMap.get(startRef.id) : undefined; + const endEl = endRef ? elementMap.get(endRef.id) : undefined; + + // Calculate arrow path from edge to edge + const startCenter = startEl + ? { x: startEl.x + (startEl.width || 0) / 2, y: startEl.y + (startEl.height || 0) / 2 } + : { x: el.x, y: el.y }; + const endCenter = endEl + ? { x: endEl.x + (endEl.width || 0) / 2, y: endEl.y + (endEl.height || 0) / 2 } + : { x: el.x + 100, y: el.y }; + + const GAP = 8; + const startPt = startEl + ? computeEdgePoint(startEl, endCenter.x, endCenter.y) + : startCenter; + const endPt = endEl + ? computeEdgePoint(endEl, startCenter.x, startCenter.y) + : endCenter; + + // Apply gap: move start point slightly away from source, end point slightly away from target + const startDx = endPt.x - startPt.x; + const startDy = endPt.y - startPt.y; + const startDist = Math.sqrt(startDx * startDx + startDy * startDy) || 1; + const endDx = startPt.x - endPt.x; + const endDy = startPt.y - endPt.y; + const endDist = Math.sqrt(endDx * endDx + endDy * endDy) || 1; + + const finalStart = { + x: startPt.x + (startDx / startDist) * GAP, + y: startPt.y + (startDy / startDist) * GAP + }; + const finalEnd = { + x: endPt.x + (endDx / endDist) * GAP, + y: endPt.y + (endDy / endDist) * GAP + }; + + // Set arrow position and points + el.x = finalStart.x; + el.y = finalStart.y; + el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]]; + + // Remove start/end refs (they were used for computation only) + delete (el as any).start; + delete (el as any).end; + + // Set binding metadata for Excalidraw + if (startEl) { + (el as any).startBinding = { + elementId: startEl.id, + focus: 0, + gap: GAP + }; + } + if (endEl) { + (el as any).endBinding = { + elementId: endEl.id, + focus: 0, + gap: GAP + }; + } + } +} + // Batch create elements app.post('/api/elements/batch', (req: Request, res: Response) => { try { const { elements: elementsToCreate } = req.body; - + if (!Array.isArray(elementsToCreate)) { return res.status(400).json({ success: false, error: 'Expected an array of elements' }); } - + const createdElements: ServerElement[] = []; - + elementsToCreate.forEach(elementData => { const params = CreateElementSchema.parse(elementData); // Prioritize passed ID (for MCP sync), otherwise generate new ID @@ -375,17 +552,22 @@ app.post('/api/elements/batch', (req: Request, res: Response) => { version: 1 }; - elements.set(id, element); createdElements.push(element); }); - + + // Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements) + resolveArrowBindings(createdElements); + + // Store all elements after binding resolution + createdElements.forEach(el => elements.set(el.id, el)); + // Broadcast to all connected clients const message: BatchCreatedMessage = { type: 'elements_batch_created', elements: createdElements }; broadcast(message); - + res.json({ success: true, elements: createdElements, @@ -525,6 +707,294 @@ app.post('/api/elements/sync', (req: Request, res: Response) => { } }); +// Image export: request (MCP -> Express -> WebSocket -> Frontend) +interface PendingExport { + resolve: (data: { format: string; data: string }) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} +const pendingExports = new Map(); + +app.post('/api/export/image', (req: Request, res: Response) => { + try { + const { format, background } = req.body; + + if (!format || !['png', 'svg'].includes(format)) { + return res.status(400).json({ + success: false, + error: 'format must be "png" or "svg"' + }); + } + + if (clients.size === 0) { + return res.status(503).json({ + success: false, + error: 'No frontend client connected. Open the canvas in a browser first.' + }); + } + + const requestId = generateId(); + + const exportPromise = new Promise<{ format: string; data: string }>((resolve, reject) => { + const timeout = setTimeout(() => { + pendingExports.delete(requestId); + reject(new Error('Export timed out after 30 seconds')); + }, 30000); + + pendingExports.set(requestId, { resolve, reject, timeout }); + }); + + broadcast({ + type: 'export_image_request', + requestId, + format, + background: background ?? true + }); + + exportPromise + .then(result => { + res.json({ + success: true, + format: result.format, + data: result.data + }); + }) + .catch(error => { + res.status(500).json({ + success: false, + error: (error as Error).message + }); + }); + } catch (error) { + logger.error('Error initiating image export:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + +// Image export: result (Frontend -> Express -> MCP) +app.post('/api/export/image/result', (req: Request, res: Response) => { + try { + const { requestId, format, data, error } = req.body; + + if (!requestId) { + return res.status(400).json({ + success: false, + error: 'requestId is required' + }); + } + + const pending = pendingExports.get(requestId); + if (!pending) { + // Already resolved by another client, or expired — ignore silently + return res.json({ success: true }); + } + + if (error) { + // Don't reject on error — another WebSocket client may still succeed. + // The timeout will handle the case where ALL clients fail. + logger.warn(`Export error from one client (requestId=${requestId}): ${error}`); + return res.json({ success: true }); + } + + clearTimeout(pending.timeout); + pendingExports.delete(requestId); + pending.resolve({ format, data }); + + res.json({ success: true }); + } catch (error) { + logger.error('Error processing export result:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + +// Viewport control: request (MCP -> Express -> WebSocket -> Frontend) +interface PendingViewport { + resolve: (data: { success: boolean; message: string }) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} +const pendingViewports = new Map(); + +app.post('/api/viewport', (req: Request, res: Response) => { + try { + const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body; + + if (clients.size === 0) { + return res.status(503).json({ + success: false, + error: 'No frontend client connected. Open the canvas in a browser first.' + }); + } + + const requestId = generateId(); + + const viewportPromise = new Promise<{ success: boolean; message: string }>((resolve, reject) => { + const timeout = setTimeout(() => { + pendingViewports.delete(requestId); + reject(new Error('Viewport request timed out after 10 seconds')); + }, 10000); + + pendingViewports.set(requestId, { resolve, reject, timeout }); + }); + + broadcast({ + type: 'set_viewport', + requestId, + scrollToContent, + scrollToElementId, + zoom, + offsetX, + offsetY + }); + + viewportPromise + .then(result => { + res.json(result); + }) + .catch(error => { + res.status(500).json({ + success: false, + error: (error as Error).message + }); + }); + } catch (error) { + logger.error('Error initiating viewport change:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + +// Viewport control: result (Frontend -> Express -> MCP) +app.post('/api/viewport/result', (req: Request, res: Response) => { + try { + const { requestId, success, message, error } = req.body; + + if (!requestId) { + return res.status(400).json({ + success: false, + error: 'requestId is required' + }); + } + + const pending = pendingViewports.get(requestId); + if (!pending) { + return res.json({ success: true }); + } + + if (error) { + clearTimeout(pending.timeout); + pendingViewports.delete(requestId); + pending.resolve({ success: false, message: error }); + return res.json({ success: true }); + } + + clearTimeout(pending.timeout); + pendingViewports.delete(requestId); + pending.resolve({ success: true, message: message || 'Viewport updated' }); + + res.json({ success: true }); + } catch (error) { + logger.error('Error processing viewport result:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + +// Snapshots: save +app.post('/api/snapshots', (req: Request, res: Response) => { + try { + const { name } = req.body; + + if (!name || typeof name !== 'string') { + return res.status(400).json({ + success: false, + error: 'Snapshot name is required' + }); + } + + const snapshot: Snapshot = { + name, + elements: Array.from(elements.values()), + createdAt: new Date().toISOString() + }; + + snapshots.set(name, snapshot); + logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`); + + res.json({ + success: true, + name, + elementCount: snapshot.elements.length, + createdAt: snapshot.createdAt + }); + } catch (error) { + logger.error('Error saving snapshot:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + +// Snapshots: list +app.get('/api/snapshots', (req: Request, res: Response) => { + try { + const list = Array.from(snapshots.values()).map(s => ({ + name: s.name, + elementCount: s.elements.length, + createdAt: s.createdAt + })); + + res.json({ + success: true, + snapshots: list, + count: list.length + }); + } catch (error) { + logger.error('Error listing snapshots:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + +// Snapshots: get by name +app.get('/api/snapshots/:name', (req: Request, res: Response) => { + try { + const { name } = req.params; + const snapshot = snapshots.get(name!); + + if (!snapshot) { + return res.status(404).json({ + success: false, + error: `Snapshot "${name}" not found` + }); + } + + res.json({ + success: true, + snapshot + }); + } catch (error) { + logger.error('Error fetching snapshot:', error); + res.status(500).json({ + success: false, + error: (error as Error).message + }); + } +}); + // Serve the frontend app.get('/', (req: Request, res: Response) => { const htmlFile = path.join(__dirname, '../dist/frontend/index.html'); diff --git a/src/types.ts b/src/types.ts index bfb50b5..b735049 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,7 +106,7 @@ export interface ExcalidrawBinding { fixedPoint?: readonly [number, number] | null; } -export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label'; +export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw'; // Excalidraw element types export const EXCALIDRAW_ELEMENT_TYPES: Record = { @@ -115,7 +115,6 @@ export const EXCALIDRAW_ELEMENT_TYPES: Record = { DIAMOND: 'diamond', ARROW: 'arrow', TEXT: 'text', - LABEL: 'label', FREEDRAW: 'freedraw', LINE: 'line' } as const; @@ -136,6 +135,10 @@ export interface ServerElement extends Omit { label?: { text: string; }; + points?: any; + // Arrow element binding: connect arrows to shapes by element ID + start?: { id: string }; + end?: { id: string }; } // API Response types @@ -168,7 +171,7 @@ export interface WebSocketMessage { [key: string]: any; } -export type WebSocketMessageType = +export type WebSocketMessageType = | 'initial_elements' | 'element_created' | 'element_updated' @@ -176,7 +179,10 @@ export type WebSocketMessageType = | 'elements_batch_created' | 'elements_synced' | 'sync_status' - | 'mermaid_convert'; + | 'mermaid_convert' + | 'canvas_cleared' + | 'export_image_request' + | 'set_viewport'; export interface InitialElementsMessage extends WebSocketMessage { type: 'initial_elements'; @@ -240,9 +246,44 @@ export interface MermaidConversionResponse extends ApiResponse { count: number; } +// Canvas cleared message +export interface CanvasClearedMessage extends WebSocketMessage { + type: 'canvas_cleared'; + timestamp: string; +} + +// Image export types +export interface ExportImageRequestMessage extends WebSocketMessage { + type: 'export_image_request'; + requestId: string; + format: 'png' | 'svg'; + background?: boolean; +} + +// Viewport control types +export interface SetViewportMessage extends WebSocketMessage { + type: 'set_viewport'; + requestId: string; + scrollToContent?: boolean; + scrollToElementId?: string; + zoom?: number; + offsetX?: number; + offsetY?: number; +} + +// Snapshot types +export interface Snapshot { + name: string; + elements: ServerElement[]; + createdAt: string; +} + // In-memory storage for Excalidraw elements export const elements = new Map(); +// In-memory storage for snapshots +export const snapshots = new Map(); + // Validation function for Excalidraw elements export function validateElement(element: Partial): element is ServerElement { const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];