✨ 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
|
- name: Check for TypeScript errors
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
|
ci-status:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: false
|
||||||
|
name: CI Status Check
|
||||||
|
needs: [build-and-test, lint-check]
|
||||||
|
if: always()
|
||||||
|
permissions:
|
||||||
|
statuses: write
|
||||||
|
steps:
|
||||||
|
- name: Failed
|
||||||
|
id: failed
|
||||||
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||||
|
run: |
|
||||||
|
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
|
||||||
|
-d '{ "state" : "failure" , "context" : "github/ci-status-check" , "description" : "CI checks failed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||||
|
exit 1
|
||||||
|
- name: Success
|
||||||
|
if: steps.failed.conclusion == 'skipped'
|
||||||
|
run: |
|
||||||
|
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
|
||||||
|
-d '{ "state" : "success" , "context" : "github/ci-status-check" , "description" : "CI checks passed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: docker.io
|
||||||
IMAGE_NAME_MCP: ${{ github.repository }}
|
IMAGE_NAME_MCP: sanjibdevnath/mcp-excalidraw-local
|
||||||
IMAGE_NAME_CANVAS: ${{ github.repository }}-canvas
|
IMAGE_NAME_CANVAS: sanjibdevnath/mcp-excalidraw-local-canvas
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-mcp:
|
build-and-push-mcp:
|
||||||
@@ -20,7 +20,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -29,12 +28,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to Docker Hub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
username: ${{ github.actor }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata for MCP Server
|
- name: Extract metadata for MCP Server
|
||||||
id: meta
|
id: meta
|
||||||
@@ -55,19 +54,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
|
|
||||||
build-and-push-canvas:
|
build-and-push-canvas:
|
||||||
name: Build and Push Canvas Server Image
|
name: Build and Push Canvas Server Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -76,12 +74,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to Docker Hub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
username: ${{ github.actor }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata for Canvas Server
|
- name: Extract metadata for Canvas Server
|
||||||
id: meta
|
id: meta
|
||||||
@@ -102,39 +100,30 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.canvas
|
file: ./Dockerfile.canvas
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
|
|
||||||
test-docker-images:
|
test-docker-images:
|
||||||
name: Test Docker Images
|
name: Test Docker Images
|
||||||
needs: [build-and-push-mcp, build-and-push-canvas]
|
needs: [build-and-push-mcp, build-and-push-canvas]
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
username: ${{ github.actor }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Determine image tag
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "value=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "value=${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Test Canvas Server image
|
- name: Test Canvas Server image
|
||||||
run: |
|
run: |
|
||||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
|
docker pull ${{ env.IMAGE_NAME_CANVAS }}:latest
|
||||||
docker run -d -p 3000:3000 --name test-canvas ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
|
docker run -d -p 3000:3000 --name test-canvas ${{ env.IMAGE_NAME_CANVAS }}:latest
|
||||||
sleep 10
|
sleep 10
|
||||||
curl -f http://localhost:3000/health || exit 1
|
curl -f http://localhost:3000/health || exit 1
|
||||||
docker logs test-canvas
|
docker logs test-canvas
|
||||||
@@ -142,5 +131,29 @@ jobs:
|
|||||||
|
|
||||||
- name: Test MCP Server image
|
- name: Test MCP Server image
|
||||||
run: |
|
run: |
|
||||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}:${{ steps.tag.outputs.value }}
|
docker pull ${{ env.IMAGE_NAME_MCP }}:latest
|
||||||
echo "MCP Server image pulled successfully"
|
echo "MCP Server image pulled successfully"
|
||||||
|
|
||||||
|
docker-status:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: false
|
||||||
|
name: Docker Build Status Check
|
||||||
|
needs: [build-and-push-mcp, build-and-push-canvas]
|
||||||
|
if: always()
|
||||||
|
permissions:
|
||||||
|
statuses: write
|
||||||
|
steps:
|
||||||
|
- name: Failed
|
||||||
|
id: failed
|
||||||
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||||
|
run: |
|
||||||
|
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
|
||||||
|
-d '{ "state" : "failure" , "context" : "github/docker-build-check" , "description" : "Docker build failed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||||
|
exit 1
|
||||||
|
- name: Success
|
||||||
|
if: steps.failed.conclusion == 'skipped'
|
||||||
|
run: |
|
||||||
|
curl -X POST -H "Content-Type: application/json" -H "Authorization: token ${{ github.token }}" \
|
||||||
|
-d '{ "state" : "success" , "context" : "github/docker-build-check" , "description" : "Docker build passed", "target_url" : "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" }' \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- name: Check if version exists on NPM
|
- name: Check if version exists on NPM
|
||||||
id: check-version
|
id: check-version
|
||||||
run: |
|
run: |
|
||||||
if npm view mcp-excalidraw-server@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
|
if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
|
||||||
echo "exists=true" >> $GITHUB_OUTPUT
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
|
echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
|
||||||
else
|
else
|
||||||
@@ -82,14 +82,14 @@ jobs:
|
|||||||
- name: Create GitHub Release Assets
|
- name: Create GitHub Release Assets
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
run: |
|
run: |
|
||||||
tar -czf mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz dist/
|
tar -czf mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz dist/
|
||||||
|
|
||||||
- name: Upload Release Assets
|
- name: Upload Release Assets
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz
|
mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -103,4 +103,4 @@ jobs:
|
|||||||
- name: Success notification
|
- name: Success notification
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Package successfully published to NPM!"
|
echo "✅ Package successfully published to NPM!"
|
||||||
echo "View at: https://www.npmjs.com/package/mcp-excalidraw-server"
|
echo "View at: https://www.npmjs.com/package/@sanjibdevnath/mcp-excalidraw-local"
|
||||||
|
|||||||
+2
-1
@@ -18,4 +18,5 @@ public/dist/
|
|||||||
# Development artifacts
|
# Development artifacts
|
||||||
*.excalidraw
|
*.excalidraw
|
||||||
|
|
||||||
docs/
|
docs/*
|
||||||
|
!docs/screenshots/
|
||||||
+12
-23
@@ -1,58 +1,47 @@
|
|||||||
# Dockerfile for MCP Excalidraw Server
|
# Dockerfile for MCP Excalidraw Server
|
||||||
# This builds the MCP server only (core product for CI/CD and GHCR)
|
# Builds the MCP server with SQLite persistence
|
||||||
# The canvas server is optional and runs separately
|
|
||||||
|
|
||||||
# Stage 1: Build backend (TypeScript compilation)
|
# Stage 1: Build backend (TypeScript compilation + native modules)
|
||||||
FROM node:18-slim AS builder
|
FROM node:18-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
# Install all dependencies (including TypeScript compiler)
|
|
||||||
RUN npm ci && npm cache clean --force
|
|
||||||
|
|
||||||
# Copy backend source
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Compile TypeScript
|
|
||||||
RUN npm run build:server
|
RUN npm run build:server
|
||||||
|
|
||||||
# Stage 2: Production MCP Server
|
# Stage 2: Production MCP Server
|
||||||
FROM node:18-slim AS production
|
FROM node:18-slim AS production
|
||||||
|
|
||||||
# Create non-root user for security
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 --gid 1001 nodejs
|
adduser --system --uid 1001 --gid 1001 nodejs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||||
|
|
||||||
# Install only production dependencies
|
# Remove build tools after native modules are compiled
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||||
|
|
||||||
# Copy compiled backend (MCP server only)
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Set ownership to nodejs user
|
|
||||||
RUN chown -R nodejs:nodejs /app
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
# Set environment variables with defaults
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV EXPRESS_SERVER_URL=http://localhost:3000
|
ENV EXPRESS_SERVER_URL=http://localhost:3000
|
||||||
ENV ENABLE_CANVAS_SYNC=true
|
ENV ENABLE_CANVAS_SYNC=true
|
||||||
|
|
||||||
# Run MCP server (stdin/stdout protocol)
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
||||||
# Labels for metadata
|
LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/yctimlin/mcp_excalidraw"
|
LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents (with SQLite persistence & multi-tenancy)"
|
||||||
LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents"
|
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|||||||
+13
-33
@@ -1,82 +1,62 @@
|
|||||||
# Dockerfile for Canvas Server (Optional)
|
# Dockerfile for Canvas Server (Optional)
|
||||||
# This is for users who want to run the visual canvas UI
|
# Provides the web interface, REST API, and SQLite persistence
|
||||||
# The canvas server provides the web interface and REST API
|
|
||||||
|
|
||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM node:18-slim AS frontend-builder
|
FROM node:18-slim AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies for build)
|
|
||||||
RUN npm ci && npm cache clean --force
|
|
||||||
|
|
||||||
# Copy frontend source
|
|
||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
COPY vite.config.js ./
|
COPY vite.config.js ./
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
RUN npm run build:frontend
|
RUN npm run build:frontend
|
||||||
|
|
||||||
# Stage 2: Build backend (TypeScript compilation)
|
# Stage 2: Build backend (TypeScript compilation + native modules)
|
||||||
FROM node:18-slim AS backend-builder
|
FROM node:18-slim AS backend-builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
# Install all dependencies (including TypeScript compiler)
|
|
||||||
RUN npm ci && npm cache clean --force
|
|
||||||
|
|
||||||
# Copy backend source
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Compile TypeScript
|
|
||||||
RUN npm run build:server
|
RUN npm run build:server
|
||||||
|
|
||||||
# Stage 3: Production Canvas Server
|
# Stage 3: Production Canvas Server
|
||||||
FROM node:18-slim AS production
|
FROM node:18-slim AS production
|
||||||
|
|
||||||
# Create non-root user for security
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 --gid 1001 nodejs
|
adduser --system --uid 1001 --gid 1001 nodejs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||||
|
|
||||||
# Install only production dependencies
|
# Remove build tools after native modules are compiled
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||||
|
|
||||||
# Copy compiled backend from builder stage
|
|
||||||
COPY --from=backend-builder /app/dist ./dist
|
COPY --from=backend-builder /app/dist ./dist
|
||||||
|
|
||||||
# Copy built frontend from frontend-builder stage
|
|
||||||
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
|
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
|
||||||
|
|
||||||
# Set ownership to nodejs user
|
|
||||||
RUN chown -R nodejs:nodejs /app
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
# Set environment variables with defaults
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Run canvas server (web UI + REST API)
|
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|
||||||
# Labels for metadata
|
LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/yctimlin/mcp_excalidraw"
|
LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API (with SQLite persistence & multi-tenancy)"
|
||||||
LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API"
|
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|||||||
@@ -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/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/ci.yml)
|
||||||
[](https://github.com/yctimlin/mcp_excalidraw/actions/workflows/docker.yml)
|
[](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/docker.yml)
|
||||||
[](https://www.npmjs.com/package/mcp-excalidraw-server)
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
Run a live Excalidraw canvas and control it from AI agents. This repo provides:
|
A fully local, self-hosted Excalidraw MCP server with **SQLite persistence**, **multi-tenancy**, and **auto-sync** — designed to run entirely on your machine without depending on `excalidraw.com`.
|
||||||
|
|
||||||
- **MCP Server**: Connect via Model Context Protocol (Claude Desktop, Cursor, Codex CLI, etc.)
|
Run a live Excalidraw canvas and control it from any AI agent. This repo provides:
|
||||||
- **Agent Skill**: Portable skill for Claude Code, Codex CLI, and other skill-enabled agents
|
|
||||||
|
|
||||||
Keywords: Excalidraw agent skill, Excalidraw MCP server, AI diagramming, Claude Code skill, Codex CLI skill, Claude Desktop MCP, Cursor MCP, Mermaid to Excalidraw.
|
- **MCP Server**: 32 tools over stdio — works with any MCP-compatible client
|
||||||
|
- **Agent Skill**: Portable skill with workflow playbooks, cheatsheets, and helper scripts
|
||||||
|
- **Live Canvas**: Real-time Excalidraw UI synced via WebSocket
|
||||||
|
- **SQLite Persistence**: Elements survive restarts, with versioning and search
|
||||||
|
- **Multi-Tenancy**: Isolated canvases per workspace, auto-detected
|
||||||
|
|
||||||
## Demo
|
> **Fork notice:** This project is forked from [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw) and extends it with persistence, multi-workspace support, and numerous UX improvements. Full credit to the original author for the excellent foundation. See [What Changed From Upstream](#what-changed-from-upstream) for details.
|
||||||
|
|
||||||

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