# Directory Structure
```
├── .gitignore
├── bun.lock
├── package-lock.json
├── package.json
├── readme.md
├── scripts
│ └── setup.sh
└── src
├── cursor_mcp_plugin
│ ├── code.js
│ ├── manifest.json
│ ├── setcharacters.js
│ └── ui.html
├── socket.ts
└── talk_to_figma_mcp
├── bun.lock
├── package.json
├── server.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | .cursor/
```
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
```markdown
1 | # Cursor Talk to Figma MCP
2 |
3 | This project implements a Model Context Protocol (MCP) integration between Cursor AI and Figma, allowing Cursor to communicate with Figma for reading designs and modifying them programmatically.
4 |
5 | https://github.com/user-attachments/assets/129a14d2-ed73-470f-9a4c-2240b2a4885c
6 |
7 | ## Project Structure
8 |
9 | - `src/talk_to_figma_mcp/` - TypeScript MCP server for Figma integration
10 | - `src/cursor_mcp_plugin/` - Figma plugin for communicating with Cursor
11 | - `src/socket.ts` - WebSocket server that facilitates communication between the MCP server and Figma plugin
12 |
13 | ## Get Started
14 |
15 | 1. Install Bun if you haven't already:
16 |
17 | ```bash
18 | curl -fsSL https://bun.sh/install | bash
19 | ```
20 |
21 | 2. Run setup, this will also install MCP in your Cursor's active project
22 |
23 | ```bash
24 | bun setup
25 | ```
26 |
27 | 3. Start the Websocket server
28 |
29 | ```bash
30 | bun start
31 | ```
32 |
33 | 4. Install [Figma Plugin](#figma-plugin)
34 |
35 | ## Manual Setup and Installation
36 |
37 | ### MCP Server: Integration with Cursor
38 |
39 | Add the server to your Cursor MCP configuration in `~/.cursor/mcp.json`:
40 |
41 | ```json
42 | {
43 | "mcpServers": {
44 | "TalkToFigma": {
45 | "command": "bun",
46 | "args": [
47 | "/path/to/cursor-talk-to-figma-mcp/src/talk_to_figma_mcp/server.ts"
48 | ]
49 | }
50 | }
51 | }
52 | ```
53 |
54 | ### WebSocket Server
55 |
56 | Start the WebSocket server:
57 |
58 | ```bash
59 | bun run src/socket.ts
60 | ```
61 |
62 | ### Figma Plugin
63 |
64 | 1. In Figma, go to Plugins > Development > New Plugin
65 | 2. Choose "Link existing plugin"
66 | 3. Select the `src/cursor_mcp_plugin/manifest.json` file
67 | 4. The plugin should now be available in your Figma development plugins
68 |
69 | ## Usage
70 |
71 | 1. Start the WebSocket server
72 | 2. Install the MCP server in Cursor
73 | 3. Open Figma and run the Cursor MCP Plugin
74 | 4. Connect the plugin to the WebSocket server by joining a channel using `join_channel`
75 | 5. Use Cursor to communicate with Figma using the MCP tools
76 |
77 | ## MCP Tools
78 |
79 | The MCP server provides the following tools for interacting with Figma:
80 |
81 | ### Document & Selection
82 |
83 | - `get_document_info` - Get information about the current Figma document
84 | - `get_selection` - Get information about the current selection
85 | - `get_node_info` - Get detailed information about a specific node
86 |
87 | ### Creating Elements
88 |
89 | - `create_rectangle` - Create a new rectangle with position, size, and optional name
90 | - `create_frame` - Create a new frame with position, size, and optional name
91 | - `create_text` - Create a new text node with customizable font properties
92 |
93 | ### Styling
94 |
95 | - `set_fill_color` - Set the fill color of a node (RGBA)
96 | - `set_stroke_color` - Set the stroke color and weight of a node
97 | - `set_corner_radius` - Set the corner radius of a node with optional per-corner control
98 |
99 | ### Layout & Organization
100 |
101 | - `move_node` - Move a node to a new position
102 | - `resize_node` - Resize a node with new dimensions
103 | - `delete_node` - Delete a node
104 |
105 | ### Components & Styles
106 |
107 | - `get_styles` - Get information about local styles
108 | - `get_local_components` - Get information about local components
109 | - `get_team_components` - Get information about team components
110 | - `create_component_instance` - Create an instance of a component
111 |
112 | ### Export & Advanced
113 |
114 | - `export_node_as_image` - Export a node as an image (PNG, JPG, SVG, or PDF)
115 | - `execute_figma_code` - Execute arbitrary JavaScript code in Figma (use with caution)
116 |
117 | ### Connection Management
118 |
119 | - `join_channel` - Join a specific channel to communicate with Figma
120 |
121 | ## Development
122 |
123 | ### Building the Figma Plugin
124 |
125 | 1. Navigate to the Figma plugin directory:
126 |
127 | ```
128 | cd src/cursor_mcp_plugin
129 | ```
130 |
131 | 2. Edit code.js and ui.html
132 |
133 | ## Best Practices
134 |
135 | When working with the Figma MCP:
136 |
137 | 1. Always join a channel before sending commands
138 | 2. Get document overview using `get_document_info` first
139 | 3. Check current selection with `get_selection` before modifications
140 | 4. Use appropriate creation tools based on needs:
141 | - `create_frame` for containers
142 | - `create_rectangle` for basic shapes
143 | - `create_text` for text elements
144 | 5. Verify changes using `get_node_info`
145 | 6. Use component instances when possible for consistency
146 | 7. Handle errors appropriately as all commands can throw exceptions
147 |
148 | ## License
149 |
150 | MIT
151 |
```
--------------------------------------------------------------------------------
/scripts/setup.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # Create .cursor directory if it doesn't exist
4 | mkdir -p .cursor
5 |
6 | # Get current directory path
7 | CURRENT_DIR=$(pwd)
8 |
9 | bun install
10 |
11 | # Create mcp.json with the current directory path
12 | echo "{
13 | \"mcpServers\": {
14 | \"TalkToFigma\": {
15 | \"command\": \"bun\",
16 | \"args\": [
17 | \"${CURRENT_DIR}/src/talk_to_figma_mcp/server.ts\"
18 | ]
19 | }
20 | }
21 | }" > .cursor/mcp.json
```
--------------------------------------------------------------------------------
/src/cursor_mcp_plugin/manifest.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "Cursor MCP Plugin",
3 | "id": "cursor-mcp-plugin",
4 | "api": "1.0.0",
5 | "main": "code.js",
6 | "ui": "ui.html",
7 | "editorType": [
8 | "figma",
9 | "figjam"
10 | ],
11 | "permissions": [],
12 | "networkAccess": {
13 | "allowedDomains": [
14 | "https://google.com"
15 | ],
16 | "devAllowedDomains": [
17 | "http://localhost:3056",
18 | "ws://localhost:3056"
19 | ]
20 | },
21 | "documentAccess": "dynamic-page",
22 | "enableProposedApi": true,
23 | "enablePrivatePluginApi": true
24 | }
```
--------------------------------------------------------------------------------
/src/talk_to_figma_mcp/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "outDir": "dist",
11 | "rootDir": ".",
12 | "declaration": true,
13 | "experimentalDecorators": false,
14 | "emitDecoratorMetadata": false,
15 | "lib": ["ESNext", "DOM"]
16 | },
17 | "include": ["./**/*.ts"],
18 | "exclude": ["node_modules", "dist"]
19 | }
20 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "cursor-talk-to-figma-mcp",
3 | "version": "1.0.0",
4 | "description": "Cursor Talk to Figma MCP",
5 | "main": "src/socket.ts",
6 | "scripts": {
7 | "setup": "./scripts/setup.sh",
8 | "start": "bun run src/socket.ts"
9 | },
10 | "dependencies": {
11 | "@modelcontextprotocol/sdk": "latest",
12 | "@types/node": "^22.13.10",
13 | "bun": "^1.2.5",
14 | "uuid": "latest",
15 | "ws": "latest",
16 | "zod": "latest"
17 | },
18 | "devDependencies": {
19 | "@types/bun": "^1.2.5",
20 | "@types/ws": "^8.18.0"
21 | }
22 | }
```
--------------------------------------------------------------------------------
/src/talk_to_figma_mcp/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "talk-to-figma-mcp",
3 | "version": "1.0.0",
4 | "description": "MCP server for Figma integration",
5 | "main": "server.ts",
6 | "type": "module",
7 | "scripts": {
8 | "start": "node --loader ts-node/esm server.ts",
9 | "build": "tsc",
10 | "dev": "node --loader ts-node/esm --watch server.ts"
11 | },
12 | "keywords": [
13 | "figma",
14 | "mcp",
15 | "cursor",
16 | "ai"
17 | ],
18 | "dependencies": {
19 | "@modelcontextprotocol/sdk": "^1.4.0",
20 | "uuid": "^9.0.1",
21 | "ws": "^8.16.0",
22 | "zod": "^3.22.4"
23 | },
24 | "devDependencies": {
25 | "@types/node": "^20.10.5",
26 | "@types/uuid": "^9.0.7",
27 | "@types/ws": "^8.5.10",
28 | "ts-node": "^10.9.2",
29 | "typescript": "^5.3.3"
30 | }
31 | }
```
--------------------------------------------------------------------------------
/src/socket.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server, ServerWebSocket } from "bun";
2 |
3 | // Store clients by channel
4 | const channels = new Map<string, Set<ServerWebSocket<any>>>();
5 |
6 | function handleConnection(ws: ServerWebSocket<any>) {
7 | // Don't add to clients immediately - wait for channel join
8 | console.log("New client connected");
9 |
10 | // Send welcome message to the new client
11 | ws.send(JSON.stringify({
12 | type: "system",
13 | message: "Please join a channel to start chatting",
14 | }));
15 |
16 | ws.close = () => {
17 | console.log("Client disconnected");
18 |
19 | // Remove client from their channel
20 | channels.forEach((clients, channelName) => {
21 | if (clients.has(ws)) {
22 | clients.delete(ws);
23 |
24 | // Notify other clients in same channel
25 | clients.forEach((client) => {
26 | if (client.readyState === WebSocket.OPEN) {
27 | client.send(JSON.stringify({
28 | type: "system",
29 | message: "A user has left the channel",
30 | channel: channelName
31 | }));
32 | }
33 | });
34 | }
35 | });
36 | };
37 | }
38 |
39 | const server = Bun.serve({
40 | port: 3056,
41 | fetch(req: Request, server: Server) {
42 | // Handle CORS preflight
43 | if (req.method === "OPTIONS") {
44 | return new Response(null, {
45 | headers: {
46 | "Access-Control-Allow-Origin": "*",
47 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
48 | "Access-Control-Allow-Headers": "Content-Type, Authorization",
49 | },
50 | });
51 | }
52 |
53 | // Handle WebSocket upgrade
54 | const success = server.upgrade(req, {
55 | headers: {
56 | "Access-Control-Allow-Origin": "*",
57 | },
58 | });
59 |
60 | if (success) {
61 | return; // Upgraded to WebSocket
62 | }
63 |
64 | // Return response for non-WebSocket requests
65 | return new Response("WebSocket server running", {
66 | headers: {
67 | "Access-Control-Allow-Origin": "*",
68 | },
69 | });
70 | },
71 | websocket: {
72 | open: handleConnection,
73 | message(ws: ServerWebSocket<any>, message: string | Buffer) {
74 | try {
75 | console.log("Received message from client:", message);
76 | const data = JSON.parse(message as string);
77 |
78 | if (data.type === "join") {
79 | const channelName = data.channel;
80 | if (!channelName || typeof channelName !== "string") {
81 | ws.send(JSON.stringify({
82 | type: "error",
83 | message: "Channel name is required"
84 | }));
85 | return;
86 | }
87 |
88 | // Create channel if it doesn't exist
89 | if (!channels.has(channelName)) {
90 | channels.set(channelName, new Set());
91 | }
92 |
93 | // Add client to channel
94 | const channelClients = channels.get(channelName)!;
95 | channelClients.add(ws);
96 |
97 | // Notify client they joined successfully
98 | ws.send(JSON.stringify({
99 | type: "system",
100 | message: `Joined channel: ${channelName}`,
101 | channel: channelName
102 | }));
103 |
104 | console.log("Sending message to client:", data.id);
105 |
106 | ws.send(JSON.stringify({
107 | type: "system",
108 | message: {
109 | id: data.id,
110 | result: "Connected to channel: " + channelName,
111 | },
112 | channel: channelName
113 | }));
114 |
115 | // Notify other clients in channel
116 | channelClients.forEach((client) => {
117 | if (client !== ws && client.readyState === WebSocket.OPEN) {
118 | client.send(JSON.stringify({
119 | type: "system",
120 | message: "A new user has joined the channel",
121 | channel: channelName
122 | }));
123 | }
124 | });
125 | return;
126 | }
127 |
128 | // Handle regular messages
129 | if (data.type === "message") {
130 | const channelName = data.channel;
131 | if (!channelName || typeof channelName !== "string") {
132 | ws.send(JSON.stringify({
133 | type: "error",
134 | message: "Channel name is required"
135 | }));
136 | return;
137 | }
138 |
139 | const channelClients = channels.get(channelName);
140 | if (!channelClients || !channelClients.has(ws)) {
141 | ws.send(JSON.stringify({
142 | type: "error",
143 | message: "You must join the channel first"
144 | }));
145 | return;
146 | }
147 |
148 | // Broadcast to all clients in the channel
149 | channelClients.forEach((client) => {
150 | if (client.readyState === WebSocket.OPEN) {
151 | console.log("Broadcasting message to client:", data.message);
152 | client.send(JSON.stringify({
153 | type: "broadcast",
154 | message: data.message,
155 | sender: client === ws ? "You" : "User",
156 | channel: channelName
157 | }));
158 | }
159 | });
160 | }
161 | } catch (err) {
162 | console.error("Error handling message:", err);
163 | }
164 | },
165 | close(ws: ServerWebSocket<any>) {
166 | // Remove client from their channel
167 | channels.forEach((clients) => {
168 | clients.delete(ws);
169 | });
170 | }
171 | }
172 | });
173 |
174 | console.log(`WebSocket server running on port ${server.port}`);
```
--------------------------------------------------------------------------------
/src/cursor_mcp_plugin/setcharacters.js:
--------------------------------------------------------------------------------
```javascript
1 | function uniqBy(arr, predicate) {
2 | const cb = typeof predicate === "function" ? predicate : (o) => o[predicate];
3 | return [
4 | ...arr
5 | .reduce((map, item) => {
6 | const key = item === null || item === undefined ? item : cb(item);
7 |
8 | map.has(key) || map.set(key, item);
9 |
10 | return map;
11 | }, new Map())
12 | .values(),
13 | ];
14 | }
15 | export const setCharacters = async (node, characters, options) => {
16 | const fallbackFont = options?.fallbackFont || {
17 | family: "Roboto",
18 | style: "Regular",
19 | };
20 | try {
21 | if (node.fontName === figma.mixed) {
22 | if (options?.smartStrategy === "prevail") {
23 | const fontHashTree = {};
24 | for (let i = 1; i < node.characters.length; i++) {
25 | const charFont = node.getRangeFontName(i - 1, i);
26 | const key = `${charFont.family}::${charFont.style}`;
27 | fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1;
28 | }
29 | const prevailedTreeItem = Object.entries(fontHashTree).sort(
30 | (a, b) => b[1] - a[1]
31 | )[0];
32 | const [family, style] = prevailedTreeItem[0].split("::");
33 | const prevailedFont = {
34 | family,
35 | style,
36 | };
37 | await figma.loadFontAsync(prevailedFont);
38 | node.fontName = prevailedFont;
39 | } else if (options?.smartStrategy === "strict") {
40 | return setCharactersWithStrictMatchFont(node, characters, fallbackFont);
41 | } else if (options?.smartStrategy === "experimental") {
42 | return setCharactersWithSmartMatchFont(node, characters, fallbackFont);
43 | } else {
44 | const firstCharFont = node.getRangeFontName(0, 1);
45 | await figma.loadFontAsync(firstCharFont);
46 | node.fontName = firstCharFont;
47 | }
48 | } else {
49 | await figma.loadFontAsync({
50 | family: node.fontName.family,
51 | style: node.fontName.style,
52 | });
53 | }
54 | } catch (err) {
55 | console.warn(
56 | `Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`,
57 | err
58 | );
59 | await figma.loadFontAsync(fallbackFont);
60 | node.fontName = fallbackFont;
61 | }
62 | try {
63 | node.characters = characters;
64 | return true;
65 | } catch (err) {
66 | console.warn(`Failed to set characters. Skipped.`, err);
67 | return false;
68 | }
69 | };
70 |
71 | const setCharactersWithStrictMatchFont = async (
72 | node,
73 | characters,
74 | fallbackFont
75 | ) => {
76 | const fontHashTree = {};
77 | for (let i = 1; i < node.characters.length; i++) {
78 | const startIdx = i - 1;
79 | const startCharFont = node.getRangeFontName(startIdx, i);
80 | const startCharFontVal = `${startCharFont.family}::${startCharFont.style}`;
81 | while (i < node.characters.length) {
82 | i++;
83 | const charFont = node.getRangeFontName(i - 1, i);
84 | if (startCharFontVal !== `${charFont.family}::${charFont.style}`) {
85 | break;
86 | }
87 | }
88 | fontHashTree[`${startIdx}_${i}`] = startCharFontVal;
89 | }
90 | await figma.loadFontAsync(fallbackFont);
91 | node.fontName = fallbackFont;
92 | node.characters = characters;
93 | console.log(fontHashTree);
94 | await Promise.all(
95 | Object.keys(fontHashTree).map(async (range) => {
96 | console.log(range, fontHashTree[range]);
97 | const [start, end] = range.split("_");
98 | const [family, style] = fontHashTree[range].split("::");
99 | const matchedFont = {
100 | family,
101 | style,
102 | };
103 | await figma.loadFontAsync(matchedFont);
104 | return node.setRangeFontName(Number(start), Number(end), matchedFont);
105 | })
106 | );
107 | return true;
108 | };
109 |
110 | const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => {
111 | const indices = [];
112 | let temp = startIdx;
113 | for (let i = startIdx; i < endIdx; i++) {
114 | if (
115 | str[i] === delimiter &&
116 | i + startIdx !== endIdx &&
117 | temp !== i + startIdx
118 | ) {
119 | indices.push([temp, i + startIdx]);
120 | temp = i + startIdx + 1;
121 | }
122 | }
123 | temp !== endIdx && indices.push([temp, endIdx]);
124 | return indices.filter(Boolean);
125 | };
126 |
127 | const buildLinearOrder = (node) => {
128 | const fontTree = [];
129 | const newLinesPos = getDelimiterPos(node.characters, "\n");
130 | newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => {
131 | const newLinesRangeFont = node.getRangeFontName(
132 | newLinesRangeStart,
133 | newLinesRangeEnd
134 | );
135 | if (newLinesRangeFont === figma.mixed) {
136 | const spacesPos = getDelimiterPos(
137 | node.characters,
138 | " ",
139 | newLinesRangeStart,
140 | newLinesRangeEnd
141 | );
142 | spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => {
143 | const spacesRangeFont = node.getRangeFontName(
144 | spacesRangeStart,
145 | spacesRangeEnd
146 | );
147 | if (spacesRangeFont === figma.mixed) {
148 | const spacesRangeFont = node.getRangeFontName(
149 | spacesRangeStart,
150 | spacesRangeStart[0]
151 | );
152 | fontTree.push({
153 | start: spacesRangeStart,
154 | delimiter: " ",
155 | family: spacesRangeFont.family,
156 | style: spacesRangeFont.style,
157 | });
158 | } else {
159 | fontTree.push({
160 | start: spacesRangeStart,
161 | delimiter: " ",
162 | family: spacesRangeFont.family,
163 | style: spacesRangeFont.style,
164 | });
165 | }
166 | });
167 | } else {
168 | fontTree.push({
169 | start: newLinesRangeStart,
170 | delimiter: "\n",
171 | family: newLinesRangeFont.family,
172 | style: newLinesRangeFont.style,
173 | });
174 | }
175 | });
176 | return fontTree
177 | .sort((a, b) => +a.start - +b.start)
178 | .map(({ family, style, delimiter }) => ({ family, style, delimiter }));
179 | };
180 |
181 | const setCharactersWithSmartMatchFont = async (
182 | node,
183 | characters,
184 | fallbackFont
185 | ) => {
186 | const rangeTree = buildLinearOrder(node);
187 | const fontsToLoad = uniqBy(
188 | rangeTree,
189 | ({ family, style }) => `${family}::${style}`
190 | ).map(({ family, style }) => ({
191 | family,
192 | style,
193 | }));
194 |
195 | await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync));
196 |
197 | node.fontName = fallbackFont;
198 | node.characters = characters;
199 |
200 | let prevPos = 0;
201 | rangeTree.forEach(({ family, style, delimiter }) => {
202 | if (prevPos < node.characters.length) {
203 | const delimeterPos = node.characters.indexOf(delimiter, prevPos);
204 | const endPos =
205 | delimeterPos > prevPos ? delimeterPos : node.characters.length;
206 | const matchedFont = {
207 | family,
208 | style,
209 | };
210 | node.setRangeFontName(prevPos, endPos, matchedFont);
211 | prevPos = endPos + 1;
212 | }
213 | });
214 | return true;
215 | };
216 |
```
--------------------------------------------------------------------------------
/src/cursor_mcp_plugin/ui.html:
--------------------------------------------------------------------------------
```html
1 | <!DOCTYPE html>
2 | <html>
3 | <head>
4 | <meta charset="utf-8" />
5 | <title>Cursor MCP Plugin</title>
6 | <style>
7 | body {
8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
9 | Helvetica, Arial, sans-serif;
10 | margin: 0;
11 | padding: 20px;
12 | color: #e0e0e0;
13 | background-color: #1e1e1e;
14 | }
15 | .container {
16 | display: flex;
17 | flex-direction: column;
18 | height: 100%;
19 | }
20 | h1 {
21 | font-size: 16px;
22 | font-weight: 600;
23 | margin-bottom: 10px;
24 | color: #ffffff;
25 | }
26 | h2 {
27 | font-size: 14px;
28 | font-weight: 600;
29 | margin-top: 20px;
30 | margin-bottom: 8px;
31 | color: #ffffff;
32 | }
33 | button {
34 | background-color: #18a0fb;
35 | border: none;
36 | color: white;
37 | padding: 8px 12px;
38 | border-radius: 6px;
39 | margin-top: 8px;
40 | margin-bottom: 8px;
41 | cursor: pointer;
42 | font-size: 14px;
43 | transition: background-color 0.2s;
44 | }
45 | button:hover {
46 | background-color: #0d8ee0;
47 | }
48 | button.secondary {
49 | background-color: #3d3d3d;
50 | color: #e0e0e0;
51 | }
52 | button.secondary:hover {
53 | background-color: #4d4d4d;
54 | }
55 | button:disabled {
56 | background-color: #333333;
57 | color: #666666;
58 | cursor: not-allowed;
59 | }
60 | input {
61 | border: 1px solid #444444;
62 | border-radius: 4px;
63 | padding: 8px;
64 | margin-bottom: 12px;
65 | font-size: 14px;
66 | width: 100%;
67 | box-sizing: border-box;
68 | background-color: #2d2d2d;
69 | color: #e0e0e0;
70 | }
71 | label {
72 | display: block;
73 | margin-bottom: 4px;
74 | font-size: 12px;
75 | font-weight: 500;
76 | color: #cccccc;
77 | }
78 | .status {
79 | margin-top: 16px;
80 | padding: 12px;
81 | border-radius: 6px;
82 | font-size: 14px;
83 | }
84 | .status.connected {
85 | background-color: #1a472a;
86 | color: #4ade80;
87 | }
88 | .status.disconnected {
89 | background-color: #471a1a;
90 | color: #ff9999;
91 | }
92 | .status.info {
93 | background-color: #1a3147;
94 | color: #66b3ff;
95 | }
96 | .section {
97 | margin-bottom: 24px;
98 | }
99 | .hidden {
100 | display: none;
101 | }
102 | .logo {
103 | width: 50px;
104 | height: 50px;
105 | }
106 | .header {
107 | display: flex;
108 | align-items: center;
109 | margin-bottom: 16px;
110 | }
111 | .header-text {
112 | margin-left: 12px;
113 | }
114 | .header-text h1 {
115 | margin: 0;
116 | font-size: 16px;
117 | }
118 | .header-text p {
119 | margin: 4px 0 0 0;
120 | font-size: 12px;
121 | color: #999999;
122 | }
123 | .tabs {
124 | display: flex;
125 | border-bottom: 1px solid #444444;
126 | margin-bottom: 16px;
127 | }
128 | .tab {
129 | padding: 8px 16px;
130 | cursor: pointer;
131 | font-size: 14px;
132 | font-weight: 500;
133 | color: #999999;
134 | }
135 | .tab.active {
136 | border-bottom: 2px solid #18a0fb;
137 | color: #18a0fb;
138 | }
139 | .tab-content {
140 | display: none;
141 | }
142 | .tab-content.active {
143 | display: block;
144 | }
145 | .link {
146 | color: #18a0fb;
147 | text-decoration: none;
148 | cursor: pointer;
149 | }
150 | .link:hover {
151 | text-decoration: underline;
152 | }
153 | .header-logo {
154 | padding: 16px;
155 | border-radius: 16px;
156 | background-color: #333;
157 | }
158 | .header-logo-image {
159 | width: 24px;
160 | height: 24px;
161 | object-fit: contain;
162 | }
163 | </style>
164 | </head>
165 | <body>
166 | <div class="container">
167 | <div class="header">
168 | <div class="header-logo">
169 | <img
170 | class="header-logo-image"
171 | src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAMAAAANIilAAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAEJwAABCcASbNOjQAAAB1UExURUdwTP////////////////39/f////////////////////////////7+/v////////////39/f////////////////////////////////////////////////////39/fn5+ejo6P///+rq6uXl5f////Ly8gf4a04AAAAkdFJOUwAOdkZCfz04zIgbT0pkIagnm7C9b6C2LWqSxBMyB11W2Ovsy3D12ZYAAALtSURBVEjHndcJt6ogEADgXNAUcWlxSQVN3/3/P/EBAgJpWdM9p5ue78xANE2n05vIUduffgvn1oA0bX+hvRc1DYjTPHe+tiGIoqhx4zTNq/y72lMURQtmqasuPc4dAmgwfWuZrqquiw8uNnC5BRJT3YXhIZ7Xris0oLjlmOrArz7VHpOb6wpNee0ITVMHvvd25/qgvtFwla8dpxV7xnTi7dbed7iuTY16lZoV7iXQb3cqRgjVgoviKTZSUw2719pbD2OEVu5yjnqeOpZ75lMMobVzfUcwC6lrofGJpdb3jGtj6TkkNKRWtXMsU+ciNdfQUwe+zZ7/vo1CYYgv39G/kShMS6mHL+g8F96K2Uqi52E6j3DFnsc4uR/hMwugYd9bOLoeSTvPE1yx4/sLh9B9fKbziHVM3z/G+dKb5wdKdysxsNCc4+2l/yk7EnrOVhwGBt9auqJ0t9gR13C4cl77bdil88SPuK9jxrXksHjab48Mwo+4ha3aSbZJ52JpC4GFbY7OdsVst4Lls/mKZe1y6fXTonS3RFsIN7C5dAJsO+WiI21jbd8xesFEtoUdLLjH+qGNJ9WRuj3MOOQNycaV6khvsLc0MxsD2Uq7bhcHuBZh4rFdujjT1c6GkaXtszCx3sW3MRRfNjwiI7EjGjGfFjZwUgM9CuNggqRVXz+vOGDTBOCP5UnHE73ghjK1jYNlEIma9UnHBb/qdkvq1MSQjk4yCvGk4UneQylLbWAIio3I1t26q4sNTuM01tqQe9+My5pYv9wk8Ypv92w7JpXYulGoD8aJ3C/bUUp8tW5EuTa2oXI7ZGLzahZYE0l03QqZWI8Lfh1lw+zxEoNIrF8Dm/NQT8rzgz+WP/oQmL6Ud4pud/4DZzMWPKjXZfJufOyiVzzKV4/609yelDaWiZsDc6+DSwOLxNqxeD/6Ah3zf674+Kyf3xUeDi3WDFIKzCpOv/5phB4MD+cs/OWXVdych/GBf/xJd4pL9+1i/wOElMO5v/co4wAAAABJRU5ErkJggg=="
172 | />
173 | </div>
174 | <div class="header-text">
175 | <h1>Cursor Talk To Figma Plugin</h1>
176 | <p>Connect Figma to Cursor AI using MCP</p>
177 | </div>
178 | </div>
179 |
180 | <div class="tabs">
181 | <div id="tab-connection" class="tab active">Connection</div>
182 | <div id="tab-about" class="tab">About</div>
183 | </div>
184 |
185 | <div id="content-connection" class="tab-content active">
186 | <div class="section">
187 | <label for="port">WebSocket Server Port</label>
188 | <div style="display: flex; gap: 8px">
189 | <input
190 | type="number"
191 | id="port"
192 | placeholder="3056"
193 | value="3056"
194 | min="1024"
195 | max="65535"
196 | />
197 | <button id="btn-connect" class="primary">Connect</button>
198 | </div>
199 | </div>
200 |
201 | <div id="connection-status" class="status disconnected">
202 | Not connected to Cursor MCP server
203 | </div>
204 |
205 | <div class="section">
206 | <button id="btn-disconnect" class="secondary" disabled>
207 | Disconnect
208 | </button>
209 | </div>
210 | </div>
211 |
212 | <div id="content-about" class="tab-content">
213 | <div class="section">
214 | <h2>About Cursor Talk To Figma Plugin</h2>
215 | <p>
216 | This plugin allows Cursor AI to communicate with Figma, enabling
217 | AI-assisted design operations. created by
218 | <a
219 | class="link"
220 | onclick="window.open(`https://github.com/sonnylazuardi`, '_blank')"
221 | >
222 | Sonny
223 | </a>
224 | </p>
225 | <p>Version: 1.0.0</p>
226 |
227 | <h2>How to Use</h2>
228 | <ol>
229 | <li>Make sure the MCP server is running in Cursor</li>
230 | <li>Connect to the server using the port number (default: 3056)</li>
231 | <li>Once connected, you can interact with Figma through Cursor</li>
232 | </ol>
233 | </div>
234 | </div>
235 | </div>
236 |
237 | <script>
238 | // WebSocket connection state
239 | const state = {
240 | connected: false,
241 | socket: null,
242 | serverPort: 3056,
243 | pendingRequests: new Map(),
244 | channel: null,
245 | };
246 |
247 | // UI Elements
248 | const portInput = document.getElementById("port");
249 | const connectButton = document.getElementById("btn-connect");
250 | const disconnectButton = document.getElementById("btn-disconnect");
251 | const connectionStatus = document.getElementById("connection-status");
252 |
253 | // Tabs
254 | const tabs = document.querySelectorAll(".tab");
255 | const tabContents = document.querySelectorAll(".tab-content");
256 |
257 | // Initialize UI
258 | function updateConnectionStatus(isConnected, message) {
259 | state.connected = isConnected;
260 | connectionStatus.innerHTML =
261 | message ||
262 | (isConnected
263 | ? "Connected to Cursor MCP server"
264 | : "Not connected to Cursor MCP server");
265 | connectionStatus.className = `status ${
266 | isConnected ? "connected" : "disconnected"
267 | }`;
268 |
269 | connectButton.disabled = isConnected;
270 | disconnectButton.disabled = !isConnected;
271 | portInput.disabled = isConnected;
272 | }
273 |
274 | // Connect to WebSocket server
275 | async function connectToServer(port) {
276 | try {
277 | if (state.connected && state.socket) {
278 | updateConnectionStatus(true, "Already connected to server");
279 | return;
280 | }
281 |
282 | state.serverPort = port;
283 | state.socket = new WebSocket(`ws://localhost:${port}`);
284 |
285 | state.socket.onopen = () => {
286 | // Generate random channel name
287 | const channelName = generateChannelName();
288 | console.log("Joining channel:", channelName);
289 | state.channel = channelName;
290 |
291 | // Join the channel using the same format as App.tsx
292 | state.socket.send(
293 | JSON.stringify({
294 | type: "join",
295 | channel: channelName.trim(),
296 | })
297 | );
298 | };
299 |
300 | state.socket.onmessage = (event) => {
301 | try {
302 | const data = JSON.parse(event.data);
303 | console.log("Received message:", data);
304 |
305 | if (data.type === "system") {
306 | // Successfully joined channel
307 | if (data.message && data.message.result) {
308 | state.connected = true;
309 | const channelName = data.channel;
310 | updateConnectionStatus(
311 | true,
312 | `Connected to server on port ${port} in channel: <strong>${channelName}</strong>`
313 | );
314 |
315 | // Notify the plugin code
316 | parent.postMessage(
317 | {
318 | pluginMessage: {
319 | type: "notify",
320 | message: `Connected to Cursor MCP server on port ${port} in channel: ${channelName}`,
321 | },
322 | },
323 | "*"
324 | );
325 | }
326 | } else if (data.type === "error") {
327 | console.error("Error:", data.message);
328 | updateConnectionStatus(false, `Error: ${data.message}`);
329 | state.socket.close();
330 | }
331 |
332 | handleSocketMessage(data);
333 | } catch (error) {
334 | console.error("Error parsing message:", error);
335 | }
336 | };
337 |
338 | state.socket.onclose = () => {
339 | state.connected = false;
340 | state.socket = null;
341 | updateConnectionStatus(false, "Disconnected from server");
342 | };
343 |
344 | state.socket.onerror = (error) => {
345 | console.error("WebSocket error:", error);
346 | updateConnectionStatus(false, "Connection error");
347 | state.connected = false;
348 | state.socket = null;
349 | };
350 | } catch (error) {
351 | console.error("Connection error:", error);
352 | updateConnectionStatus(
353 | false,
354 | `Connection error: ${error.message || "Unknown error"}`
355 | );
356 | }
357 | }
358 |
359 | // Disconnect from websocket server
360 | function disconnectFromServer() {
361 | if (state.socket) {
362 | state.socket.close();
363 | state.socket = null;
364 | state.connected = false;
365 | updateConnectionStatus(false, "Disconnected from server");
366 | }
367 | }
368 |
369 | // Handle messages from the WebSocket
370 | async function handleSocketMessage(payload) {
371 | const data = payload.message;
372 | console.log("handleSocketMessage", data);
373 |
374 | // If it's a response to a previous request
375 | if (data.id && state.pendingRequests.has(data.id)) {
376 | console.log("go inside", data);
377 |
378 | console.log("nice", data);
379 | const { resolve, reject } = state.pendingRequests.get(data.id);
380 | state.pendingRequests.delete(data.id);
381 |
382 | if (data.error) {
383 | reject(new Error(data.error));
384 | } else {
385 | resolve(data.result);
386 | }
387 | return;
388 | }
389 |
390 | // If it's a new command
391 | if (data.command) {
392 | try {
393 | // Send the command to the plugin code
394 | parent.postMessage(
395 | {
396 | pluginMessage: {
397 | type: "execute-command",
398 | id: data.id,
399 | command: data.command,
400 | params: data.params,
401 | },
402 | },
403 | "*"
404 | );
405 | } catch (error) {
406 | // Send error back to WebSocket
407 | sendErrorResponse(
408 | data.id,
409 | error.message || "Error executing command"
410 | );
411 | }
412 | }
413 | }
414 |
415 | // Send a command to the WebSocket server
416 | async function sendCommand(command, params) {
417 | return new Promise((resolve, reject) => {
418 | if (!state.connected || !state.socket) {
419 | reject(new Error("Not connected to server"));
420 | return;
421 | }
422 |
423 | const id = generateId();
424 | state.pendingRequests.set(id, { resolve, reject });
425 |
426 | state.socket.send(
427 | JSON.stringify({
428 | id,
429 | type: "message",
430 | channel: state.channel,
431 | message: {
432 | id,
433 | command,
434 | params,
435 | },
436 | })
437 | );
438 |
439 | // Set timeout to reject the promise after 30 seconds
440 | setTimeout(() => {
441 | if (state.pendingRequests.has(id)) {
442 | state.pendingRequests.delete(id);
443 | reject(new Error("Request timed out"));
444 | }
445 | }, 30000);
446 | });
447 | }
448 |
449 | // Send success response back to WebSocket
450 | function sendSuccessResponse(id, result) {
451 | if (!state.connected || !state.socket) {
452 | console.error("Cannot send response: socket not connected");
453 | return;
454 | }
455 |
456 | state.socket.send(
457 | JSON.stringify({
458 | id,
459 | type: "message",
460 | channel: state.channel,
461 | message: {
462 | id,
463 | result,
464 | },
465 | })
466 | );
467 | }
468 |
469 | // Send error response back to WebSocket
470 | function sendErrorResponse(id, errorMessage) {
471 | if (!state.connected || !state.socket) {
472 | console.error("Cannot send error response: socket not connected");
473 | return;
474 | }
475 |
476 | state.socket.send(
477 | JSON.stringify({
478 | id,
479 | error: errorMessage,
480 | })
481 | );
482 | }
483 |
484 | // Helper to generate unique IDs
485 | function generateId() {
486 | return (
487 | Date.now().toString(36) + Math.random().toString(36).substr(2, 5)
488 | );
489 | }
490 |
491 | // Add this function after the generateId() function
492 | function generateChannelName() {
493 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
494 | let result = "";
495 | for (let i = 0; i < 8; i++) {
496 | result += characters.charAt(
497 | Math.floor(Math.random() * characters.length)
498 | );
499 | }
500 | return result;
501 | }
502 |
503 | // Tab switching
504 | tabs.forEach((tab) => {
505 | tab.addEventListener("click", () => {
506 | tabs.forEach((t) => t.classList.remove("active"));
507 | tabContents.forEach((c) => c.classList.remove("active"));
508 |
509 | tab.classList.add("active");
510 | const contentId = "content-" + tab.id.split("-")[1];
511 | document.getElementById(contentId).classList.add("active");
512 | });
513 | });
514 |
515 | // Connect to server
516 | connectButton.addEventListener("click", () => {
517 | const port = parseInt(portInput.value, 10) || 3056;
518 | updateConnectionStatus(false, "Connecting...");
519 | connectionStatus.className = "status info";
520 | connectToServer(port);
521 | });
522 |
523 | // Disconnect from server
524 | disconnectButton.addEventListener("click", () => {
525 | updateConnectionStatus(false, "Disconnecting...");
526 | connectionStatus.className = "status info";
527 | disconnectFromServer();
528 | });
529 |
530 | // Listen for messages from the plugin code
531 | window.onmessage = (event) => {
532 | const message = event.data.pluginMessage;
533 | if (!message) return;
534 |
535 | switch (message.type) {
536 | case "connection-status":
537 | updateConnectionStatus(message.connected, message.message);
538 | break;
539 | case "auto-connect":
540 | connectButton.click();
541 | break;
542 | case "auto-disconnect":
543 | disconnectButton.click();
544 | break;
545 | case "command-result":
546 | // Forward the result from plugin code back to WebSocket
547 | sendSuccessResponse(message.id, message.result);
548 | break;
549 | case "command-error":
550 | // Forward the error from plugin code back to WebSocket
551 | sendErrorResponse(message.id, message.error);
552 | break;
553 | }
554 | };
555 | </script>
556 | </body>
557 | </html>
558 |
```
--------------------------------------------------------------------------------
/src/cursor_mcp_plugin/code.js:
--------------------------------------------------------------------------------
```javascript
1 | // This is the main code file for the Cursor MCP Figma plugin
2 | // It handles Figma API commands
3 |
4 | // Plugin state
5 | const state = {
6 | serverPort: 3056, // Default port
7 | };
8 |
9 | // Show UI
10 | figma.showUI(__html__, { width: 350, height: 450 });
11 |
12 | // Plugin commands from UI
13 | figma.ui.onmessage = async (msg) => {
14 | switch (msg.type) {
15 | case "update-settings":
16 | updateSettings(msg);
17 | break;
18 | case "notify":
19 | figma.notify(msg.message);
20 | break;
21 | case "close-plugin":
22 | figma.closePlugin();
23 | break;
24 | case "execute-command":
25 | // Execute commands received from UI (which gets them from WebSocket)
26 | try {
27 | const result = await handleCommand(msg.command, msg.params);
28 | // Send result back to UI
29 | figma.ui.postMessage({
30 | type: "command-result",
31 | id: msg.id,
32 | result,
33 | });
34 | } catch (error) {
35 | figma.ui.postMessage({
36 | type: "command-error",
37 | id: msg.id,
38 | error: error.message || "Error executing command",
39 | });
40 | }
41 | break;
42 | }
43 | };
44 |
45 | // Listen for plugin commands from menu
46 | figma.on("run", ({ command }) => {
47 | figma.ui.postMessage({ type: "auto-connect" });
48 | });
49 |
50 | // Update plugin settings
51 | function updateSettings(settings) {
52 | if (settings.serverPort) {
53 | state.serverPort = settings.serverPort;
54 | }
55 |
56 | figma.clientStorage.setAsync("settings", {
57 | serverPort: state.serverPort,
58 | });
59 | }
60 |
61 | // Handle commands from UI
62 | async function handleCommand(command, params) {
63 | switch (command) {
64 | case "get_document_info":
65 | return await getDocumentInfo();
66 | case "get_selection":
67 | return await getSelection();
68 | case "get_node_info":
69 | if (!params || !params.nodeId) {
70 | throw new Error("Missing nodeId parameter");
71 | }
72 | return await getNodeInfo(params.nodeId);
73 | case "create_rectangle":
74 | return await createRectangle(params);
75 | case "create_frame":
76 | return await createFrame(params);
77 | case "create_text":
78 | return await createText(params);
79 | case "set_fill_color":
80 | return await setFillColor(params);
81 | case "set_stroke_color":
82 | return await setStrokeColor(params);
83 | case "move_node":
84 | return await moveNode(params);
85 | case "resize_node":
86 | return await resizeNode(params);
87 | case "delete_node":
88 | return await deleteNode(params);
89 | case "get_styles":
90 | return await getStyles();
91 | case "get_local_components":
92 | return await getLocalComponents();
93 | case "get_team_components":
94 | return await getTeamComponents();
95 | case "create_component_instance":
96 | return await createComponentInstance(params);
97 | case "export_node_as_image":
98 | return await exportNodeAsImage(params);
99 | case "execute_code":
100 | return await executeCode(params);
101 | case "set_corner_radius":
102 | return await setCornerRadius(params);
103 | default:
104 | throw new Error(`Unknown command: ${command}`);
105 | }
106 | }
107 |
108 | // Command implementations
109 |
110 | async function getDocumentInfo() {
111 | await figma.currentPage.loadAsync();
112 | const page = figma.currentPage;
113 | return {
114 | name: page.name,
115 | id: page.id,
116 | type: page.type,
117 | children: page.children.map((node) => ({
118 | id: node.id,
119 | name: node.name,
120 | type: node.type,
121 | })),
122 | currentPage: {
123 | id: page.id,
124 | name: page.name,
125 | childCount: page.children.length,
126 | },
127 | pages: [
128 | {
129 | id: page.id,
130 | name: page.name,
131 | childCount: page.children.length,
132 | },
133 | ],
134 | };
135 | }
136 |
137 | async function getSelection() {
138 | return {
139 | selectionCount: figma.currentPage.selection.length,
140 | selection: figma.currentPage.selection.map((node) => ({
141 | id: node.id,
142 | name: node.name,
143 | type: node.type,
144 | visible: node.visible,
145 | })),
146 | };
147 | }
148 |
149 | async function getNodeInfo(nodeId) {
150 | const node = await figma.getNodeByIdAsync(nodeId);
151 |
152 | if (!node) {
153 | throw new Error(`Node not found with ID: ${nodeId}`);
154 | }
155 |
156 | // Base node information
157 | const nodeInfo = {
158 | id: node.id,
159 | name: node.name,
160 | type: node.type,
161 | visible: node.visible,
162 | };
163 |
164 | // Add position and size for SceneNode
165 | if ("x" in node && "y" in node) {
166 | nodeInfo.x = node.x;
167 | nodeInfo.y = node.y;
168 | }
169 |
170 | if ("width" in node && "height" in node) {
171 | nodeInfo.width = node.width;
172 | nodeInfo.height = node.height;
173 | }
174 |
175 | // Add fills for nodes with fills
176 | if ("fills" in node) {
177 | nodeInfo.fills = node.fills;
178 | }
179 |
180 | // Add strokes for nodes with strokes
181 | if ("strokes" in node) {
182 | nodeInfo.strokes = node.strokes;
183 | if ("strokeWeight" in node) {
184 | nodeInfo.strokeWeight = node.strokeWeight;
185 | }
186 | }
187 |
188 | // Add children for parent nodes
189 | if ("children" in node) {
190 | nodeInfo.children = node.children.map((child) => ({
191 | id: child.id,
192 | name: child.name,
193 | type: child.type,
194 | }));
195 | }
196 |
197 | // Add text-specific properties
198 | if (node.type === "TEXT") {
199 | nodeInfo.characters = node.characters;
200 | nodeInfo.fontSize = node.fontSize;
201 | nodeInfo.fontName = node.fontName;
202 | }
203 |
204 | return nodeInfo;
205 | }
206 |
207 | async function createRectangle(params) {
208 | const {
209 | x = 0,
210 | y = 0,
211 | width = 100,
212 | height = 100,
213 | name = "Rectangle",
214 | parentId,
215 | } = params || {};
216 |
217 | const rect = figma.createRectangle();
218 | rect.x = x;
219 | rect.y = y;
220 | rect.resize(width, height);
221 | rect.name = name;
222 |
223 | // If parentId is provided, append to that node, otherwise append to current page
224 | if (parentId) {
225 | const parentNode = await figma.getNodeByIdAsync(parentId);
226 | if (!parentNode) {
227 | throw new Error(`Parent node not found with ID: ${parentId}`);
228 | }
229 | if (!("appendChild" in parentNode)) {
230 | throw new Error(`Parent node does not support children: ${parentId}`);
231 | }
232 | parentNode.appendChild(rect);
233 | } else {
234 | figma.currentPage.appendChild(rect);
235 | }
236 |
237 | return {
238 | id: rect.id,
239 | name: rect.name,
240 | x: rect.x,
241 | y: rect.y,
242 | width: rect.width,
243 | height: rect.height,
244 | parentId: rect.parent ? rect.parent.id : undefined,
245 | };
246 | }
247 |
248 | async function createFrame(params) {
249 | const {
250 | x = 0,
251 | y = 0,
252 | width = 100,
253 | height = 100,
254 | name = "Frame",
255 | parentId,
256 | } = params || {};
257 |
258 | const frame = figma.createFrame();
259 | frame.x = x;
260 | frame.y = y;
261 | frame.resize(width, height);
262 | frame.name = name;
263 |
264 | // If parentId is provided, append to that node, otherwise append to current page
265 | if (parentId) {
266 | const parentNode = await figma.getNodeByIdAsync(parentId);
267 | if (!parentNode) {
268 | throw new Error(`Parent node not found with ID: ${parentId}`);
269 | }
270 | if (!("appendChild" in parentNode)) {
271 | throw new Error(`Parent node does not support children: ${parentId}`);
272 | }
273 | parentNode.appendChild(frame);
274 | } else {
275 | figma.currentPage.appendChild(frame);
276 | }
277 |
278 | return {
279 | id: frame.id,
280 | name: frame.name,
281 | x: frame.x,
282 | y: frame.y,
283 | width: frame.width,
284 | height: frame.height,
285 | parentId: frame.parent ? frame.parent.id : undefined,
286 | };
287 | }
288 |
289 | async function createText(params) {
290 | const {
291 | x = 0,
292 | y = 0,
293 | text = "Text",
294 | fontSize = 14,
295 | fontWeight = 400,
296 | fontColor = { r: 0, g: 0, b: 0, a: 1 }, // Default to black
297 | name = "Text",
298 | parentId,
299 | } = params || {};
300 |
301 | // Map common font weights to Figma font styles
302 | const getFontStyle = (weight) => {
303 | switch (weight) {
304 | case 100:
305 | return "Thin";
306 | case 200:
307 | return "Extra Light";
308 | case 300:
309 | return "Light";
310 | case 400:
311 | return "Regular";
312 | case 500:
313 | return "Medium";
314 | case 600:
315 | return "Semi Bold";
316 | case 700:
317 | return "Bold";
318 | case 800:
319 | return "Extra Bold";
320 | case 900:
321 | return "Black";
322 | default:
323 | return "Regular";
324 | }
325 | };
326 |
327 | const textNode = figma.createText();
328 | textNode.x = x;
329 | textNode.y = y;
330 | textNode.name = name;
331 | try {
332 | await figma.loadFontAsync({
333 | family: "Inter",
334 | style: getFontStyle(fontWeight),
335 | });
336 | textNode.fontName = { family: "Inter", style: getFontStyle(fontWeight) };
337 | textNode.fontSize = parseInt(fontSize);
338 | } catch (error) {
339 | console.error("Error setting font size", error);
340 | }
341 | setCharacters(textNode, text);
342 |
343 | // Set text color
344 | const paintStyle = {
345 | type: "SOLID",
346 | color: {
347 | r: parseFloat(fontColor.r) || 0,
348 | g: parseFloat(fontColor.g) || 0,
349 | b: parseFloat(fontColor.b) || 0,
350 | },
351 | opacity: parseFloat(fontColor.a) || 1,
352 | };
353 | textNode.fills = [paintStyle];
354 |
355 | // If parentId is provided, append to that node, otherwise append to current page
356 | if (parentId) {
357 | const parentNode = await figma.getNodeByIdAsync(parentId);
358 | if (!parentNode) {
359 | throw new Error(`Parent node not found with ID: ${parentId}`);
360 | }
361 | if (!("appendChild" in parentNode)) {
362 | throw new Error(`Parent node does not support children: ${parentId}`);
363 | }
364 | parentNode.appendChild(textNode);
365 | } else {
366 | figma.currentPage.appendChild(textNode);
367 | }
368 |
369 | return {
370 | id: textNode.id,
371 | name: textNode.name,
372 | x: textNode.x,
373 | y: textNode.y,
374 | width: textNode.width,
375 | height: textNode.height,
376 | characters: textNode.characters,
377 | fontSize: textNode.fontSize,
378 | fontWeight: fontWeight,
379 | fontColor: fontColor,
380 | fontName: textNode.fontName,
381 | fills: textNode.fills,
382 | parentId: textNode.parent ? textNode.parent.id : undefined,
383 | };
384 | }
385 |
386 | async function setFillColor(params) {
387 | console.log("setFillColor", params);
388 | const {
389 | nodeId,
390 | color: { r, g, b, a },
391 | } = params || {};
392 |
393 | if (!nodeId) {
394 | throw new Error("Missing nodeId parameter");
395 | }
396 |
397 | const node = await figma.getNodeByIdAsync(nodeId);
398 | if (!node) {
399 | throw new Error(`Node not found with ID: ${nodeId}`);
400 | }
401 |
402 | if (!("fills" in node)) {
403 | throw new Error(`Node does not support fills: ${nodeId}`);
404 | }
405 |
406 | // Create RGBA color
407 | const rgbColor = {
408 | r: parseFloat(r) || 0,
409 | g: parseFloat(g) || 0,
410 | b: parseFloat(b) || 0,
411 | a: parseFloat(a) || 1,
412 | };
413 |
414 | // Set fill
415 | const paintStyle = {
416 | type: "SOLID",
417 | color: {
418 | r: parseFloat(rgbColor.r),
419 | g: parseFloat(rgbColor.g),
420 | b: parseFloat(rgbColor.b),
421 | },
422 | opacity: parseFloat(rgbColor.a),
423 | };
424 |
425 | console.log("paintStyle", paintStyle);
426 |
427 | node.fills = [paintStyle];
428 |
429 | return {
430 | id: node.id,
431 | name: node.name,
432 | fills: [paintStyle],
433 | };
434 | }
435 |
436 | async function setStrokeColor(params) {
437 | const {
438 | nodeId,
439 | color: { r, g, b, a },
440 | weight = 1,
441 | } = params || {};
442 |
443 | if (!nodeId) {
444 | throw new Error("Missing nodeId parameter");
445 | }
446 |
447 | const node = await figma.getNodeByIdAsync(nodeId);
448 | if (!node) {
449 | throw new Error(`Node not found with ID: ${nodeId}`);
450 | }
451 |
452 | if (!("strokes" in node)) {
453 | throw new Error(`Node does not support strokes: ${nodeId}`);
454 | }
455 |
456 | // Create RGBA color
457 | const rgbColor = {
458 | r: r !== undefined ? r : 0,
459 | g: g !== undefined ? g : 0,
460 | b: b !== undefined ? b : 0,
461 | a: a !== undefined ? a : 1,
462 | };
463 |
464 | // Set stroke
465 | const paintStyle = {
466 | type: "SOLID",
467 | color: {
468 | r: rgbColor.r,
469 | g: rgbColor.g,
470 | b: rgbColor.b,
471 | },
472 | opacity: rgbColor.a,
473 | };
474 |
475 | node.strokes = [paintStyle];
476 |
477 | // Set stroke weight if available
478 | if ("strokeWeight" in node) {
479 | node.strokeWeight = weight;
480 | }
481 |
482 | return {
483 | id: node.id,
484 | name: node.name,
485 | strokes: node.strokes,
486 | strokeWeight: "strokeWeight" in node ? node.strokeWeight : undefined,
487 | };
488 | }
489 |
490 | async function moveNode(params) {
491 | const { nodeId, x, y } = params || {};
492 |
493 | if (!nodeId) {
494 | throw new Error("Missing nodeId parameter");
495 | }
496 |
497 | if (x === undefined || y === undefined) {
498 | throw new Error("Missing x or y parameters");
499 | }
500 |
501 | const node = await figma.getNodeByIdAsync(nodeId);
502 | if (!node) {
503 | throw new Error(`Node not found with ID: ${nodeId}`);
504 | }
505 |
506 | if (!("x" in node) || !("y" in node)) {
507 | throw new Error(`Node does not support position: ${nodeId}`);
508 | }
509 |
510 | node.x = x;
511 | node.y = y;
512 |
513 | return {
514 | id: node.id,
515 | name: node.name,
516 | x: node.x,
517 | y: node.y,
518 | };
519 | }
520 |
521 | async function resizeNode(params) {
522 | const { nodeId, width, height } = params || {};
523 |
524 | if (!nodeId) {
525 | throw new Error("Missing nodeId parameter");
526 | }
527 |
528 | if (width === undefined || height === undefined) {
529 | throw new Error("Missing width or height parameters");
530 | }
531 |
532 | const node = await figma.getNodeByIdAsync(nodeId);
533 | if (!node) {
534 | throw new Error(`Node not found with ID: ${nodeId}`);
535 | }
536 |
537 | if (!("resize" in node)) {
538 | throw new Error(`Node does not support resizing: ${nodeId}`);
539 | }
540 |
541 | node.resize(width, height);
542 |
543 | return {
544 | id: node.id,
545 | name: node.name,
546 | width: node.width,
547 | height: node.height,
548 | };
549 | }
550 |
551 | async function deleteNode(params) {
552 | const { nodeId } = params || {};
553 |
554 | if (!nodeId) {
555 | throw new Error("Missing nodeId parameter");
556 | }
557 |
558 | const node = await figma.getNodeByIdAsync(nodeId);
559 | if (!node) {
560 | throw new Error(`Node not found with ID: ${nodeId}`);
561 | }
562 |
563 | // Save node info before deleting
564 | const nodeInfo = {
565 | id: node.id,
566 | name: node.name,
567 | type: node.type,
568 | };
569 |
570 | node.remove();
571 |
572 | return nodeInfo;
573 | }
574 |
575 | async function getStyles() {
576 | const styles = {
577 | colors: await figma.getLocalPaintStylesAsync(),
578 | texts: await figma.getLocalTextStylesAsync(),
579 | effects: await figma.getLocalEffectStylesAsync(),
580 | grids: await figma.getLocalGridStylesAsync(),
581 | };
582 |
583 | return {
584 | colors: styles.colors.map((style) => ({
585 | id: style.id,
586 | name: style.name,
587 | key: style.key,
588 | paint: style.paints[0],
589 | })),
590 | texts: styles.texts.map((style) => ({
591 | id: style.id,
592 | name: style.name,
593 | key: style.key,
594 | fontSize: style.fontSize,
595 | fontName: style.fontName,
596 | })),
597 | effects: styles.effects.map((style) => ({
598 | id: style.id,
599 | name: style.name,
600 | key: style.key,
601 | })),
602 | grids: styles.grids.map((style) => ({
603 | id: style.id,
604 | name: style.name,
605 | key: style.key,
606 | })),
607 | };
608 | }
609 |
610 | async function getLocalComponents() {
611 | const components = figma.root.findAllWithCriteria({
612 | types: ["COMPONENT"],
613 | });
614 |
615 | return {
616 | count: components.length,
617 | components: components.map((component) => ({
618 | id: component.id,
619 | name: component.name,
620 | key: "key" in component ? component.key : null,
621 | })),
622 | };
623 | }
624 |
625 | async function getTeamComponents() {
626 | try {
627 | const teamComponents =
628 | await figma.teamLibrary.getAvailableComponentsAsync();
629 |
630 | return {
631 | count: teamComponents.length,
632 | components: teamComponents.map((component) => ({
633 | key: component.key,
634 | name: component.name,
635 | description: component.description,
636 | libraryName: component.libraryName,
637 | })),
638 | };
639 | } catch (error) {
640 | throw new Error(`Error getting team components: ${error.message}`);
641 | }
642 | }
643 |
644 | async function createComponentInstance(params) {
645 | const { componentKey, x = 0, y = 0 } = params || {};
646 |
647 | if (!componentKey) {
648 | throw new Error("Missing componentKey parameter");
649 | }
650 |
651 | try {
652 | const component = await figma.importComponentByKeyAsync(componentKey);
653 | const instance = component.createInstance();
654 |
655 | instance.x = x;
656 | instance.y = y;
657 |
658 | figma.currentPage.appendChild(instance);
659 |
660 | return {
661 | id: instance.id,
662 | name: instance.name,
663 | x: instance.x,
664 | y: instance.y,
665 | width: instance.width,
666 | height: instance.height,
667 | componentId: instance.componentId,
668 | };
669 | } catch (error) {
670 | throw new Error(`Error creating component instance: ${error.message}`);
671 | }
672 | }
673 |
674 | async function exportNodeAsImage(params) {
675 | const { nodeId, format = "PNG", scale = 1 } = params || {};
676 |
677 | if (!nodeId) {
678 | throw new Error("Missing nodeId parameter");
679 | }
680 |
681 | const node = await figma.getNodeByIdAsync(nodeId);
682 | if (!node) {
683 | throw new Error(`Node not found with ID: ${nodeId}`);
684 | }
685 |
686 | if (!("exportAsync" in node)) {
687 | throw new Error(`Node does not support exporting: ${nodeId}`);
688 | }
689 |
690 | try {
691 | const settings = {
692 | format: format,
693 | constraint: { type: "SCALE", value: scale },
694 | };
695 |
696 | const bytes = await node.exportAsync(settings);
697 |
698 | let mimeType;
699 | switch (format) {
700 | case "PNG":
701 | mimeType = "image/png";
702 | break;
703 | case "JPG":
704 | mimeType = "image/jpeg";
705 | break;
706 | case "SVG":
707 | mimeType = "image/svg+xml";
708 | break;
709 | case "PDF":
710 | mimeType = "application/pdf";
711 | break;
712 | default:
713 | mimeType = "application/octet-stream";
714 | }
715 |
716 | // Convert to base64
717 | const uint8Array = new Uint8Array(bytes);
718 | let binary = "";
719 | for (let i = 0; i < uint8Array.length; i++) {
720 | binary += String.fromCharCode(uint8Array[i]);
721 | }
722 | const base64 = btoa(binary);
723 | const imageData = `data:${mimeType};base64,${base64}`;
724 |
725 | return {
726 | nodeId,
727 | format,
728 | scale,
729 | mimeType,
730 | imageData,
731 | };
732 | } catch (error) {
733 | throw new Error(`Error exporting node as image: ${error.message}`);
734 | }
735 | }
736 |
737 | async function executeCode(params) {
738 | const { code } = params || {};
739 |
740 | if (!code) {
741 | throw new Error("Missing code parameter");
742 | }
743 |
744 | try {
745 | // Execute the provided code
746 | // Note: This is potentially unsafe, but matches the Blender MCP functionality
747 | const executeFn = new Function(
748 | "figma",
749 | "selection",
750 | `
751 | try {
752 | const result = (async () => {
753 | ${code}
754 | })();
755 | return result;
756 | } catch (error) {
757 | throw new Error('Error executing code: ' + error.message);
758 | }
759 | `
760 | );
761 |
762 | const result = await executeFn(figma, figma.currentPage.selection);
763 | return { result };
764 | } catch (error) {
765 | throw new Error(`Error executing code: ${error.message}`);
766 | }
767 | }
768 |
769 | async function setCornerRadius(params) {
770 | const { nodeId, radius, corners } = params || {};
771 |
772 | if (!nodeId) {
773 | throw new Error("Missing nodeId parameter");
774 | }
775 |
776 | if (radius === undefined) {
777 | throw new Error("Missing radius parameter");
778 | }
779 |
780 | const node = await figma.getNodeByIdAsync(nodeId);
781 | if (!node) {
782 | throw new Error(`Node not found with ID: ${nodeId}`);
783 | }
784 |
785 | // Check if node supports corner radius
786 | if (!("cornerRadius" in node)) {
787 | throw new Error(`Node does not support corner radius: ${nodeId}`);
788 | }
789 |
790 | // If corners array is provided, set individual corner radii
791 | if (corners && Array.isArray(corners) && corners.length === 4) {
792 | if ("topLeftRadius" in node) {
793 | // Node supports individual corner radii
794 | if (corners[0]) node.topLeftRadius = radius;
795 | if (corners[1]) node.topRightRadius = radius;
796 | if (corners[2]) node.bottomRightRadius = radius;
797 | if (corners[3]) node.bottomLeftRadius = radius;
798 | } else {
799 | // Node only supports uniform corner radius
800 | node.cornerRadius = radius;
801 | }
802 | } else {
803 | // Set uniform corner radius
804 | node.cornerRadius = radius;
805 | }
806 |
807 | return {
808 | id: node.id,
809 | name: node.name,
810 | cornerRadius: "cornerRadius" in node ? node.cornerRadius : undefined,
811 | topLeftRadius: "topLeftRadius" in node ? node.topLeftRadius : undefined,
812 | topRightRadius: "topRightRadius" in node ? node.topRightRadius : undefined,
813 | bottomRightRadius:
814 | "bottomRightRadius" in node ? node.bottomRightRadius : undefined,
815 | bottomLeftRadius:
816 | "bottomLeftRadius" in node ? node.bottomLeftRadius : undefined,
817 | };
818 | }
819 |
820 | // Initialize settings on load
821 | (async function initializePlugin() {
822 | try {
823 | const savedSettings = await figma.clientStorage.getAsync("settings");
824 | if (savedSettings) {
825 | if (savedSettings.serverPort) {
826 | state.serverPort = savedSettings.serverPort;
827 | }
828 | }
829 |
830 | // Send initial settings to UI
831 | figma.ui.postMessage({
832 | type: "init-settings",
833 | settings: {
834 | serverPort: state.serverPort,
835 | },
836 | });
837 | } catch (error) {
838 | console.error("Error loading settings:", error);
839 | }
840 | })();
841 |
842 | function uniqBy(arr, predicate) {
843 | const cb = typeof predicate === "function" ? predicate : (o) => o[predicate];
844 | return [
845 | ...arr
846 | .reduce((map, item) => {
847 | const key = item === null || item === undefined ? item : cb(item);
848 |
849 | map.has(key) || map.set(key, item);
850 |
851 | return map;
852 | }, new Map())
853 | .values(),
854 | ];
855 | }
856 | const setCharacters = async (node, characters, options) => {
857 | const fallbackFont = (options && options.fallbackFont) || {
858 | family: "Inter",
859 | style: "Regular",
860 | };
861 | try {
862 | if (node.fontName === figma.mixed) {
863 | if (options && options.smartStrategy === "prevail") {
864 | const fontHashTree = {};
865 | for (let i = 1; i < node.characters.length; i++) {
866 | const charFont = node.getRangeFontName(i - 1, i);
867 | const key = `${charFont.family}::${charFont.style}`;
868 | fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1;
869 | }
870 | const prevailedTreeItem = Object.entries(fontHashTree).sort(
871 | (a, b) => b[1] - a[1]
872 | )[0];
873 | const [family, style] = prevailedTreeItem[0].split("::");
874 | const prevailedFont = {
875 | family,
876 | style,
877 | };
878 | await figma.loadFontAsync(prevailedFont);
879 | node.fontName = prevailedFont;
880 | } else if (options && options.smartStrategy === "strict") {
881 | return setCharactersWithStrictMatchFont(node, characters, fallbackFont);
882 | } else if (options && options.smartStrategy === "experimental") {
883 | return setCharactersWithSmartMatchFont(node, characters, fallbackFont);
884 | } else {
885 | const firstCharFont = node.getRangeFontName(0, 1);
886 | await figma.loadFontAsync(firstCharFont);
887 | node.fontName = firstCharFont;
888 | }
889 | } else {
890 | await figma.loadFontAsync({
891 | family: node.fontName.family,
892 | style: node.fontName.style,
893 | });
894 | }
895 | } catch (err) {
896 | console.warn(
897 | `Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`,
898 | err
899 | );
900 | await figma.loadFontAsync(fallbackFont);
901 | node.fontName = fallbackFont;
902 | }
903 | try {
904 | node.characters = characters;
905 | return true;
906 | } catch (err) {
907 | console.warn(`Failed to set characters. Skipped.`, err);
908 | return false;
909 | }
910 | };
911 |
912 | const setCharactersWithStrictMatchFont = async (
913 | node,
914 | characters,
915 | fallbackFont
916 | ) => {
917 | const fontHashTree = {};
918 | for (let i = 1; i < node.characters.length; i++) {
919 | const startIdx = i - 1;
920 | const startCharFont = node.getRangeFontName(startIdx, i);
921 | const startCharFontVal = `${startCharFont.family}::${startCharFont.style}`;
922 | while (i < node.characters.length) {
923 | i++;
924 | const charFont = node.getRangeFontName(i - 1, i);
925 | if (startCharFontVal !== `${charFont.family}::${charFont.style}`) {
926 | break;
927 | }
928 | }
929 | fontHashTree[`${startIdx}_${i}`] = startCharFontVal;
930 | }
931 | await figma.loadFontAsync(fallbackFont);
932 | node.fontName = fallbackFont;
933 | node.characters = characters;
934 | console.log(fontHashTree);
935 | await Promise.all(
936 | Object.keys(fontHashTree).map(async (range) => {
937 | console.log(range, fontHashTree[range]);
938 | const [start, end] = range.split("_");
939 | const [family, style] = fontHashTree[range].split("::");
940 | const matchedFont = {
941 | family,
942 | style,
943 | };
944 | await figma.loadFontAsync(matchedFont);
945 | return node.setRangeFontName(Number(start), Number(end), matchedFont);
946 | })
947 | );
948 | return true;
949 | };
950 |
951 | const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => {
952 | const indices = [];
953 | let temp = startIdx;
954 | for (let i = startIdx; i < endIdx; i++) {
955 | if (
956 | str[i] === delimiter &&
957 | i + startIdx !== endIdx &&
958 | temp !== i + startIdx
959 | ) {
960 | indices.push([temp, i + startIdx]);
961 | temp = i + startIdx + 1;
962 | }
963 | }
964 | temp !== endIdx && indices.push([temp, endIdx]);
965 | return indices.filter(Boolean);
966 | };
967 |
968 | const buildLinearOrder = (node) => {
969 | const fontTree = [];
970 | const newLinesPos = getDelimiterPos(node.characters, "\n");
971 | newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => {
972 | const newLinesRangeFont = node.getRangeFontName(
973 | newLinesRangeStart,
974 | newLinesRangeEnd
975 | );
976 | if (newLinesRangeFont === figma.mixed) {
977 | const spacesPos = getDelimiterPos(
978 | node.characters,
979 | " ",
980 | newLinesRangeStart,
981 | newLinesRangeEnd
982 | );
983 | spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => {
984 | const spacesRangeFont = node.getRangeFontName(
985 | spacesRangeStart,
986 | spacesRangeEnd
987 | );
988 | if (spacesRangeFont === figma.mixed) {
989 | const spacesRangeFont = node.getRangeFontName(
990 | spacesRangeStart,
991 | spacesRangeStart[0]
992 | );
993 | fontTree.push({
994 | start: spacesRangeStart,
995 | delimiter: " ",
996 | family: spacesRangeFont.family,
997 | style: spacesRangeFont.style,
998 | });
999 | } else {
1000 | fontTree.push({
1001 | start: spacesRangeStart,
1002 | delimiter: " ",
1003 | family: spacesRangeFont.family,
1004 | style: spacesRangeFont.style,
1005 | });
1006 | }
1007 | });
1008 | } else {
1009 | fontTree.push({
1010 | start: newLinesRangeStart,
1011 | delimiter: "\n",
1012 | family: newLinesRangeFont.family,
1013 | style: newLinesRangeFont.style,
1014 | });
1015 | }
1016 | });
1017 | return fontTree
1018 | .sort((a, b) => +a.start - +b.start)
1019 | .map(({ family, style, delimiter }) => ({ family, style, delimiter }));
1020 | };
1021 |
1022 | const setCharactersWithSmartMatchFont = async (
1023 | node,
1024 | characters,
1025 | fallbackFont
1026 | ) => {
1027 | const rangeTree = buildLinearOrder(node);
1028 | const fontsToLoad = uniqBy(
1029 | rangeTree,
1030 | ({ family, style }) => `${family}::${style}`
1031 | ).map(({ family, style }) => ({
1032 | family,
1033 | style,
1034 | }));
1035 |
1036 | await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync));
1037 |
1038 | node.fontName = fallbackFont;
1039 | node.characters = characters;
1040 |
1041 | let prevPos = 0;
1042 | rangeTree.forEach(({ family, style, delimiter }) => {
1043 | if (prevPos < node.characters.length) {
1044 | const delimeterPos = node.characters.indexOf(delimiter, prevPos);
1045 | const endPos =
1046 | delimeterPos > prevPos ? delimeterPos : node.characters.length;
1047 | const matchedFont = {
1048 | family,
1049 | style,
1050 | };
1051 | node.setRangeFontName(prevPos, endPos, matchedFont);
1052 | prevPos = endPos + 1;
1053 | }
1054 | });
1055 | return true;
1056 | };
1057 |
```
--------------------------------------------------------------------------------
/src/talk_to_figma_mcp/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import { z } from "zod";
4 | import WebSocket from 'ws';
5 | import { v4 as uuidv4 } from 'uuid';
6 |
7 | // Define TypeScript interfaces for Figma responses
8 | interface FigmaResponse {
9 | id: string;
10 | result?: any;
11 | error?: string;
12 | }
13 |
14 | // WebSocket connection and request tracking
15 | let ws: WebSocket | null = null;
16 | const pendingRequests = new Map<string, {
17 | resolve: (value: unknown) => void;
18 | reject: (reason: unknown) => void;
19 | timeout: NodeJS.Timeout;
20 | }>();
21 |
22 | // Track which channel each client is in
23 | let currentChannel: string | null = null;
24 |
25 | // Create MCP server
26 | const server = new McpServer({
27 | name: "TalkToFigmaMCP",
28 | version: "1.0.0",
29 | });
30 |
31 | // Document Info Tool
32 | server.tool(
33 | "get_document_info",
34 | "Get detailed information about the current Figma document",
35 | {},
36 | async () => {
37 | try {
38 | const result = await sendCommandToFigma('get_document_info');
39 | return {
40 | content: [
41 | {
42 | type: "text",
43 | text: JSON.stringify(result, null, 2)
44 | }
45 | ]
46 | };
47 | } catch (error) {
48 | return {
49 | content: [
50 | {
51 | type: "text",
52 | text: `Error getting document info: ${error instanceof Error ? error.message : String(error)}`
53 | }
54 | ]
55 | };
56 | }
57 | }
58 | );
59 |
60 | // Selection Tool
61 | server.tool(
62 | "get_selection",
63 | "Get information about the current selection in Figma",
64 | {},
65 | async () => {
66 | try {
67 | const result = await sendCommandToFigma('get_selection');
68 | return {
69 | content: [
70 | {
71 | type: "text",
72 | text: JSON.stringify(result, null, 2)
73 | }
74 | ]
75 | };
76 | } catch (error) {
77 | return {
78 | content: [
79 | {
80 | type: "text",
81 | text: `Error getting selection: ${error instanceof Error ? error.message : String(error)}`
82 | }
83 | ]
84 | };
85 | }
86 | }
87 | );
88 |
89 | // Node Info Tool
90 | server.tool(
91 | "get_node_info",
92 | "Get detailed information about a specific node in Figma",
93 | {
94 | nodeId: z.string().describe("The ID of the node to get information about")
95 | },
96 | async ({ nodeId }) => {
97 | try {
98 | const result = await sendCommandToFigma('get_node_info', { nodeId });
99 | return {
100 | content: [
101 | {
102 | type: "text",
103 | text: JSON.stringify(result, null, 2)
104 | }
105 | ]
106 | };
107 | } catch (error) {
108 | return {
109 | content: [
110 | {
111 | type: "text",
112 | text: `Error getting node info: ${error instanceof Error ? error.message : String(error)}`
113 | }
114 | ]
115 | };
116 | }
117 | }
118 | );
119 |
120 | // Create Rectangle Tool
121 | server.tool(
122 | "create_rectangle",
123 | "Create a new rectangle in Figma",
124 | {
125 | x: z.number().describe("X position"),
126 | y: z.number().describe("Y position"),
127 | width: z.number().describe("Width of the rectangle"),
128 | height: z.number().describe("Height of the rectangle"),
129 | name: z.string().optional().describe("Optional name for the rectangle"),
130 | parentId: z.string().optional().describe("Optional parent node ID to append the rectangle to")
131 | },
132 | async ({ x, y, width, height, name, parentId }) => {
133 | try {
134 | const result = await sendCommandToFigma('create_rectangle', {
135 | x, y, width, height, name: name || 'Rectangle', parentId
136 | });
137 | return {
138 | content: [
139 | {
140 | type: "text",
141 | text: `Created rectangle "${JSON.stringify(result)}"`
142 | }
143 | ]
144 | }
145 | } catch (error) {
146 | return {
147 | content: [
148 | {
149 | type: "text",
150 | text: `Error creating rectangle: ${error instanceof Error ? error.message : String(error)}`
151 | }
152 | ]
153 | };
154 | }
155 | }
156 | );
157 |
158 | // Create Frame Tool
159 | server.tool(
160 | "create_frame",
161 | "Create a new frame in Figma",
162 | {
163 | x: z.number().describe("X position"),
164 | y: z.number().describe("Y position"),
165 | width: z.number().describe("Width of the frame"),
166 | height: z.number().describe("Height of the frame"),
167 | name: z.string().optional().describe("Optional name for the frame"),
168 | parentId: z.string().optional().describe("Optional parent node ID to append the frame to"),
169 | fillColor: z.object({
170 | r: z.number().min(0).max(1).describe("Red component (0-1)"),
171 | g: z.number().min(0).max(1).describe("Green component (0-1)"),
172 | b: z.number().min(0).max(1).describe("Blue component (0-1)"),
173 | a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)")
174 | }).optional().describe("Fill color in RGBA format"),
175 | strokeColor: z.object({
176 | r: z.number().min(0).max(1).describe("Red component (0-1)"),
177 | g: z.number().min(0).max(1).describe("Green component (0-1)"),
178 | b: z.number().min(0).max(1).describe("Blue component (0-1)"),
179 | a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)")
180 | }).optional().describe("Stroke color in RGBA format"),
181 | strokeWeight: z.number().positive().optional().describe("Stroke weight")
182 | },
183 | async ({ x, y, width, height, name, parentId, fillColor, strokeColor, strokeWeight }) => {
184 | try {
185 | const result = await sendCommandToFigma('create_frame', {
186 | x, y, width, height, name: name || 'Frame', parentId,
187 | fillColor: fillColor || { r: 1, g: 1, b: 1, a: 1 },
188 | strokeColor: strokeColor,
189 | strokeWeight: strokeWeight
190 | });
191 | const typedResult = result as { name: string, id: string };
192 | return {
193 | content: [
194 | {
195 | type: "text",
196 | text: `Created frame "${typedResult.name}" with ID: ${typedResult.id}. Use the ID as the parentId to appendChild inside this frame.`
197 | }
198 | ]
199 | };
200 | } catch (error) {
201 | return {
202 | content: [
203 | {
204 | type: "text",
205 | text: `Error creating frame: ${error instanceof Error ? error.message : String(error)}`
206 | }
207 | ]
208 | };
209 | }
210 | }
211 | );
212 |
213 | // Create Text Tool
214 | server.tool(
215 | "create_text",
216 | "Create a new text element in Figma",
217 | {
218 | x: z.number().describe("X position"),
219 | y: z.number().describe("Y position"),
220 | text: z.string().describe("Text content"),
221 | fontSize: z.number().optional().describe("Font size (default: 14)"),
222 | fontWeight: z.number().optional().describe("Font weight (e.g., 400 for Regular, 700 for Bold)"),
223 | fontColor: z.object({
224 | r: z.number().min(0).max(1).describe("Red component (0-1)"),
225 | g: z.number().min(0).max(1).describe("Green component (0-1)"),
226 | b: z.number().min(0).max(1).describe("Blue component (0-1)"),
227 | a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)")
228 | }).optional().describe("Font color in RGBA format"),
229 | name: z.string().optional().describe("Optional name for the text node by default following text"),
230 | parentId: z.string().optional().describe("Optional parent node ID to append the text to")
231 | },
232 | async ({ x, y, text, fontSize, fontWeight, fontColor, name, parentId }) => {
233 | try {
234 | const result = await sendCommandToFigma('create_text', {
235 | x, y, text,
236 | fontSize: fontSize || 14,
237 | fontWeight: fontWeight || 400,
238 | fontColor: fontColor || { r: 0, g: 0, b: 0, a: 1 },
239 | name: name || 'Text',
240 | parentId
241 | });
242 | const typedResult = result as { name: string, id: string };
243 | return {
244 | content: [
245 | {
246 | type: "text",
247 | text: `Created text "${typedResult.name}" with ID: ${typedResult.id}`
248 | }
249 | ]
250 | };
251 | } catch (error) {
252 | return {
253 | content: [
254 | {
255 | type: "text",
256 | text: `Error creating text: ${error instanceof Error ? error.message : String(error)}`
257 | }
258 | ]
259 | };
260 | }
261 | }
262 | );
263 |
264 | // Set Fill Color Tool
265 | server.tool(
266 | "set_fill_color",
267 | "Set the fill color of a node in Figma can be TextNode or FrameNode",
268 | {
269 | nodeId: z.string().describe("The ID of the node to modify"),
270 | r: z.number().min(0).max(1).describe("Red component (0-1)"),
271 | g: z.number().min(0).max(1).describe("Green component (0-1)"),
272 | b: z.number().min(0).max(1).describe("Blue component (0-1)"),
273 | a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)")
274 | },
275 | async ({ nodeId, r, g, b, a }) => {
276 | try {
277 | const result = await sendCommandToFigma('set_fill_color', {
278 | nodeId,
279 | color: { r, g, b, a: a || 1 }
280 | });
281 | const typedResult = result as { name: string };
282 | return {
283 | content: [
284 | {
285 | type: "text",
286 | text: `Set fill color of node "${typedResult.name}" to RGBA(${r}, ${g}, ${b}, ${a || 1})`
287 | }
288 | ]
289 | };
290 | } catch (error) {
291 | return {
292 | content: [
293 | {
294 | type: "text",
295 | text: `Error setting fill color: ${error instanceof Error ? error.message : String(error)}`
296 | }
297 | ]
298 | };
299 | }
300 | }
301 | );
302 |
303 | // Set Stroke Color Tool
304 | server.tool(
305 | "set_stroke_color",
306 | "Set the stroke color of a node in Figma",
307 | {
308 | nodeId: z.string().describe("The ID of the node to modify"),
309 | r: z.number().min(0).max(1).describe("Red component (0-1)"),
310 | g: z.number().min(0).max(1).describe("Green component (0-1)"),
311 | b: z.number().min(0).max(1).describe("Blue component (0-1)"),
312 | a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)"),
313 | weight: z.number().positive().optional().describe("Stroke weight")
314 | },
315 | async ({ nodeId, r, g, b, a, weight }) => {
316 | try {
317 | const result = await sendCommandToFigma('set_stroke_color', {
318 | nodeId,
319 | color: { r, g, b, a: a || 1 },
320 | weight: weight || 1
321 | });
322 | const typedResult = result as { name: string };
323 | return {
324 | content: [
325 | {
326 | type: "text",
327 | text: `Set stroke color of node "${typedResult.name}" to RGBA(${r}, ${g}, ${b}, ${a || 1}) with weight ${weight || 1}`
328 | }
329 | ]
330 | };
331 | } catch (error) {
332 | return {
333 | content: [
334 | {
335 | type: "text",
336 | text: `Error setting stroke color: ${error instanceof Error ? error.message : String(error)}`
337 | }
338 | ]
339 | };
340 | }
341 | }
342 | );
343 |
344 | // Move Node Tool
345 | server.tool(
346 | "move_node",
347 | "Move a node to a new position in Figma",
348 | {
349 | nodeId: z.string().describe("The ID of the node to move"),
350 | x: z.number().describe("New X position"),
351 | y: z.number().describe("New Y position")
352 | },
353 | async ({ nodeId, x, y }) => {
354 | try {
355 | const result = await sendCommandToFigma('move_node', { nodeId, x, y });
356 | const typedResult = result as { name: string };
357 | return {
358 | content: [
359 | {
360 | type: "text",
361 | text: `Moved node "${typedResult.name}" to position (${x}, ${y})`
362 | }
363 | ]
364 | };
365 | } catch (error) {
366 | return {
367 | content: [
368 | {
369 | type: "text",
370 | text: `Error moving node: ${error instanceof Error ? error.message : String(error)}`
371 | }
372 | ]
373 | };
374 | }
375 | }
376 | );
377 |
378 | // Resize Node Tool
379 | server.tool(
380 | "resize_node",
381 | "Resize a node in Figma",
382 | {
383 | nodeId: z.string().describe("The ID of the node to resize"),
384 | width: z.number().positive().describe("New width"),
385 | height: z.number().positive().describe("New height")
386 | },
387 | async ({ nodeId, width, height }) => {
388 | try {
389 | const result = await sendCommandToFigma('resize_node', { nodeId, width, height });
390 | const typedResult = result as { name: string };
391 | return {
392 | content: [
393 | {
394 | type: "text",
395 | text: `Resized node "${typedResult.name}" to width ${width} and height ${height}`
396 | }
397 | ]
398 | };
399 | } catch (error) {
400 | return {
401 | content: [
402 | {
403 | type: "text",
404 | text: `Error resizing node: ${error instanceof Error ? error.message : String(error)}`
405 | }
406 | ]
407 | };
408 | }
409 | }
410 | );
411 |
412 | // Delete Node Tool
413 | server.tool(
414 | "delete_node",
415 | "Delete a node from Figma",
416 | {
417 | nodeId: z.string().describe("The ID of the node to delete")
418 | },
419 | async ({ nodeId }) => {
420 | try {
421 | await sendCommandToFigma('delete_node', { nodeId });
422 | return {
423 | content: [
424 | {
425 | type: "text",
426 | text: `Deleted node with ID: ${nodeId}`
427 | }
428 | ]
429 | };
430 | } catch (error) {
431 | return {
432 | content: [
433 | {
434 | type: "text",
435 | text: `Error deleting node: ${error instanceof Error ? error.message : String(error)}`
436 | }
437 | ]
438 | };
439 | }
440 | }
441 | );
442 |
443 | // Get Styles Tool
444 | server.tool(
445 | "get_styles",
446 | "Get all styles from the current Figma document",
447 | {},
448 | async () => {
449 | try {
450 | const result = await sendCommandToFigma('get_styles');
451 | return {
452 | content: [
453 | {
454 | type: "text",
455 | text: JSON.stringify(result, null, 2)
456 | }
457 | ]
458 | };
459 | } catch (error) {
460 | return {
461 | content: [
462 | {
463 | type: "text",
464 | text: `Error getting styles: ${error instanceof Error ? error.message : String(error)}`
465 | }
466 | ]
467 | };
468 | }
469 | }
470 | );
471 |
472 | // Get Local Components Tool
473 | server.tool(
474 | "get_local_components",
475 | "Get all local components from the Figma document",
476 | {},
477 | async () => {
478 | try {
479 | const result = await sendCommandToFigma('get_local_components');
480 | return {
481 | content: [
482 | {
483 | type: "text",
484 | text: JSON.stringify(result, null, 2)
485 | }
486 | ]
487 | };
488 | } catch (error) {
489 | return {
490 | content: [
491 | {
492 | type: "text",
493 | text: `Error getting local components: ${error instanceof Error ? error.message : String(error)}`
494 | }
495 | ]
496 | };
497 | }
498 | }
499 | );
500 |
501 | // Get Team Components Tool
502 | // server.tool(
503 | // "get_team_components",
504 | // "Get all team library components available in Figma",
505 | // {},
506 | // async () => {
507 | // try {
508 | // const result = await sendCommandToFigma('get_team_components');
509 | // return {
510 | // content: [
511 | // {
512 | // type: "text",
513 | // text: JSON.stringify(result, null, 2)
514 | // }
515 | // ]
516 | // };
517 | // } catch (error) {
518 | // return {
519 | // content: [
520 | // {
521 | // type: "text",
522 | // text: `Error getting team components: ${error instanceof Error ? error.message : String(error)}`
523 | // }
524 | // ]
525 | // };
526 | // }
527 | // }
528 | // );
529 |
530 | // Create Component Instance Tool
531 | server.tool(
532 | "create_component_instance",
533 | "Create an instance of a component in Figma",
534 | {
535 | componentKey: z.string().describe("Key of the component to instantiate"),
536 | x: z.number().describe("X position"),
537 | y: z.number().describe("Y position")
538 | },
539 | async ({ componentKey, x, y }) => {
540 | try {
541 | const result = await sendCommandToFigma('create_component_instance', { componentKey, x, y });
542 | const typedResult = result as { name: string, id: string };
543 | return {
544 | content: [
545 | {
546 | type: "text",
547 | text: `Created component instance "${typedResult.name}" with ID: ${typedResult.id}`
548 | }
549 | ]
550 | };
551 | } catch (error) {
552 | return {
553 | content: [
554 | {
555 | type: "text",
556 | text: `Error creating component instance: ${error instanceof Error ? error.message : String(error)}`
557 | }
558 | ]
559 | };
560 | }
561 | }
562 | );
563 |
564 | // Export Node as Image Tool
565 | server.tool(
566 | "export_node_as_image",
567 | "Export a node as an image from Figma",
568 | {
569 | nodeId: z.string().describe("The ID of the node to export"),
570 | format: z.enum(["PNG", "JPG", "SVG", "PDF"]).optional().describe("Export format"),
571 | scale: z.number().positive().optional().describe("Export scale")
572 | },
573 | async ({ nodeId, format, scale }) => {
574 | try {
575 | const result = await sendCommandToFigma('export_node_as_image', {
576 | nodeId,
577 | format: format || 'PNG',
578 | scale: scale || 1
579 | });
580 | const typedResult = result as { imageData: string, mimeType: string };
581 |
582 | return {
583 | content: [
584 | {
585 | type: "image",
586 | data: typedResult.imageData,
587 | mimeType: typedResult.mimeType || "image/png"
588 | }
589 | ]
590 | };
591 | } catch (error) {
592 | return {
593 | content: [
594 | {
595 | type: "text",
596 | text: `Error exporting node as image: ${error instanceof Error ? error.message : String(error)}`
597 | }
598 | ]
599 | };
600 | }
601 | }
602 | );
603 |
604 | // Execute Figma Code Tool
605 | // server.tool(
606 | // "execute_figma_code",
607 | // "Execute arbitrary JavaScript code in Figma (use with caution)",
608 | // {
609 | // code: z.string().describe("JavaScript code to execute in Figma")
610 | // },
611 | // async ({ code }) => {
612 | // try {
613 | // const result = await sendCommandToFigma('execute_code', { code });
614 | // return {
615 | // content: [
616 | // {
617 | // type: "text",
618 | // text: `Code executed successfully: ${JSON.stringify(result, null, 2)}`
619 | // }
620 | // ]
621 | // };
622 | // } catch (error) {
623 | // return {
624 | // content: [
625 | // {
626 | // type: "text",
627 | // text: `Error executing code: ${error instanceof Error ? error.message : String(error)}`
628 | // }
629 | // ]
630 | // };
631 | // }
632 | // }
633 | // );
634 |
635 | // Set Corner Radius Tool
636 | server.tool(
637 | "set_corner_radius",
638 | "Set the corner radius of a node in Figma",
639 | {
640 | nodeId: z.string().describe("The ID of the node to modify"),
641 | radius: z.number().min(0).describe("Corner radius value"),
642 | corners: z.array(z.boolean()).length(4).optional().describe("Optional array of 4 booleans to specify which corners to round [topLeft, topRight, bottomRight, bottomLeft]")
643 | },
644 | async ({ nodeId, radius, corners }) => {
645 | try {
646 | const result = await sendCommandToFigma('set_corner_radius', {
647 | nodeId,
648 | radius,
649 | corners: corners || [true, true, true, true]
650 | });
651 | const typedResult = result as { name: string };
652 | return {
653 | content: [
654 | {
655 | type: "text",
656 | text: `Set corner radius of node "${typedResult.name}" to ${radius}px`
657 | }
658 | ]
659 | };
660 | } catch (error) {
661 | return {
662 | content: [
663 | {
664 | type: "text",
665 | text: `Error setting corner radius: ${error instanceof Error ? error.message : String(error)}`
666 | }
667 | ]
668 | };
669 | }
670 | }
671 | );
672 |
673 | // Define design strategy prompt
674 | server.prompt(
675 | "design_strategy",
676 | "Best practices for working with Figma designs",
677 | (extra) => {
678 | return {
679 | messages: [
680 | {
681 | role: "assistant",
682 | content: {
683 | type: "text",
684 | text: `When working with Figma designs, follow these best practices:
685 |
686 | 1. Start with Document Structure:
687 | - First use get_document_info() to understand the current document
688 | - Plan your layout hierarchy before creating elements
689 | - Create a main container frame for each screen/section
690 |
691 | 2. Naming Conventions:
692 | - Use descriptive, semantic names for all elements
693 | - Follow a consistent naming pattern (e.g., "Login Screen", "Logo Container", "Email Input")
694 | - Group related elements with meaningful names
695 |
696 | 3. Layout Hierarchy:
697 | - Create parent frames first, then add child elements
698 | - For forms/login screens:
699 | * Start with the main screen container frame
700 | * Create a logo container at the top
701 | * Group input fields in their own containers
702 | * Place action buttons (login, submit) after inputs
703 | * Add secondary elements (forgot password, signup links) last
704 |
705 | 4. Input Fields Structure:
706 | - Create a container frame for each input field
707 | - Include a label text above or inside the input
708 | - Group related inputs (e.g., username/password) together
709 |
710 | 5. Element Creation:
711 | - Use create_frame() for containers and input fields
712 | - Use create_text() for labels, buttons text, and links
713 | - Set appropriate colors and styles:
714 | * Use fillColor for backgrounds
715 | * Use strokeColor for borders
716 | * Set proper fontWeight for different text elements
717 |
718 | 6. Visual Hierarchy:
719 | - Position elements in logical reading order (top to bottom)
720 | - Maintain consistent spacing between elements
721 | - Use appropriate font sizes for different text types:
722 | * Larger for headings/welcome text
723 | * Medium for input labels
724 | * Standard for button text
725 | * Smaller for helper text/links
726 |
727 | 7. Best Practices:
728 | - Verify each creation with get_node_info()
729 | - Use parentId to maintain proper hierarchy
730 | - Group related elements together in frames
731 | - Keep consistent spacing and alignment
732 |
733 | Example Login Screen Structure:
734 | - Login Screen (main frame)
735 | - Logo Container (frame)
736 | - Logo (image/text)
737 | - Welcome Text (text)
738 | - Input Container (frame)
739 | - Email Input (frame)
740 | - Email Label (text)
741 | - Email Field (frame)
742 | - Password Input (frame)
743 | - Password Label (text)
744 | - Password Field (frame)
745 | - Login Button (frame)
746 | - Button Text (text)
747 | - Helper Links (frame)
748 | - Forgot Password (text)
749 | - Don't have account (text)`
750 | }
751 | }
752 | ],
753 | description: "Best practices for working with Figma designs"
754 | };
755 | }
756 | );
757 |
758 | // Define command types and parameters
759 | type FigmaCommand =
760 | | 'get_document_info'
761 | | 'get_selection'
762 | | 'get_node_info'
763 | | 'create_rectangle'
764 | | 'create_frame'
765 | | 'create_text'
766 | | 'set_fill_color'
767 | | 'set_stroke_color'
768 | | 'move_node'
769 | | 'resize_node'
770 | | 'delete_node'
771 | | 'get_styles'
772 | | 'get_local_components'
773 | | 'get_team_components'
774 | | 'create_component_instance'
775 | | 'export_node_as_image'
776 | | 'execute_code'
777 | | 'join'
778 | | 'set_corner_radius';
779 |
780 | // Helper function to process Figma node responses
781 | function processFigmaNodeResponse(result: unknown): any {
782 | if (!result || typeof result !== 'object') {
783 | return result;
784 | }
785 |
786 | // Check if this looks like a node response
787 | const resultObj = result as Record<string, unknown>;
788 | if ('id' in resultObj && typeof resultObj.id === 'string') {
789 | // It appears to be a node response, log the details
790 | console.info(`Processed Figma node: ${resultObj.name || 'Unknown'} (ID: ${resultObj.id})`);
791 |
792 | if ('x' in resultObj && 'y' in resultObj) {
793 | console.debug(`Node position: (${resultObj.x}, ${resultObj.y})`);
794 | }
795 |
796 | if ('width' in resultObj && 'height' in resultObj) {
797 | console.debug(`Node dimensions: ${resultObj.width}×${resultObj.height}`);
798 | }
799 | }
800 |
801 | return result;
802 | }
803 |
804 | // Simple function to connect to Figma WebSocket server
805 | function connectToFigma(port: number = 3056) {
806 | // If already connected, do nothing
807 | if (ws && ws.readyState === WebSocket.OPEN) {
808 | console.info('Already connected to Figma');
809 | return;
810 | }
811 |
812 | console.info(`Connecting to Figma socket server on port ${port}...`);
813 | ws = new WebSocket(`ws://localhost:${port}`);
814 |
815 | ws.on('open', () => {
816 | console.info('Connected to Figma socket server');
817 | // Reset channel on new connection
818 | currentChannel = null;
819 | });
820 |
821 | ws.on('message', (data: any) => {
822 | try {
823 | const json = JSON.parse(data) as { message: FigmaResponse };
824 | const myResponse = json.message;
825 | console.debug(`Received message: ${JSON.stringify(myResponse)}`);
826 | console.log('myResponse', myResponse);
827 |
828 | // Handle response to a request
829 | if (myResponse.id && pendingRequests.has(myResponse.id) && myResponse.result) {
830 | const request = pendingRequests.get(myResponse.id)!;
831 | clearTimeout(request.timeout);
832 |
833 | if (myResponse.error) {
834 | console.error(`Error from Figma: ${myResponse.error}`);
835 | request.reject(new Error(myResponse.error));
836 | } else {
837 | if (myResponse.result) {
838 | request.resolve(myResponse.result);
839 | }
840 | }
841 |
842 | pendingRequests.delete(myResponse.id);
843 | } else {
844 | // Handle broadcast messages or events
845 | console.info(`Received broadcast message: ${JSON.stringify(myResponse)}`);
846 | }
847 | } catch (error) {
848 | console.error(`Error parsing message: ${error instanceof Error ? error.message : String(error)}`);
849 | }
850 | });
851 |
852 | ws.on('error', (error) => {
853 | console.error(`Socket error: ${error}`);
854 | });
855 |
856 | ws.on('close', () => {
857 | console.info('Disconnected from Figma socket server');
858 | ws = null;
859 |
860 | // Reject all pending requests
861 | for (const [id, request] of pendingRequests.entries()) {
862 | clearTimeout(request.timeout);
863 | request.reject(new Error('Connection closed'));
864 | pendingRequests.delete(id);
865 | }
866 |
867 | // Attempt to reconnect
868 | console.info('Attempting to reconnect in 2 seconds...');
869 | setTimeout(() => connectToFigma(port), 2000);
870 | });
871 | }
872 |
873 | // Function to join a channel
874 | async function joinChannel(channelName: string): Promise<void> {
875 | if (!ws || ws.readyState !== WebSocket.OPEN) {
876 | throw new Error('Not connected to Figma');
877 | }
878 |
879 | try {
880 | await sendCommandToFigma('join', { channel: channelName });
881 | currentChannel = channelName;
882 | console.info(`Joined channel: ${channelName}`);
883 | } catch (error) {
884 | console.error(`Failed to join channel: ${error instanceof Error ? error.message : String(error)}`);
885 | throw error;
886 | }
887 | }
888 |
889 | // Function to send commands to Figma
890 | function sendCommandToFigma(command: FigmaCommand, params: unknown = {}): Promise<unknown> {
891 | return new Promise((resolve, reject) => {
892 | // If not connected, try to connect first
893 | if (!ws || ws.readyState !== WebSocket.OPEN) {
894 | connectToFigma();
895 | reject(new Error('Not connected to Figma. Attempting to connect...'));
896 | return;
897 | }
898 |
899 | // Check if we need a channel for this command
900 | const requiresChannel = command !== 'join';
901 | if (requiresChannel && !currentChannel) {
902 | reject(new Error('Must join a channel before sending commands'));
903 | return;
904 | }
905 |
906 | const id = uuidv4();
907 | const request = {
908 | id,
909 | type: command === 'join' ? 'join' : 'message',
910 | ...(command === 'join' ? { channel: (params as any).channel } : { channel: currentChannel }),
911 | message: {
912 | id,
913 | command,
914 | params: {
915 | ...(params as any),
916 | }
917 | }
918 | };
919 |
920 | // Set timeout for request
921 | const timeout = setTimeout(() => {
922 | if (pendingRequests.has(id)) {
923 | pendingRequests.delete(id);
924 | console.error(`Request ${id} to Figma timed out after 30 seconds`);
925 | reject(new Error('Request to Figma timed out'));
926 | }
927 | }, 30000); // 30 second timeout
928 |
929 | // Store the promise callbacks to resolve/reject later
930 | pendingRequests.set(id, { resolve, reject, timeout });
931 |
932 | // Send the request
933 | console.info(`Sending command to Figma: ${command}`);
934 | console.debug(`Request details: ${JSON.stringify(request)}`);
935 | ws.send(JSON.stringify(request));
936 | });
937 | }
938 |
939 | // Update the join_channel tool
940 | server.tool(
941 | "join_channel",
942 | "Join a specific channel to communicate with Figma",
943 | {
944 | channel: z.string().describe("The name of the channel to join").default("")
945 | },
946 | async ({ channel }) => {
947 | try {
948 | if (!channel) {
949 | // If no channel provided, ask the user for input
950 | return {
951 | content: [
952 | {
953 | type: "text",
954 | text: "Please provide a channel name to join:"
955 | }
956 | ],
957 | followUp: {
958 | tool: "join_channel",
959 | description: "Join the specified channel"
960 | }
961 | };
962 | }
963 |
964 | await joinChannel(channel);
965 | return {
966 | content: [
967 | {
968 | type: "text",
969 | text: `Successfully joined channel: ${channel}`
970 | }
971 | ]
972 | };
973 | } catch (error) {
974 | return {
975 | content: [
976 | {
977 | type: "text",
978 | text: `Error joining channel: ${error instanceof Error ? error.message : String(error)}`
979 | }
980 | ]
981 | };
982 | }
983 | }
984 | );
985 |
986 | // Start the server
987 | async function main() {
988 | try {
989 | // Try to connect to Figma socket server
990 | connectToFigma();
991 | } catch (error) {
992 | console.warn(`Could not connect to Figma initially: ${error instanceof Error ? error.message : String(error)}`);
993 | console.warn('Will try to connect when the first command is sent');
994 | }
995 |
996 | // Start the MCP server with stdio transport
997 | const transport = new StdioServerTransport();
998 | await server.connect(transport);
999 | console.info('FigmaMCP server running on stdio');
1000 | }
1001 |
1002 | // Run the server
1003 | main().catch(error => {
1004 | console.error(`Error starting FigmaMCP server: ${error instanceof Error ? error.message : String(error)}`);
1005 | process.exit(1);
1006 | });
```