UNPKG

spex-mcp

Version:

MCP server for Figma SpeX plugin and Cursor AI integration

618 lines (561 loc) 21.2 kB
/** * MCPToolsManager handles all MCP tools for Cursor AI integration * Supports image injection as per Cursor MCP documentation */ export class MCPToolsManager { constructor(figmaManager, mcpWebSocketConnection = null) { this.figmaManager = figmaManager; this.mcpWebSocketConnection = mcpWebSocketConnection; } /** * Set the WebSocket connection for this MCP client */ setWebSocketConnection(connection) { this.mcpWebSocketConnection = connection; } /** * Send a spec request through WebSocket to the session-matched plugin */ async sendSpecRequestViaWebSocket(requestType, fileName = null, requestData = null) { if (!this.mcpWebSocketConnection) { throw new Error('No WebSocket connection available for MCP client'); } return new Promise((resolve, reject) => { const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; // Set up response handler const responseHandler = (data) => { try { const parsedData = JSON.parse(data.toString()); if (parsedData.type === 'spec-response' && parsedData.requestId === requestId) { this.mcpWebSocketConnection.off('message', responseHandler); if (parsedData.success) { resolve({ success: true, data: parsedData.data }); } else { resolve({ success: false, error: parsedData.error }); } } } catch (error) { console.error('Error parsing WebSocket response:', error); } }; // Add response handler this.mcpWebSocketConnection.on('message', responseHandler); // Send the request const requestMessage = { type: 'spec-request', requestId: requestId, requestType: requestType, fileName: fileName, timestamp: new Date().toISOString() }; // Add requestData if provided (for complex requests like image generation) if (requestData) { try { // If requestData is a string, try to parse it as JSON requestMessage.requestData = typeof requestData === 'string' ? JSON.parse(requestData) : requestData; } catch (error) { // If parsing fails, treat as raw string requestMessage.requestData = requestData; } } this.mcpWebSocketConnection.send(JSON.stringify(requestMessage)); // Set timeout setTimeout(() => { this.mcpWebSocketConnection.off('message', responseHandler); reject(new Error('Request timeout after 30 seconds')); }, 30000); }); } /** * Get list of all available MCP tools for Cursor AI */ getToolsList() { return [ { name: "hello-world", description: "Returns a simple hello world message", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "get-specs-readme", description: "Fetches the README.md file from the SpeX plugin that contains documentation about the exported design specs", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "get-spec-files-manifest", description: "Fetches the manifest.yaml file from the SpeX plugin to confirm design specs are ready and get the list of available spec files", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "get-a-spec-file", description: "Fetches a specific design specification file from the SpeX plugin by filename", inputSchema: { type: "object", properties: { file_name: { type: "string", description: "The name of the spec file to retrieve (e.g., 'screen.yaml', 'components/button.yaml')" } }, required: ["file_name"], }, }, { name: "get-page-thumbnail", description: "Fetches a thumbnail image of the current page or screen from the Figma design", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "view_image_file_in_base64", description: "View a physical image file in base64 format from the specified file path", inputSchema: { type: "object", properties: { absolute_file_path: { type: "string", description: "The absolute file path of the image file to view" } }, required: ["absolute_file_path"], }, }, { name: "convert_svg_to_vector_drawable", description: "Converts SVG content to Android Vector Drawable XML format for use in Android applications", inputSchema: { type: "object", properties: { svgContent: { type: "string", description: "The SVG content to convert" }, tint: { type: "string", description: "Optional tint color to apply to the vector drawable (e.g., '#FF0000')" } }, required: ["svgContent"], }, }, ]; } /** * Handle MCP tool calls from Cursor AI with session matching */ async handleToolCall(name, args = {}, sessionId = null) { switch (name) { case "hello-world": return this.handleHelloWorld(); case "get-specs-readme": return this.handleGetSpecsReadme(sessionId); case "get-spec-files-manifest": return this.handleGetSpecFilesManifest(sessionId); case "get-a-spec-file": return this.handleGetASpecFile(args, sessionId); case "get-page-thumbnail": return this.handleGetPageThumbnail(args, sessionId); case "view_image_file_in_base64": return this.handleViewImageFileInBase64(args, sessionId); case "convert_svg_to_vector_drawable": return this.handleConvertSvgToVectorDrawable(args); default: throw new Error(`Unknown tool: ${name}`); } } /** * Handle hello world tool call */ handleHelloWorld() { return { content: [ { type: "text", text: "Hello World from SpeX MCP Server! 🎨", }, ], }; } /** * Handle get specs readme tool call with WebSocket communication */ async handleGetSpecsReadme(sessionId) { try { // Send request via WebSocket to session-matched plugin const result = await this.sendSpecRequestViaWebSocket('get-specs-readme'); if (result.success && result.data) { return { content: [ { type: "text", text: result.data.content, }, ], }; } else { return { content: [ { type: "text", text: `Error fetching README: ${result.error || 'Unknown error'}`, }, ], }; } } catch (error) { return { content: [ { type: "text", text: `Error requesting README: ${error.message}`, }, ], }; } } /** * Handle get spec files manifest tool call with WebSocket communication */ async handleGetSpecFilesManifest(sessionId) { try { // Send request via WebSocket to session-matched plugin const result = await this.sendSpecRequestViaWebSocket('get-spec-files-manifest'); if (result.success && result.data) { return { content: [ { type: "text", text: result.data.content, }, ], }; } else { return { content: [ { type: "text", text: `Error fetching manifest: ${result.error || 'Unknown error'}`, }, ], }; } } catch (error) { return { content: [ { type: "text", text: `Error requesting manifest: ${error.message}`, }, ], }; } } /** * Handle get a spec file tool call with WebSocket communication */ async handleGetASpecFile(args, sessionId) { try { const { file_name } = args; if (!file_name) { return { content: [ { type: "text", text: "Error: file_name parameter is required", }, ], }; } // Send request via WebSocket to session-matched plugin const result = await this.sendSpecRequestViaWebSocket('get-a-spec-file', file_name); if (result.success && result.data) { return { content: [ { type: "text", text: result.data.content, }, ], }; } else { return { content: [ { type: "text", text: `Error fetching file '${file_name}': ${result.error || 'Unknown error'}`, }, ], }; } } catch (error) { return { content: [ { type: "text", text: `Error requesting file: ${error.message}`, }, ], }; } } /** * Handle get component screenshot tool call * Returns visual screenshot of a component for reference */ /** * Handle get page thumbnail tool call with WebSocket communication * Returns thumbnail image of the current page/screen from plugin's preview files */ async handleGetPageThumbnail(args, sessionId) { try { // Send request via WebSocket to session-matched plugin const result = await this.sendSpecRequestViaWebSocket('get-page-thumbnail'); if (result.success && result.data) { // Check if response contains image data if (result.data.image_data && result.data.mime_type) { return { content: [ { type: "text", text: "Page thumbnail from Figma design:", }, { type: "image", data: result.data.image_data, mimeType: result.data.mime_type, }, ], }; } else { return { content: [ { type: "text", text: result.data.content || "Page thumbnail is not available as image data", }, ], }; } } else { return { content: [ { type: "text", text: `Error fetching page thumbnail: ${result.error || 'Unknown error'}`, }, ], }; } } catch (error) { return { content: [ { type: "text", text: `Error requesting page thumbnail: ${error.message}`, }, ], }; } } /** * Handle view image file in base64 tool call * Reads an existing image file from disk and returns it in base64 format * Supports PNG and JPEG formats */ async handleViewImageFileInBase64(args, sessionId) { try { const { absolute_file_path } = args; if (!absolute_file_path) { return { content: [ { type: "text", text: "Error: file_path parameter is required", }, ], }; } // Read the image file from disk const result = await this.readImageFile(absolute_file_path); if (result.success && result.image_data) { return { content: [ { type: "image", data: result.image_data, mimeType: result.mime_type, }, ], }; } else { return { content: [ { type: "text", text: `Error reading image file '${absolute_file_path}': ${result.error || 'Unknown error'}`, }, ], }; } } catch (error) { return { content: [ { type: "text", text: `Error reading UI Compose snapshot image: ${error.message}`, }, ], }; } } /** * Read an image file from disk and return it as base64 * Validates that the file is PNG or JPEG format */ async readImageFile(filePath) { try { // Import required modules const fs = await import('fs'); const path = await import('path'); // Check if file exists if (!fs.existsSync(filePath)) { return { success: false, error: `File not found: ${filePath}` }; } // Get file extension to determine MIME type const ext = path.extname(filePath).toLowerCase(); let mimeType; switch (ext) { case '.png': mimeType = 'image/png'; break; case '.jpg': case '.jpeg': mimeType = 'image/jpeg'; break; default: return { success: false, error: `Unsupported image format: ${ext}. Only PNG and JPEG are supported.` }; } // Read file as buffer const imageBuffer = fs.readFileSync(filePath); // Convert to base64 const base64Data = imageBuffer.toString('base64'); return { success: true, image_data: base64Data, mime_type: mimeType, file_info: { path: filePath, size: imageBuffer.length, format: ext.substring(1).toUpperCase() } }; } catch (error) { return { success: false, error: `Failed to read image file: ${error.message}` }; } } /** * Handle convert SVG to Vector Drawable tool call * Converts SVG content to Android Vector Drawable XML format * * @param {Object} args - Tool arguments * @param {string} args.svgContent - The SVG content to convert * @param {string} [args.tint] - Optional tint color to apply to the vector drawable * @returns {Promise<Object>} - Tool response with Vector Drawable XML or error message */ async handleConvertSvgToVectorDrawable(args) { try { // Import the SVG to Vector Drawable conversion utility using dynamic path resolution const { fileURLToPath } = await import('url'); const { dirname, join } = await import('path'); const { pathToFileURL } = await import('url'); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const utilsPath = pathToFileURL(join(__dirname, '..', 'utils', 'svg-to-vector-drawable.js')); const { convertSvgToVectorDrawable, validateSvgContent } = await import(utilsPath); // Extract parameters const { svgContent, tint } = args; // Validate required parameters if (!svgContent) { return { content: [ { type: "text", text: "Error: svgContent parameter is required", }, ], }; } // Validate SVG content format const validation = validateSvgContent(svgContent); if (!validation.isValid) { return { content: [ { type: "text", text: `Error: Invalid SVG content - ${validation.errors.join(', ')}`, }, ], }; } // Prepare options for conversion const options = {}; if (tint) options.tint = tint; // Convert SVG to Vector Drawable const result = await convertSvgToVectorDrawable(svgContent, options); // Prepare response const response = { content: [ { type: "text", text: "SVG successfully converted to Android Vector Drawable:", }, { type: "text", text: result.vectorDrawable, } ], }; // Add warnings if any if (result.warnings && result.warnings.length > 0) { response.content.push({ type: "text", text: `Warnings: ${result.warnings.join(', ')}`, }); } return response; } catch (error) { return { content: [ { type: "text", text: `Error converting SVG to Vector Drawable: ${error.message}`, }, ], }; } } }