diff --git a/docs/configuration.md b/docs/configuration.md index 0f8a22a..241b0bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration Guide -The Figma MCP Write Server uses YAML configuration files for customizing server behavior, with support for command-line port overrides. +The Figma MCP Write Server uses YAML configuration files for customizing server behavior, with support for explicit command-line port overrides. ## Configuration File Location @@ -124,21 +124,28 @@ The server supports these command-line arguments: - **`--check-port `** - Check if a port is available and show what's using it - **`--help, -h`** - Show help message -### Port Management +### Port Requirements -The server includes automatic port management: -- Detects if the default port 8765 is in use -- Identifies and kills zombie processes when possible -- Falls back to alternative ports (8766, 8767, etc.) if needed -- Provides clear error messages for port conflicts +The plugin and MCP server must agree on one fixed WebSocket port. + +- The default port is `8765` +- If that port is busy, the server now fails fast instead of silently moving to `8766`, `8767`, etc. +- This avoids a split-brain state where Claude talks to one server instance while the Figma plugin remains connected to another +- If you intentionally change the port, rebuild and re-import the plugin so its WebSocket target and allowed domains match the server ## Troubleshooting ### Port Conflicts If port 8765 is in use: -1. Add `--port 9000` to the args array in your MCP configuration -2. Restart Claude Desktop -3. The server will use the specified port instead +1. Run `node dist/index.js --check-port 8765` to see which process is holding the port +2. Stop the conflicting process +3. Restart Claude Desktop so the MCP server can claim `8765` + +If you intentionally want to use a non-default port: +1. Change the server port in your config or MCP args +2. Rebuild the plugin assets so the UI points at the same port +3. Update the plugin manifest allowed domains for that port +4. Re-import the plugin in Figma ### Font Database Issues If font operations are slow: @@ -199,4 +206,4 @@ fontDatabase: - **Usage Examples**: See [examples.md](examples.md) for practical usage scenarios - **Development**: See [development.md](development.md) for architecture and contribution guidelines -- **Advanced Topics**: See [architecture.md](architecture.md) for technical implementation details \ No newline at end of file +- **Advanced Topics**: See [architecture.md](architecture.md) for technical implementation details diff --git a/figma-plugin/manifest.json b/figma-plugin/manifest.json index 91c7edb..1eae8d9 100644 --- a/figma-plugin/manifest.json +++ b/figma-plugin/manifest.json @@ -7,10 +7,22 @@ "capabilities": [], "enableProposedApi": true, "enablePrivatePluginApi": true, - "permissions": ["currentuser", "activeusers"], - "editorType": ["figma"], + "permissions": [ + "currentuser", + "activeusers" + ], + "editorType": [ + "figma", + "dev" + ], "networkAccess": { - "allowedDomains": ["*"], + "allowedDomains": [ + "*" + ], + "devAllowedDomains": [ + "http://localhost:8765", + "ws://localhost:8765" + ], "reasoning": "This plugin needs to connect to the local MCP server via WebSocket for write operations." } -} \ No newline at end of file +} diff --git a/figma-plugin/src/operations/manage-nodes.ts b/figma-plugin/src/operations/manage-nodes.ts index 4819bfe..69776d6 100644 --- a/figma-plugin/src/operations/manage-nodes.ts +++ b/figma-plugin/src/operations/manage-nodes.ts @@ -826,6 +826,7 @@ async function updateFrame(params: any): Promise { // Apply frame-specific properties await applyFrameProperties(node as FrameNode, params, i); + await applyCommonNodeProperties(node, params, i); results.push(formatNodeResponse(node)); } catch (error) { @@ -1076,4 +1077,4 @@ function handleNodePositioning( moveNodeToPosition(node, finalX, finalY); return { warning, positionReason }; -} \ No newline at end of file +} diff --git a/figma-plugin/ui.template.html b/figma-plugin/ui.template.html index 8b1e5f3..a8fe02a 100644 --- a/figma-plugin/ui.template.html +++ b/figma-plugin/ui.template.html @@ -221,6 +221,11 @@ let debugMode = false; let startTime = Date.now(); let uptimeTimer = null; + const connectionTargets = [ + "ws://localhost:{{PORT}}", + "ws://127.0.0.1:{{PORT}}" + ]; + let currentTargetIndex = 0; // Statistics let stats = { @@ -334,15 +339,17 @@ connectionAttempts++; // Silent connection attempt - updateConnectionStatus(false, "Connecting...", true); + const target = connectionTargets[currentTargetIndex] || connectionTargets[0]; + updateConnectionStatus(false, `Connecting to ${target.replace("ws://", "")}...`, true); try { - ws = new WebSocket("ws://localhost:{{PORT}}"); + ws = new WebSocket(target); ws.onopen = () => { // Silent connection success - updateConnectionStatus(true, "Connected to localhost:{{PORT}}"); + updateConnectionStatus(true, `Connected to ${target.replace("ws://", "")}`); connectionAttempts = 0; + currentTargetIndex = 0; clearReconnectTimer(); ws.send(JSON.stringify({ type: "PLUGIN_HELLO", version: "{{VERSION}}" })); @@ -381,7 +388,7 @@ ws.onerror = (error) => { console.error("WebSocket error:", error); log("WebSocket connection failed"); - updateConnectionStatus(false, "Connection error"); + updateConnectionStatus(false, `Connection error on ${target.replace("ws://", "")}`); }; } catch (error) { log(`Failed to connect: ${error.message}`); @@ -409,6 +416,7 @@ reconnectTimer = setTimeout(() => { log(`Reconnecting in ${delay}ms`); + currentTargetIndex = (currentTargetIndex + 1) % connectionTargets.length; reconnectTimer = null; connect(); }, delay); @@ -557,4 +565,4 @@ setTimeout(connect, 1000); - \ No newline at end of file + diff --git a/src/handlers/nodes-handler.ts b/src/handlers/nodes-handler.ts index 782b285..ade21fe 100644 --- a/src/handlers/nodes-handler.ts +++ b/src/handlers/nodes-handler.ts @@ -13,7 +13,7 @@ export class NodeHandler implements ToolHandler { return [ { name: 'figma_nodes', - description: 'Create, get, update, delete, and duplicate geometric shape nodes. Returns YAML with node properties. Supports specialized operations for each node type: rectangles, ellipses, frames, sections, slices, stars, and polygons.', + description: 'Create, get, update, delete, and duplicate geometric shape nodes. Returns YAML with node properties. Supports specialized operations for each node type: rectangles, ellipses, frames, sections, slices, stars, and polygons. For gradient/image paints use figma_fills or figma_strokes; for shadows and blurs use figma_effects.', inputSchema: { type: 'object', properties: { @@ -73,14 +73,14 @@ export class NodeHandler implements ToolHandler { { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' }, { type: 'array', items: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' } } ], - description: 'Fill color(s) in hex format - single string or array for bulk operations' + description: 'Solid fill color(s) in hex format - single string or array for bulk operations. For gradients or images, use figma_fills.' }, strokeColor: { oneOf: [ { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' }, { type: 'array', items: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' } } ], - description: 'Stroke color(s) in hex format - single string or array for bulk operations' + description: 'Solid stroke color(s) in hex format - single string or array for bulk operations. For gradients or images, use figma_strokes.' }, fillOpacity: { oneOf: [ @@ -314,7 +314,7 @@ export class NodeHandler implements ToolHandler { '{"operation": "create_star", "name": "My Star", "pointCount": 6, "innerRadius": 0.4, "fillColor": "#FF0000", "strokeColor": "#0000FF", "strokeWeight": 3}', '{"operation": "create_polygon", "name": "Octagon", "pointCount": 8, "fillColor": "#00FF00"}', '{"operation": "update_rectangle", "nodeId": "123:456", "cornerRadius": 15, "topLeftRadius": 25}', - '{"operation": "update_frame", "nodeId": "123:789", "clipsContent": true, "cornerRadius": 8}', + '{"operation": "update_frame", "nodeId": "123:789", "clipsContent": true, "cornerRadius": 8, "strokeColor": "#222222", "strokeWeight": 2, "strokeAlign": "INSIDE"}', '{"operation": "update_star", "nodeId": "123:999", "pointCount": 8, "innerRadius": 0.6}', '{"operation": "update_section", "nodeId": "123:111", "devStatus": "IN_DEVELOPMENT"}' ] @@ -398,7 +398,7 @@ export class NodeHandler implements ToolHandler { // Specialized update operations update_rectangle: ['nodeId', 'cornerRadius', 'topLeftRadius', 'topRightRadius', 'bottomLeftRadius', 'bottomRightRadius', 'cornerSmoothing'], update_ellipse: ['nodeId'], - update_frame: ['nodeId', 'clipsContent', 'cornerRadius', 'topLeftRadius', 'topRightRadius', 'bottomLeftRadius', 'bottomRightRadius', 'cornerSmoothing'], + update_frame: ['nodeId', 'rotation', 'visible', 'locked', 'opacity', 'blendMode', 'fillColor', 'fillOpacity', 'strokeColor', 'strokeOpacity', 'strokeWeight', 'strokeAlign', 'clipsContent', 'cornerRadius', 'topLeftRadius', 'topRightRadius', 'bottomLeftRadius', 'bottomRightRadius', 'cornerSmoothing'], update_section: ['nodeId', 'sectionContentsHidden', 'devStatus'], update_slice: ['nodeId'], update_star: ['nodeId', 'pointCount', 'innerRadius'], @@ -409,4 +409,4 @@ export class NodeHandler implements ToolHandler { return this.unifiedHandler.handle(args, config); } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 821d963..0123465 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,7 @@ async function checkPortStatus(port: number): Promise { logger.log(` PID ${pid}: (process info unavailable)`); } } - logger.log(`💡 To kill these processes: kill -9 ${pids.join(' ')}`); + logger.log(`💡 Stop these processes before restarting the MCP server, or keep the port fixed and rebuild the plugin if you intentionally changed it.`); } } catch (error) { logger.log(' (Unable to identify processes using this port)'); @@ -87,7 +87,7 @@ Options: Description: MCP server with built-in WebSocket server for Figma plugin communication. - Includes automatic port management with zombie process detection and cleanup. + Uses a fixed WebSocket port so the server stays aligned with the Figma plugin. Architecture: Claude Desktop ↔ MCP Server (WebSocket Server) ↔ Figma MCP Write Bridge Plugin (WebSocket Client) @@ -99,10 +99,10 @@ Setup: 4. Use MCP tools from Claude Desktop Port Management: - - Automatically detects if port 8765 is in use - - Identifies and kills zombie processes when possible - - Falls back to alternative ports (8766, 8767, etc.) if needed - - Provides clear error messages for port conflicts + - Uses port 8765 by default + - Fails fast if the configured port is already in use + - Use --check-port to inspect conflicts before restarting + - If you intentionally change ports, rebuild and re-import the plugin to match Available MCP Tools: 25 comprehensive tools for Figma automation including: diff --git a/src/resources/index.ts b/src/resources/index.ts index c04748d..8cdd4ea 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -2,6 +2,7 @@ import { Resource, ResourceContents } from '@modelcontextprotocol/sdk/types'; import { readdir, readFile, stat, access } from 'fs/promises'; import { join, dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; +import { logger } from '../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -16,10 +17,10 @@ export class ResourceRegistry { constructor(sendToPluginFn: (request: any) => Promise) { // Use resolve to ensure absolute path and handle Windows paths correctly this.resourcesPath = resolve(join(__dirname, '..', '..', 'mcp-resources')); - console.log(`[ResourceRegistry] Initialized with path: ${this.resourcesPath}`); - console.log(`[ResourceRegistry] __dirname: ${__dirname}`); - console.log(`[ResourceRegistry] Platform: ${process.platform}`); - console.log(`[ResourceRegistry] import.meta.url: ${import.meta.url}`); + logger.debug(`[ResourceRegistry] Initialized with path: ${this.resourcesPath}`); + logger.debug(`[ResourceRegistry] __dirname: ${__dirname}`); + logger.debug(`[ResourceRegistry] Platform: ${process.platform}`); + logger.debug(`[ResourceRegistry] import.meta.url: ${import.meta.url}`); } /** @@ -27,20 +28,20 @@ export class ResourceRegistry { */ async getResources(): Promise { try { - console.log(`[ResourceRegistry] Scanning directory: ${this.resourcesPath}`); + logger.debug(`[ResourceRegistry] Scanning directory: ${this.resourcesPath}`); // Check if directory exists and is accessible try { await access(this.resourcesPath); - console.log(`[ResourceRegistry] Directory is accessible`); + logger.debug(`[ResourceRegistry] Directory is accessible`); } catch (accessError) { - console.error(`[ResourceRegistry] Directory not accessible:`, accessError); + logger.error(`[ResourceRegistry] Directory not accessible:`, accessError); return []; } const resources: Resource[] = []; const items = await readdir(this.resourcesPath); - console.log(`[ResourceRegistry] Found ${items.length} items:`, items); + logger.debug(`[ResourceRegistry] Found ${items.length} items:`, items); for (const item of items) { if (item.endsWith('.md')) { @@ -52,15 +53,15 @@ export class ResourceRegistry { mimeType: 'text/markdown', description: `${title || item.replace('.md', '')} - Figma MCP usage guide` }; - console.log(`[ResourceRegistry] Adding resource:`, resource); + logger.debug(`[ResourceRegistry] Adding resource:`, resource); resources.push(resource); } } - console.log(`[ResourceRegistry] Returning ${resources.length} resources`); + logger.debug(`[ResourceRegistry] Returning ${resources.length} resources`); return resources; } catch (error) { - console.error(`[ResourceRegistry] Error scanning resources:`, error); + logger.error(`[ResourceRegistry] Error scanning resources:`, error); return []; } } @@ -69,24 +70,24 @@ export class ResourceRegistry { * Get content for a specific resource URI */ async getResourceContent(uri: string): Promise { - console.log(`[ResourceRegistry] Getting content for URI: ${uri}`); + logger.debug(`[ResourceRegistry] Getting content for URI: ${uri}`); if (!uri.startsWith('figma://')) { - console.error(`[ResourceRegistry] Invalid URI scheme: ${uri}`); + logger.error(`[ResourceRegistry] Invalid URI scheme: ${uri}`); throw new Error(`Resource not found: ${uri}`); } const filename = uri.replace('figma://', ''); const fullPath = resolve(join(this.resourcesPath, filename)); - console.log(`[ResourceRegistry] Resolved file path: ${fullPath}`); + logger.debug(`[ResourceRegistry] Resolved file path: ${fullPath}`); try { // Check if file exists and is accessible await access(fullPath); - console.log(`[ResourceRegistry] File is accessible: ${fullPath}`); + logger.debug(`[ResourceRegistry] File is accessible: ${fullPath}`); const content = await readFile(fullPath, 'utf-8'); - console.log(`[ResourceRegistry] Successfully read content (${content.length} chars)`); + logger.debug(`[ResourceRegistry] Successfully read content (${content.length} chars)`); const result = { uri: uri, @@ -98,10 +99,10 @@ export class ResourceRegistry { } ] }; - console.log(`[ResourceRegistry] Returning resource content for: ${uri}`); + logger.debug(`[ResourceRegistry] Returning resource content for: ${uri}`); return result; } catch (error) { - console.error(`[ResourceRegistry] Error reading file ${fullPath}:`, error); + logger.error(`[ResourceRegistry] Error reading file ${fullPath}:`, error); throw new Error(`Resource not found: ${uri}`); } } @@ -138,4 +139,4 @@ export class ResourceRegistry { return null; } } -} \ No newline at end of file +} diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts index f7ee3c5..e0889a7 100644 --- a/src/utils/port-utils.ts +++ b/src/utils/port-utils.ts @@ -1,10 +1,14 @@ import { createServer } from 'http'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { logger } from "../utils/logger.js" const execAsync = promisify(exec); +export interface PortProcessInfo { + pid: string; + command: string; +} + export async function checkPortAvailable(port: number): Promise { return new Promise((resolve) => { const testServer = createServer(); @@ -27,24 +31,25 @@ export async function findZombieProcesses(port: number): Promise { } } -export async function killZombieProcesses(pids: string[]): Promise { - for (const pid of pids) { +export async function getProcessesUsingPort(port: number): Promise { + const pids = await findZombieProcesses(port); + const uniquePids = Array.from(new Set(pids)); + const processes: PortProcessInfo[] = []; + + for (const pid of uniquePids) { try { - logger.log(`🧟 Killing zombie process ${pid}`); - await execAsync(`kill -9 ${pid}`); - // Wait a bit for process to actually die - await new Promise(resolve => setTimeout(resolve, 500)); + const { stdout } = await execAsync(`ps -p ${pid} -o command=`); + processes.push({ + pid, + command: stdout.trim() || '(command unavailable)' + }); } catch (error) { - logger.warn(`⚙️ Could not kill process ${pid}:`, error); + processes.push({ + pid, + command: '(command unavailable)' + }); } } -} -export async function findAvailablePort(startPort: number, maxTries: number = 10): Promise { - for (let port = startPort; port < startPort + maxTries; port++) { - if (await checkPortAvailable(port)) { - return port; - } - } - throw new Error(`No available ports found in range ${startPort}-${startPort + maxTries - 1}`); -} \ No newline at end of file + return processes; +} diff --git a/src/websocket/websocket-server.ts b/src/websocket/websocket-server.ts index edd141b..99e5bfc 100644 --- a/src/websocket/websocket-server.ts +++ b/src/websocket/websocket-server.ts @@ -2,7 +2,7 @@ import { WebSocketServer } from 'ws'; import WebSocket from 'ws'; import { v4 as uuidv4 } from 'uuid'; import { LegacyServerConfig, QueuedRequest, RequestBatch, RequestPriority, ConnectionStatus, HealthMetrics, validateAndParse, TypedPluginMessage, TypedPluginResponse } from '../types/index.js'; -import { checkPortAvailable, findZombieProcesses, killZombieProcesses, findAvailablePort } from '../utils/port-utils.js'; +import { checkPortAvailable, getProcessesUsingPort } from '../utils/port-utils.js'; import { EventEmitter } from 'events'; import { logger } from "../utils/logger.js" @@ -53,38 +53,23 @@ export class FigmaWebSocketServer extends EventEmitter { } async start(): Promise { - let port = this.config.port; - let zombieProcesses: string[] = []; + const port = this.config.port; - // Check if default port is available + // The plugin is built with a fixed WebSocket target, so the server must not + // silently drift to another port or it will desynchronize from Figma. const isPortAvailable = await checkPortAvailable(port); if (!isPortAvailable) { - // Look for zombie processes - zombieProcesses = await findZombieProcesses(port); - - if (zombieProcesses.length > 0) { - await killZombieProcesses(zombieProcesses); - - // Check if port is now available - if (!(await checkPortAvailable(port))) { - // Find alternative port - try { - port = await findAvailablePort(port + 1); - this.config.port = port; // Update config - } catch (error) { - throw new Error(`Cannot start WebSocket server: ${error}`); - } - } - } else { - // Port in use but no zombie processes found - find alternative - try { - port = await findAvailablePort(port + 1); - this.config.port = port; // Update config - } catch (error) { - throw new Error(`Cannot start WebSocket server: ${error}`); - } - } + const processes = await getProcessesUsingPort(port); + const processDetails = processes.length > 0 + ? processes.map(({ pid, command }) => `PID ${pid}: ${command}`).join('\n') + : 'Unable to identify the process using this port.'; + + throw new Error( + `WebSocket server cannot start because port ${port} is already in use. ` + + `The Figma plugin connects to a fixed port and will not auto-discover a fallback port. ` + + `Stop the process using port ${port}, or explicitly reconfigure and rebuild the plugin to use a different port.\n${processDetails}` + ); } // Create WebSocket server @@ -614,4 +599,4 @@ export class FigmaWebSocketServer extends EventEmitter { // Reset startup time to give new grace period this.startupTime = Date.now(); } -} \ No newline at end of file +} diff --git a/tests/unit/handlers/handler-registry.test.ts b/tests/unit/handlers/handler-registry.test.ts new file mode 100644 index 0000000..b172f47 --- /dev/null +++ b/tests/unit/handlers/handler-registry.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { HandlerRegistry } from '@/handlers/index'; + +describe('HandlerRegistry', () => { + let registry: HandlerRegistry; + let mockSendToPlugin: ReturnType; + + beforeEach(async () => { + mockSendToPlugin = vi.fn(); + registry = new HandlerRegistry(mockSendToPlugin, undefined, () => null); + await registry.waitForHandlerRegistration(); + }); + + test('registers the expected core and system tools', () => { + const toolNames = registry.getTools().map(tool => tool.name); + + expect(toolNames).toEqual(expect.arrayContaining([ + 'figma_nodes', + 'figma_fills', + 'figma_strokes', + 'figma_effects', + 'figma_components', + 'figma_instances', + 'figma_pages', + 'figma_plugin_status' + ])); + }); + + test('does not register duplicate tool names', () => { + const toolNames = registry.getTools().map(tool => tool.name); + const uniqueToolNames = new Set(toolNames); + + expect(uniqueToolNames.size).toBe(toolNames.length); + }); +}); diff --git a/tests/unit/handlers/nodes-handler.test.ts b/tests/unit/handlers/nodes-handler.test.ts index 14501c3..16ee439 100644 --- a/tests/unit/handlers/nodes-handler.test.ts +++ b/tests/unit/handlers/nodes-handler.test.ts @@ -104,6 +104,39 @@ describe('NodeHandlers - Updated Architecture', () => { expect(result.isError).toBe(false); }); + test('should allow frame stroke updates through update_frame', async () => { + const mockResponse = { + success: true, + data: { id: 'frame-123', strokeColor: '#222222', strokeWeight: 2 } + }; + mockSendToPlugin.mockResolvedValue(mockResponse); + + const result = await nodeHandler.handle('figma_nodes', { + operation: 'update_frame', + nodeId: 'frame-123', + strokeColor: '#222222', + strokeWeight: 2, + strokeAlign: 'INSIDE', + strokeOpacity: 0.8, + clipsContent: true + }); + + expect(mockSendToPlugin).toHaveBeenCalledWith({ + type: 'MANAGE_NODES', + payload: expect.objectContaining({ + operation: 'update_frame', + nodeId: 'frame-123', + strokeColor: '#222222', + strokeWeight: 2, + strokeAlign: 'INSIDE', + strokeOpacity: 0.8, + clipsContent: true + }) + }); + + expect(result.isError).toBe(false); + }); + test('should handle single node deletion', async () => { const mockResponse = { success: true, data: { deleted: true } }; mockSendToPlugin.mockResolvedValue(mockResponse); @@ -748,4 +781,4 @@ describe('NodeHandlers - Updated Architecture', () => { }); }); }); -}); \ No newline at end of file +});