This is page 2 of 3. Use http://codebase.md/sichang824/mcp-figma?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .envrc ├── .gitignore ├── .mcp.pid ├── bun.lockb ├── docs │ ├── 01-overview.md │ ├── 02-implementation-steps.md │ ├── 03-components-and-features.md │ ├── 04-usage-guide.md │ ├── 05-project-status.md │ ├── image.png │ ├── README.md │ └── widget-tools-guide.md ├── Makefile ├── manifest.json ├── package.json ├── prompt.md ├── README.md ├── README.zh.md ├── src │ ├── config │ │ └── env.ts │ ├── index.ts │ ├── plugin │ │ ├── code.js │ │ ├── code.ts │ │ ├── creators │ │ │ ├── componentCreators.ts │ │ │ ├── containerCreators.ts │ │ │ ├── elementCreator.ts │ │ │ ├── imageCreators.ts │ │ │ ├── shapeCreators.ts │ │ │ ├── sliceCreators.ts │ │ │ ├── specialCreators.ts │ │ │ └── textCreator.ts │ │ ├── manifest.json │ │ ├── README.md │ │ ├── tsconfig.json │ │ ├── ui.html │ │ └── utils │ │ ├── colorUtils.ts │ │ └── nodeUtils.ts │ ├── resources.ts │ ├── services │ │ ├── figma-api.ts │ │ ├── websocket.ts │ │ └── widget-api.ts │ ├── tools │ │ ├── canvas.ts │ │ ├── comment.ts │ │ ├── component.ts │ │ ├── file.ts │ │ ├── frame.ts │ │ ├── image.ts │ │ ├── index.ts │ │ ├── node.ts │ │ ├── page.ts │ │ ├── search.ts │ │ ├── utils │ │ │ └── widget-utils.ts │ │ ├── version.ts │ │ ├── widget │ │ │ ├── analyze-widget-structure.ts │ │ │ ├── get-widget-sync-data.ts │ │ │ ├── get-widget.ts │ │ │ ├── get-widgets.ts │ │ │ ├── index.ts │ │ │ ├── README.md │ │ │ ├── search-widgets.ts │ │ │ └── widget-tools.ts │ │ └── zod-schemas.ts │ ├── utils │ │ ├── figma-utils.ts │ │ └── widget-utils.ts │ ├── utils.ts │ ├── widget │ │ └── utils │ │ └── widget-tools.ts │ └── widget-tools.ts ├── tsconfig.json └── tsconfig.widget.json ``` # Files -------------------------------------------------------------------------------- /src/services/figma-api.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from 'axios'; 2 | import { env } from '../config/env.js'; 3 | import type { 4 | GetFileResponse, 5 | GetFileNodesResponse, 6 | GetImageFillsResponse, 7 | GetImagesResponse, 8 | GetCommentsResponse, 9 | GetFileVersionsResponse, 10 | GetTeamProjectsResponse, 11 | GetProjectFilesResponse, 12 | GetTeamComponentsResponse, 13 | GetFileComponentsResponse, 14 | GetComponentResponse, 15 | GetTeamComponentSetsResponse, 16 | GetFileComponentSetsResponse, 17 | GetComponentSetResponse, 18 | GetTeamStylesResponse, 19 | GetFileStylesResponse, 20 | GetStyleResponse, 21 | PostCommentRequestBody, 22 | PostCommentResponse, 23 | } from "@figma/rest-api-spec"; 24 | 25 | const FIGMA_API_BASE_URL = 'https://api.figma.com/v1'; 26 | 27 | /** 28 | * Type definition for CreateFrameOptions 29 | */ 30 | export interface CreateFrameOptions { 31 | name: string; 32 | width: number; 33 | height: number; 34 | x?: number; 35 | y?: number; 36 | fills?: Array<{ 37 | type: string; 38 | color: { r: number; g: number; b: number }; 39 | opacity: number; 40 | }>; 41 | pageId?: string; 42 | } 43 | 44 | /** 45 | * Type definition for the expected response when creating a frame 46 | */ 47 | export interface CreateFrameResponse { 48 | frame: { 49 | id: string; 50 | name: string; 51 | }; 52 | success: boolean; 53 | } 54 | 55 | /** 56 | * Service for interacting with the Figma API 57 | */ 58 | export class FigmaApiService { 59 | private readonly headers: Record<string, string>; 60 | 61 | constructor(accessToken: string = env.FIGMA_PERSONAL_ACCESS_TOKEN) { 62 | this.headers = { 63 | 'X-Figma-Token': accessToken, 64 | }; 65 | } 66 | 67 | /** 68 | * Get file by key 69 | */ 70 | async getFile(fileKey: string, params: { ids?: string; depth?: number; geometry?: string; plugin_data?: string; branch_data?: boolean } = {}): Promise<GetFileResponse> { 71 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}`, { 72 | headers: this.headers, 73 | params, 74 | }); 75 | return response.data; 76 | } 77 | 78 | /** 79 | * Get file nodes by key and node IDs 80 | */ 81 | async getFileNodes(fileKey: string, nodeIds: string[], params: { depth?: number; geometry?: string; plugin_data?: string } = {}): Promise<GetFileNodesResponse> { 82 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/nodes`, { 83 | headers: this.headers, 84 | params: { 85 | ...params, 86 | ids: nodeIds.join(','), 87 | }, 88 | }); 89 | return response.data; 90 | } 91 | 92 | /** 93 | * Get images for file nodes 94 | */ 95 | async getImages(fileKey: string, nodeIds: string[], params: { 96 | scale?: number; 97 | format?: string; 98 | svg_include_id?: boolean; 99 | svg_include_node_id?: boolean; 100 | svg_simplify_stroke?: boolean; 101 | use_absolute_bounds?: boolean; 102 | version?: string; 103 | } = {}): Promise<GetImagesResponse> { 104 | const response = await axios.get(`${FIGMA_API_BASE_URL}/images/${fileKey}`, { 105 | headers: this.headers, 106 | params: { 107 | ...params, 108 | ids: nodeIds.join(','), 109 | }, 110 | }); 111 | return response.data; 112 | } 113 | 114 | /** 115 | * Get image fills for a file 116 | */ 117 | async getImageFills(fileKey: string): Promise<GetImageFillsResponse> { 118 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/images`, { 119 | headers: this.headers, 120 | }); 121 | return response.data; 122 | } 123 | 124 | /** 125 | * Get comments for a file 126 | */ 127 | async getComments(fileKey: string, params: { as_md?: boolean } = {}): Promise<GetCommentsResponse> { 128 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/comments`, { 129 | headers: this.headers, 130 | params, 131 | }); 132 | return response.data; 133 | } 134 | 135 | /** 136 | * Post a comment to a file 137 | */ 138 | async postComment(fileKey: string, data: PostCommentRequestBody): Promise<PostCommentResponse> { 139 | const response = await axios.post( 140 | `${FIGMA_API_BASE_URL}/files/${fileKey}/comments`, 141 | data, 142 | { headers: this.headers } 143 | ); 144 | return response.data; 145 | } 146 | 147 | /** 148 | * Create a new frame in a Figma file 149 | * Note: This uses the Figma Plugin API which requires appropriate permissions 150 | */ 151 | async createFrame(fileKey: string, options: CreateFrameOptions): Promise<CreateFrameResponse> { 152 | // Build the frame creation request payload 153 | const payload = { 154 | node: { 155 | type: "FRAME", 156 | name: options.name, 157 | size: { 158 | width: options.width, 159 | height: options.height, 160 | }, 161 | position: { 162 | x: options.x || 0, 163 | y: options.y || 0, 164 | }, 165 | fills: options.fills || [], 166 | }, 167 | pageId: options.pageId, 168 | }; 169 | 170 | const response = await axios.post( 171 | `${FIGMA_API_BASE_URL}/files/${fileKey}/nodes`, 172 | payload, 173 | { headers: this.headers } 174 | ); 175 | 176 | return { 177 | frame: { 178 | id: response.data.node.id, 179 | name: response.data.node.name, 180 | }, 181 | success: true 182 | }; 183 | } 184 | 185 | /** 186 | * Get file versions 187 | */ 188 | async getFileVersions(fileKey: string): Promise<GetFileVersionsResponse> { 189 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/versions`, { 190 | headers: this.headers, 191 | }); 192 | return response.data; 193 | } 194 | 195 | /** 196 | * Get team projects 197 | */ 198 | async getTeamProjects(teamId: string): Promise<GetTeamProjectsResponse> { 199 | const response = await axios.get(`${FIGMA_API_BASE_URL}/teams/${teamId}/projects`, { 200 | headers: this.headers, 201 | }); 202 | return response.data; 203 | } 204 | 205 | /** 206 | * Get project files 207 | */ 208 | async getProjectFiles(projectId: string, params: { branch_data?: boolean } = {}): Promise<GetProjectFilesResponse> { 209 | const response = await axios.get(`${FIGMA_API_BASE_URL}/projects/${projectId}/files`, { 210 | headers: this.headers, 211 | params, 212 | }); 213 | return response.data; 214 | } 215 | 216 | /** 217 | * Get team components 218 | */ 219 | async getTeamComponents(teamId: string, params: { page_size?: number; after?: number; before?: number } = {}): Promise<GetTeamComponentsResponse> { 220 | const response = await axios.get(`${FIGMA_API_BASE_URL}/teams/${teamId}/components`, { 221 | headers: this.headers, 222 | params, 223 | }); 224 | return response.data; 225 | } 226 | 227 | /** 228 | * Get file components 229 | */ 230 | async getFileComponents(fileKey: string): Promise<GetFileComponentsResponse> { 231 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/components`, { 232 | headers: this.headers, 233 | }); 234 | return response.data; 235 | } 236 | 237 | /** 238 | * Get component by key 239 | */ 240 | async getComponent(key: string): Promise<GetComponentResponse> { 241 | const response = await axios.get(`${FIGMA_API_BASE_URL}/components/${key}`, { 242 | headers: this.headers, 243 | }); 244 | return response.data; 245 | } 246 | 247 | /** 248 | * Get team component sets 249 | */ 250 | async getTeamComponentSets(teamId: string, params: { page_size?: number; after?: number; before?: number } = {}): Promise<GetTeamComponentSetsResponse> { 251 | const response = await axios.get(`${FIGMA_API_BASE_URL}/teams/${teamId}/component_sets`, { 252 | headers: this.headers, 253 | params, 254 | }); 255 | return response.data; 256 | } 257 | 258 | /** 259 | * Get file component sets 260 | */ 261 | async getFileComponentSets(fileKey: string): Promise<GetFileComponentSetsResponse> { 262 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/component_sets`, { 263 | headers: this.headers, 264 | }); 265 | return response.data; 266 | } 267 | 268 | /** 269 | * Get component set by key 270 | */ 271 | async getComponentSet(key: string): Promise<GetComponentSetResponse> { 272 | const response = await axios.get(`${FIGMA_API_BASE_URL}/component_sets/${key}`, { 273 | headers: this.headers, 274 | }); 275 | return response.data; 276 | } 277 | 278 | /** 279 | * Get team styles 280 | */ 281 | async getTeamStyles(teamId: string, params: { page_size?: number; after?: number; before?: number } = {}): Promise<GetTeamStylesResponse> { 282 | const response = await axios.get(`${FIGMA_API_BASE_URL}/teams/${teamId}/styles`, { 283 | headers: this.headers, 284 | params, 285 | }); 286 | return response.data; 287 | } 288 | 289 | /** 290 | * Get file styles 291 | */ 292 | async getFileStyles(fileKey: string): Promise<GetFileStylesResponse> { 293 | const response = await axios.get(`${FIGMA_API_BASE_URL}/files/${fileKey}/styles`, { 294 | headers: this.headers, 295 | }); 296 | return response.data; 297 | } 298 | 299 | /** 300 | * Get style by key 301 | */ 302 | async getStyle(key: string): Promise<GetStyleResponse> { 303 | const response = await axios.get(`${FIGMA_API_BASE_URL}/styles/${key}`, { 304 | headers: this.headers, 305 | }); 306 | return response.data; 307 | } 308 | } 309 | 310 | export default new FigmaApiService(); 311 | ``` -------------------------------------------------------------------------------- /src/plugin/creators/specialCreators.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Special element creation functions for Figma plugin 3 | * Handles more specialized node types like boolean operations, connectors, etc. 4 | */ 5 | 6 | import { createSolidPaint } from '../utils/colorUtils'; 7 | import { applyCommonProperties } from '../utils/nodeUtils'; 8 | 9 | /** 10 | * Create a boolean operation from data 11 | * @param data Boolean operation configuration data 12 | * @returns Created boolean operation node 13 | */ 14 | export function createBooleanOperationFromData(data: any): BooleanOperationNode | null { 15 | // Boolean operations require child nodes 16 | if (!data.children || !Array.isArray(data.children) || data.children.length < 2) { 17 | console.error('Boolean operation requires at least 2 child nodes'); 18 | return null; 19 | } 20 | 21 | // First we need to create the child nodes and ensure they're on the page 22 | let childNodes: SceneNode[] = []; 23 | try { 24 | for (const childData of data.children) { 25 | const node = figma.createRectangle(); // Placeholder, would need actual createElement logic here 26 | // In actual use, you'll need to create the proper node type and apply properties 27 | childNodes.push(node); 28 | } 29 | 30 | // Now create the boolean operation with the child nodes 31 | const booleanOperation = figma.createBooleanOperation(); 32 | 33 | // Set the operation type 34 | if (data.booleanOperation) { 35 | booleanOperation.booleanOperation = data.booleanOperation; 36 | } 37 | 38 | // Apply common properties 39 | applyCommonProperties(booleanOperation, data); 40 | 41 | return booleanOperation; 42 | } catch (error) { 43 | console.error('Failed to create boolean operation:', error); 44 | // Clean up any created nodes to avoid leaving orphans 45 | childNodes.forEach(node => node.remove()); 46 | return null; 47 | } 48 | } 49 | 50 | /** 51 | * Create a connector node from data 52 | * @param data Connector configuration data 53 | * @returns Created connector node 54 | */ 55 | export function createConnectorFromData(data: any): ConnectorNode { 56 | const connector = figma.createConnector(); 57 | 58 | // Set connector specific properties 59 | if (data.connectorStart) connector.connectorStart = data.connectorStart; 60 | if (data.connectorEnd) connector.connectorEnd = data.connectorEnd; 61 | if (data.connectorStartStrokeCap) connector.connectorStartStrokeCap = data.connectorStartStrokeCap; 62 | if (data.connectorEndStrokeCap) connector.connectorEndStrokeCap = data.connectorEndStrokeCap; 63 | if (data.connectorLineType) connector.connectorLineType = data.connectorLineType; 64 | 65 | // Set stroke properties 66 | if (data.strokes) connector.strokes = data.strokes; 67 | if (data.strokeWeight) connector.strokeWeight = data.strokeWeight; 68 | 69 | // Apply common properties 70 | applyCommonProperties(connector, data); 71 | 72 | return connector; 73 | } 74 | 75 | /** 76 | * Create a shape with text node (used in FigJam) 77 | * This function might not work in all Figma versions 78 | * 79 | * @param data Shape with text configuration data 80 | * @returns Created shape with text node 81 | */ 82 | export function createShapeWithTextFromData(data: any): ShapeWithTextNode | null { 83 | // Check if this node type is supported 84 | if (!('createShapeWithText' in figma)) { 85 | console.error('ShapeWithText creation is not supported in this Figma version'); 86 | return null; 87 | } 88 | 89 | try { 90 | const shapeWithText = figma.createShapeWithText(); 91 | 92 | // Set shape specific properties 93 | if (data.shapeType) shapeWithText.shapeType = data.shapeType; 94 | 95 | // Text content 96 | if (data.text || data.characters) { 97 | shapeWithText.text.characters = data.text || data.characters; 98 | } 99 | 100 | // Text styling - these properties may not be directly accessible on all versions 101 | try { 102 | if (data.fontSize) shapeWithText.text.fontSize = data.fontSize; 103 | if (data.fontName) shapeWithText.text.fontName = data.fontName; 104 | // These properties may not exist directly on TextSublayerNode depending on Figma version 105 | if (data.textAlignHorizontal && 'textAlignHorizontal' in shapeWithText.text) { 106 | (shapeWithText.text as any).textAlignHorizontal = data.textAlignHorizontal; 107 | } 108 | if (data.textAlignVertical && 'textAlignVertical' in shapeWithText.text) { 109 | (shapeWithText.text as any).textAlignVertical = data.textAlignVertical; 110 | } 111 | } catch (e) { 112 | console.warn('Some text properties could not be set on ShapeWithText:', e); 113 | } 114 | 115 | // Fill and stroke 116 | if (data.fills) shapeWithText.fills = data.fills; 117 | if (data.strokes) shapeWithText.strokes = data.strokes; 118 | 119 | // Apply common properties 120 | applyCommonProperties(shapeWithText, data); 121 | 122 | return shapeWithText; 123 | } catch (error) { 124 | console.error('Failed to create shape with text:', error); 125 | return null; 126 | } 127 | } 128 | 129 | /** 130 | * Create a code block node 131 | * @param data Code block configuration data 132 | * @returns Created code block node 133 | */ 134 | export function createCodeBlockFromData(data: any): CodeBlockNode { 135 | const codeBlock = figma.createCodeBlock(); 136 | 137 | // Code content 138 | if (data.code) codeBlock.code = data.code; 139 | if (data.codeLanguage) codeBlock.codeLanguage = data.codeLanguage; 140 | 141 | // Apply common properties 142 | applyCommonProperties(codeBlock, data); 143 | 144 | return codeBlock; 145 | } 146 | 147 | /** 148 | * Create a table node 149 | * @param data Table configuration data 150 | * @returns Created table node 151 | */ 152 | export function createTableFromData(data: any): TableNode { 153 | // Create table with specified rows and columns (defaults to 2x2) 154 | const table = figma.createTable( 155 | data.numRows || 2, 156 | data.numColumns || 2 157 | ); 158 | 159 | // Applying table styling 160 | // Note: Some properties may not be directly available depending on Figma version 161 | if (data.fills && 'fills' in table) { 162 | (table as any).fills = data.fills; 163 | } 164 | 165 | // Process cell data if provided 166 | if (data.cells && Array.isArray(data.cells)) { 167 | for (const cellData of data.cells) { 168 | if (cellData.rowIndex !== undefined && cellData.columnIndex !== undefined) { 169 | try { 170 | // Different Figma versions may have different API for accessing cells 171 | let cell; 172 | if ('cellAt' in table) { 173 | cell = table.cellAt(cellData.rowIndex, cellData.columnIndex); 174 | } else if ('getCellAt' in table) { 175 | cell = (table as any).getCellAt(cellData.rowIndex, cellData.columnIndex); 176 | } 177 | 178 | if (cell) { 179 | // Apply cell properties 180 | if (cellData.text && cell.text) cell.text.characters = cellData.text; 181 | if (cellData.fills && 'fills' in cell) cell.fills = cellData.fills; 182 | if (cellData.rowSpan && 'rowSpan' in cell) cell.rowSpan = cellData.rowSpan; 183 | if (cellData.columnSpan && 'columnSpan' in cell) cell.columnSpan = cellData.columnSpan; 184 | } 185 | } catch (e) { 186 | console.warn(`Could not set properties for cell at ${cellData.rowIndex}, ${cellData.columnIndex}:`, e); 187 | } 188 | } 189 | } 190 | } 191 | 192 | // Apply common properties 193 | applyCommonProperties(table, data); 194 | 195 | return table; 196 | } 197 | 198 | /** 199 | * Create a widget node (if supported in current Figma version) 200 | * @param data Widget configuration data 201 | * @returns Created widget node or null 202 | */ 203 | export function createWidgetFromData(data: any): WidgetNode | null { 204 | // Check if widget creation is supported 205 | if (!('createWidget' in figma)) { 206 | console.error('Widget creation is not supported in this Figma version'); 207 | return null; 208 | } 209 | 210 | // Widgets require a package ID 211 | if (!data.widgetId) { 212 | console.error('Widget creation requires a widgetId'); 213 | return null; 214 | } 215 | 216 | try { 217 | // Using type assertion since createWidget may not be recognized by TypeScript 218 | const widget = (figma as any).createWidget(data.widgetId); 219 | 220 | // Set widget properties 221 | if (data.widgetData) widget.widgetData = JSON.stringify(data.widgetData); 222 | if (data.width && data.height && 'resize' in widget) widget.resize(data.width, data.height); 223 | 224 | // Apply common properties 225 | applyCommonProperties(widget, data); 226 | 227 | return widget; 228 | } catch (error) { 229 | console.error('Failed to create widget:', error); 230 | return null; 231 | } 232 | } 233 | 234 | /** 235 | * Create a media node (if supported in current Figma version) 236 | * @param data Media configuration data 237 | * @returns Created media node or null 238 | */ 239 | export function createMediaFromData(data: any): MediaNode | null { 240 | // Check if media creation is supported 241 | if (!('createMedia' in figma)) { 242 | console.error('Media creation is not supported in this Figma version'); 243 | return null; 244 | } 245 | 246 | // Media requires a hash 247 | if (!data.hash) { 248 | console.error('Media creation requires a valid media hash'); 249 | return null; 250 | } 251 | 252 | try { 253 | // Using type assertion since createMedia may not be recognized by TypeScript 254 | const media = (figma as any).createMedia(data.hash); 255 | 256 | // Apply common properties 257 | applyCommonProperties(media, data); 258 | 259 | return media; 260 | } catch (error) { 261 | console.error('Failed to create media:', error); 262 | return null; 263 | } 264 | } ``` -------------------------------------------------------------------------------- /src/plugin/code.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Figma MCP Plugin 3 | * Allows manipulating elements on canvas through MCP tools 4 | */ 5 | 6 | // Import modules 7 | import { 8 | createElementFromData, 9 | createElementsFromDataArray, 10 | } from "./creators/elementCreator"; 11 | import { 12 | createEllipseFromData, 13 | createLineFromData, 14 | createPolygonFromData, 15 | createRectangleFromData, 16 | createStarFromData, 17 | createVectorFromData 18 | } from "./creators/shapeCreators"; 19 | import { createTextFromData } from "./creators/textCreator"; 20 | import { hexToRgb } from "./utils/colorUtils"; 21 | import { buildResultObject, selectAndFocusNodes } from "./utils/nodeUtils"; 22 | 23 | // Show plugin UI 24 | figma.showUI(__html__, { width: 320, height: 500 }); 25 | 26 | // Log that the plugin has loaded 27 | console.log("Figma MCP Plugin loaded"); 28 | 29 | // Element creator mapping 30 | type ElementCreator = (params: any) => SceneNode | Promise<SceneNode>; 31 | 32 | const elementCreators: Record<string, ElementCreator> = { 33 | "create-rectangle": createRectangleFromData, 34 | "create-circle": createEllipseFromData, 35 | "create-ellipse": createEllipseFromData, 36 | "create-polygon": createPolygonFromData, 37 | "create-line": createLineFromData, 38 | "create-text": createTextFromData, 39 | "create-star": createStarFromData, 40 | "create-vector": createVectorFromData, 41 | "create-arc": (params: any) => { 42 | const ellipse = createEllipseFromData(params); 43 | if (params.arcData || (params.startAngle !== undefined && params.endAngle !== undefined)) { 44 | ellipse.arcData = { 45 | startingAngle: params.startAngle || params.arcData.startingAngle || 0, 46 | endingAngle: params.endAngle || params.arcData.endingAngle || 360, 47 | innerRadius: params.innerRadius || params.arcData.innerRadius || 0 48 | }; 49 | } 50 | return ellipse; 51 | } 52 | }; 53 | 54 | // Generic element creation function 55 | async function createElement(type: string, params: any): Promise<SceneNode | null> { 56 | console.log(`Creating ${type} with params:`, params); 57 | 58 | // Get the creator function 59 | const creator = elementCreators[type]; 60 | if (!creator) { 61 | console.error(`Unknown element type: ${type}`); 62 | return null; 63 | } 64 | 65 | try { 66 | // Create the element (handle both synchronous and asynchronous creators) 67 | const element = await Promise.resolve(creator(params)); 68 | 69 | // Set position if provided 70 | if (element && params) { 71 | if (params.x !== undefined) element.x = params.x; 72 | if (params.y !== undefined) element.y = params.y; 73 | } 74 | 75 | // Select and focus the element 76 | if (element) { 77 | selectAndFocusNodes(element); 78 | } 79 | 80 | return element; 81 | } catch (error) { 82 | console.error(`Error creating ${type}:`, error); 83 | return null; 84 | } 85 | } 86 | 87 | // Handle messages from UI 88 | figma.ui.onmessage = async function (msg) { 89 | console.log("Received message from UI:", msg); 90 | 91 | // Handle different types of messages 92 | if (elementCreators[msg.type]) { 93 | // Element creation messages 94 | await createElement(msg.type, msg); 95 | } else if (msg.type === "create-element") { 96 | // Unified create element method 97 | console.log("Creating element with data:", msg.data); 98 | createElementFromData(msg.data); 99 | } else if (msg.type === "create-elements") { 100 | // Create multiple elements at once 101 | console.log("Creating multiple elements with data:", msg.data); 102 | createElementsFromDataArray(msg.data); 103 | } else if (msg.type === "mcp-command") { 104 | // Handle commands from MCP tool via UI 105 | console.log( 106 | "Received MCP command:", 107 | msg.command, 108 | "with params:", 109 | msg.params 110 | ); 111 | handleMcpCommand(msg.command, msg.params); 112 | } else if (msg.type === "cancel") { 113 | console.log("Closing plugin"); 114 | figma.closePlugin(); 115 | } else { 116 | console.log("Unknown message type:", msg.type); 117 | } 118 | }; 119 | 120 | // Handle MCP commands 121 | async function handleMcpCommand(command: string, params: any) { 122 | let result: 123 | | SceneNode 124 | | PageNode 125 | | readonly SceneNode[] 126 | | readonly PageNode[] 127 | | null = null; 128 | 129 | try { 130 | // Convert command format from mcp (create_rectangle) to plugin (create-rectangle) 131 | const pluginCommand = command.replace(/_/g, '-'); 132 | 133 | switch (pluginCommand) { 134 | case "create-rectangle": 135 | case "create-circle": 136 | case "create-polygon": 137 | case "create-line": 138 | case "create-arc": 139 | case "create-vector": 140 | console.log(`MCP command: Creating ${pluginCommand.substring(7)} with params:`, params); 141 | result = await createElement(pluginCommand, params); 142 | break; 143 | 144 | case "create-text": 145 | console.log("MCP command: Creating text with params:", params); 146 | result = await createElement(pluginCommand, params); 147 | break; 148 | 149 | case "create-element": 150 | console.log("MCP command: Creating element with params:", params); 151 | result = await createElementFromData(params); 152 | break; 153 | 154 | case "create-elements": 155 | console.log( 156 | "MCP command: Creating multiple elements with params:", 157 | params 158 | ); 159 | result = await createElementsFromDataArray(params); 160 | break; 161 | 162 | case "get-selection": 163 | console.log("MCP command: Getting current selection"); 164 | result = figma.currentPage.selection; 165 | break; 166 | 167 | case "get-elements": 168 | console.log("MCP command: Getting elements with params:", params); 169 | const page = params.page_id 170 | ? (figma.getNodeById(params.page_id) as PageNode) 171 | : figma.currentPage; 172 | 173 | if (!page || page.type !== "PAGE") { 174 | throw new Error("Invalid page ID or node is not a page"); 175 | } 176 | 177 | const nodeType = params.type || "ALL"; 178 | const limit = params.limit || 100; 179 | const includeHidden = params.include_hidden || false; 180 | 181 | if (nodeType === "ALL") { 182 | // Get all nodes, filtered by visibility if needed 183 | result = includeHidden 184 | ? page.children.slice(0, limit) 185 | : page.children.filter(node => node.visible).slice(0, limit); 186 | } else { 187 | // Filter by node type and visibility 188 | result = page.findAll(node => { 189 | const typeMatch = node.type === nodeType; 190 | const visibilityMatch = includeHidden || node.visible; 191 | return typeMatch && visibilityMatch; 192 | }).slice(0, limit); 193 | } 194 | break; 195 | 196 | case "get-element": 197 | console.log("MCP command: Getting element with ID:", params.node_id); 198 | const node = figma.getNodeById(params.node_id); 199 | 200 | if (!node) { 201 | throw new Error("Element not found with ID: " + params.node_id); 202 | } 203 | 204 | // Check if the node is a valid type for our result 205 | if (!['DOCUMENT', 'PAGE'].includes(node.type)) { 206 | // For scene nodes with children, include children if requested 207 | if (params.include_children && 'children' in node) { 208 | result = [node as SceneNode, ...((node as any).children || [])]; 209 | } else { 210 | result = node as SceneNode; 211 | } 212 | } else if (node.type === 'PAGE') { 213 | // Handle page nodes specially 214 | result = node as PageNode; 215 | } else { 216 | // For document or other unsupported node types 217 | throw new Error("Unsupported node type: " + node.type); 218 | } 219 | break; 220 | 221 | case "get-pages": 222 | console.log("MCP command: Getting all pages"); 223 | result = figma.root.children; 224 | break; 225 | 226 | case "get-page": 227 | console.log("MCP command: Getting page with ID:", params.page_id); 228 | if (!params.page_id) { 229 | // If no page_id is provided, use the current page 230 | console.log("No page_id provided, using current page"); 231 | result = figma.currentPage; 232 | } else { 233 | // If page_id is provided, find the page by ID 234 | const pageNode = figma.getNodeById(params.page_id); 235 | if (!pageNode || pageNode.type !== "PAGE") 236 | throw new Error("Invalid page ID or node is not a page"); 237 | result = pageNode; 238 | } 239 | break; 240 | 241 | case "create-page": 242 | console.log("MCP command: Creating new page with name:", params.name); 243 | const newPage = figma.createPage(); 244 | newPage.name = params.name || "New Page"; 245 | result = newPage; 246 | break; 247 | 248 | case "switch-page": 249 | console.log("MCP command: Switching to page with ID:", params.id); 250 | if (!params.id) throw new Error("Page ID is required"); 251 | const switchPageNode = figma.getNodeById(params.id); 252 | if (!switchPageNode || switchPageNode.type !== "PAGE") 253 | throw new Error("Invalid page ID"); 254 | 255 | figma.currentPage = switchPageNode as PageNode; 256 | result = switchPageNode; 257 | break; 258 | 259 | case "modify-rectangle": 260 | console.log("MCP command: Modifying rectangle with ID:", params.id); 261 | if (!params.id) throw new Error("Rectangle ID is required"); 262 | const modifyNode = figma.getNodeById(params.id); 263 | if (!modifyNode || modifyNode.type !== "RECTANGLE") 264 | throw new Error("Invalid rectangle ID"); 265 | 266 | const rect = modifyNode as RectangleNode; 267 | if (params.x !== undefined) rect.x = params.x; 268 | if (params.y !== undefined) rect.y = params.y; 269 | if (params.width !== undefined && params.height !== undefined) 270 | rect.resize(params.width, params.height); 271 | if (params.cornerRadius !== undefined) 272 | rect.cornerRadius = params.cornerRadius; 273 | if (params.color) 274 | rect.fills = [{ type: "SOLID", color: hexToRgb(params.color) }]; 275 | 276 | result = rect; 277 | break; 278 | 279 | default: 280 | console.log("Unknown MCP command:", command); 281 | throw new Error("Unknown command: " + command); 282 | } 283 | 284 | // Convert PageNode to a compatible format for buildResultObject if needed 285 | let resultForBuilder: SceneNode | readonly SceneNode[] | null = null; 286 | 287 | if (result === null) { 288 | resultForBuilder = null; 289 | } else if (Array.isArray(result)) { 290 | // For arrays, we rely on duck typing - both PageNode[] and SceneNode[] have id, name, type 291 | resultForBuilder = result as unknown as readonly SceneNode[]; 292 | } else if ("type" in result && result.type === "PAGE") { 293 | // For individual PageNode, we rely on duck typing - PageNode has id, name, type like SceneNode 294 | resultForBuilder = result as unknown as SceneNode; 295 | } else { 296 | resultForBuilder = result as SceneNode; 297 | } 298 | 299 | // Build result object, avoiding possible null values 300 | const resultObject = buildResultObject(resultForBuilder); 301 | console.log("Command result:", resultObject); 302 | 303 | // Send success response to UI 304 | figma.ui.postMessage({ 305 | type: "mcp-response", 306 | success: true, 307 | command: command, 308 | result: resultObject, 309 | }); 310 | console.log("Response sent to UI"); 311 | 312 | return resultObject; 313 | } catch (error) { 314 | console.error("Error handling MCP command:", error); 315 | 316 | // Send error response to UI 317 | figma.ui.postMessage({ 318 | type: "mcp-response", 319 | success: false, 320 | command: command, 321 | error: error instanceof Error ? error.message : "Unknown error", 322 | }); 323 | console.log("Error response sent to UI"); 324 | 325 | throw error; 326 | } 327 | } 328 | ``` -------------------------------------------------------------------------------- /src/tools/widget/widget-tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Widget Tools - MCP server tools for interacting with Figma widgets 3 | */ 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | import { z } from "zod"; 6 | import figmaApi from "../../services/figma-api.js"; 7 | import { FigmaUtils } from "../../utils/figma-utils.js"; 8 | 9 | /** 10 | * Register widget-related tools with the MCP server 11 | * @param server The MCP server instance 12 | */ 13 | export function registerWidgetTools(server: McpServer) { 14 | // Get all widget nodes in a file 15 | server.tool( 16 | "get_widgets", 17 | { 18 | file_key: z.string().min(1).describe("The Figma file key to retrieve widgets from") 19 | }, 20 | async ({ file_key }) => { 21 | try { 22 | const file = await figmaApi.getFile(file_key); 23 | 24 | // Find all widget nodes in the file 25 | const widgetNodes = FigmaUtils.getNodesByType(file, 'WIDGET'); 26 | 27 | if (widgetNodes.length === 0) { 28 | return { 29 | content: [ 30 | { type: "text", text: `No widgets found in file ${file_key}` } 31 | ] 32 | }; 33 | } 34 | 35 | const widgetsList = widgetNodes.map((node, index) => { 36 | const widgetSyncData = node.widgetSync ? 37 | `\n - Widget Sync Data: Available` : 38 | `\n - Widget Sync Data: None`; 39 | 40 | return `${index + 1}. **${node.name}** (ID: ${node.id}) 41 | - Widget ID: ${node.widgetId || 'Unknown'}${widgetSyncData}`; 42 | }).join('\n\n'); 43 | 44 | return { 45 | content: [ 46 | { type: "text", text: `# Widgets in file ${file_key}` }, 47 | { type: "text", text: `Found ${widgetNodes.length} widgets:` }, 48 | { type: "text", text: widgetsList } 49 | ] 50 | }; 51 | } catch (error) { 52 | console.error('Error fetching widgets:', error); 53 | return { 54 | content: [ 55 | { type: "text", text: `Error getting widgets: ${(error as Error).message}` } 56 | ] 57 | }; 58 | } 59 | } 60 | ); 61 | 62 | // Get a specific widget node 63 | server.tool( 64 | "get_widget", 65 | { 66 | file_key: z.string().min(1).describe("The Figma file key"), 67 | node_id: z.string().min(1).describe("The ID of the widget node") 68 | }, 69 | async ({ file_key, node_id }) => { 70 | try { 71 | const fileNodes = await figmaApi.getFileNodes(file_key, [node_id]); 72 | const nodeData = fileNodes.nodes[node_id]; 73 | 74 | if (!nodeData || nodeData.document.type !== 'WIDGET') { 75 | return { 76 | content: [ 77 | { type: "text", text: `Node ${node_id} not found in file ${file_key} or is not a widget` } 78 | ] 79 | }; 80 | } 81 | 82 | const widgetNode = nodeData.document; 83 | 84 | // Get the sync data if available 85 | let syncDataContent = ''; 86 | if (widgetNode.widgetSync) { 87 | try { 88 | const syncData = JSON.parse(widgetNode.widgetSync); 89 | syncDataContent = `\n\n## Widget Sync Data\n\`\`\`json\n${JSON.stringify(syncData, null, 2)}\n\`\`\``; 90 | } catch (error) { 91 | syncDataContent = '\n\n## Widget Sync Data\nError parsing widget sync data'; 92 | } 93 | } 94 | 95 | return { 96 | content: [ 97 | { type: "text", text: `# Widget: ${widgetNode.name}` }, 98 | { type: "text", text: `ID: ${widgetNode.id}` }, 99 | { type: "text", text: `Widget ID: ${widgetNode.widgetId || 'Unknown'}` }, 100 | { type: "text", text: `Has Sync Data: ${widgetNode.widgetSync ? 'Yes' : 'No'}${syncDataContent}` } 101 | ] 102 | }; 103 | } catch (error) { 104 | console.error('Error fetching widget node:', error); 105 | return { 106 | content: [ 107 | { type: "text", text: `Error getting widget: ${(error as Error).message}` } 108 | ] 109 | }; 110 | } 111 | } 112 | ); 113 | 114 | // Get widget sync data 115 | server.tool( 116 | "get_widget_sync_data", 117 | { 118 | file_key: z.string().min(1).describe("The Figma file key"), 119 | node_id: z.string().min(1).describe("The ID of the widget node") 120 | }, 121 | async ({ file_key, node_id }) => { 122 | try { 123 | const fileNodes = await figmaApi.getFileNodes(file_key, [node_id]); 124 | const nodeData = fileNodes.nodes[node_id]; 125 | 126 | if (!nodeData || nodeData.document.type !== 'WIDGET') { 127 | return { 128 | content: [ 129 | { type: "text", text: `Node ${node_id} not found in file ${file_key} or is not a widget` } 130 | ] 131 | }; 132 | } 133 | 134 | const widgetNode = nodeData.document; 135 | 136 | if (!widgetNode.widgetSync) { 137 | return { 138 | content: [ 139 | { type: "text", text: `Widget ${node_id} does not have any sync data` } 140 | ] 141 | }; 142 | } 143 | 144 | try { 145 | const syncData = JSON.parse(widgetNode.widgetSync); 146 | 147 | return { 148 | content: [ 149 | { type: "text", text: `# Widget Sync Data for "${widgetNode.name}"` }, 150 | { type: "text", text: `Widget ID: ${widgetNode.id}` }, 151 | { type: "text", text: "```json\n" + JSON.stringify(syncData, null, 2) + "\n```" } 152 | ] 153 | }; 154 | } catch (error) { 155 | console.error('Error parsing widget sync data:', error); 156 | return { 157 | content: [ 158 | { type: "text", text: `Error parsing widget sync data: ${(error as Error).message}` } 159 | ] 160 | }; 161 | } 162 | } catch (error) { 163 | console.error('Error fetching widget sync data:', error); 164 | return { 165 | content: [ 166 | { type: "text", text: `Error getting widget sync data: ${(error as Error).message}` } 167 | ] 168 | }; 169 | } 170 | } 171 | ); 172 | 173 | // Search widgets by property values 174 | server.tool( 175 | "search_widgets", 176 | { 177 | file_key: z.string().min(1).describe("The Figma file key"), 178 | property_key: z.string().min(1).describe("The sync data property key to search for"), 179 | property_value: z.string().optional().describe("Optional property value to match (if not provided, returns all widgets with the property)") 180 | }, 181 | async ({ file_key, property_key, property_value }) => { 182 | try { 183 | const file = await figmaApi.getFile(file_key); 184 | 185 | // Find all widget nodes 186 | const allWidgetNodes = FigmaUtils.getNodesByType(file, 'WIDGET'); 187 | 188 | // Filter widgets that have the specified property 189 | const matchingWidgets = allWidgetNodes.filter(node => { 190 | if (!node.widgetSync) return false; 191 | 192 | try { 193 | const syncData = JSON.parse(node.widgetSync); 194 | 195 | // If property_value is provided, check for exact match 196 | if (property_value !== undefined) { 197 | // Handle different types of values (string, number, boolean) 198 | const propValue = syncData[property_key]; 199 | 200 | if (typeof propValue === 'string') { 201 | return propValue === property_value; 202 | } else if (typeof propValue === 'number') { 203 | return propValue.toString() === property_value; 204 | } else if (typeof propValue === 'boolean') { 205 | return propValue.toString() === property_value; 206 | } else if (propValue !== null && typeof propValue === 'object') { 207 | return JSON.stringify(propValue) === property_value; 208 | } 209 | 210 | return false; 211 | } 212 | 213 | // If no value provided, just check if the property exists 214 | return property_key in syncData; 215 | } catch (error) { 216 | return false; 217 | } 218 | }); 219 | 220 | if (matchingWidgets.length === 0) { 221 | return { 222 | content: [ 223 | { type: "text", text: property_value ? 224 | `No widgets found with property "${property_key}" = "${property_value}"` : 225 | `No widgets found with property "${property_key}"` 226 | } 227 | ] 228 | }; 229 | } 230 | 231 | const widgetsList = matchingWidgets.map((node, index) => { 232 | let syncDataValue = ''; 233 | try { 234 | const syncData = JSON.parse(node.widgetSync!); 235 | const value = syncData[property_key]; 236 | syncDataValue = typeof value === 'object' ? 237 | JSON.stringify(value) : 238 | String(value); 239 | } catch (error) { 240 | syncDataValue = 'Error parsing sync data'; 241 | } 242 | 243 | return `${index + 1}. **${node.name}** (ID: ${node.id}) 244 | - Property "${property_key}": ${syncDataValue}`; 245 | }).join('\n\n'); 246 | 247 | return { 248 | content: [ 249 | { type: "text", text: property_value ? 250 | `# Widgets with property "${property_key}" = "${property_value}"` : 251 | `# Widgets with property "${property_key}"` 252 | }, 253 | { type: "text", text: `Found ${matchingWidgets.length} matching widgets:` }, 254 | { type: "text", text: widgetsList } 255 | ] 256 | }; 257 | } catch (error) { 258 | console.error('Error searching widgets:', error); 259 | return { 260 | content: [ 261 | { type: "text", text: `Error searching widgets: ${(error as Error).message}` } 262 | ] 263 | }; 264 | } 265 | } 266 | ); 267 | 268 | // Get widget properties for modification 269 | server.tool( 270 | "analyze_widget_structure", 271 | { 272 | file_key: z.string().min(1).describe("The Figma file key"), 273 | node_id: z.string().min(1).describe("The ID of the widget node") 274 | }, 275 | async ({ file_key, node_id }) => { 276 | try { 277 | const fileNodes = await figmaApi.getFileNodes(file_key, [node_id]); 278 | const nodeData = fileNodes.nodes[node_id]; 279 | 280 | if (!nodeData || nodeData.document.type !== 'WIDGET') { 281 | return { 282 | content: [ 283 | { type: "text", text: `Node ${node_id} not found in file ${file_key} or is not a widget` } 284 | ] 285 | }; 286 | } 287 | 288 | const widgetNode = nodeData.document; 289 | 290 | // Create a full analysis of the widget 291 | const widgetAnalysis = { 292 | basic: { 293 | id: widgetNode.id, 294 | name: widgetNode.name, 295 | type: widgetNode.type, 296 | widgetId: widgetNode.widgetId || 'Unknown' 297 | }, 298 | placement: { 299 | x: widgetNode.x || 0, 300 | y: widgetNode.y || 0, 301 | width: widgetNode.width || 0, 302 | height: widgetNode.height || 0, 303 | rotation: widgetNode.rotation || 0 304 | }, 305 | syncData: null as any 306 | }; 307 | 308 | // Parse the widget sync data if available 309 | if (widgetNode.widgetSync) { 310 | try { 311 | widgetAnalysis.syncData = JSON.parse(widgetNode.widgetSync); 312 | } catch (error) { 313 | widgetAnalysis.syncData = { error: 'Invalid sync data format' }; 314 | } 315 | } 316 | 317 | return { 318 | content: [ 319 | { type: "text", text: `# Widget Analysis: ${widgetNode.name}` }, 320 | { type: "text", text: `## Basic Information` }, 321 | { type: "text", text: "```json\n" + JSON.stringify(widgetAnalysis.basic, null, 2) + "\n```" }, 322 | { type: "text", text: `## Placement` }, 323 | { type: "text", text: "```json\n" + JSON.stringify(widgetAnalysis.placement, null, 2) + "\n```" }, 324 | { type: "text", text: `## Sync Data` }, 325 | { type: "text", text: widgetAnalysis.syncData ? 326 | "```json\n" + JSON.stringify(widgetAnalysis.syncData, null, 2) + "\n```" : 327 | "No sync data available" 328 | } 329 | ] 330 | }; 331 | } catch (error) { 332 | console.error('Error analyzing widget:', error); 333 | return { 334 | content: [ 335 | { type: "text", text: `Error analyzing widget: ${(error as Error).message}` } 336 | ] 337 | }; 338 | } 339 | } 340 | ); 341 | } 342 | ``` -------------------------------------------------------------------------------- /src/tools/frame.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Frame Tools - MCP server tools for working with Figma Frame components 3 | */ 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | import { z } from "zod"; 6 | import figmaApi from "../services/figma-api.js"; 7 | import { FigmaUtils } from "../utils/figma-utils.js"; 8 | 9 | /** 10 | * Register frame-related tools with the MCP server 11 | * @param server The MCP server instance 12 | */ 13 | export function registerFrameTools(server: McpServer) { 14 | // Get Frame component documentation 15 | server.tool("get_frame_documentation", {}, async () => { 16 | try { 17 | // Frame component documentation based on the provided text 18 | return { 19 | content: [ 20 | { type: "text", text: "# Frame Component Documentation" }, 21 | { 22 | type: "text", 23 | text: "Frame acts exactly like a non-autolayout Frame within Figma, where children are positioned using x and y constraints. This component is useful to define a layout hierarchy.", 24 | }, 25 | { 26 | type: "text", 27 | text: "If you want to use autolayout, use AutoLayout instead.", 28 | }, 29 | { type: "text", text: "## BaseProps" }, 30 | { 31 | type: "text", 32 | text: "- **name**: string - The name of the component", 33 | }, 34 | { 35 | type: "text", 36 | text: "- **hidden**: boolean - Toggles whether to show the component", 37 | }, 38 | { 39 | type: "text", 40 | text: "- **onClick**: (event: WidgetClickEvent) => Promise<any> | void - Attach a click handler", 41 | }, 42 | { 43 | type: "text", 44 | text: "- **key**: string | number - The key of the component", 45 | }, 46 | { 47 | type: "text", 48 | text: "- **hoverStyle**: HoverStyle - The style to be applied when hovering", 49 | }, 50 | { 51 | type: "text", 52 | text: "- **tooltip**: string - The tooltip shown when hovering", 53 | }, 54 | { 55 | type: "text", 56 | text: "- **positioning**: 'auto' | 'absolute' - How to position the node inside an AutoLayout parent", 57 | }, 58 | { type: "text", text: "## BlendProps" }, 59 | { 60 | type: "text", 61 | text: "- **blendMode**: BlendMode - The blendMode of the component", 62 | }, 63 | { 64 | type: "text", 65 | text: "- **opacity**: number - The opacity of the component", 66 | }, 67 | { 68 | type: "text", 69 | text: "- **effect**: Effect | Effect[] - The effect of the component", 70 | }, 71 | { type: "text", text: "## ConstraintProps" }, 72 | { 73 | type: "text", 74 | text: "- **x**: number | HorizontalConstraint - The x position of the node", 75 | }, 76 | { 77 | type: "text", 78 | text: "- **y**: number | VerticalConstraint - The y position of the node", 79 | }, 80 | { 81 | type: "text", 82 | text: "- **overflow**: 'visible' | 'hidden' | 'scroll' - The overflow behavior", 83 | }, 84 | { type: "text", text: "## SizeProps (Required)" }, 85 | { 86 | type: "text", 87 | text: "- **width**: Size - The width of the component (required)", 88 | }, 89 | { 90 | type: "text", 91 | text: "- **height**: Size - The height of the component (required)", 92 | }, 93 | { type: "text", text: "- **minWidth**: number - The minimum width" }, 94 | { type: "text", text: "- **maxWidth**: number - The maximum width" }, 95 | { 96 | type: "text", 97 | text: "- **minHeight**: number - The minimum height", 98 | }, 99 | { 100 | type: "text", 101 | text: "- **maxHeight**: number - The maximum height", 102 | }, 103 | { 104 | type: "text", 105 | text: "- **rotation**: number - The rotation in degrees (-180 to 180)", 106 | }, 107 | { type: "text", text: "## CornerProps" }, 108 | { 109 | type: "text", 110 | text: "- **cornerRadius**: CornerRadius - The corner radius in pixels", 111 | }, 112 | { type: "text", text: "## GeometryProps" }, 113 | { 114 | type: "text", 115 | text: "- **fill**: HexCode | Color | Paint | (SolidPaint | GradientPaint)[] - The fill paints", 116 | }, 117 | { 118 | type: "text", 119 | text: "- **stroke**: HexCode | Color | SolidPaint | GradientPaint | (SolidPaint | GradientPaint)[] - The stroke paints", 120 | }, 121 | { 122 | type: "text", 123 | text: "- **strokeWidth**: number - The stroke thickness in pixels", 124 | }, 125 | { 126 | type: "text", 127 | text: "- **strokeAlign**: StrokeAlign - The stroke alignment", 128 | }, 129 | { 130 | type: "text", 131 | text: "- **strokeDashPattern**: number[] - The stroke dash pattern", 132 | }, 133 | ], 134 | }; 135 | } catch (error) { 136 | console.error("Error retrieving Frame documentation:", error); 137 | return { 138 | content: [ 139 | { 140 | type: "text", 141 | text: `Error getting Frame documentation: ${ 142 | (error as Error).message 143 | }`, 144 | }, 145 | ], 146 | }; 147 | } 148 | }); 149 | 150 | // Create a new frame widget 151 | server.tool( 152 | "create_frame_widget", 153 | { 154 | name: z 155 | .string() 156 | .min(1) 157 | .describe("The name for the widget containing frames"), 158 | width: z.number().min(1).describe("The width of the frame"), 159 | height: z.number().min(1).describe("The height of the frame"), 160 | fill: z.string().optional().describe("The fill color (hex code)"), 161 | }, 162 | async ({ name, width, height, fill }) => { 163 | try { 164 | // Create a sample widget code with Frame component 165 | const widgetCode = ` 166 | // ${name} - Figma Widget with Frame Component 167 | const { widget } = figma; 168 | const { Frame, Text } = widget; 169 | 170 | function ${name.replace(/\\s+/g, "")}Widget() { 171 | return ( 172 | <Frame 173 | name="${name}" 174 | width={${width}} 175 | height={${height}} 176 | fill={${fill ? `"${fill}"` : "[]"}} 177 | stroke="#E0E0E0" 178 | strokeWidth={1} 179 | cornerRadius={8} 180 | > 181 | <Text 182 | x={20} 183 | y={20} 184 | width={${width - 40}} 185 | horizontalAlignText="center" 186 | fill="#000000" 187 | > 188 | Frame Widget Example 189 | </Text> 190 | </Frame> 191 | ); 192 | } 193 | 194 | widget.register(${name.replace(/\\s+/g, "")}Widget); 195 | `; 196 | 197 | return { 198 | content: [ 199 | { type: "text", text: `# Frame Widget Code` }, 200 | { 201 | type: "text", 202 | text: `The following code creates a widget using the Frame component:`, 203 | }, 204 | { type: "text", text: "```jsx\n" + widgetCode + "\n```" }, 205 | { type: "text", text: `## Instructions` }, 206 | { type: "text", text: `1. Create a new widget in Figma` }, 207 | { 208 | type: "text", 209 | text: `2. Copy and paste this code into the widget code editor`, 210 | }, 211 | { 212 | type: "text", 213 | text: `3. Customize the content inside the Frame as needed`, 214 | }, 215 | ], 216 | }; 217 | } catch (error) { 218 | console.error("Error generating frame widget code:", error); 219 | return { 220 | content: [ 221 | { 222 | type: "text", 223 | text: `Error generating frame widget code: ${ 224 | (error as Error).message 225 | }`, 226 | }, 227 | ], 228 | }; 229 | } 230 | } 231 | ); 232 | 233 | // Create a frame directly in Figma file 234 | server.tool( 235 | "create_frame_in_figma", 236 | { 237 | file_key: z 238 | .string() 239 | .min(1) 240 | .describe("The Figma file key where the frame will be created"), 241 | page_id: z 242 | .string() 243 | .optional() 244 | .describe("The page ID where the frame will be created (optional)"), 245 | name: z 246 | .string() 247 | .min(1) 248 | .describe("The name for the new frame"), 249 | width: z.number().min(1).describe("The width of the frame in pixels"), 250 | height: z.number().min(1).describe("The height of the frame in pixels"), 251 | x: z.number().default(0).describe("The X position of the frame (default: 0)"), 252 | y: z.number().default(0).describe("The Y position of the frame (default: 0)"), 253 | fill_color: z.string().optional().describe("The fill color (hex code)"), 254 | }, 255 | async ({ file_key, page_id, name, width, height, x, y, fill_color }) => { 256 | try { 257 | // Create a frame in Figma using the Figma API 258 | const createFrameResponse = await figmaApi.createFrame(file_key, { 259 | name, 260 | width, 261 | height, 262 | x, 263 | y, 264 | fills: fill_color ? [{ type: "SOLID", color: hexToRgb(fill_color), opacity: 1 }] : [], 265 | pageId: page_id 266 | }); 267 | 268 | return { 269 | content: [ 270 | { type: "text", text: `# Frame Created Successfully` }, 271 | { 272 | type: "text", 273 | text: `A new frame named "${name}" has been created in your Figma file.` 274 | }, 275 | { 276 | type: "text", 277 | text: `- Width: ${width}px\n- Height: ${height}px\n- Position: (${x}, ${y})` 278 | }, 279 | { 280 | type: "text", 281 | text: `Frame ID: ${createFrameResponse.frame.id}` 282 | }, 283 | { 284 | type: "text", 285 | text: `You can now view and edit this frame in your Figma file.` 286 | } 287 | ], 288 | }; 289 | } catch (error) { 290 | console.error("Error creating frame in Figma:", error); 291 | return { 292 | content: [ 293 | { 294 | type: "text", 295 | text: `Error creating frame in Figma: ${ 296 | (error as Error).message 297 | }`, 298 | }, 299 | { 300 | type: "text", 301 | text: "Please make sure you have write access to the file and the file key is correct." 302 | } 303 | ], 304 | }; 305 | } 306 | } 307 | ); 308 | 309 | // Get all frames in a file 310 | server.tool( 311 | "get_frames", 312 | { 313 | file_key: z 314 | .string() 315 | .min(1) 316 | .describe("The Figma file key to retrieve frames from"), 317 | }, 318 | async ({ file_key }) => { 319 | try { 320 | const file = await figmaApi.getFile(file_key); 321 | 322 | // Find all frame nodes in the file 323 | const frameNodes = FigmaUtils.getNodesByType(file, "FRAME"); 324 | 325 | if (frameNodes.length === 0) { 326 | return { 327 | content: [ 328 | { type: "text", text: `No frames found in file ${file_key}` }, 329 | ], 330 | }; 331 | } 332 | 333 | const framesList = frameNodes 334 | .map((node, index) => { 335 | // Add type assertion for frame nodes 336 | const frameNode = node as { 337 | id: string; 338 | name: string; 339 | width?: number; 340 | height?: number; 341 | children?: Array<any>; 342 | }; 343 | 344 | return `${index + 1}. **${frameNode.name}** (ID: ${frameNode.id}) 345 | - Width: ${frameNode.width || "Unknown"}, Height: ${frameNode.height || "Unknown"} 346 | - Children: ${frameNode.children?.length || 0}`; 347 | }) 348 | .join("\n\n"); 349 | 350 | return { 351 | content: [ 352 | { type: "text", text: `# Frames in file ${file_key}` }, 353 | { type: "text", text: `Found ${frameNodes.length} frames:` }, 354 | { type: "text", text: framesList }, 355 | ], 356 | }; 357 | } catch (error) { 358 | console.error("Error fetching frames:", error); 359 | return { 360 | content: [ 361 | { 362 | type: "text", 363 | text: `Error getting frames: ${(error as Error).message}`, 364 | }, 365 | ], 366 | }; 367 | } 368 | } 369 | ); 370 | } 371 | 372 | /** 373 | * Convert hex color code to RGB values 374 | * @param hex Hex color code (e.g., #RRGGBB or #RGB) 375 | * @returns RGB color object with r, g, b values between 0 and 1 376 | */ 377 | function hexToRgb(hex: string) { 378 | // Remove # if present 379 | hex = hex.replace(/^#/, ''); 380 | 381 | // Parse hex values 382 | let r, g, b; 383 | if (hex.length === 3) { 384 | // Convert 3-digit hex to 6-digit 385 | r = parseInt(hex.charAt(0) + hex.charAt(0), 16) / 255; 386 | g = parseInt(hex.charAt(1) + hex.charAt(1), 16) / 255; 387 | b = parseInt(hex.charAt(2) + hex.charAt(2), 16) / 255; 388 | } else if (hex.length === 6) { 389 | r = parseInt(hex.substring(0, 2), 16) / 255; 390 | g = parseInt(hex.substring(2, 4), 16) / 255; 391 | b = parseInt(hex.substring(4, 6), 16) / 255; 392 | } else { 393 | throw new Error(`Invalid hex color: ${hex}`); 394 | } 395 | 396 | return { r, g, b }; 397 | } 398 | ``` -------------------------------------------------------------------------------- /src/plugin/creators/shapeCreators.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Shape creation functions for Figma plugin 3 | */ 4 | 5 | import { createSolidPaint } from "../utils/colorUtils"; 6 | import { selectAndFocusNodes } from "../utils/nodeUtils"; 7 | 8 | /** 9 | * Create a rectangle from data 10 | * @param data Rectangle configuration data 11 | * @returns Created rectangle node 12 | */ 13 | export function createRectangleFromData(data: any): RectangleNode { 14 | const rect = figma.createRectangle(); 15 | 16 | // Size 17 | rect.resize(data.width || 100, data.height || 100); 18 | 19 | // Fill 20 | if (data.fills) { 21 | rect.fills = data.fills; 22 | } else if (data.fill) { 23 | if (typeof data.fill === "string") { 24 | // Handle hex color 25 | rect.fills = [createSolidPaint(data.fill)]; 26 | } else { 27 | // Handle fill object 28 | rect.fills = [data.fill]; 29 | } 30 | } 31 | 32 | // Stroke 33 | if (data.strokes) rect.strokes = data.strokes; 34 | if (data.strokeWeight !== undefined) rect.strokeWeight = data.strokeWeight; 35 | if (data.strokeAlign) rect.strokeAlign = data.strokeAlign; 36 | if (data.strokeCap) rect.strokeCap = data.strokeCap; 37 | if (data.strokeJoin) rect.strokeJoin = data.strokeJoin; 38 | if (data.dashPattern) rect.dashPattern = data.dashPattern; 39 | 40 | // Corner radius 41 | if (data.cornerRadius !== undefined) rect.cornerRadius = data.cornerRadius; 42 | if (data.topLeftRadius !== undefined) rect.topLeftRadius = data.topLeftRadius; 43 | if (data.topRightRadius !== undefined) 44 | rect.topRightRadius = data.topRightRadius; 45 | if (data.bottomLeftRadius !== undefined) 46 | rect.bottomLeftRadius = data.bottomLeftRadius; 47 | if (data.bottomRightRadius !== undefined) 48 | rect.bottomRightRadius = data.bottomRightRadius; 49 | 50 | return rect; 51 | } 52 | 53 | /** 54 | * Create a simple rectangle 55 | * Convenient function for basic rectangle creation 56 | * 57 | * @param x X coordinate 58 | * @param y Y coordinate 59 | * @param width Width of rectangle 60 | * @param height Height of rectangle 61 | * @param color Fill color as hex string 62 | * @returns Created rectangle node 63 | */ 64 | export function createRectangle( 65 | x: number, 66 | y: number, 67 | width: number, 68 | height: number, 69 | color: string 70 | ): RectangleNode { 71 | // Use the new data-driven function 72 | const rect = createRectangleFromData({ 73 | width, 74 | height, 75 | fill: color, 76 | }); 77 | 78 | // Set position 79 | rect.x = x; 80 | rect.y = y; 81 | 82 | // Select and focus 83 | selectAndFocusNodes(rect); 84 | 85 | return rect; 86 | } 87 | 88 | /** 89 | * Create an ellipse/circle from data 90 | * @param data Ellipse configuration data 91 | * @returns Created ellipse node 92 | */ 93 | export function createEllipseFromData(data: any): EllipseNode { 94 | const ellipse = figma.createEllipse(); 95 | 96 | // Size 97 | ellipse.resize(data.width || 100, data.height || 100); 98 | 99 | // Fill 100 | if (data.fills) { 101 | ellipse.fills = data.fills; 102 | } else if (data.fill) { 103 | if (typeof data.fill === "string") { 104 | // Handle hex color 105 | ellipse.fills = [createSolidPaint(data.fill)]; 106 | } else { 107 | // Handle fill object 108 | ellipse.fills = [data.fill]; 109 | } 110 | } 111 | 112 | // Arc data for partial ellipses (arcs/donuts) 113 | if (data.arcData) { 114 | ellipse.arcData = { 115 | startingAngle: data.arcData.startingAngle !== undefined ? data.arcData.startingAngle : 0, 116 | endingAngle: data.arcData.endingAngle !== undefined ? data.arcData.endingAngle : 360, 117 | innerRadius: data.arcData.innerRadius !== undefined ? data.arcData.innerRadius : 0 118 | }; 119 | } 120 | 121 | // Stroke 122 | if (data.strokes) ellipse.strokes = data.strokes; 123 | if (data.strokeWeight !== undefined) ellipse.strokeWeight = data.strokeWeight; 124 | if (data.strokeAlign) ellipse.strokeAlign = data.strokeAlign; 125 | if (data.strokeCap) ellipse.strokeCap = data.strokeCap; 126 | if (data.strokeJoin) ellipse.strokeJoin = data.strokeJoin; 127 | if (data.dashPattern) ellipse.dashPattern = data.dashPattern; 128 | 129 | return ellipse; 130 | } 131 | 132 | /** 133 | * Create a simple circle or ellipse 134 | * @param x X coordinate 135 | * @param y Y coordinate 136 | * @param width Width of ellipse 137 | * @param height Height of ellipse 138 | * @param color Fill color as hex string 139 | * @returns Created ellipse node 140 | */ 141 | export function createCircle( 142 | x: number, 143 | y: number, 144 | width: number, 145 | height: number, 146 | color: string 147 | ): EllipseNode { 148 | // Use the data-driven function 149 | const ellipse = createEllipseFromData({ 150 | width, 151 | height, 152 | fill: color, 153 | }); 154 | 155 | // Set position 156 | ellipse.x = x; 157 | ellipse.y = y; 158 | 159 | // Select and focus 160 | selectAndFocusNodes(ellipse); 161 | 162 | return ellipse; 163 | } 164 | 165 | /** 166 | * Create a polygon from data 167 | * @param data Polygon configuration data 168 | * @returns Created polygon node 169 | */ 170 | export function createPolygonFromData(data: any): PolygonNode { 171 | const polygon = figma.createPolygon(); 172 | polygon.resize(data.width || 100, data.height || 100); 173 | 174 | // Set number of sides 175 | if (data.pointCount) polygon.pointCount = data.pointCount; 176 | 177 | // Fill 178 | if (data.fills) { 179 | polygon.fills = data.fills; 180 | } else if (data.fill) { 181 | if (typeof data.fill === "string") { 182 | polygon.fills = [createSolidPaint(data.fill)]; 183 | } else { 184 | polygon.fills = [data.fill]; 185 | } 186 | } else if (data.color) { 187 | // For consistency with other shape creation functions 188 | polygon.fills = [createSolidPaint(data.color)]; 189 | } 190 | 191 | // Stroke 192 | if (data.strokes) polygon.strokes = data.strokes; 193 | if (data.strokeWeight !== undefined) polygon.strokeWeight = data.strokeWeight; 194 | if (data.strokeAlign) polygon.strokeAlign = data.strokeAlign; 195 | if (data.strokeCap) polygon.strokeCap = data.strokeCap; 196 | if (data.strokeJoin) polygon.strokeJoin = data.strokeJoin; 197 | if (data.dashPattern) polygon.dashPattern = data.dashPattern; 198 | 199 | return polygon; 200 | } 201 | 202 | /** 203 | * Create a simple polygon 204 | * @param x X coordinate 205 | * @param y Y coordinate 206 | * @param width Width of polygon 207 | * @param height Height of polygon 208 | * @param sides Number of sides (≥ 3) 209 | * @param color Fill color as hex string 210 | * @returns Created polygon node 211 | */ 212 | export function createPolygon( 213 | x: number, 214 | y: number, 215 | width: number, 216 | height: number, 217 | sides: number = 3, 218 | color: string 219 | ): PolygonNode { 220 | // Use the data-driven function 221 | const polygon = createPolygonFromData({ 222 | width, 223 | height, 224 | pointCount: sides, 225 | fill: color 226 | }); 227 | 228 | // Set position 229 | polygon.x = x; 230 | polygon.y = y; 231 | 232 | // Select and focus 233 | selectAndFocusNodes(polygon); 234 | 235 | return polygon; 236 | } 237 | 238 | /** 239 | * Create a star from data 240 | * @param data Star configuration data 241 | * @returns Created star node 242 | */ 243 | export function createStarFromData(data: any): StarNode { 244 | const star = figma.createStar(); 245 | star.resize(data.width || 100, data.height || 100); 246 | 247 | // Star specific properties 248 | if (data.pointCount) star.pointCount = data.pointCount; 249 | if (data.innerRadius) star.innerRadius = data.innerRadius; 250 | 251 | // Fill 252 | if (data.fills) { 253 | star.fills = data.fills; 254 | } else if (data.fill) { 255 | if (typeof data.fill === "string") { 256 | star.fills = [createSolidPaint(data.fill)]; 257 | } else { 258 | star.fills = [data.fill]; 259 | } 260 | } 261 | 262 | return star; 263 | } 264 | 265 | /** 266 | * Create a line from data 267 | * @param data Line configuration data 268 | * @returns Created line node 269 | */ 270 | export function createLineFromData(data: any): LineNode { 271 | const line = figma.createLine(); 272 | 273 | // Set line length (width) 274 | line.resize(data.width || 100, 0); // Line height is always 0 275 | 276 | // Set rotation if provided 277 | if (data.rotation !== undefined) line.rotation = data.rotation; 278 | 279 | // Stroke properties 280 | if (data.strokeWeight) line.strokeWeight = data.strokeWeight; 281 | if (data.strokeAlign) line.strokeAlign = data.strokeAlign; 282 | if (data.strokeCap) line.strokeCap = data.strokeCap; 283 | if (data.strokeJoin) line.strokeJoin = data.strokeJoin; 284 | if (data.dashPattern) line.dashPattern = data.dashPattern; 285 | 286 | // Stroke color 287 | if (data.strokes) { 288 | line.strokes = data.strokes; 289 | } else if (data.stroke) { 290 | if (typeof data.stroke === "string") { 291 | line.strokes = [createSolidPaint(data.stroke)]; 292 | } else { 293 | line.strokes = [data.stroke]; 294 | } 295 | } else if (data.color) { 296 | // For consistency with other shape creation functions 297 | line.strokes = [createSolidPaint(data.color)]; 298 | } 299 | 300 | return line; 301 | } 302 | 303 | /** 304 | * Create a simple line 305 | * @param x X coordinate 306 | * @param y Y coordinate 307 | * @param length Length of the line (width) 308 | * @param color Stroke color as hex string 309 | * @param rotation Rotation in degrees 310 | * @param strokeWeight Stroke thickness 311 | * @returns Created line node 312 | */ 313 | export function createLine( 314 | x: number, 315 | y: number, 316 | length: number, 317 | color: string, 318 | rotation: number = 0, 319 | strokeWeight: number = 1 320 | ): LineNode { 321 | // Use the data-driven function 322 | const line = createLineFromData({ 323 | width: length, 324 | stroke: color, 325 | strokeWeight: strokeWeight, 326 | rotation: rotation 327 | }); 328 | 329 | // Set position 330 | line.x = x; 331 | line.y = y; 332 | 333 | // Select and focus 334 | selectAndFocusNodes(line); 335 | 336 | return line; 337 | } 338 | 339 | /** 340 | * Create a simple arc (partial ellipse) 341 | * @param x X coordinate 342 | * @param y Y coordinate 343 | * @param width Width of ellipse 344 | * @param height Height of ellipse 345 | * @param startAngle Starting angle in degrees 346 | * @param endAngle Ending angle in degrees 347 | * @param innerRadius Inner radius ratio (0-1) for donut shapes 348 | * @param color Fill color as hex string 349 | * @returns Created ellipse node as an arc 350 | */ 351 | export function createArc( 352 | x: number, 353 | y: number, 354 | width: number, 355 | height: number, 356 | startAngle: number, 357 | endAngle: number, 358 | innerRadius: number = 0, 359 | color: string 360 | ): EllipseNode { 361 | // Use the data-driven function 362 | const arc = createEllipseFromData({ 363 | width, 364 | height, 365 | fill: color, 366 | arcData: { 367 | startingAngle: startAngle, 368 | endingAngle: endAngle, 369 | innerRadius: innerRadius 370 | } 371 | }); 372 | 373 | // Set position 374 | arc.x = x; 375 | arc.y = y; 376 | 377 | // Select and focus 378 | selectAndFocusNodes(arc); 379 | 380 | return arc; 381 | } 382 | 383 | /** 384 | * Create a vector from data 385 | * @param data Vector configuration data 386 | * @returns Created vector node 387 | */ 388 | export function createVectorFromData(data: any): VectorNode { 389 | const vector = figma.createVector(); 390 | 391 | try { 392 | // Resize the vector 393 | vector.resize(data.width || 100, data.height || 100); 394 | 395 | // Set vector-specific properties 396 | if (data.vectorNetwork) { 397 | vector.vectorNetwork = data.vectorNetwork; 398 | } 399 | 400 | if (data.vectorPaths) { 401 | vector.vectorPaths = data.vectorPaths; 402 | } 403 | 404 | if (data.handleMirroring) { 405 | vector.handleMirroring = data.handleMirroring; 406 | } 407 | 408 | // Fill 409 | if (data.fills) { 410 | vector.fills = data.fills; 411 | } else if (data.fill) { 412 | if (typeof data.fill === "string") { 413 | vector.fills = [createSolidPaint(data.fill)]; 414 | } else { 415 | vector.fills = [data.fill]; 416 | } 417 | } else if (data.color) { 418 | // For consistency with other shape creation functions 419 | vector.fills = [createSolidPaint(data.color)]; 420 | } 421 | 422 | // Stroke 423 | if (data.strokes) vector.strokes = data.strokes; 424 | if (data.strokeWeight !== undefined) vector.strokeWeight = data.strokeWeight; 425 | if (data.strokeAlign) vector.strokeAlign = data.strokeAlign; 426 | if (data.strokeCap) vector.strokeCap = data.strokeCap; 427 | if (data.strokeJoin) vector.strokeJoin = data.strokeJoin; 428 | if (data.dashPattern) vector.dashPattern = data.dashPattern; 429 | if (data.strokeMiterLimit) vector.strokeMiterLimit = data.strokeMiterLimit; 430 | 431 | // Corner properties 432 | if (data.cornerRadius !== undefined) vector.cornerRadius = data.cornerRadius; 433 | if (data.cornerSmoothing !== undefined) vector.cornerSmoothing = data.cornerSmoothing; 434 | 435 | // Blend properties 436 | if (data.opacity !== undefined) vector.opacity = data.opacity; 437 | if (data.blendMode) vector.blendMode = data.blendMode; 438 | if (data.isMask !== undefined) vector.isMask = data.isMask; 439 | if (data.effects) vector.effects = data.effects; 440 | 441 | // Layout properties 442 | if (data.constraints) vector.constraints = data.constraints; 443 | if (data.layoutAlign) vector.layoutAlign = data.layoutAlign; 444 | if (data.layoutGrow !== undefined) vector.layoutGrow = data.layoutGrow; 445 | if (data.layoutPositioning) vector.layoutPositioning = data.layoutPositioning; 446 | if (data.rotation !== undefined) vector.rotation = data.rotation; 447 | if (data.layoutSizingHorizontal) vector.layoutSizingHorizontal = data.layoutSizingHorizontal; 448 | if (data.layoutSizingVertical) vector.layoutSizingVertical = data.layoutSizingVertical; 449 | 450 | console.log("Vector created successfully:", vector); 451 | } catch (error) { 452 | console.error("Error creating vector:", error); 453 | } 454 | 455 | return vector; 456 | } 457 | 458 | /** 459 | * Create a simple vector 460 | * @param x X coordinate 461 | * @param y Y coordinate 462 | * @param width Width of vector 463 | * @param height Height of vector 464 | * @param color Fill color as hex string 465 | * @returns Created vector node 466 | */ 467 | export function createVector( 468 | x: number, 469 | y: number, 470 | width: number, 471 | height: number, 472 | color: string 473 | ): VectorNode { 474 | // Use the data-driven function 475 | const vector = createVectorFromData({ 476 | width, 477 | height, 478 | fill: color 479 | }); 480 | 481 | // Set position 482 | vector.x = x; 483 | vector.y = y; 484 | 485 | // Select and focus 486 | selectAndFocusNodes(vector); 487 | 488 | return vector; 489 | } 490 | ``` -------------------------------------------------------------------------------- /src/plugin/ui.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <style> 5 | body { 6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 7 | Helvetica, Arial, sans-serif; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | .container { 12 | padding: 20px; 13 | } 14 | h2 { 15 | font-size: 16px; 16 | margin-bottom: 15px; 17 | } 18 | .control-group { 19 | margin-bottom: 15px; 20 | } 21 | label { 22 | display: block; 23 | margin-bottom: 5px; 24 | font-size: 12px; 25 | } 26 | input { 27 | width: 100%; 28 | padding: 6px; 29 | margin-bottom: 10px; 30 | box-sizing: border-box; 31 | border: 1px solid #ccc; 32 | border-radius: 4px; 33 | } 34 | .button-group { 35 | display: flex; 36 | gap: 8px; 37 | margin-top: 15px; 38 | } 39 | button { 40 | background-color: #18a0fb; 41 | color: white; 42 | border: none; 43 | padding: 8px 12px; 44 | border-radius: 6px; 45 | cursor: pointer; 46 | flex: 1; 47 | font-size: 12px; 48 | } 49 | button:hover { 50 | background-color: #0d8ee3; 51 | } 52 | button.cancel { 53 | background-color: #f24822; 54 | } 55 | button.cancel:hover { 56 | background-color: #d83b17; 57 | } 58 | .connection-status { 59 | margin-top: 15px; 60 | padding: 8px; 61 | border-radius: 4px; 62 | font-size: 12px; 63 | text-align: center; 64 | } 65 | .status-connected { 66 | background-color: #ecfdf5; 67 | color: #047857; 68 | } 69 | .status-disconnected { 70 | background-color: #fef2f2; 71 | color: #b91c1c; 72 | } 73 | .status-connecting { 74 | background-color: #fef3c7; 75 | color: #92400e; 76 | } 77 | .mcp-section { 78 | margin-top: 20px; 79 | padding-top: 15px; 80 | border-top: 1px solid #e5e7eb; 81 | } 82 | .log-area { 83 | margin-top: 10px; 84 | height: 100px; 85 | overflow-y: auto; 86 | border: 1px solid #ccc; 87 | border-radius: 4px; 88 | padding: 8px; 89 | font-size: 11px; 90 | font-family: monospace; 91 | background-color: #f9fafb; 92 | } 93 | .log-item { 94 | margin-bottom: 4px; 95 | } 96 | .server-input { 97 | display: flex; 98 | gap: 8px; 99 | margin-bottom: 10px; 100 | } 101 | .server-input input { 102 | flex: 1; 103 | } 104 | .checkbox-group { 105 | display: flex; 106 | align-items: center; 107 | margin-bottom: 10px; 108 | } 109 | .checkbox-group input[type="checkbox"] { 110 | width: auto; 111 | margin-right: 8px; 112 | } 113 | .checkbox-group label { 114 | display: inline; 115 | font-size: 12px; 116 | } 117 | </style> 118 | </head> 119 | <body> 120 | <div class="container"> 121 | <h2>Figma MCP 画布操作工具</h2> 122 | 123 | <div class="control-group"> 124 | <label for="x">X 位置:</label> 125 | <input type="number" id="x" value="100" /> 126 | 127 | <label for="y">Y 位置:</label> 128 | <input type="number" id="y" value="100" /> 129 | </div> 130 | 131 | <div class="control-group"> 132 | <label for="width">宽度:</label> 133 | <input type="number" id="width" value="150" /> 134 | 135 | <label for="height">高度:</label> 136 | <input type="number" id="height" value="150" /> 137 | </div> 138 | 139 | <div class="control-group"> 140 | <label for="color">颜色:</label> 141 | <input type="color" id="color" value="#ff0000" /> 142 | </div> 143 | 144 | <div class="control-group"> 145 | <label for="text">文本:</label> 146 | <input type="text" id="text" value="Hello Figma!" /> 147 | 148 | <label for="fontSize">字体大小:</label> 149 | <input type="number" id="fontSize" value="24" /> 150 | </div> 151 | 152 | <div class="button-group"> 153 | <button id="create-rectangle">矩形</button> 154 | <button id="create-circle">圆形</button> 155 | <button id="create-text">文本</button> 156 | </div> 157 | 158 | <div class="mcp-section"> 159 | <h2>MCP 连接</h2> 160 | <div class="server-input"> 161 | <input 162 | type="text" 163 | id="server-url" 164 | value="ws://localhost:3001/ws" 165 | placeholder="输入 MCP 服务器 WebSocket URL" 166 | /> 167 | <button id="connect-button">连接</button> 168 | </div> 169 | 170 | <div class="checkbox-group"> 171 | <input type="checkbox" id="auto-reconnect" checked /> 172 | <label for="auto-reconnect">自动重连</label> 173 | </div> 174 | 175 | <div 176 | id="connection-status" 177 | class="connection-status status-disconnected" 178 | > 179 | 未连接到 MCP 180 | </div> 181 | 182 | <div class="log-area" id="log-area"> 183 | <div class="log-item">等待 MCP 连接和命令...</div> 184 | </div> 185 | </div> 186 | 187 | <div class="button-group"> 188 | <button class="cancel" id="cancel">关闭</button> 189 | </div> 190 | </div> 191 | 192 | <script> 193 | // 获取所有输入元素 194 | const xInput = document.getElementById("x"); 195 | const yInput = document.getElementById("y"); 196 | const widthInput = document.getElementById("width"); 197 | const heightInput = document.getElementById("height"); 198 | const colorInput = document.getElementById("color"); 199 | const textInput = document.getElementById("text"); 200 | const fontSizeInput = document.getElementById("fontSize"); 201 | const connectionStatus = document.getElementById("connection-status"); 202 | const logArea = document.getElementById("log-area"); 203 | const serverUrlInput = document.getElementById("server-url"); 204 | const connectButton = document.getElementById("connect-button"); 205 | const autoReconnectCheckbox = document.getElementById("auto-reconnect"); 206 | 207 | // MCP 连接状态和 WebSocket 对象 208 | let mcpConnected = false; 209 | let ws = null; 210 | let isConnecting = false; 211 | let isManualDisconnect = false; 212 | let retryCount = 0; 213 | let maxRetries = 10; 214 | let reconnectTimer = null; 215 | 216 | // 添加按钮事件监听器 217 | document.getElementById("create-rectangle").onclick = () => { 218 | parent.postMessage( 219 | { 220 | pluginMessage: { 221 | type: "create-rectangle", 222 | x: parseInt(xInput.value), 223 | y: parseInt(yInput.value), 224 | width: parseInt(widthInput.value), 225 | height: parseInt(heightInput.value), 226 | color: colorInput.value, 227 | }, 228 | }, 229 | "*" 230 | ); 231 | }; 232 | 233 | document.getElementById("create-circle").onclick = () => { 234 | parent.postMessage( 235 | { 236 | pluginMessage: { 237 | type: "create-circle", 238 | x: parseInt(xInput.value), 239 | y: parseInt(yInput.value), 240 | width: parseInt(widthInput.value), 241 | height: parseInt(heightInput.value), 242 | color: colorInput.value, 243 | }, 244 | }, 245 | "*" 246 | ); 247 | }; 248 | 249 | document.getElementById("create-text").onclick = () => { 250 | parent.postMessage( 251 | { 252 | pluginMessage: { 253 | type: "create-text", 254 | x: parseInt(xInput.value), 255 | y: parseInt(yInput.value), 256 | text: textInput.value, 257 | fontSize: parseInt(fontSizeInput.value), 258 | }, 259 | }, 260 | "*" 261 | ); 262 | }; 263 | 264 | document.getElementById("cancel").onclick = () => { 265 | parent.postMessage( 266 | { 267 | pluginMessage: { type: "cancel" }, 268 | }, 269 | "*" 270 | ); 271 | }; 272 | 273 | // 添加日志条目 274 | function addLogEntry(message) { 275 | const logItem = document.createElement("div"); 276 | logItem.classList.add("log-item"); 277 | logItem.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; 278 | logArea.appendChild(logItem); 279 | logArea.scrollTop = logArea.scrollHeight; 280 | } 281 | 282 | // 设置 MCP 连接状态 283 | function setMcpConnectionStatus(status) { 284 | if (status === "connected") { 285 | mcpConnected = true; 286 | isConnecting = false; 287 | connectionStatus.className = "connection-status status-connected"; 288 | connectionStatus.textContent = "已连接到 MCP"; 289 | connectButton.textContent = "断开"; 290 | addLogEntry("MCP 已连接"); 291 | retryCount = 0; 292 | } else if (status === "connecting") { 293 | mcpConnected = false; 294 | isConnecting = true; 295 | connectionStatus.className = "connection-status status-connecting"; 296 | connectionStatus.textContent = `正在连接 MCP (尝试 ${ 297 | retryCount + 1 298 | }/${maxRetries})`; 299 | connectButton.textContent = "取消"; 300 | addLogEntry(`尝试连接 MCP (${retryCount + 1}/${maxRetries})...`); 301 | } else { 302 | // disconnected 303 | mcpConnected = false; 304 | isConnecting = false; 305 | connectionStatus.className = "connection-status status-disconnected"; 306 | connectionStatus.textContent = "未连接到 MCP"; 307 | connectButton.textContent = "连接"; 308 | addLogEntry("MCP 已断开连接"); 309 | } 310 | } 311 | 312 | // 计算重连延迟,使用指数退避策略 313 | function getReconnectDelay() { 314 | // 1秒, 2秒, 4秒, 8秒... 315 | return Math.min(1000 * Math.pow(2, retryCount), 30000); 316 | } 317 | 318 | // 清除所有重连定时器 319 | function clearReconnectTimer() { 320 | if (reconnectTimer) { 321 | clearTimeout(reconnectTimer); 322 | reconnectTimer = null; 323 | } 324 | } 325 | 326 | // 重置连接状态 327 | function resetConnectionState() { 328 | isConnecting = false; 329 | isManualDisconnect = false; 330 | clearReconnectTimer(); 331 | 332 | if (ws) { 333 | try { 334 | ws.close(); 335 | } catch (e) { 336 | // 忽略关闭错误 337 | } 338 | ws = null; 339 | } 340 | } 341 | 342 | // 尝试重连 343 | function attemptReconnect() { 344 | if ( 345 | isManualDisconnect || 346 | !autoReconnectCheckbox.checked || 347 | retryCount >= maxRetries 348 | ) { 349 | if (retryCount >= maxRetries) { 350 | addLogEntry(`已达到最大重试次数 (${maxRetries}),停止重连`); 351 | } 352 | setMcpConnectionStatus("disconnected"); 353 | retryCount = 0; 354 | return; 355 | } 356 | 357 | retryCount++; 358 | setMcpConnectionStatus("connecting"); 359 | 360 | const delay = getReconnectDelay(); 361 | addLogEntry(`将在 ${delay / 1000}秒后重新连接...`); 362 | 363 | clearReconnectTimer(); 364 | reconnectTimer = setTimeout(() => { 365 | connectToMcp(); 366 | }, delay); 367 | } 368 | 369 | // 连接到 MCP 服务器 370 | function connectToMcp() { 371 | // 如果已经连接或正在连接,返回 372 | if (mcpConnected) { 373 | // 如果已连接,则断开 374 | isManualDisconnect = true; 375 | resetConnectionState(); 376 | setMcpConnectionStatus("disconnected"); 377 | return; 378 | } 379 | 380 | // 如果正在尝试连接,则取消 381 | if (isConnecting) { 382 | isManualDisconnect = true; 383 | resetConnectionState(); 384 | setMcpConnectionStatus("disconnected"); 385 | return; 386 | } 387 | 388 | // 清除之前的连接 389 | resetConnectionState(); 390 | isManualDisconnect = false; 391 | 392 | const serverUrl = serverUrlInput.value.trim(); 393 | if (!serverUrl) { 394 | addLogEntry("错误: 服务器 URL 不能为空"); 395 | return; 396 | } 397 | 398 | try { 399 | setMcpConnectionStatus("connecting"); 400 | 401 | // 创建 WebSocket 连接 402 | ws = new WebSocket(serverUrl); 403 | 404 | ws.onopen = function () { 405 | setMcpConnectionStatus("connected"); 406 | 407 | // 发送初始化消息 408 | ws.send( 409 | JSON.stringify({ 410 | type: "figma-plugin-connected", 411 | pluginId: "figma-mcp-canvas-tools", 412 | }) 413 | ); 414 | }; 415 | 416 | ws.onmessage = function (event) { 417 | try { 418 | const message = JSON.parse(event.data); 419 | addLogEntry(`收到 MCP 命令: ${message.command}`); 420 | 421 | // 转发给插件代码 422 | parent.postMessage( 423 | { 424 | pluginMessage: { 425 | type: "mcp-command", 426 | command: message.command, 427 | params: message.params || {}, 428 | }, 429 | }, 430 | "*" 431 | ); 432 | } catch (error) { 433 | addLogEntry(`解析消息错误: ${error.message}`); 434 | } 435 | }; 436 | 437 | ws.onclose = function () { 438 | // 只有在不是手动断开连接的情况下才尝试重连 439 | if (!isManualDisconnect) { 440 | addLogEntry("与 MCP 服务器的连接已关闭"); 441 | attemptReconnect(); 442 | } else { 443 | setMcpConnectionStatus("disconnected"); 444 | } 445 | ws = null; 446 | }; 447 | 448 | ws.onerror = function (error) { 449 | addLogEntry(`WebSocket 错误: ${error.message || "未知错误"}`); 450 | // 错误会触发关闭事件,关闭事件会处理重连 451 | }; 452 | } catch (error) { 453 | addLogEntry(`连接错误: ${error.message}`); 454 | attemptReconnect(); 455 | } 456 | } 457 | 458 | // 连接按钮点击事件 459 | connectButton.addEventListener("click", connectToMcp); 460 | 461 | // 自动重连选项变更 462 | autoReconnectCheckbox.addEventListener("change", function () { 463 | if (this.checked) { 464 | addLogEntry("自动重连已启用"); 465 | // 如果目前未连接并且不是手动断开,尝试立即连接 466 | if (!mcpConnected && !isManualDisconnect && !isConnecting) { 467 | retryCount = 0; 468 | connectToMcp(); 469 | } 470 | } else { 471 | addLogEntry("自动重连已禁用"); 472 | isManualDisconnect = true; 473 | clearReconnectTimer(); 474 | } 475 | }); 476 | 477 | // 监听来自插件代码的消息 478 | window.addEventListener("message", (event) => { 479 | const message = event.data.pluginMessage; 480 | console.log("Received message from plugin:", message); 481 | // 处理来自插件代码的消息 482 | if (message && message.type === "mcp-response") { 483 | addLogEntry( 484 | `命令 ${message.command} ${ 485 | message.success ? "成功执行" : "执行失败" 486 | }` 487 | ); 488 | 489 | // 如果连接了 MCP 服务器,则将响应发送给服务器 490 | if (ws && ws.readyState === WebSocket.OPEN) { 491 | ws.send( 492 | JSON.stringify({ 493 | type: "figma-plugin-response", 494 | success: message.success, 495 | command: message.command, 496 | result: message.result, 497 | error: message.error, 498 | }) 499 | ); 500 | } 501 | } 502 | }); 503 | 504 | // 页面加载后自动尝试连接 505 | window.addEventListener("load", () => { 506 | if (autoReconnectCheckbox.checked) { 507 | // 小延迟后开始连接,给 UI 渲染一些时间 508 | setTimeout(() => { 509 | connectToMcp(); 510 | }, 1000); 511 | } 512 | }); 513 | </script> 514 | </body> 515 | </html> 516 | ``` -------------------------------------------------------------------------------- /src/tools/canvas.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Canvas Tools - MCP server tools for interacting with Figma canvas elements 3 | */ 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | import { 6 | isPluginConnected, 7 | sendCommandToPlugin, 8 | } from "../services/websocket.js"; 9 | import { logError } from "../utils.js"; 10 | import { 11 | arcParams, 12 | elementParams, 13 | elementsParams, 14 | ellipseParams, 15 | lineParams, 16 | polygonParams, 17 | rectangleParams, 18 | starParams, 19 | textParams, 20 | vectorParams, 21 | } from "./zod-schemas.js"; 22 | 23 | /** 24 | * Register canvas-related tools with the MCP server 25 | * @param server The MCP server instance 26 | */ 27 | export function registerCanvasTools(server: McpServer) { 28 | // Create a rectangle in Figma 29 | server.tool("create_rectangle", rectangleParams, async (params) => { 30 | try { 31 | // Send command to Figma plugin 32 | const response = await sendCommandToPlugin( 33 | "create-rectangle", 34 | params 35 | ).catch((error: Error) => { 36 | throw error; 37 | }); 38 | 39 | if (!response.success) { 40 | throw new Error(response.error || "Unknown error"); 41 | } 42 | 43 | return { 44 | content: [ 45 | { type: "text", text: `# Rectangle Created Successfully` }, 46 | { 47 | type: "text", 48 | text: `A new rectangle has been created in your Figma canvas.`, 49 | }, 50 | { 51 | type: "text", 52 | text: `- Position: (${params.x}, ${params.y})\n- Size: ${params.width}×${params.height}px\n- Color: ${params.color}`, 53 | }, 54 | { 55 | type: "text", 56 | text: 57 | response.result && response.result.id 58 | ? `Node ID: ${response.result.id}` 59 | : `Creation successful`, 60 | }, 61 | ], 62 | }; 63 | } catch (error: unknown) { 64 | logError("Error creating rectangle in Figma", error); 65 | return { 66 | content: [ 67 | { 68 | type: "text", 69 | text: `Error creating rectangle: ${ 70 | error instanceof Error ? error.message : "Unknown error" 71 | }`, 72 | }, 73 | { 74 | type: "text", 75 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 76 | }, 77 | ], 78 | }; 79 | } 80 | }); 81 | 82 | // Create a circle in Figma 83 | server.tool("create_circle", ellipseParams, async (params) => { 84 | try { 85 | // Send command to Figma plugin 86 | const response = await sendCommandToPlugin("create-circle", params).catch( 87 | (error: Error) => { 88 | throw error; 89 | } 90 | ); 91 | 92 | if (!response.success) { 93 | throw new Error(response.error || "Unknown error"); 94 | } 95 | 96 | return { 97 | content: [ 98 | { type: "text", text: `# Circle Created Successfully` }, 99 | { 100 | type: "text", 101 | text: `A new circle has been created in your Figma canvas.`, 102 | }, 103 | { 104 | type: "text", 105 | text: `- Position: (${params.x}, ${params.y})\n- Size: ${params.width}×${params.height}px\n- Color: ${params.color}`, 106 | }, 107 | { 108 | type: "text", 109 | text: 110 | response.result && response.result.id 111 | ? `Node ID: ${response.result.id}` 112 | : `Creation successful`, 113 | }, 114 | ], 115 | }; 116 | } catch (error: unknown) { 117 | logError("Error creating circle in Figma", error); 118 | return { 119 | content: [ 120 | { 121 | type: "text", 122 | text: `Error creating circle: ${ 123 | error instanceof Error ? error.message : "Unknown error" 124 | }`, 125 | }, 126 | { 127 | type: "text", 128 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 129 | }, 130 | ], 131 | }; 132 | } 133 | }); 134 | 135 | // Create an arc (partial ellipse) in Figma 136 | server.tool("create_arc", arcParams, async (params) => { 137 | try { 138 | // Prepare parameters for the plugin 139 | const arcParams = { 140 | ...params, 141 | startAngle: params.startAngle, 142 | endAngle: params.endAngle, 143 | innerRadius: params.innerRadius, 144 | }; 145 | 146 | // Send command to Figma plugin 147 | const response = await sendCommandToPlugin("create-arc", arcParams).catch( 148 | (error: Error) => { 149 | throw error; 150 | } 151 | ); 152 | 153 | if (!response.success) { 154 | throw new Error(response.error || "Unknown error"); 155 | } 156 | 157 | return { 158 | content: [ 159 | { type: "text", text: `# Arc Created Successfully` }, 160 | { 161 | type: "text", 162 | text: `A new arc has been created in your Figma canvas.`, 163 | }, 164 | { 165 | type: "text", 166 | text: `- Position: (${params.x}, ${params.y})\n- Size: ${params.width}×${params.height}px\n- Angles: ${params.startAngle}° to ${params.endAngle}°\n- Inner radius: ${params.innerRadius}\n- Color: ${params.color}`, 167 | }, 168 | { 169 | type: "text", 170 | text: 171 | response.result && response.result.id 172 | ? `Node ID: ${response.result.id}` 173 | : `Creation successful`, 174 | }, 175 | ], 176 | }; 177 | } catch (error: unknown) { 178 | logError("Error creating arc in Figma", error); 179 | return { 180 | content: [ 181 | { 182 | type: "text", 183 | text: `Error creating arc: ${ 184 | error instanceof Error ? error.message : "Unknown error" 185 | }`, 186 | }, 187 | { 188 | type: "text", 189 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 190 | }, 191 | ], 192 | }; 193 | } 194 | }); 195 | 196 | // Create a polygon in Figma 197 | server.tool("create_polygon", polygonParams, async (params) => { 198 | try { 199 | // Prepare parameters for the plugin 200 | const polygonParams = { 201 | ...params, 202 | pointCount: params.pointCount, 203 | }; 204 | 205 | // Send command to Figma plugin 206 | const response = await sendCommandToPlugin( 207 | "create-polygon", 208 | polygonParams 209 | ).catch((error: Error) => { 210 | throw error; 211 | }); 212 | 213 | if (!response.success) { 214 | throw new Error(response.error || "Unknown error"); 215 | } 216 | 217 | return { 218 | content: [ 219 | { type: "text", text: `# Polygon Created Successfully` }, 220 | { 221 | type: "text", 222 | text: `A new polygon has been created in your Figma canvas.`, 223 | }, 224 | { 225 | type: "text", 226 | text: `- Position: (${params.x}, ${params.y})\n- Size: ${params.width}×${params.height}px\n- Sides: ${params.pointCount}\n- Color: ${params.color}`, 227 | }, 228 | { 229 | type: "text", 230 | text: 231 | response.result && response.result.id 232 | ? `Node ID: ${response.result.id}` 233 | : `Creation successful`, 234 | }, 235 | ], 236 | }; 237 | } catch (error: unknown) { 238 | logError("Error creating polygon in Figma", error); 239 | return { 240 | content: [ 241 | { 242 | type: "text", 243 | text: `Error creating polygon: ${ 244 | error instanceof Error ? error.message : "Unknown error" 245 | }`, 246 | }, 247 | { 248 | type: "text", 249 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 250 | }, 251 | ], 252 | }; 253 | } 254 | }); 255 | 256 | // Create a star in Figma 257 | server.tool("create_star", starParams, async (params) => { 258 | try { 259 | // Prepare parameters for the plugin 260 | const starParams = { 261 | ...params, 262 | pointCount: params.pointCount, 263 | innerRadius: params.innerRadius, 264 | }; 265 | 266 | // Send command to Figma plugin 267 | const response = await sendCommandToPlugin( 268 | "create-star", 269 | starParams 270 | ).catch((error: Error) => { 271 | throw error; 272 | }); 273 | 274 | if (!response.success) { 275 | throw new Error(response.error || "Unknown error"); 276 | } 277 | 278 | return { 279 | content: [ 280 | { type: "text", text: `# Star Created Successfully` }, 281 | { 282 | type: "text", 283 | text: `A new star has been created in your Figma canvas.`, 284 | }, 285 | { 286 | type: "text", 287 | text: `- Position: (${params.x}, ${params.y})\n- Size: ${params.width}×${params.height}px\n- Points: ${params.pointCount}\n- Inner Radius: ${params.innerRadius}\n- Color: ${params.color}`, 288 | }, 289 | { 290 | type: "text", 291 | text: 292 | response.result && response.result.id 293 | ? `Node ID: ${response.result.id}` 294 | : `Creation successful`, 295 | }, 296 | ], 297 | }; 298 | } catch (error: unknown) { 299 | logError("Error creating star in Figma", error); 300 | return { 301 | content: [ 302 | { 303 | type: "text", 304 | text: `Error creating star: ${ 305 | error instanceof Error ? error.message : "Unknown error" 306 | }`, 307 | }, 308 | { 309 | type: "text", 310 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 311 | }, 312 | ], 313 | }; 314 | } 315 | }); 316 | 317 | // Create a vector in Figma 318 | server.tool("create_vector", vectorParams, async (params) => { 319 | try { 320 | // Prepare parameters for the plugin 321 | const vectorParams = { 322 | ...params, 323 | vectorNetwork: params.vectorNetwork, 324 | vectorPaths: params.vectorPaths, 325 | handleMirroring: params.handleMirroring, 326 | }; 327 | 328 | // Send command to Figma plugin 329 | const response = await sendCommandToPlugin( 330 | "create-vector", 331 | vectorParams 332 | ).catch((error: Error) => { 333 | throw error; 334 | }); 335 | 336 | if (!response.success) { 337 | throw new Error(response.error || "Unknown error"); 338 | } 339 | 340 | return { 341 | content: [ 342 | { type: "text", text: `# Vector Created Successfully` }, 343 | { 344 | type: "text", 345 | text: `A new vector has been created in your Figma canvas.`, 346 | }, 347 | { 348 | type: "text", 349 | text: `- Position: (${params.x}, ${params.y})\n- Size: ${params.width}×${params.height}px\n- Color: ${params.color}`, 350 | }, 351 | { 352 | type: "text", 353 | text: 354 | response.result && response.result.id 355 | ? `Node ID: ${response.result.id}` 356 | : `Creation successful`, 357 | }, 358 | ], 359 | }; 360 | } catch (error: unknown) { 361 | logError("Error creating vector in Figma", error); 362 | return { 363 | content: [ 364 | { 365 | type: "text", 366 | text: `Error creating vector: ${ 367 | error instanceof Error ? error.message : "Unknown error" 368 | }`, 369 | }, 370 | { 371 | type: "text", 372 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 373 | }, 374 | ], 375 | }; 376 | } 377 | }); 378 | 379 | // Create a line in Figma 380 | server.tool("create_line", lineParams, async (params) => { 381 | try { 382 | // Send command to Figma plugin 383 | const response = await sendCommandToPlugin("create-line", params).catch( 384 | (error: Error) => { 385 | throw error; 386 | } 387 | ); 388 | 389 | if (!response.success) { 390 | throw new Error(response.error || "Unknown error"); 391 | } 392 | 393 | return { 394 | content: [ 395 | { type: "text", text: `# Line Created Successfully` }, 396 | { 397 | type: "text", 398 | text: `A new line has been created in your Figma canvas.`, 399 | }, 400 | { 401 | type: "text", 402 | text: `- Position: (${params.x}, ${params.y})\n- Length: ${params.width}px\n- Color: ${params.color}`, 403 | }, 404 | { 405 | type: "text", 406 | text: `- Rotation: ${params.rotation || 0}°`, 407 | }, 408 | { 409 | type: "text", 410 | text: 411 | response.result && response.result.id 412 | ? `Node ID: ${response.result.id}` 413 | : `Creation successful`, 414 | }, 415 | ], 416 | }; 417 | } catch (error: unknown) { 418 | logError("Error creating line in Figma", error); 419 | return { 420 | content: [ 421 | { 422 | type: "text", 423 | text: `Error creating line: ${ 424 | error instanceof Error ? error.message : "Unknown error" 425 | }`, 426 | }, 427 | { 428 | type: "text", 429 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 430 | }, 431 | ], 432 | }; 433 | } 434 | }); 435 | 436 | // Create text in Figma 437 | server.tool("create_text", textParams, async (params) => { 438 | try { 439 | // Send command to Figma plugin 440 | const response = await sendCommandToPlugin("create-text", params).catch( 441 | (error: Error) => { 442 | throw error; 443 | } 444 | ); 445 | 446 | if (!response.success) { 447 | throw new Error(response.error || "Unknown error"); 448 | } 449 | 450 | return { 451 | content: [ 452 | { type: "text", text: `# Text Created Successfully` }, 453 | { 454 | type: "text", 455 | text: `New text has been created in your Figma canvas.`, 456 | }, 457 | { 458 | type: "text", 459 | text: `- Position: (${params.x}, ${params.y})\n- Font Size: ${params.fontSize}px\n- Content: "${params.text}"`, 460 | }, 461 | { 462 | type: "text", 463 | text: 464 | response.result && response.result.id 465 | ? `Node ID: ${response.result.id}` 466 | : `Creation successful`, 467 | }, 468 | ], 469 | }; 470 | } catch (error: unknown) { 471 | logError("Error creating text in Figma", error); 472 | return { 473 | content: [ 474 | { 475 | type: "text", 476 | text: `Error creating text: ${ 477 | error instanceof Error ? error.message : "Unknown error" 478 | }`, 479 | }, 480 | { 481 | type: "text", 482 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 483 | }, 484 | ], 485 | }; 486 | } 487 | }); 488 | 489 | // Get current selection in Figma 490 | server.tool("get_selection", {}, async () => { 491 | try { 492 | // Send command to Figma plugin 493 | const response = await sendCommandToPlugin("get-selection", {}).catch( 494 | (error: Error) => { 495 | throw error; 496 | } 497 | ); 498 | 499 | if (!response.success) { 500 | throw new Error(response.error || "Unknown error"); 501 | } 502 | 503 | return { 504 | content: [ 505 | { type: "text", text: `# Current Selection` }, 506 | { 507 | type: "text", 508 | text: `Information about currently selected elements in Figma:`, 509 | }, 510 | { 511 | type: "text", 512 | text: response.result 513 | ? JSON.stringify(response.result, null, 2) 514 | : "No selection information available", 515 | }, 516 | ], 517 | }; 518 | } catch (error: unknown) { 519 | logError("Error getting selection in Figma", error); 520 | return { 521 | content: [ 522 | { 523 | type: "text", 524 | text: `Error getting selection: ${ 525 | error instanceof Error ? error.message : "Unknown error" 526 | }`, 527 | }, 528 | { 529 | type: "text", 530 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 531 | }, 532 | ], 533 | }; 534 | } 535 | }); 536 | 537 | // Check plugin connection status 538 | server.tool("check_connection", {}, async () => { 539 | return { 540 | content: [ 541 | { type: "text", text: `# Figma Plugin Connection Status` }, 542 | { 543 | type: "text", 544 | text: isPluginConnected() 545 | ? `✅ Figma plugin is connected to MCP server` 546 | : `❌ No Figma plugin is currently connected`, 547 | }, 548 | { 549 | type: "text", 550 | text: isPluginConnected() 551 | ? `You can now use MCP tools to interact with the Figma canvas.` 552 | : `Please make sure the Figma plugin is running and connected to the MCP server.`, 553 | }, 554 | ], 555 | }; 556 | }); 557 | 558 | // Get all elements from current page or specified page 559 | server.tool("get_elements", elementsParams, async (params) => { 560 | try { 561 | // Send command to Figma plugin 562 | const response = await sendCommandToPlugin("get-elements", params).catch( 563 | (error: Error) => { 564 | throw error; 565 | } 566 | ); 567 | 568 | if (!response.success) { 569 | throw new Error(response.error || "Unknown error"); 570 | } 571 | 572 | const elements = response.result; 573 | const count = Array.isArray(elements) ? elements.length : 0; 574 | const typeValue = params.type || "ALL"; 575 | const pageName = params.page_id ? `specified page` : "current page"; 576 | 577 | return { 578 | content: [ 579 | { type: "text", text: `# Elements Retrieved` }, 580 | { 581 | type: "text", 582 | text: `Found ${count} element${ 583 | count !== 1 ? "s" : "" 584 | } of type ${typeValue} on ${pageName}.`, 585 | }, 586 | { 587 | type: "text", 588 | text: 589 | count > 0 590 | ? `Element information: ${JSON.stringify(elements, null, 2)}` 591 | : "No elements matched your criteria.", 592 | }, 593 | ], 594 | }; 595 | } catch (error: unknown) { 596 | logError("Error getting elements from Figma", error); 597 | return { 598 | content: [ 599 | { 600 | type: "text", 601 | text: `Error retrieving elements: ${ 602 | error instanceof Error ? error.message : "Unknown error" 603 | }`, 604 | }, 605 | { 606 | type: "text", 607 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 608 | }, 609 | ], 610 | }; 611 | } 612 | }); 613 | 614 | // Get a specific element by ID 615 | server.tool("get_element", elementParams, async (params) => { 616 | try { 617 | // Send command to Figma plugin 618 | const response = await sendCommandToPlugin("get-element", params).catch( 619 | (error: Error) => { 620 | throw error; 621 | } 622 | ); 623 | 624 | if (!response.success) { 625 | throw new Error(response.error || "Unknown error"); 626 | } 627 | 628 | const element = response.result; 629 | const isArray = Array.isArray(element); 630 | const hasChildren = isArray && element.length > 1; 631 | 632 | return { 633 | content: [ 634 | { type: "text", text: `# Element Retrieved` }, 635 | { 636 | type: "text", 637 | text: `Successfully retrieved element with ID: ${params.node_id}`, 638 | }, 639 | { 640 | type: "text", 641 | text: hasChildren 642 | ? `Element and ${element.length - 1} children retrieved.` 643 | : `Element information:`, 644 | }, 645 | { 646 | type: "text", 647 | text: JSON.stringify(element, null, 2), 648 | }, 649 | ], 650 | }; 651 | } catch (error: unknown) { 652 | logError("Error getting element from Figma", error); 653 | return { 654 | content: [ 655 | { 656 | type: "text", 657 | text: `Error retrieving element: ${ 658 | error instanceof Error ? error.message : "Unknown error" 659 | }`, 660 | }, 661 | { 662 | type: "text", 663 | text: `Make sure the Figma plugin is running and connected to the MCP server.`, 664 | }, 665 | ], 666 | }; 667 | } 668 | }); 669 | } 670 | ``` -------------------------------------------------------------------------------- /src/tools/zod-schemas.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Figma Zod Schemas 3 | * Zod schemas for validating Figma API parameters 4 | */ 5 | 6 | import { z } from "zod"; 7 | 8 | // Base types 9 | export const colorSchema = z.object({ 10 | r: z.number().min(0).max(1).describe("Red channel (0-1)"), 11 | g: z.number().min(0).max(1).describe("Green channel (0-1)"), 12 | b: z.number().min(0).max(1).describe("Blue channel (0-1)"), 13 | }); 14 | 15 | // Position and size base params 16 | const positionParams = { 17 | x: z.number().default(0).describe("X position of the element"), 18 | y: z.number().default(0).describe("Y position of the element"), 19 | }; 20 | 21 | const sizeParams = { 22 | width: z.number().min(1).default(100).describe("Width of the element in pixels"), 23 | height: z.number().min(1).default(100).describe("Height of the element in pixels"), 24 | }; 25 | 26 | // Base node properties 27 | const baseNodeParams = { 28 | name: z.string().optional().describe("Name of the node"), 29 | }; 30 | 31 | // Scene node properties 32 | const sceneNodeParams = { 33 | visible: z.boolean().optional().describe("Whether the node is visible"), 34 | locked: z.boolean().optional().describe("Whether the node is locked"), 35 | }; 36 | 37 | // Blend-related properties 38 | const blendParams = { 39 | opacity: z.number().min(0).max(1).optional().describe("Opacity of the node (0-1)"), 40 | blendMode: z.enum([ 41 | "NORMAL", "DARKEN", "MULTIPLY", "LINEAR_BURN", "COLOR_BURN", 42 | "LIGHTEN", "SCREEN", "LINEAR_DODGE", "COLOR_DODGE", 43 | "OVERLAY", "SOFT_LIGHT", "HARD_LIGHT", 44 | "DIFFERENCE", "EXCLUSION", "SUBTRACT", "DIVIDE", 45 | "HUE", "SATURATION", "COLOR", "LUMINOSITY", 46 | "PASS_THROUGH" 47 | ]).optional().describe("Blend mode of the node"), 48 | isMask: z.boolean().optional().describe("Whether this node is a mask"), 49 | maskType: z.enum(["ALPHA", "LUMINANCE"]).optional().describe("Type of masking to use if this node is a mask"), 50 | effects: z.array(z.any()).optional().describe("Array of effects"), 51 | effectStyleId: z.string().optional().describe("The id of the EffectStyle object"), 52 | }; 53 | 54 | // Corner-related properties 55 | const cornerParams = { 56 | cornerRadius: z.number().min(0).optional().describe("Rounds all corners by this amount"), 57 | cornerSmoothing: z.number().min(0).max(1).optional().describe("Corner smoothing between 0 and 1"), 58 | topLeftRadius: z.number().min(0).optional().describe("Top left corner radius override"), 59 | topRightRadius: z.number().min(0).optional().describe("Top right corner radius override"), 60 | bottomLeftRadius: z.number().min(0).optional().describe("Bottom left corner radius override"), 61 | bottomRightRadius: z.number().min(0).optional().describe("Bottom right corner radius override"), 62 | }; 63 | 64 | // Geometry-related properties 65 | const geometryParams = { 66 | fills: z.array(z.any()).optional().describe("The paints used to fill the area of the shape"), 67 | fillStyleId: z.string().optional().describe("The id of the PaintStyle object linked to fills"), 68 | strokes: z.array(z.any()).optional().describe("The paints used to fill the area of the shape's strokes"), 69 | strokeStyleId: z.string().optional().describe("The id of the PaintStyle object linked to strokes"), 70 | strokeWeight: z.number().min(0).optional().describe("The thickness of the stroke, in pixels"), 71 | strokeJoin: z.enum(["MITER", "BEVEL", "ROUND"]).optional().describe("The decoration applied to vertices"), 72 | strokeAlign: z.enum(["CENTER", "INSIDE", "OUTSIDE"]).optional().describe("The alignment of the stroke"), 73 | dashPattern: z.array(z.number().min(0)).optional().describe("Array of numbers for dash pattern"), 74 | strokeCap: z.enum(["NONE", "ROUND", "SQUARE", "ARROW_LINES", "ARROW_EQUILATERAL"]).optional().describe("The decoration applied to vertices"), 75 | strokeMiterLimit: z.number().min(0).optional().describe("The miter limit on the stroke"), 76 | color: z.string().regex(/^#([0-9A-F]{6}|[0-9A-F]{8})$/i).default("#ff0000").describe("Fill color as hex code (#RRGGBB or #RRGGBBAA)"), 77 | }; 78 | 79 | // Individual strokes-related properties 80 | const rectangleStrokeParams = { 81 | strokeTopWeight: z.number().min(0).optional().describe("Top stroke weight"), 82 | strokeBottomWeight: z.number().min(0).optional().describe("Bottom stroke weight"), 83 | strokeLeftWeight: z.number().min(0).optional().describe("Left stroke weight"), 84 | strokeRightWeight: z.number().min(0).optional().describe("Right stroke weight"), 85 | }; 86 | 87 | // Layout-related properties 88 | const layoutParams = { 89 | minWidth: z.number().nullable().optional().describe("Minimum width constraint"), 90 | maxWidth: z.number().nullable().optional().describe("Maximum width constraint"), 91 | minHeight: z.number().nullable().optional().describe("Minimum height constraint"), 92 | maxHeight: z.number().nullable().optional().describe("Maximum height constraint"), 93 | layoutAlign: z.enum(["MIN", "CENTER", "MAX", "STRETCH", "INHERIT"]).optional().describe("Alignment within parent"), 94 | layoutGrow: z.number().min(0).default(0).optional().describe("Stretch along parent's primary axis"), 95 | layoutPositioning: z.enum(["AUTO", "ABSOLUTE"]).optional().describe("Layout positioning mode"), 96 | constrainProportions: z.boolean().optional().describe("Whether to keep proportions when resizing"), 97 | rotation: z.number().min(-180).max(180).optional().describe("Rotation in degrees (-180 to 180)"), 98 | layoutSizingHorizontal: z.enum(["FIXED", "HUG", "FILL"]).optional().describe("Horizontal sizing mode"), 99 | layoutSizingVertical: z.enum(["FIXED", "HUG", "FILL"]).optional().describe("Vertical sizing mode"), 100 | constraints: z.any().optional().describe("Constraints relative to containing frame"), 101 | }; 102 | 103 | // Common properties for all export settings 104 | const commonExportSettingsProps = { 105 | contentsOnly: z.boolean().optional().describe("Whether only the contents of the node are exported. Defaults to true."), 106 | useAbsoluteBounds: z.boolean().optional().describe("Use full dimensions regardless of cropping. Defaults to false."), 107 | suffix: z.string().optional().describe("Suffix appended to the file name when exporting."), 108 | colorProfile: z.enum(["DOCUMENT", "SRGB", "DISPLAY_P3_V4"]).optional().describe("Color profile of the export."), 109 | }; 110 | 111 | // Common SVG export properties 112 | const commonSvgExportProps = { 113 | ...commonExportSettingsProps, 114 | svgOutlineText: z.boolean().optional().describe("Whether text elements are rendered as outlines. Defaults to true."), 115 | svgIdAttribute: z.boolean().optional().describe("Whether to include layer names as ID attributes. Defaults to false."), 116 | svgSimplifyStroke: z.boolean().optional().describe("Whether to simplify inside and outside strokes. Defaults to true."), 117 | }; 118 | 119 | // Export constraints 120 | const exportConstraintsSchema = z.object({ 121 | type: z.enum(["SCALE", "WIDTH", "HEIGHT"]).describe("Type of constraint for the export"), 122 | value: z.number().positive().describe("Value for the constraint") 123 | }); 124 | 125 | // Export Settings Image (JPG/PNG) 126 | const exportSettingsImageSchema = z.object({ 127 | format: z.enum(["JPG", "PNG"]).describe("The export format (JPG or PNG)"), 128 | constraint: exportConstraintsSchema.optional().describe("Constraint on the image size when exporting"), 129 | ...commonExportSettingsProps 130 | }); 131 | 132 | // Export Settings SVG 133 | const exportSettingsSvgSchema = z.object({ 134 | format: z.literal("SVG").describe("The export format (SVG)"), 135 | ...commonSvgExportProps 136 | }); 137 | 138 | // Export Settings SVG String (for exportAsync only) 139 | const exportSettingsSvgStringSchema = z.object({ 140 | format: z.literal("SVG_STRING").describe("The export format (SVG_STRING)"), 141 | ...commonSvgExportProps 142 | }); 143 | 144 | // Export Settings PDF 145 | const exportSettingsPdfSchema = z.object({ 146 | format: z.literal("PDF").describe("The export format (PDF)"), 147 | ...commonExportSettingsProps 148 | }); 149 | 150 | // Export Settings REST 151 | const exportSettingsRestSchema = z.object({ 152 | format: z.literal("JSON_REST_V1").describe("The export format (JSON_REST_V1)"), 153 | ...commonExportSettingsProps 154 | }); 155 | 156 | // Combined Export Settings type 157 | const exportSettingsSchema = z.discriminatedUnion("format", [ 158 | exportSettingsImageSchema, 159 | exportSettingsSvgSchema, 160 | exportSettingsSvgStringSchema, 161 | exportSettingsPdfSchema, 162 | exportSettingsRestSchema 163 | ]); 164 | 165 | // Export-related properties 166 | const exportParams = { 167 | exportSettings: z.array(exportSettingsSchema).optional().describe("Export settings stored on the node"), 168 | }; 169 | 170 | // Prototyping - Trigger and Action types 171 | const triggerSchema = z.enum([ 172 | "ON_CLICK", 173 | "ON_HOVER", 174 | "ON_PRESS", 175 | "ON_DRAG", 176 | "AFTER_TIMEOUT", 177 | "MOUSE_ENTER", 178 | "MOUSE_LEAVE", 179 | "MOUSE_UP", 180 | "MOUSE_DOWN", 181 | "ON_KEY_DOWN" 182 | ]).nullable().describe("The trigger that initiates the prototype interaction"); 183 | 184 | // Action represents what happens when a trigger is activated 185 | const actionSchema = z.object({ 186 | type: z.enum([ 187 | "BACK", 188 | "CLOSE", 189 | "URL", 190 | "NODE", 191 | "SWAP", 192 | "OVERLAY", 193 | "SCROLL_TO", 194 | "OPEN_LINK" 195 | ]).describe("The type of action to perform"), 196 | url: z.string().optional().describe("URL to navigate to if action type is URL"), 197 | nodeID: z.string().optional().describe("ID of the node if action type is NODE"), 198 | destinationID: z.string().optional().describe("Destination node ID"), 199 | navigation: z.enum(["NAVIGATE", "SWAP", "OVERLAY", "SCROLL_TO"]).optional().describe("Navigation type"), 200 | transitionNode: z.string().optional().describe("ID of the node to use for transition"), 201 | preserveScrollPosition: z.boolean().optional().describe("Whether to preserve scroll position"), 202 | overlayRelativePosition: z.object({ 203 | x: z.number(), 204 | y: z.number() 205 | }).optional().describe("Relative position for overlay"), 206 | // Additional properties can be added as needed based on Figma API 207 | }); 208 | 209 | // Reaction combines a trigger with actions for prototyping 210 | const reactionSchema = z.object({ 211 | action: actionSchema.optional().describe("DEPRECATED: The action triggered by this reaction"), 212 | actions: z.array(actionSchema).optional().describe("The actions triggered by this reaction"), 213 | trigger: triggerSchema.describe("The trigger that initiates this reaction") 214 | }); 215 | 216 | // Reaction properties 217 | const reactionParams = { 218 | reactions: z.array(reactionSchema).optional().describe("List of reactions for prototyping"), 219 | }; 220 | 221 | // Annotation properties 222 | const annotationPropertySchema = z.object({ 223 | type: z.enum([ 224 | "width", 225 | "height", 226 | "fills", 227 | "strokes", 228 | "strokeWeight", 229 | "cornerRadius", 230 | "opacity", 231 | "blendMode", 232 | "effects", 233 | "layoutConstraints", 234 | "padding", 235 | "itemSpacing", 236 | "layoutMode", 237 | "primaryAxisAlignment", 238 | "counterAxisAlignment", 239 | "fontName", 240 | "fontSize", 241 | "letterSpacing", 242 | "lineHeight", 243 | "textCase", 244 | "textDecoration", 245 | "textAlignHorizontal", 246 | "textAlignVertical", 247 | "characters" 248 | ]).describe("The type of property being annotated"), 249 | value: z.any().optional().describe("The value of the property (if applicable)") 250 | }); 251 | 252 | // Annotation schema 253 | const annotationSchema = z.object({ 254 | label: z.string().optional().describe("Text label for the annotation"), 255 | labelMarkdown: z.string().optional().describe("Markdown-formatted text label"), 256 | properties: z.array(annotationPropertySchema).optional().describe("Properties pinned in this annotation"), 257 | categoryId: z.string().optional().describe("ID of the annotation category") 258 | }); 259 | 260 | // Annotation properties 261 | const annotationParams = { 262 | annotations: z.array(annotationSchema).optional().describe("Annotations on the node"), 263 | }; 264 | 265 | // Line parameters (width represents length, height is always 0) 266 | export const lineParams = { 267 | ...positionParams, 268 | width: z.number().min(1).default(100).describe("Length of the line in pixels"), 269 | ...baseNodeParams, 270 | ...sceneNodeParams, 271 | ...blendParams, 272 | ...geometryParams, 273 | ...layoutParams, 274 | ...exportParams, 275 | ...reactionParams, 276 | ...annotationParams, 277 | }; 278 | 279 | // Combined parameters for rectangles 280 | export const rectangleParams = { 281 | ...positionParams, 282 | ...sizeParams, 283 | ...baseNodeParams, 284 | ...sceneNodeParams, 285 | ...blendParams, 286 | ...cornerParams, 287 | ...geometryParams, 288 | ...rectangleStrokeParams, 289 | ...layoutParams, 290 | ...exportParams, 291 | ...reactionParams, 292 | ...annotationParams, 293 | }; 294 | 295 | // Ellipse Arc data for creating arcs and donuts 296 | const arcDataSchema = z.object({ 297 | startingAngle: z.number().describe("Starting angle in degrees from 0 to 360"), 298 | endingAngle: z.number().describe("Ending angle in degrees from 0 to 360"), 299 | innerRadius: z.number().min(0).max(1).describe("Inner radius ratio from 0 to 1") 300 | }); 301 | 302 | // Circle/Ellipse parameters 303 | export const ellipseParams = { 304 | ...positionParams, 305 | ...sizeParams, 306 | ...baseNodeParams, 307 | ...sceneNodeParams, 308 | ...blendParams, 309 | ...geometryParams, 310 | ...layoutParams, 311 | ...exportParams, 312 | ...reactionParams, 313 | ...annotationParams, 314 | arcData: arcDataSchema.optional().describe("Arc data for creating partial ellipses and donuts") 315 | }; 316 | 317 | // Text parameters 318 | export const textParams = { 319 | ...positionParams, 320 | ...baseNodeParams, 321 | ...sceneNodeParams, 322 | ...blendParams, 323 | ...layoutParams, 324 | ...exportParams, 325 | ...reactionParams, 326 | ...annotationParams, 327 | text: z.string().default("Hello Figma!").describe("The text content"), 328 | characters: z.string().optional().describe("Alternative for text content"), 329 | fontSize: z.number().min(1).default(24).describe("The font size in pixels"), 330 | fontFamily: z.string().optional().describe("Font family name"), 331 | fontStyle: z.string().optional().describe("Font style (e.g., 'Regular', 'Bold')"), 332 | fontName: z.object({ 333 | family: z.string().optional().describe("Font family name"), 334 | style: z.string().optional().describe("Font style (e.g., 'Regular', 'Bold')"), 335 | }).optional().describe("Font family and style"), 336 | textAlignHorizontal: z.enum(["LEFT", "CENTER", "RIGHT", "JUSTIFIED"]).optional().describe("Horizontal text alignment"), 337 | textAlignVertical: z.enum(["TOP", "CENTER", "BOTTOM"]).optional().describe("Vertical text alignment"), 338 | textAutoResize: z.enum(["NONE", "WIDTH_AND_HEIGHT", "HEIGHT", "TRUNCATE"]).optional().describe("How text box adjusts to fit characters"), 339 | textTruncation: z.enum(["DISABLED", "ENDING"]).optional().describe("Whether text will truncate with ellipsis"), 340 | maxLines: z.number().nullable().optional().describe("Max number of lines before truncation"), 341 | paragraphIndent: z.number().optional().describe("Indentation of paragraphs"), 342 | paragraphSpacing: z.number().optional().describe("Vertical distance between paragraphs"), 343 | listSpacing: z.number().optional().describe("Vertical distance between lines of a list"), 344 | hangingPunctuation: z.boolean().optional().describe("Whether punctuation hangs outside the text box"), 345 | hangingList: z.boolean().optional().describe("Whether list counters/bullets hang outside the text box"), 346 | autoRename: z.boolean().optional().describe("Whether to update node name based on text content"), 347 | letterSpacing: z.union([ 348 | z.number(), 349 | z.object({ 350 | value: z.number(), 351 | unit: z.enum(["PIXELS", "PERCENT"]) 352 | }) 353 | ]).optional().describe("Letter spacing between characters"), 354 | lineHeight: z.union([ 355 | z.number(), 356 | z.object({ 357 | value: z.number(), 358 | unit: z.enum(["PIXELS", "PERCENT"]) 359 | }) 360 | ]).optional().describe("Line height"), 361 | leadingTrim: z.enum(["NONE", "CAP_HEIGHT", "BOTH"]).optional().describe("Removal of vertical space above/below text glyphs"), 362 | textCase: z.enum(["ORIGINAL", "UPPER", "LOWER", "TITLE"]).optional().describe("Text case transformation"), 363 | textDecoration: z.enum(["NONE", "UNDERLINE", "STRIKETHROUGH"]).optional().describe("Text decoration"), 364 | textDecorationStyle: z.enum(["SOLID", "DASHED", "DOTTED", "WAVY", "DOUBLE"]).optional().describe("Text decoration style"), 365 | textDecorationOffset: z.union([ 366 | z.number(), 367 | z.object({ 368 | value: z.number(), 369 | unit: z.enum(["PIXELS", "PERCENT"]) 370 | }) 371 | ]).optional().describe("Text decoration offset"), 372 | textDecorationThickness: z.union([ 373 | z.number(), 374 | z.object({ 375 | value: z.number(), 376 | unit: z.enum(["PIXELS", "PERCENT"]) 377 | }) 378 | ]).optional().describe("Text decoration thickness"), 379 | textDecorationColor: z.union([ 380 | z.object({ 381 | r: z.number().min(0).max(1), 382 | g: z.number().min(0).max(1), 383 | b: z.number().min(0).max(1), 384 | a: z.number().min(0).max(1).optional() 385 | }), 386 | z.string() 387 | ]).optional().describe("Text decoration color"), 388 | textDecorationSkipInk: z.boolean().optional().describe("Whether text decoration skips descenders"), 389 | textStyleId: z.string().optional().describe("ID of linked TextStyle object"), 390 | hyperlink: z.object({ 391 | type: z.enum(["URL", "NODE"]), 392 | url: z.string().optional(), 393 | nodeID: z.string().optional() 394 | }).nullable().optional().describe("Hyperlink target"), 395 | fill: z.string().optional().describe("Fill color as hex code (shorthand for fills)"), 396 | rangeStyles: z.array( 397 | z.object({ 398 | start: z.number().describe("Start index (inclusive)"), 399 | end: z.number().describe("End index (exclusive)"), 400 | style: z.object({}).passthrough().describe("Style properties to apply to range") 401 | }) 402 | ).optional().describe("Character-level styling for text ranges"), 403 | width: z.number().optional().describe("Width of the text box") 404 | }; 405 | 406 | // Frame parameters 407 | export const frameParams = { 408 | ...positionParams, 409 | ...sizeParams, 410 | ...baseNodeParams, 411 | ...sceneNodeParams, 412 | ...blendParams, 413 | ...cornerParams, 414 | ...geometryParams, 415 | ...layoutParams, 416 | ...exportParams, 417 | ...reactionParams, 418 | ...annotationParams, 419 | itemSpacing: z.number().min(0).optional().describe("Space between children in auto-layout"), 420 | layoutMode: z.enum(["NONE", "HORIZONTAL", "VERTICAL"]).optional().describe("Auto-layout direction"), 421 | primaryAxisSizingMode: z.enum(["FIXED", "AUTO"]).optional().describe("How frame sizes along primary axis"), 422 | counterAxisSizingMode: z.enum(["FIXED", "AUTO"]).optional().describe("How frame sizes along counter axis"), 423 | primaryAxisAlignItems: z.enum(["MIN", "CENTER", "MAX", "SPACE_BETWEEN"]).optional().describe("Alignment along primary axis"), 424 | counterAxisAlignItems: z.enum(["MIN", "CENTER", "MAX"]).optional().describe("Alignment along counter axis"), 425 | paddingLeft: z.number().min(0).optional().describe("Padding on left side"), 426 | paddingRight: z.number().min(0).optional().describe("Padding on right side"), 427 | paddingTop: z.number().min(0).optional().describe("Padding on top side"), 428 | paddingBottom: z.number().min(0).optional().describe("Padding on bottom side"), 429 | }; 430 | 431 | // Arc parameters (based on ellipse parameters with added angle parameters) 432 | export const arcParams = { 433 | ...ellipseParams, 434 | startAngle: z.number().default(0).describe("Starting angle in degrees"), 435 | endAngle: z.number().default(180).describe("Ending angle in degrees"), 436 | innerRadius: z.number().min(0).max(1).default(0).describe("Inner radius ratio (0-1) for donut shapes") 437 | }; 438 | 439 | // Polygon parameters 440 | export const polygonParams = { 441 | ...positionParams, 442 | ...sizeParams, 443 | ...baseNodeParams, 444 | ...sceneNodeParams, 445 | ...blendParams, 446 | ...cornerParams, 447 | ...geometryParams, 448 | ...layoutParams, 449 | ...exportParams, 450 | ...reactionParams, 451 | ...annotationParams, 452 | pointCount: z.number().int().min(3).default(3).describe("Number of sides of the polygon. Must be an integer >= 3.") 453 | }; 454 | 455 | // Star parameters 456 | export const starParams = { 457 | ...positionParams, 458 | ...sizeParams, 459 | ...baseNodeParams, 460 | ...sceneNodeParams, 461 | ...blendParams, 462 | ...geometryParams, 463 | ...layoutParams, 464 | ...exportParams, 465 | ...reactionParams, 466 | ...annotationParams, 467 | pointCount: z.number().int().min(3).default(5).describe("Number of points on the star. Must be an integer >= 3."), 468 | innerRadius: z.number().min(0).max(1).default(0.5).describe("Ratio of inner radius to outer radius (0-1).") 469 | }; 470 | 471 | // Vector parameters for Vector Node 472 | export const vectorParams = { 473 | ...positionParams, 474 | ...sizeParams, 475 | ...baseNodeParams, 476 | ...sceneNodeParams, 477 | ...blendParams, 478 | ...cornerParams, 479 | ...geometryParams, 480 | ...layoutParams, 481 | ...exportParams, 482 | ...reactionParams, 483 | ...annotationParams, 484 | // Vector specific parameters 485 | vectorNetwork: z.any().optional().describe("Complete representation of vectors as a network of edges between vertices"), 486 | vectorPaths: z.any().optional().describe("Simple representation of vectors as paths"), 487 | handleMirroring: z.enum(["NONE", "ANGLE", "ANGLE_AND_LENGTH"]).optional().describe("Whether the vector handles are mirrored or independent") 488 | }; 489 | 490 | export const elementParams = { 491 | node_id: z.string().describe("ID of the element to retrieve"), 492 | include_children: z.boolean().optional().default(false).describe("Whether to include children of the element"), 493 | } 494 | 495 | export const elementsParams = { 496 | type: z.enum([ 497 | "ALL", "RECTANGLE", "ELLIPSE", "POLYGON", "STAR", "VECTOR", 498 | "TEXT", "FRAME", "COMPONENT", "INSTANCE", "BOOLEAN_OPERATION", 499 | "GROUP", "SECTION", "SLICE", "LINE", "CONNECTOR", "SHAPE_WITH_TEXT", 500 | "CODE_BLOCK", "STAMP", "WIDGET", "STICKY", "TABLE", "SECTION", "HIGHLIGHT" 501 | ]).optional().default("ALL").describe("Type of elements to filter (default: ALL)"), 502 | page_id: z.string().optional().describe("ID of page to get elements from (default: current page)"), 503 | limit: z.number().int().min(1).max(1000).optional().default(100).describe("Maximum number of elements to return"), 504 | include_hidden: z.boolean().optional().default(false).describe("Whether to include hidden elements"), 505 | }; 506 | ```