✨ 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:
@@ -70,3 +70,27 @@ jobs:
|
||||
|
||||
- name: Check for TypeScript errors
|
||||
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 }}
|
||||
|
||||
@@ -10,9 +10,9 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME_MCP: ${{ github.repository }}
|
||||
IMAGE_NAME_CANVAS: ${{ github.repository }}-canvas
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME_MCP: sanjibdevnath/mcp-excalidraw-local
|
||||
IMAGE_NAME_CANVAS: sanjibdevnath/mcp-excalidraw-local-canvas
|
||||
|
||||
jobs:
|
||||
build-and-push-mcp:
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -29,12 +28,12 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for MCP Server
|
||||
id: meta
|
||||
@@ -55,19 +54,18 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
|
||||
build-and-push-canvas:
|
||||
name: Build and Push Canvas Server Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -76,12 +74,12 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Canvas Server
|
||||
id: meta
|
||||
@@ -102,39 +100,30 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.canvas
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
|
||||
test-docker-images:
|
||||
name: Test Docker Images
|
||||
needs: [build-and-push-mcp, build-and-push-canvas]
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
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
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Test Canvas Server image
|
||||
run: |
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
|
||||
docker run -d -p 3000:3000 --name test-canvas ${{ 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.IMAGE_NAME_CANVAS }}:latest
|
||||
sleep 10
|
||||
curl -f http://localhost:3000/health || exit 1
|
||||
docker logs test-canvas
|
||||
@@ -142,5 +131,29 @@ jobs:
|
||||
|
||||
- name: Test MCP Server image
|
||||
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"
|
||||
|
||||
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 }}
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Check if version exists on NPM
|
||||
id: check-version
|
||||
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 "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
|
||||
else
|
||||
@@ -82,14 +82,14 @@ jobs:
|
||||
- name: Create GitHub Release Assets
|
||||
if: github.event_name == 'release'
|
||||
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
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz
|
||||
mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -103,4 +103,4 @@ jobs:
|
||||
- name: Success notification
|
||||
run: |
|
||||
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
@@ -18,4 +18,5 @@ public/dist/
|
||||
# Development artifacts
|
||||
*.excalidraw
|
||||
|
||||
docs/
|
||||
docs/*
|
||||
!docs/screenshots/
|
||||
+12
-23
@@ -1,58 +1,47 @@
|
||||
# Dockerfile for MCP Excalidraw Server
|
||||
# This builds the MCP server only (core product for CI/CD and GHCR)
|
||||
# The canvas server is optional and runs separately
|
||||
# Builds the MCP server with SQLite persistence
|
||||
|
||||
# Stage 1: Build backend (TypeScript compilation)
|
||||
# Stage 1: Build backend (TypeScript compilation + native modules)
|
||||
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
|
||||
|
||||
# Copy package files
|
||||
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 tsconfig.json ./
|
||||
|
||||
# Compile TypeScript
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 2: Production MCP Server
|
||||
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 && \
|
||||
adduser --system --uid 1001 --gid 1001 nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
# Remove build tools after native modules are compiled
|
||||
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||
|
||||
# Copy compiled backend (MCP server only)
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Set ownership to nodejs user
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Set environment variables with defaults
|
||||
ENV NODE_ENV=production
|
||||
ENV EXPRESS_SERVER_URL=http://localhost:3000
|
||||
ENV ENABLE_CANVAS_SYNC=true
|
||||
|
||||
# Run MCP server (stdin/stdout protocol)
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
# Labels for metadata
|
||||
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"
|
||||
LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
|
||||
LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents (with SQLite persistence & multi-tenancy)"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
+13
-33
@@ -1,82 +1,62 @@
|
||||
# Dockerfile for Canvas Server (Optional)
|
||||
# This is for users who want to run the visual canvas UI
|
||||
# The canvas server provides the web interface and REST API
|
||||
# Provides the web interface, REST API, and SQLite persistence
|
||||
|
||||
# Stage 1: Build frontend
|
||||
FROM node:18-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
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 vite.config.js ./
|
||||
|
||||
# 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
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
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 tsconfig.json ./
|
||||
|
||||
# Compile TypeScript
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 3: Production Canvas Server
|
||||
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 && \
|
||||
adduser --system --uid 1001 --gid 1001 nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
# Remove build tools after native modules are compiled
|
||||
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 built frontend from frontend-builder stage
|
||||
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
|
||||
|
||||
# Set ownership to nodejs user
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Set environment variables with defaults
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run canvas server (web UI + REST API)
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
||||
# Labels for metadata
|
||||
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"
|
||||
LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
|
||||
LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API (with SQLite persistence & multi-tenancy)"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
@@ -1,51 +1,69 @@
|
||||
# Excalidraw MCP Server & Agent Skill
|
||||
# MCP Excalidraw Local
|
||||
|
||||
[](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/ci.yml)
|
||||
[](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/docker.yml)
|
||||
[](https://www.npmjs.com/package/mcp-excalidraw-server)
|
||||
[](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/ci.yml)
|
||||
[](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/docker.yml)
|
||||
[](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.)
|
||||
- **Agent Skill**: Portable skill for Claude Code, Codex CLI, and other skill-enabled agents
|
||||
Run a live Excalidraw canvas and control it from any AI agent. This repo provides:
|
||||
|
||||
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.
|
||||
|
||||

|
||||
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:
|
||||
|
||||

|
||||
|
||||
### Workspace Switcher
|
||||
|
||||
Click the workspace badge to switch between isolated canvases — each workspace has its own set of diagrams:
|
||||
|
||||

|
||||
|
||||
> For a demo of the upstream project (before persistence/multi-tenancy), see the [original video by @yctimlin](https://youtu.be/ufW78Amq5qA).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Demo](#demo)
|
||||
- [Screenshots](#screenshots)
|
||||
- [What It Is](#what-it-is)
|
||||
- [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)
|
||||
- [Quick Start (Local)](#quick-start-local)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Quick Start (Docker)](#quick-start-docker)
|
||||
- [Configure MCP Clients](#configure-mcp-clients)
|
||||
- [Claude Desktop](#claude-desktop)
|
||||
- [Claude Code](#claude-code)
|
||||
- [Cursor](#cursor)
|
||||
- [Codex CLI](#codex-cli)
|
||||
- [OpenCode](#opencode)
|
||||
- [Antigravity (Google)](#antigravity-google)
|
||||
- [Configuration](#configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Multi-Tenancy (Workspaces)](#multi-tenancy-workspaces)
|
||||
- [Agent Skill (Optional)](#agent-skill-optional)
|
||||
- [MCP Tools (26 Total)](#mcp-tools-26-total)
|
||||
- [MCP Tools (32 Total)](#mcp-tools-32-total)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Known Issues / TODO](#known-issues--todo)
|
||||
- [Development](#development)
|
||||
- [Credits](#credits)
|
||||
|
||||
## 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`)
|
||||
- MCP server: exposes MCP tools over stdio; syncs to the canvas via `EXPRESS_SERVER_URL`
|
||||
The repo contains a single Node.js process that runs:
|
||||
|
||||
- **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
|
||||
|
||||
@@ -53,8 +71,10 @@ Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mc
|
||||
|
||||
| | 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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
@@ -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) |
|
||||
| **Snapshot & rollback** | No | `snapshot_scene` / `restore_snapshot` |
|
||||
| **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) |
|
||||
| **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 |
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
### 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`
|
||||
- **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
|
||||
- **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 — agents can auto-fit diagrams after creation
|
||||
- **Design guide**: `read_diagram_guide` returns best-practice color palettes, sizing rules, layout patterns, and anti-patterns
|
||||
- **Viewport control**: `set_viewport` with `scrollToContent`, `scrollToElementId`, or manual zoom/offset
|
||||
- **File I/O**: export/import full `.excalidraw` JSON files
|
||||
- **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
|
||||
- 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
|
||||
- **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, `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)
|
||||
- Better testing loop: MCP Inspector CLI examples + browser screenshot checks (`agent-browser`)
|
||||
- Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed (`main.tsx`)
|
||||
- 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
|
||||
|
||||
## Quick Start (Local)
|
||||
## Architecture
|
||||
|
||||
Prereqs: Node >= 18, npm
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ MCP Process (single node process) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 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
|
||||
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
|
||||
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)
|
||||
|
||||
Canvas server:
|
||||
```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 |
|
||||
|----------|-------------|---------|
|
||||
| `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` |
|
||||
|
||||
---
|
||||
## 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:
|
||||
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
- Linux: `~/.config/Claude/claude_desktop_config.json`
|
||||
### How it works
|
||||
|
||||
**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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
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.
|
||||
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: <name>" badge. Click it to open a dropdown with all known workspaces, complete with search.
|
||||
4. **Multi-instance safe**: SQLite WAL mode with `busy_timeout = 5000ms` handles concurrent access from multiple client instances.
|
||||
|
||||
**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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
### Projects within a tenant
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
Each tenant can have multiple projects (collections of elements). Use the `list_projects` and `switch_project` MCP tools, or manage via the REST API.
|
||||
|
||||
## Agent Skill (Optional)
|
||||
|
||||
This repo includes a skill at `skills/excalidraw-skill/` that provides:
|
||||
|
||||
- **Workflow playbook** (`SKILL.md`): step-by-step guidance for drawing, refining, and exporting diagrams
|
||||
- **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference
|
||||
- **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 for all 32 tools
|
||||
- **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.
|
||||
|
||||
### Install The Skill (Codex CLI example)
|
||||
### Install the Skill
|
||||
|
||||
Copy the skill folder to your agent's skill directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.codex/skills
|
||||
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
|
||||
# Claude Code
|
||||
mkdir -p ~/.claude/skills
|
||||
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
|
||||
```
|
||||
|
||||
**Project-level** (scoped to a specific project, can be committed to the repo):
|
||||
```bash
|
||||
mkdir -p /path/to/your/project/.claude/skills
|
||||
cp -R skills/excalidraw-skill /path/to/your/project/.claude/skills/excalidraw-skill
|
||||
```
|
||||
# Cursor
|
||||
mkdir -p ~/.cursor/skills
|
||||
cp -R skills/excalidraw-skill ~/.cursor/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.
|
||||
|
||||
### Use The Skill Scripts
|
||||
### Use the Skill Scripts
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### When The Skill Is Useful
|
||||
### When the Skill Is Useful
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- **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
|
||||
- **Automated smoke tests**: create/update/delete a known element to validate a deployment
|
||||
- **Repeatable diagrams**: keep a library of element JSON snippets and import them
|
||||
|
||||
See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`.
|
||||
|
||||
## MCP Tools (26 Total)
|
||||
## MCP Tools (32 Total)
|
||||
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
@@ -433,64 +376,106 @@ See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/c
|
||||
| **Viewport** | `set_viewport` |
|
||||
| **Design Guide** | `read_diagram_guide` |
|
||||
| **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`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Canvas Smoke Test (HTTP)
|
||||
### Health check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
### MCP Smoke Test (MCP Inspector)
|
||||
### MCP Inspector
|
||||
|
||||
List tools:
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli \
|
||||
-e EXPRESS_SERVER_URL=http://localhost:3000 \
|
||||
-e ENABLE_CANVAS_SYNC=true -- \
|
||||
-e CANVAS_PORT=3000 -- \
|
||||
node dist/index.js --method tools/list
|
||||
```
|
||||
|
||||
Create a rectangle:
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli \
|
||||
-e EXPRESS_SERVER_URL=http://localhost:3000 \
|
||||
-e ENABLE_CANVAS_SYNC=true -- \
|
||||
-e CANVAS_PORT=3000 -- \
|
||||
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 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
|
||||
agent-browser install
|
||||
agent-browser open http://127.0.0.1:3000
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser screenshot /tmp/canvas.png
|
||||
# Open the canvas and take a screenshot for verification
|
||||
open http://127.0.0.1:3000
|
||||
# Or use agent-browser, Playwright, Puppeteer, etc.
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Canvas not updating: confirm `EXPRESS_SERVER_URL` points at the running canvas server.
|
||||
- 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 loading**: Ensure `CANVAS_PORT` isn't occupied by another process. Check `lsof -i :3000`.
|
||||
- **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
|
||||
|
||||
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 doing the actual 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 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!
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
npm run build
|
||||
# Type check
|
||||
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).
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp_excalidraw": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "excalidraw-mcp"]
|
||||
"excalidraw-canvas": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"],
|
||||
"env": {
|
||||
"CANVAS_PORT": "3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -15,7 +15,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.canvas
|
||||
image: mcp-excalidraw-canvas:latest
|
||||
image: sanjibdevnath/mcp-excalidraw-local-canvas:latest
|
||||
container_name: mcp-excalidraw-canvas
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: mcp-excalidraw:latest
|
||||
image: sanjibdevnath/mcp-excalidraw-local:latest
|
||||
container_name: mcp-excalidraw-mcp
|
||||
stdin_open: 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
@@ -13,6 +13,7 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 10px 20px;
|
||||
@@ -107,6 +108,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.api-panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
@@ -232,48 +234,181 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Sync Controls Styles */
|
||||
.sync-controls {
|
||||
/* Button group — joined buttons with shared style */
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left h1 { margin: 0; color: #333; font-size: 24px; }
|
||||
|
||||
.btn-loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* Clickable tenant badge — opens workspace switcher */
|
||||
.tenant-badge-btn {
|
||||
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;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sync-error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.sync-time {
|
||||
color: #666;
|
||||
.menu-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
+352
-72
@@ -70,7 +70,7 @@ interface ApiResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
|
||||
type SyncStatus = 'idle' | 'syncing';
|
||||
|
||||
// Helper function to clean elements for Excalidraw
|
||||
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 {
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false)
|
||||
const websocketRef = useRef<WebSocket | null>(null)
|
||||
|
||||
// Sync state management
|
||||
// Sync state
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -165,16 +203,72 @@ function App(): JSX.Element {
|
||||
}
|
||||
}, [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> => {
|
||||
try {
|
||||
const response = await fetch('/api/elements')
|
||||
const response = await fetch('/api/elements', { headers: tenantHeaders() })
|
||||
const result: ApiResponse = await response.json()
|
||||
|
||||
if (result.success && result.elements && result.elements.length > 0) {
|
||||
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 })
|
||||
excalidrawAPI?.updateScene({ elements: convertedElements })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading existing elements:', error)
|
||||
}
|
||||
@@ -355,7 +449,7 @@ function App(): JSX.Element {
|
||||
const svgString = new XMLSerializer().serializeToString(svg)
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
format: 'svg',
|
||||
@@ -382,7 +476,7 @@ function App(): JSX.Element {
|
||||
}
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
format: 'png',
|
||||
@@ -393,7 +487,7 @@ function App(): JSX.Element {
|
||||
console.error('Image export (FileReader) failed:', readerError)
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
error: (readerError as Error).message
|
||||
@@ -405,7 +499,7 @@ function App(): JSX.Element {
|
||||
console.error('FileReader error:', reader.error)
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
error: reader.error?.message || 'FileReader failed'
|
||||
@@ -419,7 +513,7 @@ function App(): JSX.Element {
|
||||
console.error('Image export failed:', exportError)
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
error: (exportError as Error).message
|
||||
@@ -465,7 +559,7 @@ function App(): JSX.Element {
|
||||
|
||||
await fetch('/api/viewport/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
success: true,
|
||||
@@ -476,7 +570,7 @@ function App(): JSX.Element {
|
||||
console.error('Viewport control failed:', viewportError)
|
||||
await fetch('/api/viewport/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
error: (viewportError as Error).message
|
||||
@@ -519,6 +613,27 @@ function App(): JSX.Element {
|
||||
}
|
||||
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:
|
||||
console.log('Unknown WebSocket message type:', data.type)
|
||||
}
|
||||
@@ -527,49 +642,157 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Data format conversion for backend
|
||||
const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => {
|
||||
return {
|
||||
...element
|
||||
} as ServerElement
|
||||
}
|
||||
// Normalize Excalidraw native elements back to MCP format for backend storage.
|
||||
// Excalidraw internally splits label text out of containers into separate text
|
||||
// elements linked by containerId/boundElements. This causes text to detach on
|
||||
// reload because convertToExcalidrawElements doesn't reconstruct that binding.
|
||||
// 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
|
||||
const formatSyncTime = (time: Date | null): string => {
|
||||
if (!time) return ''
|
||||
return time.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
// Collect IDs of text elements that are bound inside a container
|
||||
const boundTextIds = new Set<string>()
|
||||
// Map containerId → text content for merging
|
||||
const containerTextMap = new Map<string, { text: string; fontSize?: number; fontFamily?: number }>()
|
||||
|
||||
for (const el of elements) {
|
||||
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 syncToBackend = async (): Promise<void> => {
|
||||
if (!excalidrawAPI) {
|
||||
console.warn('Excalidraw API not available')
|
||||
const result: ServerElement[] = []
|
||||
for (const el of elements) {
|
||||
if (boundTextIds.has(el.id)) continue // skip bound text — merged into container
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
try {
|
||||
// 1. Get current elements
|
||||
const currentElements = excalidrawAPI.getSceneElements()
|
||||
console.log(`Syncing ${currentElements.length} elements to backend`)
|
||||
|
||||
// Filter out deleted elements
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
elements: backendElements,
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -578,33 +801,33 @@ function App(): JSX.Element {
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse = await response.json()
|
||||
setSyncStatus('success')
|
||||
setLastSyncTime(new Date())
|
||||
console.log(`Sync successful: ${result.count} elements synced`)
|
||||
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setSyncStatus('idle'), 2000)
|
||||
lastSyncedHashRef.current = computeElementHash(currentElements)
|
||||
setSyncStatus('idle')
|
||||
showToast('Saved')
|
||||
console.log(`Sync: ${result.count} elements synced`)
|
||||
} else {
|
||||
const error: ApiResponse = await response.json()
|
||||
setSyncStatus('error')
|
||||
console.error('Sync failed:', error.error)
|
||||
setSyncStatus('idle')
|
||||
showToast('Sync failed', 3000)
|
||||
console.error('Sync failed:', (await response.json() as ApiResponse).error)
|
||||
}
|
||||
} catch (error) {
|
||||
setSyncStatus('error')
|
||||
setSyncStatus('idle')
|
||||
showToast('Sync failed', 3000)
|
||||
console.error('Sync error:', error)
|
||||
} finally {
|
||||
isSyncingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearCanvas = async (): Promise<void> => {
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
// Get all current elements and delete them from backend
|
||||
const response = await fetch('/api/elements')
|
||||
const response = await fetch('/api/elements', { headers: tenantHeaders() })
|
||||
const result: ApiResponse = await response.json()
|
||||
|
||||
if (result.success && result.elements) {
|
||||
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)
|
||||
}
|
||||
@@ -629,44 +852,100 @@ function App(): JSX.Element {
|
||||
<div className="app">
|
||||
{/* Header */}
|
||||
<div className="header">
|
||||
<div className="header-left">
|
||||
<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="status">
|
||||
<div className={`status-dot ${isConnected ? 'status-connected' : 'status-disconnected'}`}></div>
|
||||
<span>{isConnected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
|
||||
{/* Sync Controls */}
|
||||
<div className="sync-controls">
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className={`btn-primary ${syncStatus === 'syncing' ? 'btn-loading' : ''}`}
|
||||
className={`btn-group-item ${syncStatus === 'syncing' ? 'btn-group-busy' : ''}`}
|
||||
onClick={syncToBackend}
|
||||
disabled={syncStatus === 'syncing' || !excalidrawAPI}
|
||||
>
|
||||
{syncStatus === 'syncing' && <span className="spinner"></span>}
|
||||
{syncStatus === 'syncing' ? 'Syncing...' : 'Sync to Backend'}
|
||||
{syncStatus === 'syncing' ? 'Syncing...' : 'Sync'}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button>
|
||||
</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 */}
|
||||
<div className="canvas-container">
|
||||
<Excalidraw
|
||||
@@ -678,6 +957,7 @@ function App(): JSX.Element {
|
||||
viewBackgroundColor: '#ffffff'
|
||||
}
|
||||
}}
|
||||
onChange={handleCanvasChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Generated
+393
@@ -12,6 +12,7 @@
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
|
||||
"@modelcontextprotocol/sdk": "latest",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -28,6 +29,7 @@
|
||||
"mcp-excalidraw-server": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.19.7",
|
||||
@@ -2583,6 +2585,16 @@
|
||||
"@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": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -3173,6 +3185,40 @@
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -3185,6 +3231,26 @@
|
||||
"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": {
|
||||
"version": "1.20.3",
|
||||
"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_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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -3456,6 +3546,12 @@
|
||||
"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": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -4258,6 +4354,30 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
@@ -4295,6 +4415,15 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
@@ -4386,6 +4515,15 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -4513,6 +4651,15 @@
|
||||
"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": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
@@ -4636,6 +4783,12 @@
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -4726,6 +4879,12 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -4823,6 +4982,12 @@
|
||||
"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": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
@@ -4921,6 +5086,26 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -5871,6 +6062,33 @@
|
||||
"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": {
|
||||
"version": "1.8.0",
|
||||
"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_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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -5946,6 +6170,30 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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_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": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -6288,6 +6562,16 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6358,6 +6642,21 @@
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -6880,6 +7179,51 @@
|
||||
"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": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||
@@ -6971,6 +7315,15 @@
|
||||
"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": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
@@ -6990,6 +7343,34 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
@@ -7102,6 +7483,18 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"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": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
|
||||
|
||||
+28
-13
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "mcp-excalidraw-server",
|
||||
"version": "1.0.2",
|
||||
"description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management",
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "3.0.0",
|
||||
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-excalidraw-server": "dist/index.js"
|
||||
"mcp-excalidraw-local": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npm run build:server && node dist/index.js",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
|
||||
"@modelcontextprotocol/sdk": "latest",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -37,6 +38,7 @@
|
||||
"zod-to-json-schema": "^3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.19.7",
|
||||
@@ -60,21 +62,35 @@
|
||||
"real-time",
|
||||
"websocket",
|
||||
"visualization",
|
||||
"claude",
|
||||
"ai-tools"
|
||||
"sqlite",
|
||||
"multi-tenancy",
|
||||
"self-hosted",
|
||||
"local"
|
||||
],
|
||||
"author": {
|
||||
"name": "yctimlin",
|
||||
"email": "c22647809@gmail.com"
|
||||
"name": "sanjibdevnathlabs"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "yctimlin",
|
||||
"email": "c22647809@gmail.com",
|
||||
"url": "https://github.com/yctimlin"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"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": {
|
||||
"url": "https://github.com/yctimlin/mcp_excalidraw/issues"
|
||||
"url": "https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/issues"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -84,9 +100,8 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"dist/**/*",
|
||||
"*.d.ts",
|
||||
"skills/**/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
|
||||
Generated
+5507
File diff suppressed because it is too large
Load Diff
+305
-215
@@ -1,280 +1,370 @@
|
||||
---
|
||||
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
|
||||
|
||||
## 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.
|
||||
|
||||
### Check 2: REST API (Fallback — works without MCP server)
|
||||
```bash
|
||||
curl -s http://localhost:3000/health
|
||||
WRITE (create/update elements)
|
||||
→ CHECK (screenshot to see actual rendering)
|
||||
→ REVIEW (critically evaluate against Quality Checklist)
|
||||
→ FIX (if issues found, fix and re-screenshot)
|
||||
→ 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
|
||||
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
|
||||
> ```
|
||||
**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.
|
||||
|
||||
### MCP vs REST API Quick Reference
|
||||
### 2. Use batch_create_elements, Not Mermaid
|
||||
|
||||
| Operation | MCP Tool | REST API Equivalent |
|
||||
|-----------|----------|-------------------|
|
||||
| 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 |
|
||||
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.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
### 3. Shapes First, Arrows Second — Two Separate Batches
|
||||
|
||||
## 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:
|
||||
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.
|
||||
**Never clear the canvas** between diagrams. Place them side-by-side or in a grid:
|
||||
|
||||
### If ANY issue is found:
|
||||
- **STOP adding new elements**
|
||||
- Fix the issue first (resize, reposition, delete and recreate)
|
||||
- Re-verify with a new screenshot
|
||||
- Only proceed to next iteration after ALL checks pass
|
||||
```
|
||||
Diagram 1: x=0 to ~1100
|
||||
Diagram 2: x=1400 onward (300px gap)
|
||||
— or —
|
||||
Row 1: y=0 to ~800
|
||||
Row 2: y=1100 onward (300px gap)
|
||||
```
|
||||
|
||||
### Sizing Rules (prevent truncation):
|
||||
- **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.
|
||||
Use a title text element above each diagram to label it.
|
||||
|
||||
### Layout Planning (prevent overlap):
|
||||
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)
|
||||
### 5. Set roughness: 0 for Clean Diagrams
|
||||
|
||||
**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.
|
||||
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.
|
||||
4. For full tool/endpoint reference, read `references/cheatsheet.md`.
|
||||
Excalidraw's Virgil font is ~30% wider than standard fonts. These rules account for that.
|
||||
|
||||
### Rectangles
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
### MCP Mode
|
||||
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.
|
||||
### Phase 1: Plan
|
||||
|
||||
### REST API Mode
|
||||
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.
|
||||
Before writing any JSON, plan on paper:
|
||||
|
||||
### 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
|
||||
{"elements": [
|
||||
{"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 120, "height": 60, "text": "Service A"},
|
||||
{"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 120, "height": 60, "text": "Service B"},
|
||||
{"type": "arrow", "x": 0, "y": 0, "startElementId": "svc-a", "endElementId": "svc-b", "text": "calls"}
|
||||
{"id": "title", "type": "text", "x": 100, "y": 0,
|
||||
"text": "MY DIAGRAM", "fontSize": 28, "strokeColor": "#1e1e1e"},
|
||||
{"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
|
||||
{"elements": [
|
||||
{"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 120, "height": 60, "label": {"text": "Service A"}},
|
||||
{"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 120, "height": 60, "label": {"text": "Service B"}},
|
||||
{"type": "arrow", "x": 0, "y": 0, "start": {"id": "svc-a"}, "end": {"id": "svc-b"}, "label": {"text": "calls"}}
|
||||
{"type": "arrow", "x": 100, "y": 150,
|
||||
"startElementId": "box-a", "endElementId": "box-b",
|
||||
"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.
|
||||
|
||||
**Option 2: Elbowed arrows** — right-angle routing (L-shaped or Z-shaped):
|
||||
```json
|
||||
{
|
||||
"type": "arrow", "x": 100, "y": 100,
|
||||
"points": [[0, 0], [0, -50], [200, -50], [200, 0]],
|
||||
"elbowed": true,
|
||||
"strokeColor": "#1971c2"
|
||||
}
|
||||
Layout:
|
||||
Diamonds: w=400, h=160, fontSize=16, gap=120px vertical
|
||||
Answer boxes: w=300, h=80, fontSize=20, offset 130px right of diamonds
|
||||
Start ellipse: w=340, h=70, fontSize=18
|
||||
Title: fontSize=28
|
||||
Arrows: strokeWidth=2, roughness=0
|
||||
YES arrows: strokeColor=#2f9e44 (green), horizontal right
|
||||
NO arrows: strokeColor=#e03131 (red), vertical down
|
||||
All elements: roughness=0
|
||||
```
|
||||
|
||||
**When to use which:**
|
||||
- **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.
|
||||
## Architecture Diagram Template
|
||||
|
||||
**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"
|
||||
→ update_element (increase widths) → get_canvas_screenshot → "overlap between X and Y"
|
||||
→ update_element (reposition) → get_canvas_screenshot → "all checks pass"
|
||||
→ proceed to next iteration
|
||||
Layout:
|
||||
Zones: large rectangles, backgroundColor=#e9ecef, opacity=30
|
||||
Services: w=200, h=70, fontSize=18, spaced 60px apart
|
||||
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
|
||||
|
||||
1. `describe_scene` to understand current state.
|
||||
2. Identify targets by id, type, or label text (not x/y coordinates).
|
||||
3. `update_element` to move/resize/recolor, `delete_element` to remove.
|
||||
4. `get_canvas_screenshot` to verify changes visually.
|
||||
5. If updates fail: check element id exists (`get_element`), element isn't locked (`unlock_elements`).
|
||||
1. `describe_scene` to understand current state
|
||||
2. Identify targets by `id` or label text (or use `search_elements` for text search)
|
||||
3. `update_element` to move/resize/recolor
|
||||
4. Screenshot to verify
|
||||
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`.
|
||||
- Import from .excalidraw: `import_scene` with `mode: "replace"` or `"merge"`.
|
||||
- Export to image: `export_to_image` with `format: "png"` or `"svg"` (requires browser open).
|
||||
- CLI export: `node scripts/export-elements.cjs --out diagram.elements.json`
|
||||
- CLI import: `node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync`
|
||||
- Export: `export_scene` (optional `filePath`)
|
||||
- Import: `import_scene` with `mode: "replace"` or `"merge"`
|
||||
- Image export: `export_to_image` with `format: "png"` or `"svg"` (requires browser)
|
||||
|
||||
## Workflow: Snapshots (Save/Restore Canvas State)
|
||||
## Workflow: Snapshots
|
||||
|
||||
1. `snapshot_scene` with a name before risky changes.
|
||||
2. Make changes, `describe_scene` / `get_canvas_screenshot` to evaluate.
|
||||
3. `restore_snapshot` to rollback if needed.
|
||||
1. `snapshot_scene` with a name before risky changes
|
||||
2. Make changes, screenshot to evaluate
|
||||
3. `restore_snapshot` to rollback if needed
|
||||
|
||||
## Workflow: Duplication
|
||||
|
||||
- `duplicate_elements` with `elementIds` and optional `offsetX`/`offsetY` (default 20,20).
|
||||
- Useful for creating repeated patterns or copying existing layouts.
|
||||
|
||||
## Points Format for Arrows/Lines
|
||||
|
||||
The `points` field accepts both formats:
|
||||
- Tuple: `[[0, 0], [100, 50]]`
|
||||
- Object: `[{"x": 0, "y": 0}, {"x": 100, "y": 50}]`
|
||||
|
||||
Both are normalized to tuples automatically.
|
||||
|
||||
## Workflow: Share Diagram (excalidraw.com URL)
|
||||
|
||||
1. Create your diagram using any of the above workflows.
|
||||
2. `export_to_excalidraw_url` — uploads encrypted scene, returns a shareable URL.
|
||||
3. Share the URL — anyone can open it in excalidraw.com to view and edit.
|
||||
**Note**: Snapshot restore may not always reload elements into the active view. If the canvas appears empty after restore, re-fetch elements or recreate.
|
||||
|
||||
## Workflow: Viewport Control
|
||||
|
||||
- `set_viewport` with `scrollToContent: true` — auto-fit all elements (zoom-to-fit).
|
||||
- `set_viewport` with `scrollToElementId: "my-element"` — center view on a specific element.
|
||||
- `set_viewport` with `zoom: 1.5, offsetX: 100, offsetY: 200` — manual camera control.
|
||||
- `scrollToContent: true` — auto-fit all elements
|
||||
- `scrollToElementId: "my-element"` — center on specific element
|
||||
- `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/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
|
||||
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
@@ -15,9 +17,11 @@
|
||||
| `get_element` | Get single element by ID | `id` |
|
||||
| `update_element` | Update element properties | `id` |
|
||||
| `delete_element` | Delete element | `id` |
|
||||
| `query_elements` | Query by type/filters | (optional) `type`, `filter` |
|
||||
| `batch_create_elements` | Create many at once | `elements[]` |
|
||||
| `query_elements` | Query by type | (optional) `type` |
|
||||
| `batch_create_elements` | Create many at once (recommended) | `elements[]` |
|
||||
| `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
|
||||
|
||||
@@ -35,8 +39,9 @@
|
||||
| Tool | Description | Required params |
|
||||
|------|-------------|-----------------|
|
||||
| `describe_scene` | AI-readable scene description (types, positions, labels, connections, bounding box) | (none) |
|
||||
| `get_canvas_screenshot` | Returns PNG image of canvas for visual verification | (optional) `background` |
|
||||
| `get_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` |
|
||||
| `read_diagram_guide` | Get design best practices (colors, sizing, layout, anti-patterns) | (none) |
|
||||
|
||||
### File I/O & Export
|
||||
|
||||
@@ -45,15 +50,15 @@
|
||||
| `export_scene` | Export to .excalidraw JSON | (optional) `filePath` |
|
||||
| `import_scene` | Import from .excalidraw JSON | `mode` ("replace"\|"merge"), `filePath` or `data` |
|
||||
| `export_to_image` | Export to PNG/SVG (needs browser) | `format` ("png"\|"svg"), (optional) `filePath`, `background` |
|
||||
| `export_to_excalidraw_url` | Upload & get shareable excalidraw.com URL | (none) |
|
||||
| `export_to_excalidraw_url` | Upload & get shareable excalidraw.com URL (may fail if org blocks excalidraw.com) | (none) |
|
||||
|
||||
### State Management
|
||||
|
||||
| Tool | Description | Required params |
|
||||
|------|-------------|-----------------|
|
||||
| `clear_canvas` | Remove all elements | (none) |
|
||||
| `snapshot_scene` | Save named snapshot | `name` |
|
||||
| `restore_snapshot` | Restore from snapshot | `name` |
|
||||
| `clear_canvas` | Remove all elements from active project | (none) |
|
||||
| `snapshot_scene` | Save named snapshot of current canvas state | `name` |
|
||||
| `restore_snapshot` | Restore from snapshot (may not reload into view — re-fetch if canvas appears empty) | `name` |
|
||||
|
||||
### 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` |
|
||||
|
||||
### Design Guide
|
||||
### Multi-Tenancy (Workspaces)
|
||||
|
||||
| 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
|
||||
|
||||
| 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:
|
||||
- **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!)
|
||||
- `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.
|
||||
- **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.
|
||||
## Key Notes
|
||||
|
||||
### MCP vs REST API Format Differences
|
||||
|
||||
| Concept | MCP Tool Format | REST API Format |
|
||||
|---------|----------------|-----------------|
|
||||
| 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)
|
||||
|
||||
All endpoints accept an optional `X-Tenant-Id` header to scope operations to a specific tenant.
|
||||
|
||||
### Elements
|
||||
|
||||
| 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 |
|
||||
| `POST` | `/api/elements` | Create element |
|
||||
| `PUT` | `/api/elements/:id` | Update element |
|
||||
@@ -95,29 +132,37 @@ Notes:
|
||||
| `DELETE` | `/api/elements/clear` | Clear all elements |
|
||||
| `GET` | `/api/elements/search?type=...` | Search with filters |
|
||||
| `POST` | `/api/elements/batch` | Batch create |
|
||||
| `POST` | `/api/elements/sync` | Overwrite import (clear + write) |
|
||||
| `POST` | `/api/elements/sync` | Full sync (clear + write all elements) |
|
||||
| `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
|
||||
|
||||
| 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 |
|
||||
|
||||
### Viewport
|
||||
|
||||
| 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 |
|
||||
|
||||
### Snapshots
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/snapshots` | Save snapshot `{name}` |
|
||||
| `GET` | `/api/snapshots` | List snapshots |
|
||||
| `POST` | `/api/snapshots` | Save snapshot `{"name": "..."}` |
|
||||
| `GET` | `/api/snapshots` | List all snapshots |
|
||||
| `GET` | `/api/snapshots/:name` | Get snapshot by name |
|
||||
|
||||
### System
|
||||
@@ -125,7 +170,7 @@ Notes:
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/health` | Health check |
|
||||
| `GET` | `/api/sync/status` | Memory/WebSocket stats |
|
||||
| `GET` | `/api/sync/status` | Element count and WebSocket stats |
|
||||
|
||||
## Skill Scripts
|
||||
|
||||
@@ -140,3 +185,11 @@ node scripts/create-element.cjs --data '{...}'
|
||||
node scripts/update-element.cjs --id <id> --data '{...}'
|
||||
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) |
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@ process.env.NO_COLOR = '1';
|
||||
|
||||
import { fileURLToPath } from "url";
|
||||
import { deflateSync } from 'zlib';
|
||||
import { webcrypto } from 'crypto';
|
||||
import { webcrypto, createHash } from 'crypto';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
@@ -28,6 +28,17 @@ import {
|
||||
validateElement
|
||||
} from './types.js';
|
||||
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
|
||||
dotenv.config();
|
||||
@@ -47,9 +58,10 @@ function sanitizeFilePath(filePath: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Express server configuration
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
|
||||
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
|
||||
// Express server configuration — derive URL from CANVAS_PORT
|
||||
const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
|
||||
const ENABLE_CANVAS_SYNC = true;
|
||||
|
||||
// API Response types
|
||||
interface ApiResponse {
|
||||
@@ -66,6 +78,14 @@ interface SyncResponse {
|
||||
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)
|
||||
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
|
||||
if (!ENABLE_CANVAS_SYNC) {
|
||||
@@ -82,7 +102,7 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements`;
|
||||
options = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
break;
|
||||
@@ -91,21 +111,21 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
|
||||
options = {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
|
||||
options = { method: 'DELETE' };
|
||||
options = { method: 'DELETE', headers: canvasHeaders() };
|
||||
break;
|
||||
|
||||
case 'batch_create':
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
|
||||
options = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ elements: data })
|
||||
};
|
||||
break;
|
||||
@@ -168,7 +188,9 @@ async function getElementFromCanvas(elementId: string): Promise<ServerElement |
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
|
||||
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 response = await fetch(url);
|
||||
const response = await fetch(url, { headers: canvasHeaders() });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
||||
@@ -1019,8 +1122,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
case 'library':
|
||||
case 'elements':
|
||||
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) {
|
||||
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
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
mermaidDiagram: params.mermaidDiagram,
|
||||
config: params.config
|
||||
@@ -1429,7 +1533,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
logger.info('Clearing canvas via MCP');
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1453,7 +1558,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
// If replace mode, clear first
|
||||
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
|
||||
@@ -1557,7 +1663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: params.format,
|
||||
background: params.background ?? true
|
||||
@@ -1649,7 +1755,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
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);
|
||||
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) {
|
||||
throw new Error(`Snapshot "${params.name}" not found`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
|
||||
|
||||
// Clear current canvas
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' });
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
|
||||
// Restore elements
|
||||
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
|
||||
@@ -1696,7 +1802,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
case 'describe_scene': {
|
||||
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) {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: 'png',
|
||||
background: params.background ?? true
|
||||
@@ -1851,8 +1959,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
case 'export_to_excalidraw_url': {
|
||||
logger.info('Exporting to excalidraw.com URL');
|
||||
|
||||
// 1. Fetch current scene elements
|
||||
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
|
||||
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!urlExportResponse.ok) {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
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:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
@@ -2193,12 +2431,74 @@ async function runServer(): Promise<void> {
|
||||
try {
|
||||
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();
|
||||
logger.debug('Connecting to stdio transport...');
|
||||
|
||||
await server.connect(transport);
|
||||
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();
|
||||
} catch (error) {
|
||||
logger.error('Error starting server:', error);
|
||||
|
||||
+155
-86
@@ -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 { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
@@ -7,8 +7,6 @@ import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import logger from './utils/logger.js';
|
||||
import {
|
||||
elements,
|
||||
snapshots,
|
||||
generateId,
|
||||
EXCALIDRAW_ELEMENT_TYPES,
|
||||
ServerElement,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
InitialElementsMessage,
|
||||
Snapshot
|
||||
} 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 WebSocket from 'ws';
|
||||
|
||||
@@ -31,9 +31,9 @@ dotenv.config();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
const app: Application = express();
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
@@ -45,6 +45,14 @@ app.use(express.static(staticDir));
|
||||
// Also serve frontend assets
|
||||
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
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
@@ -63,17 +71,26 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
clients.add(ws);
|
||||
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
|
||||
const initialMessage: InitialElementsMessage = {
|
||||
type: 'initial_elements',
|
||||
elements: Array.from(elements.values())
|
||||
elements: store.getAllElements()
|
||||
};
|
||||
ws.send(JSON.stringify(initialMessage));
|
||||
|
||||
// Send sync status to new client
|
||||
const syncMessage: SyncStatusMessage = {
|
||||
type: 'sync_status',
|
||||
elementCount: elements.size,
|
||||
elementCount: store.getElementCount(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
ws.send(JSON.stringify(syncMessage));
|
||||
@@ -161,11 +178,12 @@ const UpdateElementSchema = z.object({
|
||||
// Get all elements
|
||||
app.get('/api/elements', (req: Request, res: Response) => {
|
||||
try {
|
||||
const elementsArray = Array.from(elements.values());
|
||||
const projId = resolveTenantProject(req);
|
||||
const allElements = store.getAllElements(projId);
|
||||
res.json({
|
||||
success: true,
|
||||
elements: elementsArray,
|
||||
count: elementsArray.length
|
||||
elements: allElements,
|
||||
count: allElements.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching elements:', error);
|
||||
@@ -179,10 +197,10 @@ app.get('/api/elements', (req: Request, res: Response) => {
|
||||
// Create new element
|
||||
app.post('/api/elements', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const params = CreateElementSchema.parse(req.body);
|
||||
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 element: ServerElement = {
|
||||
id,
|
||||
@@ -192,9 +210,8 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
version: 1
|
||||
};
|
||||
|
||||
elements.set(id, element);
|
||||
store.setElement(id, element, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: ElementCreatedMessage = {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
@@ -217,6 +234,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
// Update element
|
||||
app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
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) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -242,9 +260,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
version: (existingElement.version || 0) + 1
|
||||
};
|
||||
|
||||
elements.set(id, updatedElement);
|
||||
store.setElement(id, updatedElement, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: ElementUpdatedMessage = {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
@@ -267,8 +284,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
// Clear all elements (must be before /:id route)
|
||||
app.delete('/api/elements/clear', (req: Request, res: Response) => {
|
||||
try {
|
||||
const count = elements.size;
|
||||
elements.clear();
|
||||
const projId = resolveTenantProject(req);
|
||||
const count = store.clearElements(projId);
|
||||
|
||||
broadcast({
|
||||
type: 'canvas_cleared',
|
||||
@@ -294,6 +311,7 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
|
||||
// Delete element
|
||||
app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
|
||||
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({
|
||||
success: false,
|
||||
error: `Element with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
elements.delete(id);
|
||||
store.deleteElement(id, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: ElementDeletedMessage = {
|
||||
@@ -335,22 +353,19 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
// Query elements with filters
|
||||
app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, ...filters } = req.query;
|
||||
let results = Array.from(elements.values());
|
||||
const projId = resolveTenantProject(req);
|
||||
const { type, q, ...filters } = req.query;
|
||||
|
||||
// Filter by type if specified
|
||||
if (type && typeof type === 'string') {
|
||||
results = results.filter(element => element.type === type);
|
||||
if (q && typeof q === 'string') {
|
||||
const results = store.searchElements(q, projId);
|
||||
return res.json({ success: true, elements: results, count: results.length });
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
if (Object.keys(filters).length > 0) {
|
||||
results = results.filter(element => {
|
||||
return Object.entries(filters).every(([key, value]) => {
|
||||
return (element as any)[key] === value;
|
||||
});
|
||||
});
|
||||
}
|
||||
const results = store.queryElements(
|
||||
type && typeof type === 'string' ? type : undefined,
|
||||
Object.keys(filters).length > 0 ? filters as Record<string, any> : undefined,
|
||||
projId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -369,6 +384,7 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
// Get element by ID
|
||||
app.get('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
|
||||
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) {
|
||||
return res.status(404).json({
|
||||
@@ -453,14 +469,14 @@ function computeEdgePoint(
|
||||
}
|
||||
|
||||
// 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>();
|
||||
batchElements.forEach(el => elementMap.set(el.id, el));
|
||||
|
||||
// Also check existing elements for cross-batch references
|
||||
elements.forEach((el, id) => {
|
||||
if (!elementMap.has(id)) elementMap.set(id, el);
|
||||
});
|
||||
for (const el of store.getAllElements(projectId)) {
|
||||
if (!elementMap.has(el.id)) elementMap.set(el.id, el);
|
||||
}
|
||||
|
||||
for (const el of batchElements) {
|
||||
if (el.type !== 'arrow' && el.type !== 'line') continue;
|
||||
@@ -535,6 +551,7 @@ function resolveArrowBindings(batchElements: ServerElement[]): void {
|
||||
// Batch create elements
|
||||
app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: elementsToCreate } = req.body;
|
||||
|
||||
if (!Array.isArray(elementsToCreate)) {
|
||||
@@ -548,7 +565,6 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
|
||||
elementsToCreate.forEach(elementData => {
|
||||
const params = CreateElementSchema.parse(elementData);
|
||||
// Prioritize passed ID (for MCP sync), otherwise generate new ID
|
||||
const id = params.id || generateId();
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
@@ -561,11 +577,9 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
createdElements.push(element);
|
||||
});
|
||||
|
||||
// Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements)
|
||||
resolveArrowBindings(createdElements);
|
||||
resolveArrowBindings(createdElements, projId);
|
||||
|
||||
// Store all elements after binding resolution
|
||||
createdElements.forEach(el => elements.set(el.id, el));
|
||||
createdElements.forEach(el => store.setElement(el.id, el, projId));
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: BatchCreatedMessage = {
|
||||
@@ -632,6 +646,7 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
|
||||
// Sync elements from frontend (overwrite sync)
|
||||
app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: frontendElements, timestamp } = req.body;
|
||||
|
||||
logger.info(`Sync request received: ${frontendElements.length} elements`, {
|
||||
@@ -639,7 +654,6 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
elementCount: frontendElements.length
|
||||
});
|
||||
|
||||
// Validate input data
|
||||
if (!Array.isArray(frontendElements)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -647,23 +661,15 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Record element count before sync
|
||||
const beforeCount = elements.size;
|
||||
const beforeCount = store.getElementCount(projId);
|
||||
|
||||
// 1. Clear existing memory storage
|
||||
elements.clear();
|
||||
logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
|
||||
|
||||
// 2. Batch write new data
|
||||
let successCount = 0;
|
||||
// Process elements with server metadata
|
||||
const processedElements: ServerElement[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
frontendElements.forEach((element: any, index: number) => {
|
||||
try {
|
||||
// Ensure element has ID, generate one if missing
|
||||
const elementId = element.id || generateId();
|
||||
|
||||
// Add server metadata
|
||||
const processedElement: ServerElement = {
|
||||
...element,
|
||||
id: elementId,
|
||||
@@ -672,20 +678,16 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
syncTimestamp: timestamp,
|
||||
version: 1
|
||||
};
|
||||
|
||||
// Store to memory
|
||||
elements.set(elementId, processedElement);
|
||||
processedElements.push(processedElement);
|
||||
successCount++;
|
||||
|
||||
} catch (elementError) {
|
||||
logger.warn(`Failed to process element ${index}:`, elementError);
|
||||
}
|
||||
});
|
||||
|
||||
store.bulkReplaceElements(processedElements, projId);
|
||||
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
|
||||
|
||||
// 3. Broadcast sync event to all WebSocket clients
|
||||
broadcast({
|
||||
type: 'elements_synced',
|
||||
count: successCount,
|
||||
@@ -693,14 +695,13 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
source: 'manual_sync'
|
||||
});
|
||||
|
||||
// 4. Return sync results
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully synced ${successCount} elements`,
|
||||
count: successCount,
|
||||
syncedAt: new Date().toISOString(),
|
||||
beforeCount,
|
||||
afterCount: elements.size
|
||||
afterCount: store.getElementCount(projId)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -919,6 +920,7 @@ app.post('/api/viewport/result', (req: Request, res: Response) => {
|
||||
// Snapshots: save
|
||||
app.post('/api/snapshots', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
@@ -928,20 +930,15 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
name,
|
||||
elements: Array.from(elements.values()),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
snapshots.set(name, snapshot);
|
||||
logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
|
||||
const allElements = store.getAllElements(projId);
|
||||
store.saveSnapshot(name, allElements, projId);
|
||||
logger.info(`Snapshot saved: "${name}" with ${allElements.length} elements`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
name,
|
||||
elementCount: snapshot.elements.length,
|
||||
createdAt: snapshot.createdAt
|
||||
elementCount: allElements.length,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error saving snapshot:', error);
|
||||
@@ -955,11 +952,8 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
|
||||
// Snapshots: list
|
||||
app.get('/api/snapshots', (req: Request, res: Response) => {
|
||||
try {
|
||||
const list = Array.from(snapshots.values()).map(s => ({
|
||||
name: s.name,
|
||||
elementCount: s.elements.length,
|
||||
createdAt: s.createdAt
|
||||
}));
|
||||
const projId = resolveTenantProject(req);
|
||||
const list = store.listSnapshots(projId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -978,8 +972,9 @@ app.get('/api/snapshots', (req: Request, res: Response) => {
|
||||
// Snapshots: get by name
|
||||
app.get('/api/snapshots/:name', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { name } = req.params;
|
||||
const snapshot = snapshots.get(name!);
|
||||
const snapshot = store.getSnapshot(name!, projId);
|
||||
|
||||
if (!snapshot) {
|
||||
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
|
||||
app.get('/health', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
elements_count: elements.size,
|
||||
elements_count: store.getElementCount(projId),
|
||||
websocket_clients: clients.size
|
||||
});
|
||||
});
|
||||
|
||||
// Sync status endpoint
|
||||
app.get('/api/sync/status', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
res.json({
|
||||
success: true,
|
||||
elementCount: elements.size,
|
||||
elementCount: store.getElementCount(projId),
|
||||
timestamp: new Date().toISOString(),
|
||||
memoryUsage: {
|
||||
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
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
// Server configuration
|
||||
const PORT = parseInt(process.env.CANVAS_PORT || process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || 'localhost';
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
logger.info(`POC server running on http://${HOST}:${PORT}`);
|
||||
export function startCanvasServer(): Promise<void> {
|
||||
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}`);
|
||||
});
|
||||
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;
|
||||
+15
-6
@@ -182,7 +182,8 @@ export type WebSocketMessageType =
|
||||
| 'mermaid_convert'
|
||||
| 'canvas_cleared'
|
||||
| 'export_image_request'
|
||||
| 'set_viewport';
|
||||
| 'set_viewport'
|
||||
| 'tenant_switched';
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
@@ -271,6 +272,16 @@ export interface SetViewportMessage extends WebSocketMessage {
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
// Tenant switched message
|
||||
export interface TenantSwitchedMessage extends WebSocketMessage {
|
||||
type: 'tenant_switched';
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_path: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Snapshot types
|
||||
export interface Snapshot {
|
||||
name: string;
|
||||
@@ -278,11 +289,9 @@ export interface Snapshot {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// In-memory storage for Excalidraw elements
|
||||
export const elements = new Map<string, ServerElement>();
|
||||
|
||||
// In-memory storage for snapshots
|
||||
export const snapshots = new Map<string, Snapshot>();
|
||||
// Storage is now handled by src/db.ts (SQLite).
|
||||
// The Map exports below are kept only for backward compatibility with
|
||||
// standalone server.ts usage; they are NOT used when the DB is active.
|
||||
|
||||
// Validation function for Excalidraw elements
|
||||
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
|
||||
|
||||
Reference in New Issue
Block a user