+3
-1
@@ -17,4 +17,6 @@ public/dist/
|
|||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Development artifacts
|
# Development artifacts
|
||||||
*.excalidraw
|
*.excalidraw
|
||||||
|
|
||||||
|
docs/
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>,
|
|
||||||
)
|
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user