# 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:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules/
3 |
4 | # Build artifacts
5 | dist/
6 | public/dist/
7 |
8 | # Environment files
9 | .env
10 |
11 | # Logs
12 | *.log
13 |
14 | # Editor artifacts
15 | .cursor/
16 | .claude/
17 |
18 | # Development artifacts
19 | *.excalidraw
20 |
21 | docs/
```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules
3 | npm-debug.log*
4 | yarn-debug.log*
5 | yarn-error.log*
6 |
7 | # Build outputs
8 | dist
9 | build
10 | *.log
11 |
12 | # Git
13 | .git
14 | .gitignore
15 | .gitattributes
16 |
17 | # IDE
18 | .vscode
19 | .idea
20 | *.swp
21 | *.swo
22 | *~
23 |
24 | # OS
25 | .DS_Store
26 | Thumbs.db
27 |
28 | # Testing
29 | coverage
30 | .nyc_output
31 | *.test.ts
32 | *.spec.ts
33 |
34 | # CI/CD
35 | .github
36 |
37 | # Documentation
38 | *.md
39 | !README.md
40 | docs
41 |
42 | # Docker
43 | Dockerfile*
44 | docker-compose*.yml
45 | .dockerignore
46 |
47 | # Environment
48 | .env
49 | .env.local
50 | .env.*.local
51 |
52 | # Misc
53 | tmp
54 | temp
55 | *.tmp
56 |
```
--------------------------------------------------------------------------------
/claude_desktop_config.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "mcpServers": {
3 | "mcp_excalidraw": {
4 | "command": "npx",
5 | "args": ["-y", "excalidraw-mcp"]
6 | }
7 | }
8 | }
```
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.tsx'
4 | import '@excalidraw/excalidraw/index.css'
5 |
6 | const rootElement = document.getElementById('root');
7 | if (!rootElement) {
8 | throw new Error('Root element not found');
9 | }
10 |
11 | ReactDOM.createRoot(rootElement).render(
12 | <React.StrictMode>
13 | <App />
14 | </React.StrictMode>,
15 | )
```
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
```javascript
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig({
5 | root: 'frontend',
6 | plugins: [react()],
7 | build: {
8 | outDir: '../dist/frontend',
9 | emptyOutDir: true,
10 | },
11 | server: {
12 | port: 5173,
13 | proxy: {
14 | '/api': {
15 | target: 'http://localhost:3000',
16 | changeOrigin: true,
17 | },
18 | '/health': {
19 | target: 'http://localhost:3000',
20 | changeOrigin: true,
21 | },
22 | },
23 | },
24 | })
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
1 | import winston from 'winston';
2 |
3 | const LOG_FILE_PATH = process.env.LOG_FILE_PATH || 'excalidraw.log';
4 |
5 | const logger: winston.Logger = winston.createLogger({
6 | level: process.env.LOG_LEVEL || 'info',
7 |
8 | format: winston.format.combine(
9 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
10 | winston.format.uncolorize(),
11 | winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp'] }),
12 | winston.format.printf(info => {
13 | const extra = info.metadata && Object.keys(info.metadata).length
14 | ? ` ${JSON.stringify(info.metadata)}`
15 | : '';
16 | return `${info.timestamp} [${info.level}] ${info.message}${extra}`
17 | })
18 | ),
19 |
20 | transports: [
21 | new winston.transports.Console({
22 | level: 'warn', // only warn+error to stderr
23 | stderrLevels: ['warn','error']
24 | }),
25 |
26 | new winston.transports.File({
27 | filename: LOG_FILE_PATH, // all levels to file
28 | level: 'debug'
29 | })
30 | ]
31 | });
32 |
33 | export default logger;
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "allowJs": false,
7 | "checkJs": false,
8 | "declaration": true,
9 | "declarationMap": true,
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "rootDir": "./src",
13 | "strict": true,
14 | "noImplicitAny": true,
15 | "strictNullChecks": true,
16 | "strictFunctionTypes": true,
17 | "strictBindCallApply": true,
18 | "strictPropertyInitialization": true,
19 | "noImplicitThis": true,
20 | "noImplicitReturns": false,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedIndexedAccess": true,
23 | "noImplicitOverride": true,
24 | "esModuleInterop": true,
25 | "allowSyntheticDefaultImports": true,
26 | "forceConsistentCasingInFileNames": true,
27 | "skipLibCheck": true,
28 | "resolveJsonModule": true,
29 | "isolatedModules": true,
30 | "noEmitOnError": false,
31 | "preserveConstEnums": true,
32 | "removeComments": false,
33 | "types": ["node"]
34 | },
35 | "include": [
36 | "src/**/*.ts"
37 | ],
38 | "exclude": [
39 | "node_modules",
40 | "dist",
41 | "frontend",
42 | "**/*.test.ts",
43 | "**/*.spec.ts",
44 | "**/*.js",
45 | "**/*.jsx"
46 | ],
47 | "ts-node": {
48 | "esm": true
49 | }
50 | }
```
--------------------------------------------------------------------------------
/frontend/src/utils/mermaidConverter.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { parseMermaidToExcalidraw, MermaidConfig } from '@excalidraw/mermaid-to-excalidraw';
2 | import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
3 | import type { BinaryFiles } from '@excalidraw/excalidraw/types/types';
4 |
5 | export interface MermaidConversionResult {
6 | elements: readonly ExcalidrawElement[];
7 | files?: BinaryFiles;
8 | error?: string;
9 | }
10 |
11 | /**
12 | * Converts a Mermaid diagram definition to Excalidraw elements
13 | * This function needs to run in the browser context as it requires DOM access
14 | */
15 | export const convertMermaidToExcalidraw = async (
16 | mermaidDefinition: string,
17 | config?: MermaidConfig
18 | ): Promise<MermaidConversionResult> => {
19 | try {
20 | // Parse the Mermaid diagram to Excalidraw elements
21 | const result = await parseMermaidToExcalidraw(mermaidDefinition, config);
22 |
23 | return {
24 | elements: result.elements,
25 | files: result.files,
26 | };
27 | } catch (error) {
28 | console.error('Error converting Mermaid to Excalidraw:', error);
29 | return {
30 | elements: [],
31 | error: error instanceof Error ? error.message : String(error),
32 | };
33 | }
34 | };
35 |
36 | /**
37 | * Default Mermaid configuration for Excalidraw conversion
38 | */
39 | export const DEFAULT_MERMAID_CONFIG: MermaidConfig = {
40 | startOnLoad: false,
41 | flowchart: {
42 | curve: 'linear',
43 | },
44 | themeVariables: {
45 | fontSize: '20px',
46 | },
47 | maxEdges: 500,
48 | maxTextSize: 50000,
49 | };
50 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Dockerfile for MCP Excalidraw Server
2 | # This builds the MCP server only (core product for CI/CD and GHCR)
3 | # The canvas server is optional and runs separately
4 |
5 | # Stage 1: Build backend (TypeScript compilation)
6 | FROM node:18-slim AS builder
7 |
8 | WORKDIR /app
9 |
10 | # Copy package files
11 | COPY package*.json ./
12 |
13 | # Install all dependencies (including TypeScript compiler)
14 | RUN npm ci && npm cache clean --force
15 |
16 | # Copy backend source
17 | COPY src ./src
18 | COPY tsconfig.json ./
19 |
20 | # Compile TypeScript
21 | RUN npm run build:server
22 |
23 | # Stage 2: Production MCP Server
24 | FROM node:18-slim AS production
25 |
26 | # Create non-root user for security
27 | RUN addgroup --system --gid 1001 nodejs && \
28 | adduser --system --uid 1001 --gid 1001 nodejs
29 |
30 | WORKDIR /app
31 |
32 | # Copy package files
33 | COPY package*.json ./
34 |
35 | # Install only production dependencies
36 | RUN npm ci --only=production && npm cache clean --force
37 |
38 | # Copy compiled backend (MCP server only)
39 | COPY --from=builder /app/dist ./dist
40 |
41 | # Set ownership to nodejs user
42 | RUN chown -R nodejs:nodejs /app
43 |
44 | # Switch to non-root user
45 | USER nodejs
46 |
47 | # Set environment variables with defaults
48 | ENV NODE_ENV=production
49 | ENV EXPRESS_SERVER_URL=http://localhost:3000
50 | ENV ENABLE_CANVAS_SYNC=true
51 |
52 | # Run MCP server (stdin/stdout protocol)
53 | CMD ["node", "dist/index.js"]
54 |
55 | # Labels for metadata
56 | LABEL org.opencontainers.image.source="https://github.com/yctimlin/mcp_excalidraw"
57 | LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents"
58 | LABEL org.opencontainers.image.licenses="MIT"
59 |
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
1 | version: '3.8'
2 |
3 | # Docker Compose for MCP Excalidraw
4 | #
5 | # Usage scenarios:
6 | # 1. Canvas only: docker-compose up canvas
7 | # 2. MCP only: docker-compose up mcp (requires canvas running elsewhere)
8 | # 3. Both: docker-compose --profile full up
9 | #
10 | # Most common: Run canvas locally, MCP via Claude Desktop config
11 |
12 | services:
13 | # Canvas server (optional) - Visual UI and REST API
14 | canvas:
15 | build:
16 | context: .
17 | dockerfile: Dockerfile.canvas
18 | image: mcp-excalidraw-canvas:latest
19 | container_name: mcp-excalidraw-canvas
20 | ports:
21 | - "3000:3000"
22 | environment:
23 | - NODE_ENV=production
24 | - PORT=3000
25 | - HOST=0.0.0.0
26 | - DEBUG=false
27 | restart: unless-stopped
28 | healthcheck:
29 | test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
30 | interval: 30s
31 | timeout: 10s
32 | retries: 3
33 | start_period: 40s
34 | networks:
35 | - mcp-network
36 |
37 | # MCP server - Core product (typically run via Claude Desktop, not docker-compose)
38 | # This is here for testing or special deployment scenarios
39 | mcp:
40 | build:
41 | context: .
42 | dockerfile: Dockerfile
43 | image: mcp-excalidraw:latest
44 | container_name: mcp-excalidraw-mcp
45 | stdin_open: true
46 | tty: true
47 | environment:
48 | - NODE_ENV=production
49 | - EXPRESS_SERVER_URL=http://canvas:3000
50 | - ENABLE_CANVAS_SYNC=true
51 | - DEBUG=false
52 | depends_on:
53 | canvas:
54 | condition: service_healthy
55 | networks:
56 | - mcp-network
57 | profiles:
58 | - full
59 |
60 | networks:
61 | mcp-network:
62 | driver: bridge
63 |
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main, develop ]
6 | pull_request:
7 | branches: [ main, develop ]
8 |
9 | jobs:
10 | build-and-test:
11 | name: Build and Type Check
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | node-version: [18.x, 20.x, 22.x]
17 |
18 | steps:
19 | - name: Checkout code
20 | uses: actions/checkout@v4
21 |
22 | - name: Setup Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 | cache: 'npm'
27 |
28 | - name: Install dependencies
29 | run: npm ci
30 |
31 | - name: Run TypeScript type check
32 | run: npm run type-check
33 |
34 | - name: Build project
35 | run: npm run build
36 |
37 | - name: Check build artifacts
38 | run: |
39 | echo "Checking if build artifacts exist..."
40 | test -f dist/index.js || (echo "dist/index.js not found" && exit 1)
41 | test -f dist/server.js || (echo "dist/server.js not found" && exit 1)
42 | test -d dist/frontend || (echo "dist/frontend not found" && exit 1)
43 | echo "All build artifacts present!"
44 |
45 | - name: Upload build artifacts
46 | if: matrix.node-version == '20.x'
47 | uses: actions/upload-artifact@v4
48 | with:
49 | name: build-artifacts
50 | path: |
51 | dist/
52 | retention-days: 7
53 |
54 | lint-check:
55 | name: Lint Check
56 | runs-on: ubuntu-latest
57 |
58 | steps:
59 | - name: Checkout code
60 | uses: actions/checkout@v4
61 |
62 | - name: Setup Node.js
63 | uses: actions/setup-node@v4
64 | with:
65 | node-version: '20.x'
66 | cache: 'npm'
67 |
68 | - name: Install dependencies
69 | run: npm ci
70 |
71 | - name: Check for TypeScript errors
72 | run: npm run type-check
73 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp-excalidraw-server",
3 | "version": "1.0.2",
4 | "description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management",
5 | "main": "dist/index.js",
6 | "type": "module",
7 | "bin": {
8 | "mcp-excalidraw-server": "dist/index.js"
9 | },
10 | "scripts": {
11 | "start": "npm run build:server && node dist/index.js",
12 | "canvas": "npm run build:server && node dist/server.js",
13 | "build": "npm run build:frontend && npm run build:server",
14 | "build:frontend": "vite build",
15 | "build:server": "npx tsc",
16 | "build:types": "npx tsc --emitDeclarationOnly",
17 | "dev": "concurrently \"npm run dev:server\" \"vite\"",
18 | "dev:server": "npx tsc --watch",
19 | "production": "npm run build && npm run canvas",
20 | "prepublishOnly": "npm run build",
21 | "type-check": "npx tsc --noEmit"
22 | },
23 | "dependencies": {
24 | "@excalidraw/excalidraw": "^0.18.0",
25 | "@excalidraw/mermaid-to-excalidraw": "^1.1.3",
26 | "@modelcontextprotocol/sdk": "latest",
27 | "cors": "^2.8.5",
28 | "dotenv": "^16.3.1",
29 | "express": "^4.18.2",
30 | "mermaid": "^11.12.1",
31 | "node-fetch": "^3.3.2",
32 | "react": "^18.3.1",
33 | "react-dom": "^18.3.1",
34 | "winston": "^3.11.0",
35 | "ws": "^8.14.2",
36 | "zod": "^3.22.4",
37 | "zod-to-json-schema": "^3.22.3"
38 | },
39 | "devDependencies": {
40 | "@types/cors": "^2.8.17",
41 | "@types/express": "^4.17.21",
42 | "@types/node": "^20.19.7",
43 | "@types/react": "^18.3.3",
44 | "@types/react-dom": "^18.3.0",
45 | "@types/ws": "^8.5.10",
46 | "@vitejs/plugin-react": "^4.6.0",
47 | "concurrently": "^9.2.0",
48 | "typescript": "^5.8.3",
49 | "vite": "^6.3.5"
50 | },
51 | "keywords": [
52 | "mcp",
53 | "mcp-server",
54 | "excalidraw",
55 | "model-context-protocol",
56 | "ai",
57 | "drawing",
58 | "diagrams",
59 | "canvas",
60 | "real-time",
61 | "websocket",
62 | "visualization",
63 | "claude",
64 | "ai-tools"
65 | ],
66 | "author": {
67 | "name": "yctimlin",
68 | "email": "[email protected]"
69 | },
70 | "license": "MIT",
71 | "repository": {
72 | "type": "git",
73 | "url": "https://github.com/yctimlin/mcp_excalidraw.git"
74 | },
75 | "homepage": "https://github.com/yctimlin/mcp_excalidraw#readme",
76 | "bugs": {
77 | "url": "https://github.com/yctimlin/mcp_excalidraw/issues"
78 | },
79 | "engines": {
80 | "node": ">=18.0.0"
81 | },
82 | "publishConfig": {
83 | "access": "public",
84 | "registry": "https://registry.npmjs.org/"
85 | },
86 | "files": [
87 | "src/**/*",
88 | "dist/**/*",
89 | "*.d.ts",
90 | "README.md",
91 | "LICENSE"
92 | ]
93 | }
94 |
```
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Publish to NPM
2 |
3 | on:
4 | release:
5 | types: [published]
6 | workflow_dispatch:
7 | inputs:
8 | tag:
9 | description: 'Tag to publish (e.g., latest, beta, next)'
10 | required: true
11 | default: 'latest'
12 |
13 | jobs:
14 | publish:
15 | name: Publish to NPM Registry
16 | runs-on: ubuntu-latest
17 | permissions:
18 | contents: read
19 | id-token: write
20 |
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v4
24 |
25 | - name: Setup Node.js
26 | uses: actions/setup-node@v4
27 | with:
28 | node-version: '20.x'
29 | registry-url: 'https://registry.npmjs.org'
30 | cache: 'npm'
31 |
32 | - name: Install dependencies
33 | run: npm ci
34 |
35 | - name: Run type check
36 | run: npm run type-check
37 |
38 | - name: Build project
39 | run: npm run build
40 |
41 | - name: Verify build artifacts
42 | run: |
43 | echo "Verifying build artifacts..."
44 | test -f dist/index.js || (echo "ERROR: dist/index.js not found" && exit 1)
45 | test -f dist/server.js || (echo "ERROR: dist/server.js not found" && exit 1)
46 | test -d dist/frontend || (echo "ERROR: dist/frontend not found" && exit 1)
47 | echo "All required artifacts present!"
48 |
49 | - name: Get package version
50 | id: package-version
51 | run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
52 |
53 | - name: Check if version exists on NPM
54 | id: check-version
55 | run: |
56 | if npm view mcp-excalidraw-server@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
57 | echo "exists=true" >> $GITHUB_OUTPUT
58 | echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
59 | else
60 | echo "exists=false" >> $GITHUB_OUTPUT
61 | echo "Version ${{ steps.package-version.outputs.version }} does not exist on NPM"
62 | fi
63 |
64 | - name: Publish to NPM (Release)
65 | if: github.event_name == 'release' && steps.check-version.outputs.exists == 'false'
66 | run: npm publish --provenance --access public
67 | env:
68 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
69 |
70 | - name: Publish to NPM (Manual)
71 | if: github.event_name == 'workflow_dispatch' && steps.check-version.outputs.exists == 'false'
72 | run: npm publish --tag ${{ github.event.inputs.tag }} --provenance --access public
73 | env:
74 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
75 |
76 | - name: Skip publishing (version exists)
77 | if: steps.check-version.outputs.exists == 'true'
78 | run: |
79 | echo "⚠️ Skipping publish - version ${{ steps.package-version.outputs.version }} already exists on NPM"
80 | echo "Please bump the version in package.json before publishing"
81 |
82 | - name: Create GitHub Release Assets
83 | if: github.event_name == 'release'
84 | run: |
85 | tar -czf mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz dist/
86 |
87 | - name: Upload Release Assets
88 | if: github.event_name == 'release'
89 | uses: softprops/action-gh-release@v1
90 | with:
91 | files: |
92 | mcp-excalidraw-server-${{ steps.package-version.outputs.version }}.tar.gz
93 | env:
94 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
95 |
96 | notify:
97 | name: Publish Notification
98 | needs: publish
99 | runs-on: ubuntu-latest
100 | if: success()
101 |
102 | steps:
103 | - name: Success notification
104 | run: |
105 | echo "✅ Package successfully published to NPM!"
106 | echo "View at: https://www.npmjs.com/package/mcp-excalidraw-server"
107 |
```
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Docker Build & Push
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | tags:
7 | - 'v*.*.*'
8 | pull_request:
9 | branches: [ main ]
10 | workflow_dispatch:
11 |
12 | env:
13 | REGISTRY: ghcr.io
14 | IMAGE_NAME_MCP: ${{ github.repository }}
15 | IMAGE_NAME_CANVAS: ${{ github.repository }}-canvas
16 |
17 | jobs:
18 | build-and-push-mcp:
19 | name: Build and Push MCP Server Image
20 | runs-on: ubuntu-latest
21 | permissions:
22 | contents: read
23 | packages: write
24 |
25 | steps:
26 | - name: Checkout code
27 | uses: actions/checkout@v4
28 |
29 | - name: Set up Docker Buildx
30 | uses: docker/setup-buildx-action@v3
31 |
32 | - name: Log in to GitHub Container Registry
33 | uses: docker/login-action@v3
34 | with:
35 | registry: ${{ env.REGISTRY }}
36 | username: ${{ github.actor }}
37 | password: ${{ secrets.GITHUB_TOKEN }}
38 |
39 | - name: Extract metadata for MCP Server
40 | id: meta
41 | uses: docker/metadata-action@v5
42 | with:
43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}
44 | tags: |
45 | type=ref,event=branch
46 | type=ref,event=pr
47 | type=semver,pattern={{version}}
48 | type=semver,pattern={{major}}.{{minor}}
49 | type=semver,pattern={{major}}
50 | type=sha,prefix=sha-
51 | type=raw,value=latest,enable={{is_default_branch}}
52 |
53 | - name: Build and push MCP Server image
54 | uses: docker/build-push-action@v5
55 | with:
56 | context: .
57 | file: ./Dockerfile
58 | push: true
59 | tags: ${{ steps.meta.outputs.tags }}
60 | labels: ${{ steps.meta.outputs.labels }}
61 | cache-from: type=gha
62 | cache-to: type=gha,mode=max
63 | platforms: linux/amd64,linux/arm64
64 |
65 | build-and-push-canvas:
66 | name: Build and Push Canvas Server Image
67 | runs-on: ubuntu-latest
68 | permissions:
69 | contents: read
70 | packages: write
71 |
72 | steps:
73 | - name: Checkout code
74 | uses: actions/checkout@v4
75 |
76 | - name: Set up Docker Buildx
77 | uses: docker/setup-buildx-action@v3
78 |
79 | - name: Log in to GitHub Container Registry
80 | uses: docker/login-action@v3
81 | with:
82 | registry: ${{ env.REGISTRY }}
83 | username: ${{ github.actor }}
84 | password: ${{ secrets.GITHUB_TOKEN }}
85 |
86 | - name: Extract metadata for Canvas Server
87 | id: meta
88 | uses: docker/metadata-action@v5
89 | with:
90 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}
91 | tags: |
92 | type=ref,event=branch
93 | type=ref,event=pr
94 | type=semver,pattern={{version}}
95 | type=semver,pattern={{major}}.{{minor}}
96 | type=semver,pattern={{major}}
97 | type=sha,prefix=sha-
98 | type=raw,value=latest,enable={{is_default_branch}}
99 |
100 | - name: Build and push Canvas Server image
101 | uses: docker/build-push-action@v5
102 | with:
103 | context: .
104 | file: ./Dockerfile.canvas
105 | push: true
106 | tags: ${{ steps.meta.outputs.tags }}
107 | labels: ${{ steps.meta.outputs.labels }}
108 | cache-from: type=gha
109 | cache-to: type=gha,mode=max
110 | platforms: linux/amd64,linux/arm64
111 |
112 | test-docker-images:
113 | name: Test Docker Images
114 | needs: [build-and-push-mcp, build-and-push-canvas]
115 | runs-on: ubuntu-latest
116 |
117 | steps:
118 | - name: Log in to GitHub Container Registry
119 | uses: docker/login-action@v3
120 | with:
121 | registry: ${{ env.REGISTRY }}
122 | username: ${{ github.actor }}
123 | password: ${{ secrets.GITHUB_TOKEN }}
124 |
125 | - name: Determine image tag
126 | id: tag
127 | run: |
128 | if [[ "${{ github.event_name }}" == "pull_request" ]]; then
129 | echo "value=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
130 | else
131 | echo "value=${{ github.ref_name }}" >> $GITHUB_OUTPUT
132 | fi
133 |
134 | - name: Test Canvas Server image
135 | run: |
136 | docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
137 | docker run -d -p 3000:3000 --name test-canvas ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}:${{ steps.tag.outputs.value }}
138 | sleep 10
139 | curl -f http://localhost:3000/health || exit 1
140 | docker logs test-canvas
141 | docker stop test-canvas
142 |
143 | - name: Test MCP Server image
144 | run: |
145 | docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}:${{ steps.tag.outputs.value }}
146 | echo "MCP Server image pulled successfully"
147 |
```
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
```html
1 | <!DOCTYPE html>
2 | <html lang="en">
3 | <head>
4 | <meta charset="UTF-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 | <title>Excalidraw POC - Backend API Integration</title>
7 | <style>
8 | body {
9 | margin: 0;
10 | padding: 0;
11 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
12 | background-color: #f5f5f5;
13 | }
14 |
15 | .header {
16 | background: #fff;
17 | box-shadow: 0 2px 4px rgba(0,0,0,0.1);
18 | padding: 10px 20px;
19 | display: flex;
20 | align-items: center;
21 | justify-content: space-between;
22 | flex-wrap: wrap;
23 | gap: 10px;
24 | }
25 |
26 | .header h1 {
27 | margin: 0;
28 | color: #333;
29 | font-size: 24px;
30 | }
31 |
32 | .controls {
33 | display: flex;
34 | gap: 10px;
35 | flex-wrap: wrap;
36 | }
37 |
38 | button {
39 | padding: 8px 16px;
40 | border: none;
41 | border-radius: 4px;
42 | cursor: pointer;
43 | font-size: 14px;
44 | transition: background-color 0.2s;
45 | }
46 |
47 | .btn-primary {
48 | background-color: #007bff;
49 | color: white;
50 | }
51 |
52 | .btn-primary:hover {
53 | background-color: #0056b3;
54 | }
55 |
56 | .btn-secondary {
57 | background-color: #6c757d;
58 | color: white;
59 | }
60 |
61 | .btn-secondary:hover {
62 | background-color: #545b62;
63 | }
64 |
65 | .btn-success {
66 | background-color: #28a745;
67 | color: white;
68 | }
69 |
70 | .btn-success:hover {
71 | background-color: #218838;
72 | }
73 |
74 | .btn-danger {
75 | background-color: #dc3545;
76 | color: white;
77 | }
78 |
79 | .btn-danger:hover {
80 | background-color: #c82333;
81 | }
82 |
83 | .status {
84 | display: flex;
85 | align-items: center;
86 | gap: 8px;
87 | font-size: 14px;
88 | }
89 |
90 | .status-dot {
91 | width: 8px;
92 | height: 8px;
93 | border-radius: 50%;
94 | }
95 |
96 | .status-connected {
97 | background-color: #28a745;
98 | }
99 |
100 | .status-disconnected {
101 | background-color: #dc3545;
102 | }
103 |
104 | .canvas-container {
105 | height: calc(100vh - 80px);
106 | width: 100%;
107 | position: relative;
108 | }
109 |
110 | .api-panel {
111 | position: fixed;
112 | right: 20px;
113 | top: 100px;
114 | background: white;
115 | border-radius: 8px;
116 | box-shadow: 0 4px 12px rgba(0,0,0,0.15);
117 | padding: 20px;
118 | width: 300px;
119 | max-height: 400px;
120 | overflow-y: auto;
121 | z-index: 1000;
122 | }
123 |
124 | .api-panel h3 {
125 | margin: 0 0 15px 0;
126 | color: #333;
127 | font-size: 18px;
128 | }
129 |
130 | .api-form {
131 | display: flex;
132 | flex-direction: column;
133 | gap: 10px;
134 | }
135 |
136 | .form-group {
137 | display: flex;
138 | flex-direction: column;
139 | gap: 5px;
140 | }
141 |
142 | label {
143 | font-weight: 500;
144 | color: #555;
145 | font-size: 14px;
146 | }
147 |
148 | input, select {
149 | padding: 8px;
150 | border: 1px solid #ddd;
151 | border-radius: 4px;
152 | font-size: 14px;
153 | }
154 |
155 | .form-row {
156 | display: flex;
157 | gap: 10px;
158 | }
159 |
160 | .form-row .form-group {
161 | flex: 1;
162 | }
163 |
164 | .toggle-panel {
165 | position: fixed;
166 | right: 20px;
167 | top: 60px;
168 | background: #007bff;
169 | color: white;
170 | border: none;
171 | border-radius: 4px;
172 | padding: 10px;
173 | cursor: pointer;
174 | font-size: 14px;
175 | z-index: 1001;
176 | }
177 |
178 | .api-panel.hidden {
179 | display: none;
180 | }
181 |
182 | .notification {
183 | position: fixed;
184 | top: 20px;
185 | right: 20px;
186 | padding: 12px 16px;
187 | border-radius: 4px;
188 | color: white;
189 | font-size: 14px;
190 | z-index: 1002;
191 | animation: slideIn 0.3s ease;
192 | }
193 |
194 | .notification.success {
195 | background-color: #28a745;
196 | }
197 |
198 | .notification.error {
199 | background-color: #dc3545;
200 | }
201 |
202 | @keyframes slideIn {
203 | from {
204 | transform: translateX(100%);
205 | opacity: 0;
206 | }
207 | to {
208 | transform: translateX(0);
209 | opacity: 1;
210 | }
211 | }
212 |
213 | .element-count {
214 | font-size: 14px;
215 | color: #666;
216 | }
217 |
218 | .loading {
219 | display: flex;
220 | justify-content: center;
221 | align-items: center;
222 | height: 100%;
223 | font-size: 16px;
224 | color: #666;
225 | }
226 |
227 | .loading-content {
228 | text-align: center;
229 | }
230 |
231 | .loading-content div:first-child {
232 | margin-bottom: 10px;
233 | }
234 |
235 | /* Sync Controls Styles */
236 | .sync-controls {
237 | display: flex;
238 | align-items: center;
239 | gap: 10px;
240 | }
241 |
242 | .btn-loading {
243 | position: relative;
244 | }
245 |
246 | .spinner {
247 | display: inline-block;
248 | width: 12px;
249 | height: 12px;
250 | border: 2px solid #ffffff40;
251 | border-top: 2px solid #ffffff;
252 | border-radius: 50%;
253 | animation: spin 1s linear infinite;
254 | margin-right: 5px;
255 | }
256 |
257 | @keyframes spin {
258 | 0% { transform: rotate(0deg); }
259 | 100% { transform: rotate(360deg); }
260 | }
261 |
262 | .sync-status {
263 | font-size: 12px;
264 | min-width: 100px;
265 | }
266 |
267 | .sync-success {
268 | color: #4caf50;
269 | }
270 |
271 | .sync-error {
272 | color: #f44336;
273 | }
274 |
275 | .sync-time {
276 | color: #666;
277 | }
278 | </style>
279 | </head>
280 | <body>
281 | <div id="root"></div>
282 | <script type="module" src="./src/main.jsx"></script>
283 | </body>
284 | </html>
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface ExcalidrawElementBase {
2 | id: string;
3 | type: ExcalidrawElementType;
4 | x: number;
5 | y: number;
6 | width?: number;
7 | height?: number;
8 | angle?: number;
9 | strokeColor?: string;
10 | backgroundColor?: string;
11 | fillStyle?: string;
12 | strokeWidth?: number;
13 | strokeStyle?: string;
14 | roughness?: number;
15 | opacity?: number;
16 | groupIds?: string[];
17 | frameId?: string | null;
18 | roundness?: {
19 | type: number;
20 | value?: number;
21 | } | null;
22 | seed?: number;
23 | versionNonce?: number;
24 | isDeleted?: boolean;
25 | locked?: boolean;
26 | link?: string | null;
27 | customData?: Record<string, any> | null;
28 | boundElements?: readonly ExcalidrawBoundElement[] | null;
29 | updated?: number;
30 | containerId?: string | null;
31 | }
32 |
33 | export interface ExcalidrawTextElement extends ExcalidrawElementBase {
34 | type: 'text';
35 | text: string;
36 | fontSize?: number;
37 | fontFamily?: number;
38 | textAlign?: string;
39 | verticalAlign?: string;
40 | baseline?: number;
41 | lineHeight?: number;
42 | }
43 |
44 | export interface ExcalidrawRectangleElement extends ExcalidrawElementBase {
45 | type: 'rectangle';
46 | width: number;
47 | height: number;
48 | }
49 |
50 | export interface ExcalidrawEllipseElement extends ExcalidrawElementBase {
51 | type: 'ellipse';
52 | width: number;
53 | height: number;
54 | }
55 |
56 | export interface ExcalidrawDiamondElement extends ExcalidrawElementBase {
57 | type: 'diamond';
58 | width: number;
59 | height: number;
60 | }
61 |
62 | export interface ExcalidrawArrowElement extends ExcalidrawElementBase {
63 | type: 'arrow';
64 | points: readonly [number, number][];
65 | lastCommittedPoint?: readonly [number, number] | null;
66 | startBinding?: ExcalidrawBinding | null;
67 | endBinding?: ExcalidrawBinding | null;
68 | startArrowhead?: string | null;
69 | endArrowhead?: string | null;
70 | }
71 |
72 | export interface ExcalidrawLineElement extends ExcalidrawElementBase {
73 | type: 'line';
74 | points: readonly [number, number][];
75 | lastCommittedPoint?: readonly [number, number] | null;
76 | startBinding?: ExcalidrawBinding | null;
77 | endBinding?: ExcalidrawBinding | null;
78 | }
79 |
80 | export interface ExcalidrawFreedrawElement extends ExcalidrawElementBase {
81 | type: 'freedraw';
82 | points: readonly [number, number][];
83 | pressures?: readonly number[];
84 | simulatePressure?: boolean;
85 | lastCommittedPoint?: readonly [number, number] | null;
86 | }
87 |
88 | export type ExcalidrawElement =
89 | | ExcalidrawTextElement
90 | | ExcalidrawRectangleElement
91 | | ExcalidrawEllipseElement
92 | | ExcalidrawDiamondElement
93 | | ExcalidrawArrowElement
94 | | ExcalidrawLineElement
95 | | ExcalidrawFreedrawElement;
96 |
97 | export interface ExcalidrawBoundElement {
98 | id: string;
99 | type: 'text' | 'arrow';
100 | }
101 |
102 | export interface ExcalidrawBinding {
103 | elementId: string;
104 | focus: number;
105 | gap: number;
106 | fixedPoint?: readonly [number, number] | null;
107 | }
108 |
109 | export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label';
110 |
111 | // Excalidraw element types
112 | export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
113 | RECTANGLE: 'rectangle',
114 | ELLIPSE: 'ellipse',
115 | DIAMOND: 'diamond',
116 | ARROW: 'arrow',
117 | TEXT: 'text',
118 | LABEL: 'label',
119 | FREEDRAW: 'freedraw',
120 | LINE: 'line'
121 | } as const;
122 |
123 | // Server-side element with metadata
124 | export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
125 | id: string;
126 | type: ExcalidrawElementType;
127 | createdAt?: string;
128 | updatedAt?: string;
129 | version?: number;
130 | syncedAt?: string;
131 | source?: string;
132 | syncTimestamp?: string;
133 | text?: string;
134 | fontSize?: number;
135 | fontFamily?: string | number;
136 | label?: {
137 | text: string;
138 | };
139 | }
140 |
141 | // API Response types
142 | export interface ApiResponse<T = any> {
143 | success: boolean;
144 | data?: T;
145 | error?: string;
146 | message?: string;
147 | }
148 |
149 | export interface ElementsResponse extends ApiResponse {
150 | elements: ServerElement[];
151 | count: number;
152 | }
153 |
154 | export interface ElementResponse extends ApiResponse {
155 | element: ServerElement;
156 | }
157 |
158 | export interface SyncResponse extends ApiResponse {
159 | count: number;
160 | syncedAt: string;
161 | beforeCount: number;
162 | afterCount: number;
163 | }
164 |
165 | // WebSocket message types
166 | export interface WebSocketMessage {
167 | type: WebSocketMessageType;
168 | [key: string]: any;
169 | }
170 |
171 | export type WebSocketMessageType =
172 | | 'initial_elements'
173 | | 'element_created'
174 | | 'element_updated'
175 | | 'element_deleted'
176 | | 'elements_batch_created'
177 | | 'elements_synced'
178 | | 'sync_status'
179 | | 'mermaid_convert';
180 |
181 | export interface InitialElementsMessage extends WebSocketMessage {
182 | type: 'initial_elements';
183 | elements: ServerElement[];
184 | }
185 |
186 | export interface ElementCreatedMessage extends WebSocketMessage {
187 | type: 'element_created';
188 | element: ServerElement;
189 | }
190 |
191 | export interface ElementUpdatedMessage extends WebSocketMessage {
192 | type: 'element_updated';
193 | element: ServerElement;
194 | }
195 |
196 | export interface ElementDeletedMessage extends WebSocketMessage {
197 | type: 'element_deleted';
198 | elementId: string;
199 | }
200 |
201 | export interface BatchCreatedMessage extends WebSocketMessage {
202 | type: 'elements_batch_created';
203 | elements: ServerElement[];
204 | }
205 |
206 | export interface SyncStatusMessage extends WebSocketMessage {
207 | type: 'sync_status';
208 | elementCount: number;
209 | timestamp: string;
210 | }
211 |
212 | export interface MermaidConvertMessage extends WebSocketMessage {
213 | type: 'mermaid_convert';
214 | mermaidDiagram: string;
215 | config?: MermaidConfig;
216 | timestamp: string;
217 | }
218 |
219 | // Mermaid conversion types
220 | export interface MermaidConfig {
221 | startOnLoad?: boolean;
222 | flowchart?: {
223 | curve?: 'linear' | 'basis';
224 | };
225 | themeVariables?: {
226 | fontSize?: string;
227 | };
228 | maxEdges?: number;
229 | maxTextSize?: number;
230 | }
231 |
232 | export interface MermaidConversionRequest {
233 | mermaidDiagram: string;
234 | config?: MermaidConfig;
235 | }
236 |
237 | export interface MermaidConversionResponse extends ApiResponse {
238 | elements: ServerElement[];
239 | files?: any;
240 | count: number;
241 | }
242 |
243 | // In-memory storage for Excalidraw elements
244 | export const elements = new Map<string, ServerElement>();
245 |
246 | // Validation function for Excalidraw elements
247 | export function validateElement(element: Partial<ServerElement>): element is ServerElement {
248 | const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];
249 | const hasRequiredFields = requiredFields.every(field => field in element);
250 |
251 | if (!hasRequiredFields) {
252 | throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
253 | }
254 |
255 | if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type as ExcalidrawElementType)) {
256 | throw new Error(`Invalid element type: ${element.type}`);
257 | }
258 |
259 | return true;
260 | }
261 |
262 | // Helper function to generate unique IDs
263 | export function generateId(): string {
264 | return Date.now().toString(36) + Math.random().toString(36).substring(2);
265 | }
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import express, { Request, Response, NextFunction } from 'express';
2 | import cors from 'cors';
3 | import { WebSocketServer } from 'ws';
4 | import { createServer } from 'http';
5 | import path from 'path';
6 | import { fileURLToPath } from 'url';
7 | import dotenv from 'dotenv';
8 | import logger from './utils/logger.js';
9 | import {
10 | elements,
11 | generateId,
12 | EXCALIDRAW_ELEMENT_TYPES,
13 | ServerElement,
14 | ExcalidrawElementType,
15 | WebSocketMessage,
16 | ElementCreatedMessage,
17 | ElementUpdatedMessage,
18 | ElementDeletedMessage,
19 | BatchCreatedMessage,
20 | SyncStatusMessage,
21 | InitialElementsMessage
22 | } from './types.js';
23 | import { z } from 'zod';
24 | import WebSocket from 'ws';
25 |
26 | // Load environment variables
27 | dotenv.config();
28 |
29 | const __filename = fileURLToPath(import.meta.url);
30 | const __dirname = path.dirname(__filename);
31 |
32 | const app = express();
33 | const server = createServer(app);
34 | const wss = new WebSocketServer({ server });
35 |
36 | // Middleware
37 | app.use(cors());
38 | app.use(express.json());
39 |
40 | // Serve static files from the build directory
41 | const staticDir = path.join(__dirname, '../dist');
42 | app.use(express.static(staticDir));
43 | // Also serve frontend assets
44 | app.use(express.static(path.join(__dirname, '../dist/frontend')));
45 |
46 | // WebSocket connections
47 | const clients = new Set<WebSocket>();
48 |
49 | // Broadcast to all connected clients
50 | function broadcast(message: WebSocketMessage): void {
51 | const data = JSON.stringify(message);
52 | clients.forEach(client => {
53 | if (client.readyState === WebSocket.OPEN) {
54 | client.send(data);
55 | }
56 | });
57 | }
58 |
59 | // WebSocket connection handling
60 | wss.on('connection', (ws: WebSocket) => {
61 | clients.add(ws);
62 | logger.info('New WebSocket connection established');
63 |
64 | // Send current elements to new client
65 | const initialMessage: InitialElementsMessage = {
66 | type: 'initial_elements',
67 | elements: Array.from(elements.values())
68 | };
69 | ws.send(JSON.stringify(initialMessage));
70 |
71 | // Send sync status to new client
72 | const syncMessage: SyncStatusMessage = {
73 | type: 'sync_status',
74 | elementCount: elements.size,
75 | timestamp: new Date().toISOString()
76 | };
77 | ws.send(JSON.stringify(syncMessage));
78 |
79 | ws.on('close', () => {
80 | clients.delete(ws);
81 | logger.info('WebSocket connection closed');
82 | });
83 |
84 | ws.on('error', (error) => {
85 | logger.error('WebSocket error:', error);
86 | clients.delete(ws);
87 | });
88 | });
89 |
90 | // Schema validation
91 | const CreateElementSchema = z.object({
92 | id: z.string().optional(), // Allow passing ID for MCP sync
93 | type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
94 | x: z.number(),
95 | y: z.number(),
96 | width: z.number().optional(),
97 | height: z.number().optional(),
98 | backgroundColor: z.string().optional(),
99 | strokeColor: z.string().optional(),
100 | strokeWidth: z.number().optional(),
101 | roughness: z.number().optional(),
102 | opacity: z.number().optional(),
103 | text: z.string().optional(),
104 | label: z.object({
105 | text: z.string()
106 | }).optional(),
107 | fontSize: z.number().optional(),
108 | fontFamily: z.string().optional(),
109 | groupIds: z.array(z.string()).optional(),
110 | locked: z.boolean().optional()
111 | });
112 |
113 | const UpdateElementSchema = z.object({
114 | id: z.string(),
115 | type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
116 | x: z.number().optional(),
117 | y: z.number().optional(),
118 | width: z.number().optional(),
119 | height: z.number().optional(),
120 | backgroundColor: z.string().optional(),
121 | strokeColor: z.string().optional(),
122 | strokeWidth: z.number().optional(),
123 | roughness: z.number().optional(),
124 | opacity: z.number().optional(),
125 | text: z.string().optional(),
126 | label: z.object({
127 | text: z.string()
128 | }).optional(),
129 | fontSize: z.number().optional(),
130 | fontFamily: z.string().optional(),
131 | groupIds: z.array(z.string()).optional(),
132 | locked: z.boolean().optional()
133 | });
134 |
135 | // API Routes
136 |
137 | // Get all elements
138 | app.get('/api/elements', (req: Request, res: Response) => {
139 | try {
140 | const elementsArray = Array.from(elements.values());
141 | res.json({
142 | success: true,
143 | elements: elementsArray,
144 | count: elementsArray.length
145 | });
146 | } catch (error) {
147 | logger.error('Error fetching elements:', error);
148 | res.status(500).json({
149 | success: false,
150 | error: (error as Error).message
151 | });
152 | }
153 | });
154 |
155 | // Create new element
156 | app.post('/api/elements', (req: Request, res: Response) => {
157 | try {
158 | const params = CreateElementSchema.parse(req.body);
159 | logger.info('Creating element via API', { type: params.type });
160 |
161 | // Prioritize passed ID (for MCP sync), otherwise generate new ID
162 | const id = params.id || generateId();
163 | const element: ServerElement = {
164 | id,
165 | ...params,
166 | createdAt: new Date().toISOString(),
167 | updatedAt: new Date().toISOString(),
168 | version: 1
169 | };
170 |
171 | elements.set(id, element);
172 |
173 | // Broadcast to all connected clients
174 | const message: ElementCreatedMessage = {
175 | type: 'element_created',
176 | element: element
177 | };
178 | broadcast(message);
179 |
180 | res.json({
181 | success: true,
182 | element: element
183 | });
184 | } catch (error) {
185 | logger.error('Error creating element:', error);
186 | res.status(400).json({
187 | success: false,
188 | error: (error as Error).message
189 | });
190 | }
191 | });
192 |
193 | // Update element
194 | app.put('/api/elements/:id', (req: Request, res: Response) => {
195 | try {
196 | const { id } = req.params;
197 | const updates = UpdateElementSchema.parse({ id, ...req.body });
198 |
199 | if (!id) {
200 | return res.status(400).json({
201 | success: false,
202 | error: 'Element ID is required'
203 | });
204 | }
205 |
206 | const existingElement = elements.get(id);
207 | if (!existingElement) {
208 | return res.status(404).json({
209 | success: false,
210 | error: `Element with ID ${id} not found`
211 | });
212 | }
213 |
214 | const updatedElement: ServerElement = {
215 | ...existingElement,
216 | ...updates,
217 | updatedAt: new Date().toISOString(),
218 | version: (existingElement.version || 0) + 1
219 | };
220 |
221 | elements.set(id, updatedElement);
222 |
223 | // Broadcast to all connected clients
224 | const message: ElementUpdatedMessage = {
225 | type: 'element_updated',
226 | element: updatedElement
227 | };
228 | broadcast(message);
229 |
230 | res.json({
231 | success: true,
232 | element: updatedElement
233 | });
234 | } catch (error) {
235 | logger.error('Error updating element:', error);
236 | res.status(400).json({
237 | success: false,
238 | error: (error as Error).message
239 | });
240 | }
241 | });
242 |
243 | // Delete element
244 | app.delete('/api/elements/:id', (req: Request, res: Response) => {
245 | try {
246 | const { id } = req.params;
247 |
248 | if (!id) {
249 | return res.status(400).json({
250 | success: false,
251 | error: 'Element ID is required'
252 | });
253 | }
254 |
255 | if (!elements.has(id)) {
256 | return res.status(404).json({
257 | success: false,
258 | error: `Element with ID ${id} not found`
259 | });
260 | }
261 |
262 | elements.delete(id);
263 |
264 | // Broadcast to all connected clients
265 | const message: ElementDeletedMessage = {
266 | type: 'element_deleted',
267 | elementId: id!
268 | };
269 | broadcast(message);
270 |
271 | res.json({
272 | success: true,
273 | message: `Element ${id} deleted successfully`
274 | });
275 | } catch (error) {
276 | logger.error('Error deleting element:', error);
277 | res.status(500).json({
278 | success: false,
279 | error: (error as Error).message
280 | });
281 | }
282 | });
283 |
284 | // Query elements with filters
285 | app.get('/api/elements/search', (req: Request, res: Response) => {
286 | try {
287 | const { type, ...filters } = req.query;
288 | let results = Array.from(elements.values());
289 |
290 | // Filter by type if specified
291 | if (type && typeof type === 'string') {
292 | results = results.filter(element => element.type === type);
293 | }
294 |
295 | // Apply additional filters
296 | if (Object.keys(filters).length > 0) {
297 | results = results.filter(element => {
298 | return Object.entries(filters).every(([key, value]) => {
299 | return (element as any)[key] === value;
300 | });
301 | });
302 | }
303 |
304 | res.json({
305 | success: true,
306 | elements: results,
307 | count: results.length
308 | });
309 | } catch (error) {
310 | logger.error('Error querying elements:', error);
311 | res.status(500).json({
312 | success: false,
313 | error: (error as Error).message
314 | });
315 | }
316 | });
317 |
318 | // Get element by ID
319 | app.get('/api/elements/:id', (req: Request, res: Response) => {
320 | try {
321 | const { id } = req.params;
322 |
323 | if (!id) {
324 | return res.status(400).json({
325 | success: false,
326 | error: 'Element ID is required'
327 | });
328 | }
329 |
330 | const element = elements.get(id);
331 |
332 | if (!element) {
333 | return res.status(404).json({
334 | success: false,
335 | error: `Element with ID ${id} not found`
336 | });
337 | }
338 |
339 | res.json({
340 | success: true,
341 | element: element
342 | });
343 | } catch (error) {
344 | logger.error('Error fetching element:', error);
345 | res.status(500).json({
346 | success: false,
347 | error: (error as Error).message
348 | });
349 | }
350 | });
351 |
352 | // Batch create elements
353 | app.post('/api/elements/batch', (req: Request, res: Response) => {
354 | try {
355 | const { elements: elementsToCreate } = req.body;
356 |
357 | if (!Array.isArray(elementsToCreate)) {
358 | return res.status(400).json({
359 | success: false,
360 | error: 'Expected an array of elements'
361 | });
362 | }
363 |
364 | const createdElements: ServerElement[] = [];
365 |
366 | elementsToCreate.forEach(elementData => {
367 | const params = CreateElementSchema.parse(elementData);
368 | const id = generateId();
369 | const element: ServerElement = {
370 | id,
371 | ...params,
372 | createdAt: new Date().toISOString(),
373 | updatedAt: new Date().toISOString(),
374 | version: 1
375 | };
376 |
377 | elements.set(id, element);
378 | createdElements.push(element);
379 | });
380 |
381 | // Broadcast to all connected clients
382 | const message: BatchCreatedMessage = {
383 | type: 'elements_batch_created',
384 | elements: createdElements
385 | };
386 | broadcast(message);
387 |
388 | res.json({
389 | success: true,
390 | elements: createdElements,
391 | count: createdElements.length
392 | });
393 | } catch (error) {
394 | logger.error('Error batch creating elements:', error);
395 | res.status(400).json({
396 | success: false,
397 | error: (error as Error).message
398 | });
399 | }
400 | });
401 |
402 | // Convert Mermaid diagram to Excalidraw elements
403 | app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
404 | try {
405 | const { mermaidDiagram, config } = req.body;
406 |
407 | if (!mermaidDiagram || typeof mermaidDiagram !== 'string') {
408 | return res.status(400).json({
409 | success: false,
410 | error: 'Mermaid diagram definition is required'
411 | });
412 | }
413 |
414 | logger.info('Received Mermaid conversion request', {
415 | diagramLength: mermaidDiagram.length,
416 | hasConfig: !!config
417 | });
418 |
419 | // Broadcast to all WebSocket clients to process the Mermaid diagram
420 | broadcast({
421 | type: 'mermaid_convert',
422 | mermaidDiagram,
423 | config: config || {},
424 | timestamp: new Date().toISOString()
425 | });
426 |
427 | // Return the diagram for frontend processing
428 | res.json({
429 | success: true,
430 | mermaidDiagram,
431 | config: config || {},
432 | message: 'Mermaid diagram sent to frontend for conversion.'
433 | });
434 | } catch (error) {
435 | logger.error('Error processing Mermaid diagram:', error);
436 | res.status(400).json({
437 | success: false,
438 | error: (error as Error).message
439 | });
440 | }
441 | });
442 |
443 | // Sync elements from frontend (overwrite sync)
444 | app.post('/api/elements/sync', (req: Request, res: Response) => {
445 | try {
446 | const { elements: frontendElements, timestamp } = req.body;
447 |
448 | logger.info(`Sync request received: ${frontendElements.length} elements`, {
449 | timestamp,
450 | elementCount: frontendElements.length
451 | });
452 |
453 | // Validate input data
454 | if (!Array.isArray(frontendElements)) {
455 | return res.status(400).json({
456 | success: false,
457 | error: 'Expected elements to be an array'
458 | });
459 | }
460 |
461 | // Record element count before sync
462 | const beforeCount = elements.size;
463 |
464 | // 1. Clear existing memory storage
465 | elements.clear();
466 | logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
467 |
468 | // 2. Batch write new data
469 | let successCount = 0;
470 | const processedElements: ServerElement[] = [];
471 |
472 | frontendElements.forEach((element: any, index: number) => {
473 | try {
474 | // Ensure element has ID, generate one if missing
475 | const elementId = element.id || generateId();
476 |
477 | // Add server metadata
478 | const processedElement: ServerElement = {
479 | ...element,
480 | id: elementId,
481 | syncedAt: new Date().toISOString(),
482 | source: 'frontend_sync',
483 | syncTimestamp: timestamp,
484 | version: 1
485 | };
486 |
487 | // Store to memory
488 | elements.set(elementId, processedElement);
489 | processedElements.push(processedElement);
490 | successCount++;
491 |
492 | } catch (elementError) {
493 | logger.warn(`Failed to process element ${index}:`, elementError);
494 | }
495 | });
496 |
497 | logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
498 |
499 | // 3. Broadcast sync event to all WebSocket clients
500 | broadcast({
501 | type: 'elements_synced',
502 | count: successCount,
503 | timestamp: new Date().toISOString(),
504 | source: 'manual_sync'
505 | });
506 |
507 | // 4. Return sync results
508 | res.json({
509 | success: true,
510 | message: `Successfully synced ${successCount} elements`,
511 | count: successCount,
512 | syncedAt: new Date().toISOString(),
513 | beforeCount,
514 | afterCount: elements.size
515 | });
516 |
517 | } catch (error) {
518 | logger.error('Sync error:', error);
519 | res.status(500).json({
520 | success: false,
521 | error: (error as Error).message,
522 | details: 'Internal server error during sync operation'
523 | });
524 | }
525 | });
526 |
527 | // Serve the frontend
528 | app.get('/', (req: Request, res: Response) => {
529 | const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
530 | res.sendFile(htmlFile, (err) => {
531 | if (err) {
532 | logger.error('Error serving frontend:', err);
533 | res.status(404).send('Frontend not found. Please run "npm run build" first.');
534 | }
535 | });
536 | });
537 |
538 | // Health check endpoint
539 | app.get('/health', (req: Request, res: Response) => {
540 | res.json({
541 | status: 'healthy',
542 | timestamp: new Date().toISOString(),
543 | elements_count: elements.size,
544 | websocket_clients: clients.size
545 | });
546 | });
547 |
548 | // Sync status endpoint
549 | app.get('/api/sync/status', (req: Request, res: Response) => {
550 | res.json({
551 | success: true,
552 | elementCount: elements.size,
553 | timestamp: new Date().toISOString(),
554 | memoryUsage: {
555 | heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
556 | heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
557 | },
558 | websocketClients: clients.size
559 | });
560 | });
561 |
562 | // Error handling middleware
563 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
564 | logger.error('Unhandled error:', err);
565 | res.status(500).json({
566 | success: false,
567 | error: 'Internal server error'
568 | });
569 | });
570 |
571 | // Start server
572 | const PORT = parseInt(process.env.PORT || '3000', 10);
573 | const HOST = process.env.HOST || 'localhost';
574 |
575 | server.listen(PORT, HOST, () => {
576 | logger.info(`POC server running on http://${HOST}:${PORT}`);
577 | logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
578 | });
579 |
580 | export default app;
```
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import React, { useState, useEffect, useRef } from 'react'
2 | import {
3 | Excalidraw,
4 | convertToExcalidrawElements,
5 | CaptureUpdateAction,
6 | ExcalidrawImperativeAPI
7 | } from '@excalidraw/excalidraw'
8 | import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
9 | import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
10 | import type { MermaidConfig } from '@excalidraw/mermaid-to-excalidraw'
11 |
12 | // Type definitions
13 | type ExcalidrawAPIRefValue = ExcalidrawImperativeAPI;
14 |
15 | interface ServerElement {
16 | id: string;
17 | type: string;
18 | x: number;
19 | y: number;
20 | width?: number;
21 | height?: number;
22 | backgroundColor?: string;
23 | strokeColor?: string;
24 | strokeWidth?: number;
25 | roughness?: number;
26 | opacity?: number;
27 | text?: string;
28 | fontSize?: number;
29 | fontFamily?: string | number;
30 | label?: {
31 | text: string;
32 | };
33 | createdAt?: string;
34 | updatedAt?: string;
35 | version?: number;
36 | syncedAt?: string;
37 | source?: string;
38 | syncTimestamp?: string;
39 | boundElements?: any[] | null;
40 | containerId?: string | null;
41 | locked?: boolean;
42 | }
43 |
44 | interface WebSocketMessage {
45 | type: string;
46 | element?: ServerElement;
47 | elements?: ServerElement[];
48 | elementId?: string;
49 | count?: number;
50 | timestamp?: string;
51 | source?: string;
52 | mermaidDiagram?: string;
53 | config?: MermaidConfig;
54 | }
55 |
56 | interface ApiResponse {
57 | success: boolean;
58 | elements?: ServerElement[];
59 | element?: ServerElement;
60 | count?: number;
61 | error?: string;
62 | message?: string;
63 | }
64 |
65 | type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
66 |
67 | // Helper function to clean elements for Excalidraw
68 | const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
69 | const {
70 | createdAt,
71 | updatedAt,
72 | version,
73 | syncedAt,
74 | source,
75 | syncTimestamp,
76 | ...cleanElement
77 | } = element;
78 | return cleanElement;
79 | }
80 |
81 | // Helper function to validate and fix element binding data
82 | const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
83 | const elementMap = new Map(elements.map(el => [el.id!, el]));
84 |
85 | return elements.map(element => {
86 | const fixedElement = { ...element };
87 |
88 | // Validate and fix boundElements
89 | if (fixedElement.boundElements) {
90 | if (Array.isArray(fixedElement.boundElements)) {
91 | fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
92 | // Ensure binding has required properties
93 | if (!binding || typeof binding !== 'object') return false;
94 | if (!binding.id || !binding.type) return false;
95 |
96 | // Ensure the referenced element exists
97 | const referencedElement = elementMap.get(binding.id);
98 | if (!referencedElement) return false;
99 |
100 | // Validate binding type
101 | if (!['text', 'arrow'].includes(binding.type)) return false;
102 |
103 | return true;
104 | });
105 |
106 | // Remove boundElements if empty
107 | if (fixedElement.boundElements.length === 0) {
108 | fixedElement.boundElements = null;
109 | }
110 | } else {
111 | // Invalid boundElements format, set to null
112 | fixedElement.boundElements = null;
113 | }
114 | }
115 |
116 | // Validate and fix containerId
117 | if (fixedElement.containerId) {
118 | const containerElement = elementMap.get(fixedElement.containerId);
119 | if (!containerElement) {
120 | // Container doesn't exist, remove containerId
121 | fixedElement.containerId = null;
122 | }
123 | }
124 |
125 | return fixedElement;
126 | });
127 | }
128 |
129 | function App(): JSX.Element {
130 | const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
131 | const [isConnected, setIsConnected] = useState<boolean>(false)
132 | const websocketRef = useRef<WebSocket | null>(null)
133 |
134 | // Sync state management
135 | const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
136 | const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
137 |
138 | // WebSocket connection
139 | useEffect(() => {
140 | connectWebSocket()
141 | return () => {
142 | if (websocketRef.current) {
143 | websocketRef.current.close()
144 | }
145 | }
146 | }, [])
147 |
148 | // Load existing elements when Excalidraw API becomes available
149 | useEffect(() => {
150 | if (excalidrawAPI) {
151 | loadExistingElements()
152 |
153 | // Ensure WebSocket is connected for real-time updates
154 | if (!isConnected) {
155 | connectWebSocket()
156 | }
157 | }
158 | }, [excalidrawAPI, isConnected])
159 |
160 | const loadExistingElements = async (): Promise<void> => {
161 | try {
162 | const response = await fetch('/api/elements')
163 | const result: ApiResponse = await response.json()
164 |
165 | if (result.success && result.elements && result.elements.length > 0) {
166 | const cleanedElements = result.elements.map(cleanElementForExcalidraw)
167 | const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
168 | excalidrawAPI?.updateScene({ elements: convertedElements })
169 | }
170 | } catch (error) {
171 | console.error('Error loading existing elements:', error)
172 | }
173 | }
174 |
175 | const connectWebSocket = (): void => {
176 | if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
177 | return
178 | }
179 |
180 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
181 | const wsUrl = `${protocol}//${window.location.host}`
182 |
183 | websocketRef.current = new WebSocket(wsUrl)
184 |
185 | websocketRef.current.onopen = () => {
186 | setIsConnected(true)
187 |
188 | if (excalidrawAPI) {
189 | setTimeout(loadExistingElements, 100)
190 | }
191 | }
192 |
193 | websocketRef.current.onmessage = (event: MessageEvent) => {
194 | try {
195 | const data: WebSocketMessage = JSON.parse(event.data)
196 | handleWebSocketMessage(data)
197 | } catch (error) {
198 | console.error('Error parsing WebSocket message:', error, event.data)
199 | }
200 | }
201 |
202 | websocketRef.current.onclose = (event: CloseEvent) => {
203 | setIsConnected(false)
204 |
205 | // Reconnect after 3 seconds if not a clean close
206 | if (event.code !== 1000) {
207 | setTimeout(connectWebSocket, 3000)
208 | }
209 | }
210 |
211 | websocketRef.current.onerror = (error: Event) => {
212 | console.error('WebSocket error:', error)
213 | setIsConnected(false)
214 | }
215 | }
216 |
217 | const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
218 | if (!excalidrawAPI) {
219 | return
220 | }
221 |
222 | try {
223 | const currentElements = excalidrawAPI.getSceneElements()
224 | console.log('Current elements:', currentElements);
225 |
226 | switch (data.type) {
227 | case 'initial_elements':
228 | if (data.elements && data.elements.length > 0) {
229 | const cleanedElements = data.elements.map(cleanElementForExcalidraw)
230 | const validatedElements = validateAndFixBindings(cleanedElements)
231 | const convertedElements = convertToExcalidrawElements(validatedElements)
232 | excalidrawAPI.updateScene({
233 | elements: convertedElements,
234 | captureUpdate: CaptureUpdateAction.NEVER
235 | })
236 | }
237 | break
238 |
239 | case 'element_created':
240 | if (data.element) {
241 | const cleanedNewElement = cleanElementForExcalidraw(data.element)
242 | const newElement = convertToExcalidrawElements([cleanedNewElement])
243 | const updatedElementsAfterCreate = [...currentElements, ...newElement]
244 | excalidrawAPI.updateScene({
245 | elements: updatedElementsAfterCreate,
246 | captureUpdate: CaptureUpdateAction.NEVER
247 | })
248 | }
249 | break
250 |
251 | case 'element_updated':
252 | if (data.element) {
253 | const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
254 | const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0]
255 | const updatedElements = currentElements.map(el =>
256 | el.id === data.element!.id ? convertedUpdatedElement : el
257 | )
258 | excalidrawAPI.updateScene({
259 | elements: updatedElements,
260 | captureUpdate: CaptureUpdateAction.NEVER
261 | })
262 | }
263 | break
264 |
265 | case 'element_deleted':
266 | if (data.elementId) {
267 | const filteredElements = currentElements.filter(el => el.id !== data.elementId)
268 | excalidrawAPI.updateScene({
269 | elements: filteredElements,
270 | captureUpdate: CaptureUpdateAction.NEVER
271 | })
272 | }
273 | break
274 |
275 | case 'elements_batch_created':
276 | if (data.elements) {
277 | const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
278 | const batchElements = convertToExcalidrawElements(cleanedBatchElements)
279 | const updatedElementsAfterBatch = [...currentElements, ...batchElements]
280 | excalidrawAPI.updateScene({
281 | elements: updatedElementsAfterBatch,
282 | captureUpdate: CaptureUpdateAction.NEVER
283 | })
284 | }
285 | break
286 |
287 | case 'elements_synced':
288 | console.log(`Sync confirmed by server: ${data.count} elements`)
289 | // Sync confirmation already handled by HTTP response
290 | break
291 |
292 | case 'sync_status':
293 | console.log(`Server sync status: ${data.count} elements`)
294 | break
295 |
296 | case 'mermaid_convert':
297 | console.log('Received Mermaid conversion request from MCP')
298 | if (data.mermaidDiagram) {
299 | try {
300 | const result = await convertMermaidToExcalidraw(data.mermaidDiagram, data.config || DEFAULT_MERMAID_CONFIG)
301 |
302 | if (result.error) {
303 | console.error('Mermaid conversion error:', result.error)
304 | return
305 | }
306 |
307 | if (result.elements && result.elements.length > 0) {
308 | const convertedElements = convertToExcalidrawElements(result.elements, { regenerateIds: false })
309 | excalidrawAPI.updateScene({
310 | elements: convertedElements,
311 | captureUpdate: CaptureUpdateAction.IMMEDIATELY
312 | })
313 |
314 | if (result.files) {
315 | excalidrawAPI.addFiles(Object.values(result.files))
316 | }
317 |
318 | console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements')
319 |
320 | // Sync to backend automatically after creating elements
321 | await syncToBackend()
322 | }
323 | } catch (error) {
324 | console.error('Error converting Mermaid diagram from WebSocket:', error)
325 | }
326 | }
327 | break
328 |
329 | default:
330 | console.log('Unknown WebSocket message type:', data.type)
331 | }
332 | } catch (error) {
333 | console.error('Error processing WebSocket message:', error, data)
334 | }
335 | }
336 |
337 | // Data format conversion for backend
338 | const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => {
339 | return {
340 | ...element
341 | } as ServerElement
342 | }
343 |
344 | // Format sync time display
345 | const formatSyncTime = (time: Date | null): string => {
346 | if (!time) return ''
347 | return time.toLocaleTimeString('zh-CN', {
348 | hour: '2-digit',
349 | minute: '2-digit',
350 | second: '2-digit'
351 | })
352 | }
353 |
354 | // Main sync function
355 | const syncToBackend = async (): Promise<void> => {
356 | if (!excalidrawAPI) {
357 | console.warn('Excalidraw API not available')
358 | return
359 | }
360 |
361 | setSyncStatus('syncing')
362 |
363 | try {
364 | // 1. Get current elements
365 | const currentElements = excalidrawAPI.getSceneElements()
366 | console.log(`Syncing ${currentElements.length} elements to backend`)
367 |
368 | // Filter out deleted elements
369 | const activeElements = currentElements.filter(el => !el.isDeleted)
370 |
371 | // 3. Convert to backend format
372 | const backendElements = activeElements.map(convertToBackendFormat)
373 |
374 | // 4. Send to backend
375 | const response = await fetch('/api/elements/sync', {
376 | method: 'POST',
377 | headers: {
378 | 'Content-Type': 'application/json',
379 | },
380 | body: JSON.stringify({
381 | elements: backendElements,
382 | timestamp: new Date().toISOString()
383 | })
384 | })
385 |
386 | if (response.ok) {
387 | const result: ApiResponse = await response.json()
388 | setSyncStatus('success')
389 | setLastSyncTime(new Date())
390 | console.log(`Sync successful: ${result.count} elements synced`)
391 |
392 | // Reset status after 2 seconds
393 | setTimeout(() => setSyncStatus('idle'), 2000)
394 | } else {
395 | const error: ApiResponse = await response.json()
396 | setSyncStatus('error')
397 | console.error('Sync failed:', error.error)
398 | }
399 | } catch (error) {
400 | setSyncStatus('error')
401 | console.error('Sync error:', error)
402 | }
403 | }
404 |
405 | const clearCanvas = async (): Promise<void> => {
406 | if (excalidrawAPI) {
407 | try {
408 | // Get all current elements and delete them from backend
409 | const response = await fetch('/api/elements')
410 | const result: ApiResponse = await response.json()
411 |
412 | if (result.success && result.elements) {
413 | const deletePromises = result.elements.map(element =>
414 | fetch(`/api/elements/${element.id}`, { method: 'DELETE' })
415 | )
416 | await Promise.all(deletePromises)
417 | }
418 |
419 | // Clear the frontend canvas
420 | excalidrawAPI.updateScene({
421 | elements: [],
422 | captureUpdate: CaptureUpdateAction.IMMEDIATELY
423 | })
424 | } catch (error) {
425 | console.error('Error clearing canvas:', error)
426 | // Still clear frontend even if backend fails
427 | excalidrawAPI.updateScene({
428 | elements: [],
429 | captureUpdate: CaptureUpdateAction.IMMEDIATELY
430 | })
431 | }
432 | }
433 | }
434 |
435 | return (
436 | <div className="app">
437 | {/* Header */}
438 | <div className="header">
439 | <h1>Excalidraw Canvas</h1>
440 | <div className="controls">
441 | <div className="status">
442 | <div className={`status-dot ${isConnected ? 'status-connected' : 'status-disconnected'}`}></div>
443 | <span>{isConnected ? 'Connected' : 'Disconnected'}</span>
444 | </div>
445 |
446 | {/* Sync Controls */}
447 | <div className="sync-controls">
448 | <button
449 | className={`btn-primary ${syncStatus === 'syncing' ? 'btn-loading' : ''}`}
450 | onClick={syncToBackend}
451 | disabled={syncStatus === 'syncing' || !excalidrawAPI}
452 | >
453 | {syncStatus === 'syncing' && <span className="spinner"></span>}
454 | {syncStatus === 'syncing' ? 'Syncing...' : 'Sync to Backend'}
455 | </button>
456 |
457 | {/* Sync Status */}
458 | <div className="sync-status">
459 | {syncStatus === 'success' && (
460 | <span className="sync-success">✅ Synced</span>
461 | )}
462 | {syncStatus === 'error' && (
463 | <span className="sync-error">❌ Sync Failed</span>
464 | )}
465 | {lastSyncTime && syncStatus === 'idle' && (
466 | <span className="sync-time">
467 | Last sync: {formatSyncTime(lastSyncTime)}
468 | </span>
469 | )}
470 | </div>
471 | </div>
472 |
473 | <button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button>
474 | </div>
475 | </div>
476 |
477 | {/* Canvas Container */}
478 | <div className="canvas-container">
479 | <Excalidraw
480 | excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)}
481 | initialData={{
482 | elements: [],
483 | appState: {
484 | theme: 'light',
485 | viewBackgroundColor: '#ffffff'
486 | }
487 | }}
488 | />
489 | </div>
490 | </div>
491 | )
492 | }
493 |
494 | export default App
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | // Disable colors to prevent ANSI color codes from breaking JSON parsing
4 | process.env.NODE_DISABLE_COLORS = '1';
5 | process.env.NO_COLOR = '1';
6 |
7 | import { fileURLToPath } from "url";
8 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10 | import {
11 | CallToolRequestSchema,
12 | ListToolsRequestSchema,
13 | CallToolRequest,
14 | Tool
15 | } from '@modelcontextprotocol/sdk/types.js';
16 | import { z } from 'zod';
17 | import dotenv from 'dotenv';
18 | import logger from './utils/logger.js';
19 | import {
20 | generateId,
21 | EXCALIDRAW_ELEMENT_TYPES,
22 | ServerElement,
23 | ExcalidrawElementType,
24 | validateElement
25 | } from './types.js';
26 | import fetch from 'node-fetch';
27 |
28 | // Load environment variables
29 | dotenv.config();
30 |
31 | // Express server configuration
32 | const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
33 | const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
34 |
35 | // API Response types
36 | interface ApiResponse {
37 | success: boolean;
38 | element?: ServerElement;
39 | elements?: ServerElement[];
40 | message?: string;
41 | error?: string;
42 | count?: number;
43 | }
44 |
45 | interface SyncResponse {
46 | element?: ServerElement;
47 | elements?: ServerElement[];
48 | }
49 |
50 | // Helper functions to sync with Express server (canvas)
51 | async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
52 | if (!ENABLE_CANVAS_SYNC) {
53 | logger.debug('Canvas sync disabled, skipping');
54 | return null;
55 | }
56 |
57 | try {
58 | let url: string;
59 | let options: any;
60 |
61 | switch (operation) {
62 | case 'create':
63 | url = `${EXPRESS_SERVER_URL}/api/elements`;
64 | options = {
65 | method: 'POST',
66 | headers: { 'Content-Type': 'application/json' },
67 | body: JSON.stringify(data)
68 | };
69 | break;
70 |
71 | case 'update':
72 | url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
73 | options = {
74 | method: 'PUT',
75 | headers: { 'Content-Type': 'application/json' },
76 | body: JSON.stringify(data)
77 | };
78 | break;
79 |
80 | case 'delete':
81 | url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
82 | options = { method: 'DELETE' };
83 | break;
84 |
85 | case 'batch_create':
86 | url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
87 | options = {
88 | method: 'POST',
89 | headers: { 'Content-Type': 'application/json' },
90 | body: JSON.stringify({ elements: data })
91 | };
92 | break;
93 |
94 | default:
95 | logger.warn(`Unknown sync operation: ${operation}`);
96 | return null;
97 | }
98 |
99 | logger.debug(`Syncing to canvas: ${operation}`, { url, data });
100 | const response = await fetch(url, options);
101 |
102 | // Parse JSON response regardless of HTTP status
103 | const result = await response.json() as ApiResponse;
104 |
105 | if (!response.ok) {
106 | logger.warn(`Canvas sync returned error status: ${response.status}`, result);
107 | throw new Error(result.error || `Canvas sync failed: ${response.status} ${response.statusText}`);
108 | }
109 |
110 | logger.debug(`Canvas sync successful: ${operation}`, result);
111 | return result as SyncResponse;
112 |
113 | } catch (error) {
114 | logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
115 | // Don't throw - we want MCP operations to work even if canvas is unavailable
116 | return null;
117 | }
118 | }
119 |
120 | // Helper to sync element creation to canvas
121 | async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
122 | const result = await syncToCanvas('create', elementData);
123 | return result?.element || elementData;
124 | }
125 |
126 | // Helper to sync element update to canvas
127 | async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
128 | const result = await syncToCanvas('update', elementData);
129 | return result?.element || null;
130 | }
131 |
132 | // Helper to sync element deletion to canvas
133 | async function deleteElementOnCanvas(elementId: string): Promise<any> {
134 | const result = await syncToCanvas('delete', { id: elementId });
135 | return result;
136 | }
137 |
138 | // Helper to sync batch creation to canvas
139 | async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
140 | const result = await syncToCanvas('batch_create', elementsData);
141 | return result?.elements || elementsData;
142 | }
143 |
144 | // Helper to fetch element from canvas
145 | async function getElementFromCanvas(elementId: string): Promise<ServerElement | null> {
146 | if (!ENABLE_CANVAS_SYNC) {
147 | logger.debug('Canvas sync disabled, skipping fetch');
148 | return null;
149 | }
150 |
151 | try {
152 | const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`);
153 | if (!response.ok) {
154 | logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
155 | return null;
156 | }
157 | const data = await response.json() as { element?: ServerElement };
158 | return data.element || null;
159 | } catch (error) {
160 | logger.error('Error fetching element from canvas:', error);
161 | return null;
162 | }
163 | }
164 |
165 | // In-memory storage for scene state
166 | interface SceneState {
167 | theme: string;
168 | viewport: { x: number; y: number; zoom: number };
169 | selectedElements: Set<string>;
170 | groups: Map<string, string[]>;
171 | }
172 |
173 | const sceneState: SceneState = {
174 | theme: 'light',
175 | viewport: { x: 0, y: 0, zoom: 1 },
176 | selectedElements: new Set(),
177 | groups: new Map()
178 | };
179 |
180 | // Schema definitions using zod
181 | const ElementSchema = z.object({
182 | type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
183 | x: z.number(),
184 | y: z.number(),
185 | width: z.number().optional(),
186 | height: z.number().optional(),
187 | points: z.array(z.object({ x: z.number(), y: z.number() })).optional(),
188 | backgroundColor: z.string().optional(),
189 | strokeColor: z.string().optional(),
190 | strokeWidth: z.number().optional(),
191 | roughness: z.number().optional(),
192 | opacity: z.number().optional(),
193 | text: z.string().optional(),
194 | fontSize: z.number().optional(),
195 | fontFamily: z.string().optional(),
196 | groupIds: z.array(z.string()).optional(),
197 | locked: z.boolean().optional()
198 | });
199 |
200 | const ElementIdSchema = z.object({
201 | id: z.string()
202 | });
203 |
204 | const ElementIdsSchema = z.object({
205 | elementIds: z.array(z.string())
206 | });
207 |
208 | const GroupIdSchema = z.object({
209 | groupId: z.string()
210 | });
211 |
212 | const AlignElementsSchema = z.object({
213 | elementIds: z.array(z.string()),
214 | alignment: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
215 | });
216 |
217 | const DistributeElementsSchema = z.object({
218 | elementIds: z.array(z.string()),
219 | direction: z.enum(['horizontal', 'vertical'])
220 | });
221 |
222 | const QuerySchema = z.object({
223 | type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
224 | filter: z.record(z.any()).optional()
225 | });
226 |
227 | const ResourceSchema = z.object({
228 | resource: z.enum(['scene', 'library', 'theme', 'elements'])
229 | });
230 |
231 | // Tool definitions
232 | const tools: Tool[] = [
233 | {
234 | name: 'create_element',
235 | description: 'Create a new Excalidraw element',
236 | inputSchema: {
237 | type: 'object',
238 | properties: {
239 | type: {
240 | type: 'string',
241 | enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
242 | },
243 | x: { type: 'number' },
244 | y: { type: 'number' },
245 | width: { type: 'number' },
246 | height: { type: 'number' },
247 | backgroundColor: { type: 'string' },
248 | strokeColor: { type: 'string' },
249 | strokeWidth: { type: 'number' },
250 | roughness: { type: 'number' },
251 | opacity: { type: 'number' },
252 | text: { type: 'string' },
253 | fontSize: { type: 'number' },
254 | fontFamily: { type: 'string' }
255 | },
256 | required: ['type', 'x', 'y']
257 | }
258 | },
259 | {
260 | name: 'update_element',
261 | description: 'Update an existing Excalidraw element',
262 | inputSchema: {
263 | type: 'object',
264 | properties: {
265 | id: { type: 'string' },
266 | type: {
267 | type: 'string',
268 | enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
269 | },
270 | x: { type: 'number' },
271 | y: { type: 'number' },
272 | width: { type: 'number' },
273 | height: { type: 'number' },
274 | backgroundColor: { type: 'string' },
275 | strokeColor: { type: 'string' },
276 | strokeWidth: { type: 'number' },
277 | roughness: { type: 'number' },
278 | opacity: { type: 'number' },
279 | text: { type: 'string' },
280 | fontSize: { type: 'number' },
281 | fontFamily: { type: 'string' }
282 | },
283 | required: ['id']
284 | }
285 | },
286 | {
287 | name: 'delete_element',
288 | description: 'Delete an Excalidraw element',
289 | inputSchema: {
290 | type: 'object',
291 | properties: {
292 | id: { type: 'string' }
293 | },
294 | required: ['id']
295 | }
296 | },
297 | {
298 | name: 'query_elements',
299 | description: 'Query Excalidraw elements with optional filters',
300 | inputSchema: {
301 | type: 'object',
302 | properties: {
303 | type: {
304 | type: 'string',
305 | enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
306 | },
307 | filter: {
308 | type: 'object',
309 | additionalProperties: true
310 | }
311 | }
312 | }
313 | },
314 | {
315 | name: 'get_resource',
316 | description: 'Get an Excalidraw resource',
317 | inputSchema: {
318 | type: 'object',
319 | properties: {
320 | resource: {
321 | type: 'string',
322 | enum: ['scene', 'library', 'theme', 'elements']
323 | }
324 | },
325 | required: ['resource']
326 | }
327 | },
328 | {
329 | name: 'group_elements',
330 | description: 'Group multiple elements together',
331 | inputSchema: {
332 | type: 'object',
333 | properties: {
334 | elementIds: {
335 | type: 'array',
336 | items: { type: 'string' }
337 | }
338 | },
339 | required: ['elementIds']
340 | }
341 | },
342 | {
343 | name: 'ungroup_elements',
344 | description: 'Ungroup a group of elements',
345 | inputSchema: {
346 | type: 'object',
347 | properties: {
348 | groupId: { type: 'string' }
349 | },
350 | required: ['groupId']
351 | }
352 | },
353 | {
354 | name: 'align_elements',
355 | description: 'Align elements to a specific position',
356 | inputSchema: {
357 | type: 'object',
358 | properties: {
359 | elementIds: {
360 | type: 'array',
361 | items: { type: 'string' }
362 | },
363 | alignment: {
364 | type: 'string',
365 | enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
366 | }
367 | },
368 | required: ['elementIds', 'alignment']
369 | }
370 | },
371 | {
372 | name: 'distribute_elements',
373 | description: 'Distribute elements evenly',
374 | inputSchema: {
375 | type: 'object',
376 | properties: {
377 | elementIds: {
378 | type: 'array',
379 | items: { type: 'string' }
380 | },
381 | direction: {
382 | type: 'string',
383 | enum: ['horizontal', 'vertical']
384 | }
385 | },
386 | required: ['elementIds', 'direction']
387 | }
388 | },
389 | {
390 | name: 'lock_elements',
391 | description: 'Lock elements to prevent modification',
392 | inputSchema: {
393 | type: 'object',
394 | properties: {
395 | elementIds: {
396 | type: 'array',
397 | items: { type: 'string' }
398 | }
399 | },
400 | required: ['elementIds']
401 | }
402 | },
403 | {
404 | name: 'unlock_elements',
405 | description: 'Unlock elements to allow modification',
406 | inputSchema: {
407 | type: 'object',
408 | properties: {
409 | elementIds: {
410 | type: 'array',
411 | items: { type: 'string' }
412 | }
413 | },
414 | required: ['elementIds']
415 | }
416 | },
417 | {
418 | name: 'create_from_mermaid',
419 | description: 'Convert a Mermaid diagram to Excalidraw elements and render them on the canvas',
420 | inputSchema: {
421 | type: 'object',
422 | properties: {
423 | mermaidDiagram: {
424 | type: 'string',
425 | description: 'The Mermaid diagram definition (e.g., "graph TD; A-->B; B-->C;")'
426 | },
427 | config: {
428 | type: 'object',
429 | description: 'Optional Mermaid configuration',
430 | properties: {
431 | startOnLoad: { type: 'boolean' },
432 | flowchart: {
433 | type: 'object',
434 | properties: {
435 | curve: { type: 'string', enum: ['linear', 'basis'] }
436 | }
437 | },
438 | themeVariables: {
439 | type: 'object',
440 | properties: {
441 | fontSize: { type: 'string' }
442 | }
443 | },
444 | maxEdges: { type: 'number' },
445 | maxTextSize: { type: 'number' }
446 | }
447 | }
448 | },
449 | required: ['mermaidDiagram']
450 | }
451 | },
452 | {
453 | name: 'batch_create_elements',
454 | description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
455 | inputSchema: {
456 | type: 'object',
457 | properties: {
458 | elements: {
459 | type: 'array',
460 | items: {
461 | type: 'object',
462 | properties: {
463 | type: {
464 | type: 'string',
465 | enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
466 | },
467 | x: { type: 'number' },
468 | y: { type: 'number' },
469 | width: { type: 'number' },
470 | height: { type: 'number' },
471 | backgroundColor: { type: 'string' },
472 | strokeColor: { type: 'string' },
473 | strokeWidth: { type: 'number' },
474 | roughness: { type: 'number' },
475 | opacity: { type: 'number' },
476 | text: { type: 'string' },
477 | fontSize: { type: 'number' },
478 | fontFamily: { type: 'string' }
479 | },
480 | required: ['type', 'x', 'y']
481 | }
482 | }
483 | },
484 | required: ['elements']
485 | }
486 | }
487 | ];
488 |
489 | // Initialize MCP server
490 | const server = new Server(
491 | {
492 | name: "mcp-excalidraw-server",
493 | version: "1.0.2",
494 | description: "Advanced MCP server for Excalidraw with real-time canvas"
495 | },
496 | {
497 | capabilities: {
498 | tools: Object.fromEntries(tools.map(tool => [tool.name, {
499 | description: tool.description,
500 | inputSchema: tool.inputSchema
501 | }]))
502 | }
503 | }
504 | );
505 |
506 | // Helper function to convert text property to label format for Excalidraw
507 | function convertTextToLabel(element: ServerElement): ServerElement {
508 | const { text, ...rest } = element;
509 | if (text) {
510 | // For standalone text elements, keep text as direct property
511 | if (element.type === 'text') {
512 | return element; // Keep text as direct property
513 | }
514 | // For other elements (rectangle, ellipse, diamond), convert to label format
515 | return {
516 | ...rest,
517 | label: { text }
518 | } as ServerElement;
519 | }
520 | return element;
521 | }
522 |
523 | // Set up request handler for tool calls
524 | server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
525 | try {
526 | const { name, arguments: args } = request.params;
527 | logger.info(`Handling tool call: ${name}`);
528 |
529 | switch (name) {
530 | case 'create_element': {
531 | const params = ElementSchema.parse(args);
532 | logger.info('Creating element via MCP', { type: params.type });
533 |
534 | const id = generateId();
535 | const element: ServerElement = {
536 | id,
537 | ...params,
538 | createdAt: new Date().toISOString(),
539 | updatedAt: new Date().toISOString(),
540 | version: 1
541 | };
542 |
543 | // Convert text to label format for Excalidraw
544 | const excalidrawElement = convertTextToLabel(element);
545 |
546 | // Create element directly on HTTP server (no local storage)
547 | const canvasElement = await createElementOnCanvas(excalidrawElement);
548 |
549 | if (!canvasElement) {
550 | throw new Error('Failed to create element: HTTP server unavailable');
551 | }
552 |
553 | logger.info('Element created via MCP and synced to canvas', {
554 | id: excalidrawElement.id,
555 | type: excalidrawElement.type,
556 | synced: !!canvasElement
557 | });
558 |
559 | return {
560 | content: [{
561 | type: 'text',
562 | text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
563 | }]
564 | };
565 | }
566 |
567 | case 'update_element': {
568 | const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args);
569 | const { id, ...updates } = params;
570 |
571 | if (!id) throw new Error('Element ID is required');
572 |
573 | // Build update payload with timestamp and version increment
574 | const updatePayload: Partial<ServerElement> & { id: string } = {
575 | id,
576 | ...updates,
577 | updatedAt: new Date().toISOString()
578 | };
579 |
580 | // Convert text to label format for Excalidraw
581 | const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
582 |
583 | // Update element directly on HTTP server (no local storage)
584 | const canvasElement = await updateElementOnCanvas(excalidrawElement);
585 |
586 | if (!canvasElement) {
587 | throw new Error('Failed to update element: HTTP server unavailable or element not found');
588 | }
589 |
590 | logger.info('Element updated via MCP and synced to canvas', {
591 | id: excalidrawElement.id,
592 | synced: !!canvasElement
593 | });
594 |
595 | return {
596 | content: [{
597 | type: 'text',
598 | text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
599 | }]
600 | };
601 | }
602 |
603 | case 'delete_element': {
604 | const params = ElementIdSchema.parse(args);
605 | const { id } = params;
606 |
607 | // Delete element directly on HTTP server (no local storage)
608 | const canvasResult = await deleteElementOnCanvas(id);
609 |
610 | if (!canvasResult || !(canvasResult as ApiResponse).success) {
611 | throw new Error('Failed to delete element: HTTP server unavailable or element not found');
612 | }
613 |
614 | const result = { id, deleted: true, syncedToCanvas: true };
615 | logger.info('Element deleted via MCP and synced to canvas', result);
616 |
617 | return {
618 | content: [{
619 | type: 'text',
620 | text: `Element deleted successfully!\n\n${JSON.stringify(result, null, 2)}\n\n✅ Synced to canvas`
621 | }]
622 | };
623 | }
624 |
625 | case 'query_elements': {
626 | const params = QuerySchema.parse(args || {});
627 | const { type, filter } = params;
628 |
629 | try {
630 | // Build query parameters
631 | const queryParams = new URLSearchParams();
632 | if (type) queryParams.set('type', type);
633 | if (filter) {
634 | Object.entries(filter).forEach(([key, value]) => {
635 | queryParams.set(key, String(value));
636 | });
637 | }
638 |
639 | // Query elements from HTTP server
640 | const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
641 | const response = await fetch(url);
642 |
643 | if (!response.ok) {
644 | throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
645 | }
646 |
647 | const data = await response.json() as ApiResponse;
648 | const results = data.elements || [];
649 |
650 | return {
651 | content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
652 | };
653 | } catch (error) {
654 | throw new Error(`Failed to query elements: ${(error as Error).message}`);
655 | }
656 | }
657 |
658 | case 'get_resource': {
659 | const params = ResourceSchema.parse(args);
660 | const { resource } = params;
661 | logger.info('Getting resource', { resource });
662 |
663 | let result: any;
664 | switch (resource) {
665 | case 'scene':
666 | result = {
667 | theme: sceneState.theme,
668 | viewport: sceneState.viewport,
669 | selectedElements: Array.from(sceneState.selectedElements)
670 | };
671 | break;
672 | case 'library':
673 | case 'elements':
674 | try {
675 | // Get elements from HTTP server
676 | const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
677 | if (!response.ok) {
678 | throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
679 | }
680 | const data = await response.json() as ApiResponse;
681 | result = {
682 | elements: data.elements || []
683 | };
684 | } catch (error) {
685 | throw new Error(`Failed to get elements: ${(error as Error).message}`);
686 | }
687 | break;
688 | case 'theme':
689 | result = {
690 | theme: sceneState.theme
691 | };
692 | break;
693 | default:
694 | throw new Error(`Unknown resource: ${resource}`);
695 | }
696 |
697 | return {
698 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
699 | };
700 | }
701 |
702 | case 'group_elements': {
703 | const params = ElementIdsSchema.parse(args);
704 | const { elementIds } = params;
705 |
706 | try {
707 | const groupId = generateId();
708 | sceneState.groups.set(groupId, elementIds);
709 |
710 | // Update elements on canvas with proper error handling
711 | // Fetch existing groups and append new groupId to preserve multi-group membership
712 | const updatePromises = elementIds.map(async (id) => {
713 | const element = await getElementFromCanvas(id);
714 | const existingGroups = element?.groupIds || [];
715 | const updatedGroupIds = [...existingGroups, groupId];
716 | return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
717 | });
718 |
719 | const results = await Promise.all(updatePromises);
720 | const successCount = results.filter(result => result).length;
721 |
722 | if (successCount === 0) {
723 | sceneState.groups.delete(groupId); // Rollback local state
724 | throw new Error('Failed to group any elements: HTTP server unavailable');
725 | }
726 |
727 | logger.info('Grouping elements', { elementIds, groupId, successCount });
728 |
729 | const result = { groupId, elementIds, successCount };
730 | return {
731 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
732 | };
733 | } catch (error) {
734 | throw new Error(`Failed to group elements: ${(error as Error).message}`);
735 | }
736 | }
737 |
738 | case 'ungroup_elements': {
739 | const params = GroupIdSchema.parse(args);
740 | const { groupId } = params;
741 |
742 | if (!sceneState.groups.has(groupId)) {
743 | throw new Error(`Group ${groupId} not found`);
744 | }
745 |
746 | try {
747 | const elementIds = sceneState.groups.get(groupId);
748 | sceneState.groups.delete(groupId);
749 |
750 | // Update elements on canvas, removing only this specific groupId
751 | const updatePromises = (elementIds ?? []).map(async (id) => {
752 | // Fetch current element to get existing groupIds
753 | const element = await getElementFromCanvas(id);
754 | if (!element) {
755 | logger.warn(`Element ${id} not found on canvas, skipping ungroup`);
756 | return null;
757 | }
758 |
759 | // Remove only the specific groupId, preserve others
760 | const updatedGroupIds = (element.groupIds || []).filter(gid => gid !== groupId);
761 | return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
762 | });
763 |
764 | const results = await Promise.all(updatePromises);
765 | const successCount = results.filter(result => result !== null).length;
766 |
767 | if (successCount === 0) {
768 | logger.warn('Failed to ungroup any elements: HTTP server unavailable or elements not found');
769 | }
770 |
771 | logger.info('Ungrouping elements', { groupId, elementIds, successCount });
772 |
773 | const result = { groupId, ungrouped: true, elementIds, successCount };
774 | return {
775 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
776 | };
777 | } catch (error) {
778 | throw new Error(`Failed to ungroup elements: ${(error as Error).message}`);
779 | }
780 | }
781 |
782 | case 'align_elements': {
783 | const params = AlignElementsSchema.parse(args);
784 | const { elementIds, alignment } = params;
785 |
786 | // Implementation would align elements based on the specified alignment
787 | logger.info('Aligning elements', { elementIds, alignment });
788 |
789 | const result = { aligned: true, elementIds, alignment };
790 | return {
791 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
792 | };
793 | }
794 |
795 | case 'distribute_elements': {
796 | const params = DistributeElementsSchema.parse(args);
797 | const { elementIds, direction } = params;
798 |
799 | // Implementation would distribute elements based on the specified direction
800 | logger.info('Distributing elements', { elementIds, direction });
801 |
802 | const result = { distributed: true, elementIds, direction };
803 | return {
804 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
805 | };
806 | }
807 |
808 | case 'lock_elements': {
809 | const params = ElementIdsSchema.parse(args);
810 | const { elementIds } = params;
811 |
812 | try {
813 | // Lock elements through HTTP API updates
814 | const updatePromises = elementIds.map(async (id) => {
815 | return await updateElementOnCanvas({ id, locked: true });
816 | });
817 |
818 | const results = await Promise.all(updatePromises);
819 | const successCount = results.filter(result => result).length;
820 |
821 | if (successCount === 0) {
822 | throw new Error('Failed to lock any elements: HTTP server unavailable');
823 | }
824 |
825 | const result = { locked: true, elementIds, successCount };
826 | return {
827 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
828 | };
829 | } catch (error) {
830 | throw new Error(`Failed to lock elements: ${(error as Error).message}`);
831 | }
832 | }
833 |
834 | case 'unlock_elements': {
835 | const params = ElementIdsSchema.parse(args);
836 | const { elementIds } = params;
837 |
838 | try {
839 | // Unlock elements through HTTP API updates
840 | const updatePromises = elementIds.map(async (id) => {
841 | return await updateElementOnCanvas({ id, locked: false });
842 | });
843 |
844 | const results = await Promise.all(updatePromises);
845 | const successCount = results.filter(result => result).length;
846 |
847 | if (successCount === 0) {
848 | throw new Error('Failed to unlock any elements: HTTP server unavailable');
849 | }
850 |
851 | const result = { unlocked: true, elementIds, successCount };
852 | return {
853 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
854 | };
855 | } catch (error) {
856 | throw new Error(`Failed to unlock elements: ${(error as Error).message}`);
857 | }
858 | }
859 |
860 | case 'create_from_mermaid': {
861 | const params = z.object({
862 | mermaidDiagram: z.string(),
863 | config: z.object({
864 | startOnLoad: z.boolean().optional(),
865 | flowchart: z.object({
866 | curve: z.enum(['linear', 'basis']).optional()
867 | }).optional(),
868 | themeVariables: z.object({
869 | fontSize: z.string().optional()
870 | }).optional(),
871 | maxEdges: z.number().optional(),
872 | maxTextSize: z.number().optional()
873 | }).optional()
874 | }).parse(args);
875 |
876 | logger.info('Creating Excalidraw elements from Mermaid diagram via MCP', {
877 | diagramLength: params.mermaidDiagram.length,
878 | hasConfig: !!params.config
879 | });
880 |
881 | try {
882 | // Send the Mermaid diagram to the frontend via the API
883 | // The frontend will use mermaid-to-excalidraw to convert it
884 | const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
885 | method: 'POST',
886 | headers: { 'Content-Type': 'application/json' },
887 | body: JSON.stringify({
888 | mermaidDiagram: params.mermaidDiagram,
889 | config: params.config
890 | })
891 | });
892 |
893 | if (!response.ok) {
894 | throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
895 | }
896 |
897 | const result = await response.json() as ApiResponse;
898 |
899 | logger.info('Mermaid diagram sent to frontend for conversion', {
900 | success: result.success
901 | });
902 |
903 | return {
904 | content: [{
905 | type: 'text',
906 | 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.`
907 | }]
908 | };
909 | } catch (error) {
910 | throw new Error(`Failed to process Mermaid diagram: ${(error as Error).message}`);
911 | }
912 | }
913 |
914 | case 'batch_create_elements': {
915 | const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
916 | logger.info('Batch creating elements via MCP', { count: params.elements.length });
917 |
918 | const createdElements: ServerElement[] = [];
919 |
920 | // Create each element with unique ID
921 | for (const elementData of params.elements) {
922 | const id = generateId();
923 | const element: ServerElement = {
924 | id,
925 | ...elementData,
926 | createdAt: new Date().toISOString(),
927 | updatedAt: new Date().toISOString(),
928 | version: 1
929 | };
930 |
931 | // Convert text to label format for Excalidraw
932 | const excalidrawElement = convertTextToLabel(element);
933 | createdElements.push(excalidrawElement);
934 | }
935 |
936 | // Create all elements directly on HTTP server (no local storage)
937 | const canvasElements = await batchCreateElementsOnCanvas(createdElements);
938 |
939 | if (!canvasElements) {
940 | throw new Error('Failed to batch create elements: HTTP server unavailable');
941 | }
942 |
943 | const result = {
944 | success: true,
945 | elements: canvasElements,
946 | count: canvasElements.length,
947 | syncedToCanvas: true
948 | };
949 |
950 | logger.info('Batch elements created via MCP and synced to canvas', {
951 | count: result.count,
952 | synced: result.syncedToCanvas
953 | });
954 |
955 | return {
956 | content: [{
957 | type: 'text',
958 | 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)'}`
959 | }]
960 | };
961 | }
962 |
963 | default:
964 | throw new Error(`Unknown tool: ${name}`);
965 | }
966 | } catch (error) {
967 | logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
968 | return {
969 | content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
970 | isError: true
971 | };
972 | }
973 | });
974 |
975 | // Set up request handler for listing available tools
976 | server.setRequestHandler(ListToolsRequestSchema, async () => {
977 | logger.info('Listing available tools');
978 | return { tools };
979 | });
980 |
981 | // Start server with transport based on mode
982 | async function runServer(): Promise<void> {
983 | try {
984 | logger.info('Starting Excalidraw MCP server...');
985 |
986 | const transportMode = process.env.MCP_TRANSPORT_MODE || 'stdio';
987 | let transport;
988 |
989 | if (transportMode === 'http') {
990 | const port = parseInt(process.env.PORT || '3000', 10);
991 | const host = process.env.HOST || 'localhost';
992 |
993 | logger.info(`Starting HTTP server on ${host}:${port}`);
994 | // Here you would create an HTTP transport
995 | // This is a placeholder - actual HTTP transport implementation would need to be added
996 | transport = new StdioServerTransport(); // Fallback to stdio for now
997 | } else {
998 | // Default to stdio transport
999 | transport = new StdioServerTransport();
1000 | }
1001 |
1002 | // Add a debug message before connecting
1003 | logger.debug('Connecting to transport...');
1004 |
1005 | await server.connect(transport);
1006 | logger.info(`Excalidraw MCP server running on ${transportMode}`);
1007 |
1008 | // Keep the process running
1009 | process.stdin.resume();
1010 | } catch (error) {
1011 | logger.error('Error starting server:', error);
1012 | process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`);
1013 | process.exit(1);
1014 | }
1015 | }
1016 |
1017 | // Add global error handlers
1018 | process.on('uncaughtException', (error: Error) => {
1019 | logger.error('Uncaught exception:', error);
1020 | process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
1021 | setTimeout(() => process.exit(1), 1000);
1022 | });
1023 |
1024 | process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
1025 | logger.error('Unhandled promise rejection:', reason);
1026 | process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
1027 | setTimeout(() => process.exit(1), 1000);
1028 | });
1029 |
1030 | // For testing and debugging purposes
1031 | if (process.env.DEBUG === 'true') {
1032 | logger.debug('Debug mode enabled');
1033 | }
1034 |
1035 | // Start the server if this file is run directly
1036 | if (fileURLToPath(import.meta.url) === process.argv[1]) {
1037 | runServer().catch(error => {
1038 | logger.error('Failed to start server:', error);
1039 | process.exit(1);
1040 | });
1041 | }
1042 |
1043 | export default runServer;
```