From 860e905b565a893c17af154be20564b1703539c1 Mon Sep 17 00:00:00 2001 From: yctimlin Date: Sun, 16 Mar 2025 21:52:02 +0800 Subject: [PATCH] Initialize Excalidraw MCP server with essential files including Docker setup, environment configuration, and logging. Added core functionality for element manipulation and server operations. --- .env.example | 8 + .gitignore | 3 + Dockerfile | 12 + README.md | 162 +++++++++ claude_desktop_config.json | 14 + package.json | 41 +++ src/index.js | 707 +++++++++++++++++++++++++++++++++++++ src/types.js | 36 ++ src/utils/logger.js | 19 + 9 files changed, 1002 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 claude_desktop_config.json create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/types.js create mode 100644 src/utils/logger.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ed3b70c --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5693cd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +package-lock.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fe83e6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-slim + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..80e3c2e --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/claude_desktop_config.json b/claude_desktop_config.json new file mode 100644 index 0000000..4c03725 --- /dev/null +++ b/claude_desktop_config.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "excalidraw": { + "command": "node", + "args": [ + "src/index.js" + ], + "env": { + "LOG_LEVEL": "info", + "DEBUG": "false" + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9c10df --- /dev/null +++ b/package.json @@ -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" +} \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..9e37753 --- /dev/null +++ b/src/index.js @@ -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; \ No newline at end of file diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..9ea807c --- /dev/null +++ b/src/types.js @@ -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); +} \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..81b5dbe --- /dev/null +++ b/src/utils/logger.js @@ -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; \ No newline at end of file