# Directory Structure
```
├── .dockerignore
├── .github
│ └── workflows
│ ├── ci.yml
│ ├── docker.yml
│ └── npm-publish.yml
├── .gitignore
├── claude_desktop_config.json
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.canvas
├── frontend
│ ├── index.html
│ └── src
│ ├── App.tsx
│ ├── main.tsx
│ └── utils
│ └── mermaidConverter.ts
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── index.ts
│ ├── server.ts
│ ├── types.ts
│ └── utils
│ └── logger.ts
├── tsconfig.json
└── vite.config.js
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Dependencies
node_modules/
# Build artifacts
dist/
public/dist/
# Environment files
.env
# Logs
*.log
# Editor artifacts
.cursor/
.claude/
# Development artifacts
*.excalidraw
docs/
```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
build
*.log
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
coverage
.nyc_output
*.test.ts
*.spec.ts
# CI/CD
.github
# Documentation
*.md
!README.md
docs
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Environment
.env
.env.local
.env.*.local
# Misc
tmp
temp
*.tmp
```
--------------------------------------------------------------------------------
/claude_desktop_config.json:
--------------------------------------------------------------------------------
```json
{
"mcpServers": {
"mcp_excalidraw": {
"command": "npx",
"args": ["-y", "excalidraw-mcp"]
}
}
}
```
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
```typescript
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import '@excalidraw/excalidraw/index.css'
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
```
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
```javascript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: 'frontend',
plugins: [react()],
build: {
outDir: '../dist/frontend',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
import winston from 'winston';
const LOG_FILE_PATH = process.env.LOG_FILE_PATH || 'excalidraw.log';
const logger: winston.Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.uncolorize(),
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp'] }),
winston.format.printf(info => {
const extra = info.metadata && Object.keys(info.metadata).length
? ` ${JSON.stringify(info.metadata)}`
: '';
return `${info.timestamp} [${info.level}] ${info.message}${extra}`
})
),
transports: [
new winston.transports.Console({
level: 'warn', // only warn+error to stderr
stderrLevels: ['warn','error']
}),
new winston.transports.File({
filename: LOG_FILE_PATH, // all levels to file
level: 'debug'
})
]
});
export default logger;
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": false,
"checkJs": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmitOnError": false,
"preserveConstEnums": true,
"removeComments": false,
"types": ["node"]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"frontend",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.js",
"**/*.jsx"
],
"ts-node": {
"esm": true
}
}
```
--------------------------------------------------------------------------------
/frontend/src/utils/mermaidConverter.ts:
--------------------------------------------------------------------------------
```typescript
import { parseMermaidToExcalidraw, MermaidConfig } from '@excalidraw/mermaid-to-excalidraw';
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
import type { BinaryFiles } from '@excalidraw/excalidraw/types/types';
export interface MermaidConversionResult {
elements: readonly ExcalidrawElement[];
files?: BinaryFiles;
error?: string;
}
/**
* Converts a Mermaid diagram definition to Excalidraw elements
* This function needs to run in the browser context as it requires DOM access
*/
export const convertMermaidToExcalidraw = async (
mermaidDefinition: string,
config?: MermaidConfig
): Promise<MermaidConversionResult> => {
try {
// Parse the Mermaid diagram to Excalidraw elements
const result = await parseMermaidToExcalidraw(mermaidDefinition, config);
return {
elements: result.elements,
files: result.files,
};
} catch (error) {
console.error('Error converting Mermaid to Excalidraw:', error);
return {
elements: [],
error: error instanceof Error ? error.message : String(error),
};
}
};
/**
* Default Mermaid configuration for Excalidraw conversion
*/
export const DEFAULT_MERMAID_CONFIG: MermaidConfig = {
startOnLoad: false,
flowchart: {
curve: 'linear',
},
themeVariables: {
fontSize: '20px',
},
maxEdges: 500,
maxTextSize: 50000,
};
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Dockerfile for MCP Excalidraw Server
# This builds the MCP server only (core product for CI/CD and GHCR)
# The canvas server is optional and runs separately
# Stage 1: Build backend (TypeScript compilation)
FROM node:18-slim AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including TypeScript compiler)
RUN npm ci && npm cache clean --force
# Copy backend source
COPY src ./src
COPY tsconfig.json ./
# Compile TypeScript
RUN npm run build:server
# Stage 2: Production MCP Server
FROM node:18-slim AS production
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 --gid 1001 nodejs
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy compiled backend (MCP server only)
COPY --from=builder /app/dist ./dist
# Set ownership to nodejs user
RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Set environment variables with defaults
ENV NODE_ENV=production
ENV EXPRESS_SERVER_URL=http://localhost:3000
ENV ENABLE_CANVAS_SYNC=true
# Run MCP server (stdin/stdout protocol)
CMD ["node", "dist/index.js"]
# Labels for metadata
LABEL org.opencontainers.image.source="https://github.com/yctimlin/mcp_excalidraw"
LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents"
LABEL org.opencontainers.image.licenses="MIT"
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
version: '3.8'
# Docker Compose for MCP Excalidraw
#
# Usage scenarios:
# 1. Canvas only: docker-compose up canvas
# 2. MCP only: docker-compose up mcp (requires canvas running elsewhere)
# 3. Both: docker-compose --profile full up
#
# Most common: Run canvas locally, MCP via Claude Desktop config
services:
# Canvas server (optional) - Visual UI and REST API
canvas:
build:
context: .
dockerfile: Dockerfile.canvas
image: mcp-excalidraw-canvas:latest
container_name: mcp-excalidraw-canvas
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
- DEBUG=false
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- mcp-network
# MCP server - Core product (typically run via Claude Desktop, not docker-compose)
# This is here for testing or special deployment scenarios
mcp:
build:
context: .
dockerfile: Dockerfile
image: mcp-excalidraw:latest
container_name: mcp-excalidraw-mcp
stdin_open: true
tty: true
environment:
- NODE_ENV=production
- EXPRESS_SERVER_URL=http://canvas:3000
- ENABLE_CANVAS_SYNC=true
- DEBUG=false
depends_on:
canvas:
condition: service_healthy
networks:
- mcp-network
profiles:
- full
networks:
mcp-network:
driver: bridge
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
build-and-test:
name: Build and Type Check
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run type-check
- name: Build project
run: npm run build
- name: Check build artifacts
run: |
echo "Checking if build artifacts exist..."
test -f dist/index.js || (echo "dist/index.js not found" && exit 1)
test -f dist/server.js || (echo "dist/server.js not found" && exit 1)
test -d dist/frontend || (echo "dist/frontend not found" && exit 1)
echo "All build artifacts present!"
- name: Upload build artifacts
if: matrix.node-version == '20.x'
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
dist/
retention-days: 7
lint-check:
name: Lint Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check for TypeScript errors
run: npm run type-check
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "mcp-excalidraw-server",
"version": "1.0.2",
"description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management",
"main": "dist/index.js",
"type": "module",
"bin": {
"mcp-excalidraw-server": "dist/index.js"
},
"scripts": {
"start": "npm run build:server && node dist/index.js",
"canvas": "npm run build:server && node dist/server.js",
"build": "npm run build:frontend && npm run build:server",
"build:frontend": "vite build",
"build:server": "npx tsc",
"build:types": "npx tsc --emitDeclarationOnly",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:server": "npx tsc --watch",
"production": "npm run build && npm run canvas",
"prepublishOnly": "npm run build",
"type-check": "npx tsc --noEmit"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.0",
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
"@modelcontextprotocol/sdk": "latest",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"mermaid": "^11.12.1",
"node-fetch": "^3.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"winston": "^3.11.0",
"ws": "^8.14.2",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.19.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^4.6.0",
"concurrently": "^9.2.0",
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"keywords": [
"mcp",
"mcp-server",
"excalidraw",
"model-context-protocol",
"ai",
"drawing",
"diagrams",
"canvas",
"real-time",
"websocket",
"visualization",
"claude",
"ai-tools"
],
"author": {
"name": "yctimlin",
"email": "[email protected]"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yctimlin/mcp_excalidraw.git"
},
"homepage": "https://github.com/yctimlin/mcp_excalidraw#readme",
"bugs": {
"url": "https://github.com/yctimlin/mcp_excalidraw/issues"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"files": [
"src/**/*",
"dist/**/*",
"*.d.ts",
"README.md",
"LICENSE"
]
}
```
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
```yaml
name: Publish to NPM
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g., latest, beta, next)'
required: true
default: 'latest'
jobs:
publish:
name: Publish to NPM Registry
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run type-check
- name: Build project
run: npm run build
- name: Verify build artifacts
run: |
echo "Verifying build artifacts..."
test -f dist/index.js || (echo "ERROR: dist/index.js not found" && exit 1)
test -f dist/server.js || (echo "ERROR: dist/server.js not found" && exit 1)
test -d dist/frontend || (echo "ERROR: dist/frontend not found" && exit 1)
echo "All required artifacts present!"
- name: Get package version
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Check if version exists on NPM
id: check-version
run: |
if npm view mcp-excalidraw-server@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} does not exist on NPM"
fi
- name: Publish to NPM (Release)
if: github.event_name == 'release' && steps.check-version.outputs.exists == 'false'
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to NPM (Manual)
if: github.event_name == 'workflow_dispatch' && steps.check-version.outputs.exists == 'false'
run: npm publish --tag ${{ github.event.inputs.tag }} --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Skip publishing (version exists)
if: steps.check-version.outputs.exists == 'true'
run: |
echo "⚠️ Skipping publish - version ${{ steps.package-version.outputs.version }} already exists on NPM"
echo "Please bump the version in package.json before publishing"
- name: Create GitHub Release Assets
if: github.event_name == 'release'
run: |
tar -czf mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz dist/
- name: Upload Release Assets
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
with:
files: |
mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify:
name: Publish Notification
needs: publish
runs-on: ubuntu-latest
if: success()
steps:
- name: Success notification
run: |
echo "✅ Package successfully published to NPM!"
echo "View at: https://www.npmjs.com/package/mcp-excalidraw-server"
```
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
```yaml
name: Docker Build & Push
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME_MCP: ${{ github.repository }}
IMAGE_NAME_CANVAS: ${{ github.repository }}-canvas
jobs:
build-and-push-mcp:
name: Build and Push MCP Server Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for MCP Server
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push MCP Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-and-push-canvas:
name: Build and Push Canvas Server Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Canvas Server
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Canvas Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.canvas
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
test-docker-images:
name: Test Docker Images
needs: [build-and-push-mcp, build-and-push-canvas]
runs-on: ubuntu-latest
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "value=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
else
echo "value=${{ github.ref_name }}" >> $GITHUB_OUTPUT
fi
- name: Test Canvas Server image
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
docker run -d -p 3000:3000 --name test-canvas ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
sleep 10
curl -f http://localhost:3000/health || exit 1
docker logs test-canvas
docker stop test-canvas
- name: Test MCP Server image
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}:${{ steps.tag.outputs.value }}
echo "MCP Server image pulled successfully"
```
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw POC - Backend API Integration</title>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background-color: #f5f5f5;
}
.header {
background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.header h1 {
margin: 0;
color: #333;
font-size: 24px;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-success {
background-color: #28a745;
color: white;
}
.btn-success:hover {
background-color: #218838;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-connected {
background-color: #28a745;
}
.status-disconnected {
background-color: #dc3545;
}
.canvas-container {
height: calc(100vh - 80px);
width: 100%;
position: relative;
}
.api-panel {
position: fixed;
right: 20px;
top: 100px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 20px;
width: 300px;
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
.api-panel h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 18px;
}
.api-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
label {
font-weight: 500;
color: #555;
font-size: 14px;
}
input, select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-row {
display: flex;
gap: 10px;
}
.form-row .form-group {
flex: 1;
}
.toggle-panel {
position: fixed;
right: 20px;
top: 60px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
padding: 10px;
cursor: pointer;
font-size: 14px;
z-index: 1001;
}
.api-panel.hidden {
display: none;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 16px;
border-radius: 4px;
color: white;
font-size: 14px;
z-index: 1002;
animation: slideIn 0.3s ease;
}
.notification.success {
background-color: #28a745;
}
.notification.error {
background-color: #dc3545;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.element-count {
font-size: 14px;
color: #666;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
font-size: 16px;
color: #666;
}
.loading-content {
text-align: center;
}
.loading-content div:first-child {
margin-bottom: 10px;
}
/* Sync Controls Styles */
.sync-controls {
display: flex;
align-items: center;
gap: 10px;
}
.btn-loading {
position: relative;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #ffffff40;
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 5px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.sync-status {
font-size: 12px;
min-width: 100px;
}
.sync-success {
color: #4caf50;
}
.sync-error {
color: #f44336;
}
.sync-time {
color: #666;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
export interface ExcalidrawElementBase {
id: string;
type: ExcalidrawElementType;
x: number;
y: number;
width?: number;
height?: number;
angle?: number;
strokeColor?: string;
backgroundColor?: string;
fillStyle?: string;
strokeWidth?: number;
strokeStyle?: string;
roughness?: number;
opacity?: number;
groupIds?: string[];
frameId?: string | null;
roundness?: {
type: number;
value?: number;
} | null;
seed?: number;
versionNonce?: number;
isDeleted?: boolean;
locked?: boolean;
link?: string | null;
customData?: Record<string, any> | null;
boundElements?: readonly ExcalidrawBoundElement[] | null;
updated?: number;
containerId?: string | null;
}
export interface ExcalidrawTextElement extends ExcalidrawElementBase {
type: 'text';
text: string;
fontSize?: number;
fontFamily?: number;
textAlign?: string;
verticalAlign?: string;
baseline?: number;
lineHeight?: number;
}
export interface ExcalidrawRectangleElement extends ExcalidrawElementBase {
type: 'rectangle';
width: number;
height: number;
}
export interface ExcalidrawEllipseElement extends ExcalidrawElementBase {
type: 'ellipse';
width: number;
height: number;
}
export interface ExcalidrawDiamondElement extends ExcalidrawElementBase {
type: 'diamond';
width: number;
height: number;
}
export interface ExcalidrawArrowElement extends ExcalidrawElementBase {
type: 'arrow';
points: readonly [number, number][];
lastCommittedPoint?: readonly [number, number] | null;
startBinding?: ExcalidrawBinding | null;
endBinding?: ExcalidrawBinding | null;
startArrowhead?: string | null;
endArrowhead?: string | null;
}
export interface ExcalidrawLineElement extends ExcalidrawElementBase {
type: 'line';
points: readonly [number, number][];
lastCommittedPoint?: readonly [number, number] | null;
startBinding?: ExcalidrawBinding | null;
endBinding?: ExcalidrawBinding | null;
}
export interface ExcalidrawFreedrawElement extends ExcalidrawElementBase {
type: 'freedraw';
points: readonly [number, number][];
pressures?: readonly number[];
simulatePressure?: boolean;
lastCommittedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement
| ExcalidrawArrowElement
| ExcalidrawLineElement
| ExcalidrawFreedrawElement;
export interface ExcalidrawBoundElement {
id: string;
type: 'text' | 'arrow';
}
export interface ExcalidrawBinding {
elementId: string;
focus: number;
gap: number;
fixedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label';
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
RECTANGLE: 'rectangle',
ELLIPSE: 'ellipse',
DIAMOND: 'diamond',
ARROW: 'arrow',
TEXT: 'text',
LABEL: 'label',
FREEDRAW: 'freedraw',
LINE: 'line'
} as const;
// Server-side element with metadata
export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
id: string;
type: ExcalidrawElementType;
createdAt?: string;
updatedAt?: string;
version?: number;
syncedAt?: string;
source?: string;
syncTimestamp?: string;
text?: string;
fontSize?: number;
fontFamily?: string | number;
label?: {
text: string;
};
}
// API Response types
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface ElementsResponse extends ApiResponse {
elements: ServerElement[];
count: number;
}
export interface ElementResponse extends ApiResponse {
element: ServerElement;
}
export interface SyncResponse extends ApiResponse {
count: number;
syncedAt: string;
beforeCount: number;
afterCount: number;
}
// WebSocket message types
export interface WebSocketMessage {
type: WebSocketMessageType;
[key: string]: any;
}
export type WebSocketMessageType =
| 'initial_elements'
| 'element_created'
| 'element_updated'
| 'element_deleted'
| 'elements_batch_created'
| 'elements_synced'
| 'sync_status'
| 'mermaid_convert';
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
elements: ServerElement[];
}
export interface ElementCreatedMessage extends WebSocketMessage {
type: 'element_created';
element: ServerElement;
}
export interface ElementUpdatedMessage extends WebSocketMessage {
type: 'element_updated';
element: ServerElement;
}
export interface ElementDeletedMessage extends WebSocketMessage {
type: 'element_deleted';
elementId: string;
}
export interface BatchCreatedMessage extends WebSocketMessage {
type: 'elements_batch_created';
elements: ServerElement[];
}
export interface SyncStatusMessage extends WebSocketMessage {
type: 'sync_status';
elementCount: number;
timestamp: string;
}
export interface MermaidConvertMessage extends WebSocketMessage {
type: 'mermaid_convert';
mermaidDiagram: string;
config?: MermaidConfig;
timestamp: string;
}
// Mermaid conversion types
export interface MermaidConfig {
startOnLoad?: boolean;
flowchart?: {
curve?: 'linear' | 'basis';
};
themeVariables?: {
fontSize?: string;
};
maxEdges?: number;
maxTextSize?: number;
}
export interface MermaidConversionRequest {
mermaidDiagram: string;
config?: MermaidConfig;
}
export interface MermaidConversionResponse extends ApiResponse {
elements: ServerElement[];
files?: any;
count: number;
}
// In-memory storage for Excalidraw elements
export const elements = new Map<string, ServerElement>();
// Validation function for Excalidraw elements
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];
const hasRequiredFields = requiredFields.every(field => field in element);
if (!hasRequiredFields) {
throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
}
if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type as ExcalidrawElementType)) {
throw new Error(`Invalid element type: ${element.type}`);
}
return true;
}
// Helper function to generate unique IDs
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import logger from './utils/logger.js';
import {
elements,
generateId,
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
WebSocketMessage,
ElementCreatedMessage,
ElementUpdatedMessage,
ElementDeletedMessage,
BatchCreatedMessage,
SyncStatusMessage,
InitialElementsMessage
} from './types.js';
import { z } from 'zod';
import WebSocket from 'ws';
// Load environment variables
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
// Middleware
app.use(cors());
app.use(express.json());
// Serve static files from the build directory
const staticDir = path.join(__dirname, '../dist');
app.use(express.static(staticDir));
// Also serve frontend assets
app.use(express.static(path.join(__dirname, '../dist/frontend')));
// WebSocket connections
const clients = new Set<WebSocket>();
// Broadcast to all connected clients
function broadcast(message: WebSocketMessage): void {
const data = JSON.stringify(message);
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
// WebSocket connection handling
wss.on('connection', (ws: WebSocket) => {
clients.add(ws);
logger.info('New WebSocket connection established');
// Send current elements to new client
const initialMessage: InitialElementsMessage = {
type: 'initial_elements',
elements: Array.from(elements.values())
};
ws.send(JSON.stringify(initialMessage));
// Send sync status to new client
const syncMessage: SyncStatusMessage = {
type: 'sync_status',
elementCount: elements.size,
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(syncMessage));
ws.on('close', () => {
clients.delete(ws);
logger.info('WebSocket connection closed');
});
ws.on('error', (error) => {
logger.error('WebSocket error:', error);
clients.delete(ws);
});
});
// Schema validation
const CreateElementSchema = z.object({
id: z.string().optional(), // Allow passing ID for MCP sync
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
label: z.object({
text: z.string()
}).optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional()
});
const UpdateElementSchema = z.object({
id: z.string(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
x: z.number().optional(),
y: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
label: z.object({
text: z.string()
}).optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional()
});
// API Routes
// Get all elements
app.get('/api/elements', (req: Request, res: Response) => {
try {
const elementsArray = Array.from(elements.values());
res.json({
success: true,
elements: elementsArray,
count: elementsArray.length
});
} catch (error) {
logger.error('Error fetching elements:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Create new element
app.post('/api/elements', (req: Request, res: Response) => {
try {
const params = CreateElementSchema.parse(req.body);
logger.info('Creating element via API', { type: params.type });
// Prioritize passed ID (for MCP sync), otherwise generate new ID
const id = params.id || generateId();
const element: ServerElement = {
id,
...params,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
elements.set(id, element);
// Broadcast to all connected clients
const message: ElementCreatedMessage = {
type: 'element_created',
element: element
};
broadcast(message);
res.json({
success: true,
element: element
});
} catch (error) {
logger.error('Error creating element:', error);
res.status(400).json({
success: false,
error: (error as Error).message
});
}
});
// Update element
app.put('/api/elements/:id', (req: Request, res: Response) => {
try {
const { id } = req.params;
const updates = UpdateElementSchema.parse({ id, ...req.body });
if (!id) {
return res.status(400).json({
success: false,
error: 'Element ID is required'
});
}
const existingElement = elements.get(id);
if (!existingElement) {
return res.status(404).json({
success: false,
error: `Element with ID ${id} not found`
});
}
const updatedElement: ServerElement = {
...existingElement,
...updates,
updatedAt: new Date().toISOString(),
version: (existingElement.version || 0) + 1
};
elements.set(id, updatedElement);
// Broadcast to all connected clients
const message: ElementUpdatedMessage = {
type: 'element_updated',
element: updatedElement
};
broadcast(message);
res.json({
success: true,
element: updatedElement
});
} catch (error) {
logger.error('Error updating element:', error);
res.status(400).json({
success: false,
error: (error as Error).message
});
}
});
// Delete element
app.delete('/api/elements/:id', (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
error: 'Element ID is required'
});
}
if (!elements.has(id)) {
return res.status(404).json({
success: false,
error: `Element with ID ${id} not found`
});
}
elements.delete(id);
// Broadcast to all connected clients
const message: ElementDeletedMessage = {
type: 'element_deleted',
elementId: id!
};
broadcast(message);
res.json({
success: true,
message: `Element ${id} deleted successfully`
});
} catch (error) {
logger.error('Error deleting element:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Query elements with filters
app.get('/api/elements/search', (req: Request, res: Response) => {
try {
const { type, ...filters } = req.query;
let results = Array.from(elements.values());
// Filter by type if specified
if (type && typeof type === 'string') {
results = results.filter(element => element.type === type);
}
// Apply additional filters
if (Object.keys(filters).length > 0) {
results = results.filter(element => {
return Object.entries(filters).every(([key, value]) => {
return (element as any)[key] === value;
});
});
}
res.json({
success: true,
elements: results,
count: results.length
});
} catch (error) {
logger.error('Error querying elements:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Get element by ID
app.get('/api/elements/:id', (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
error: 'Element ID is required'
});
}
const element = elements.get(id);
if (!element) {
return res.status(404).json({
success: false,
error: `Element with ID ${id} not found`
});
}
res.json({
success: true,
element: element
});
} catch (error) {
logger.error('Error fetching element:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Batch create elements
app.post('/api/elements/batch', (req: Request, res: Response) => {
try {
const { elements: elementsToCreate } = req.body;
if (!Array.isArray(elementsToCreate)) {
return res.status(400).json({
success: false,
error: 'Expected an array of elements'
});
}
const createdElements: ServerElement[] = [];
elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData);
const id = generateId();
const element: ServerElement = {
id,
...params,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
elements.set(id, element);
createdElements.push(element);
});
// Broadcast to all connected clients
const message: BatchCreatedMessage = {
type: 'elements_batch_created',
elements: createdElements
};
broadcast(message);
res.json({
success: true,
elements: createdElements,
count: createdElements.length
});
} catch (error) {
logger.error('Error batch creating elements:', error);
res.status(400).json({
success: false,
error: (error as Error).message
});
}
});
// Convert Mermaid diagram to Excalidraw elements
app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
try {
const { mermaidDiagram, config } = req.body;
if (!mermaidDiagram || typeof mermaidDiagram !== 'string') {
return res.status(400).json({
success: false,
error: 'Mermaid diagram definition is required'
});
}
logger.info('Received Mermaid conversion request', {
diagramLength: mermaidDiagram.length,
hasConfig: !!config
});
// Broadcast to all WebSocket clients to process the Mermaid diagram
broadcast({
type: 'mermaid_convert',
mermaidDiagram,
config: config || {},
timestamp: new Date().toISOString()
});
// Return the diagram for frontend processing
res.json({
success: true,
mermaidDiagram,
config: config || {},
message: 'Mermaid diagram sent to frontend for conversion.'
});
} catch (error) {
logger.error('Error processing Mermaid diagram:', error);
res.status(400).json({
success: false,
error: (error as Error).message
});
}
});
// Sync elements from frontend (overwrite sync)
app.post('/api/elements/sync', (req: Request, res: Response) => {
try {
const { elements: frontendElements, timestamp } = req.body;
logger.info(`Sync request received: ${frontendElements.length} elements`, {
timestamp,
elementCount: frontendElements.length
});
// Validate input data
if (!Array.isArray(frontendElements)) {
return res.status(400).json({
success: false,
error: 'Expected elements to be an array'
});
}
// Record element count before sync
const beforeCount = elements.size;
// 1. Clear existing memory storage
elements.clear();
logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
// 2. Batch write new data
let successCount = 0;
const processedElements: ServerElement[] = [];
frontendElements.forEach((element: any, index: number) => {
try {
// Ensure element has ID, generate one if missing
const elementId = element.id || generateId();
// Add server metadata
const processedElement: ServerElement = {
...element,
id: elementId,
syncedAt: new Date().toISOString(),
source: 'frontend_sync',
syncTimestamp: timestamp,
version: 1
};
// Store to memory
elements.set(elementId, processedElement);
processedElements.push(processedElement);
successCount++;
} catch (elementError) {
logger.warn(`Failed to process element ${index}:`, elementError);
}
});
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
// 3. Broadcast sync event to all WebSocket clients
broadcast({
type: 'elements_synced',
count: successCount,
timestamp: new Date().toISOString(),
source: 'manual_sync'
});
// 4. Return sync results
res.json({
success: true,
message: `Successfully synced ${successCount} elements`,
count: successCount,
syncedAt: new Date().toISOString(),
beforeCount,
afterCount: elements.size
});
} catch (error) {
logger.error('Sync error:', error);
res.status(500).json({
success: false,
error: (error as Error).message,
details: 'Internal server error during sync operation'
});
}
});
// Serve the frontend
app.get('/', (req: Request, res: Response) => {
const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
res.sendFile(htmlFile, (err) => {
if (err) {
logger.error('Error serving frontend:', err);
res.status(404).send('Frontend not found. Please run "npm run build" first.');
}
});
});
// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
elements_count: elements.size,
websocket_clients: clients.size
});
});
// Sync status endpoint
app.get('/api/sync/status', (req: Request, res: Response) => {
res.json({
success: true,
elementCount: elements.size,
timestamp: new Date().toISOString(),
memoryUsage: {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
},
websocketClients: clients.size
});
});
// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: 'Internal server error'
});
});
// Start server
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || 'localhost';
server.listen(PORT, HOST, () => {
logger.info(`POC server running on http://${HOST}:${PORT}`);
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
});
export default app;
```
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
```typescript
import React, { useState, useEffect, useRef } from 'react'
import {
Excalidraw,
convertToExcalidrawElements,
CaptureUpdateAction,
ExcalidrawImperativeAPI
} from '@excalidraw/excalidraw'
import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
import type { MermaidConfig } from '@excalidraw/mermaid-to-excalidraw'
// Type definitions
type ExcalidrawAPIRefValue = ExcalidrawImperativeAPI;
interface ServerElement {
id: string;
type: string;
x: number;
y: number;
width?: number;
height?: number;
backgroundColor?: string;
strokeColor?: string;
strokeWidth?: number;
roughness?: number;
opacity?: number;
text?: string;
fontSize?: number;
fontFamily?: string | number;
label?: {
text: string;
};
createdAt?: string;
updatedAt?: string;
version?: number;
syncedAt?: string;
source?: string;
syncTimestamp?: string;
boundElements?: any[] | null;
containerId?: string | null;
locked?: boolean;
}
interface WebSocketMessage {
type: string;
element?: ServerElement;
elements?: ServerElement[];
elementId?: string;
count?: number;
timestamp?: string;
source?: string;
mermaidDiagram?: string;
config?: MermaidConfig;
}
interface ApiResponse {
success: boolean;
elements?: ServerElement[];
element?: ServerElement;
count?: number;
error?: string;
message?: string;
}
type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
// Helper function to clean elements for Excalidraw
const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
const {
createdAt,
updatedAt,
version,
syncedAt,
source,
syncTimestamp,
...cleanElement
} = element;
return cleanElement;
}
// Helper function to validate and fix element binding data
const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
const elementMap = new Map(elements.map(el => [el.id!, el]));
return elements.map(element => {
const fixedElement = { ...element };
// Validate and fix boundElements
if (fixedElement.boundElements) {
if (Array.isArray(fixedElement.boundElements)) {
fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
// Ensure binding has required properties
if (!binding || typeof binding !== 'object') return false;
if (!binding.id || !binding.type) return false;
// Ensure the referenced element exists
const referencedElement = elementMap.get(binding.id);
if (!referencedElement) return false;
// Validate binding type
if (!['text', 'arrow'].includes(binding.type)) return false;
return true;
});
// Remove boundElements if empty
if (fixedElement.boundElements.length === 0) {
fixedElement.boundElements = null;
}
} else {
// Invalid boundElements format, set to null
fixedElement.boundElements = null;
}
}
// Validate and fix containerId
if (fixedElement.containerId) {
const containerElement = elementMap.get(fixedElement.containerId);
if (!containerElement) {
// Container doesn't exist, remove containerId
fixedElement.containerId = null;
}
}
return fixedElement;
});
}
function App(): JSX.Element {
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
const [isConnected, setIsConnected] = useState<boolean>(false)
const websocketRef = useRef<WebSocket | null>(null)
// Sync state management
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
// WebSocket connection
useEffect(() => {
connectWebSocket()
return () => {
if (websocketRef.current) {
websocketRef.current.close()
}
}
}, [])
// Load existing elements when Excalidraw API becomes available
useEffect(() => {
if (excalidrawAPI) {
loadExistingElements()
// Ensure WebSocket is connected for real-time updates
if (!isConnected) {
connectWebSocket()
}
}
}, [excalidrawAPI, isConnected])
const loadExistingElements = async (): Promise<void> => {
try {
const response = await fetch('/api/elements')
const result: ApiResponse = await response.json()
if (result.success && result.elements && result.elements.length > 0) {
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
excalidrawAPI?.updateScene({ elements: convertedElements })
}
} catch (error) {
console.error('Error loading existing elements:', error)
}
}
const connectWebSocket = (): void => {
if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}`
websocketRef.current = new WebSocket(wsUrl)
websocketRef.current.onopen = () => {
setIsConnected(true)
if (excalidrawAPI) {
setTimeout(loadExistingElements, 100)
}
}
websocketRef.current.onmessage = (event: MessageEvent) => {
try {
const data: WebSocketMessage = JSON.parse(event.data)
handleWebSocketMessage(data)
} catch (error) {
console.error('Error parsing WebSocket message:', error, event.data)
}
}
websocketRef.current.onclose = (event: CloseEvent) => {
setIsConnected(false)
// Reconnect after 3 seconds if not a clean close
if (event.code !== 1000) {
setTimeout(connectWebSocket, 3000)
}
}
websocketRef.current.onerror = (error: Event) => {
console.error('WebSocket error:', error)
setIsConnected(false)
}
}
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
if (!excalidrawAPI) {
return
}
try {
const currentElements = excalidrawAPI.getSceneElements()
console.log('Current elements:', currentElements);
switch (data.type) {
case 'initial_elements':
if (data.elements && data.elements.length > 0) {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertToExcalidrawElements(validatedElements)
excalidrawAPI.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'element_created':
if (data.element) {
const cleanedNewElement = cleanElementForExcalidraw(data.element)
const newElement = convertToExcalidrawElements([cleanedNewElement])
const updatedElementsAfterCreate = [...currentElements, ...newElement]
excalidrawAPI.updateScene({
elements: updatedElementsAfterCreate,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'element_updated':
if (data.element) {
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0]
const updatedElements = currentElements.map(el =>
el.id === data.element!.id ? convertedUpdatedElement : el
)
excalidrawAPI.updateScene({
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'element_deleted':
if (data.elementId) {
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
excalidrawAPI.updateScene({
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'elements_batch_created':
if (data.elements) {
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
const batchElements = convertToExcalidrawElements(cleanedBatchElements)
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
excalidrawAPI.updateScene({
elements: updatedElementsAfterBatch,
captureUpdate: CaptureUpdateAction.NEVER
})
}
break
case 'elements_synced':
console.log(`Sync confirmed by server: ${data.count} elements`)
// Sync confirmation already handled by HTTP response
break
case 'sync_status':
console.log(`Server sync status: ${data.count} elements`)
break
case 'mermaid_convert':
console.log('Received Mermaid conversion request from MCP')
if (data.mermaidDiagram) {
try {
const result = await convertMermaidToExcalidraw(data.mermaidDiagram, data.config || DEFAULT_MERMAID_CONFIG)
if (result.error) {
console.error('Mermaid conversion error:', result.error)
return
}
if (result.elements && result.elements.length > 0) {
const convertedElements = convertToExcalidrawElements(result.elements, { regenerateIds: false })
excalidrawAPI.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
if (result.files) {
excalidrawAPI.addFiles(Object.values(result.files))
}
console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements')
// Sync to backend automatically after creating elements
await syncToBackend()
}
} catch (error) {
console.error('Error converting Mermaid diagram from WebSocket:', error)
}
}
break
default:
console.log('Unknown WebSocket message type:', data.type)
}
} catch (error) {
console.error('Error processing WebSocket message:', error, data)
}
}
// Data format conversion for backend
const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => {
return {
...element
} as ServerElement
}
// Format sync time display
const formatSyncTime = (time: Date | null): string => {
if (!time) return ''
return time.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// Main sync function
const syncToBackend = async (): Promise<void> => {
if (!excalidrawAPI) {
console.warn('Excalidraw API not available')
return
}
setSyncStatus('syncing')
try {
// 1. Get current elements
const currentElements = excalidrawAPI.getSceneElements()
console.log(`Syncing ${currentElements.length} elements to backend`)
// Filter out deleted elements
const activeElements = currentElements.filter(el => !el.isDeleted)
// 3. Convert to backend format
const backendElements = activeElements.map(convertToBackendFormat)
// 4. Send to backend
const response = await fetch('/api/elements/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
elements: backendElements,
timestamp: new Date().toISOString()
})
})
if (response.ok) {
const result: ApiResponse = await response.json()
setSyncStatus('success')
setLastSyncTime(new Date())
console.log(`Sync successful: ${result.count} elements synced`)
// Reset status after 2 seconds
setTimeout(() => setSyncStatus('idle'), 2000)
} else {
const error: ApiResponse = await response.json()
setSyncStatus('error')
console.error('Sync failed:', error.error)
}
} catch (error) {
setSyncStatus('error')
console.error('Sync error:', error)
}
}
const clearCanvas = async (): Promise<void> => {
if (excalidrawAPI) {
try {
// Get all current elements and delete them from backend
const response = await fetch('/api/elements')
const result: ApiResponse = await response.json()
if (result.success && result.elements) {
const deletePromises = result.elements.map(element =>
fetch(`/api/elements/${element.id}`, { method: 'DELETE' })
)
await Promise.all(deletePromises)
}
// Clear the frontend canvas
excalidrawAPI.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
} catch (error) {
console.error('Error clearing canvas:', error)
// Still clear frontend even if backend fails
excalidrawAPI.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
}
}
}
return (
<div className="app">
{/* Header */}
<div className="header">
<h1>Excalidraw Canvas</h1>
<div className="controls">
<div className="status">
<div className={`status-dot ${isConnected ? 'status-connected' : 'status-disconnected'}`}></div>
<span>{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
{/* Sync Controls */}
<div className="sync-controls">
<button
className={`btn-primary ${syncStatus === 'syncing' ? 'btn-loading' : ''}`}
onClick={syncToBackend}
disabled={syncStatus === 'syncing' || !excalidrawAPI}
>
{syncStatus === 'syncing' && <span className="spinner"></span>}
{syncStatus === 'syncing' ? 'Syncing...' : 'Sync to Backend'}
</button>
{/* Sync Status */}
<div className="sync-status">
{syncStatus === 'success' && (
<span className="sync-success">✅ Synced</span>
)}
{syncStatus === 'error' && (
<span className="sync-error">❌ Sync Failed</span>
)}
{lastSyncTime && syncStatus === 'idle' && (
<span className="sync-time">
Last sync: {formatSyncTime(lastSyncTime)}
</span>
)}
</div>
</div>
<button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button>
</div>
</div>
{/* Canvas Container */}
<div className="canvas-container">
<Excalidraw
excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)}
initialData={{
elements: [],
appState: {
theme: 'light',
viewBackgroundColor: '#ffffff'
}
}}
/>
</div>
</div>
)
}
export default App
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
// Disable colors to prevent ANSI color codes from breaking JSON parsing
process.env.NODE_DISABLE_COLORS = '1';
process.env.NO_COLOR = '1';
import { fileURLToPath } from "url";
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
CallToolRequest,
Tool
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import dotenv from 'dotenv';
import logger from './utils/logger.js';
import {
generateId,
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
validateElement
} from './types.js';
import fetch from 'node-fetch';
// Load environment variables
dotenv.config();
// Express server configuration
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
// API Response types
interface ApiResponse {
success: boolean;
element?: ServerElement;
elements?: ServerElement[];
message?: string;
error?: string;
count?: number;
}
interface SyncResponse {
element?: ServerElement;
elements?: ServerElement[];
}
// Helper functions to sync with Express server (canvas)
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
if (!ENABLE_CANVAS_SYNC) {
logger.debug('Canvas sync disabled, skipping');
return null;
}
try {
let url: string;
let options: any;
switch (operation) {
case 'create':
url = `${EXPRESS_SERVER_URL}/api/elements`;
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
};
break;
case 'update':
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
};
break;
case 'delete':
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = { method: 'DELETE' };
break;
case 'batch_create':
url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ elements: data })
};
break;
default:
logger.warn(`Unknown sync operation: ${operation}`);
return null;
}
logger.debug(`Syncing to canvas: ${operation}`, { url, data });
const response = await fetch(url, options);
// Parse JSON response regardless of HTTP status
const result = await response.json() as ApiResponse;
if (!response.ok) {
logger.warn(`Canvas sync returned error status: ${response.status}`, result);
throw new Error(result.error || `Canvas sync failed: ${response.status} ${response.statusText}`);
}
logger.debug(`Canvas sync successful: ${operation}`, result);
return result as SyncResponse;
} catch (error) {
logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
// Don't throw - we want MCP operations to work even if canvas is unavailable
return null;
}
}
// Helper to sync element creation to canvas
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
const result = await syncToCanvas('create', elementData);
return result?.element || elementData;
}
// Helper to sync element update to canvas
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
const result = await syncToCanvas('update', elementData);
return result?.element || null;
}
// Helper to sync element deletion to canvas
async function deleteElementOnCanvas(elementId: string): Promise<any> {
const result = await syncToCanvas('delete', { id: elementId });
return result;
}
// Helper to sync batch creation to canvas
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
const result = await syncToCanvas('batch_create', elementsData);
return result?.elements || elementsData;
}
// Helper to fetch element from canvas
async function getElementFromCanvas(elementId: string): Promise<ServerElement | null> {
if (!ENABLE_CANVAS_SYNC) {
logger.debug('Canvas sync disabled, skipping fetch');
return null;
}
try {
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`);
if (!response.ok) {
logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
return null;
}
const data = await response.json() as { element?: ServerElement };
return data.element || null;
} catch (error) {
logger.error('Error fetching element from canvas:', error);
return null;
}
}
// In-memory storage for scene state
interface SceneState {
theme: string;
viewport: { x: number; y: number; zoom: number };
selectedElements: Set<string>;
groups: Map<string, string[]>;
}
const sceneState: SceneState = {
theme: 'light',
viewport: { x: 0, y: 0, zoom: 1 },
selectedElements: new Set(),
groups: new Map()
};
// Schema definitions using zod
const ElementSchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
points: z.array(z.object({ x: z.number(), y: z.number() })).optional(),
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional()
});
const ElementIdSchema = z.object({
id: z.string()
});
const ElementIdsSchema = z.object({
elementIds: z.array(z.string())
});
const GroupIdSchema = z.object({
groupId: z.string()
});
const AlignElementsSchema = z.object({
elementIds: z.array(z.string()),
alignment: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
});
const DistributeElementsSchema = z.object({
elementIds: z.array(z.string()),
direction: z.enum(['horizontal', 'vertical'])
});
const QuerySchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
filter: z.record(z.any()).optional()
});
const ResourceSchema = z.object({
resource: z.enum(['scene', 'library', 'theme', 'elements'])
});
// Tool definitions
const tools: Tool[] = [
{
name: 'create_element',
description: 'Create a new Excalidraw element',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['type', 'x', 'y']
}
},
{
name: 'update_element',
description: 'Update an existing Excalidraw element',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['id']
}
},
{
name: 'delete_element',
description: 'Delete an Excalidraw element',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' }
},
required: ['id']
}
},
{
name: 'query_elements',
description: 'Query Excalidraw elements with optional filters',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
filter: {
type: 'object',
additionalProperties: true
}
}
}
},
{
name: 'get_resource',
description: 'Get an Excalidraw resource',
inputSchema: {
type: 'object',
properties: {
resource: {
type: 'string',
enum: ['scene', 'library', 'theme', 'elements']
}
},
required: ['resource']
}
},
{
name: 'group_elements',
description: 'Group multiple elements together',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'ungroup_elements',
description: 'Ungroup a group of elements',
inputSchema: {
type: 'object',
properties: {
groupId: { type: 'string' }
},
required: ['groupId']
}
},
{
name: 'align_elements',
description: 'Align elements to a specific position',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
alignment: {
type: 'string',
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
}
},
required: ['elementIds', 'alignment']
}
},
{
name: 'distribute_elements',
description: 'Distribute elements evenly',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
direction: {
type: 'string',
enum: ['horizontal', 'vertical']
}
},
required: ['elementIds', 'direction']
}
},
{
name: 'lock_elements',
description: 'Lock elements to prevent modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'unlock_elements',
description: 'Unlock elements to allow modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'create_from_mermaid',
description: 'Convert a Mermaid diagram to Excalidraw elements and render them on the canvas',
inputSchema: {
type: 'object',
properties: {
mermaidDiagram: {
type: 'string',
description: 'The Mermaid diagram definition (e.g., "graph TD; A-->B; B-->C;")'
},
config: {
type: 'object',
description: 'Optional Mermaid configuration',
properties: {
startOnLoad: { type: 'boolean' },
flowchart: {
type: 'object',
properties: {
curve: { type: 'string', enum: ['linear', 'basis'] }
}
},
themeVariables: {
type: 'object',
properties: {
fontSize: { type: 'string' }
}
},
maxEdges: { type: 'number' },
maxTextSize: { type: 'number' }
}
}
},
required: ['mermaidDiagram']
}
},
{
name: 'batch_create_elements',
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
inputSchema: {
type: 'object',
properties: {
elements: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['type', 'x', 'y']
}
}
},
required: ['elements']
}
}
];
// Initialize MCP server
const server = new Server(
{
name: "mcp-excalidraw-server",
version: "1.0.2",
description: "Advanced MCP server for Excalidraw with real-time canvas"
},
{
capabilities: {
tools: Object.fromEntries(tools.map(tool => [tool.name, {
description: tool.description,
inputSchema: tool.inputSchema
}]))
}
}
);
// Helper function to convert text property to label format for Excalidraw
function convertTextToLabel(element: ServerElement): ServerElement {
const { text, ...rest } = element;
if (text) {
// For standalone text elements, keep text as direct property
if (element.type === 'text') {
return element; // Keep text as direct property
}
// For other elements (rectangle, ellipse, diamond), convert to label format
return {
...rest,
label: { text }
} as ServerElement;
}
return element;
}
// Set up request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
try {
const { name, arguments: args } = request.params;
logger.info(`Handling tool call: ${name}`);
switch (name) {
case 'create_element': {
const params = ElementSchema.parse(args);
logger.info('Creating element via MCP', { type: params.type });
const id = generateId();
const element: ServerElement = {
id,
...params,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(element);
// Create element directly on HTTP server (no local storage)
const canvasElement = await createElementOnCanvas(excalidrawElement);
if (!canvasElement) {
throw new Error('Failed to create element: HTTP server unavailable');
}
logger.info('Element created via MCP and synced to canvas', {
id: excalidrawElement.id,
type: excalidrawElement.type,
synced: !!canvasElement
});
return {
content: [{
type: 'text',
text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
}]
};
}
case 'update_element': {
const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args);
const { id, ...updates } = params;
if (!id) throw new Error('Element ID is required');
// Build update payload with timestamp and version increment
const updatePayload: Partial<ServerElement> & { id: string } = {
id,
...updates,
updatedAt: new Date().toISOString()
};
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
// Update element directly on HTTP server (no local storage)
const canvasElement = await updateElementOnCanvas(excalidrawElement);
if (!canvasElement) {
throw new Error('Failed to update element: HTTP server unavailable or element not found');
}
logger.info('Element updated via MCP and synced to canvas', {
id: excalidrawElement.id,
synced: !!canvasElement
});
return {
content: [{
type: 'text',
text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
}]
};
}
case 'delete_element': {
const params = ElementIdSchema.parse(args);
const { id } = params;
// Delete element directly on HTTP server (no local storage)
const canvasResult = await deleteElementOnCanvas(id);
if (!canvasResult || !(canvasResult as ApiResponse).success) {
throw new Error('Failed to delete element: HTTP server unavailable or element not found');
}
const result = { id, deleted: true, syncedToCanvas: true };
logger.info('Element deleted via MCP and synced to canvas', result);
return {
content: [{
type: 'text',
text: `Element deleted successfully!\n\n${JSON.stringify(result, null, 2)}\n\n✅ Synced to canvas`
}]
};
}
case 'query_elements': {
const params = QuerySchema.parse(args || {});
const { type, filter } = params;
try {
// Build query parameters
const queryParams = new URLSearchParams();
if (type) queryParams.set('type', type);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
queryParams.set(key, String(value));
});
}
// Query elements from HTTP server
const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
const data = await response.json() as ApiResponse;
const results = data.elements || [];
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to query elements: ${(error as Error).message}`);
}
}
case 'get_resource': {
const params = ResourceSchema.parse(args);
const { resource } = params;
logger.info('Getting resource', { resource });
let result: any;
switch (resource) {
case 'scene':
result = {
theme: sceneState.theme,
viewport: sceneState.viewport,
selectedElements: Array.from(sceneState.selectedElements)
};
break;
case 'library':
case 'elements':
try {
// Get elements from HTTP server
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
const data = await response.json() as ApiResponse;
result = {
elements: data.elements || []
};
} catch (error) {
throw new Error(`Failed to get elements: ${(error as Error).message}`);
}
break;
case 'theme':
result = {
theme: sceneState.theme
};
break;
default:
throw new Error(`Unknown resource: ${resource}`);
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'group_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
try {
const groupId = generateId();
sceneState.groups.set(groupId, elementIds);
// Update elements on canvas with proper error handling
// Fetch existing groups and append new groupId to preserve multi-group membership
const updatePromises = elementIds.map(async (id) => {
const element = await getElementFromCanvas(id);
const existingGroups = element?.groupIds || [];
const updatedGroupIds = [...existingGroups, groupId];
return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
});
const results = await Promise.all(updatePromises);
const successCount = results.filter(result => result).length;
if (successCount === 0) {
sceneState.groups.delete(groupId); // Rollback local state
throw new Error('Failed to group any elements: HTTP server unavailable');
}
logger.info('Grouping elements', { elementIds, groupId, successCount });
const result = { groupId, elementIds, successCount };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to group elements: ${(error as Error).message}`);
}
}
case 'ungroup_elements': {
const params = GroupIdSchema.parse(args);
const { groupId } = params;
if (!sceneState.groups.has(groupId)) {
throw new Error(`Group ${groupId} not found`);
}
try {
const elementIds = sceneState.groups.get(groupId);
sceneState.groups.delete(groupId);
// Update elements on canvas, removing only this specific groupId
const updatePromises = (elementIds ?? []).map(async (id) => {
// Fetch current element to get existing groupIds
const element = await getElementFromCanvas(id);
if (!element) {
logger.warn(`Element ${id} not found on canvas, skipping ungroup`);
return null;
}
// Remove only the specific groupId, preserve others
const updatedGroupIds = (element.groupIds || []).filter(gid => gid !== groupId);
return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
});
const results = await Promise.all(updatePromises);
const successCount = results.filter(result => result !== null).length;
if (successCount === 0) {
logger.warn('Failed to ungroup any elements: HTTP server unavailable or elements not found');
}
logger.info('Ungrouping elements', { groupId, elementIds, successCount });
const result = { groupId, ungrouped: true, elementIds, successCount };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to ungroup elements: ${(error as Error).message}`);
}
}
case 'align_elements': {
const params = AlignElementsSchema.parse(args);
const { elementIds, alignment } = params;
// Implementation would align elements based on the specified alignment
logger.info('Aligning elements', { elementIds, alignment });
const result = { aligned: true, elementIds, alignment };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'distribute_elements': {
const params = DistributeElementsSchema.parse(args);
const { elementIds, direction } = params;
// Implementation would distribute elements based on the specified direction
logger.info('Distributing elements', { elementIds, direction });
const result = { distributed: true, elementIds, direction };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'lock_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
try {
// Lock elements through HTTP API updates
const updatePromises = elementIds.map(async (id) => {
return await updateElementOnCanvas({ id, locked: true });
});
const results = await Promise.all(updatePromises);
const successCount = results.filter(result => result).length;
if (successCount === 0) {
throw new Error('Failed to lock any elements: HTTP server unavailable');
}
const result = { locked: true, elementIds, successCount };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to lock elements: ${(error as Error).message}`);
}
}
case 'unlock_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
try {
// Unlock elements through HTTP API updates
const updatePromises = elementIds.map(async (id) => {
return await updateElementOnCanvas({ id, locked: false });
});
const results = await Promise.all(updatePromises);
const successCount = results.filter(result => result).length;
if (successCount === 0) {
throw new Error('Failed to unlock any elements: HTTP server unavailable');
}
const result = { unlocked: true, elementIds, successCount };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to unlock elements: ${(error as Error).message}`);
}
}
case 'create_from_mermaid': {
const params = z.object({
mermaidDiagram: z.string(),
config: z.object({
startOnLoad: z.boolean().optional(),
flowchart: z.object({
curve: z.enum(['linear', 'basis']).optional()
}).optional(),
themeVariables: z.object({
fontSize: z.string().optional()
}).optional(),
maxEdges: z.number().optional(),
maxTextSize: z.number().optional()
}).optional()
}).parse(args);
logger.info('Creating Excalidraw elements from Mermaid diagram via MCP', {
diagramLength: params.mermaidDiagram.length,
hasConfig: !!params.config
});
try {
// Send the Mermaid diagram to the frontend via the API
// The frontend will use mermaid-to-excalidraw to convert it
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mermaidDiagram: params.mermaidDiagram,
config: params.config
})
});
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
const result = await response.json() as ApiResponse;
logger.info('Mermaid diagram sent to frontend for conversion', {
success: result.success
});
return {
content: [{
type: 'text',
text: `Mermaid diagram sent for conversion!\n\n${JSON.stringify(result, null, 2)}\n\n⚠️ Note: The actual conversion happens in the frontend canvas with DOM access. Open the canvas at ${EXPRESS_SERVER_URL} to see the diagram rendered.`
}]
};
} catch (error) {
throw new Error(`Failed to process Mermaid diagram: ${(error as Error).message}`);
}
}
case 'batch_create_elements': {
const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
logger.info('Batch creating elements via MCP', { count: params.elements.length });
const createdElements: ServerElement[] = [];
// Create each element with unique ID
for (const elementData of params.elements) {
const id = generateId();
const element: ServerElement = {
id,
...elementData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(element);
createdElements.push(excalidrawElement);
}
// Create all elements directly on HTTP server (no local storage)
const canvasElements = await batchCreateElementsOnCanvas(createdElements);
if (!canvasElements) {
throw new Error('Failed to batch create elements: HTTP server unavailable');
}
const result = {
success: true,
elements: canvasElements,
count: canvasElements.length,
syncedToCanvas: true
};
logger.info('Batch elements created via MCP and synced to canvas', {
count: result.count,
synced: result.syncedToCanvas
});
return {
content: [{
type: 'text',
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}`
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
return {
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
isError: true
};
}
});
// Set up request handler for listing available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
logger.info('Listing available tools');
return { tools };
});
// Start server with transport based on mode
async function runServer(): Promise<void> {
try {
logger.info('Starting Excalidraw MCP server...');
const transportMode = process.env.MCP_TRANSPORT_MODE || 'stdio';
let transport;
if (transportMode === 'http') {
const port = parseInt(process.env.PORT || '3000', 10);
const host = process.env.HOST || 'localhost';
logger.info(`Starting HTTP server on ${host}:${port}`);
// Here you would create an HTTP transport
// This is a placeholder - actual HTTP transport implementation would need to be added
transport = new StdioServerTransport(); // Fallback to stdio for now
} else {
// Default to stdio transport
transport = new StdioServerTransport();
}
// Add a debug message before connecting
logger.debug('Connecting to transport...');
await server.connect(transport);
logger.info(`Excalidraw MCP server running on ${transportMode}`);
// Keep the process running
process.stdin.resume();
} catch (error) {
logger.error('Error starting server:', error);
process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`);
process.exit(1);
}
}
// Add global error handlers
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught exception:', error);
process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
setTimeout(() => process.exit(1), 1000);
});
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.error('Unhandled promise rejection:', reason);
process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
setTimeout(() => process.exit(1), 1000);
});
// For testing and debugging purposes
if (process.env.DEBUG === 'true') {
logger.debug('Debug mode enabled');
}
// Start the server if this file is run directly
if (fileURLToPath(import.meta.url) === process.argv[1]) {
runServer().catch(error => {
logger.error('Failed to start server:', error);
process.exit(1);
});
}
export default runServer;
```