Initialize Excalidraw MCP server with essential files including Docker setup, environment configuration, and logging. Added core functionality for element manipulation and server operations.

This commit is contained in:
yctimlin
2025-03-16 21:52:02 +08:00
commit 860e905b56
9 changed files with 1002 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Logging level (debug, info, warn, error)
LOG_LEVEL=info
# Enable debug mode (true/false)
DEBUG=false
# Default theme (light/dark)
DEFAULT_THEME=light
+3
View File
@@ -0,0 +1,3 @@
node_modules
.env
package-lock.json
+12
View File
@@ -0,0 +1,12 @@
FROM node:18-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
+162
View File
@@ -0,0 +1,162 @@
# Excalidraw MCP Server
A Model Context Protocol server that provides full interaction with Excalidraw elements. This server enables LLMs to create, modify, query, and manipulate Excalidraw drawings through a structured API.
## Components
### Tools
* **create_element**
* Create a new Excalidraw element
* Required inputs: `type` (rectangle, ellipse, etc.), `x`, `y` coordinates
* Optional inputs: dimensions, colors, styling properties
* **update_element**
* Update an existing Excalidraw element by ID
* Required input: `id` of the element to update
* Optional inputs: any element property to modify
* **delete_element**
* Delete an Excalidraw element
* Required input: `id` of the element to delete
* **query_elements**
* Query elements with optional filtering
* Optional inputs: `type` to filter by element type, `filter` object with key-value pairs
* **get_resource**
* Get a specific resource like scene information or all elements
* Required input: `resource` type (scene, library, theme, elements)
* **group_elements**
* Group multiple elements together
* Required input: `elementIds` array of element IDs to group
* **ungroup_elements**
* Ungroup a group of elements
* Required input: `groupId` of the group to ungroup
* **align_elements**
* Align multiple elements based on specified alignment
* Required inputs: `elementIds` array and `alignment` (left, center, right, top, middle, bottom)
* **distribute_elements**
* Distribute elements evenly
* Required inputs: `elementIds` array and `direction` (horizontal or vertical)
* **lock_elements**
* Lock elements to prevent modification
* Required input: `elementIds` array of elements to lock
* **unlock_elements**
* Unlock elements to allow modification
* Required input: `elementIds` array of elements to unlock
## Usage with Claude Desktop
To use this server with the Claude Desktop app, add the following configuration to the "mcpServers" section of your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"excalidraw": {
"command": "node",
"args": ["src/index.js"],
"env": {
"LOG_LEVEL": "info",
"DEBUG": "false"
}
}
}
}
```
### Docker
```json
{
"mcpServers": {
"excalidraw": {
"command": "docker",
"args": ["run", "-i", "--rm", "mcp/excalidraw"],
"env": {
"LOG_LEVEL": "info",
"DEBUG": "false"
}
}
}
}
```
## Installation
### NPM
```bash
# Install dependencies
npm install
# Start the server
npm start
```
### Docker
```bash
# Build the Docker image
docker build -t mcp/excalidraw .
# Run the container
docker run -i --rm mcp/excalidraw
```
## Environment Variables
The server can be configured using the following environment variables:
- `LOG_LEVEL` - Set the logging level (default: "info")
- `DEBUG` - Enable debug mode (default: "false")
- `DEFAULT_THEME` - Set the default theme (default: "light")
## Example Usage
Here are some examples of how to use the Excalidraw MCP server:
### Creating a Rectangle
```json
{
"type": "rectangle",
"x": 100,
"y": 100,
"width": 200,
"height": 100,
"backgroundColor": "#ffffff",
"strokeColor": "#000000",
"strokeWidth": 2,
"roughness": 1
}
```
### Querying Elements
```json
{
"type": "rectangle",
"filter": {
"strokeColor": "#000000"
}
}
```
### Grouping Elements
```json
{
"elementIds": ["elem1", "elem2", "elem3"]
}
```
## License
This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"excalidraw": {
"command": "node",
"args": [
"src/index.js"
],
"env": {
"LOG_LEVEL": "info",
"DEBUG": "false"
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "excalidraw-mcp",
"version": "1.0.0",
"description": "MCP server for Excalidraw",
"main": "src/index.js",
"type": "module",
"bin": {
"excalidraw-mcp": "./src/cli.js"
},
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest",
"lint": "eslint src/**/*.js",
"build": "tsc",
"docker:build": "docker build -t mcp/excalidraw .",
"docker:run": "docker run -p 3000:3000 mcp/excalidraw"
},
"dependencies": {
"@modelcontextprotocol/sdk": "latest",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"winston": "^3.11.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.3"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0",
"eslint": "^8.56.0"
},
"keywords": [
"mcp",
"excalidraw",
"model-context-protocol",
"ai",
"drawing"
],
"author": "",
"license": "MIT"
}
+707
View File
@@ -0,0 +1,707 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import dotenv from 'dotenv';
import logger from './utils/logger.js';
import {
elements,
validateElement,
generateId,
EXCALIDRAW_ELEMENT_TYPES
} from './types.js';
// Load environment variables
dotenv.config();
// In-memory storage for scene state
const sceneState = {
theme: 'light',
viewport: { x: 0, y: 0, zoom: 1 },
selectedElements: new Set(),
groups: new Map()
};
// Schema definitions using zod
const ElementSchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
points: z.array(z.object({ x: z.number(), y: z.number() })).optional(),
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional()
});
const ElementIdSchema = z.object({
id: z.string()
});
const ElementIdsSchema = z.object({
elementIds: z.array(z.string())
});
const GroupIdSchema = z.object({
groupId: z.string()
});
const AlignElementsSchema = z.object({
elementIds: z.array(z.string()),
alignment: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
});
const DistributeElementsSchema = z.object({
elementIds: z.array(z.string()),
direction: z.enum(['horizontal', 'vertical'])
});
const QuerySchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(),
filter: z.record(z.any()).optional()
});
const ResourceSchema = z.object({
resource: z.enum(['scene', 'library', 'theme', 'elements'])
});
// Initialize MCP server
const server = new Server(
{
name: "excalidraw-mcp-server",
version: "1.0.0",
description: "MCP server for Excalidraw"
},
{
capabilities: {
tools: {
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']
}
},
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']
}
},
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']
}
},
}
}
}
);
// Set up request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
logger.info(`Handling tool call: ${name}`);
switch (name) {
case 'create_element': {
const params = ElementSchema.parse(args);
logger.info('Creating element', { type: params.type });
const id = generateId();
const element = {
id,
...params,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
elements.set(id, element);
return {
content: [{ type: 'text', text: JSON.stringify(element, null, 2) }]
};
}
case 'update_element': {
const params = ElementSchema.partial().extend(ElementIdSchema).parse(args);
const { id, ...updates } = params;
if (!id) throw new Error('Element ID is required');
const existingElement = elements.get(id);
if (!existingElement) throw new Error(`Element with ID ${id} not found`);
// Validate the updated element
ElementSchema.parse({ ...existingElement, ...updates });
const updatedElement = {
...existingElement,
...updates,
updatedAt: new Date().toISOString(),
version: existingElement.version + 1
};
elements.set(id, updatedElement);
return {
content: [{ type: 'text', text: JSON.stringify(updatedElement, null, 2) }]
};
}
case 'delete_element': {
const params = ElementIdSchema.parse(args);
const { id } = params;
if (!elements.has(id)) throw new Error(`Element with ID ${id} not found`);
elements.delete(id);
return {
content: [{ type: 'text', text: JSON.stringify({ id, deleted: true }, null, 2) }]
};
}
case 'query_elements': {
const params = QuerySchema.parse(args || {});
const { type, filter } = params;
let results = Array.from(elements.values());
if (type) {
results = results.filter(element => element.type === type);
}
if (filter) {
results = results.filter(element => {
return Object.entries(filter).every(([key, value]) => {
return element[key] === value;
});
});
}
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
};
}
case 'get_resource': {
const params = ResourceSchema.parse(args);
const { resource } = params;
logger.info('Getting resource', { resource });
let result;
switch (resource) {
case 'scene':
result = {
theme: sceneState.theme,
viewport: sceneState.viewport,
selectedElements: Array.from(sceneState.selectedElements)
};
break;
case 'library':
result = {
elements: Array.from(elements.values())
};
break;
case 'theme':
result = {
theme: sceneState.theme
};
break;
case 'elements':
result = {
elements: Array.from(elements.values())
};
break;
default:
throw new Error(`Unknown resource: ${resource}`);
}
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'group_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
const groupId = generateId();
sceneState.groups.set(groupId, elementIds);
const result = { groupId, elementIds };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'ungroup_elements': {
const params = GroupIdSchema.parse(args);
const { groupId } = params;
if (!sceneState.groups.has(groupId)) {
throw new Error(`Group ${groupId} not found`);
}
const elementIds = sceneState.groups.get(groupId);
sceneState.groups.delete(groupId);
const result = { groupId, ungrouped: true, elementIds };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'align_elements': {
const params = AlignElementsSchema.parse(args);
const { elementIds, alignment } = params;
// Implementation would align elements based on the specified alignment
logger.info('Aligning elements', { elementIds, alignment });
const result = { aligned: true, elementIds, alignment };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'distribute_elements': {
const params = DistributeElementsSchema.parse(args);
const { elementIds, direction } = params;
// Implementation would distribute elements based on the specified direction
logger.info('Distributing elements', { elementIds, direction });
const result = { distributed: true, elementIds, direction };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'lock_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
elementIds.forEach(id => {
const element = elements.get(id);
if (element) {
element.locked = true;
}
});
const result = { locked: true, elementIds };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
case 'unlock_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
elementIds.forEach(id => {
const element = elements.get(id);
if (element) {
element.locked = false;
}
});
const result = { unlocked: true, elementIds };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
logger.error(`Error handling tool call: ${error.message}`, { error });
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
isError: true
};
}
});
// Set up request handler for listing available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
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']
}
}
];
return { tools };
});
// Start server with STDIO transport
async function runServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info('Excalidraw MCP server running on stdio');
} catch (error) {
logger.error('Error starting server:', error);
process.exit(1);
}
}
runServer();
// For testing and debugging purposes
if (process.env.DEBUG === 'true') {
logger.debug('Debug mode enabled');
}
export default server;
+36
View File
@@ -0,0 +1,36 @@
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES = {
RECTANGLE: 'rectangle',
ELLIPSE: 'ellipse',
DIAMOND: 'diamond',
ARROW: 'arrow',
TEXT: 'text',
LABEL: 'label',
FREEDRAW: 'freedraw',
LINE: 'line',
ARROW_LABEL: 'arrowLabel'
};
// 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);
}
+19
View File
@@ -0,0 +1,19 @@
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
]
});
export default logger;