feat: add SQLite persistence, multi-tenancy, auto-sync, and CI/Docker improvements (#1)

Replace in-memory storage with SQLite (WAL mode), add workspace-based
multi-tenancy with auto-detection via server.listRoots(), and embed the
canvas server into the MCP process for single-process operation.

🔧 Core enhancements:
- SQLite persistence with versioning, element history, and search
- Multi-tenancy: isolated canvases per workspace (SHA-256 tenant IDs)
- Embedded canvas lifecycle (single node process starts MCP + canvas)
- Auto-sync with 3s debounce and manual override toggle
- Configurable canvas port via CANVAS_PORT env var
- 6 new MCP tools (search, history, tenants, projects)
- Workspace switcher UI with dropdown search
- Sync normalization to prevent bound-text breakage on reload

🐳 Docker & CI improvements:
- BuildKit cache mounts for faster npm installs across builds
- Skip native compilation in frontend-builder stage (--ignore-scripts)
- Build only linux/amd64 on PRs, multi-arch on push to main
- Docker Hub registry with proper build tools for better-sqlite3
- CI and Docker status check gates (github/ci-status-check, github/docker-build-check)

📦 Package & publishing:
- Renamed to @sanjibdevnath/mcp-excalidraw-local (v3.0.0)
- Updated npm-publish workflow for scoped package
- Updated bin entry, keywords, and files list

📝 Documentation:
- README with UI screenshots, architecture diagram, and full feature docs
- Updated agent skill with 32-tool cheatsheet and workflow playbooks
- Fork attribution and upstream comparison table

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sanjib Devnath
2026-02-19 10:17:29 +05:30
committed by GitHub
co-authored by Cursor
parent 9883dce220
commit 7143b5e41e
23 changed files with 8278 additions and 923 deletions
+24
View File
@@ -70,3 +70,27 @@ jobs:
- name: Check for TypeScript errors - name: Check for TypeScript errors
run: npm run type-check run: npm run type-check
ci-status:
runs-on: ubuntu-latest
continue-on-error: false
name: CI Status Check
needs: [build-and-test, lint-check]
if: always()
permissions:
statuses: write
steps:
- name: Failed
id: failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
-d '{ "state" : "failure" , "context" : "github/ci-status-check" , "description" : "CI checks failed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
exit 1
- name: Success
if: steps.failed.conclusion == 'skipped'
run: |
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
-d '{ "state" : "success" , "context" : "github/ci-status-check" , "description" : "CI checks passed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
+46 -33
View File
@@ -10,9 +10,9 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
REGISTRY: ghcr.io REGISTRY: docker.io
IMAGE_NAME_MCP: ${{ github.repository }} IMAGE_NAME_MCP: sanjibdevnath/mcp-excalidraw-local
IMAGE_NAME_CANVAS: ${{ github.repository }}-canvas IMAGE_NAME_CANVAS: sanjibdevnath/mcp-excalidraw-local-canvas
jobs: jobs:
build-and-push-mcp: build-and-push-mcp:
@@ -20,7 +20,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write
steps: steps:
- name: Checkout code - name: Checkout code
@@ -29,12 +28,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }}
username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for MCP Server - name: Extract metadata for MCP Server
id: meta id: meta
@@ -55,19 +54,18 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
build-and-push-canvas: build-and-push-canvas:
name: Build and Push Canvas Server Image name: Build and Push Canvas Server Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write
steps: steps:
- name: Checkout code - name: Checkout code
@@ -76,12 +74,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }}
username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Canvas Server - name: Extract metadata for Canvas Server
id: meta id: meta
@@ -102,39 +100,30 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile.canvas file: ./Dockerfile.canvas
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
test-docker-images: test-docker-images:
name: Test Docker Images name: Test Docker Images
needs: [build-and-push-mcp, build-and-push-canvas] needs: [build-and-push-mcp, build-and-push-canvas]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Log in to GitHub Container Registry - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }}
username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "value=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
else
echo "value=${{ github.ref_name }}" >> $GITHUB_OUTPUT
fi
- name: Test Canvas Server image - name: Test Canvas Server image
run: | run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }} docker pull ${{ env.IMAGE_NAME_CANVAS }}:latest
docker run -d -p 3000:3000 --name test-canvas ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }} docker run -d -p 3000:3000 --name test-canvas ${{ env.IMAGE_NAME_CANVAS }}:latest
sleep 10 sleep 10
curl -f http://localhost:3000/health || exit 1 curl -f http://localhost:3000/health || exit 1
docker logs test-canvas docker logs test-canvas
@@ -142,5 +131,29 @@ jobs:
- name: Test MCP Server image - name: Test MCP Server image
run: | run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}:${{ steps.tag.outputs.value }} docker pull ${{ env.IMAGE_NAME_MCP }}:latest
echo "MCP Server image pulled successfully" echo "MCP Server image pulled successfully"
docker-status:
runs-on: ubuntu-latest
continue-on-error: false
name: Docker Build Status Check
needs: [build-and-push-mcp, build-and-push-canvas]
if: always()
permissions:
statuses: write
steps:
- name: Failed
id: failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
-d '{ "state" : "failure" , "context" : "github/docker-build-check" , "description" : "Docker build failed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
exit 1
- name: Success
if: steps.failed.conclusion == 'skipped'
run: |
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
-d '{ "state" : "success" , "context" : "github/docker-build-check" , "description" : "Docker build passed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
+4 -4
View File
@@ -53,7 +53,7 @@ jobs:
- name: Check if version exists on NPM - name: Check if version exists on NPM
id: check-version id: check-version
run: | run: |
if npm view mcp-excalidraw-server@${{ steps.package-version.outputs.version }} version 2>/dev/null; then if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT echo "exists=true" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM" echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
else else
@@ -82,14 +82,14 @@ jobs:
- name: Create GitHub Release Assets - name: Create GitHub Release Assets
if: github.event_name == 'release' if: github.event_name == 'release'
run: | run: |
tar -czf mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz dist/ tar -czf mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz dist/
- name: Upload Release Assets - name: Upload Release Assets
if: github.event_name == 'release' if: github.event_name == 'release'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: | files: |
mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -103,4 +103,4 @@ jobs:
- name: Success notification - name: Success notification
run: | run: |
echo "✅ Package successfully published to NPM!" echo "✅ Package successfully published to NPM!"
echo "View at: https://www.npmjs.com/package/mcp-excalidraw-server" echo "View at: https://www.npmjs.com/package/@sanjibdevnath/mcp-excalidraw-local"
+2 -1
View File
@@ -18,4 +18,5 @@ public/dist/
# Development artifacts # Development artifacts
*.excalidraw *.excalidraw
docs/ docs/*
!docs/screenshots/
+12 -23
View File
@@ -1,58 +1,47 @@
# Dockerfile for MCP Excalidraw Server # Dockerfile for MCP Excalidraw Server
# This builds the MCP server only (core product for CI/CD and GHCR) # Builds the MCP server with SQLite persistence
# The canvas server is optional and runs separately
# Stage 1: Build backend (TypeScript compilation) # Stage 1: Build backend (TypeScript compilation + native modules)
FROM node:18-slim AS builder FROM node:18-slim AS builder
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# Install all dependencies (including TypeScript compiler)
RUN npm ci && npm cache clean --force
# Copy backend source
COPY src ./src COPY src ./src
COPY tsconfig.json ./ COPY tsconfig.json ./
# Compile TypeScript
RUN npm run build:server RUN npm run build:server
# Stage 2: Production MCP Server # Stage 2: Production MCP Server
FROM node:18-slim AS production FROM node:18-slim AS production
# Create non-root user for security RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 --gid 1001 nodejs adduser --system --uid 1001 --gid 1001 nodejs
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# Install only production dependencies # Remove build tools after native modules are compiled
RUN npm ci --only=production && npm cache clean --force RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
# Copy compiled backend (MCP server only)
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Set ownership to nodejs user
RUN chown -R nodejs:nodejs /app RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs USER nodejs
# Set environment variables with defaults
ENV NODE_ENV=production ENV NODE_ENV=production
ENV EXPRESS_SERVER_URL=http://localhost:3000 ENV EXPRESS_SERVER_URL=http://localhost:3000
ENV ENABLE_CANVAS_SYNC=true ENV ENABLE_CANVAS_SYNC=true
# Run MCP server (stdin/stdout protocol)
CMD ["node", "dist/index.js"] CMD ["node", "dist/index.js"]
# Labels for metadata LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
LABEL org.opencontainers.image.source="https://github.com/yctimlin/mcp_excalidraw" LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents (with SQLite persistence & multi-tenancy)"
LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
+13 -33
View File
@@ -1,82 +1,62 @@
# Dockerfile for Canvas Server (Optional) # Dockerfile for Canvas Server (Optional)
# This is for users who want to run the visual canvas UI # Provides the web interface, REST API, and SQLite persistence
# The canvas server provides the web interface and REST API
# Stage 1: Build frontend # Stage 1: Build frontend
FROM node:18-slim AS frontend-builder FROM node:18-slim AS frontend-builder
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
# Install all dependencies (including dev dependencies for build)
RUN npm ci && npm cache clean --force
# Copy frontend source
COPY frontend ./frontend COPY frontend ./frontend
COPY vite.config.js ./ COPY vite.config.js ./
# Build frontend
RUN npm run build:frontend RUN npm run build:frontend
# Stage 2: Build backend (TypeScript compilation) # Stage 2: Build backend (TypeScript compilation + native modules)
FROM node:18-slim AS backend-builder FROM node:18-slim AS backend-builder
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# Install all dependencies (including TypeScript compiler)
RUN npm ci && npm cache clean --force
# Copy backend source
COPY src ./src COPY src ./src
COPY tsconfig.json ./ COPY tsconfig.json ./
# Compile TypeScript
RUN npm run build:server RUN npm run build:server
# Stage 3: Production Canvas Server # Stage 3: Production Canvas Server
FROM node:18-slim AS production FROM node:18-slim AS production
# Create non-root user for security RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 --gid 1001 nodejs adduser --system --uid 1001 --gid 1001 nodejs
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# Install only production dependencies # Remove build tools after native modules are compiled
RUN npm ci --only=production && npm cache clean --force RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
# Copy compiled backend from builder stage
COPY --from=backend-builder /app/dist ./dist COPY --from=backend-builder /app/dist ./dist
# Copy built frontend from frontend-builder stage
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
# Set ownership to nodejs user
RUN chown -R nodejs:nodejs /app RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs USER nodejs
# Set environment variables with defaults
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
# Expose port
EXPOSE 3000 EXPOSE 3000
# Run canvas server (web UI + REST API)
CMD ["node", "dist/server.js"] CMD ["node", "dist/server.js"]
# Labels for metadata LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
LABEL org.opencontainers.image.source="https://github.com/yctimlin/mcp_excalidraw" LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API (with SQLite persistence & multi-tenancy)"
LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
+310 -325
View File
@@ -1,51 +1,69 @@
# Excalidraw MCP Server & Agent Skill # MCP Excalidraw Local
[![CI](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/ci.yml/badge.svg)](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/ci.yml) [![CI](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/ci.yml/badge.svg)](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/ci.yml)
[![Docker Build & Push](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/docker.yml/badge.svg)](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/docker.yml) [![Docker Build & Push](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/docker.yml/badge.svg)](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/docker.yml)
[![NPM Version](https://img.shields.io/npm/v/mcp-excalidraw-server)](https://www.npmjs.com/package/mcp-excalidraw-server)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Run a live Excalidraw canvas and control it from AI agents. This repo provides: A fully local, self-hosted Excalidraw MCP server with **SQLite persistence**, **multi-tenancy**, and **auto-sync** — designed to run entirely on your machine without depending on `excalidraw.com`.
- **MCP Server**: Connect via Model Context Protocol (Claude Desktop, Cursor, Codex CLI, etc.) Run a live Excalidraw canvas and control it from any AI agent. This repo provides:
- **Agent Skill**: Portable skill for Claude Code, Codex CLI, and other skill-enabled agents
Keywords: Excalidraw agent skill, Excalidraw MCP server, AI diagramming, Claude Code skill, Codex CLI skill, Claude Desktop MCP, Cursor MCP, Mermaid to Excalidraw. - **MCP Server**: 32 tools over stdio — works with any MCP-compatible client
- **Agent Skill**: Portable skill with workflow playbooks, cheatsheets, and helper scripts
- **Live Canvas**: Real-time Excalidraw UI synced via WebSocket
- **SQLite Persistence**: Elements survive restarts, with versioning and search
- **Multi-Tenancy**: Isolated canvases per workspace, auto-detected
## Demo > **Fork notice:** This project is forked from [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw) and extends it with persistence, multi-workspace support, and numerous UX improvements. Full credit to the original author for the excellent foundation. See [What Changed From Upstream](#what-changed-from-upstream) for details.
![MCP Excalidraw Demo](demo.gif) Keywords: Excalidraw MCP server, AI diagramming, local Excalidraw, self-hosted, SQLite persistence, multi-tenant, Mermaid to Excalidraw.
*AI agent creates a complete architecture diagram from a single prompt (4x speed). [Watch full video on YouTube](https://youtu.be/ufW78Amq5qA)* ## Screenshots
### Canvas UI
The live Excalidraw canvas with toolbar, connection status, sync controls, and workspace badge:
![Canvas UI](docs/screenshots/canvas-ui.png)
### Workspace Switcher
Click the workspace badge to switch between isolated canvases — each workspace has its own set of diagrams:
![Workspace Switcher](docs/screenshots/workspace-switcher.png)
> For a demo of the upstream project (before persistence/multi-tenancy), see the [original video by @yctimlin](https://youtu.be/ufW78Amq5qA).
## Table of Contents ## Table of Contents
- [Demo](#demo) - [Screenshots](#screenshots)
- [What It Is](#what-it-is) - [What It Is](#what-it-is)
- [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp) - [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp)
- [What Changed From Upstream](#what-changed-from-upstream)
- [What's New](#whats-new) - [What's New](#whats-new)
- [Quick Start (Local)](#quick-start-local) - [Architecture](#architecture)
- [Quick Start](#quick-start)
- [Quick Start (Docker)](#quick-start-docker) - [Quick Start (Docker)](#quick-start-docker)
- [Configure MCP Clients](#configure-mcp-clients) - [Configuration](#configuration)
- [Claude Desktop](#claude-desktop) - [Environment Variables](#environment-variables)
- [Claude Code](#claude-code) - [Multi-Tenancy (Workspaces)](#multi-tenancy-workspaces)
- [Cursor](#cursor)
- [Codex CLI](#codex-cli)
- [OpenCode](#opencode)
- [Antigravity (Google)](#antigravity-google)
- [Agent Skill (Optional)](#agent-skill-optional) - [Agent Skill (Optional)](#agent-skill-optional)
- [MCP Tools (26 Total)](#mcp-tools-26-total) - [MCP Tools (32 Total)](#mcp-tools-32-total)
- [Testing](#testing) - [Testing](#testing)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Known Issues / TODO](#known-issues--todo) - [Known Issues / TODO](#known-issues--todo)
- [Development](#development) - [Development](#development)
- [Credits](#credits)
## What It Is ## What It Is
This repo contains two separate processes: This MCP server gives AI agents a full canvas toolkit to build, inspect, and iteratively refine Excalidraw diagrams — including the ability to see what they drew.
- Canvas server: web UI + REST API + WebSocket updates (default `http://localhost:3000`) The repo contains a single Node.js process that runs:
- MCP server: exposes MCP tools over stdio; syncs to the canvas via `EXPRESS_SERVER_URL`
- **MCP server** (stdio): 32 tools for element CRUD, layout, scene awareness, file I/O, snapshots, search, multi-tenancy, and more
- **Canvas server** (embedded): web UI + REST API + WebSocket updates at `http://localhost:<CANVAS_PORT>`
- **SQLite database**: persistent storage at `~/.excalidraw-mcp/excalidraw.db`
## How We Differ from the Official Excalidraw MCP ## How We Differ from the Official Excalidraw MCP
@@ -53,8 +71,10 @@ Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mc
| | Official Excalidraw MCP | This Project | | | Official Excalidraw MCP | This Project |
|---|---|---| |---|---|---|
| **Approach** | Prompt in, diagram out (one-shot) | Programmatic element-level control (26 tools) | | **Approach** | Prompt in, diagram out (one-shot) | Programmatic element-level control (32 tools) |
| **State** | Stateless — each call is independent | Persistent live canvas with real-time sync | | **State** | Stateless — each call is independent | Persistent live canvas with real-time sync |
| **Storage** | None | SQLite with WAL mode, versioning, element history |
| **Multi-tenancy** | No | Workspace-based isolation, auto-detected |
| **Element CRUD** | No | Full create / read / update / delete per element | | **Element CRUD** | No | Full create / read / update / delete per element |
| **AI sees the canvas** | No | `describe_scene` (structured text) + `get_canvas_screenshot` (image) | | **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 | | **Iterative refinement** | No — regenerate the whole diagram | Draw → look → adjust → look again, element by element |
@@ -62,7 +82,7 @@ Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mc
| **File I/O** | No | `export_scene` / `import_scene` (.excalidraw JSON) | | **File I/O** | No | `export_scene` / `import_scene` (.excalidraw JSON) |
| **Snapshot & rollback** | No | `snapshot_scene` / `restore_snapshot` | | **Snapshot & rollback** | No | `snapshot_scene` / `restore_snapshot` |
| **Mermaid conversion** | No | `create_from_mermaid` | | **Mermaid conversion** | No | `create_from_mermaid` |
| **Shareable URLs** | Yes | Yes — `export_to_excalidraw_url` | | **Search** | No | `search_elements` — full-text search across labels |
| **Design guide** | `read_me` cheat sheet | `read_diagram_guide` (colors, sizing, layout, anti-patterns) | | **Design guide** | `read_me` cheat sheet | `read_diagram_guide` (colors, sizing, layout, anti-patterns) |
| **Viewport control** | Camera animations | `set_viewport` (zoom-to-fit, center on element, manual zoom) | | **Viewport control** | Camera animations | `set_viewport` (zoom-to-fit, center on element, manual zoom) |
| **Live canvas UI** | Rendered inline in chat | Standalone Excalidraw app synced via WebSocket | | **Live canvas UI** | Rendered inline in chat | Standalone Excalidraw app synced via WebSocket |
@@ -71,338 +91,261 @@ Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mc
**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. **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 Changed From Upstream
This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw) with the following enhancements:
| Area | Upstream | This Fork |
|---|---|---|
| **Storage** | In-memory (lost on restart) | SQLite with WAL mode, versioning, element history |
| **Multi-tenancy** | None | Workspace-based tenant isolation (auto-detected via `server.listRoots()`) |
| **Canvas lifecycle** | Separate process (2 terminals) | Embedded in MCP process (single `node dist/index.js`) |
| **Auto-sync** | Manual "Sync to Backend" button | Debounced auto-sync (3s idle) with manual override |
| **Canvas port** | Hardcoded 3000 | Configurable via `CANVAS_PORT` env var |
| **MCP tools** | 26 | 32 (added search, history, tenants, projects) |
| **Workspace switcher** | None | Dropdown with search in canvas UI |
| **Sync normalization** | Bound text breaks on reload | Elements normalized to MCP format before storage |
| **Projects** | None | Multiple projects per tenant |
| **Element history** | None | Full version history per element |
| **Search** | None | Full-text search across elements |
### New MCP Tools (6 added)
| Tool | Description |
|---|---|
| `search_elements` | Full-text search across element labels and text |
| `element_history` | View version history for any element |
| `list_projects` | List projects within the active tenant |
| `switch_project` | Switch between projects |
| `list_tenants` | List all workspace tenants |
| `switch_tenant` | Switch the active workspace tenant |
## What's New ## What's New
### v2.0 — Canvas Toolkit ### v3.0 — This Fork (Persistence & Multi-Tenancy)
- **SQLite persistence**: Elements, projects, tenants, snapshots, and element versions stored in `~/.excalidraw-mcp/excalidraw.db` with WAL mode and `busy_timeout` for multi-process safety
- **Multi-tenancy**: Each workspace gets an isolated canvas. Tenant auto-detected from workspace path via `server.listRoots()`. UI dropdown with search for switching workspaces
- **Embedded canvas**: Canvas server runs inside the MCP process — single `node dist/index.js` starts everything, stops together
- **Auto-sync with debounce**: Canvas changes are automatically persisted after 3s of inactivity. Manual sync button as fallback. Toggle auto-sync on/off
- **Configurable port**: `CANVAS_PORT` env var (default `3000`)
- **Sync normalization**: Excalidraw's internal bound-text representation is normalized to MCP format before storage, preventing text overflow/detachment on reload
- **6 new MCP tools**: `search_elements`, `element_history`, `list_projects`, `switch_project`, `list_tenants`, `switch_tenant`
- **Updated agent skill**: Comprehensive workflow playbook with iterative write-check-review cycle, sizing rules, anti-patterns, and quality checklist
- **Workspace switcher UI**: Click "Workspace: ..." badge to search and switch between workspaces
### v2.0 — Canvas Toolkit (upstream)
- 13 new MCP tools (26 total): `get_element`, `clear_canvas`, `export_scene`, `import_scene`, `export_to_image`, `duplicate_elements`, `snapshot_scene`, `restore_snapshot`, `describe_scene`, `get_canvas_screenshot`, `read_diagram_guide`, `export_to_excalidraw_url`, `set_viewport` - 13 new MCP tools (26 total): `get_element`, `clear_canvas`, `export_scene`, `import_scene`, `export_to_image`, `duplicate_elements`, `snapshot_scene`, `restore_snapshot`, `describe_scene`, `get_canvas_screenshot`, `read_diagram_guide`, `export_to_excalidraw_url`, `set_viewport`
- **Closed feedback loop**: AI can now inspect the canvas (`describe_scene`) and see it (`get_canvas_screenshot` returns an image) — enabling iterative refinement - **Closed feedback loop**: AI can now inspect the canvas (`describe_scene`) and see it (`get_canvas_screenshot` returns an image) — enabling iterative refinement
- **Design guide**: `read_diagram_guide` returns best-practice color palettes, sizing rules, layout patterns, and anti-patterns — dramatically improves AI-generated diagram quality - **Design guide**: `read_diagram_guide` returns best-practice color palettes, sizing rules, layout patterns, and anti-patterns
- **Shareable URLs**: `export_to_excalidraw_url` encrypts and uploads the scene to excalidraw.com, returns a shareable link anyone can open - **Viewport control**: `set_viewport` with `scrollToContent`, `scrollToElementId`, or manual zoom/offset
- **Viewport control**: `set_viewport` with `scrollToContent`, `scrollToElementId`, or manual zoom/offset — agents can auto-fit diagrams after creation
- **File I/O**: export/import full `.excalidraw` JSON files - **File I/O**: export/import full `.excalidraw` JSON files
- **Snapshots**: save and restore named canvas states - **Snapshots**: save and restore named canvas states
- **Skill fallback**: Agent skill auto-detects MCP vs REST API mode, gracefully falls back to HTTP endpoints when MCP server isn't configured - **Skill fallback**: Agent skill auto-detects MCP vs REST API mode
- 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 - Fixed all previously known issues: `align_elements` / `distribute_elements` fully implemented, points type normalization, removed invalid `label` type, `ungroup_elements` now errors on failure
### v1.x ### v1.x (upstream)
- Agent skill: `skills/excalidraw-skill/` (portable instructions + helper scripts for export/import and repeatable CRUD) - 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`) - Better testing loop: MCP Inspector CLI examples + browser screenshot checks
- Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed (`main.tsx`) - Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed
## Quick Start (Local) ## Architecture
Prereqs: Node >= 18, npm ```
┌─────────────────────────────────────────────────┐
```bash │ MCP Process (single node process) │
npm ci │ │
npm run build │ ┌──────────────┐ ┌──────────────────────┐ │
│ │ MCP Server │────▶│ Canvas Server │ │
│ │ (stdio) │ │ (Express + WS) │ │
│ │ 32 tools │ │ http://localhost:PORT │ │
│ └──────────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌──────────▼───────────┐ │
│ │ SQLite Database │ │
│ │ ~/.excalidraw-mcp/ │ │
│ │ excalidraw.db │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────┘
▲ ▲
│ stdio │ HTTP/WS
┌────┴─────┐ ┌───────┴──────┐
│ Any MCP │ │ Browser │
│ Client │ │ :3000 │
│ │ │ (Excalidraw) │
└──────────┘ └──────────────┘
``` ```
Terminal 1: start the canvas - **Single process**: The MCP server embeds the canvas server. Starting the MCP starts both; stopping it stops both.
- **SQLite**: Stored at `~/.excalidraw-mcp/excalidraw.db` by default. WAL mode + `busy_timeout` for multi-process safety.
- **Multi-tenancy**: Each workspace gets an isolated tenant (SHA-256 hash of workspace path). The UI shows a workspace switcher dropdown with search.
## Quick Start
### Option A: NPM (recommended)
```bash ```bash
HOST=0.0.0.0 PORT=3000 npm run canvas npx @sanjibdevnath/mcp-excalidraw-local
``` ```
Open `http://localhost:3000`. Or install globally:
Terminal 2: run the MCP server (stdio)
```bash ```bash
EXPRESS_SERVER_URL=http://localhost:3000 node dist/index.js npm install -g @sanjibdevnath/mcp-excalidraw-local
mcp-excalidraw-local
``` ```
### Option B: From source
**Prerequisites:** Node >= 18, npm or pnpm
```bash
git clone https://github.com/sanjibdevnathlabs/mcp-excalidraw-local.git
cd mcp-excalidraw-local
# Install dependencies (pnpm or npm)
pnpm install
pnpm rebuild better-sqlite3 esbuild
# Build frontend + server
pnpm run build
```
The MCP server is typically started by your MCP client — see [Configuration](#configuration). To run manually:
```bash
node dist/index.js
```
This starts the MCP server (stdio) **and** the canvas server. Open `http://localhost:3000` in your browser.
## Quick Start (Docker) ## Quick Start (Docker)
Canvas server: Canvas server:
```bash ```bash
docker run -d -p 3000:3000 --name mcp-excalidraw-canvas ghcr.io/yctimlin/mcp_excalidraw-canvas:latest docker run -d -p 3000:3000 --name mcp-excalidraw-canvas sanjibdevnath/mcp-excalidraw-local-canvas:latest
``` ```
MCP server (stdio) is typically launched by your MCP client (Claude Desktop/Cursor/etc.). If you want a local container for it, use the image `ghcr.io/yctimlin/mcp_excalidraw:latest` and set `EXPRESS_SERVER_URL` to point at the canvas. MCP server (stdio) is typically launched by your MCP client. If you want a local container, use `sanjibdevnath/mcp-excalidraw-local:latest`.
## Configure MCP Clients ## Configuration
The MCP server runs over stdio and can be configured with any MCP-compatible client. Below are configurations for both **local** (requires cloning and building) and **Docker** (pull-and-run) setups. This is a standard MCP server communicating over **stdio**. It works with any MCP-compatible client (Cursor, Claude Desktop, Claude Code, Codex CLI, OpenCode, Gemini, or any other agent that supports the Model Context Protocol).
### Environment Variables ### JSON config (most clients)
Add this to your client's MCP configuration file:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "node",
"args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"],
"env": {
"CANVAS_PORT": "3000"
}
}
}
}
```
Replace `/absolute/path/to/mcp-excalidraw-local` with the actual path where you cloned and built the repo.
### CLI-based registration
```bash
# Example for Claude Code
claude mcp add excalidraw-canvas --scope user \
-e CANVAS_PORT=3000 \
-- node /absolute/path/to/mcp-excalidraw-local/dist/index.js
```
### Docker
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "CANVAS_PORT=3000",
"sanjibdevnath/mcp-excalidraw-local:latest"
]
}
}
}
```
> **Note:** For Docker on Linux, you may need `--add-host=host.docker.internal:host-gateway`.
### Key points
- **Single process** — The canvas server is embedded. No separate terminal or process needed.
- **Browser required for screenshots** — `export_to_image` and `get_canvas_screenshot` rely on the frontend. Open `http://localhost:3000` in a browser.
## Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `EXPRESS_SERVER_URL` | URL of the canvas server | `http://localhost:3000` | | `CANVAS_PORT` | Port for the embedded canvas server | `3000` |
| `EXCALIDRAW_DB_PATH` | Path to the SQLite database file | `~/.excalidraw-mcp/excalidraw.db` |
| `EXCALIDRAW_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` |
| `EXPRESS_SERVER_URL` | Canvas server URL (only if running canvas separately) | `http://localhost:3000` |
| `ENABLE_CANVAS_SYNC` | Enable real-time canvas sync | `true` | | `ENABLE_CANVAS_SYNC` | Enable real-time canvas sync | `true` |
--- ## Multi-Tenancy (Workspaces)
### Claude Desktop Each workspace (codebase) gets an isolated canvas. The tenant is identified by a SHA-256 hash of the workspace path.
Config location: ### How it works
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
- Linux: `~/.config/Claude/claude_desktop_config.json`
**Local (node)** 1. **Auto-detection**: When the MCP starts, it calls `server.listRoots()` to get the actual workspace path from the MCP client. This is hashed to create a unique tenant ID.
```json 2. **Per-request scoping**: Every HTTP request includes an `X-Tenant-Id` header. The canvas server uses this to scope all CRUD operations to the correct tenant.
{ 3. **UI switcher**: The canvas UI shows a "Workspace: &lt;name&gt;" badge. Click it to open a dropdown with all known workspaces, complete with search.
"mcpServers": { 4. **Multi-instance safe**: SQLite WAL mode with `busy_timeout = 5000ms` handles concurrent access from multiple client instances.
"excalidraw": {
"command": "node",
"args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"],
"env": {
"EXPRESS_SERVER_URL": "http://localhost:3000",
"ENABLE_CANVAS_SYNC": "true"
}
}
}
}
```
**Docker** ### Projects within a tenant
```json
{
"mcpServers": {
"excalidraw": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "EXPRESS_SERVER_URL=http://host.docker.internal:3000",
"-e", "ENABLE_CANVAS_SYNC=true",
"ghcr.io/yctimlin/mcp_excalidraw:latest"
]
}
}
}
```
--- Each tenant can have multiple projects (collections of elements). Use the `list_projects` and `switch_project` MCP tools, or manage via the REST API.
### Claude Code
Use the `claude mcp add` command to register the MCP server.
**Local (node)** - User-level (available across all projects):
```bash
claude mcp add excalidraw --scope user \
-e EXPRESS_SERVER_URL=http://localhost:3000 \
-e ENABLE_CANVAS_SYNC=true \
-- node /absolute/path/to/mcp_excalidraw/dist/index.js
```
**Local (node)** - Project-level (shared via `.mcp.json`):
```bash
claude mcp add excalidraw --scope project \
-e EXPRESS_SERVER_URL=http://localhost:3000 \
-e ENABLE_CANVAS_SYNC=true \
-- node /absolute/path/to/mcp_excalidraw/dist/index.js
```
**Docker**
```bash
claude mcp add excalidraw --scope user \
-- docker run -i --rm \
-e EXPRESS_SERVER_URL=http://host.docker.internal:3000 \
-e ENABLE_CANVAS_SYNC=true \
ghcr.io/yctimlin/mcp_excalidraw:latest
```
**Manage servers:**
```bash
claude mcp list # List configured servers
claude mcp remove excalidraw # Remove a server
```
---
### Cursor
Config location: `.cursor/mcp.json` in your project root (or `~/.cursor/mcp.json` for global config)
**Local (node)**
```json
{
"mcpServers": {
"excalidraw": {
"command": "node",
"args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"],
"env": {
"EXPRESS_SERVER_URL": "http://localhost:3000",
"ENABLE_CANVAS_SYNC": "true"
}
}
}
}
```
**Docker**
```json
{
"mcpServers": {
"excalidraw": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "EXPRESS_SERVER_URL=http://host.docker.internal:3000",
"-e", "ENABLE_CANVAS_SYNC=true",
"ghcr.io/yctimlin/mcp_excalidraw:latest"
]
}
}
}
```
---
### Codex CLI
Use the `codex mcp add` command to register the MCP server.
**Local (node)**
```bash
codex mcp add excalidraw \
--env EXPRESS_SERVER_URL=http://localhost:3000 \
--env ENABLE_CANVAS_SYNC=true \
-- node /absolute/path/to/mcp_excalidraw/dist/index.js
```
**Docker**
```bash
codex mcp add excalidraw \
-- docker run -i --rm \
-e EXPRESS_SERVER_URL=http://host.docker.internal:3000 \
-e ENABLE_CANVAS_SYNC=true \
ghcr.io/yctimlin/mcp_excalidraw:latest
```
**Manage servers:**
```bash
codex mcp list # List configured servers
codex mcp remove excalidraw # Remove a server
```
---
### OpenCode
Config location: `~/.config/opencode/opencode.json` or project-level `opencode.json`
**Local (node)**
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"excalidraw": {
"type": "local",
"command": ["node", "/absolute/path/to/mcp_excalidraw/dist/index.js"],
"enabled": true,
"environment": {
"EXPRESS_SERVER_URL": "http://localhost:3000",
"ENABLE_CANVAS_SYNC": "true"
}
}
}
}
```
**Docker**
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"excalidraw": {
"type": "local",
"command": ["docker", "run", "-i", "--rm", "-e", "EXPRESS_SERVER_URL=http://host.docker.internal:3000", "-e", "ENABLE_CANVAS_SYNC=true", "ghcr.io/yctimlin/mcp_excalidraw:latest"],
"enabled": true
}
}
}
```
---
### Antigravity (Google)
Config location: `~/.gemini/antigravity/mcp_config.json`
**Local (node)**
```json
{
"mcpServers": {
"excalidraw": {
"command": "node",
"args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"],
"env": {
"EXPRESS_SERVER_URL": "http://localhost:3000",
"ENABLE_CANVAS_SYNC": "true"
}
}
}
}
```
**Docker**
```json
{
"mcpServers": {
"excalidraw": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "EXPRESS_SERVER_URL=http://host.docker.internal:3000",
"-e", "ENABLE_CANVAS_SYNC=true",
"ghcr.io/yctimlin/mcp_excalidraw:latest"
]
}
}
}
```
---
### Notes
- **Docker networking**: Use `host.docker.internal` to reach the canvas server running on your host machine. On Linux, you may need `--add-host=host.docker.internal:host-gateway` or use `172.17.0.1`.
- **Canvas server**: Must be running before the MCP server connects. Start it with `npm run canvas` (local) or `docker run -d -p 3000:3000 ghcr.io/yctimlin/mcp_excalidraw-canvas:latest` (Docker).
- **Absolute paths**: When using local node setup, replace `/absolute/path/to/mcp_excalidraw` with the actual path where you cloned and built the repo.
- **In-memory storage**: The canvas server stores elements in memory. Restarting the server will clear all elements. Use the export/import scripts if you need persistence.
## Agent Skill (Optional) ## Agent Skill (Optional)
This repo includes a skill at `skills/excalidraw-skill/` 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 - **Workflow playbook** (`SKILL.md`): step-by-step guidance for drawing, refining, and exporting diagrams — including an iterative write-check-review cycle, sizing rules, color palettes, and anti-patterns
- **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference - **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference for all 32 tools
- **Helper scripts** (`scripts/*.cjs`): export, import, clear, healthcheck, CRUD operations - **Helper scripts** (`scripts/*.cjs`): export, import, clear, healthcheck, CRUD operations
The skill complements the MCP server by giving your AI agent structured workflows to follow. The skill complements the MCP server by giving your AI agent structured workflows to follow.
### Install The Skill (Codex CLI example) ### Install the Skill
Copy the skill folder to your agent's skill directory:
```bash ```bash
mkdir -p ~/.codex/skills # Claude Code
cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill
```
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 mkdir -p ~/.claude/skills
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
```
**Project-level** (scoped to a specific project, can be committed to the repo): # Cursor
```bash mkdir -p ~/.cursor/skills
mkdir -p /path/to/your/project/.claude/skills cp -R skills/excalidraw-skill ~/.cursor/skills/excalidraw-skill
cp -R skills/excalidraw-skill /path/to/your/project/.claude/skills/excalidraw-skill
```
Then invoke the skill in Claude Code with `/excalidraw-skill`. # Codex CLI
mkdir -p ~/.codex/skills
cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill
# Or any agent that supports a skills directory
cp -R skills/excalidraw-skill /path/to/your/agent/skills/
```
To update an existing installation, remove the old folder first then re-copy. To update an existing installation, remove the old folder first then re-copy.
### Use The Skill Scripts ### Use the Skill Scripts
All scripts respect `EXPRESS_SERVER_URL` (default `http://localhost:3000`) or accept `--url`. All scripts respect `EXPRESS_SERVER_URL` (default `http://localhost:3000`) or accept `--url`.
@@ -412,16 +355,16 @@ EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/ex
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/import-elements.cjs --in diagram.elements.json --mode batch 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 ### When the Skill Is Useful
- Repository workflow: export elements as JSON, commit it, and re-import later. - **Repository workflow**: export elements as JSON, commit it, and re-import later
- Reliable refactors: clear + re-import in `sync` mode to make canvas match a file. - **Reliable refactors**: clear + re-import in `sync` mode to make canvas match a file
- Automated smoke tests: create/update/delete a known element to validate a deployment. - **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. - **Repeatable diagrams**: keep a library of element JSON snippets and import them
See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`. See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`.
## MCP Tools (26 Total) ## MCP Tools (32 Total)
| Category | Tools | | Category | Tools |
|---|---| |---|---|
@@ -433,64 +376,106 @@ See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/c
| **Viewport** | `set_viewport` | | **Viewport** | `set_viewport` |
| **Design Guide** | `read_diagram_guide` | | **Design Guide** | `read_diagram_guide` |
| **Resources** | `get_resource` | | **Resources** | `get_resource` |
| **Search & History** | `search_elements`, `element_history` |
| **Multi-Tenancy** | `list_tenants`, `switch_tenant` |
| **Projects** | `list_projects`, `switch_project` |
Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`. Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`.
## Testing ## Testing
### Canvas Smoke Test (HTTP) ### Health check
```bash ```bash
curl http://localhost:3000/health curl http://localhost:3000/health
``` ```
### MCP Smoke Test (MCP Inspector) ### MCP Inspector
List tools: List tools:
```bash ```bash
npx @modelcontextprotocol/inspector --cli \ npx @modelcontextprotocol/inspector --cli \
-e EXPRESS_SERVER_URL=http://localhost:3000 \ -e CANVAS_PORT=3000 -- \
-e ENABLE_CANVAS_SYNC=true -- \
node dist/index.js --method tools/list node dist/index.js --method tools/list
``` ```
Create a rectangle: Create a rectangle:
```bash ```bash
npx @modelcontextprotocol/inspector --cli \ npx @modelcontextprotocol/inspector --cli \
-e EXPRESS_SERVER_URL=http://localhost:3000 \ -e CANVAS_PORT=3000 -- \
-e ENABLE_CANVAS_SYNC=true -- \
node dist/index.js --method tools/call --tool-name create_element \ node dist/index.js --method tools/call --tool-name create_element \
--tool-arg type=rectangle --tool-arg x=100 --tool-arg y=100 \ --tool-arg type=rectangle --tool-arg x=100 --tool-arg y=100 \
--tool-arg width=300 --tool-arg height=200 --tool-arg width=300 --tool-arg height=200
``` ```
### Frontend Screenshots (agent-browser) ### Frontend Screenshots
If you use `agent-browser` for UI checks: If you use a browser automation tool for UI checks:
```bash ```bash
agent-browser install # Open the canvas and take a screenshot for verification
agent-browser open http://127.0.0.1:3000 open http://127.0.0.1:3000
agent-browser wait --load networkidle # Or use agent-browser, Playwright, Puppeteer, etc.
agent-browser screenshot /tmp/canvas.png
``` ```
## Troubleshooting ## Troubleshooting
- Canvas not updating: confirm `EXPRESS_SERVER_URL` points at the running canvas server. - **Canvas not loading**: Ensure `CANVAS_PORT` isn't occupied by another process. Check `lsof -i :3000`.
- Updates/deletes fail after batch creation: ensure you are on a build that includes the batch id preservation fix (merged via PR #34). - **Canvas not updating**: Confirm the MCP process is running and the browser is connected (check the status dot in the header).
- **Wrong workspace shown**: The MCP uses `server.listRoots()` to detect the workspace. Restart your MCP client if the workspace changed.
- **Elements missing after restart**: Check `~/.excalidraw-mcp/excalidraw.db` exists. If you previously ran the upstream (in-memory) version, data wasn't persisted.
- **Port conflict with multiple instances**: Set different `CANVAS_PORT` values for each workspace, or rely on multi-tenancy (same port, different tenants).
- **Updates/deletes fail after batch creation**: Ensure you are on a build that includes the batch id preservation fix.
## Known Issues / TODO ## Known Issues / TODO
All previously listed bugs have been fixed in v2.0. Remaining items: All previously listed bugs from the upstream have been fixed. Remaining items:
- [ ] **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 rendering. The canvas UI must be open in a browser.
- [ ] **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. - [ ] **`export_to_excalidraw_url` blocked**: Organizations that block `excalidraw.com` cannot use shareable URL export. Use `export_scene` for local `.excalidraw` files instead.
Contributions welcome! Contributions welcome!
## Development ## Development
```bash ```bash
npm run type-check # Type check
npm run build pnpm run type-check
# Full build (frontend + server)
pnpm run build
# Dev mode (watch)
pnpm run dev
``` ```
### Database
SQLite database: `~/.excalidraw-mcp/excalidraw.db`
Override with `EXCALIDRAW_DB_PATH` environment variable.
### REST API
The canvas server exposes a REST API alongside the WebSocket interface:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/health` | Health check |
| GET | `/api/elements` | List all elements |
| POST | `/api/elements` | Create an element |
| PUT | `/api/elements/:id` | Update an element |
| DELETE | `/api/elements/:id` | Delete an element |
| POST | `/api/elements/sync` | Sync all elements (bulk upsert) |
| GET | `/api/tenants` | List all tenants |
| GET | `/api/tenant/active` | Get the active tenant |
| PUT | `/api/tenant/active` | Set the active tenant |
All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping.
## Credits
This project is forked from [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw) — an excellent Excalidraw MCP server with a live canvas, 26 tools, real-time WebSocket sync, Mermaid conversion, and a comprehensive agent skill. Full credit to [@yctimlin](https://github.com/yctimlin) for the original design and implementation.
This fork adds SQLite persistence, multi-tenancy, auto-sync, embedded canvas lifecycle, and workspace management on top of that foundation.
Licensed under [MIT](LICENSE).
+6 -3
View File
@@ -1,8 +1,11 @@
{ {
"mcpServers": { "mcpServers": {
"mcp_excalidraw": { "excalidraw-canvas": {
"command": "npx", "command": "node",
"args": ["-y", "excalidraw-mcp"] "args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"],
"env": {
"CANVAS_PORT": "3000"
}
} }
} }
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

+2 -2
View File
@@ -15,7 +15,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.canvas dockerfile: Dockerfile.canvas
image: mcp-excalidraw-canvas:latest image: sanjibdevnath/mcp-excalidraw-local-canvas:latest
container_name: mcp-excalidraw-canvas container_name: mcp-excalidraw-canvas
ports: ports:
- "3000:3000" - "3000:3000"
@@ -40,7 +40,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: mcp-excalidraw:latest image: sanjibdevnath/mcp-excalidraw-local:latest
container_name: mcp-excalidraw-mcp container_name: mcp-excalidraw-mcp
stdin_open: true stdin_open: true
tty: true tty: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+167 -32
View File
@@ -13,6 +13,7 @@
} }
.header { .header {
position: relative;
background: #fff; background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 10px 20px; padding: 10px 20px;
@@ -107,6 +108,7 @@
position: relative; position: relative;
} }
.api-panel { .api-panel {
position: fixed; position: fixed;
right: 20px; right: 20px;
@@ -232,48 +234,181 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
/* Sync Controls Styles */ /* Button group — joined buttons with shared style */
.sync-controls { .btn-group {
display: inline-flex;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.btn-group .btn-group-item {
border-radius: 0;
border: none;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
background: #4caf50;
color: #fff;
transition: background 0.2s;
border-right: 1px solid rgba(255,255,255,0.18);
}
.btn-group .btn-group-item:last-child { border-right: none; }
.btn-group .btn-group-item:hover:not(:disabled) { background: #43a047; }
.btn-group .btn-group-item:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-group .btn-group-item.btn-group-busy { opacity: 0.7; cursor: wait; }
/* Floating toast — centered in the header */
.toast {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: #333;
color: #fff;
font-size: 13px;
font-weight: 500;
padding: 5px 16px;
border-radius: 999px;
pointer-events: none;
white-space: nowrap;
animation: toast-in 0.25s ease, toast-out 0.4s ease 1.6s forwards;
z-index: 10;
}
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* Header left group */
.header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.header-left h1 { margin: 0; color: #333; font-size: 24px; }
.btn-loading { /* Clickable tenant badge — opens workspace switcher */
position: relative; .tenant-badge-btn {
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #ffffff40;
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 5px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.sync-status {
font-size: 12px; font-size: 12px;
min-width: 100px; font-weight: 600;
background: #e9ecef;
color: #495057;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.tenant-badge-btn:hover {
background: #dee2e6;
border-color: #ced4da;
}
.tenant-label {
color: #868e96;
font-weight: 400;
} }
.sync-success { /* Menu overlay + panel */
.menu-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.15);
z-index: 2000;
}
.menu-panel {
position: absolute;
top: 56px;
left: 100px;
width: 320px;
max-height: calc(100vh - 80px);
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
overflow: hidden;
display: flex;
flex-direction: column;
}
.menu-header {
padding: 14px 16px 10px;
font-size: 13px;
font-weight: 700;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #f0f0f0;
}
.menu-search-wrap {
padding: 8px 10px 4px;
}
.menu-search {
width: 100%;
padding: 7px 10px;
font-size: 13px;
border: 1px solid #ddd;
border-radius: 6px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.menu-search:focus {
border-color: #4caf50;
}
.menu-list {
overflow-y: auto;
max-height: 320px;
padding: 6px;
}
.menu-item {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 10px 12px;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
font-size: 14px;
position: relative;
transition: background 0.15s;
}
.menu-item:hover { background: #f5f5f5; }
.menu-item-active { background: #e8f5e9; }
.menu-item-active:hover { background: #c8e6c9; }
.menu-item-name {
font-weight: 600;
color: #333;
}
.menu-item-path {
font-size: 11px;
color: #999;
margin-top: 2px;
word-break: break-all;
}
.menu-item-check {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #4caf50; color: #4caf50;
font-weight: 700;
font-size: 16px;
} }
.menu-empty {
.sync-error { padding: 20px;
color: #f44336; text-align: center;
} color: #aaa;
font-size: 13px;
.sync-time {
color: #666;
} }
</style> </style>
</head> </head>
+352 -72
View File
@@ -70,7 +70,7 @@ interface ApiResponse {
message?: string; message?: string;
} }
type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; type SyncStatus = 'idle' | 'syncing';
// Helper function to clean elements for Excalidraw // Helper function to clean elements for Excalidraw
const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => { const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
@@ -134,14 +134,52 @@ const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial
}); });
} }
interface TenantInfo {
id: string;
name: string;
workspace_path: string;
}
function App(): JSX.Element { function App(): JSX.Element {
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null) const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
const [isConnected, setIsConnected] = useState<boolean>(false) const [isConnected, setIsConnected] = useState<boolean>(false)
const websocketRef = useRef<WebSocket | null>(null) const websocketRef = useRef<WebSocket | null>(null)
// Sync state management // Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle') const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null) const [autoSave, setAutoSave] = useState<boolean>(() => {
const stored = localStorage.getItem('excalidraw-autosave')
return stored === null ? true : stored === 'true'
})
const isSyncingRef = useRef<boolean>(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSyncedHashRef = useRef<string>('')
const DEBOUNCE_MS = 3000
// Tenant state
const [activeTenant, setActiveTenant] = useState<TenantInfo | null>(null)
const activeTenantIdRef = useRef<string | null>(null)
const [tenantList, setTenantList] = useState<TenantInfo[]>([])
const [menuOpen, setMenuOpen] = useState<boolean>(false)
const [tenantSearch, setTenantSearch] = useState<string>('')
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Keep ref in sync so closures (WebSocket handlers) always see latest tenant
useEffect(() => {
activeTenantIdRef.current = activeTenant?.id ?? null
}, [activeTenant])
// Build headers with tenant ID for all fetch calls to the backend
const tenantHeaders = (extra?: Record<string, string>): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...extra
}
const tid = activeTenantIdRef.current
if (tid) headers['X-Tenant-Id'] = tid
return headers
}
// WebSocket connection // WebSocket connection
useEffect(() => { useEffect(() => {
@@ -165,16 +203,72 @@ function App(): JSX.Element {
} }
}, [excalidrawAPI, isConnected]) }, [excalidrawAPI, isConnected])
const computeElementHash = (elements: readonly { id: string; version: number }[]): string => {
let h = String(elements.length)
for (let i = 0; i < elements.length; i++) {
h += elements[i].id
h += elements[i].version
}
return h
}
// Persist auto-save preference and cancel pending timer when toggled off
const toggleAutoSave = () => {
setAutoSave(prev => {
const next = !prev
localStorage.setItem('excalidraw-autosave', String(next))
if (!next && debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
return next
})
}
// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
}
}, [])
// Trailing debounce: resets on every change, fires after user is idle.
// Only active when auto-save is on.
const handleCanvasChange = (): void => {
if (!autoSave) return
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(() => {
if (!excalidrawAPI || isSyncingRef.current) return
const elements = excalidrawAPI.getSceneElements()
const hash = computeElementHash(elements)
if (hash === lastSyncedHashRef.current) return
syncToBackend()
}, DEBOUNCE_MS)
}
const loadExistingElements = async (): Promise<void> => { const loadExistingElements = async (): Promise<void> => {
try { try {
const response = await fetch('/api/elements') const response = await fetch('/api/elements', { headers: tenantHeaders() })
const result: ApiResponse = await response.json() const result: ApiResponse = await response.json()
if (result.success && result.elements && result.elements.length > 0) { if (result.success && result.elements && result.elements.length > 0) {
const cleanedElements = result.elements.map(cleanElementForExcalidraw) const cleanedElements = result.elements.map(cleanElementForExcalidraw)
// Elements with containerId are in Excalidraw native format (from a
// previous sync before the normalization fix). Pass them directly —
// convertToExcalidrawElements would re-create bound text and break layout.
const hasNativeFormat = cleanedElements.some((el: any) => el.containerId)
if (hasNativeFormat) {
const validated = validateAndFixBindings(cleanedElements)
excalidrawAPI?.updateScene({ elements: validated as any })
} else {
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false }) const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
excalidrawAPI?.updateScene({ elements: convertedElements }) excalidrawAPI?.updateScene({ elements: convertedElements })
} }
}
} catch (error) { } catch (error) {
console.error('Error loading existing elements:', error) console.error('Error loading existing elements:', error)
} }
@@ -355,7 +449,7 @@ function App(): JSX.Element {
const svgString = new XMLSerializer().serializeToString(svg) const svgString = new XMLSerializer().serializeToString(svg)
await fetch('/api/export/image/result', { await fetch('/api/export/image/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
format: 'svg', format: 'svg',
@@ -382,7 +476,7 @@ function App(): JSX.Element {
} }
await fetch('/api/export/image/result', { await fetch('/api/export/image/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
format: 'png', format: 'png',
@@ -393,7 +487,7 @@ function App(): JSX.Element {
console.error('Image export (FileReader) failed:', readerError) console.error('Image export (FileReader) failed:', readerError)
await fetch('/api/export/image/result', { await fetch('/api/export/image/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
error: (readerError as Error).message error: (readerError as Error).message
@@ -405,7 +499,7 @@ function App(): JSX.Element {
console.error('FileReader error:', reader.error) console.error('FileReader error:', reader.error)
await fetch('/api/export/image/result', { await fetch('/api/export/image/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
error: reader.error?.message || 'FileReader failed' error: reader.error?.message || 'FileReader failed'
@@ -419,7 +513,7 @@ function App(): JSX.Element {
console.error('Image export failed:', exportError) console.error('Image export failed:', exportError)
await fetch('/api/export/image/result', { await fetch('/api/export/image/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
error: (exportError as Error).message error: (exportError as Error).message
@@ -465,7 +559,7 @@ function App(): JSX.Element {
await fetch('/api/viewport/result', { await fetch('/api/viewport/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
success: true, success: true,
@@ -476,7 +570,7 @@ function App(): JSX.Element {
console.error('Viewport control failed:', viewportError) console.error('Viewport control failed:', viewportError)
await fetch('/api/viewport/result', { await fetch('/api/viewport/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: tenantHeaders(),
body: JSON.stringify({ body: JSON.stringify({
requestId: data.requestId, requestId: data.requestId,
error: (viewportError as Error).message error: (viewportError as Error).message
@@ -519,6 +613,27 @@ function App(): JSX.Element {
} }
break break
case 'tenant_switched':
console.log('Tenant switched:', data.tenant)
if (data.tenant) {
const incoming = data.tenant as TenantInfo
// Only reload if the switch came from an external source (MCP tool)
// and we aren't already on that tenant (UI-driven switch handles its own reload)
if (incoming.id !== activeTenantIdRef.current) {
activeTenantIdRef.current = incoming.id
setActiveTenant(incoming)
excalidrawAPI.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
lastSyncedHashRef.current = ''
loadExistingElements()
} else {
setActiveTenant(incoming)
}
}
break
default: default:
console.log('Unknown WebSocket message type:', data.type) console.log('Unknown WebSocket message type:', data.type)
} }
@@ -527,49 +642,157 @@ function App(): JSX.Element {
} }
} }
// Data format conversion for backend // Normalize Excalidraw native elements back to MCP format for backend storage.
const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => { // Excalidraw internally splits label text out of containers into separate text
return { // elements linked by containerId/boundElements. This causes text to detach on
...element // reload because convertToExcalidrawElements doesn't reconstruct that binding.
} as ServerElement // Fix: merge bound text back into container label.text so the backend always
} // stores MCP format that round-trips cleanly.
const normalizeForBackend = (elements: readonly ExcalidrawElement[]): ServerElement[] => {
const elementMap = new Map<string, ExcalidrawElement>()
for (const el of elements) elementMap.set(el.id, el)
// Format sync time display // Collect IDs of text elements that are bound inside a container
const formatSyncTime = (time: Date | null): string => { const boundTextIds = new Set<string>()
if (!time) return '' // Map containerId → text content for merging
return time.toLocaleTimeString('zh-CN', { const containerTextMap = new Map<string, { text: string; fontSize?: number; fontFamily?: number }>()
hour: '2-digit',
minute: '2-digit', for (const el of elements) {
second: '2-digit' const cid = (el as any).containerId
if (el.type === 'text' && cid && elementMap.has(cid)) {
boundTextIds.add(el.id)
containerTextMap.set(cid, {
text: (el as any).text || (el as any).originalText || '',
fontSize: (el as any).fontSize,
fontFamily: (el as any).fontFamily,
}) })
} }
}
// Main sync function const result: ServerElement[] = []
const syncToBackend = async (): Promise<void> => { for (const el of elements) {
if (!excalidrawAPI) { if (boundTextIds.has(el.id)) continue // skip bound text — merged into container
console.warn('Excalidraw API not available')
const out: any = { ...el }
// If this container has bound text, put it back as label.text
const merged = containerTextMap.get(el.id)
if (merged && merged.text) {
out.label = { text: merged.text }
if (merged.fontSize) out.fontSize = merged.fontSize
if (merged.fontFamily) out.fontFamily = merged.fontFamily
// Clean up Excalidraw-internal binding metadata
delete out.boundElements
}
// Normalize arrow bindings from Excalidraw format back to MCP format
if (el.type === 'arrow') {
const startBinding = (el as any).startBinding
const endBinding = (el as any).endBinding
if (startBinding?.elementId) out.start = { id: startBinding.elementId }
if (endBinding?.elementId) out.end = { id: endBinding.elementId }
}
result.push(out as ServerElement)
}
return result
}
// Toast message shown briefly in the center of the header
const [toast, setToast] = useState<string | null>(null)
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const showToast = (msg: string, durationMs = 2000) => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current)
setToast(msg)
toastTimerRef.current = setTimeout(() => setToast(null), durationMs)
}
// Fetch list of tenants for the menu
const fetchTenants = async () => {
try {
const res = await fetch('/api/tenants', { headers: tenantHeaders() })
if (!res.ok) return
const data = await res.json()
if (data.success) {
setTenantList(data.tenants)
}
} catch (err) {
console.error('Failed to fetch tenants:', err)
}
}
// Switch active tenant via API, then reload canvas with new tenant's elements
const switchTenant = async (tenantId: string) => {
if (tenantId === activeTenantIdRef.current) {
setMenuOpen(false)
return return
} }
try {
const res = await fetch('/api/tenant/active', {
method: 'PUT',
headers: tenantHeaders(),
body: JSON.stringify({ tenantId })
})
if (!res.ok) return
// Update ref immediately so subsequent fetch uses the new tenant
activeTenantIdRef.current = tenantId
// Clear the canvas before loading the new tenant's elements
excalidrawAPI?.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
lastSyncedHashRef.current = ''
// Update React state (will also re-sync the ref via useEffect, which is fine)
const tenant = tenantList.find(t => t.id === tenantId)
if (tenant) setActiveTenant(tenant)
setMenuOpen(false)
// Load elements for the newly-active tenant
const elemRes = await fetch('/api/elements', {
headers: {
'Content-Type': 'application/json',
'X-Tenant-Id': tenantId
}
})
const result: ApiResponse = await elemRes.json()
if (result.success && result.elements && result.elements.length > 0) {
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
const hasNativeFormat = cleanedElements.some((el: any) => el.containerId)
if (hasNativeFormat) {
const validated = validateAndFixBindings(cleanedElements)
excalidrawAPI?.updateScene({ elements: validated as any })
} else {
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
excalidrawAPI?.updateScene({ elements: convertedElements })
}
}
showToast('Workspace switched')
} catch (err) {
console.error('Failed to switch tenant:', err)
}
}
const syncToBackend = async (): Promise<void> => {
if (!excalidrawAPI || isSyncingRef.current) return
isSyncingRef.current = true
setSyncStatus('syncing') setSyncStatus('syncing')
try { try {
// 1. Get current elements
const currentElements = excalidrawAPI.getSceneElements() const currentElements = excalidrawAPI.getSceneElements()
console.log(`Syncing ${currentElements.length} elements to backend`)
// Filter out deleted elements
const activeElements = currentElements.filter(el => !el.isDeleted) const activeElements = currentElements.filter(el => !el.isDeleted)
const backendElements = normalizeForBackend(activeElements)
// 3. Convert to backend format
const backendElements = activeElements.map(convertToBackendFormat)
// 4. Send to backend
const response = await fetch('/api/elements/sync', { const response = await fetch('/api/elements/sync', {
method: 'POST', method: 'POST',
headers: { headers: tenantHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
elements: backendElements, elements: backendElements,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
@@ -578,33 +801,33 @@ function App(): JSX.Element {
if (response.ok) { if (response.ok) {
const result: ApiResponse = await response.json() const result: ApiResponse = await response.json()
setSyncStatus('success') lastSyncedHashRef.current = computeElementHash(currentElements)
setLastSyncTime(new Date()) setSyncStatus('idle')
console.log(`Sync successful: ${result.count} elements synced`) showToast('Saved')
console.log(`Sync: ${result.count} elements synced`)
// Reset status after 2 seconds
setTimeout(() => setSyncStatus('idle'), 2000)
} else { } else {
const error: ApiResponse = await response.json() setSyncStatus('idle')
setSyncStatus('error') showToast('Sync failed', 3000)
console.error('Sync failed:', error.error) console.error('Sync failed:', (await response.json() as ApiResponse).error)
} }
} catch (error) { } catch (error) {
setSyncStatus('error') setSyncStatus('idle')
showToast('Sync failed', 3000)
console.error('Sync error:', error) console.error('Sync error:', error)
} finally {
isSyncingRef.current = false
} }
} }
const clearCanvas = async (): Promise<void> => { const clearCanvas = async (): Promise<void> => {
if (excalidrawAPI) { if (excalidrawAPI) {
try { try {
// Get all current elements and delete them from backend const response = await fetch('/api/elements', { headers: tenantHeaders() })
const response = await fetch('/api/elements')
const result: ApiResponse = await response.json() const result: ApiResponse = await response.json()
if (result.success && result.elements) { if (result.success && result.elements) {
const deletePromises = result.elements.map(element => const deletePromises = result.elements.map(element =>
fetch(`/api/elements/${element.id}`, { method: 'DELETE' }) fetch(`/api/elements/${element.id}`, { method: 'DELETE', headers: tenantHeaders() })
) )
await Promise.all(deletePromises) await Promise.all(deletePromises)
} }
@@ -629,44 +852,100 @@ function App(): JSX.Element {
<div className="app"> <div className="app">
{/* Header */} {/* Header */}
<div className="header"> <div className="header">
<div className="header-left">
<h1>Excalidraw Canvas</h1> <h1>Excalidraw Canvas</h1>
{activeTenant && (
<button
className="tenant-badge-btn"
onClick={() => {
setMenuOpen(o => {
if (!o) {
setTenantSearch('')
fetchTenants()
setTimeout(() => searchInputRef.current?.focus(), 80)
}
return !o
})
}}
title="Switch workspace"
>
<span className="tenant-label">Workspace:</span> {activeTenant.name}
</button>
)}
</div>
{toast && <div className="toast">{toast}</div>}
<div className="controls"> <div className="controls">
<div className="status"> <div className="status">
<div className={`status-dot ${isConnected ? 'status-connected' : 'status-disconnected'}`}></div> <div className={`status-dot ${isConnected ? 'status-connected' : 'status-disconnected'}`}></div>
<span>{isConnected ? 'Connected' : 'Disconnected'}</span> <span>{isConnected ? 'Connected' : 'Disconnected'}</span>
</div> </div>
{/* Sync Controls */} <div className="btn-group">
<div className="sync-controls">
<button <button
className={`btn-primary ${syncStatus === 'syncing' ? 'btn-loading' : ''}`} className={`btn-group-item ${syncStatus === 'syncing' ? 'btn-group-busy' : ''}`}
onClick={syncToBackend} onClick={syncToBackend}
disabled={syncStatus === 'syncing' || !excalidrawAPI} disabled={syncStatus === 'syncing' || !excalidrawAPI}
> >
{syncStatus === 'syncing' && <span className="spinner"></span>} {syncStatus === 'syncing' ? 'Syncing...' : 'Sync'}
{syncStatus === 'syncing' ? 'Syncing...' : 'Sync to Backend'} </button>
<button
className="btn-group-item"
onClick={toggleAutoSave}
title={autoSave ? 'Auto-sync is on — click to turn off' : 'Auto-sync is off — click to turn on'}
>
{autoSave ? 'Auto ✓' : 'Auto ✗'}
</button> </button>
{/* Sync Status */}
<div className="sync-status">
{syncStatus === 'success' && (
<span className="sync-success"> Synced</span>
)}
{syncStatus === 'error' && (
<span className="sync-error"> Sync Failed</span>
)}
{lastSyncTime && syncStatus === 'idle' && (
<span className="sync-time">
Last sync: {formatSyncTime(lastSyncTime)}
</span>
)}
</div>
</div> </div>
<button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button> <button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button>
</div> </div>
</div> </div>
{/* Tenant menu overlay */}
{menuOpen && (() => {
const q = tenantSearch.toLowerCase()
const filtered = q
? tenantList.filter(t => t.name.toLowerCase().includes(q) || t.workspace_path.toLowerCase().includes(q))
: tenantList
return (
<div className="menu-overlay" onClick={() => setMenuOpen(false)}>
<div className="menu-panel" onClick={e => e.stopPropagation()}>
<div className="menu-header">Workspaces</div>
<div className="menu-search-wrap">
<input
ref={searchInputRef}
className="menu-search"
type="text"
placeholder="Search workspaces..."
value={tenantSearch}
onChange={e => setTenantSearch(e.target.value)}
/>
</div>
<div className="menu-list">
{filtered.map(t => (
<button
key={t.id}
className={`menu-item ${activeTenant?.id === t.id ? 'menu-item-active' : ''}`}
onClick={() => switchTenant(t.id)}
>
<span className="menu-item-name">{t.name}</span>
<span className="menu-item-path" title={t.workspace_path}>
{t.workspace_path.length > 40
? '...' + t.workspace_path.slice(-37)
: t.workspace_path}
</span>
{activeTenant?.id === t.id && <span className="menu-item-check"></span>}
</button>
))}
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
</div>
</div>
</div>
)
})()}
{/* Canvas Container */} {/* Canvas Container */}
<div className="canvas-container"> <div className="canvas-container">
<Excalidraw <Excalidraw
@@ -678,6 +957,7 @@ function App(): JSX.Element {
viewBackgroundColor: '#ffffff' viewBackgroundColor: '#ffffff'
} }
}} }}
onChange={handleCanvasChange}
/> />
</div> </div>
</div> </div>
+393
View File
@@ -12,6 +12,7 @@
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
"@excalidraw/mermaid-to-excalidraw": "^1.1.3", "@excalidraw/mermaid-to-excalidraw": "^1.1.3",
"@modelcontextprotocol/sdk": "latest", "@modelcontextprotocol/sdk": "latest",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
@@ -28,6 +29,7 @@
"mcp-excalidraw-server": "dist/index.js" "mcp-excalidraw-server": "dist/index.js"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.19.7", "@types/node": "^20.19.7",
@@ -2583,6 +2585,16 @@
"@babel/types": "^7.20.7" "@babel/types": "^7.20.7"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -3173,6 +3185,40 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3185,6 +3231,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -3302,6 +3368,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -3456,6 +3546,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -4258,6 +4354,30 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/delaunator": { "node_modules/delaunator": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
@@ -4295,6 +4415,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node-es": { "node_modules/detect-node-es": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -4386,6 +4515,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4513,6 +4651,15 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -4636,6 +4783,12 @@
"node": "^12.20 || >= 14.13" "node": "^12.20 || >= 14.13"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -4726,6 +4879,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4823,6 +4982,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/globals": { "node_modules/globals": {
"version": "15.15.0", "version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
@@ -4921,6 +5086,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-blob-reduce": { "node_modules/image-blob-reduce": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz",
@@ -4942,6 +5127,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internmap": { "node_modules/internmap": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -5871,6 +6062,33 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mlly": { "node_modules/mlly": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
@@ -5937,6 +6155,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -5946,6 +6170,30 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.87.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-domexception": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -6264,6 +6512,32 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6288,6 +6562,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6358,6 +6642,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -6880,6 +7179,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": { "node_modules/simple-swizzle": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ -6971,6 +7315,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stylis": { "node_modules/stylis": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -6990,6 +7343,34 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/text-hex": { "node_modules/text-hex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -7102,6 +7483,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tunnel-rat": { "node_modules/tunnel-rat": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+28 -13
View File
@@ -1,11 +1,11 @@
{ {
"name": "mcp-excalidraw-server", "name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.0.2", "version": "3.0.0",
"description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management", "description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"bin": { "bin": {
"mcp-excalidraw-server": "dist/index.js" "mcp-excalidraw-local": "dist/index.js"
}, },
"scripts": { "scripts": {
"start": "npm run build:server && node dist/index.js", "start": "npm run build:server && node dist/index.js",
@@ -24,6 +24,7 @@
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
"@excalidraw/mermaid-to-excalidraw": "^1.1.3", "@excalidraw/mermaid-to-excalidraw": "^1.1.3",
"@modelcontextprotocol/sdk": "latest", "@modelcontextprotocol/sdk": "latest",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
@@ -37,6 +38,7 @@
"zod-to-json-schema": "^3.22.3" "zod-to-json-schema": "^3.22.3"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.19.7", "@types/node": "^20.19.7",
@@ -60,21 +62,35 @@
"real-time", "real-time",
"websocket", "websocket",
"visualization", "visualization",
"claude", "sqlite",
"ai-tools" "multi-tenancy",
"self-hosted",
"local"
], ],
"author": { "author": {
"name": "yctimlin", "name": "sanjibdevnathlabs"
"email": "c22647809@gmail.com"
}, },
"contributors": [
{
"name": "yctimlin",
"email": "c22647809@gmail.com",
"url": "https://github.com/yctimlin"
}
],
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/yctimlin/mcp_excalidraw.git" "url": "https://github.com/sanjibdevnathlabs/mcp-excalidraw-local.git"
}, },
"homepage": "https://github.com/yctimlin/mcp_excalidraw#readme", "homepage": "https://github.com/sanjibdevnathlabs/mcp-excalidraw-local#readme",
"bugs": { "bugs": {
"url": "https://github.com/yctimlin/mcp_excalidraw/issues" "url": "https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/issues"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -84,9 +100,8 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"files": [ "files": [
"src/**/*",
"dist/**/*", "dist/**/*",
"*.d.ts", "skills/**/*",
"README.md", "README.md",
"LICENSE" "LICENSE"
] ]
+5507
View File
File diff suppressed because it is too large Load Diff
+305 -215
View File
@@ -1,280 +1,370 @@
--- ---
name: excalidraw-skill 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). description: Programmatic canvas toolkit for creating, editing, and refining Excalidraw diagrams via MCP tools (32 tools) or REST API with real-time canvas sync, multi-tenant workspace isolation, SQLite persistence, project management, full-text search, and element version history. Use when an agent needs to draw or lay out diagrams on a live canvas, iteratively refine diagrams using screenshots, manage workspaces/tenants and projects, export/import .excalidraw files or PNG/SVG images, search elements, view change history, save/restore canvas snapshots, or perform element-level CRUD. Canvas server port is configurable via CANVAS_PORT env var (default 3000).
--- ---
# Excalidraw Skill # Excalidraw Skill
## Step 0: Detect Connection Mode ## Step 0: Detect Connection Mode
Before doing anything, determine which mode is available. Run these checks **in order**: Run these checks **in order**:
1. **MCP Server** (best): If tools like `batch_create_elements` are available → use MCP mode.
2. **REST API** (fallback): `curl -s http://localhost:3000/health` returns `{"status":"ok"}` → use REST API mode.
3. **Nothing works**: Guide user to install (clone `sanjibdevnathlabs/mcp-excalidraw-local`, build, configure MCP).
See `references/cheatsheet.md` for the full MCP-vs-REST mapping and REST API gotchas.
## Core Principles (Read Before Any Diagram)
These principles were learned through extensive iterative use. Violating them produces bad diagrams.
### 1. Never Trust Blind Output — Use the Write-Check-Review Cycle
Every diagram iteration follows this mandatory loop:
### Check 1: MCP Server (Best experience)
```bash
mcp-cli tools | grep excalidraw
``` ```
If you see tools like `excalidraw/batch_create_elements`**use MCP mode**. Call MCP tools directly. WRITE (create/update elements)
→ CHECK (screenshot to see actual rendering)
### Check 2: REST API (Fallback — works without MCP server) → REVIEW (critically evaluate against Quality Checklist)
```bash → FIX (if issues found, fix and re-screenshot)
curl -s http://localhost:3000/health → only proceed when ALL checks pass
``` ```
If you get `{"status":"ok"}`**use REST API mode**. Use HTTP endpoints (`curl` / `fetch`) from the cheatsheet.
### Check 3: Nothing works → Guide user to install **Screenshot strategy**: `get_canvas_screenshot` may return empty images. When it fails, use Chrome DevTools MCP (`take_screenshot` after `navigate_page` to canvas URL) as a reliable fallback.
If neither works, tell the user:
> The Excalidraw canvas server is not running. To set up:
> 1. Clone: `git clone https://github.com/yctimlin/mcp_excalidraw && cd mcp_excalidraw`
> 2. Build: `npm ci && npm run build`
> 3. Start canvas: `HOST=0.0.0.0 PORT=3000 npm run canvas`
> 4. Open `http://localhost:3000` in a browser
> 5. (Recommended) Install the MCP server for the best experience:
> ```
> claude mcp add excalidraw -s user -e EXPRESS_SERVER_URL=http://localhost:3000 -- node /path/to/mcp_excalidraw/dist/index.js
> ```
### MCP vs REST API Quick Reference ### 2. Use batch_create_elements, Not Mermaid
| Operation | MCP Tool | REST API Equivalent | The `create_from_mermaid` tool produces **low-quality output**: overlapping text, poor spacing, unreadable labels. It is a quick preview tool, not a production tool.
|-----------|----------|-------------------|
| Create elements | `batch_create_elements` | `POST /api/elements/batch` with `{"elements": [...]}` |
| Get all elements | `query_elements` | `GET /api/elements` |
| Get one element | `get_element` | `GET /api/elements/:id` |
| Update element | `update_element` | `PUT /api/elements/:id` |
| Delete element | `delete_element` | `DELETE /api/elements/:id` |
| Clear canvas | `clear_canvas` | `DELETE /api/elements/clear` |
| Describe scene | `describe_scene` | `GET /api/elements` (parse manually) |
| Export scene | `export_scene` | `GET /api/elements` (save to file) |
| Import scene | `import_scene` | `POST /api/elements/sync` with `{"elements": [...]}` |
| Snapshot | `snapshot_scene` | `POST /api/snapshots` with `{"name": "..."}` |
| Restore snapshot | `restore_snapshot` | `GET /api/snapshots/:name` then `POST /api/elements/sync` |
| Screenshot | `get_canvas_screenshot` | Only via MCP (needs browser) |
| Design guide | `read_diagram_guide` | Not available — see cheatsheet for guidelines |
| Viewport | `set_viewport` | `POST /api/viewport` (needs browser) |
| Export image | `export_to_image` | `POST /api/export/image` (needs browser) |
| Export URL | `export_to_excalidraw_url` | Only via MCP |
### REST API Gotchas (Critical — read before using REST API) For quality diagrams, **always use `batch_create_elements`** with precise coordinates, explicit sizing, and color coding. The extra planning time pays for itself in fewer fix iterations.
1. **Labels**: Use `"label": {"text": "My Label"}` (not `"text": "My Label"`). MCP tools auto-convert, REST API does not. ### 3. Shapes First, Arrows Second — Two Separate Batches
2. **Arrow binding**: Use `"start": {"id": "svc-a"}, "end": {"id": "svc-b"}` (not `"startElementId"`/`"endElementId"`). MCP tools accept `startElementId` and convert, REST API requires the `start`/`end` object format directly.
3. **fontFamily**: Must be a string (e.g. `"1"`) or omit it entirely. Do NOT pass a number like `1`.
4. **Updating labels**: When updating a shape via `PUT /api/elements/:id`, include the full `label` in the update body to preserve it. Omitting `label` from the update won't delete it, but re-sending ensures it renders correctly.
5. **Screenshot in REST mode**: `POST /api/export/image` returns `{"data": "<base64>"}`. Save to file and read it back for visual verification. Requires browser open.
## Quality Gate (MANDATORY — read before creating any diagram) Create shapes in one batch, then arrows in a separate batch. Arrow binding (`startElementId`/`endElementId`) requires shapes to already exist in the scene. Mixing both in one call can work but often produces binding errors.
**After EVERY iteration (each batch of elements added), you MUST run a quality check before proceeding. NEVER say "looks great" unless ALL checks pass.** ### 4. Multiple Diagrams on One Canvas
### Quality Checklist — verify ALL before adding more elements: **Never clear the canvas** between diagrams. Place them side-by-side or in a grid:
1. **Text truncation**: Is ALL text fully visible? Labels must fit inside their shapes. If text is cut off or wrapping badly → increase `width` and/or `height`.
2. **Overlap**: Do ANY elements overlap each other? Check that no rectangles, ellipses, or text elements share the same space. Background zones must fully contain their children with padding.
3. **Arrow crossing**: Do arrows cross through unrelated elements or overlap with text labels? If yes → **use curved/elbowed arrows with waypoints** to route around obstacles (see "Arrow Routing" section). Never accept crossing arrows.
4. **Arrow-text overlap**: Do any arrow labels ("charge", "event", etc.) overlap with shapes? Arrow labels are positioned at the midpoint — if they overlap, either remove the label, shorten it, or adjust the arrow path.
5. **Spacing**: Is there at least 40px gap between elements? Cramped layouts are unreadable.
6. **Readability**: Can all labels be read at normal zoom? Font size >= 16 for body text, >= 20 for titles.
### If ANY issue is found: ```
- **STOP adding new elements** Diagram 1: x=0 to ~1100
- Fix the issue first (resize, reposition, delete and recreate) Diagram 2: x=1400 onward (300px gap)
- Re-verify with a new screenshot — or —
- Only proceed to next iteration after ALL checks pass Row 1: y=0 to ~800
Row 2: y=1100 onward (300px gap)
```
### Sizing Rules (prevent truncation): Use a title text element above each diagram to label it.
- **Shape width**: `max(160, labelTextLength * 9)` pixels. For multi-word labels like "API Gateway (Kong)", count all characters.
- **Shape height**: 60px for single line, 80px for 2 lines, 100px for 3 lines.
- **Background zones**: Add 50px padding on ALL sides around contained elements.
- **Element spacing**: 60px vertical between tiers, 40px horizontal between siblings.
- **Side panels**: Place at least 80px away from main diagram elements.
- **Arrow labels**: Keep labels short (1-2 words). Long arrow labels overlap with other elements.
### Layout Planning (prevent overlap): ### 5. Set roughness: 0 for Clean Diagrams
Before creating elements, **plan your coordinate grid** on paper first:
- Tier 1 (y=50-130): Client apps
- Tier 2 (y=200-280): Gateway/Edge
- Tier 3 (y=350-440): Services (spread wide: each service ~180px apart)
- Tier 4 (y=510-590): Data stores
- Side panels: x < 0 (left) or x > mainDiagramRight + 80 (right)
**Do NOT place side panels (observability, external APIs) at the same x-range as the main diagram — they WILL overlap.** Excalidraw defaults to hand-drawn style (roughness > 0). For professional, readable diagrams, always set `"roughness": 0` on every element. Also use `"strokeWidth": 2` for arrows to ensure visibility.
## Quick Start ## Sizing Rules (Critical — Prevents Truncation)
1. Run **Step 0** above to detect your connection mode. Excalidraw's Virgil font is ~30% wider than standard fonts. These rules account for that.
2. Open the canvas URL in a browser (required for image export/screenshot).
3. **MCP mode**: Use MCP tools for all operations. **REST mode**: Use HTTP endpoints from cheatsheet. ### Rectangles
4. For full tool/endpoint reference, read `references/cheatsheet.md`.
```
width: max(200, characterCount * 11)
height: 70 (1 line), 80 (2 lines), 100 (3 lines)
fontSize: 16-20
```
### Diamonds (Decision Nodes)
Diamond usable text area is ~50% of the bounding box. **Double your width estimate.**
```
width: max(400, longestLineChars * 18)
height: max(160, lineCount * 50)
fontSize: 16
```
A diamond with text "Behavioral guideline\nor project standard?" (20 chars) needs at least 400x160.
### Ellipses
Ellipse text area is ~60% of bounding box. Size generously.
```
width: max(280, characterCount * 14)
height: max(65, lineCount * 35)
fontSize: 16-18
```
### Text Elements (Standalone Titles)
```
fontSize: 24-28 for diagram titles
fontSize: 16-20 for annotations
```
## Arrow Visibility Rules (Critical — Prevents Invisible Arrows)
When arrows are bound to shapes via `startElementId`/`endElementId`, the actual rendered arrow length equals the **gap between shape edges minus binding padding (8px each side)**. If shapes are too close, arrows shrink to 0px and become invisible.
### Minimum Gap Between Connected Shapes
| Connection Direction | Minimum Gap | Recommended Gap |
|---------------------|-------------|-----------------|
| Vertical (top-down flow) | 80px | 120px |
| Horizontal (left-right) | 100px | 140px |
### Calculating Vertical Gap for Flowcharts
```
gap = nextShapeY - (currentShapeY + currentShapeHeight)
Example (diamonds h=160, gap needed ≥ 120):
Q1: y=260, h=160 → bottom edge = 420
Q2: y=540 → gap = 540 - 420 = 120px ✓
```
If the gap is < 80px, arrows will be too short to see — especially with labels like "YES"/"NO".
## Workflow: Draw A Diagram ## Workflow: Draw A Diagram
### MCP Mode ### Phase 1: Plan
1. **Call `read_diagram_guide`** first to load design best practices.
2. **Plan your coordinate grid** (see Quality Gate → Layout Planning) before writing any JSON.
3. Optional: `clear_canvas` to start fresh.
4. Use `batch_create_elements` with shapes AND arrows in one call.
5. **Assign custom `id` to shapes** (e.g. `"id": "auth-svc"`). Set `text` field to label shapes.
6. **Size shapes for their text** — use `width: max(160, textLength * 9)`.
7. **Bind arrows** using `startElementId` / `endElementId` — arrows auto-route.
8. `set_viewport` with `scrollToContent: true` to auto-fit the diagram.
9. **Run Quality Checklist**`get_canvas_screenshot` and critically evaluate. Fix issues before proceeding.
### REST API Mode Before writing any JSON, plan on paper:
1. Read `references/cheatsheet.md` for design guidelines.
2. **Plan your coordinate grid** (see Quality Gate → Layout Planning) before writing any JSON.
3. Optional: `curl -X DELETE http://localhost:3000/api/elements/clear`
4. Create elements in one call (use `@file.json` for large payloads):
```bash
curl -X POST http://localhost:3000/api/elements/batch \
-H "Content-Type: application/json" \
-d '{"elements": [
{"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 160, "height": 60, "label": {"text": "Service A"}},
{"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 160, "height": 60, "label": {"text": "Service B"}},
{"type": "arrow", "x": 0, "y": 0, "start": {"id": "svc-a"}, "end": {"id": "svc-b"}}
]}'
```
5. **Use `"label": {"text": "..."}` for shape labels** (not `"text": "..."`).
6. **Bind arrows with `"start": {"id": "..."}` / `"end": {"id": "..."}`** — server auto-routes edges.
7. **Size shapes for their text** — use `width: max(160, labelTextLength * 9)`.
8. **Run Quality Checklist** — take screenshot, critically evaluate. Fix issues before adding more elements.
### Arrow Binding (Recommended) 1. **List all elements**: shapes, labels, connections
2. **Choose layout direction**: top-down (flowcharts), left-right (timelines), grid (architecture)
3. **Assign coordinates**: use the sizing rules above to compute widths/heights, then lay out with proper gaps
4. **Assign IDs**: every shape needs a custom `id` so arrows can reference it
Bind arrows to shapes for auto-routed edges. The format differs between MCP and REST API: ### Phase 2: Create Shapes (Batch 1)
**MCP Mode** — use `startElementId` / `endElementId`:
```json ```json
{"elements": [ {"elements": [
{"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 120, "height": 60, "text": "Service A"}, {"id": "title", "type": "text", "x": 100, "y": 0,
{"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 120, "height": 60, "text": "Service B"}, "text": "MY DIAGRAM", "fontSize": 28, "strokeColor": "#1e1e1e"},
{"type": "arrow", "x": 0, "y": 0, "startElementId": "svc-a", "endElementId": "svc-b", "text": "calls"} {"id": "box-a", "type": "rectangle", "x": 0, "y": 80,
"width": 200, "height": 70, "text": "Service A",
"backgroundColor": "#a5d8ff", "strokeColor": "#1971c2",
"roughness": 0, "fontSize": 18},
{"id": "box-b", "type": "rectangle", "x": 0, "y": 280,
"width": 200, "height": 70, "text": "Service B",
"backgroundColor": "#b2f2bb", "strokeColor": "#2f9e44",
"roughness": 0, "fontSize": 18}
]} ]}
``` ```
**REST API Mode** — use `start: {id}` / `end: {id}` and `label: {text}`: ### Phase 3: Create Arrows (Batch 2)
```json ```json
{"elements": [ {"elements": [
{"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 120, "height": 60, "label": {"text": "Service A"}}, {"type": "arrow", "x": 100, "y": 150,
{"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 120, "height": 60, "label": {"text": "Service B"}}, "startElementId": "box-a", "endElementId": "box-b",
{"type": "arrow", "x": 0, "y": 0, "start": {"id": "svc-a"}, "end": {"id": "svc-b"}, "label": {"text": "calls"}} "width": 0, "height": 130, "text": "calls",
"strokeColor": "#1e1e1e", "roughness": 0, "strokeWidth": 2,
"endArrowhead": "arrow"}
]} ]}
``` ```
Arrows without binding use manual `x`, `y`, `points` coordinates. ### Phase 4: Check (MANDATORY)
### Arrow Routing — Avoid Overlaps (Critical for complex diagrams) 1. `set_viewport` with `scrollToContent: true`
2. Wait 1-2 seconds for render
3. Take screenshot (MCP `get_canvas_screenshot` or Chrome DevTools `take_screenshot`)
4. **Critically evaluate** against the Quality Checklist below
5. Fix any issues, re-screenshot, repeat until clean
Straight arrows (2-point) cause crossing and overlap in complex diagrams. **Use curved or elbowed arrows instead:** ## Quality Checklist
After EVERY batch of elements, verify ALL of these:
| Check | What to Look For | Fix |
|-------|-----------------|-----|
| **Text truncation** | Any label cut off or hidden? | Increase shape width/height |
| **Invisible arrows** | Can you see arrows between all connected shapes? | Increase gap between shapes to ≥ 120px |
| **Arrow labels** | Do YES/NO/labels overlap with shapes? | Shorten labels or increase gap |
| **Overlap** | Do any elements share space? | Reposition with more spacing |
| **Readability** | Can all text be read at 50-70% zoom? | Increase fontSize to ≥ 16 |
| **Spacing** | At least 40px gap between unconnected elements? | Spread elements apart |
### If ANY Check Fails
**STOP.** Do not add more elements. Fix the issue first:
1. Use `update_element` to resize/reposition
2. Or `delete_element` + recreate with better coordinates
3. Re-screenshot to verify the fix
4. Only proceed when ALL checks pass
### How to Honestly Evaluate a Screenshot
- Zoom into different regions — don't just glance at the overview
- Check every label individually for truncation
- Trace every arrow path for visibility
- **If you see ANY issue, say "I see [issue], fixing it"** — never say "looks great" unless it truly is
## Color Palette
Use consistent colors from this palette:
| Role | Fill | Stroke | Use For |
|------|------|--------|---------|
| Primary | #a5d8ff | #1971c2 | Main flow, services |
| Success | #b2f2bb | #2f9e44 | Approved, healthy, YES paths |
| Warning | #ffd8a8 | #e8590c | Attention, agents |
| Error | #ffc9c9 | #e03131 | Critical, NO paths, failures |
| Purple | #eebefa | #9c36b5 | Rules, governance |
| Cyan | #99e9f2 | #0c8599 | Data stores, MCP |
| Neutral | #e9ecef | #868e96 | Secondary, annotations |
| Default | #ffffff | #1e1e1e | Decisions, generic |
## Flowchart Template (Tested & Verified)
This template produces clean, readable decision flowcharts:
**Option 1: Curved arrows** — add intermediate waypoints + `roundness`:
```json
{
"type": "arrow", "x": 100, "y": 100,
"points": [[0, 0], [50, -40], [200, 0]],
"roundness": {"type": 2},
"strokeColor": "#1971c2"
}
``` ```
The waypoint `[50, -40]` pushes the arrow upward to arc over elements. `roundness: {type: 2}` makes it a smooth curve. Layout:
Diamonds: w=400, h=160, fontSize=16, gap=120px vertical
**Option 2: Elbowed arrows** — right-angle routing (L-shaped or Z-shaped): Answer boxes: w=300, h=80, fontSize=20, offset 130px right of diamonds
```json Start ellipse: w=340, h=70, fontSize=18
{ Title: fontSize=28
"type": "arrow", "x": 100, "y": 100, Arrows: strokeWidth=2, roughness=0
"points": [[0, 0], [0, -50], [200, -50], [200, 0]], YES arrows: strokeColor=#2f9e44 (green), horizontal right
"elbowed": true, NO arrows: strokeColor=#e03131 (red), vertical down
"strokeColor": "#1971c2" All elements: roughness=0
}
``` ```
**When to use which:** ## Architecture Diagram Template
- **Fan-out arrows** (one source → many targets): Use curved arrows with waypoints spread vertically to avoid overlapping each other.
- **Cross-lane arrows** (connecting to side panels): Use elbowed arrows that route around the main diagram — go UP first, then ACROSS, then DOWN.
- **Inter-service arrows** (horizontal connections): Use curved arrows with a slight vertical offset to avoid crossing through adjacent elements.
**Rule of thumb:** If an arrow would cross through an unrelated element, add a waypoint to route around it. Never accept crossing arrows — always fix them.
## Workflow: Iterative Refinement (Key Differentiator)
The feedback loop that makes this skill unique. **Each iteration MUST include a quality check.**
### MCP Mode (full feedback loop)
1. Add elements (`batch_create_elements`, `create_element`).
2. `set_viewport` with `scrollToContent: true`.
3. `get_canvas_screenshot` — **critically evaluate** against the Quality Checklist.
4. **If issues found** → fix them (`update_element`, `delete_element`, resize, reposition).
5. `get_canvas_screenshot` again — re-verify fix.
6. **Only proceed to next iteration when ALL quality checks pass.**
### REST API Mode (partial feedback loop)
1. Add elements via `POST /api/elements/batch`.
2. `POST /api/viewport` with `{"scrollToContent": true}`.
3. Take screenshot: `POST /api/export/image` → save PNG → **critically evaluate** against Quality Checklist.
4. **If issues found** → fix via `PUT /api/elements/:id` or delete and recreate.
5. Re-screenshot and re-verify.
6. **Only proceed to next iteration when ALL quality checks pass.**
### How to critically evaluate a screenshot:
- Look at EVERY label — is any text cut off or overflowing its container?
- Look at EVERY arrow — does any arrow pass through an unrelated element?
- Look at ALL element pairs — do any overlap or touch?
- Look at spacing — is anything crammed together?
- **Be honest.** If you see ANY issue, say "I see [issue], fixing it" — not "looks great".
Example flow (MCP):
``` ```
batch_create_elements → get_canvas_screenshot → "text truncated on 2 shapes" Layout:
→ update_element (increase widths) → get_canvas_screenshot → "overlap between X and Y" Zones: large rectangles, backgroundColor=#e9ecef, opacity=30
→ update_element (reposition) → get_canvas_screenshot → "all checks pass" Services: w=200, h=70, fontSize=18, spaced 60px apart
→ proceed to next iteration Data stores: w=180, h=60, fontSize=16, strokeColor=#0c8599
Arrows: solid for sync, dashed (strokeStyle="dashed") for async
Title: fontSize=24 above each zone
``` ```
## Workflow: Iterative Refinement
```
create shapes (batch 1)
→ create arrows (batch 2)
→ set_viewport(scrollToContent: true)
→ wait 1-2s
→ screenshot
→ evaluate quality checklist
→ issues? fix → re-screenshot → re-evaluate
→ clean? proceed to next diagram section
```
For multi-diagram canvases, offset each new diagram by 300px+ from the previous one's bounding box.
## Workflow: Multi-Tenancy (Workspaces)
The MCP is multi-tenant. Each Cursor workspace automatically gets its own tenant (identified by a SHA-256 hash of the workspace path). All elements, projects, and snapshots are scoped to the active tenant.
### Automatic Tenant Detection
On MCP startup, the server:
1. Creates a tenant from `process.cwd()` (initial guess)
2. After connecting, calls `server.listRoots()` to get the real workspace path from Cursor
3. If different, re-creates/switches to the correct tenant and notifies the canvas
This means globally-configured MCPs (`~/.cursor/mcp.json`) correctly detect the per-window workspace — no manual setup needed.
### Tenant Operations
| Task | Tool | Notes |
|------|------|-------|
| See all workspaces | `list_tenants` | Returns id, name, workspace_path, created_at |
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads that tenant's elements via WebSocket |
| Check current tenant | (from describe_scene or frontend header) | Shows "Workspace: [name]" in canvas |
### Multiple Cursor Instances
Each instance sends its own `X-Tenant-Id` header on every HTTP/MCP request. SQLite uses `busy_timeout` for concurrent write safety. No state conflicts between windows.
## Workflow: Projects (Within a Tenant)
Projects group diagrams within a tenant. Each tenant has a "Default Project" created automatically. Use projects to organize different diagram sets (e.g., "Architecture", "User Flows", "Sprint Planning").
| Task | Tool | Notes |
|------|------|-------|
| List projects | `list_projects` | Shows all projects in active tenant |
| Switch project | `switch_project` with `projectId` | Elements change to that project's set |
| Create new project | `switch_project` with `createName` | Creates and switches in one call |
## Workflow: Search & History
### Full-Text Search
`search_elements` with `query` — searches across element labels and text content in the active project. Useful for finding specific elements in large diagrams.
### Element Version History
`element_history` — view create/update/delete operations for:
- A specific element: pass `elementId`
- Entire active project: omit `elementId`
- Control result count with `limit` (default 50)
Use history to debug unexpected changes or audit what was modified.
## Workflow: Refine An Existing Diagram ## Workflow: Refine An Existing Diagram
1. `describe_scene` to understand current state. 1. `describe_scene` to understand current state
2. Identify targets by id, type, or label text (not x/y coordinates). 2. Identify targets by `id` or label text (or use `search_elements` for text search)
3. `update_element` to move/resize/recolor, `delete_element` to remove. 3. `update_element` to move/resize/recolor
4. `get_canvas_screenshot` to verify changes visually. 4. Screenshot to verify
5. If updates fail: check element id exists (`get_element`), element isn't locked (`unlock_elements`). 5. If updates fail: check element id exists (`get_element`), element isn't locked
6. Use `element_history` to see what changed if something looks wrong
## Workflow: File I/O (Diagrams-as-Code) ## Workflow: File I/O
- Export to .excalidraw format: `export_scene` with optional `filePath`. - Export: `export_scene` (optional `filePath`)
- Import from .excalidraw: `import_scene` with `mode: "replace"` or `"merge"`. - Import: `import_scene` with `mode: "replace"` or `"merge"`
- Export to image: `export_to_image` with `format: "png"` or `"svg"` (requires browser open). - Image export: `export_to_image` with `format: "png"` or `"svg"` (requires browser)
- 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) ## Workflow: Snapshots
1. `snapshot_scene` with a name before risky changes. 1. `snapshot_scene` with a name before risky changes
2. Make changes, `describe_scene` / `get_canvas_screenshot` to evaluate. 2. Make changes, screenshot to evaluate
3. `restore_snapshot` to rollback if needed. 3. `restore_snapshot` to rollback if needed
## Workflow: Duplication **Note**: Snapshot restore may not always reload elements into the active view. If the canvas appears empty after restore, re-fetch elements or recreate.
- `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 ## Workflow: Viewport Control
- `set_viewport` with `scrollToContent: true` — auto-fit all elements (zoom-to-fit). - `scrollToContent: true` — auto-fit all elements
- `set_viewport` with `scrollToElementId: "my-element"` — center view on a specific element. - `scrollToElementId: "my-element"` — center on specific element
- `set_viewport` with `zoom: 1.5, offsetX: 100, offsetY: 200` — manual camera control. - `zoom: 0.7, offsetX: 100, offsetY: 50` — manual camera for close-up review
## Anti-Patterns (Common Mistakes)
| Mistake | Why It Fails | Do This Instead |
|---------|-------------|-----------------|
| Using `create_from_mermaid` for final diagrams | Overlapping text, poor layout | Use `batch_create_elements` with coordinates |
| Shapes too small for text | Truncation, especially in diamonds | Use sizing formulas above |
| No gap between connected shapes | Arrows become invisible (0px length) | Maintain 120px+ vertical gap |
| Clearing canvas between diagrams | Loses previous work | Place diagrams side-by-side |
| Skipping screenshot verification | Invisible defects compound | Screenshot after EVERY batch |
| Shapes + arrows in one batch | Binding errors | Shapes first, arrows second |
| Default roughness (hand-drawn look) | Unprofessional for technical diagrams | Set `roughness: 0` on all elements |
| Trusting MCP screenshot alone | May return empty image | Use Chrome DevTools as fallback |
## MCP Tool Quick Reference (32 Tools)
| Category | Tools |
|----------|-------|
| Element CRUD (9) | `create_element`, `get_element`, `update_element`, `delete_element`, `query_elements`, `batch_create_elements`, `duplicate_elements`, `search_elements`, `element_history` |
| Layout (6) | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` |
| Scene (4) | `describe_scene`, `get_canvas_screenshot`, `get_resource`, `read_diagram_guide` |
| File I/O (4) | `export_scene`, `import_scene`, `export_to_image`, `export_to_excalidraw_url` |
| State (3) | `clear_canvas`, `snapshot_scene`, `restore_snapshot` |
| Viewport (1) | `set_viewport` |
| Tenants (2) | `list_tenants`, `switch_tenant` |
| Projects (2) | `list_projects`, `switch_project` |
| Conversion (1) | `create_from_mermaid` (⚠ low quality — use `batch_create_elements` instead) |
## References ## References
- `references/cheatsheet.md`: Complete MCP tool list (26 tools) + REST API endpoints + payload shapes. - `references/cheatsheet.md`: Complete MCP tool list (32 tools) + REST API endpoints + payload shapes + env vars
@@ -2,10 +2,12 @@
## Defaults ## Defaults
- Canvas base URL: `EXPRESS_SERVER_URL` (default `http://localhost:3000`) - Canvas base URL: configurable via `CANVAS_PORT` env var (default `3000`), resolves to `http://localhost:<CANVAS_PORT>`
- Canvas health: `GET /health` - Canvas health: `GET /health`
- Data persistence: SQLite database at `~/.excalidraw-mcp/excalidraw.db`
- Multi-tenancy: each Cursor workspace auto-creates a tenant (hash of workspace path)
## MCP Tools (26 total) ## MCP Tools (32 total)
### Element CRUD ### Element CRUD
@@ -15,9 +17,11 @@
| `get_element` | Get single element by ID | `id` | | `get_element` | Get single element by ID | `id` |
| `update_element` | Update element properties | `id` | | `update_element` | Update element properties | `id` |
| `delete_element` | Delete element | `id` | | `delete_element` | Delete element | `id` |
| `query_elements` | Query by type/filters | (optional) `type`, `filter` | | `query_elements` | Query by type | (optional) `type` |
| `batch_create_elements` | Create many at once | `elements[]` | | `batch_create_elements` | Create many at once (recommended) | `elements[]` |
| `duplicate_elements` | Clone with offset | `elementIds[]`, (optional) `offsetX`, `offsetY` | | `duplicate_elements` | Clone with offset | `elementIds[]`, (optional) `offsetX`, `offsetY` |
| `search_elements` | Full-text search over labels/text | `query` |
| `element_history` | View version history (create/update/delete ops) | (optional) `elementId`, `limit` (default 50) |
### Layout & Organization ### Layout & Organization
@@ -35,8 +39,9 @@
| Tool | Description | Required params | | Tool | Description | Required params |
|------|-------------|-----------------| |------|-------------|-----------------|
| `describe_scene` | AI-readable scene description (types, positions, labels, connections, bounding box) | (none) | | `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_canvas_screenshot` | Returns PNG image of canvas for visual verification (may return empty — use Chrome DevTools as fallback) | (optional) `background` |
| `get_resource` | Get scene/library/theme/elements | `resource` | | `get_resource` | Get scene/library/theme/elements | `resource` |
| `read_diagram_guide` | Get design best practices (colors, sizing, layout, anti-patterns) | (none) |
### File I/O & Export ### File I/O & Export
@@ -45,15 +50,15 @@
| `export_scene` | Export to .excalidraw JSON | (optional) `filePath` | | `export_scene` | Export to .excalidraw JSON | (optional) `filePath` |
| `import_scene` | Import from .excalidraw JSON | `mode` ("replace"\|"merge"), `filePath` or `data` | | `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_image` | Export to PNG/SVG (needs browser) | `format` ("png"\|"svg"), (optional) `filePath`, `background` |
| `export_to_excalidraw_url` | Upload & get shareable excalidraw.com URL | (none) | | `export_to_excalidraw_url` | Upload & get shareable excalidraw.com URL (may fail if org blocks excalidraw.com) | (none) |
### State Management ### State Management
| Tool | Description | Required params | | Tool | Description | Required params |
|------|-------------|-----------------| |------|-------------|-----------------|
| `clear_canvas` | Remove all elements | (none) | | `clear_canvas` | Remove all elements from active project | (none) |
| `snapshot_scene` | Save named snapshot | `name` | | `snapshot_scene` | Save named snapshot of current canvas state | `name` |
| `restore_snapshot` | Restore from snapshot | `name` | | `restore_snapshot` | Restore from snapshot (may not reload into view — re-fetch if canvas appears empty) | `name` |
### Viewport & Camera ### Viewport & Camera
@@ -61,33 +66,65 @@
|------|-------------|-----------------| |------|-------------|-----------------|
| `set_viewport` | Control camera: zoom-to-fit, center on element, manual zoom/scroll (needs browser) | (optional) `scrollToContent`, `scrollToElementId`, `zoom`, `offsetX`, `offsetY` | | `set_viewport` | Control camera: zoom-to-fit, center on element, manual zoom/scroll (needs browser) | (optional) `scrollToContent`, `scrollToElementId`, `zoom`, `offsetX`, `offsetY` |
### Design Guide ### Multi-Tenancy (Workspaces)
| Tool | Description | Required params | | Tool | Description | Required params |
|------|-------------|-----------------| |------|-------------|-----------------|
| `read_diagram_guide` | Get design best practices (colors, sizing, layout, anti-patterns) | (none) | | `list_tenants` | List all tenants (workspaces). Each tenant maps to a Cursor workspace. | (none) |
| `switch_tenant` | Switch active tenant. All later operations use that tenant's projects/elements. | `tenantId` |
### Projects (Within a Tenant)
| Tool | Description | Required params |
|------|-------------|-----------------|
| `list_projects` | List all diagram projects in the active tenant | (none) |
| `switch_project` | Switch active project or create a new one | (optional) `projectId`, `createName`, `createDescription` |
### Conversion ### Conversion
| Tool | Description | Required params | | Tool | Description | Required params |
|------|-------------|-----------------| |------|-------------|-----------------|
| `create_from_mermaid` | Mermaid diagram to Excalidraw | `mermaidDiagram` | | `create_from_mermaid` | Mermaid diagram to Excalidraw (⚠ produces low-quality output — use `batch_create_elements` for production diagrams) | `mermaidDiagram` |
Notes: ## Key Notes
- **MCP tools**: Set `text` field on shapes to label them (auto-converts to `label.text`). Use `startElementId`/`endElementId` on arrows.
- **REST API**: Use `"label": {"text": "..."}` for shape labels. Use `"start": {"id": "..."}` / `"end": {"id": "..."}` for arrow binding. (Different format from MCP!) ### MCP vs REST API Format Differences
- `fontFamily` must be a string (e.g. `"1"`) or omit it entirely — do NOT pass a number.
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects. | Concept | MCP Tool Format | REST API Format |
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points for smooth curves. Use `"elbowed": true` for right-angle routing. |---------|----------------|-----------------|
- Prefer creating shapes first, then arrows, then alignment/grouping. | Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` |
| `fontFamily` | String `"1"` or omit | String `"1"` or omit (never a number) |
| Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
### Element Creation Best Practices
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn).
- **Always set `strokeWidth: 2`** on arrows for visibility.
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
- **Assign custom `id`** to every shape so arrows can reference it.
- **Size shapes for their text** — Virgil font is ~30% wider than standard. Use sizing formulas from SKILL.md.
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects — normalized automatically.
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
### Multi-Tenancy Architecture
- **Tenant**: Maps to a Cursor workspace. Auto-created on MCP startup from workspace path hash.
- **Project**: Groups diagrams within a tenant. Default project created per tenant.
- **Elements**: Belong to the active project within the active tenant.
- **Hierarchy**: Tenant → Project → Elements
- **Concurrent instances**: Multiple Cursor windows each send `X-Tenant-Id` header for isolation. SQLite `busy_timeout` handles concurrent writes.
- **Frontend workspace switcher**: Dropdown in the canvas UI header labeled "Workspace: [name] ▾" with search filter.
## Canvas REST API (HTTP) ## Canvas REST API (HTTP)
All endpoints accept an optional `X-Tenant-Id` header to scope operations to a specific tenant.
### Elements ### Elements
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `GET` | `/api/elements` | List all elements | | `GET` | `/api/elements` | List all elements in active project |
| `GET` | `/api/elements/:id` | Get element by ID | | `GET` | `/api/elements/:id` | Get element by ID |
| `POST` | `/api/elements` | Create element | | `POST` | `/api/elements` | Create element |
| `PUT` | `/api/elements/:id` | Update element | | `PUT` | `/api/elements/:id` | Update element |
@@ -95,29 +132,37 @@ Notes:
| `DELETE` | `/api/elements/clear` | Clear all elements | | `DELETE` | `/api/elements/clear` | Clear all elements |
| `GET` | `/api/elements/search?type=...` | Search with filters | | `GET` | `/api/elements/search?type=...` | Search with filters |
| `POST` | `/api/elements/batch` | Batch create | | `POST` | `/api/elements/batch` | Batch create |
| `POST` | `/api/elements/sync` | Overwrite import (clear + write) | | `POST` | `/api/elements/sync` | Full sync (clear + write all elements) |
| `POST` | `/api/elements/from-mermaid` | Mermaid conversion via frontend | | `POST` | `/api/elements/from-mermaid` | Mermaid conversion via frontend |
### Tenants
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/tenants` | List all tenants |
| `GET` | `/api/tenant/active` | Get active tenant |
| `PUT` | `/api/tenant/active` | Switch active tenant `{"tenantId": "..."}` (broadcasts to all WebSocket clients) |
### Export ### Export
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `POST` | `/api/export/image` | Request image export (needs frontend) | | `POST` | `/api/export/image` | Request image export (needs browser) |
| `POST` | `/api/export/image/result` | Frontend posts export result back | | `POST` | `/api/export/image/result` | Frontend posts export result back |
### Viewport ### Viewport
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `POST` | `/api/viewport` | Set viewport/camera (needs frontend) | | `POST` | `/api/viewport` | Set viewport/camera (needs browser) |
| `POST` | `/api/viewport/result` | Frontend posts viewport result back | | `POST` | `/api/viewport/result` | Frontend posts viewport result back |
### Snapshots ### Snapshots
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `POST` | `/api/snapshots` | Save snapshot `{name}` | | `POST` | `/api/snapshots` | Save snapshot `{"name": "..."}` |
| `GET` | `/api/snapshots` | List snapshots | | `GET` | `/api/snapshots` | List all snapshots |
| `GET` | `/api/snapshots/:name` | Get snapshot by name | | `GET` | `/api/snapshots/:name` | Get snapshot by name |
### System ### System
@@ -125,7 +170,7 @@ Notes:
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `GET` | `/health` | Health check | | `GET` | `/health` | Health check |
| `GET` | `/api/sync/status` | Memory/WebSocket stats | | `GET` | `/api/sync/status` | Element count and WebSocket stats |
## Skill Scripts ## Skill Scripts
@@ -140,3 +185,11 @@ node scripts/create-element.cjs --data '{...}'
node scripts/update-element.cjs --id <id> --data '{...}' node scripts/update-element.cjs --id <id> --data '{...}'
node scripts/delete-element.cjs --id <id> node scripts/delete-element.cjs --id <id>
``` ```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CANVAS_PORT` | `3000` | Port the canvas server listens on |
| `EXPRESS_SERVER_URL` | `http://localhost:3000` | Full canvas URL (derived from CANVAS_PORT if not set) |
| `EXCALIDRAW_EXPORT_DIR` | `process.cwd()` | Allowed base directory for file exports (path traversal protection) |
+509
View File
@@ -0,0 +1,509 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import os from 'os';
import logger from './utils/logger.js';
import type { ServerElement, Snapshot } from './types.js';
export interface Tenant {
id: string;
name: string;
workspace_path: string;
created_at: string;
last_accessed_at: string;
}
export interface Project {
id: string;
name: string;
description: string | null;
tenant_id: string;
created_at: string;
updated_at: string;
}
export interface ElementVersion {
id: number;
element_id: string;
project_id: string;
version: number;
data: ServerElement;
operation: 'create' | 'update' | 'delete';
created_at: string;
}
const DEFAULT_PROJECT_ID = 'default';
const DEFAULT_TENANT_ID = 'default';
let db: Database.Database;
let activeTenantId: string = DEFAULT_TENANT_ID;
let activeProjectId: string = DEFAULT_PROJECT_ID;
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
export function initDb(dbPath?: string): void {
const resolvedPath = dbPath
|| process.env.EXCALIDRAW_DB_PATH
|| path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db');
const dir = path.dirname(resolvedPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(resolvedPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.pragma('foreign_keys = ON');
runMigrations();
// Ensure default tenant exists
const defaultTenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(DEFAULT_TENANT_ID);
if (!defaultTenant) {
const now = new Date().toISOString();
db.prepare('INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)').run(
DEFAULT_TENANT_ID, 'Default', '(none)', now, now
);
}
// Ensure default project exists and is linked to default tenant
const defaultProject = db.prepare('SELECT id FROM projects WHERE id = ?').get(DEFAULT_PROJECT_ID);
if (!defaultProject) {
db.prepare('INSERT INTO projects (id, name, description, tenant_id) VALUES (?, ?, ?, ?)').run(
DEFAULT_PROJECT_ID, 'Default', 'Default project', DEFAULT_TENANT_ID
);
}
logger.info(`SQLite database initialized at ${resolvedPath}`);
}
function runMigrations(): void {
db.exec(`
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
workspace_path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL,
data TEXT NOT NULL,
label_text TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS element_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
element_id TEXT NOT NULL,
project_id TEXT NOT NULL,
version INTEGER NOT NULL,
data TEXT NOT NULL,
operation TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
elements TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, name)
);
CREATE INDEX IF NOT EXISTS idx_elements_project ON elements(project_id);
CREATE INDEX IF NOT EXISTS idx_elements_type ON elements(project_id, type);
CREATE INDEX IF NOT EXISTS idx_elements_deleted ON elements(project_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_versions_element ON element_versions(element_id);
CREATE INDEX IF NOT EXISTS idx_versions_project ON element_versions(project_id, created_at);
`);
// FTS table
const ftsExists = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='elements_fts'"
).get();
if (!ftsExists) {
db.exec(`
CREATE VIRTUAL TABLE elements_fts USING fts5(
element_id,
label_text,
type
);
`);
}
// Migration: add tenant_id to projects if it doesn't exist (upgrading from older schema)
const cols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
const hasTenantCol = cols.some(c => c.name === 'tenant_id');
if (!hasTenantCol) {
db.exec(`ALTER TABLE projects ADD COLUMN tenant_id TEXT REFERENCES tenants(id)`);
logger.info('Migrated: added tenant_id column to projects');
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id)`);
// Migration: assign orphan projects (no tenant_id) to default tenant
const orphans = db.prepare('SELECT id FROM projects WHERE tenant_id IS NULL').all() as { id: string }[];
if (orphans.length > 0) {
// Ensure default tenant exists for migration
const defTenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(DEFAULT_TENANT_ID);
if (!defTenant) {
const now = new Date().toISOString();
db.prepare('INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)').run(
DEFAULT_TENANT_ID, 'Default', '(none)', now, now
);
}
db.prepare('UPDATE projects SET tenant_id = ? WHERE tenant_id IS NULL').run(DEFAULT_TENANT_ID);
logger.info(`Migrated: assigned ${orphans.length} orphan projects to default tenant`);
}
}
function extractLabelText(element: ServerElement): string | null {
if (element.label?.text) return element.label.text;
if (element.text) return element.text;
return null;
}
// Resolve effective project ID: explicit override > in-memory active
function pid(override?: string): string {
return override ?? activeProjectId;
}
// Given a tenant ID, return its default project (creating one if needed)
export function getDefaultProjectForTenant(tenantId: string): string {
const row = db.prepare(
'SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at ASC LIMIT 1'
).get(tenantId) as { id: string } | undefined;
if (row) return row.id;
const id = `${tenantId}-default`;
const now = new Date().toISOString();
db.prepare(
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, 'Default', 'Default project', tenantId, now, now);
return id;
}
// ── Element CRUD ──
export function getElement(id: string, projectId?: string): ServerElement | undefined {
const row = db.prepare(
'SELECT data FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
).get(id, pid(projectId)) as { data: string } | undefined;
return row ? JSON.parse(row.data) : undefined;
}
export function hasElement(id: string, projectId?: string): boolean {
const row = db.prepare(
'SELECT 1 FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
).get(id, pid(projectId));
return !!row;
}
export function setElement(id: string, element: ServerElement, projectId?: string): void {
const p = pid(projectId);
const now = new Date().toISOString();
const data = JSON.stringify(element);
const labelText = extractLabelText(element);
const existing = db.prepare(
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
).get(id, p) as { version: number; is_deleted: number } | undefined;
if (existing) {
const newVersion = existing.is_deleted ? 1 : (existing.version + 1);
db.prepare(`
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0
WHERE id = ? AND project_id = ?
`).run(element.type, data, labelText, now, newVersion, id, p);
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
updateFts(id, labelText, element.type);
} else {
db.prepare(`
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`).run(id, p, element.type, data, labelText, now, now);
recordVersion(id, 1, data, 'create', p);
insertFts(id, labelText, element.type);
}
}
export function deleteElement(id: string, projectId?: string): boolean {
const p = pid(projectId);
const existing = db.prepare(
'SELECT version, data FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
).get(id, p) as { version: number; data: string } | undefined;
if (!existing) return false;
const newVersion = existing.version + 1;
db.prepare(`
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?
WHERE id = ? AND project_id = ?
`).run(newVersion, new Date().toISOString(), id, p);
recordVersion(id, newVersion, existing.data, 'delete', p);
deleteFts(id);
return true;
}
export function getAllElements(projectId?: string): ServerElement[] {
const rows = db.prepare(
'SELECT data FROM elements WHERE project_id = ? AND is_deleted = 0'
).all(pid(projectId)) as { data: string }[];
return rows.map(r => JSON.parse(r.data));
}
export function getElementCount(projectId?: string): number {
const row = db.prepare(
'SELECT COUNT(*) as count FROM elements WHERE project_id = ? AND is_deleted = 0'
).get(pid(projectId)) as { count: number };
return row.count;
}
export function clearElements(projectId?: string): number {
const p = pid(projectId);
const now = new Date().toISOString();
const elements = getAllElements(p);
const stmt = db.prepare(`
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?
WHERE project_id = ? AND is_deleted = 0
`);
const clearTx = db.transaction(() => {
const info = stmt.run(now, p);
for (const el of elements) {
recordVersion(el.id, (el.version || 1) + 1, JSON.stringify(el), 'delete', p);
deleteFts(el.id);
}
return info.changes;
});
return clearTx() as number;
}
export function queryElements(type?: string, filter?: Record<string, any>, projectId?: string): ServerElement[] {
let elements = getAllElements(projectId);
if (type) {
elements = elements.filter(el => el.type === type);
}
if (filter) {
elements = elements.filter(el => {
return Object.entries(filter).every(([key, value]) => {
return (el as any)[key] === value;
});
});
}
return elements;
}
export function searchElements(query: string, projectId?: string): ServerElement[] {
const rows = db.prepare(`
SELECT e.data FROM elements e
INNER JOIN elements_fts fts ON fts.element_id = e.id
WHERE elements_fts MATCH ? AND e.project_id = ? AND e.is_deleted = 0
`).all(query, pid(projectId)) as { data: string }[];
return rows.map(r => JSON.parse(r.data));
}
// ── FTS helpers ──
function insertFts(elementId: string, labelText: string | null, type: string): void {
db.prepare('INSERT INTO elements_fts (element_id, label_text, type) VALUES (?, ?, ?)').run(
elementId, labelText || '', type
);
}
function updateFts(elementId: string, labelText: string | null, type: string): void {
deleteFts(elementId);
insertFts(elementId, labelText, type);
}
function deleteFts(elementId: string): void {
db.prepare("DELETE FROM elements_fts WHERE element_id = ?").run(elementId);
}
// ── Version history ──
function recordVersion(elementId: string, version: number, data: string, operation: string, projectId?: string): void {
db.prepare(`
INSERT INTO element_versions (element_id, project_id, version, data, operation)
VALUES (?, ?, ?, ?, ?)
`).run(elementId, pid(projectId), version, data, operation);
}
export function getElementHistory(elementId: string, limit: number = 50, projectId?: string): ElementVersion[] {
const rows = db.prepare(`
SELECT id, element_id, project_id, version, data, operation, created_at
FROM element_versions WHERE element_id = ? AND project_id = ?
ORDER BY created_at DESC LIMIT ?
`).all(elementId, pid(projectId), limit) as any[];
return rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
}
export function getProjectHistory(limit: number = 100, projectId?: string): ElementVersion[] {
const rows = db.prepare(`
SELECT id, element_id, project_id, version, data, operation, created_at
FROM element_versions WHERE project_id = ?
ORDER BY created_at DESC LIMIT ?
`).all(pid(projectId), limit) as any[];
return rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
}
// ── Snapshots ──
export function saveSnapshot(name: string, elements: ServerElement[], projectId?: string): void {
const data = JSON.stringify(elements);
db.prepare(`
INSERT OR REPLACE INTO snapshots (project_id, name, elements, created_at)
VALUES (?, ?, ?, datetime('now'))
`).run(pid(projectId), name, data);
}
export function getSnapshot(name: string, projectId?: string): Snapshot | undefined {
const row = db.prepare(
'SELECT name, elements, created_at FROM snapshots WHERE name = ? AND project_id = ?'
).get(name, pid(projectId)) as { name: string; elements: string; created_at: string } | undefined;
if (!row) return undefined;
return { name: row.name, elements: JSON.parse(row.elements), createdAt: row.created_at };
}
export function listSnapshots(projectId?: string): { name: string; elementCount: number; createdAt: string }[] {
const rows = db.prepare(
'SELECT name, elements, created_at FROM snapshots WHERE project_id = ? ORDER BY created_at DESC'
).all(pid(projectId)) as { name: string; elements: string; created_at: string }[];
return rows.map(r => ({
name: r.name,
elementCount: (JSON.parse(r.elements) as any[]).length,
createdAt: r.created_at
}));
}
// ── Tenants ──
export function ensureTenant(id: string, name: string, workspacePath: string): Tenant {
const now = new Date().toISOString();
const existing = db.prepare('SELECT * FROM tenants WHERE id = ?').get(id) as Tenant | undefined;
if (existing) {
db.prepare('UPDATE tenants SET last_accessed_at = ? WHERE id = ?').run(now, id);
return { ...existing, last_accessed_at: now };
}
db.prepare(
'INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)'
).run(id, name, workspacePath, now, now);
return { id, name, workspace_path: workspacePath, created_at: now, last_accessed_at: now };
}
export function setActiveTenant(id: string): void {
const tenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(id);
if (!tenant) throw new Error(`Tenant "${id}" not found`);
activeTenantId = id;
// Auto-set active project to the tenant's first project, creating a default if none exists
const firstProject = db.prepare(
'SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at ASC LIMIT 1'
).get(id) as { id: string } | undefined;
if (firstProject) {
activeProjectId = firstProject.id;
} else {
const defaultId = `${id}-default`;
const now = new Date().toISOString();
db.prepare(
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(defaultId, 'Default', 'Default project', id, now, now);
activeProjectId = defaultId;
}
logger.info(`Active tenant set to "${id}", active project: "${activeProjectId}"`);
}
export function getActiveTenant(): Tenant {
return db.prepare('SELECT * FROM tenants WHERE id = ?').get(activeTenantId) as Tenant;
}
export function getActiveTenantId(): string {
return activeTenantId;
}
export function listTenants(): Tenant[] {
return db.prepare('SELECT * FROM tenants ORDER BY last_accessed_at DESC').all() as Tenant[];
}
// ── Projects ──
export function createProject(name: string, description?: string): Project {
const id = generateId();
const now = new Date().toISOString();
db.prepare(
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, description || null, activeTenantId, now, now);
return { id, name, description: description || null, tenant_id: activeTenantId, created_at: now, updated_at: now };
}
export function listProjects(): Project[] {
return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[];
}
export function setActiveProject(id: string): void {
const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined;
if (!project) throw new Error(`Project "${id}" not found`);
if (project.tenant_id !== activeTenantId) {
throw new Error(`Project "${id}" belongs to tenant "${project.tenant_id}", not the active tenant "${activeTenantId}"`);
}
activeProjectId = id;
}
export function getActiveProject(): Project {
return db.prepare('SELECT * FROM projects WHERE id = ?').get(activeProjectId) as Project;
}
export function getActiveProjectId(): string {
return activeProjectId;
}
// ── Bulk operations (for sync endpoint) ──
export function bulkReplaceElements(elements: ServerElement[], projectId?: string): number {
const tx = db.transaction(() => {
clearElements(projectId);
for (const el of elements) {
setElement(el.id, el, projectId);
}
return elements.length;
});
return tx();
}
export function closeDb(): void {
if (db) {
db.close();
logger.info('SQLite database closed');
}
}
+329 -29
View File
@@ -6,7 +6,7 @@ process.env.NO_COLOR = '1';
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { deflateSync } from 'zlib'; import { deflateSync } from 'zlib';
import { webcrypto } from 'crypto'; import { webcrypto, createHash } from 'crypto';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { import {
@@ -28,6 +28,17 @@ import {
validateElement validateElement
} from './types.js'; } from './types.js';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
import {
initDb, closeDb,
searchElements as dbSearchElements,
listProjects as dbListProjects, createProject as dbCreateProject,
setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject,
getElementHistory as dbGetElementHistory, getProjectHistory as dbGetProjectHistory,
ensureTenant as dbEnsureTenant, setActiveTenant as dbSetActiveTenant,
getActiveTenant as dbGetActiveTenant, getActiveTenantId as dbGetActiveTenantId,
listTenants as dbListTenants
} from './db.js';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
@@ -47,9 +58,10 @@ function sanitizeFilePath(filePath: string): string {
return resolved; return resolved;
} }
// Express server configuration // Express server configuration — derive URL from CANVAS_PORT
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000'; const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
const ENABLE_CANVAS_SYNC = true;
// API Response types // API Response types
interface ApiResponse { interface ApiResponse {
@@ -66,6 +78,14 @@ interface SyncResponse {
elements?: ServerElement[]; elements?: ServerElement[];
} }
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
return {
'Content-Type': 'application/json',
'X-Tenant-Id': dbGetActiveTenantId(),
...extra
};
}
// Helper functions to sync with Express server (canvas) // Helper functions to sync with Express server (canvas)
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> { async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
if (!ENABLE_CANVAS_SYNC) { if (!ENABLE_CANVAS_SYNC) {
@@ -82,7 +102,7 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
url = `${EXPRESS_SERVER_URL}/api/elements`; url = `${EXPRESS_SERVER_URL}/api/elements`;
options = { options = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
}; };
break; break;
@@ -91,21 +111,21 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`; url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = { options = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
}; };
break; break;
case 'delete': case 'delete':
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`; url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = { method: 'DELETE' }; options = { method: 'DELETE', headers: canvasHeaders() };
break; break;
case 'batch_create': case 'batch_create':
url = `${EXPRESS_SERVER_URL}/api/elements/batch`; url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
options = { options = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify({ elements: data }) body: JSON.stringify({ elements: data })
}; };
break; break;
@@ -168,7 +188,9 @@ async function getElementFromCanvas(elementId: string): Promise<ServerElement |
} }
try { try {
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`); const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`, {
headers: canvasHeaders()
});
if (!response.ok) { if (!response.ok) {
logger.warn(`Failed to fetch element ${elementId}: ${response.status}`); logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
return null; return null;
@@ -819,6 +841,88 @@ const tools: Tool[] = [
} }
} }
} }
},
{
name: 'search_elements',
description: 'Full-text search across element labels and text content. Returns elements matching the query.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query for FTS (matches against element labels and text)'
}
},
required: ['query']
}
},
{
name: 'list_projects',
description: 'List all diagram projects. Projects organize diagrams into separate workspaces.',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'switch_project',
description: 'Switch the active project or create a new one. All element operations apply to the active project.',
inputSchema: {
type: 'object',
properties: {
projectId: {
type: 'string',
description: 'ID of existing project to switch to'
},
createName: {
type: 'string',
description: 'Name for a new project (creates and switches to it)'
},
createDescription: {
type: 'string',
description: 'Optional description for the new project'
}
}
}
},
{
name: 'element_history',
description: 'View the version history of a specific element or the entire active project. Shows create, update, and delete operations.',
inputSchema: {
type: 'object',
properties: {
elementId: {
type: 'string',
description: 'Element ID to view history for (omit for project-wide history)'
},
limit: {
type: 'number',
description: 'Maximum number of history entries to return (default: 50)'
}
}
}
},
{
name: 'list_tenants',
description: 'List all tenants (workspaces). Each tenant corresponds to a Cursor workspace and has isolated diagrams.',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'switch_tenant',
description: 'Switch the active tenant (workspace). All subsequent operations will use the selected tenant\'s projects and elements.',
inputSchema: {
type: 'object',
properties: {
tenantId: {
type: 'string',
description: 'ID of the tenant to switch to'
}
},
required: ['tenantId']
}
} }
]; ];
@@ -983,9 +1087,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
}); });
} }
// Query elements from HTTP server
const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`; const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
const response = await fetch(url); const response = await fetch(url, { headers: canvasHeaders() });
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`); throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
@@ -1019,8 +1122,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
case 'library': case 'library':
case 'elements': case 'elements':
try { try {
// Get elements from HTTP server const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); headers: canvasHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`); throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
} }
@@ -1327,7 +1431,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
// The frontend will use mermaid-to-excalidraw to convert it // The frontend will use mermaid-to-excalidraw to convert it
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, { const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify({ body: JSON.stringify({
mermaidDiagram: params.mermaidDiagram, mermaidDiagram: params.mermaidDiagram,
config: params.config config: params.config
@@ -1429,7 +1533,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
logger.info('Clearing canvas via MCP'); logger.info('Clearing canvas via MCP');
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
method: 'DELETE' method: 'DELETE',
headers: canvasHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -1453,7 +1558,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
logger.info('Exporting scene via MCP'); logger.info('Exporting scene via MCP');
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`);
} }
@@ -1522,9 +1629,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
throw new Error('No elements found in the import data'); throw new Error('No elements found in the import data');
} }
// If replace mode, clear first
if (params.mode === 'replace') { if (params.mode === 'replace') {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' }); await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
} }
// Batch create the imported elements // Batch create the imported elements
@@ -1557,7 +1663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, { const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify({ body: JSON.stringify({
format: params.format, format: params.format,
background: params.background ?? true background: params.background ?? true
@@ -1649,7 +1755,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, { const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify({ name: params.name }) body: JSON.stringify({ name: params.name })
}); });
@@ -1671,16 +1777,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const params = z.object({ name: z.string() }).parse(args); const params = z.object({ name: z.string() }).parse(args);
logger.info('Restoring snapshot via MCP', { name: params.name }); logger.info('Restoring snapshot via MCP', { name: params.name });
// Fetch the snapshot const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`, {
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`); headers: canvasHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Snapshot "${params.name}" not found`); throw new Error(`Snapshot "${params.name}" not found`);
} }
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } }; 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', headers: canvasHeaders() });
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' });
// Restore elements // Restore elements
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements); const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
@@ -1696,7 +1802,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
case 'describe_scene': { case 'describe_scene': {
logger.info('Describing scene via MCP'); logger.info('Describing scene via MCP');
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch elements: ${response.status}`); throw new Error(`Failed to fetch elements: ${response.status}`);
} }
@@ -1813,7 +1921,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, { const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify({ body: JSON.stringify({
format: 'png', format: 'png',
background: params.background ?? true background: params.background ?? true
@@ -1851,8 +1959,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
case 'export_to_excalidraw_url': { case 'export_to_excalidraw_url': {
logger.info('Exporting to excalidraw.com URL'); logger.info('Exporting to excalidraw.com URL');
// 1. Fetch current scene elements const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`); headers: canvasHeaders()
});
if (!urlExportResponse.ok) { if (!urlExportResponse.ok) {
throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`); throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`);
} }
@@ -2151,7 +2260,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const viewportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/viewport`, { const viewportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/viewport`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: canvasHeaders(),
body: JSON.stringify(viewportParams) body: JSON.stringify(viewportParams)
}); });
@@ -2170,6 +2279,135 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
}; };
} }
case 'search_elements': {
const params = z.object({ query: z.string() }).parse(args);
logger.info('Searching elements via MCP', { query: params.query });
const results = dbSearchElements(params.query);
return {
content: [{
type: 'text',
text: results.length > 0
? `Found ${results.length} matching elements:\n\n${JSON.stringify(results, null, 2)}`
: `No elements found matching "${params.query}"`
}]
};
}
case 'list_projects': {
logger.info('Listing projects via MCP');
const projects = dbListProjects();
const active = dbGetActiveProject();
return {
content: [{
type: 'text',
text: `Active project: ${active.name} (${active.id})\n\nAll projects:\n${JSON.stringify(projects, null, 2)}`
}]
};
}
case 'switch_project': {
const params = z.object({
projectId: z.string().optional(),
createName: z.string().optional(),
createDescription: z.string().optional()
}).parse(args || {});
if (params.createName) {
const newProject = dbCreateProject(params.createName, params.createDescription);
dbSetActiveProject(newProject.id);
logger.info('Created and switched to new project', { project: newProject });
return {
content: [{
type: 'text',
text: `Created new project "${newProject.name}" and switched to it.\n\n${JSON.stringify(newProject, null, 2)}`
}]
};
}
if (params.projectId) {
dbSetActiveProject(params.projectId);
const active = dbGetActiveProject();
logger.info('Switched project', { project: active });
return {
content: [{
type: 'text',
text: `Switched to project "${active.name}" (${active.id})`
}]
};
}
throw new Error('Provide either projectId to switch to or createName to create a new project');
}
case 'element_history': {
const params = z.object({
elementId: z.string().optional(),
limit: z.number().optional()
}).parse(args || {});
const limit = params.limit ?? 50;
if (params.elementId) {
const history = dbGetElementHistory(params.elementId, limit);
return {
content: [{
type: 'text',
text: history.length > 0
? `Version history for element ${params.elementId} (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
: `No history found for element ${params.elementId}`
}]
};
}
const history = dbGetProjectHistory(limit);
const active = dbGetActiveProject();
return {
content: [{
type: 'text',
text: history.length > 0
? `Project history for "${active.name}" (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
: `No history in project "${active.name}"`
}]
};
}
case 'list_tenants': {
logger.info('Listing tenants via MCP');
const tenants = dbListTenants();
const activeTenant = dbGetActiveTenant();
return {
content: [{
type: 'text',
text: `Active tenant: ${activeTenant.name} (${activeTenant.id})\nWorkspace: ${activeTenant.workspace_path}\n\nAll tenants:\n${JSON.stringify(tenants, null, 2)}`
}]
};
}
case 'switch_tenant': {
const params = z.object({ tenantId: z.string() }).parse(args);
logger.info('Switching tenant via MCP', { tenantId: params.tenantId });
dbSetActiveTenant(params.tenantId);
const tenant = dbGetActiveTenant();
const activeProject = dbGetActiveProject();
try {
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ tenantId: params.tenantId })
});
} catch {}
return {
content: [{
type: 'text',
text: `Switched to tenant "${tenant.name}" (${tenant.id})\nWorkspace: ${tenant.workspace_path}\nActive project: ${activeProject.name} (${activeProject.id})`
}]
};
}
default: default:
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }
@@ -2193,12 +2431,74 @@ async function runServer(): Promise<void> {
try { try {
logger.info('Starting Excalidraw MCP server...'); logger.info('Starting Excalidraw MCP server...');
// Initialize SQLite before anything else
initDb();
// Bootstrap tenant from process.cwd() (may be home dir for global MCPs)
let workspacePath = process.cwd();
function applyTenant(wp: string) {
const tid = createHash('sha256').update(wp).digest('hex').slice(0, 12);
const tname = path.basename(wp);
dbEnsureTenant(tid, tname, wp);
dbSetActiveTenant(tid);
logger.info(`Tenant initialized: "${tname}" (${tid}) from ${wp}`);
return { tenantId: tid, tenantName: tname };
}
applyTenant(workspacePath);
try {
await startCanvasServer();
logger.info('Canvas server started — lifecycle managed by MCP process');
} catch (canvasError) {
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
logger.warn('MCP tools will work without real-time canvas sync');
}
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
logger.debug('Connecting to stdio transport...'); logger.debug('Connecting to stdio transport...');
await server.connect(transport); await server.connect(transport);
logger.info('Excalidraw MCP server running on stdio'); logger.info('Excalidraw MCP server running on stdio');
// After connecting, ask the client for the real workspace roots.
// Global MCPs often get cwd=HOME; roots gives us the actual workspace.
try {
const { roots } = await server.listRoots(undefined, { timeout: 5_000 });
if (roots && roots.length > 0) {
const rootUri = roots[0]!.uri;
const rootPath = rootUri.startsWith('file://') ? decodeURIComponent(rootUri.slice(7)) : rootUri;
if (rootPath && rootPath !== workspacePath) {
logger.info(`Client reported workspace root: ${rootPath} (was ${workspacePath})`);
workspacePath = rootPath;
const { tenantId: newTid } = applyTenant(workspacePath);
try {
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ tenantId: newTid })
});
} catch {}
}
}
} catch (rootsErr) {
logger.debug('Could not retrieve roots from client (not supported or timed out):', (rootsErr as Error).message);
}
async function shutdown() {
logger.info('MCP transport closed — shutting down');
try { await stopCanvasServer(); } catch {}
try { closeDb(); } catch {}
process.exit(0);
}
server.onclose = shutdown;
process.stdin.on('close', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.stdin.resume(); process.stdin.resume();
} catch (error) { } catch (error) {
logger.error('Error starting server:', error); logger.error('Error starting server:', error);
+155 -86
View File
@@ -1,4 +1,4 @@
import express, { Request, Response, NextFunction } from 'express'; import express, { type Application, Request, Response, NextFunction } from 'express';
import cors from 'cors'; import cors from 'cors';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { createServer } from 'http'; import { createServer } from 'http';
@@ -7,8 +7,6 @@ import { fileURLToPath } from 'url';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import logger from './utils/logger.js'; import logger from './utils/logger.js';
import { import {
elements,
snapshots,
generateId, generateId,
EXCALIDRAW_ELEMENT_TYPES, EXCALIDRAW_ELEMENT_TYPES,
ServerElement, ServerElement,
@@ -22,6 +20,8 @@ import {
InitialElementsMessage, InitialElementsMessage,
Snapshot Snapshot
} from './types.js'; } from './types.js';
import * as store from './db.js';
import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { z } from 'zod'; import { z } from 'zod';
import WebSocket from 'ws'; import WebSocket from 'ws';
@@ -31,9 +31,9 @@ dotenv.config();
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const app = express(); const app: Application = express();
const server = createServer(app); const httpServer = createServer(app);
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server: httpServer });
// Middleware // Middleware
app.use(cors()); app.use(cors());
@@ -45,6 +45,14 @@ app.use(express.static(staticDir));
// Also serve frontend assets // Also serve frontend assets
app.use(express.static(path.join(__dirname, '../dist/frontend'))); app.use(express.static(path.join(__dirname, '../dist/frontend')));
// Resolve tenant from X-Tenant-Id header to a projectId override.
// Returns undefined when header is absent (browser requests), falling back to global state.
function resolveTenantProject(req: Request): string | undefined {
const tenantId = req.headers['x-tenant-id'] as string | undefined;
if (!tenantId) return undefined;
return getDefaultProjectForTenant(tenantId);
}
// WebSocket connections // WebSocket connections
const clients = new Set<WebSocket>(); const clients = new Set<WebSocket>();
@@ -63,17 +71,26 @@ wss.on('connection', (ws: WebSocket) => {
clients.add(ws); clients.add(ws);
logger.info('New WebSocket connection established'); logger.info('New WebSocket connection established');
// Send current tenant info
try {
const tenant = dbGetActiveTenant();
ws.send(JSON.stringify({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
}));
} catch {}
// Send current elements to new client // Send current elements to new client
const initialMessage: InitialElementsMessage = { const initialMessage: InitialElementsMessage = {
type: 'initial_elements', type: 'initial_elements',
elements: Array.from(elements.values()) elements: store.getAllElements()
}; };
ws.send(JSON.stringify(initialMessage)); ws.send(JSON.stringify(initialMessage));
// Send sync status to new client // Send sync status to new client
const syncMessage: SyncStatusMessage = { const syncMessage: SyncStatusMessage = {
type: 'sync_status', type: 'sync_status',
elementCount: elements.size, elementCount: store.getElementCount(),
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
ws.send(JSON.stringify(syncMessage)); ws.send(JSON.stringify(syncMessage));
@@ -161,11 +178,12 @@ const UpdateElementSchema = z.object({
// Get all elements // Get all elements
app.get('/api/elements', (req: Request, res: Response) => { app.get('/api/elements', (req: Request, res: Response) => {
try { try {
const elementsArray = Array.from(elements.values()); const projId = resolveTenantProject(req);
const allElements = store.getAllElements(projId);
res.json({ res.json({
success: true, success: true,
elements: elementsArray, elements: allElements,
count: elementsArray.length count: allElements.length
}); });
} catch (error) { } catch (error) {
logger.error('Error fetching elements:', error); logger.error('Error fetching elements:', error);
@@ -179,10 +197,10 @@ app.get('/api/elements', (req: Request, res: Response) => {
// Create new element // Create new element
app.post('/api/elements', (req: Request, res: Response) => { app.post('/api/elements', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const params = CreateElementSchema.parse(req.body); const params = CreateElementSchema.parse(req.body);
logger.info('Creating element via API', { type: params.type }); logger.info('Creating element via API', { type: params.type });
// Prioritize passed ID (for MCP sync), otherwise generate new ID
const id = params.id || generateId(); const id = params.id || generateId();
const element: ServerElement = { const element: ServerElement = {
id, id,
@@ -192,9 +210,8 @@ app.post('/api/elements', (req: Request, res: Response) => {
version: 1 version: 1
}; };
elements.set(id, element); store.setElement(id, element, projId);
// Broadcast to all connected clients
const message: ElementCreatedMessage = { const message: ElementCreatedMessage = {
type: 'element_created', type: 'element_created',
element: element element: element
@@ -217,6 +234,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
// Update element // Update element
app.put('/api/elements/:id', (req: Request, res: Response) => { app.put('/api/elements/:id', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { id } = req.params; const { id } = req.params;
const updates = UpdateElementSchema.parse({ id, ...req.body }); const updates = UpdateElementSchema.parse({ id, ...req.body });
@@ -227,7 +245,7 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
}); });
} }
const existingElement = elements.get(id); const existingElement = store.getElement(id, projId);
if (!existingElement) { if (!existingElement) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@@ -242,9 +260,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
version: (existingElement.version || 0) + 1 version: (existingElement.version || 0) + 1
}; };
elements.set(id, updatedElement); store.setElement(id, updatedElement, projId);
// Broadcast to all connected clients
const message: ElementUpdatedMessage = { const message: ElementUpdatedMessage = {
type: 'element_updated', type: 'element_updated',
element: updatedElement element: updatedElement
@@ -267,8 +284,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
// Clear all elements (must be before /:id route) // Clear all elements (must be before /:id route)
app.delete('/api/elements/clear', (req: Request, res: Response) => { app.delete('/api/elements/clear', (req: Request, res: Response) => {
try { try {
const count = elements.size; const projId = resolveTenantProject(req);
elements.clear(); const count = store.clearElements(projId);
broadcast({ broadcast({
type: 'canvas_cleared', type: 'canvas_cleared',
@@ -294,6 +311,7 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
// Delete element // Delete element
app.delete('/api/elements/:id', (req: Request, res: Response) => { app.delete('/api/elements/:id', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { id } = req.params; const { id } = req.params;
if (!id) { if (!id) {
@@ -303,14 +321,14 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
}); });
} }
if (!elements.has(id)) { if (!store.hasElement(id, projId)) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: `Element with ID ${id} not found` error: `Element with ID ${id} not found`
}); });
} }
elements.delete(id); store.deleteElement(id, projId);
// Broadcast to all connected clients // Broadcast to all connected clients
const message: ElementDeletedMessage = { const message: ElementDeletedMessage = {
@@ -335,22 +353,19 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
// Query elements with filters // Query elements with filters
app.get('/api/elements/search', (req: Request, res: Response) => { app.get('/api/elements/search', (req: Request, res: Response) => {
try { try {
const { type, ...filters } = req.query; const projId = resolveTenantProject(req);
let results = Array.from(elements.values()); const { type, q, ...filters } = req.query;
// Filter by type if specified if (q && typeof q === 'string') {
if (type && typeof type === 'string') { const results = store.searchElements(q, projId);
results = results.filter(element => element.type === type); return res.json({ success: true, elements: results, count: results.length });
} }
// Apply additional filters const results = store.queryElements(
if (Object.keys(filters).length > 0) { type && typeof type === 'string' ? type : undefined,
results = results.filter(element => { Object.keys(filters).length > 0 ? filters as Record<string, any> : undefined,
return Object.entries(filters).every(([key, value]) => { projId
return (element as any)[key] === value; );
});
});
}
res.json({ res.json({
success: true, success: true,
@@ -369,6 +384,7 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
// Get element by ID // Get element by ID
app.get('/api/elements/:id', (req: Request, res: Response) => { app.get('/api/elements/:id', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { id } = req.params; const { id } = req.params;
if (!id) { if (!id) {
@@ -378,7 +394,7 @@ app.get('/api/elements/:id', (req: Request, res: Response) => {
}); });
} }
const element = elements.get(id); const element = store.getElement(id, projId);
if (!element) { if (!element) {
return res.status(404).json({ return res.status(404).json({
@@ -453,14 +469,14 @@ function computeEdgePoint(
} }
// Helper: resolve arrow bindings in a batch // Helper: resolve arrow bindings in a batch
function resolveArrowBindings(batchElements: ServerElement[]): void { function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
const elementMap = new Map<string, ServerElement>(); const elementMap = new Map<string, ServerElement>();
batchElements.forEach(el => elementMap.set(el.id, el)); batchElements.forEach(el => elementMap.set(el.id, el));
// Also check existing elements for cross-batch references // Also check existing elements for cross-batch references
elements.forEach((el, id) => { for (const el of store.getAllElements(projectId)) {
if (!elementMap.has(id)) elementMap.set(id, el); if (!elementMap.has(el.id)) elementMap.set(el.id, el);
}); }
for (const el of batchElements) { for (const el of batchElements) {
if (el.type !== 'arrow' && el.type !== 'line') continue; if (el.type !== 'arrow' && el.type !== 'line') continue;
@@ -535,6 +551,7 @@ function resolveArrowBindings(batchElements: ServerElement[]): void {
// Batch create elements // Batch create elements
app.post('/api/elements/batch', (req: Request, res: Response) => { app.post('/api/elements/batch', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { elements: elementsToCreate } = req.body; const { elements: elementsToCreate } = req.body;
if (!Array.isArray(elementsToCreate)) { if (!Array.isArray(elementsToCreate)) {
@@ -548,7 +565,6 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
elementsToCreate.forEach(elementData => { elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData); const params = CreateElementSchema.parse(elementData);
// Prioritize passed ID (for MCP sync), otherwise generate new ID
const id = params.id || generateId(); const id = params.id || generateId();
const element: ServerElement = { const element: ServerElement = {
id, id,
@@ -561,11 +577,9 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
createdElements.push(element); createdElements.push(element);
}); });
// Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements) resolveArrowBindings(createdElements, projId);
resolveArrowBindings(createdElements);
// Store all elements after binding resolution createdElements.forEach(el => store.setElement(el.id, el, projId));
createdElements.forEach(el => elements.set(el.id, el));
// Broadcast to all connected clients // Broadcast to all connected clients
const message: BatchCreatedMessage = { const message: BatchCreatedMessage = {
@@ -632,6 +646,7 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
// Sync elements from frontend (overwrite sync) // Sync elements from frontend (overwrite sync)
app.post('/api/elements/sync', (req: Request, res: Response) => { app.post('/api/elements/sync', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { elements: frontendElements, timestamp } = req.body; const { elements: frontendElements, timestamp } = req.body;
logger.info(`Sync request received: ${frontendElements.length} elements`, { logger.info(`Sync request received: ${frontendElements.length} elements`, {
@@ -639,7 +654,6 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
elementCount: frontendElements.length elementCount: frontendElements.length
}); });
// Validate input data
if (!Array.isArray(frontendElements)) { if (!Array.isArray(frontendElements)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -647,23 +661,15 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
}); });
} }
// Record element count before sync const beforeCount = store.getElementCount(projId);
const beforeCount = elements.size;
// 1. Clear existing memory storage // Process elements with server metadata
elements.clear();
logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
// 2. Batch write new data
let successCount = 0;
const processedElements: ServerElement[] = []; const processedElements: ServerElement[] = [];
let successCount = 0;
frontendElements.forEach((element: any, index: number) => { frontendElements.forEach((element: any, index: number) => {
try { try {
// Ensure element has ID, generate one if missing
const elementId = element.id || generateId(); const elementId = element.id || generateId();
// Add server metadata
const processedElement: ServerElement = { const processedElement: ServerElement = {
...element, ...element,
id: elementId, id: elementId,
@@ -672,20 +678,16 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
syncTimestamp: timestamp, syncTimestamp: timestamp,
version: 1 version: 1
}; };
// Store to memory
elements.set(elementId, processedElement);
processedElements.push(processedElement); processedElements.push(processedElement);
successCount++; successCount++;
} catch (elementError) { } catch (elementError) {
logger.warn(`Failed to process element ${index}:`, elementError); logger.warn(`Failed to process element ${index}:`, elementError);
} }
}); });
store.bulkReplaceElements(processedElements, projId);
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`); logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
// 3. Broadcast sync event to all WebSocket clients
broadcast({ broadcast({
type: 'elements_synced', type: 'elements_synced',
count: successCount, count: successCount,
@@ -693,14 +695,13 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
source: 'manual_sync' source: 'manual_sync'
}); });
// 4. Return sync results
res.json({ res.json({
success: true, success: true,
message: `Successfully synced ${successCount} elements`, message: `Successfully synced ${successCount} elements`,
count: successCount, count: successCount,
syncedAt: new Date().toISOString(), syncedAt: new Date().toISOString(),
beforeCount, beforeCount,
afterCount: elements.size afterCount: store.getElementCount(projId)
}); });
} catch (error) { } catch (error) {
@@ -919,6 +920,7 @@ app.post('/api/viewport/result', (req: Request, res: Response) => {
// Snapshots: save // Snapshots: save
app.post('/api/snapshots', (req: Request, res: Response) => { app.post('/api/snapshots', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { name } = req.body; const { name } = req.body;
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
@@ -928,20 +930,15 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
}); });
} }
const snapshot: Snapshot = { const allElements = store.getAllElements(projId);
name, store.saveSnapshot(name, allElements, projId);
elements: Array.from(elements.values()), logger.info(`Snapshot saved: "${name}" with ${allElements.length} elements`);
createdAt: new Date().toISOString()
};
snapshots.set(name, snapshot);
logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
res.json({ res.json({
success: true, success: true,
name, name,
elementCount: snapshot.elements.length, elementCount: allElements.length,
createdAt: snapshot.createdAt createdAt: new Date().toISOString()
}); });
} catch (error) { } catch (error) {
logger.error('Error saving snapshot:', error); logger.error('Error saving snapshot:', error);
@@ -955,11 +952,8 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
// Snapshots: list // Snapshots: list
app.get('/api/snapshots', (req: Request, res: Response) => { app.get('/api/snapshots', (req: Request, res: Response) => {
try { try {
const list = Array.from(snapshots.values()).map(s => ({ const projId = resolveTenantProject(req);
name: s.name, const list = store.listSnapshots(projId);
elementCount: s.elements.length,
createdAt: s.createdAt
}));
res.json({ res.json({
success: true, success: true,
@@ -978,8 +972,9 @@ app.get('/api/snapshots', (req: Request, res: Response) => {
// Snapshots: get by name // Snapshots: get by name
app.get('/api/snapshots/:name', (req: Request, res: Response) => { app.get('/api/snapshots/:name', (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req);
const { name } = req.params; const { name } = req.params;
const snapshot = snapshots.get(name!); const snapshot = store.getSnapshot(name!, projId);
if (!snapshot) { if (!snapshot) {
return res.status(404).json({ return res.status(404).json({
@@ -1012,21 +1007,68 @@ app.get('/', (req: Request, res: Response) => {
}); });
}); });
// ── Tenant API ──
app.get('/api/tenants', (req: Request, res: Response) => {
try {
const tenants = dbListTenants();
const active = dbGetActiveTenant();
res.json({ success: true, tenants, activeTenantId: active.id });
} catch (error) {
logger.error('Error listing tenants:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.get('/api/tenant/active', (req: Request, res: Response) => {
try {
const tenant = dbGetActiveTenant();
res.json({ success: true, tenant });
} catch (error) {
logger.error('Error getting active tenant:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.put('/api/tenant/active', (req: Request, res: Response) => {
try {
const { tenantId } = req.body;
if (!tenantId || typeof tenantId !== 'string') {
return res.status(400).json({ success: false, error: 'tenantId is required' });
}
dbSetActiveTenant(tenantId);
const tenant = dbGetActiveTenant();
broadcast({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
});
res.json({ success: true, tenant });
} catch (error) {
logger.error('Error switching tenant:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
// Health check endpoint // Health check endpoint
app.get('/health', (req: Request, res: Response) => { app.get('/health', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
res.json({ res.json({
status: 'healthy', status: 'healthy',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
elements_count: elements.size, elements_count: store.getElementCount(projId),
websocket_clients: clients.size websocket_clients: clients.size
}); });
}); });
// Sync status endpoint // Sync status endpoint
app.get('/api/sync/status', (req: Request, res: Response) => { app.get('/api/sync/status', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
res.json({ res.json({
success: true, success: true,
elementCount: elements.size, elementCount: store.getElementCount(projId),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
memoryUsage: { memoryUsage: {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
@@ -1045,13 +1087,40 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
}); });
}); });
// Start server // Server configuration
const PORT = parseInt(process.env.PORT || '3000', 10); const PORT = parseInt(process.env.CANVAS_PORT || process.env.PORT || '3000', 10);
const HOST = process.env.HOST || 'localhost'; const HOST = process.env.HOST || 'localhost';
server.listen(PORT, HOST, () => { export function startCanvasServer(): Promise<void> {
logger.info(`POC server running on http://${HOST}:${PORT}`); return new Promise((resolve, reject) => {
const onError = (err: Error) => {
httpServer.removeListener('error', onError);
reject(err);
};
httpServer.on('error', onError);
httpServer.listen(PORT, HOST, () => {
httpServer.removeListener('error', onError);
logger.info(`Canvas server running on http://${HOST}:${PORT}`);
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`); logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
}); resolve();
});
});
}
export function stopCanvasServer(): Promise<void> {
return new Promise((resolve) => {
clients.forEach(c => c.close());
httpServer.close(() => resolve());
});
}
// Direct execution: `node dist/server.js` still works standalone
if (fileURLToPath(import.meta.url) === process.argv[1]) {
startCanvasServer().catch((err) => {
logger.error('Failed to start canvas server:', err);
process.exit(1);
});
}
export default app; export default app;
+15 -6
View File
@@ -182,7 +182,8 @@ export type WebSocketMessageType =
| 'mermaid_convert' | 'mermaid_convert'
| 'canvas_cleared' | 'canvas_cleared'
| 'export_image_request' | 'export_image_request'
| 'set_viewport'; | 'set_viewport'
| 'tenant_switched';
export interface InitialElementsMessage extends WebSocketMessage { export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements'; type: 'initial_elements';
@@ -271,6 +272,16 @@ export interface SetViewportMessage extends WebSocketMessage {
offsetY?: number; offsetY?: number;
} }
// Tenant switched message
export interface TenantSwitchedMessage extends WebSocketMessage {
type: 'tenant_switched';
tenant: {
id: string;
name: string;
workspace_path: string;
};
}
// Snapshot types // Snapshot types
export interface Snapshot { export interface Snapshot {
name: string; name: string;
@@ -278,11 +289,9 @@ export interface Snapshot {
createdAt: string; createdAt: string;
} }
// In-memory storage for Excalidraw elements // Storage is now handled by src/db.ts (SQLite).
export const elements = new Map<string, ServerElement>(); // The Map exports below are kept only for backward compatibility with
// standalone server.ts usage; they are NOT used when the DB is active.
// In-memory storage for snapshots
export const snapshots = new Map<string, Snapshot>();
// Validation function for Excalidraw elements // Validation function for Excalidraw elements
export function validateElement(element: Partial<ServerElement>): element is ServerElement { export function validateElement(element: Partial<ServerElement>): element is ServerElement {