Refactor project structure and enhance TypeScript support
- Updated package.json to point to compiled TypeScript files in the dist directory. - Improved TypeScript configuration with stricter type checks and removed JavaScript support. - Migrated frontend entry point to TypeScript and added a new App component with enhanced functionality. - Implemented a new server structure with TypeScript, including WebSocket support and improved element management. - Updated README to reflect changes in architecture and usage instructions. - Added comprehensive type definitions for Excalidraw elements and server responses.
This commit is contained in:
+877
@@ -0,0 +1,877 @@
|
||||
#!/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;
|
||||
Reference in New Issue
Block a user