index.ts•27 kB
#!/usr/bin/env node
// Disable colors to prevent ANSI color codes from breaking JSON parsing
process.env.NODE_DISABLE_COLORS = '1';
process.env.NO_COLOR = '1';
import { fileURLToPath } from "url";
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
CallToolRequest,
Tool
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import dotenv from 'dotenv';
import logger from './utils/logger.js';
import {
generateId,
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
validateElement
} from './types.js';
import fetch from 'node-fetch';
// Load environment variables
dotenv.config();
// Express server configuration
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
// 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)
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
if (!ENABLE_CANVAS_SYNC) {
logger.debug('Canvas sync disabled, skipping');
return null;
}
try {
let url: string;
let options: any;
switch (operation) {
case 'create':
url = `${EXPRESS_SERVER_URL}/api/elements`;
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
};
break;
case 'update':
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
};
break;
case 'delete':
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
options = { method: 'DELETE' };
break;
case 'batch_create':
url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ elements: data })
};
break;
default:
logger.warn(`Unknown sync operation: ${operation}`);
return null;
}
logger.debug(`Syncing to canvas: ${operation}`, { url, data });
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Canvas sync failed: ${response.status} ${response.statusText}`);
}
const result = await response.json() as ApiResponse;
logger.debug(`Canvas sync successful: ${operation}`, result);
return result as SyncResponse;
} catch (error) {
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
return null;
}
}
// Helper to sync element creation to canvas
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
const result = await syncToCanvas('create', elementData);
return result?.element || elementData;
}
// Helper to sync element update to canvas
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
const result = await syncToCanvas('update', elementData);
return result?.element || null;
}
// Helper to sync element deletion to canvas
async function deleteElementOnCanvas(elementId: string): Promise<any> {
const result = await syncToCanvas('delete', { id: elementId });
return result;
}
// Helper to sync batch creation to canvas
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
const result = await syncToCanvas('batch_create', elementsData);
return result?.elements || elementsData;
}
// In-memory storage for scene state
interface SceneState {
theme: string;
viewport: { x: number; y: number; zoom: number };
selectedElements: Set<string>;
groups: Map<string, string[]>;
}
const sceneState: 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) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
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) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
filter: z.record(z.any()).optional()
});
const ResourceSchema = z.object({
resource: z.enum(['scene', 'library', 'theme', 'elements'])
});
// Tool definitions
const tools: Tool[] = [
{
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']
}
}
];
// Initialize MCP server
const server = new Server(
{
name: "mcp-excalidraw-server",
version: "1.0.2",
description: "Advanced MCP server for Excalidraw with real-time canvas"
},
{
capabilities: {
tools: Object.fromEntries(tools.map(tool => [tool.name, {
description: tool.description,
inputSchema: tool.inputSchema
}]))
}
}
);
// Helper function to convert text property to label format for Excalidraw
function convertTextToLabel(element: ServerElement): ServerElement {
const { text, ...rest } = element;
if (text) {
// For standalone text elements, keep text as direct property
if (element.type === 'text') {
return element; // Keep text as direct property
}
// For other elements (rectangle, ellipse, diamond), convert to label format
return {
...rest,
label: { text }
} as ServerElement;
}
return element;
}
// Set up request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
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 via MCP', { type: params.type });
const id = generateId();
const element: ServerElement = {
id,
...params,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(element);
// Create element directly on HTTP server (no local storage)
const canvasElement = await createElementOnCanvas(excalidrawElement);
if (!canvasElement) {
throw new Error('Failed to create element: HTTP server unavailable');
}
logger.info('Element created via MCP and synced to canvas', {
id: excalidrawElement.id,
type: excalidrawElement.type,
synced: !!canvasElement
});
return {
content: [{
type: 'text',
text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
}]
};
}
case 'update_element': {
const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args);
const { id, ...updates } = params;
if (!id) throw new Error('Element ID is required');
// Build update payload with timestamp and version increment
const updatePayload: Partial<ServerElement> & { id: string } = {
id,
...updates,
updatedAt: new Date().toISOString()
};
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
// Update element directly on HTTP server (no local storage)
const canvasElement = await updateElementOnCanvas(excalidrawElement);
if (!canvasElement) {
throw new Error('Failed to update element: HTTP server unavailable or element not found');
}
logger.info('Element updated via MCP and synced to canvas', {
id: excalidrawElement.id,
synced: !!canvasElement
});
return {
content: [{
type: 'text',
text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
}]
};
}
case 'delete_element': {
const params = ElementIdSchema.parse(args);
const { id } = params;
// Delete element directly on HTTP server (no local storage)
const canvasResult = await deleteElementOnCanvas(id);
if (!canvasResult) {
throw new Error('Failed to delete element: HTTP server unavailable or element not found');
}
const result = { id, deleted: true, syncedToCanvas: true };
logger.info('Element deleted via MCP and synced to canvas', result);
return {
content: [{
type: 'text',
text: `Element deleted successfully!\n\n${JSON.stringify(result, null, 2)}\n\n✅ Synced to canvas`
}]
};
}
case 'query_elements': {
const params = QuerySchema.parse(args || {});
const { type, filter } = params;
try {
// Build query parameters
const queryParams = new URLSearchParams();
if (type) queryParams.set('type', type);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
queryParams.set(key, String(value));
});
}
// Query elements from HTTP server
const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
const data = await response.json() as ApiResponse;
const results = data.elements || [];
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to query elements: ${(error as Error).message}`);
}
}
case 'get_resource': {
const params = ResourceSchema.parse(args);
const { resource } = params;
logger.info('Getting resource', { resource });
let result: any;
switch (resource) {
case 'scene':
result = {
theme: sceneState.theme,
viewport: sceneState.viewport,
selectedElements: Array.from(sceneState.selectedElements)
};
break;
case 'library':
case 'elements':
try {
// Get elements from HTTP server
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
const data = await response.json() as ApiResponse;
result = {
elements: data.elements || []
};
} catch (error) {
throw new Error(`Failed to get elements: ${(error as Error).message}`);
}
break;
case 'theme':
result = {
theme: sceneState.theme
};
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;
try {
// Lock elements through HTTP API updates
const updatePromises = elementIds.map(async (id) => {
return await updateElementOnCanvas({ id, locked: true });
});
const results = await Promise.all(updatePromises);
const successCount = results.filter(result => result).length;
if (successCount === 0) {
throw new Error('Failed to lock any elements: HTTP server unavailable');
}
const result = { locked: true, elementIds, successCount };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to lock elements: ${(error as Error).message}`);
}
}
case 'unlock_elements': {
const params = ElementIdsSchema.parse(args);
const { elementIds } = params;
try {
// Unlock elements through HTTP API updates
const updatePromises = elementIds.map(async (id) => {
return await updateElementOnCanvas({ id, locked: false });
});
const results = await Promise.all(updatePromises);
const successCount = results.filter(result => result).length;
if (successCount === 0) {
throw new Error('Failed to unlock any elements: HTTP server unavailable');
}
const result = { unlocked: true, elementIds, successCount };
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
throw new Error(`Failed to unlock elements: ${(error as Error).message}`);
}
}
case 'batch_create_elements': {
const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
logger.info('Batch creating elements via MCP', { count: params.elements.length });
const createdElements: ServerElement[] = [];
// Create each element with unique ID
for (const elementData of params.elements) {
const id = generateId();
const element: ServerElement = {
id,
...elementData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(element);
createdElements.push(excalidrawElement);
}
// Create all elements directly on HTTP server (no local storage)
const canvasElements = await batchCreateElementsOnCanvas(createdElements);
if (!canvasElements) {
throw new Error('Failed to batch create elements: HTTP server unavailable');
}
const result = {
success: true,
elements: canvasElements,
count: canvasElements.length,
syncedToCanvas: true
};
logger.info('Batch elements created via MCP and synced to canvas', {
count: result.count,
synced: result.syncedToCanvas
});
return {
content: [{
type: 'text',
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}`
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
return {
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
isError: true
};
}
});
// Set up request handler for listing available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
logger.info('Listing available tools');
return { tools };
});
// Start server with transport based on mode
async function runServer(): Promise<void> {
try {
logger.info('Starting Excalidraw MCP server...');
const transportMode = process.env.MCP_TRANSPORT_MODE || 'stdio';
let transport;
if (transportMode === 'http') {
const port = parseInt(process.env.PORT || '3000', 10);
const host = process.env.HOST || 'localhost';
logger.info(`Starting HTTP server on ${host}:${port}`);
// Here you would create an HTTP transport
// This is a placeholder - actual HTTP transport implementation would need to be added
transport = new StdioServerTransport(); // Fallback to stdio for now
} else {
// Default to stdio transport
transport = new StdioServerTransport();
}
// Add a debug message before connecting
logger.debug('Connecting to transport...');
await server.connect(transport);
logger.info(`Excalidraw MCP server running on ${transportMode}`);
// Keep the process running
process.stdin.resume();
} catch (error) {
logger.error('Error starting server:', error);
process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`);
process.exit(1);
}
}
// Add global error handlers
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught exception:', error);
process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
setTimeout(() => process.exit(1), 1000);
});
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.error('Unhandled promise rejection:', reason);
process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
setTimeout(() => process.exit(1), 1000);
});
// For testing and debugging purposes
if (process.env.DEBUG === 'true') {
logger.debug('Debug mode enabled');
}
// Start the server if this file is run directly
if (fileURLToPath(import.meta.url) === process.argv[1]) {
runServer().catch(error => {
logger.error('Failed to start server:', error);
process.exit(1);
});
}
export default runServer;