Merge pull request #31 from frNNcs/feature/mermaid-integration

Add Mermaid Diagram Integration to MCP Excalidraw
This commit is contained in:
yctimlin
2025-11-07 23:02:26 +08:00
committed by GitHub
9 changed files with 1131 additions and 96 deletions
+7
View File
@@ -99,6 +99,13 @@ Both local and Docker setups are **fully working** and production-ready!
- **Strict Type Checking**: Enhanced development experience and compile-time error detection - **Strict Type Checking**: Enhanced development experience and compile-time error detection
- **Type-Safe React Components**: TSX components with proper props typing - **Type-Safe React Components**: TSX components with proper props typing
### **🎨 Mermaid Diagram Support (NEW!)**
- **Mermaid to Excalidraw**: Convert Mermaid diagrams directly to Excalidraw elements
- **MCP Tool Integration**: Use `create_from_mermaid` tool from Claude
- **Browser-based Conversion**: Leverages DOM access in the frontend for accurate rendering
- **Multiple Diagram Types**: Supports flowcharts, sequence diagrams, class diagrams, and more
- **Test Button**: Quick test functionality directly from the canvas UI
### **Real-time Canvas Integration** ### **Real-time Canvas Integration**
- Elements created via MCP appear instantly on the live canvas - Elements created via MCP appear instantly on the live canvas
- WebSocket-based real-time synchronization - WebSocket-based real-time synchronization
+48 -14
View File
@@ -2,13 +2,14 @@ import React, { useState, useEffect, useRef } from 'react'
import { import {
Excalidraw, Excalidraw,
convertToExcalidrawElements, convertToExcalidrawElements,
CaptureUpdateAction, CaptureUpdateAction
ExcalidrawAPIRefValue,
ExcalidrawElement
} from '@excalidraw/excalidraw' } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css' import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
// Type definitions // Type definitions
type ExcalidrawAPIRefValue = any;
type ExcalidrawElement = any;
interface ServerElement { interface ServerElement {
id: string; id: string;
type: string; type: string;
@@ -164,7 +165,7 @@ function App(): JSX.Element {
if (result.success && result.elements && result.elements.length > 0) { if (result.success && result.elements && result.elements.length > 0) {
const cleanedElements = result.elements.map(cleanElementForExcalidraw) const cleanedElements = result.elements.map(cleanElementForExcalidraw)
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false }) const convertedElements = convertToExcalidrawElements(cleanedElements as any, { regenerateIds: false })
excalidrawAPI?.updateScene({ elements: convertedElements }) excalidrawAPI?.updateScene({ elements: convertedElements })
} }
} catch (error) { } catch (error) {
@@ -214,7 +215,7 @@ function App(): JSX.Element {
} }
} }
const handleWebSocketMessage = (data: WebSocketMessage): void => { const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
if (!excalidrawAPI) { if (!excalidrawAPI) {
return return
} }
@@ -228,7 +229,7 @@ function App(): JSX.Element {
if (data.elements && data.elements.length > 0) { if (data.elements && data.elements.length > 0) {
const cleanedElements = data.elements.map(cleanElementForExcalidraw) const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements) const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertToExcalidrawElements(validatedElements) const convertedElements = convertToExcalidrawElements(validatedElements as any)
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: convertedElements, elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER captureUpdate: CaptureUpdateAction.NEVER
@@ -239,7 +240,7 @@ function App(): JSX.Element {
case 'element_created': case 'element_created':
if (data.element) { if (data.element) {
const cleanedNewElement = cleanElementForExcalidraw(data.element) const cleanedNewElement = cleanElementForExcalidraw(data.element)
const newElement = convertToExcalidrawElements([cleanedNewElement]) const newElement = convertToExcalidrawElements([cleanedNewElement] as any)
const updatedElementsAfterCreate = [...currentElements, ...newElement] const updatedElementsAfterCreate = [...currentElements, ...newElement]
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: updatedElementsAfterCreate, elements: updatedElementsAfterCreate,
@@ -251,8 +252,8 @@ function App(): JSX.Element {
case 'element_updated': case 'element_updated':
if (data.element) { if (data.element) {
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element) const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0] const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement] as any)[0]
const updatedElements = currentElements.map(el => const updatedElements = currentElements.map((el: any) =>
el.id === data.element!.id ? convertedUpdatedElement : el el.id === data.element!.id ? convertedUpdatedElement : el
) )
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
@@ -264,7 +265,7 @@ function App(): JSX.Element {
case 'element_deleted': case 'element_deleted':
if (data.elementId) { if (data.elementId) {
const filteredElements = currentElements.filter(el => el.id !== data.elementId) const filteredElements = currentElements.filter((el: any) => el.id !== data.elementId)
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: filteredElements, elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER captureUpdate: CaptureUpdateAction.NEVER
@@ -275,7 +276,7 @@ function App(): JSX.Element {
case 'elements_batch_created': case 'elements_batch_created':
if (data.elements) { if (data.elements) {
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
const batchElements = convertToExcalidrawElements(cleanedBatchElements) const batchElements = convertToExcalidrawElements(cleanedBatchElements as any)
const updatedElementsAfterBatch = [...currentElements, ...batchElements] const updatedElementsAfterBatch = [...currentElements, ...batchElements]
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: updatedElementsAfterBatch, elements: updatedElementsAfterBatch,
@@ -293,6 +294,39 @@ function App(): JSX.Element {
console.log(`Server sync status: ${data.count} elements`) console.log(`Server sync status: ${data.count} elements`)
break break
case 'mermaid_convert':
console.log('Received Mermaid conversion request from MCP')
if ((data as any).mermaidDiagram) {
try {
const result = await convertMermaidToExcalidraw((data as any).mermaidDiagram, (data as any).config || DEFAULT_MERMAID_CONFIG)
if (result.error) {
console.error('Mermaid conversion error:', result.error)
return
}
if (result.elements && result.elements.length > 0) {
const convertedElements = convertToExcalidrawElements(result.elements as any, { regenerateIds: false })
excalidrawAPI.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
if (result.files) {
excalidrawAPI.addFiles(Object.values(result.files))
}
console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements')
// Sync to backend automatically after creating elements
await syncToBackend()
}
} catch (error) {
console.error('Error converting Mermaid diagram from WebSocket:', error)
}
}
break
default: default:
console.log('Unknown WebSocket message type:', data.type) console.log('Unknown WebSocket message type:', data.type)
} }
@@ -332,8 +366,8 @@ function App(): JSX.Element {
const currentElements = excalidrawAPI.getSceneElements() const currentElements = excalidrawAPI.getSceneElements()
console.log(`Syncing ${currentElements.length} elements to backend`) console.log(`Syncing ${currentElements.length} elements to backend`)
// 2. Filter out deleted elements // Filter out deleted elements
const activeElements = currentElements.filter(el => !el.isDeleted) const activeElements = currentElements.filter((el: any) => !el.isDeleted)
// 3. Convert to backend format // 3. Convert to backend format
const backendElements = activeElements.map(convertToBackendFormat) const backendElements = activeElements.map(convertToBackendFormat)
+1
View File
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import '@excalidraw/excalidraw/index.css'
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
+47
View File
@@ -0,0 +1,47 @@
import { parseMermaidToExcalidraw, MermaidConfig } from '@excalidraw/mermaid-to-excalidraw';
export interface MermaidConversionResult {
elements: any[];
files?: any;
error?: string;
}
/**
* Converts a Mermaid diagram definition to Excalidraw elements
* This function needs to run in the browser context as it requires DOM access
*/
export const convertMermaidToExcalidraw = async (
mermaidDefinition: string,
config?: MermaidConfig
): Promise<MermaidConversionResult> => {
try {
// Parse the Mermaid diagram to Excalidraw elements
const result = await parseMermaidToExcalidraw(mermaidDefinition, config);
return {
elements: result.elements,
files: result.files,
};
} catch (error) {
console.error('Error converting Mermaid to Excalidraw:', error);
return {
elements: [],
error: error instanceof Error ? error.message : String(error),
};
}
};
/**
* Default Mermaid configuration for Excalidraw conversion
*/
export const DEFAULT_MERMAID_CONFIG: MermaidConfig = {
startOnLoad: false,
flowchart: {
curve: 'linear',
},
themeVariables: {
fontSize: '20px',
},
maxEdges: 500,
maxTextSize: 50000,
};
+863 -81
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,10 +22,12 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
"@modelcontextprotocol/sdk": "latest", "@modelcontextprotocol/sdk": "latest",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"mermaid": "^11.12.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
+89
View File
@@ -414,6 +414,41 @@ const tools: Tool[] = [
required: ['elementIds'] required: ['elementIds']
} }
}, },
{
name: 'create_from_mermaid',
description: 'Convert a Mermaid diagram to Excalidraw elements and render them on the canvas',
inputSchema: {
type: 'object',
properties: {
mermaidDiagram: {
type: 'string',
description: 'The Mermaid diagram definition (e.g., "graph TD; A-->B; B-->C;")'
},
config: {
type: 'object',
description: 'Optional Mermaid configuration',
properties: {
startOnLoad: { type: 'boolean' },
flowchart: {
type: 'object',
properties: {
curve: { type: 'string', enum: ['linear', 'basis'] }
}
},
themeVariables: {
type: 'object',
properties: {
fontSize: { type: 'string' }
}
},
maxEdges: { type: 'number' },
maxTextSize: { type: 'number' }
}
}
},
required: ['mermaidDiagram']
}
},
{ {
name: 'batch_create_elements', name: 'batch_create_elements',
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams', description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
@@ -822,6 +857,60 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
} }
} }
case 'create_from_mermaid': {
const params = z.object({
mermaidDiagram: z.string(),
config: z.object({
startOnLoad: z.boolean().optional(),
flowchart: z.object({
curve: z.enum(['linear', 'basis']).optional()
}).optional(),
themeVariables: z.object({
fontSize: z.string().optional()
}).optional(),
maxEdges: z.number().optional(),
maxTextSize: z.number().optional()
}).optional()
}).parse(args);
logger.info('Creating Excalidraw elements from Mermaid diagram via MCP', {
diagramLength: params.mermaidDiagram.length,
hasConfig: !!params.config
});
try {
// Send the Mermaid diagram to the frontend via the API
// The frontend will use mermaid-to-excalidraw to convert it
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mermaidDiagram: params.mermaidDiagram,
config: params.config
})
});
if (!response.ok) {
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
}
const result = await response.json() as ApiResponse;
logger.info('Mermaid diagram sent to frontend for conversion', {
success: result.success
});
return {
content: [{
type: 'text',
text: `Mermaid diagram sent for conversion!\n\n${JSON.stringify(result, null, 2)}\n\n⚠️ Note: The actual conversion happens in the frontend canvas with DOM access. Open the canvas at ${EXPRESS_SERVER_URL} to see the diagram rendered.`
}]
};
} catch (error) {
throw new Error(`Failed to process Mermaid diagram: ${(error as Error).message}`);
}
}
case 'batch_create_elements': { case 'batch_create_elements': {
const params = z.object({ elements: z.array(ElementSchema) }).parse(args); const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
logger.info('Batch creating elements via MCP', { count: params.elements.length }); logger.info('Batch creating elements via MCP', { count: params.elements.length });
+41
View File
@@ -399,6 +399,47 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
} }
}); });
// Convert Mermaid diagram to Excalidraw elements
app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
try {
const { mermaidDiagram, config } = req.body;
if (!mermaidDiagram || typeof mermaidDiagram !== 'string') {
return res.status(400).json({
success: false,
error: 'Mermaid diagram definition is required'
});
}
logger.info('Received Mermaid conversion request', {
diagramLength: mermaidDiagram.length,
hasConfig: !!config
});
// Broadcast to all WebSocket clients to process the Mermaid diagram
broadcast({
type: 'mermaid_convert',
mermaidDiagram,
config: config || {},
timestamp: new Date().toISOString()
});
// Return the diagram for frontend processing
res.json({
success: true,
mermaidDiagram,
config: config || {},
message: 'Mermaid diagram sent to frontend for conversion.'
});
} catch (error) {
logger.error('Error processing Mermaid diagram:', error);
res.status(400).json({
success: false,
error: (error as Error).message
});
}
});
// Sync elements from frontend (overwrite sync) // Sync elements from frontend (overwrite sync)
app.post('/api/elements/sync', (req: Request, res: Response) => { app.post('/api/elements/sync', (req: Request, res: Response) => {
try { try {
+33 -1
View File
@@ -175,7 +175,8 @@ export type WebSocketMessageType =
| 'element_deleted' | 'element_deleted'
| 'elements_batch_created' | 'elements_batch_created'
| 'elements_synced' | 'elements_synced'
| 'sync_status'; | 'sync_status'
| 'mermaid_convert';
export interface InitialElementsMessage extends WebSocketMessage { export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements'; type: 'initial_elements';
@@ -208,6 +209,37 @@ export interface SyncStatusMessage extends WebSocketMessage {
timestamp: string; timestamp: string;
} }
export interface MermaidConvertMessage extends WebSocketMessage {
type: 'mermaid_convert';
mermaidDiagram: string;
config?: MermaidConfig;
timestamp: string;
}
// Mermaid conversion types
export interface MermaidConfig {
startOnLoad?: boolean;
flowchart?: {
curve?: 'linear' | 'basis';
};
themeVariables?: {
fontSize?: string;
};
maxEdges?: number;
maxTextSize?: number;
}
export interface MermaidConversionRequest {
mermaidDiagram: string;
config?: MermaidConfig;
}
export interface MermaidConversionResponse extends ApiResponse {
elements: ServerElement[];
files?: any;
count: number;
}
// In-memory storage for Excalidraw elements // In-memory storage for Excalidraw elements
export const elements = new Map<string, ServerElement>(); export const elements = new Map<string, ServerElement>();