spex-mcp
Version:
MCP server for Figma SpeX plugin and Cursor AI integration
618 lines (561 loc) • 21.2 kB
JavaScript
/**
* 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}`,
},
],
};
}
}
}