Refactor project structure and enhance TypeScript support

- Updated package.json to point to compiled TypeScript files in the dist directory.
- Improved TypeScript configuration with stricter type checks and removed JavaScript support.
- Migrated frontend entry point to TypeScript and added a new App component with enhanced functionality.
- Implemented a new server structure with TypeScript, including WebSocket support and improved element management.
- Updated README to reflect changes in architecture and usage instructions.
- Added comprehensive type definitions for Excalidraw elements and server responses.
This commit is contained in:
yctimlin
2025-08-20 15:31:42 +00:00
parent fcd3cc077a
commit 5043a67385
11 changed files with 841 additions and 662 deletions
+60 -33
View File
@@ -1,6 +1,6 @@
# MCP Excalidraw Server: Advanced Live Visual Diagramming with AI Integration # MCP Excalidraw Server: Advanced Live Visual Diagramming with AI Integration
A comprehensive system that combines **Excalidraw's powerful drawing capabilities** with **Model Context Protocol (MCP)** integration, enabling AI agents to create and manipulate diagrams in real-time on a live canvas. A comprehensive **TypeScript-based** system that combines **Excalidraw's powerful drawing capabilities** with **Model Context Protocol (MCP)** integration, enabling AI agents to create and manipulate diagrams in real-time on a live canvas.
## 🚦 Current Status & Version Information ## 🚦 Current Status & Version Information
@@ -54,6 +54,12 @@ For the most stable experience, we recommend using the local development setup.
## 🌟 Key Features ## 🌟 Key Features
### **Modern TypeScript Architecture**
- **Full TypeScript Migration**: Complete type safety for backend and frontend
- **Comprehensive Type Definitions**: Excalidraw elements, API responses, WebSocket messages
- **Strict Type Checking**: Enhanced development experience and compile-time error detection
- **Type-Safe React Components**: TSX components with proper props typing
### **Real-time Canvas Integration** ### **Real-time Canvas Integration**
- Elements created via MCP appear instantly on the live canvas - Elements created via MCP appear instantly on the live canvas
- WebSocket-based real-time synchronization - WebSocket-based real-time synchronization
@@ -71,8 +77,8 @@ For the most stable experience, we recommend using the local development setup.
- **Advanced Features**: grouping, alignment, distribution, locking - **Advanced Features**: grouping, alignment, distribution, locking
### **Robust Architecture** ### **Robust Architecture**
- Express.js backend with REST API + WebSocket - TypeScript-based Express.js backend with REST API + WebSocket
- React frontend with official Excalidraw package - React frontend with official Excalidraw package and TypeScript
- Dual-path element loading for reliability - Dual-path element loading for reliability
- Auto-reconnection and error handling - Auto-reconnection and error handling
@@ -133,10 +139,13 @@ docker run -p 3000:3000 mcp-excalidraw-server
| Script | Description | | Script | Description |
|--------|-------------| |--------|-------------|
| `npm start` | Start MCP server (`src/index.js`) | | `npm start` | Build and start MCP server (`dist/index.js`) |
| `npm run canvas` | Start canvas server (`src/server.js`) | | `npm run canvas` | Build and start canvas server (`dist/server.js`) |
| `npm run build` | Build frontend for production | | `npm run build` | Build both frontend and TypeScript backend |
| `npm run dev` | Start canvas + Vite dev server | | `npm run build:frontend` | Build React frontend only |
| `npm run build:server` | Compile TypeScript backend to JavaScript |
| `npm run dev` | Start TypeScript watch mode + Vite dev server |
| `npm run type-check` | Run TypeScript type checking without compilation |
| `npm run production` | Build + start in production mode | | `npm run production` | Build + start in production mode |
## 🎯 Usage Guide ## 🎯 Usage Guide
@@ -225,13 +234,13 @@ For the **local development version** (most stable), add this configuration to y
"mcpServers": { "mcpServers": {
"excalidraw": { "excalidraw": {
"command": "node", "command": "node",
"args": ["/absolute/path/to/mcp_excalidraw/src/index.js"] "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
} }
} }
} }
``` ```
**Important**: Replace `/absolute/path/to/mcp_excalidraw` with the actual absolute path to your cloned repository. **Important**: Replace `/absolute/path/to/mcp_excalidraw` with the actual absolute path to your cloned repository. Note that the path now points to `dist/index.js` (the compiled TypeScript output).
### **🔧 Alternative Configurations (Beta)** ### **🔧 Alternative Configurations (Beta)**
@@ -272,7 +281,7 @@ Add to your `.cursor/mcp.json`:
"mcpServers": { "mcpServers": {
"excalidraw": { "excalidraw": {
"command": "node", "command": "node",
"args": ["/absolute/path/to/mcp_excalidraw/src/index.js"] "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
} }
} }
} }
@@ -288,7 +297,7 @@ For VS Code MCP extension, add to your settings:
"servers": { "servers": {
"excalidraw": { "excalidraw": {
"command": "node", "command": "node",
"args": ["/absolute/path/to/mcp_excalidraw/src/index.js"] "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
} }
} }
} }
@@ -342,22 +351,29 @@ The canvas server provides these REST endpoints:
## 🏗️ Development Architecture ## 🏗️ Development Architecture
### **Frontend** (`frontend/src/`) ### **Frontend** (`frontend/src/`)
- **React + Vite**: Modern build system - **React + TypeScript**: Modern TSX components with full type safety
- **Official Excalidraw**: `@excalidraw/excalidraw` package - **Vite Build System**: Fast development and optimized production builds
- **WebSocket Client**: Real-time element sync - **Official Excalidraw**: `@excalidraw/excalidraw` package with TypeScript types
- **Clean UI**: Production-ready interface - **WebSocket Client**: Type-safe real-time element synchronization
- **Clean UI**: Production-ready interface with proper TypeScript typing
### **Canvas Server** (`src/server.js`) ### **Canvas Server** (`src/server.ts` → `dist/server.js`)
- **Express.js**: REST API + static file serving - **TypeScript + Express.js**: Fully typed REST API + static file serving
- **WebSocket**: Real-time client communication - **WebSocket**: Type-safe real-time client communication
- **Element Storage**: In-memory with persistence options - **Element Storage**: In-memory with comprehensive type definitions
- **CORS**: Cross-origin support - **CORS**: Cross-origin support with proper typing
### **MCP Server** (`src/index.js`) ### **MCP Server** (`src/index.ts` → `dist/index.js`)
- **MCP Protocol**: Standard Model Context Protocol - **TypeScript MCP Protocol**: Type-safe Model Context Protocol implementation
- **Canvas Sync**: HTTP requests to canvas server - **Canvas Sync**: Strongly typed HTTP requests to canvas server
- **Element Management**: Full CRUD operations - **Element Management**: Full CRUD operations with comprehensive type checking
- **Batch Support**: Complex diagram creation - **Batch Support**: Type-safe complex diagram creation
### **Type System** (`src/types.ts`)
- **Excalidraw Element Types**: Complete type definitions for all element types
- **API Response Types**: Strongly typed REST API interfaces
- **WebSocket Message Types**: Type-safe real-time communication
- **Server Element Types**: Enhanced element types with metadata
## 🐛 Troubleshooting ## 🐛 Troubleshooting
@@ -390,6 +406,8 @@ The canvas server provides these REST endpoints:
- Delete `node_modules` and run `npm install` - Delete `node_modules` and run `npm install`
- Check Node.js version (requires 16+) - Check Node.js version (requires 16+)
- Ensure all dependencies are installed - Ensure all dependencies are installed
- Run `npm run type-check` to identify TypeScript issues
- Verify `dist/` directory is created after `npm run build:server`
## 📋 Project Structure ## 📋 Project Structure
@@ -397,16 +415,23 @@ The canvas server provides these REST endpoints:
mcp_excalidraw/ mcp_excalidraw/
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
│ │ ├── App.jsx # Main React component │ │ ├── App.tsx # Main React component (TypeScript)
│ │ └── main.jsx # React entry point │ │ └── main.tsx # React entry point (TypeScript)
│ └── index.html # HTML template │ └── index.html # HTML template
├── src/ ├── src/ (TypeScript Source)
│ ├── index.js # MCP server │ ├── index.ts # MCP server (TypeScript)
│ ├── server.js # Canvas server (Express + WebSocket) │ ├── server.ts # Canvas server (Express + WebSocket, TypeScript)
│ ├── types.js # Shared types and utilities │ ├── types.ts # Comprehensive type definitions
│ └── utils/ │ └── utils/
│ └── logger.js # Logging utility │ └── logger.ts # Logging utility (TypeScript)
├── dist/ # Built frontend (generated) ├── dist/ (Compiled Output)
│ ├── index.js # Compiled MCP server
│ ├── server.js # Compiled Canvas server
│ ├── types.js # Compiled type definitions
│ ├── utils/
│ │ └── logger.js # Compiled logging utility
│ └── frontend/ # Built React frontend
├── tsconfig.json # TypeScript configuration
├── vite.config.js # Vite build configuration ├── vite.config.js # Vite build configuration
├── package.json # Dependencies and scripts ├── package.json # Dependencies and scripts
└── README.md # This file └── README.md # This file
@@ -414,10 +439,12 @@ mcp_excalidraw/
## 🔮 Development Roadmap ## 🔮 Development Roadmap
-**TypeScript Migration**: Complete type safety for enhanced development experience
- **NPM Package**: Resolving MCP tool registration issues - **NPM Package**: Resolving MCP tool registration issues
- **Docker Deployment**: Improving canvas synchronization - **Docker Deployment**: Improving canvas synchronization
- **Enhanced Features**: Additional MCP tools and capabilities - **Enhanced Features**: Additional MCP tools and capabilities
- **Performance Optimization**: Real-time sync improvements - **Performance Optimization**: Real-time sync improvements
- **Advanced TypeScript Features**: Stricter type checking and advanced type utilities
## 🤝 Contributing ## 🤝 Contributing
+132 -61
View File
@@ -1,21 +1,86 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { Excalidraw, convertToExcalidrawElements, CaptureUpdateAction } from '@excalidraw/excalidraw' import {
Excalidraw,
convertToExcalidrawElements,
CaptureUpdateAction,
ExcalidrawAPIRefValue,
ExcalidrawElement
} from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css' import '@excalidraw/excalidraw/index.css'
// Type definitions
interface ServerElement {
id: string;
type: string;
x: number;
y: number;
width?: number;
height?: number;
backgroundColor?: string;
strokeColor?: string;
strokeWidth?: number;
roughness?: number;
opacity?: number;
text?: string;
fontSize?: number;
fontFamily?: string | number;
label?: {
text: string;
};
createdAt?: string;
updatedAt?: string;
version?: number;
syncedAt?: string;
source?: string;
syncTimestamp?: string;
boundElements?: any[] | null;
containerId?: string | null;
locked?: boolean;
}
interface WebSocketMessage {
type: string;
element?: ServerElement;
elements?: ServerElement[];
elementId?: string;
count?: number;
timestamp?: string;
source?: string;
}
interface ApiResponse {
success: boolean;
elements?: ServerElement[];
element?: ServerElement;
count?: number;
error?: string;
message?: string;
}
interface ElementBinding {
id: string;
type: 'text' | 'arrow';
}
type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
// Helper function to clean elements for Excalidraw // Helper function to clean elements for Excalidraw
const cleanElementForExcalidraw = (element) => { const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
const { const {
createdAt, createdAt,
updatedAt, updatedAt,
version, version,
syncedAt,
source,
syncTimestamp,
...cleanElement ...cleanElement
} = element; } = element;
return cleanElement; return cleanElement;
} }
// Helper function to validate and fix element binding data // Helper function to validate and fix element binding data
const validateAndFixBindings = (elements) => { const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
const elementMap = new Map(elements.map(el => [el.id, el])); const elementMap = new Map(elements.map(el => [el.id!, el]));
return elements.map(element => { return elements.map(element => {
const fixedElement = { ...element }; const fixedElement = { ...element };
@@ -23,7 +88,7 @@ const validateAndFixBindings = (elements) => {
// Validate and fix boundElements // Validate and fix boundElements
if (fixedElement.boundElements) { if (fixedElement.boundElements) {
if (Array.isArray(fixedElement.boundElements)) { if (Array.isArray(fixedElement.boundElements)) {
fixedElement.boundElements = fixedElement.boundElements.filter(binding => { fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
// Ensure binding has required properties // Ensure binding has required properties
if (!binding || typeof binding !== 'object') return false; if (!binding || typeof binding !== 'object') return false;
if (!binding.id || !binding.type) return false; if (!binding.id || !binding.type) return false;
@@ -61,14 +126,14 @@ const validateAndFixBindings = (elements) => {
}); });
} }
function App() { function App(): JSX.Element {
const [excalidrawAPI, setExcalidrawAPI] = useState(null) const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState<boolean>(false)
const websocketRef = useRef(null) const websocketRef = useRef<WebSocket | null>(null)
// Sync state management // Sync state management
const [syncStatus, setSyncStatus] = useState('idle') // idle, syncing, success, error const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
const [lastSyncTime, setLastSyncTime] = useState(null) const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
// WebSocket connection // WebSocket connection
useEffect(() => { useEffect(() => {
@@ -92,22 +157,22 @@ function App() {
} }
}, [excalidrawAPI, isConnected]) }, [excalidrawAPI, isConnected])
const loadExistingElements = async () => { const loadExistingElements = async (): Promise<void> => {
try { try {
const response = await fetch('/api/elements') const response = await fetch('/api/elements')
const result = await response.json() const result: ApiResponse = await response.json()
if (result.success && result.elements && result.elements.length > 0) { if (result.success && result.elements && result.elements.length > 0) {
const cleanedElements = result.elements.map(cleanElementForExcalidraw) const cleanedElements = result.elements.map(cleanElementForExcalidraw)
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false }) const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
excalidrawAPI.updateScene({ elements: convertedElements }) excalidrawAPI?.updateScene({ elements: convertedElements })
} }
} catch (error) { } catch (error) {
console.error('Error loading existing elements:', error) console.error('Error loading existing elements:', error)
} }
} }
const connectWebSocket = () => { const connectWebSocket = (): void => {
if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) { if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
return return
} }
@@ -125,16 +190,16 @@ function App() {
} }
} }
websocketRef.current.onmessage = (event) => { websocketRef.current.onmessage = (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data: WebSocketMessage = JSON.parse(event.data)
handleWebSocketMessage(data) handleWebSocketMessage(data)
} catch (error) { } catch (error) {
console.error('Error parsing WebSocket message:', error, event.data) console.error('Error parsing WebSocket message:', error, event.data)
} }
} }
websocketRef.current.onclose = (event) => { websocketRef.current.onclose = (event: CloseEvent) => {
setIsConnected(false) setIsConnected(false)
// Reconnect after 3 seconds if not a clean close // Reconnect after 3 seconds if not a clean close
@@ -143,14 +208,13 @@ function App() {
} }
} }
websocketRef.current.onerror = (error) => { websocketRef.current.onerror = (error: Event) => {
console.error('WebSocket error:', error) console.error('WebSocket error:', error)
setIsConnected(false) setIsConnected(false)
} }
} }
const handleWebSocketMessage = (data: WebSocketMessage): void => {
const handleWebSocketMessage = (data) => {
if (!excalidrawAPI) { if (!excalidrawAPI) {
return return
} }
@@ -173,43 +237,51 @@ function App() {
break break
case 'element_created': case 'element_created':
const cleanedNewElement = cleanElementForExcalidraw(data.element) if (data.element) {
const newElement = convertToExcalidrawElements([cleanedNewElement]) const cleanedNewElement = cleanElementForExcalidraw(data.element)
const updatedElementsAfterCreate = [...currentElements, ...newElement] const newElement = convertToExcalidrawElements([cleanedNewElement])
excalidrawAPI.updateScene({ const updatedElementsAfterCreate = [...currentElements, ...newElement]
elements: updatedElementsAfterCreate, excalidrawAPI.updateScene({
captureUpdate: CaptureUpdateAction.NEVER elements: updatedElementsAfterCreate,
}) captureUpdate: CaptureUpdateAction.NEVER
})
}
break break
case 'element_updated': case 'element_updated':
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element) if (data.element) {
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0] const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
const updatedElements = currentElements.map(el => const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0]
el.id === data.element.id ? convertedUpdatedElement : el const updatedElements = currentElements.map(el =>
) el.id === data.element!.id ? convertedUpdatedElement : el
excalidrawAPI.updateScene({ )
elements: updatedElements, excalidrawAPI.updateScene({
captureUpdate: CaptureUpdateAction.NEVER elements: updatedElements,
}) captureUpdate: CaptureUpdateAction.NEVER
})
}
break break
case 'element_deleted': case 'element_deleted':
const filteredElements = currentElements.filter(el => el.id !== data.elementId) if (data.elementId) {
excalidrawAPI.updateScene({ const filteredElements = currentElements.filter(el => el.id !== data.elementId)
elements: filteredElements, excalidrawAPI.updateScene({
captureUpdate: CaptureUpdateAction.NEVER elements: filteredElements,
}) captureUpdate: CaptureUpdateAction.NEVER
})
}
break break
case 'elements_batch_created': case 'elements_batch_created':
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) if (data.elements) {
const batchElements = convertToExcalidrawElements(cleanedBatchElements) const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
const updatedElementsAfterBatch = [...currentElements, ...batchElements] const batchElements = convertToExcalidrawElements(cleanedBatchElements)
excalidrawAPI.updateScene({ const updatedElementsAfterBatch = [...currentElements, ...batchElements]
elements: updatedElementsAfterBatch, excalidrawAPI.updateScene({
captureUpdate: CaptureUpdateAction.NEVER elements: updatedElementsAfterBatch,
}) captureUpdate: CaptureUpdateAction.NEVER
})
}
break break
case 'elements_synced': case 'elements_synced':
@@ -218,7 +290,7 @@ function App() {
break break
case 'sync_status': case 'sync_status':
console.log(`Server sync status: ${data.elementCount} elements`) console.log(`Server sync status: ${data.count} elements`)
break break
default: default:
@@ -230,14 +302,14 @@ function App() {
} }
// Data format conversion for backend // Data format conversion for backend
const convertToBackendFormat = (element) => { const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => {
return { return {
...element ...element
} } as ServerElement
} }
// Format sync time display // Format sync time display
const formatSyncTime = (time) => { const formatSyncTime = (time: Date | null): string => {
if (!time) return '' if (!time) return ''
return time.toLocaleTimeString('zh-CN', { return time.toLocaleTimeString('zh-CN', {
hour: '2-digit', hour: '2-digit',
@@ -247,7 +319,7 @@ function App() {
} }
// Main sync function // Main sync function
const syncToBackend = async () => { const syncToBackend = async (): Promise<void> => {
if (!excalidrawAPI) { if (!excalidrawAPI) {
console.warn('Excalidraw API not available') console.warn('Excalidraw API not available')
return return
@@ -279,7 +351,7 @@ function App() {
}) })
if (response.ok) { if (response.ok) {
const result = await response.json() const result: ApiResponse = await response.json()
setSyncStatus('success') setSyncStatus('success')
setLastSyncTime(new Date()) setLastSyncTime(new Date())
console.log(`Sync successful: ${result.count} elements synced`) console.log(`Sync successful: ${result.count} elements synced`)
@@ -287,7 +359,7 @@ function App() {
// Reset status after 2 seconds // Reset status after 2 seconds
setTimeout(() => setSyncStatus('idle'), 2000) setTimeout(() => setSyncStatus('idle'), 2000)
} else { } else {
const error = await response.json() const error: ApiResponse = await response.json()
setSyncStatus('error') setSyncStatus('error')
console.error('Sync failed:', error.error) console.error('Sync failed:', error.error)
} }
@@ -297,13 +369,12 @@ function App() {
} }
} }
const clearCanvas = async (): Promise<void> => {
const clearCanvas = async () => {
if (excalidrawAPI) { if (excalidrawAPI) {
try { try {
// Get all current elements and delete them from backend // Get all current elements and delete them from backend
const response = await fetch('/api/elements') const response = await fetch('/api/elements')
const result = await response.json() const result: ApiResponse = await response.json()
if (result.success && result.elements) { if (result.success && result.elements) {
const deletePromises = result.elements.map(element => const deletePromises = result.elements.map(element =>
@@ -373,7 +444,7 @@ function App() {
{/* Canvas Container */} {/* Canvas Container */}
<div className="canvas-container"> <div className="canvas-container">
<Excalidraw <Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)}
initialData={{ initialData={{
elements: [], elements: [],
appState: { appState: {
@@ -387,4 +458,4 @@ function App() {
) )
} }
export default App export default App
-9
View File
@@ -1,9 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+15 -8
View File
@@ -2,21 +2,23 @@
"name": "mcp-excalidraw-server", "name": "mcp-excalidraw-server",
"version": "1.0.2", "version": "1.0.2",
"description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management", "description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management",
"main": "src/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"bin": { "bin": {
"mcp-excalidraw-server": "src/index.js" "mcp-excalidraw-server": "dist/index.js"
}, },
"scripts": { "scripts": {
"start": "node src/index.js", "start": "npm run build:server && node dist/index.js",
"canvas": "node src/server.js", "canvas": "npm run build:server && node dist/server.js",
"build": "npm run build:frontend && npm run build:types", "build": "npm run build:frontend && npm run build:server",
"build:frontend": "vite build", "build:frontend": "vite build",
"build:types": "npx tsc --emitDeclarationOnly",
"build:server": "npx tsc", "build:server": "npx tsc",
"dev": "concurrently \"npm run canvas\" \"vite\"", "build:types": "npx tsc --emitDeclarationOnly",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:server": "npx tsc --watch",
"production": "npm run build && npm run canvas", "production": "npm run build && npm run canvas",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build",
"type-check": "npx tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
@@ -33,7 +35,12 @@
"zod-to-json-schema": "^3.22.3" "zod-to-json-schema": "^3.22.3"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.19.7", "@types/node": "^20.19.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
Executable → Regular
+277 -456
View File
@@ -5,19 +5,23 @@ process.env.NODE_DISABLE_COLORS = '1';
process.env.NO_COLOR = '1'; process.env.NO_COLOR = '1';
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { import {
CallToolRequestSchema, CallToolRequestSchema,
ListToolsRequestSchema ListToolsRequestSchema,
CallToolRequest,
Tool
} from '@modelcontextprotocol/sdk/types.js'; } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod'; import { z } from 'zod';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import logger from './utils/logger.js'; import logger from './utils/logger.js';
import { import {
generateId, generateId,
EXCALIDRAW_ELEMENT_TYPES EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
validateElement
} from './types.js'; } from './types.js';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
@@ -28,15 +32,30 @@ dotenv.config();
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000'; const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
// API Response types
interface ApiResponse {
success: boolean;
element?: ServerElement;
elements?: ServerElement[];
message?: string;
count?: number;
}
interface SyncResponse {
element?: ServerElement;
elements?: ServerElement[];
}
// Helper functions to sync with Express server (canvas) // Helper functions to sync with Express server (canvas)
async function syncToCanvas(operation, data) { async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
if (!ENABLE_CANVAS_SYNC) { if (!ENABLE_CANVAS_SYNC) {
logger.debug('Canvas sync disabled, skipping'); logger.debug('Canvas sync disabled, skipping');
return null; return null;
} }
try { try {
let url, options; let url: string;
let options: any;
switch (operation) { switch (operation) {
case 'create': case 'create':
@@ -83,43 +102,50 @@ async function syncToCanvas(operation, data) {
throw new Error(`Canvas sync failed: ${response.status} ${response.statusText}`); throw new Error(`Canvas sync failed: ${response.status} ${response.statusText}`);
} }
const result = await response.json(); const result = await response.json() as ApiResponse;
logger.debug(`Canvas sync successful: ${operation}`, result); logger.debug(`Canvas sync successful: ${operation}`, result);
return result; return result as SyncResponse;
} catch (error) { } catch (error) {
logger.warn(`Canvas sync failed for ${operation}:`, error.message); logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
// Don't throw - we want MCP operations to work even if canvas is unavailable // Don't throw - we want MCP operations to work even if canvas is unavailable
return null; return null;
} }
} }
// Helper to sync element creation to canvas // Helper to sync element creation to canvas
async function createElementOnCanvas(elementData) { async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
const result = await syncToCanvas('create', elementData); const result = await syncToCanvas('create', elementData);
return result?.element || elementData; return result?.element || elementData;
} }
// Helper to sync element update to canvas // Helper to sync element update to canvas
async function updateElementOnCanvas(elementData) { async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
const result = await syncToCanvas('update', elementData); const result = await syncToCanvas('update', elementData);
return result?.element || elementData; return result?.element || null;
} }
// Helper to sync element deletion to canvas // Helper to sync element deletion to canvas
async function deleteElementOnCanvas(elementId) { async function deleteElementOnCanvas(elementId: string): Promise<any> {
const result = await syncToCanvas('delete', { id: elementId }); const result = await syncToCanvas('delete', { id: elementId });
return result; return result;
} }
// Helper to sync batch creation to canvas // Helper to sync batch creation to canvas
async function batchCreateElementsOnCanvas(elementsData) { async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
const result = await syncToCanvas('batch_create', elementsData); const result = await syncToCanvas('batch_create', elementsData);
return result?.elements || elementsData; return result?.elements || elementsData;
} }
// In-memory storage for scene state // In-memory storage for scene state
const sceneState = { interface SceneState {
theme: string;
viewport: { x: number; y: number; zoom: number };
selectedElements: Set<string>;
groups: Map<string, string[]>;
}
const sceneState: SceneState = {
theme: 'light', theme: 'light',
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
selectedElements: new Set(), selectedElements: new Set(),
@@ -128,7 +154,7 @@ const sceneState = {
// Schema definitions using zod // Schema definitions using zod
const ElementSchema = z.object({ const ElementSchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
width: z.number().optional(), width: z.number().optional(),
@@ -167,7 +193,7 @@ const DistributeElementsSchema = z.object({
}); });
const QuerySchema = z.object({ const QuerySchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
filter: z.record(z.any()).optional() filter: z.record(z.any()).optional()
}); });
@@ -175,19 +201,201 @@ const ResourceSchema = z.object({
resource: z.enum(['scene', 'library', 'theme', 'elements']) resource: z.enum(['scene', 'library', 'theme', 'elements'])
}); });
// Initialize MCP server // Tool definitions
const server = new Server( const tools: Tool[] = [
{ {
name: "mcp-excalidraw-server", name: 'create_element',
version: "1.0.2", description: 'Create a new Excalidraw element',
description: "Advanced MCP server for Excalidraw with real-time canvas" inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['type', 'x', 'y']
}
}, },
{ {
capabilities: { name: 'update_element',
tools: { description: 'Update an existing Excalidraw element',
create_element: { inputSchema: {
description: 'Create a new Excalidraw element', type: 'object',
inputSchema: { properties: {
id: { type: 'string' },
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['id']
}
},
{
name: 'delete_element',
description: 'Delete an Excalidraw element',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' }
},
required: ['id']
}
},
{
name: 'query_elements',
description: 'Query Excalidraw elements with optional filters',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
filter: {
type: 'object',
additionalProperties: true
}
}
}
},
{
name: 'get_resource',
description: 'Get an Excalidraw resource',
inputSchema: {
type: 'object',
properties: {
resource: {
type: 'string',
enum: ['scene', 'library', 'theme', 'elements']
}
},
required: ['resource']
}
},
{
name: 'group_elements',
description: 'Group multiple elements together',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'ungroup_elements',
description: 'Ungroup a group of elements',
inputSchema: {
type: 'object',
properties: {
groupId: { type: 'string' }
},
required: ['groupId']
}
},
{
name: 'align_elements',
description: 'Align elements to a specific position',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
alignment: {
type: 'string',
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
}
},
required: ['elementIds', 'alignment']
}
},
{
name: 'distribute_elements',
description: 'Distribute elements evenly',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
direction: {
type: 'string',
enum: ['horizontal', 'vertical']
}
},
required: ['elementIds', 'direction']
}
},
{
name: 'lock_elements',
description: 'Lock elements to prevent modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'unlock_elements',
description: 'Unlock elements to allow modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'batch_create_elements',
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
inputSchema: {
type: 'object',
properties: {
elements: {
type: 'array',
items: {
type: 'object', type: 'object',
properties: { properties: {
type: { type: {
@@ -209,196 +417,32 @@ const server = new Server(
}, },
required: ['type', 'x', 'y'] required: ['type', 'x', 'y']
} }
}, }
update_element: { },
description: 'Update an existing Excalidraw element', required: ['elements']
inputSchema: { }
type: 'object', }
properties: { ];
id: { type: 'string' },
type: { // Initialize MCP server
type: 'string', const server = new Server(
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES) {
}, name: "mcp-excalidraw-server",
x: { type: 'number' }, version: "1.0.2",
y: { type: 'number' }, description: "Advanced MCP server for Excalidraw with real-time canvas"
width: { type: 'number' }, },
height: { type: 'number' }, {
backgroundColor: { type: 'string' }, capabilities: {
strokeColor: { type: 'string' }, tools: Object.fromEntries(tools.map(tool => [tool.name, {
strokeWidth: { type: 'number' }, description: tool.description,
roughness: { type: 'number' }, inputSchema: tool.inputSchema
opacity: { type: 'number' }, }]))
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['id']
}
},
delete_element: {
description: 'Delete an Excalidraw element',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' }
},
required: ['id']
}
},
query_elements: {
description: 'Query Excalidraw elements with optional filters',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
filter: {
type: 'object',
additionalProperties: true
}
}
}
},
get_resource: {
description: 'Get an Excalidraw resource',
inputSchema: {
type: 'object',
properties: {
resource: {
type: 'string',
enum: ['scene', 'library', 'theme', 'elements']
}
},
required: ['resource']
}
},
group_elements: {
description: 'Group multiple elements together',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
ungroup_elements: {
description: 'Ungroup a group of elements',
inputSchema: {
type: 'object',
properties: {
groupId: { type: 'string' }
},
required: ['groupId']
}
},
align_elements: {
description: 'Align elements to a specific position',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
alignment: {
type: 'string',
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
}
},
required: ['elementIds', 'alignment']
}
},
distribute_elements: {
description: 'Distribute elements evenly',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
direction: {
type: 'string',
enum: ['horizontal', 'vertical']
}
},
required: ['elementIds', 'direction']
}
},
lock_elements: {
description: 'Lock elements to prevent modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
unlock_elements: {
description: 'Unlock elements to allow modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
batch_create_elements: {
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
inputSchema: {
type: 'object',
properties: {
elements: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['type', 'x', 'y']
}
}
},
required: ['elements']
}
},
}
} }
} }
); );
// Helper function to convert text property to label format for Excalidraw // Helper function to convert text property to label format for Excalidraw
function convertTextToLabel(element) { function convertTextToLabel(element: ServerElement): ServerElement {
const { text, ...rest } = element; const { text, ...rest } = element;
if (text) { if (text) {
// For standalone text elements, keep text as direct property // For standalone text elements, keep text as direct property
@@ -409,13 +453,13 @@ function convertTextToLabel(element) {
return { return {
...rest, ...rest,
label: { text } label: { text }
}; } as ServerElement;
} }
return element; return element;
} }
// Set up request handler for tool calls // Set up request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => { server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
try { try {
const { name, arguments: args } = request.params; const { name, arguments: args } = request.params;
logger.info(`Handling tool call: ${name}`); logger.info(`Handling tool call: ${name}`);
@@ -426,7 +470,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
logger.info('Creating element via MCP', { type: params.type }); logger.info('Creating element via MCP', { type: params.type });
const id = generateId(); const id = generateId();
const element = { const element: ServerElement = {
id, id,
...params, ...params,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -465,14 +509,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (!id) throw new Error('Element ID is required'); if (!id) throw new Error('Element ID is required');
// Build update payload with timestamp and version increment // Build update payload with timestamp and version increment
const updatePayload = { const updatePayload: Partial<ServerElement> & { id: string } = {
id, id,
...updates, ...updates,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
// Convert text to label format for Excalidraw // Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(updatePayload); const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
// Update element directly on HTTP server (no local storage) // Update element directly on HTTP server (no local storage)
const canvasElement = await updateElementOnCanvas(excalidrawElement); const canvasElement = await updateElementOnCanvas(excalidrawElement);
@@ -526,7 +570,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (type) queryParams.set('type', type); if (type) queryParams.set('type', type);
if (filter) { if (filter) {
Object.entries(filter).forEach(([key, value]) => { Object.entries(filter).forEach(([key, value]) => {
queryParams.set(key, value); queryParams.set(key, String(value));
}); });
} }
@@ -538,14 +582,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`); throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json() as ApiResponse;
const results = data.elements || []; const results = data.elements || [];
return { return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
}; };
} catch (error) { } catch (error) {
throw new Error(`Failed to query elements: ${error.message}`); throw new Error(`Failed to query elements: ${(error as Error).message}`);
} }
} }
@@ -554,7 +598,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { resource } = params; const { resource } = params;
logger.info('Getting resource', { resource }); logger.info('Getting resource', { resource });
let result; let result: any;
switch (resource) { switch (resource) {
case 'scene': case 'scene':
result = { result = {
@@ -571,12 +615,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`); throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json() as ApiResponse;
result = { result = {
elements: data.elements || [] elements: data.elements || []
}; };
} catch (error) { } catch (error) {
throw new Error(`Failed to get elements: ${error.message}`); throw new Error(`Failed to get elements: ${(error as Error).message}`);
} }
break; break;
case 'theme': case 'theme':
@@ -671,7 +715,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
}; };
} catch (error) { } catch (error) {
throw new Error(`Failed to lock elements: ${error.message}`); throw new Error(`Failed to lock elements: ${(error as Error).message}`);
} }
} }
@@ -697,7 +741,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
}; };
} catch (error) { } catch (error) {
throw new Error(`Failed to unlock elements: ${error.message}`); throw new Error(`Failed to unlock elements: ${(error as Error).message}`);
} }
} }
@@ -705,12 +749,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const params = z.object({ elements: z.array(ElementSchema) }).parse(args); const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
logger.info('Batch creating elements via MCP', { count: params.elements.length }); logger.info('Batch creating elements via MCP', { count: params.elements.length });
const createdElements = []; const createdElements: ServerElement[] = [];
// Create each element with unique ID // Create each element with unique ID
for (const elementData of params.elements) { for (const elementData of params.elements) {
const id = generateId(); const id = generateId();
const element = { const element: ServerElement = {
id, id,
...elementData, ...elementData,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -754,9 +798,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }
} catch (error) { } catch (error) {
logger.error(`Error handling tool call: ${error.message}`, { error }); logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
return { return {
content: [{ type: 'text', text: `Error: ${error.message}` }], content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
isError: true isError: true
}; };
} }
@@ -765,234 +809,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Set up request handler for listing available tools // Set up request handler for listing available tools
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
logger.info('Listing available tools'); logger.info('Listing available tools');
const tools = [
{
name: 'create_element',
description: 'Create a new Excalidraw element',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['type', 'x', 'y']
}
},
{
name: 'update_element',
description: 'Update an existing Excalidraw element',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['id']
}
},
{
name: 'delete_element',
description: 'Delete an Excalidraw element',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' }
},
required: ['id']
}
},
{
name: 'query_elements',
description: 'Query Excalidraw elements with optional filters',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
filter: {
type: 'object',
additionalProperties: true
}
}
}
},
{
name: 'get_resource',
description: 'Get an Excalidraw resource',
inputSchema: {
type: 'object',
properties: {
resource: {
type: 'string',
enum: ['scene', 'library', 'theme', 'elements']
}
},
required: ['resource']
}
},
{
name: 'group_elements',
description: 'Group multiple elements together',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'ungroup_elements',
description: 'Ungroup a group of elements',
inputSchema: {
type: 'object',
properties: {
groupId: { type: 'string' }
},
required: ['groupId']
}
},
{
name: 'align_elements',
description: 'Align elements to a specific position',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
alignment: {
type: 'string',
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
}
},
required: ['elementIds', 'alignment']
}
},
{
name: 'distribute_elements',
description: 'Distribute elements evenly',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
},
direction: {
type: 'string',
enum: ['horizontal', 'vertical']
}
},
required: ['elementIds', 'direction']
}
},
{
name: 'lock_elements',
description: 'Lock elements to prevent modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'unlock_elements',
description: 'Unlock elements to allow modification',
inputSchema: {
type: 'object',
properties: {
elementIds: {
type: 'array',
items: { type: 'string' }
}
},
required: ['elementIds']
}
},
{
name: 'batch_create_elements',
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
inputSchema: {
type: 'object',
properties: {
elements: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
},
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
backgroundColor: { type: 'string' },
strokeColor: { type: 'string' },
strokeWidth: { type: 'number' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' }
},
required: ['type', 'x', 'y']
}
}
},
required: ['elements']
}
}
];
return { tools }; return { tools };
}); });
// Start server with transport based on mode // Start server with transport based on mode
async function runServer() { async function runServer(): Promise<void> {
try { try {
logger.info('Starting Excalidraw MCP server...'); logger.info('Starting Excalidraw MCP server...');
@@ -1022,19 +843,19 @@ async function runServer() {
process.stdin.resume(); process.stdin.resume();
} catch (error) { } catch (error) {
logger.error('Error starting server:', error); logger.error('Error starting server:', error);
process.stderr.write(`Failed to start MCP server: ${error.message}\n${error.stack}\n`); process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`);
process.exit(1); process.exit(1);
} }
} }
// Add global error handlers // Add global error handlers
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught exception:', error); logger.error('Uncaught exception:', error);
process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`); process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
setTimeout(() => process.exit(1), 1000); setTimeout(() => process.exit(1), 1000);
}); });
process.on('unhandledRejection', (reason, promise) => { process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.error('Unhandled promise rejection:', reason); logger.error('Unhandled promise rejection:', reason);
process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`); process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
setTimeout(() => process.exit(1), 1000); setTimeout(() => process.exit(1), 1000);
@@ -1053,4 +874,4 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) {
}); });
} }
export default runServer; export default runServer;
+91 -53
View File
@@ -1,4 +1,4 @@
import express from 'express'; import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors'; import cors from 'cors';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { createServer } from 'http'; import { createServer } from 'http';
@@ -9,9 +9,19 @@ import logger from './utils/logger.js';
import { import {
elements, elements,
generateId, generateId,
EXCALIDRAW_ELEMENT_TYPES EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
WebSocketMessage,
ElementCreatedMessage,
ElementUpdatedMessage,
ElementDeletedMessage,
BatchCreatedMessage,
SyncStatusMessage,
InitialElementsMessage
} from './types.js'; } from './types.js';
import { z } from 'zod'; import { z } from 'zod';
import WebSocket from 'ws';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
@@ -34,35 +44,37 @@ app.use(express.static(staticDir));
app.use(express.static(path.join(__dirname, '../dist/frontend'))); app.use(express.static(path.join(__dirname, '../dist/frontend')));
// WebSocket connections // WebSocket connections
const clients = new Set(); const clients = new Set<WebSocket>();
// Broadcast to all connected clients // Broadcast to all connected clients
function broadcast(message) { function broadcast(message: WebSocketMessage): void {
const data = JSON.stringify(message); const data = JSON.stringify(message);
clients.forEach(client => { clients.forEach(client => {
if (client.readyState === client.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(data); client.send(data);
} }
}); });
} }
// WebSocket connection handling // WebSocket connection handling
wss.on('connection', (ws) => { wss.on('connection', (ws: WebSocket) => {
clients.add(ws); clients.add(ws);
logger.info('New WebSocket connection established'); logger.info('New WebSocket connection established');
// Send current elements to new client // Send current elements to new client
ws.send(JSON.stringify({ const initialMessage: InitialElementsMessage = {
type: 'initial_elements', type: 'initial_elements',
elements: Array.from(elements.values()) elements: Array.from(elements.values())
})); };
ws.send(JSON.stringify(initialMessage));
// Send sync status to new client // Send sync status to new client
ws.send(JSON.stringify({ const syncMessage: SyncStatusMessage = {
type: 'sync_status', type: 'sync_status',
elementCount: elements.size, elementCount: elements.size,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
})); };
ws.send(JSON.stringify(syncMessage));
ws.on('close', () => { ws.on('close', () => {
clients.delete(ws); clients.delete(ws);
@@ -78,7 +90,7 @@ wss.on('connection', (ws) => {
// Schema validation // Schema validation
const CreateElementSchema = z.object({ const CreateElementSchema = z.object({
id: z.string().optional(), // Allow passing ID for MCP sync id: z.string().optional(), // Allow passing ID for MCP sync
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
width: z.number().optional(), width: z.number().optional(),
@@ -98,7 +110,7 @@ const CreateElementSchema = z.object({
const UpdateElementSchema = z.object({ const UpdateElementSchema = z.object({
id: z.string(), id: z.string(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
x: z.number().optional(), x: z.number().optional(),
y: z.number().optional(), y: z.number().optional(),
width: z.number().optional(), width: z.number().optional(),
@@ -119,7 +131,7 @@ const UpdateElementSchema = z.object({
// API Routes // API Routes
// Get all elements // Get all elements
app.get('/api/elements', (req, res) => { app.get('/api/elements', (req: Request, res: Response) => {
try { try {
const elementsArray = Array.from(elements.values()); const elementsArray = Array.from(elements.values());
res.json({ res.json({
@@ -131,20 +143,20 @@ app.get('/api/elements', (req, res) => {
logger.error('Error fetching elements:', error); logger.error('Error fetching elements:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Create new element // Create new element
app.post('/api/elements', (req, res) => { app.post('/api/elements', (req: Request, res: Response) => {
try { try {
const params = CreateElementSchema.parse(req.body); const params = CreateElementSchema.parse(req.body);
logger.info('Creating element via API', { type: params.type }); logger.info('Creating element via API', { type: params.type });
// Prioritize passed ID (for MCP sync), otherwise generate new ID // Prioritize passed ID (for MCP sync), otherwise generate new ID
const id = params.id || generateId(); const id = params.id || generateId();
const element = { const element: ServerElement = {
id, id,
...params, ...params,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -155,10 +167,11 @@ app.post('/api/elements', (req, res) => {
elements.set(id, element); elements.set(id, element);
// Broadcast to all connected clients // Broadcast to all connected clients
broadcast({ const message: ElementCreatedMessage = {
type: 'element_created', type: 'element_created',
element: element element: element
}); };
broadcast(message);
res.json({ res.json({
success: true, success: true,
@@ -168,17 +181,24 @@ app.post('/api/elements', (req, res) => {
logger.error('Error creating element:', error); logger.error('Error creating element:', error);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Update element // Update element
app.put('/api/elements/:id', (req, res) => { app.put('/api/elements/:id', (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const updates = UpdateElementSchema.parse({ id, ...req.body }); const updates = UpdateElementSchema.parse({ id, ...req.body });
if (!id) {
return res.status(400).json({
success: false,
error: 'Element ID is required'
});
}
const existingElement = elements.get(id); const existingElement = elements.get(id);
if (!existingElement) { if (!existingElement) {
return res.status(404).json({ return res.status(404).json({
@@ -187,20 +207,21 @@ app.put('/api/elements/:id', (req, res) => {
}); });
} }
const updatedElement = { const updatedElement: ServerElement = {
...existingElement, ...existingElement,
...updates, ...updates,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
version: existingElement.version + 1 version: (existingElement.version || 0) + 1
}; };
elements.set(id, updatedElement); elements.set(id, updatedElement);
// Broadcast to all connected clients // Broadcast to all connected clients
broadcast({ const message: ElementUpdatedMessage = {
type: 'element_updated', type: 'element_updated',
element: updatedElement element: updatedElement
}); };
broadcast(message);
res.json({ res.json({
success: true, success: true,
@@ -210,16 +231,23 @@ app.put('/api/elements/:id', (req, res) => {
logger.error('Error updating element:', error); logger.error('Error updating element:', error);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Delete element // Delete element
app.delete('/api/elements/:id', (req, res) => { app.delete('/api/elements/:id', (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
error: 'Element ID is required'
});
}
if (!elements.has(id)) { if (!elements.has(id)) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@@ -230,10 +258,11 @@ app.delete('/api/elements/:id', (req, res) => {
elements.delete(id); elements.delete(id);
// Broadcast to all connected clients // Broadcast to all connected clients
broadcast({ const message: ElementDeletedMessage = {
type: 'element_deleted', type: 'element_deleted',
elementId: id elementId: id!
}); };
broadcast(message);
res.json({ res.json({
success: true, success: true,
@@ -243,19 +272,19 @@ app.delete('/api/elements/:id', (req, res) => {
logger.error('Error deleting element:', error); logger.error('Error deleting element:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Query elements with filters // Query elements with filters
app.get('/api/elements/search', (req, res) => { app.get('/api/elements/search', (req: Request, res: Response) => {
try { try {
const { type, ...filters } = req.query; const { type, ...filters } = req.query;
let results = Array.from(elements.values()); let results = Array.from(elements.values());
// Filter by type if specified // Filter by type if specified
if (type) { if (type && typeof type === 'string') {
results = results.filter(element => element.type === type); results = results.filter(element => element.type === type);
} }
@@ -263,7 +292,7 @@ app.get('/api/elements/search', (req, res) => {
if (Object.keys(filters).length > 0) { if (Object.keys(filters).length > 0) {
results = results.filter(element => { results = results.filter(element => {
return Object.entries(filters).every(([key, value]) => { return Object.entries(filters).every(([key, value]) => {
return element[key] === value; return (element as any)[key] === value;
}); });
}); });
} }
@@ -277,15 +306,23 @@ app.get('/api/elements/search', (req, res) => {
logger.error('Error querying elements:', error); logger.error('Error querying elements:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Get element by ID // Get element by ID
app.get('/api/elements/:id', (req, res) => { app.get('/api/elements/:id', (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
error: 'Element ID is required'
});
}
const element = elements.get(id); const element = elements.get(id);
if (!element) { if (!element) {
@@ -303,13 +340,13 @@ app.get('/api/elements/:id', (req, res) => {
logger.error('Error fetching element:', error); logger.error('Error fetching element:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Batch create elements // Batch create elements
app.post('/api/elements/batch', (req, res) => { app.post('/api/elements/batch', (req: Request, res: Response) => {
try { try {
const { elements: elementsToCreate } = req.body; const { elements: elementsToCreate } = req.body;
@@ -320,12 +357,12 @@ app.post('/api/elements/batch', (req, res) => {
}); });
} }
const createdElements = []; const createdElements: ServerElement[] = [];
elementsToCreate.forEach(elementData => { elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData); const params = CreateElementSchema.parse(elementData);
const id = generateId(); const id = generateId();
const element = { const element: ServerElement = {
id, id,
...params, ...params,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -338,10 +375,11 @@ app.post('/api/elements/batch', (req, res) => {
}); });
// Broadcast to all connected clients // Broadcast to all connected clients
broadcast({ const message: BatchCreatedMessage = {
type: 'elements_batch_created', type: 'elements_batch_created',
elements: createdElements elements: createdElements
}); };
broadcast(message);
res.json({ res.json({
success: true, success: true,
@@ -352,13 +390,13 @@ app.post('/api/elements/batch', (req, res) => {
logger.error('Error batch creating elements:', error); logger.error('Error batch creating elements:', error);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error.message error: (error as Error).message
}); });
} }
}); });
// Sync elements from frontend (overwrite sync) // Sync elements from frontend (overwrite sync)
app.post('/api/elements/sync', (req, res) => { app.post('/api/elements/sync', (req: Request, res: Response) => {
try { try {
const { elements: frontendElements, timestamp } = req.body; const { elements: frontendElements, timestamp } = req.body;
@@ -384,15 +422,15 @@ app.post('/api/elements/sync', (req, res) => {
// 2. Batch write new data // 2. Batch write new data
let successCount = 0; let successCount = 0;
const processedElements = []; const processedElements: ServerElement[] = [];
frontendElements.forEach((element, index) => { frontendElements.forEach((element: any, index: number) => {
try { try {
// Ensure element has ID, generate one if missing // Ensure element has ID, generate one if missing
const elementId = element.id || generateId(); const elementId = element.id || generateId();
// Add server metadata // Add server metadata
const processedElement = { const processedElement: ServerElement = {
...element, ...element,
id: elementId, id: elementId,
syncedAt: new Date().toISOString(), syncedAt: new Date().toISOString(),
@@ -435,14 +473,14 @@ app.post('/api/elements/sync', (req, res) => {
logger.error('Sync error:', error); logger.error('Sync error:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message, error: (error as Error).message,
details: 'Internal server error during sync operation' details: 'Internal server error during sync operation'
}); });
} }
}); });
// Serve the frontend // Serve the frontend
app.get('/', (req, res) => { app.get('/', (req: Request, res: Response) => {
const htmlFile = path.join(__dirname, '../dist/frontend/index.html'); const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
res.sendFile(htmlFile, (err) => { res.sendFile(htmlFile, (err) => {
if (err) { if (err) {
@@ -453,7 +491,7 @@ app.get('/', (req, res) => {
}); });
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req: Request, res: Response) => {
res.json({ res.json({
status: 'healthy', status: 'healthy',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -463,7 +501,7 @@ app.get('/health', (req, res) => {
}); });
// Sync status endpoint // Sync status endpoint
app.get('/api/sync/status', (req, res) => { app.get('/api/sync/status', (req: Request, res: Response) => {
res.json({ res.json({
success: true, success: true,
elementCount: elements.size, elementCount: elements.size,
@@ -477,7 +515,7 @@ app.get('/api/sync/status', (req, res) => {
}); });
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
@@ -486,7 +524,7 @@ app.use((err, req, res, next) => {
}); });
// Start server // Start server
const PORT = process.env.PORT || 3000; const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || 'localhost'; const HOST = process.env.HOST || 'localhost';
server.listen(PORT, HOST, () => { server.listen(PORT, HOST, () => {
@@ -494,4 +532,4 @@ server.listen(PORT, HOST, () => {
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`); logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
}); });
export default app; export default app;
-35
View File
@@ -1,35 +0,0 @@
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES = {
RECTANGLE: 'rectangle',
ELLIPSE: 'ellipse',
DIAMOND: 'diamond',
ARROW: 'arrow',
TEXT: 'text',
LABEL: 'label',
FREEDRAW: 'freedraw',
LINE: 'line'
};
// In-memory storage for Excalidraw elements
export const elements = new Map();
// Validation function for Excalidraw elements
export function validateElement(element) {
const requiredFields = ['type', 'x', 'y'];
const hasRequiredFields = requiredFields.every(field => field in element);
if (!hasRequiredFields) {
throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
}
if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type)) {
throw new Error(`Invalid element type: ${element.type}`);
}
return true;
}
// Helper function to generate unique IDs
export function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
+233
View File
@@ -0,0 +1,233 @@
export interface ExcalidrawElementBase {
id: string;
type: ExcalidrawElementType;
x: number;
y: number;
width?: number;
height?: number;
angle?: number;
strokeColor?: string;
backgroundColor?: string;
fillStyle?: string;
strokeWidth?: number;
strokeStyle?: string;
roughness?: number;
opacity?: number;
groupIds?: string[];
frameId?: string | null;
roundness?: {
type: number;
value?: number;
} | null;
seed?: number;
versionNonce?: number;
isDeleted?: boolean;
locked?: boolean;
link?: string | null;
customData?: Record<string, any> | null;
boundElements?: readonly ExcalidrawBoundElement[] | null;
updated?: number;
containerId?: string | null;
}
export interface ExcalidrawTextElement extends ExcalidrawElementBase {
type: 'text';
text: string;
fontSize?: number;
fontFamily?: number;
textAlign?: string;
verticalAlign?: string;
baseline?: number;
lineHeight?: number;
}
export interface ExcalidrawRectangleElement extends ExcalidrawElementBase {
type: 'rectangle';
width: number;
height: number;
}
export interface ExcalidrawEllipseElement extends ExcalidrawElementBase {
type: 'ellipse';
width: number;
height: number;
}
export interface ExcalidrawDiamondElement extends ExcalidrawElementBase {
type: 'diamond';
width: number;
height: number;
}
export interface ExcalidrawArrowElement extends ExcalidrawElementBase {
type: 'arrow';
points: readonly [number, number][];
lastCommittedPoint?: readonly [number, number] | null;
startBinding?: ExcalidrawBinding | null;
endBinding?: ExcalidrawBinding | null;
startArrowhead?: string | null;
endArrowhead?: string | null;
}
export interface ExcalidrawLineElement extends ExcalidrawElementBase {
type: 'line';
points: readonly [number, number][];
lastCommittedPoint?: readonly [number, number] | null;
startBinding?: ExcalidrawBinding | null;
endBinding?: ExcalidrawBinding | null;
}
export interface ExcalidrawFreedrawElement extends ExcalidrawElementBase {
type: 'freedraw';
points: readonly [number, number][];
pressures?: readonly number[];
simulatePressure?: boolean;
lastCommittedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement
| ExcalidrawArrowElement
| ExcalidrawLineElement
| ExcalidrawFreedrawElement;
export interface ExcalidrawBoundElement {
id: string;
type: 'text' | 'arrow';
}
export interface ExcalidrawBinding {
elementId: string;
focus: number;
gap: number;
fixedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label';
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
RECTANGLE: 'rectangle',
ELLIPSE: 'ellipse',
DIAMOND: 'diamond',
ARROW: 'arrow',
TEXT: 'text',
LABEL: 'label',
FREEDRAW: 'freedraw',
LINE: 'line'
} as const;
// Server-side element with metadata
export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
id: string;
type: ExcalidrawElementType;
createdAt?: string;
updatedAt?: string;
version?: number;
syncedAt?: string;
source?: string;
syncTimestamp?: string;
text?: string;
fontSize?: number;
fontFamily?: string | number;
label?: {
text: string;
};
}
// API Response types
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface ElementsResponse extends ApiResponse {
elements: ServerElement[];
count: number;
}
export interface ElementResponse extends ApiResponse {
element: ServerElement;
}
export interface SyncResponse extends ApiResponse {
count: number;
syncedAt: string;
beforeCount: number;
afterCount: number;
}
// WebSocket message types
export interface WebSocketMessage {
type: WebSocketMessageType;
[key: string]: any;
}
export type WebSocketMessageType =
| 'initial_elements'
| 'element_created'
| 'element_updated'
| 'element_deleted'
| 'elements_batch_created'
| 'elements_synced'
| 'sync_status';
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
elements: ServerElement[];
}
export interface ElementCreatedMessage extends WebSocketMessage {
type: 'element_created';
element: ServerElement;
}
export interface ElementUpdatedMessage extends WebSocketMessage {
type: 'element_updated';
element: ServerElement;
}
export interface ElementDeletedMessage extends WebSocketMessage {
type: 'element_deleted';
elementId: string;
}
export interface BatchCreatedMessage extends WebSocketMessage {
type: 'elements_batch_created';
elements: ServerElement[];
}
export interface SyncStatusMessage extends WebSocketMessage {
type: 'sync_status';
elementCount: number;
timestamp: string;
}
// In-memory storage for Excalidraw elements
export const elements = new Map<string, ServerElement>();
// Validation function for Excalidraw elements
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];
const hasRequiredFields = requiredFields.every(field => field in element);
if (!hasRequiredFields) {
throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
}
if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type as ExcalidrawElementType)) {
throw new Error(`Invalid element type: ${element.type}`);
}
return true;
}
// Helper function to generate unique IDs
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import winston from 'winston'; import winston from 'winston';
const logger = winston.createLogger({ const logger: winston.Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine( format: winston.format.combine(
@@ -24,4 +24,4 @@ const logger = winston.createLogger({
] ]
}); });
export default logger; export default logger;
+17 -5
View File
@@ -3,14 +3,24 @@
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node",
"allowJs": true, "allowJs": false,
"checkJs": false, "checkJs": false,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"strict": false, "strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
@@ -23,14 +33,16 @@
"types": ["node"] "types": ["node"]
}, },
"include": [ "include": [
"src/**/*" "src/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",
"dist", "dist",
"frontend", "frontend",
"**/*.test.js", "**/*.test.ts",
"**/*.spec.js" "**/*.spec.ts",
"**/*.js",
"**/*.jsx"
], ],
"ts-node": { "ts-node": {
"esm": true "esm": true