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 |
```