Skip to main content
Glama

Sketch Context MCP

index.js40.9 kB
#!/usr/bin/env node const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); const dotenv = require('dotenv'); const AdmZip = require('adm-zip'); const fetch = require('node-fetch'); const bodyParser = require('body-parser'); const readline = require('readline'); const tmp = require('tmp'); const { createServer } = require('http'); const WebSocket = require('ws'); // Load environment variables from .env file dotenv.config(); // Enhanced error handling system const ERROR_TYPES = { NETWORK: 'network_error', AUTH: 'authentication_error', FILE: 'file_error', VALIDATION: 'validation_error', SKETCH: 'sketch_error', WEBSOCKET: 'websocket_error' }; const LOG_LEVELS = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 }; // Configuration object with validation const configSchema = { sketchApiKey: { type: 'string', required: false }, port: { type: 'number', min: 1000, max: 65535, default: 3333 }, isStdioMode: { type: 'boolean', default: false }, localFilePath: { type: 'string', required: false }, maxFileSize: { type: 'number', default: 100 * 1024 * 1024 }, // 100MB logLevel: { type: 'string', enum: Object.keys(LOG_LEVELS), default: 'INFO' }, healthCheckInterval: { type: 'number', default: 30000 }, // 30 seconds wsReconnectAttempts: { type: 'number', default: 5 }, requestTimeout: { type: 'number', default: 30000 } }; // Parse command line arguments const argv = yargs(hideBin(process.argv)) .option('sketch-api-key', { description: 'Your Sketch API access token', type: 'string', }) .option('port', { description: 'The port to run the server on', type: 'number', default: 3333, }) .option('stdio', { description: 'Run the server in command mode, instead of default HTTP/SSE', type: 'boolean', default: false, }) .option('local-file', { description: 'Path to a local Sketch file to serve', type: 'string', }) .option('max-file-size', { description: 'Maximum file size to process (in MB)', type: 'number', default: 100, }) .option('log-level', { description: 'Logging level (ERROR, WARN, INFO, DEBUG)', type: 'string', default: 'INFO', }) .help() .version() .alias('help', 'h') .argv; // Configuration variables with validation function validateConfig(config) { const errors = []; if (config.port < 1000 || config.port > 65535) { errors.push('Port must be between 1000 and 65535'); } if (config.localFilePath && !fs.existsSync(config.localFilePath)) { errors.push(`Local file path does not exist: ${config.localFilePath}`); } if (config.maxFileSize < 1 || config.maxFileSize > 1000) { errors.push('Max file size must be between 1MB and 1000MB'); } if (!Object.keys(LOG_LEVELS).includes(config.logLevel.toUpperCase())) { errors.push(`Invalid log level: ${config.logLevel}`); } if (errors.length > 0) { throw new ConfigurationError(errors.join(', ')); } return config; } const config = validateConfig({ sketchApiKey: argv['sketch-api-key'] || process.env.SKETCH_API_KEY, port: argv.port || process.env.PORT || 3333, isStdioMode: argv.stdio || false, localFilePath: argv['local-file'] || process.env.LOCAL_SKETCH_PATH, maxFileSize: (argv['max-file-size'] || 100) * 1024 * 1024, // Convert MB to bytes logLevel: (argv['log-level'] || process.env.LOG_LEVEL || 'INFO').toUpperCase(), healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000, wsReconnectAttempts: parseInt(process.env.WS_RECONNECT_ATTEMPTS) || 5, requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 30000 }); // Enhanced logging system class Logger { constructor(level = 'INFO') { this.level = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO; } log(level, message, metadata = {}) { if (LOG_LEVELS[level] <= this.level) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, level, message, ...metadata }; console.log(JSON.stringify(logEntry)); } } error(message, metadata = {}) { this.log('ERROR', message, metadata); } warn(message, metadata = {}) { this.log('WARN', message, metadata); } info(message, metadata = {}) { this.log('INFO', message, metadata); } debug(message, metadata = {}) { this.log('DEBUG', message, metadata); } } const logger = new Logger(config.logLevel); // Custom error classes class ConfigurationError extends Error { constructor(message) { super(message); this.name = 'ConfigurationError'; this.type = ERROR_TYPES.VALIDATION; } } class NetworkError extends Error { constructor(message, originalError = null) { super(message); this.name = 'NetworkError'; this.type = ERROR_TYPES.NETWORK; this.originalError = originalError; } } class AuthenticationError extends Error { constructor(message) { super(message); this.name = 'AuthenticationError'; this.type = ERROR_TYPES.AUTH; } } class FileError extends Error { constructor(message, filePath = null) { super(message); this.name = 'FileError'; this.type = ERROR_TYPES.FILE; this.filePath = filePath; } } class SketchError extends Error { constructor(message, sketchOperation = null) { super(message); this.name = 'SketchError'; this.type = ERROR_TYPES.SKETCH; this.sketchOperation = sketchOperation; } } // Initialize Express app const app = express(); const httpServer = createServer(app); // Enhanced CORS configuration app.use(cors({ origin: process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : true, credentials: true, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] })); app.use(bodyParser.json({ limit: '10mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' })); // Enhanced WebSocket and connection management const channels = new Map(); const clients = new Set(); const pendingRequests = new Map(); const connectionHealth = new Map(); // Connection health monitoring class ConnectionHealthMonitor { constructor() { this.healthChecks = new Map(); this.interval = null; } start() { this.interval = setInterval(() => { this.performHealthChecks(); }, config.healthCheckInterval); logger.info('Health monitoring started', { interval: config.healthCheckInterval }); } stop() { if (this.interval) { clearInterval(this.interval); this.interval = null; logger.info('Health monitoring stopped'); } } performHealthChecks() { const now = Date.now(); for (const [ws, lastSeen] of this.healthChecks.entries()) { if (now - lastSeen > config.healthCheckInterval * 2) { logger.warn('WebSocket client failed health check', { lastSeen: new Date(lastSeen).toISOString() }); this.removeUnhealthyClient(ws); } else { // Send ping to check if client is still responsive if (ws.readyState === WebSocket.OPEN) { ws.ping(); } } } } updateClientHealth(ws) { this.healthChecks.set(ws, Date.now()); } removeUnhealthyClient(ws) { this.healthChecks.delete(ws); clients.delete(ws); // Remove from all channels for (const [channelName, channelClients] of channels.entries()) { if (channelClients.has(ws)) { channelClients.delete(ws); if (channelClients.size === 0) { channels.delete(channelName); logger.info('Removed empty channel', { channel: channelName }); } } } } } const healthMonitor = new ConnectionHealthMonitor(); // Initialize WebSocket server with enhanced error handling const wss = new WebSocket.Server({ server: httpServer, perMessageDeflate: false, maxPayload: config.maxFileSize }); wss.on('connection', (ws, req) => { const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`; logger.info('WebSocket client connected', { clientId }); clients.add(ws); healthMonitor.updateClientHealth(ws); // Enhanced message handling with validation ws.on('message', (message) => { try { healthMonitor.updateClientHealth(ws); if (message.length > config.maxFileSize) { throw new Error('Message too large'); } const data = JSON.parse(message); handleWebSocketMessage(ws, data); } catch (error) { logger.error('Error processing WebSocket message', { error: error.message, clientId, messageLength: message.length }); const errorResponse = createErrorResponse(ERROR_TYPES.WEBSOCKET, error.message, { suggestion: 'Check message format and size limits' }); sendSafeWebSocketMessage(ws, errorResponse); } }); ws.on('close', (code, reason) => { logger.info('WebSocket client disconnected', { clientId, code, reason: reason.toString() }); clients.delete(ws); healthMonitor.healthChecks.delete(ws); // Remove client from all channels for (const [channelName, channelClients] of channels.entries()) { if (channelClients.has(ws)) { channelClients.delete(ws); if (channelClients.size === 0) { channels.delete(channelName); logger.info('Removed empty channel', { channel: channelName }); } } } }); ws.on('error', (error) => { logger.error('WebSocket error', { clientId, error: error.message }); healthMonitor.removeUnhealthyClient(ws); }); ws.on('pong', () => { healthMonitor.updateClientHealth(ws); }); // Send initial connection confirmation with server info const welcomeMessage = { type: 'connected', message: 'Connected to Sketch MCP server', serverInfo: { version: require('./package.json').version, capabilities: ['file_processing', 'component_listing', 'real_time_updates'], maxFileSize: config.maxFileSize, healthCheckInterval: config.healthCheckInterval } }; sendSafeWebSocketMessage(ws, welcomeMessage); }); // Safe WebSocket message sending with error handling function sendSafeWebSocketMessage(ws, message) { try { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); return true; } else { logger.warn('Attempted to send message to closed WebSocket'); return false; } } catch (error) { logger.error('Failed to send WebSocket message', { error: error.message }); return false; } } // Enhanced error response creation function createErrorResponse(type, message, metadata = {}) { return { type: 'error', errorType: type, message, timestamp: new Date().toISOString(), ...metadata }; } // Handle WebSocket messages function handleWebSocketMessage(ws, data) { console.log('Received WebSocket message:', data); // Handle joining a channel if (data.type === 'join') { const channelName = data.channel; if (!channelName) { return ws.send(JSON.stringify({ type: 'error', message: 'Channel name is required' })); } // Create the channel if it doesn't exist if (!channels.has(channelName)) { channels.set(channelName, new Set()); } // Add the client to the channel channels.get(channelName).add(ws); // Send confirmation ws.send(JSON.stringify({ type: 'system', channel: channelName, message: { result: true } })); console.log(`Client joined channel: ${channelName}`); return; } // Handle messages to be forwarded to a channel if (data.type === 'message' && data.channel) { const channelName = data.channel; const channelClients = channels.get(channelName); if (!channelClients) { return ws.send(JSON.stringify({ type: 'error', message: 'Channel not found' })); } // Store request ID if present for response routing if (data.id) { pendingRequests.set(data.id, ws); // Set timeout to clean up pending requests after 30 seconds setTimeout(() => { if (pendingRequests.has(data.id)) { pendingRequests.delete(data.id); } }, 30000); } // Forward the message to all clients in the channel except the sender channelClients.forEach(client => { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ type: 'message', channel: channelName, message: data.message })); } }); return; } // Handle command execution responses if (data.id && pendingRequests.has(data.id)) { const requester = pendingRequests.get(data.id); pendingRequests.delete(data.id); if (requester && requester.readyState === WebSocket.OPEN) { requester.send(JSON.stringify({ id: data.id, type: 'message', result: data.result, error: data.error })); } return; } // Unknown message type ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type or format' })); } // SSE endpoint for Cursor to connect to app.get('/sse', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // Add this client to our connected clients clients.add(res); // Remove client when they disconnect req.on('close', () => { clients.delete(res); }); // Send initial connection successful message res.write(`data: ${JSON.stringify({ type: 'connection_success' })}\n\n`); }); // MCP Tools registration const TOOLS = [ { name: 'get_file', description: 'Get the contents of a Sketch file', parameters: { type: 'object', properties: { url: { type: 'string', description: 'URL to a Sketch file or Sketch Cloud document', }, nodeId: { type: 'string', description: 'Optional. ID of a specific node within the document to retrieve', }, }, required: ['url'], }, }, { name: 'list_components', description: 'List all components in a Sketch file', parameters: { type: 'object', properties: { url: { type: 'string', description: 'URL to a Sketch file or Sketch Cloud document', }, }, required: ['url'], }, }, { name: 'get_selection', description: 'Get information about selected elements in a Sketch document', parameters: { type: 'object', properties: { url: { type: 'string', description: 'URL to the Sketch document', }, selectionIds: { type: 'array', items: { type: 'string' }, description: 'Array of selected element IDs from the Sketch Selection Helper plugin', } }, required: ['url', 'selectionIds'], }, }, { name: 'create_rectangle', description: 'Create a new rectangle in the Sketch document', parameters: { type: 'object', properties: { x: { type: 'number', description: 'X position of the rectangle' }, y: { type: 'number', description: 'Y position of the rectangle' }, width: { type: 'number', description: 'Width of the rectangle' }, height: { type: 'number', description: 'Height of the rectangle' }, color: { type: 'string', description: 'Fill color of the rectangle (hex format)' } }, required: ['width', 'height'], }, }, { name: 'create_text', description: 'Create a new text layer in the Sketch document', parameters: { type: 'object', properties: { text: { type: 'string', description: 'Text content' }, x: { type: 'number', description: 'X position of the text layer' }, y: { type: 'number', description: 'Y position of the text layer' }, fontSize: { type: 'number', description: 'Font size' }, color: { type: 'string', description: 'Text color (hex format)' } }, required: ['text'], }, } ]; // Handler for messages endpoint app.post('/messages', async (req, res) => { try { const message = req.body; if (message.type === 'ping') { return res.json({ type: 'pong' }); } if (message.type === 'get_tools') { return res.json({ type: 'tools', tools: TOOLS, }); } if (message.type === 'execute_tool') { const { tool, params } = message; let result; if (tool === 'get_file') { result = await getSketchFile(params.url, params.nodeId); } else if (tool === 'list_components') { result = await listSketchComponents(params.url); } else if (tool === 'get_selection') { result = await getSketchSelection(params.url, params.selectionIds); } else if (tool === 'create_rectangle') { result = await forwardToWebSocketClients('create_rectangle', params); } else if (tool === 'create_text') { result = await forwardToWebSocketClients('create_text', params); } else { return res.status(400).json({ type: 'error', error: `Unknown tool: ${tool}`, }); } return res.json({ type: 'tool_result', id: message.id, result, }); } return res.status(400).json({ type: 'error', error: `Unknown message type: ${message.type}`, }); } catch (error) { console.error('Error processing message:', error); return res.status(500).json({ type: 'error', error: error.message, }); } }); // Forward a command to all connected WebSocket clients async function forwardToWebSocketClients(command, params) { return new Promise((resolve, reject) => { if (clients.size === 0) { return reject(new Error('No connected Sketch instances')); } const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); let responseReceived = false; // Store the request handlers pendingRequests.set(requestId, { resolve, reject, timeout: setTimeout(() => { if (!responseReceived) { pendingRequests.delete(requestId); reject(new Error('Request timed out')); } }, 30000) }); // Send to all WebSocket clients in all channels for (const [channelName, channelClients] of channels.entries()) { channelClients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ type: 'message', channel: channelName, message: { id: requestId, command, params } })); } }); } }); } // Health check endpoint app.get('/health', (req, res) => { const health = { status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), memory: process.memoryUsage(), connections: { websocket: clients.size, channels: channels.size, pending: pendingRequests.size }, config: { port: config.port, maxFileSize: config.maxFileSize, logLevel: config.logLevel, hasApiKey: !!config.sketchApiKey, hasLocalFile: !!config.localFilePath } }; res.json(health); }); // Validation endpoint for configuration app.get('/validate', (req, res) => { const validationResults = { timestamp: new Date().toISOString(), configuration: { port: { valid: true, value: config.port }, maxFileSize: { valid: true, value: `${config.maxFileSize / 1024 / 1024}MB` }, logLevel: { valid: true, value: config.logLevel } }, connectivity: { websocket: clients.size > 0, channels: channels.size > 0 }, requirements: { sketchApiKey: { present: !!config.sketchApiKey, required: 'Optional for local files, required for Sketch Cloud' }, localFile: { present: !!config.localFilePath, valid: config.localFilePath ? fs.existsSync(config.localFilePath) : null, path: config.localFilePath } } }; // Add warnings if any const warnings = []; if (!config.sketchApiKey && !config.localFilePath) { warnings.push('No Sketch API key or local file configured'); } if (clients.size === 0) { warnings.push('No WebSocket clients connected'); } if (warnings.length > 0) { validationResults.warnings = warnings; } res.json(validationResults); }); // Main endpoint for basic info app.get('/', (req, res) => { const stats = { connections: clients.size, channels: channels.size, uptime: Math.floor(process.uptime()), memory: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB` }; res.send(` <h1>Sketch MCP Server</h1> <p><strong>Status:</strong> Running</p> <p><strong>Version:</strong> ${require('./package.json').version}</p> <div style="margin: 20px 0; padding: 15px; background: #f5f5f5; border-radius: 5px;"> <h3>Server Statistics</h3> <ul> <li>Active WebSocket connections: ${stats.connections}</li> <li>Active channels: ${stats.channels}</li> <li>Uptime: ${stats.uptime} seconds</li> <li>Memory usage: ${stats.memory}</li> </ul> </div> <div style="margin: 20px 0;"> <h3>Available Endpoints</h3> <ul> <li><a href="/health">Health Check</a> - Server health status</li> <li><a href="/validate">Configuration Validation</a> - Check server setup</li> <li><a href="/sse">Server-Sent Events</a> - For Cursor IDE integration</li> <li>POST /messages - Message endpoint for MCP tools</li> <li>WebSocket: ws://localhost:${config.port} - For Sketch plugin</li> </ul> </div> <div style="margin: 20px 0; padding: 15px; background: #e8f4fd; border-radius: 5px;"> <h3>Setup Instructions</h3> <p><strong>For Cursor IDE:</strong> Add MCP server with URL: <code>http://localhost:${config.port}/sse</code></p> <p><strong>For Sketch Plugin:</strong> Connect to port <code>${config.port}</code></p> </div> `); }); // Enhanced file processing with validation and error handling async function getSketchFile(url, nodeId) { try { logger.debug('Processing Sketch file request', { url, nodeId }); // Validate input parameters if (!url || typeof url !== 'string') { throw new FileError('URL parameter is required and must be a string'); } // Determine file source and validate const isCloudUrl = url.includes('sketch.cloud') || url.includes('sketch.com'); const isLocalFile = !isCloudUrl && (url.startsWith('/') || url.includes(':\\')); if (!isCloudUrl && !isLocalFile && !config.localFilePath) { throw new FileError('Invalid URL format. Must be a Sketch Cloud URL or local file path', url); } let documentData; let fileSize = 0; if (isCloudUrl) { if (!config.sketchApiKey) { throw new AuthenticationError('Sketch API key is required for cloud files. Use --sketch-api-key parameter or set SKETCH_API_KEY environment variable'); } const documentId = extractDocumentIdFromUrl(url); if (!documentId) { throw new FileError('Invalid Sketch Cloud URL format', url); } documentData = await fetchCloudDocument(documentId); } else { const filePath = isLocalFile ? url : config.localFilePath; if (!filePath) { throw new FileError('No local file specified. Use --local-file parameter or provide a file path'); } // Check file existence and size if (!fs.existsSync(filePath)) { throw new FileError(`File not found: ${filePath}`, filePath); } const stats = fs.statSync(filePath); fileSize = stats.size; if (fileSize > config.maxFileSize) { throw new FileError(`File too large: ${Math.round(fileSize / 1024 / 1024)}MB (max: ${Math.round(config.maxFileSize / 1024 / 1024)}MB)`, filePath); } if (fileSize === 0) { throw new FileError('File is empty', filePath); } documentData = await parseLocalSketchFile(filePath); } // Log successful file processing logger.info('File processed successfully', { url: isCloudUrl ? 'cloud' : 'local', fileSize: Math.round(fileSize / 1024), hasNodeId: !!nodeId }); // Handle specific node requests if (nodeId) { const node = findNodeWithMetadata(documentData, nodeId); if (!node) { throw new SketchError(`Node with ID '${nodeId}' not found in document`, 'node_lookup'); } logger.debug('Node found successfully', { nodeId, nodeType: node._class }); return enrichNodeData(node); } return documentData; } catch (error) { logger.error('Failed to process Sketch file', { url, nodeId, error: error.message, errorType: error.type || 'unknown' }); // Re-throw with appropriate error type if not already categorized if (error instanceof FileError || error instanceof AuthenticationError || error instanceof SketchError) { throw error; } else { throw new FileError(`Failed to process Sketch file: ${error.message}`, url); } } } // Function to fetch document from Sketch Cloud async function fetchCloudDocument(documentId) { if (!config.sketchApiKey) { throw new Error('Sketch API key is required for cloud files'); } // Call Sketch Cloud API to get document details const apiUrl = `https://api.sketch.cloud/v1/documents/${documentId}`; const response = await fetch(apiUrl, { headers: { 'Authorization': `Bearer ${config.sketchApiKey}` } }); if (!response.ok) { throw new Error(`Failed to fetch document from Sketch Cloud: ${response.statusText}`); } const documentData = await response.json(); // Get the download URL for the sketch file const downloadUrl = documentData.shortcut.downloadUrl; // Download the file const fileResponse = await fetch(downloadUrl); if (!fileResponse.ok) { throw new Error(`Failed to download Sketch file: ${fileResponse.statusText}`); } const buffer = await fileResponse.buffer(); return parseSketchBuffer(buffer); } // Function to parse Sketch file buffer async function parseSketchBuffer(buffer, nodeId = null) { // Create a temporary file to store the buffer const tmpFile = tmp.fileSync({ postfix: '.sketch' }); fs.writeFileSync(tmpFile.name, buffer); try { // Extract the sketch file (it's a zip) const zip = new AdmZip(tmpFile.name); const entries = zip.getEntries(); // Parse the document.json file const documentEntry = entries.find(entry => entry.entryName === 'document.json'); if (!documentEntry) { throw new Error('Invalid Sketch file: document.json not found'); } const documentJson = JSON.parse(zip.readAsText(documentEntry)); // Get meta.json for additional info const metaEntry = entries.find(entry => entry.entryName === 'meta.json'); const metaJson = metaEntry ? JSON.parse(zip.readAsText(metaEntry)) : {}; // Parse pages const pages = []; const pagesEntries = entries.filter(entry => entry.entryName.startsWith('pages/')); for (const pageEntry of pagesEntries) { const pageJson = JSON.parse(zip.readAsText(pageEntry)); pages.push(pageJson); } // Construct the result const result = { document: documentJson, meta: metaJson, pages: pages, }; // If nodeId is specified, find the specific node if (nodeId) { // First check in the document let node = findNodeById(documentJson, nodeId); // If not found, check in pages if (!node) { for (const page of pages) { node = findNodeById(page, nodeId); if (node) break; } } if (!node) { throw new Error(`Node with ID ${nodeId} not found in the document`); } return node; } return result; } finally { // Clean up tmpFile.removeCallback(); } } // Function to list components in a Sketch file async function listSketchComponents(url) { try { const sketchData = await getSketchFile(url); const components = []; // Search for components in document findComponents(sketchData.document, components); // Search for components in pages for (const page of sketchData.pages) { findComponents(page, components); } return components; } catch (error) { console.error('Error listing components:', error); throw error; } } // Helper function to find components in a Sketch object function findComponents(obj, components) { if (!obj) return; // Check if the object is a component (Symbol master in Sketch) if (obj._class === 'symbolMaster') { components.push({ id: obj.do_objectID, name: obj.name, type: 'component', frame: obj.frame, }); } // Recursively search for components in layers and other objects if (obj.layers && Array.isArray(obj.layers)) { for (const layer of obj.layers) { findComponents(layer, components); } } // Check for artboards, which can contain components if (obj.artboards && obj.artboards.objects) { for (const artboard of obj.artboards.objects) { findComponents(artboard, components); } } } // Helper function to find a node by ID function findNodeById(obj, id) { if (!obj) return null; // Check if this is the node we're looking for if (obj.do_objectID === id) { return obj; } // Check in layers if (obj.layers && Array.isArray(obj.layers)) { for (const layer of obj.layers) { const found = findNodeById(layer, id); if (found) return found; } } // Check in artboards if (obj.artboards && obj.artboards.objects) { for (const artboard of obj.artboards.objects) { const found = findNodeById(artboard, id); if (found) return found; } } // Check in pages if (obj.pages && obj.pages.objects) { for (const page of obj.pages.objects) { const found = findNodeById(page, id); if (found) return found; } } return null; } // Helper function to extract document ID from Sketch Cloud URL function extractDocumentIdFromUrl(url) { const regex = /sketch\.cloud\/s\/([a-zA-Z0-9]+)/; const match = url.match(regex); return match ? match[1] : null; } // Function to broadcast messages to all connected SSE clients function broadcast(message) { for (const client of clients) { if (client.write) { // SSE client client.write(`data: ${JSON.stringify(message)}\n\n`); } else if (client.readyState === WebSocket.OPEN) { // WebSocket client client.send(JSON.stringify(message)); } } } // Check if we should run in stdio mode if (config.isStdioMode) { console.log('Initializing Sketch MCP Server in stdio mode...'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false, }); rl.on('line', async (line) => { try { const message = JSON.parse(line); let response; if (message.type === 'ping') { response = { type: 'pong' }; } else if (message.type === 'get_tools') { response = { type: 'tools', tools: TOOLS, }; } else if (message.type === 'execute_tool') { const { tool, params } = message; let result; if (tool === 'get_file') result = await getSketchFile(params.url, params.nodeId); else if (tool === 'list_components') result = await listSketchComponents(params.url); else if (tool === 'get_selection') result = await getSketchSelection(params.url, params.selectionIds); else if (tool === 'create_rectangle') result = await forwardToWebSocketClients('create_rectangle', params); else if (tool === 'create_text') result = await forwardToWebSocketClients('create_text', params); else throw new Error(`Unknown tool: ${tool}`); response = { type: 'tool_result', id: message.id, result, }; } else { throw new Error(`Unknown message type: ${message.type}`); } console.log('Sending response:', response); broadcast(response); } catch (error) { console.error('Error processing message:', error); const response = handleError(error); console.log('Sending error response:', response); broadcast(response); } }); } // Add more robust error handling function handleError(error) { return { type: 'error', error: { message: error.message, code: error.code || 'UNKNOWN_ERROR', details: error.details || {}, suggestion: getSuggestionForError(error) } }; } function getSuggestionForError(error) { // Implement logic to generate a suggestion based on the error return 'Please try again later or contact support for assistance.'; } // Add implementation for parseLocalSketchFile async function parseLocalSketchFile(url) { // If url is a local file path, use it directly // Otherwise, if we have a default local file configured, use that const filePath = url.startsWith('/') || url.includes(':\\') ? url : config.localFilePath; if (!filePath) { throw new Error('No local Sketch file specified. Use --local-file parameter or set LOCAL_SKETCH_PATH environment variable.'); } if (!fs.existsSync(filePath)) { throw new Error(`Local Sketch file not found: ${filePath}`); } // Read the file and parse it const buffer = fs.readFileSync(filePath); return parseSketchBuffer(buffer); } // Helper function to find a node with metadata function findNodeWithMetadata(obj, nodeId) { const node = findNodeById(obj, nodeId); if (!node) return null; return node; } // Helper function to enrich node data with context function enrichNodeData(node) { // Add additional context like parent information, styling, etc. return { node: node, type: node._class, metadata: { id: node.do_objectID, name: node.name, class: node._class } }; } // Function to get information about selected elements async function getSketchSelection(url, selectionIds) { try { // Get the full document data const documentData = await getSketchFile(url); // Find the selected nodes const selectedNodes = []; for (const id of selectionIds) { // Search for the node in the document const node = findNodeById(documentData, id); if (node) { // Enrich the node with additional context selectedNodes.push(enrichNodeData(node)); } } // Return the selection data return { url: url, selectionCount: selectedNodes.length, selectedNodes: selectedNodes, missingIds: selectionIds.filter(id => !selectedNodes.some(node => node.metadata.id === id)) }; } catch (error) { console.error('Error getting selection:', error); throw error; } } // Graceful shutdown handling process.on('SIGTERM', () => { logger.info('Received SIGTERM, shutting down gracefully'); shutdown(); }); process.on('SIGINT', () => { logger.info('Received SIGINT, shutting down gracefully'); shutdown(); }); process.on('uncaughtException', (error) => { logger.error('Uncaught exception', { error: error.message, stack: error.stack }); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled rejection', { reason, promise }); }); function shutdown() { logger.info('Starting graceful shutdown'); // Stop health monitoring healthMonitor.stop(); // Close all WebSocket connections clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.close(1000, 'Server shutting down'); } }); // Close HTTP server httpServer.close(() => { logger.info('HTTP server closed'); process.exit(0); }); // Force exit after 5 seconds setTimeout(() => { logger.error('Forced shutdown after timeout'); process.exit(1); }, 5000); } // Start the server try { httpServer.listen(config.port, () => { // Start health monitoring healthMonitor.start(); const serverInfo = { version: require('./package.json').version, port: config.port, logLevel: config.logLevel, maxFileSize: `${Math.round(config.maxFileSize / 1024 / 1024)}MB`, hasApiKey: !!config.sketchApiKey, hasLocalFile: !!config.localFilePath, endpoints: { info: `http://localhost:${config.port}`, health: `http://localhost:${config.port}/health`, validate: `http://localhost:${config.port}/validate`, sse: `http://localhost:${config.port}/sse`, messages: `http://localhost:${config.port}/messages`, websocket: `ws://localhost:${config.port}` } }; logger.info('Sketch Context MCP Server started successfully', serverInfo); // Display startup information console.log(` ┌─────────────────────────────────────────────────────────────────┐ │ Sketch Context MCP Server v${require('./package.json').version} │ ├─────────────────────────────────────────────────────────────────┤ │ Status: Running on port ${config.port} │ │ Log Level: ${config.logLevel} │ │ Max File Size: ${Math.round(config.maxFileSize / 1024 / 1024)}MB │ │ API Key: ${config.sketchApiKey ? '✓ Configured' : '✗ Not set'} │ │ Local File: ${config.localFilePath ? '✓ Configured' : '✗ Not set'} │ ├─────────────────────────────────────────────────────────────────┤ │ Quick Links: │ │ • Server Info: http://localhost:${config.port} │ │ • Health Check: http://localhost:${config.port}/health │ │ • Validation: http://localhost:${config.port}/validate │ ├─────────────────────────────────────────────────────────────────┤ │ Integration: │ │ • Cursor MCP: http://localhost:${config.port}/sse │ │ • Sketch Plugin: ws://localhost:${config.port} │ └─────────────────────────────────────────────────────────────────┘ Setup Instructions: 1. For Cursor IDE: Add MCP server with URL http://localhost:${config.port}/sse 2. For Sketch Plugin: Connect to port ${config.port} 3. Visit http://localhost:${config.port}/validate to check your configuration Press Ctrl+C to stop the server. `); }); httpServer.on('error', (error) => { if (error.code === 'EADDRINUSE') { logger.error(`Port ${config.port} is already in use. Please choose a different port.`); } else { logger.error('Server error', { error: error.message }); } process.exit(1); }); } catch (error) { logger.error('Failed to start server', { error: error.message }); process.exit(1); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jshmllr/Sketch-Context-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server