figma-code-agent-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assetServer.d.ts +34 -0
- package/dist/assetServer.js +168 -0
- package/dist/codeGenerator/componentDetector.d.ts +57 -0
- package/dist/codeGenerator/componentDetector.js +171 -0
- package/dist/codeGenerator/index.d.ts +77 -0
- package/dist/codeGenerator/index.js +184 -0
- package/dist/codeGenerator/jsxGenerator.d.ts +46 -0
- package/dist/codeGenerator/jsxGenerator.js +182 -0
- package/dist/codeGenerator/styleConverter.d.ts +95 -0
- package/dist/codeGenerator/styleConverter.js +306 -0
- package/dist/hints.d.ts +14 -0
- package/dist/hints.js +105 -0
- package/dist/hub.d.ts +9 -0
- package/dist/hub.js +252 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +146 -0
- package/dist/sandbox.d.ts +18 -0
- package/dist/sandbox.js +154 -0
- package/dist/toolRegistry.d.ts +19 -0
- package/dist/toolRegistry.js +729 -0
- package/dist/tools/captureScreenshot.d.ts +28 -0
- package/dist/tools/captureScreenshot.js +31 -0
- package/dist/tools/cloneNode.d.ts +43 -0
- package/dist/tools/cloneNode.js +46 -0
- package/dist/tools/createFrame.d.ts +157 -0
- package/dist/tools/createFrame.js +114 -0
- package/dist/tools/createInstance.d.ts +38 -0
- package/dist/tools/createInstance.js +41 -0
- package/dist/tools/createRectangle.d.ts +108 -0
- package/dist/tools/createRectangle.js +77 -0
- package/dist/tools/createText.d.ts +81 -0
- package/dist/tools/createText.js +67 -0
- package/dist/tools/deleteNode.d.ts +15 -0
- package/dist/tools/deleteNode.js +18 -0
- package/dist/tools/execute.d.ts +17 -0
- package/dist/tools/execute.js +68 -0
- package/dist/tools/getCurrentPage.d.ts +16 -0
- package/dist/tools/getCurrentPage.js +19 -0
- package/dist/tools/getDesignContext.d.ts +42 -0
- package/dist/tools/getDesignContext.js +55 -0
- package/dist/tools/getLocalComponents.d.ts +20 -0
- package/dist/tools/getLocalComponents.js +23 -0
- package/dist/tools/getNode.d.ts +30 -0
- package/dist/tools/getNode.js +33 -0
- package/dist/tools/getSelection.d.ts +10 -0
- package/dist/tools/getSelection.js +13 -0
- package/dist/tools/getStyles.d.ts +17 -0
- package/dist/tools/getStyles.js +20 -0
- package/dist/tools/getVariables.d.ts +26 -0
- package/dist/tools/getVariables.js +29 -0
- package/dist/tools/moveNode.d.ts +28 -0
- package/dist/tools/moveNode.js +31 -0
- package/dist/tools/openInEditor.d.ts +21 -0
- package/dist/tools/openInEditor.js +98 -0
- package/dist/tools/searchNodes.d.ts +30 -0
- package/dist/tools/searchNodes.js +46 -0
- package/dist/tools/searchTools.d.ts +28 -0
- package/dist/tools/searchTools.js +28 -0
- package/dist/tools/swapComponent.d.ts +23 -0
- package/dist/tools/swapComponent.js +26 -0
- package/dist/tools/updateNode.d.ts +194 -0
- package/dist/tools/updateNode.js +163 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +1 -0
- package/dist/websocket.d.ts +30 -0
- package/dist/websocket.js +282 -0
- package/package.json +29 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { figmaWebSocket } from '../websocket.js';
|
|
2
|
+
export const updateNodeSchema = {
|
|
3
|
+
name: 'update_node',
|
|
4
|
+
description: 'Update properties of an existing node in Figma. To copy image fills between nodes, use copyFillsFrom with the source node ID.',
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
nodeId: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'ID of the node to update',
|
|
11
|
+
},
|
|
12
|
+
properties: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
description: 'Properties to update on the node',
|
|
15
|
+
properties: {
|
|
16
|
+
name: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'New name for the node',
|
|
19
|
+
},
|
|
20
|
+
x: {
|
|
21
|
+
type: 'number',
|
|
22
|
+
description: 'New X position',
|
|
23
|
+
},
|
|
24
|
+
y: {
|
|
25
|
+
type: 'number',
|
|
26
|
+
description: 'New Y position',
|
|
27
|
+
},
|
|
28
|
+
width: {
|
|
29
|
+
type: 'number',
|
|
30
|
+
description: 'New width (only for resizable nodes)',
|
|
31
|
+
},
|
|
32
|
+
height: {
|
|
33
|
+
type: 'number',
|
|
34
|
+
description: 'New height (only for resizable nodes)',
|
|
35
|
+
},
|
|
36
|
+
fillColor: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
description: 'New fill color (RGB values from 0-1)',
|
|
39
|
+
properties: {
|
|
40
|
+
r: { type: 'number', minimum: 0, maximum: 1 },
|
|
41
|
+
g: { type: 'number', minimum: 0, maximum: 1 },
|
|
42
|
+
b: { type: 'number', minimum: 0, maximum: 1 },
|
|
43
|
+
a: { type: 'number', minimum: 0, maximum: 1, default: 1 },
|
|
44
|
+
},
|
|
45
|
+
required: ['r', 'g', 'b'],
|
|
46
|
+
},
|
|
47
|
+
opacity: {
|
|
48
|
+
type: 'number',
|
|
49
|
+
description: 'Node opacity (0-1)',
|
|
50
|
+
minimum: 0,
|
|
51
|
+
maximum: 1,
|
|
52
|
+
},
|
|
53
|
+
visible: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Whether the node is visible',
|
|
56
|
+
},
|
|
57
|
+
locked: {
|
|
58
|
+
type: 'boolean',
|
|
59
|
+
description: 'Whether the node is locked',
|
|
60
|
+
},
|
|
61
|
+
text: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'New text content (only for text nodes)',
|
|
64
|
+
},
|
|
65
|
+
fontSize: {
|
|
66
|
+
type: 'number',
|
|
67
|
+
description: 'New font size (only for text nodes)',
|
|
68
|
+
},
|
|
69
|
+
fontWeight: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'Font weight for text nodes (e.g., "Regular", "Bold", "Medium")',
|
|
72
|
+
},
|
|
73
|
+
cornerRadius: {
|
|
74
|
+
type: 'number',
|
|
75
|
+
description: 'Corner radius for frames and rectangles',
|
|
76
|
+
},
|
|
77
|
+
strokeColor: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
description: 'Stroke/border color (RGB values from 0-1)',
|
|
80
|
+
properties: {
|
|
81
|
+
r: { type: 'number', minimum: 0, maximum: 1 },
|
|
82
|
+
g: { type: 'number', minimum: 0, maximum: 1 },
|
|
83
|
+
b: { type: 'number', minimum: 0, maximum: 1 },
|
|
84
|
+
a: { type: 'number', minimum: 0, maximum: 1, default: 1 },
|
|
85
|
+
},
|
|
86
|
+
required: ['r', 'g', 'b'],
|
|
87
|
+
},
|
|
88
|
+
strokeWeight: {
|
|
89
|
+
type: 'number',
|
|
90
|
+
description: 'Stroke/border width in pixels',
|
|
91
|
+
},
|
|
92
|
+
copyFillsFrom: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'Node ID to copy fills from (including image fills). This copies the entire fills array from the source node.',
|
|
95
|
+
},
|
|
96
|
+
copyStrokesFrom: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'Node ID to copy strokes from. This copies the entire strokes array from the source node.',
|
|
99
|
+
},
|
|
100
|
+
layoutMode: {
|
|
101
|
+
type: 'string',
|
|
102
|
+
enum: ['NONE', 'HORIZONTAL', 'VERTICAL'],
|
|
103
|
+
description: 'Auto-layout direction. HORIZONTAL = row, VERTICAL = column, NONE = disable auto-layout',
|
|
104
|
+
},
|
|
105
|
+
primaryAxisSizingMode: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
enum: ['FIXED', 'AUTO'],
|
|
108
|
+
description: 'How the frame sizes itself along the primary axis (FIXED = fixed size, AUTO = hug contents)',
|
|
109
|
+
},
|
|
110
|
+
counterAxisSizingMode: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
enum: ['FIXED', 'AUTO'],
|
|
113
|
+
description: 'How the frame sizes itself along the counter axis',
|
|
114
|
+
},
|
|
115
|
+
primaryAxisAlignItems: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
enum: ['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN'],
|
|
118
|
+
description: 'Alignment of children along primary axis',
|
|
119
|
+
},
|
|
120
|
+
counterAxisAlignItems: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
enum: ['MIN', 'CENTER', 'MAX', 'BASELINE'],
|
|
123
|
+
description: 'Alignment of children along counter axis',
|
|
124
|
+
},
|
|
125
|
+
itemSpacing: {
|
|
126
|
+
type: 'number',
|
|
127
|
+
description: 'Gap between children in pixels (for auto-layout frames)',
|
|
128
|
+
},
|
|
129
|
+
paddingTop: {
|
|
130
|
+
type: 'number',
|
|
131
|
+
description: 'Top padding in pixels (for auto-layout frames)',
|
|
132
|
+
},
|
|
133
|
+
paddingBottom: {
|
|
134
|
+
type: 'number',
|
|
135
|
+
description: 'Bottom padding in pixels (for auto-layout frames)',
|
|
136
|
+
},
|
|
137
|
+
paddingLeft: {
|
|
138
|
+
type: 'number',
|
|
139
|
+
description: 'Left padding in pixels (for auto-layout frames)',
|
|
140
|
+
},
|
|
141
|
+
paddingRight: {
|
|
142
|
+
type: 'number',
|
|
143
|
+
description: 'Right padding in pixels (for auto-layout frames)',
|
|
144
|
+
},
|
|
145
|
+
layoutWrap: {
|
|
146
|
+
type: 'string',
|
|
147
|
+
enum: ['NO_WRAP', 'WRAP'],
|
|
148
|
+
description: 'Whether children wrap to new lines (for auto-layout frames)',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
returnScreenshot: {
|
|
153
|
+
type: 'boolean',
|
|
154
|
+
description: 'If true, returns a screenshot of the updated node for visual verification',
|
|
155
|
+
default: false,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ['nodeId', 'properties'],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
export async function updateNode(params) {
|
|
162
|
+
return figmaWebSocket.sendCommand('updateNode', params);
|
|
163
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export interface WebSocketMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
command: string;
|
|
4
|
+
params: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface WebSocketResponse {
|
|
7
|
+
id: string;
|
|
8
|
+
success: boolean;
|
|
9
|
+
result?: unknown;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PendingRequest {
|
|
13
|
+
resolve: (value: unknown) => void;
|
|
14
|
+
reject: (reason: Error) => void;
|
|
15
|
+
timeout: NodeJS.Timeout;
|
|
16
|
+
}
|
|
17
|
+
export interface FigmaNode {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
x?: number;
|
|
22
|
+
y?: number;
|
|
23
|
+
width?: number;
|
|
24
|
+
height?: number;
|
|
25
|
+
children?: FigmaNode[];
|
|
26
|
+
}
|
|
27
|
+
export interface CreateFrameParams {
|
|
28
|
+
name: string;
|
|
29
|
+
x?: number;
|
|
30
|
+
y?: number;
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
parentId?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface CreateRectangleParams {
|
|
36
|
+
name?: string;
|
|
37
|
+
x?: number;
|
|
38
|
+
y?: number;
|
|
39
|
+
width?: number;
|
|
40
|
+
height?: number;
|
|
41
|
+
fillColor?: {
|
|
42
|
+
r: number;
|
|
43
|
+
g: number;
|
|
44
|
+
b: number;
|
|
45
|
+
a?: number;
|
|
46
|
+
};
|
|
47
|
+
parentId?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface CreateTextParams {
|
|
50
|
+
text: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
x?: number;
|
|
53
|
+
y?: number;
|
|
54
|
+
fontSize?: number;
|
|
55
|
+
fontFamily?: string;
|
|
56
|
+
fillColor?: {
|
|
57
|
+
r: number;
|
|
58
|
+
g: number;
|
|
59
|
+
b: number;
|
|
60
|
+
a?: number;
|
|
61
|
+
};
|
|
62
|
+
parentId?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface CreateInstanceParams {
|
|
65
|
+
componentKey: string;
|
|
66
|
+
x?: number;
|
|
67
|
+
y?: number;
|
|
68
|
+
parentId?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface UpdateNodeParams {
|
|
71
|
+
nodeId: string;
|
|
72
|
+
properties: {
|
|
73
|
+
name?: string;
|
|
74
|
+
x?: number;
|
|
75
|
+
y?: number;
|
|
76
|
+
width?: number;
|
|
77
|
+
height?: number;
|
|
78
|
+
fillColor?: {
|
|
79
|
+
r: number;
|
|
80
|
+
g: number;
|
|
81
|
+
b: number;
|
|
82
|
+
a?: number;
|
|
83
|
+
};
|
|
84
|
+
opacity?: number;
|
|
85
|
+
visible?: boolean;
|
|
86
|
+
locked?: boolean;
|
|
87
|
+
text?: string;
|
|
88
|
+
fontSize?: number;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export interface DeleteNodeParams {
|
|
92
|
+
nodeId: string;
|
|
93
|
+
}
|
|
94
|
+
export interface GetNodeParams {
|
|
95
|
+
nodeId: string;
|
|
96
|
+
}
|
|
97
|
+
export interface CaptureScreenshotParams {
|
|
98
|
+
nodeId: string;
|
|
99
|
+
scale?: number;
|
|
100
|
+
format?: 'PNG' | 'JPG' | 'SVG' | 'PDF';
|
|
101
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ClientInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
projectPath?: string;
|
|
5
|
+
}
|
|
6
|
+
declare class FigmaWebSocketClient {
|
|
7
|
+
private ws;
|
|
8
|
+
private pendingRequests;
|
|
9
|
+
private isInitialized;
|
|
10
|
+
private mcpClientInfo;
|
|
11
|
+
private pluginEditorType;
|
|
12
|
+
private pluginConnected;
|
|
13
|
+
private sessionId;
|
|
14
|
+
private reconnectTimer;
|
|
15
|
+
initialize(): Promise<void>;
|
|
16
|
+
private readAuthToken;
|
|
17
|
+
private launchHub;
|
|
18
|
+
private tryConnect;
|
|
19
|
+
private setupListeners;
|
|
20
|
+
private scheduleReconnect;
|
|
21
|
+
private handleResponse;
|
|
22
|
+
sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
23
|
+
isConnected(): boolean;
|
|
24
|
+
getEditorType(): string | null;
|
|
25
|
+
setClientInfo(info: ClientInfo): void;
|
|
26
|
+
private sendClientInfo;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare const figmaWebSocket: FigmaWebSocketClient;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const PORT = Number(process.env.FIGMA_HUB_PORT) || 3001;
|
|
9
|
+
const COMMAND_TIMEOUT = 30000; // 30 seconds
|
|
10
|
+
const HUB_CONNECT_TIMEOUT = 5000; // 5 seconds to wait for hub startup
|
|
11
|
+
const MAX_PENDING = 100;
|
|
12
|
+
class FigmaWebSocketClient {
|
|
13
|
+
ws = null;
|
|
14
|
+
pendingRequests = new Map();
|
|
15
|
+
isInitialized = false;
|
|
16
|
+
mcpClientInfo = null;
|
|
17
|
+
pluginEditorType = null;
|
|
18
|
+
pluginConnected = false;
|
|
19
|
+
sessionId = uuidv4();
|
|
20
|
+
reconnectTimer = null;
|
|
21
|
+
async initialize() {
|
|
22
|
+
if (this.isInitialized)
|
|
23
|
+
return;
|
|
24
|
+
this.isInitialized = true;
|
|
25
|
+
// Try connecting to an existing hub first
|
|
26
|
+
const connected = await this.tryConnect();
|
|
27
|
+
if (connected)
|
|
28
|
+
return;
|
|
29
|
+
// No hub running — launch one
|
|
30
|
+
await this.launchHub();
|
|
31
|
+
// Now connect to it
|
|
32
|
+
const retryConnected = await this.tryConnect();
|
|
33
|
+
if (!retryConnected) {
|
|
34
|
+
console.error('[WebSocket] Failed to connect to hub after launching it');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
readAuthToken() {
|
|
38
|
+
try {
|
|
39
|
+
const tokenPath = path.join(os.homedir(), '.figma-mcp-hub-token');
|
|
40
|
+
return fs.readFileSync(tokenPath, 'utf-8').trim();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async launchHub() {
|
|
47
|
+
const lockPath = path.join(os.homedir(), '.figma-mcp-hub.lock');
|
|
48
|
+
// Check if another process is already launching the hub
|
|
49
|
+
try {
|
|
50
|
+
const lockContent = fs.readFileSync(lockPath, 'utf-8').trim();
|
|
51
|
+
const lockTime = parseInt(lockContent, 10);
|
|
52
|
+
// If lock is less than 10 seconds old, skip spawning
|
|
53
|
+
if (Date.now() - lockTime < 10000) {
|
|
54
|
+
console.error('[WebSocket] Hub launch already in progress, waiting...');
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// No lock file, proceed
|
|
61
|
+
}
|
|
62
|
+
// Write lock file
|
|
63
|
+
try {
|
|
64
|
+
fs.writeFileSync(lockPath, String(Date.now()), { mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// If we can't write lock, proceed anyway
|
|
68
|
+
}
|
|
69
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
70
|
+
const __dirname = path.dirname(__filename);
|
|
71
|
+
const hubPath = path.join(__dirname, 'hub.js');
|
|
72
|
+
console.error('[WebSocket] Launching hub...');
|
|
73
|
+
const child = spawn(process.execPath, [hubPath], {
|
|
74
|
+
detached: true,
|
|
75
|
+
stdio: 'ignore',
|
|
76
|
+
env: { ...process.env, FIGMA_HUB_PORT: String(PORT) },
|
|
77
|
+
});
|
|
78
|
+
child.unref();
|
|
79
|
+
// Wait for hub to be ready
|
|
80
|
+
await new Promise((resolve) => {
|
|
81
|
+
const start = Date.now();
|
|
82
|
+
const check = () => {
|
|
83
|
+
const probe = new WebSocket(`ws://localhost:${PORT}`);
|
|
84
|
+
probe.on('open', () => {
|
|
85
|
+
probe.close();
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
probe.on('error', () => {
|
|
89
|
+
if (Date.now() - start > HUB_CONNECT_TIMEOUT) {
|
|
90
|
+
resolve(); // give up waiting, tryConnect will fail
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
setTimeout(check, 200);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
check();
|
|
98
|
+
});
|
|
99
|
+
// Clean up lock after hub is ready
|
|
100
|
+
try {
|
|
101
|
+
fs.unlinkSync(lockPath);
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
}
|
|
105
|
+
tryConnect() {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
try {
|
|
108
|
+
const ws = new WebSocket(`ws://localhost:${PORT}`);
|
|
109
|
+
const timeout = setTimeout(() => {
|
|
110
|
+
ws.close();
|
|
111
|
+
resolve(false);
|
|
112
|
+
}, 3000);
|
|
113
|
+
ws.on('open', () => {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
this.ws = ws;
|
|
116
|
+
this.setupListeners();
|
|
117
|
+
// Register as MCP server
|
|
118
|
+
const token = this.readAuthToken();
|
|
119
|
+
ws.send(JSON.stringify({
|
|
120
|
+
type: 'register',
|
|
121
|
+
role: 'mcp-server',
|
|
122
|
+
sessionId: this.sessionId,
|
|
123
|
+
token,
|
|
124
|
+
}));
|
|
125
|
+
// Send client info if already available
|
|
126
|
+
if (this.mcpClientInfo) {
|
|
127
|
+
this.sendClientInfo();
|
|
128
|
+
}
|
|
129
|
+
console.error(`[WebSocket] Connected to hub (session: ${this.sessionId.slice(0, 8)})`);
|
|
130
|
+
resolve(true);
|
|
131
|
+
});
|
|
132
|
+
ws.on('error', () => {
|
|
133
|
+
clearTimeout(timeout);
|
|
134
|
+
resolve(false);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
resolve(false);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
setupListeners() {
|
|
143
|
+
if (!this.ws)
|
|
144
|
+
return;
|
|
145
|
+
this.ws.on('message', (data) => {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(data.toString());
|
|
148
|
+
// Hub status update
|
|
149
|
+
if (parsed.type === 'hub-status') {
|
|
150
|
+
this.pluginConnected = parsed.pluginConnected ?? false;
|
|
151
|
+
this.pluginEditorType = parsed.editorType ?? null;
|
|
152
|
+
console.error(`[WebSocket] Plugin connected: ${this.pluginConnected}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Plugin disconnected notification
|
|
156
|
+
if (parsed.type === 'plugin-disconnected') {
|
|
157
|
+
this.pluginConnected = false;
|
|
158
|
+
this.pluginEditorType = null;
|
|
159
|
+
console.error('[WebSocket] Figma plugin disconnected');
|
|
160
|
+
// Reject all pending requests
|
|
161
|
+
for (const [id, request] of this.pendingRequests) {
|
|
162
|
+
clearTimeout(request.timeout);
|
|
163
|
+
request.reject(new Error('Figma plugin disconnected'));
|
|
164
|
+
this.pendingRequests.delete(id);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Plugin connected notification
|
|
169
|
+
if (parsed.type === 'plugin-connected') {
|
|
170
|
+
this.pluginConnected = true;
|
|
171
|
+
this.pluginEditorType = parsed.editorType ?? null;
|
|
172
|
+
console.error('[WebSocket] Figma plugin connected');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Command response
|
|
176
|
+
this.handleResponse(parsed);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.error('[WebSocket] Failed to parse message:', error);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
this.ws.on('close', () => {
|
|
183
|
+
console.error('[WebSocket] Disconnected from hub');
|
|
184
|
+
this.ws = null;
|
|
185
|
+
this.pluginConnected = false;
|
|
186
|
+
// Reject pending requests
|
|
187
|
+
for (const [id, request] of this.pendingRequests) {
|
|
188
|
+
clearTimeout(request.timeout);
|
|
189
|
+
request.reject(new Error('Disconnected from hub'));
|
|
190
|
+
this.pendingRequests.delete(id);
|
|
191
|
+
}
|
|
192
|
+
// Try to reconnect
|
|
193
|
+
this.scheduleReconnect();
|
|
194
|
+
});
|
|
195
|
+
this.ws.on('error', (error) => {
|
|
196
|
+
console.error('[WebSocket] Error:', error.message);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
scheduleReconnect() {
|
|
200
|
+
if (this.reconnectTimer)
|
|
201
|
+
return;
|
|
202
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
203
|
+
this.reconnectTimer = null;
|
|
204
|
+
const connected = await this.tryConnect();
|
|
205
|
+
if (!connected) {
|
|
206
|
+
// Hub might have died — try launching a new one
|
|
207
|
+
await this.launchHub();
|
|
208
|
+
await this.tryConnect();
|
|
209
|
+
}
|
|
210
|
+
}, 2000);
|
|
211
|
+
}
|
|
212
|
+
handleResponse(response) {
|
|
213
|
+
const pending = this.pendingRequests.get(response.id);
|
|
214
|
+
if (!pending) {
|
|
215
|
+
// Not for us — ignore (another MCP server's response)
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
clearTimeout(pending.timeout);
|
|
219
|
+
this.pendingRequests.delete(response.id);
|
|
220
|
+
if (response.success) {
|
|
221
|
+
pending.resolve(response.result);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
pending.reject(new Error(response.error || 'Unknown error'));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async sendCommand(command, params = {}) {
|
|
228
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
229
|
+
throw new Error('Not connected to hub. Please ensure the MCP server is running.');
|
|
230
|
+
}
|
|
231
|
+
if (!this.pluginConnected) {
|
|
232
|
+
throw new Error('Figma plugin is not connected. Please open the Figma plugin first.');
|
|
233
|
+
}
|
|
234
|
+
if (this.pendingRequests.size >= MAX_PENDING) {
|
|
235
|
+
throw new Error('Too many pending requests. Please wait for some to complete.');
|
|
236
|
+
}
|
|
237
|
+
const id = uuidv4();
|
|
238
|
+
const message = { id, command, params };
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const timeout = setTimeout(() => {
|
|
241
|
+
this.pendingRequests.delete(id);
|
|
242
|
+
reject(new Error(`Command '${command}' timed out after ${COMMAND_TIMEOUT / 1000} seconds`));
|
|
243
|
+
}, COMMAND_TIMEOUT);
|
|
244
|
+
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
245
|
+
this.ws.send(JSON.stringify(message));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
isConnected() {
|
|
249
|
+
return this.pluginConnected;
|
|
250
|
+
}
|
|
251
|
+
getEditorType() {
|
|
252
|
+
return this.pluginEditorType;
|
|
253
|
+
}
|
|
254
|
+
setClientInfo(info) {
|
|
255
|
+
this.mcpClientInfo = info;
|
|
256
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
257
|
+
this.sendClientInfo();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
sendClientInfo() {
|
|
261
|
+
if (!this.ws || !this.mcpClientInfo)
|
|
262
|
+
return;
|
|
263
|
+
this.ws.send(JSON.stringify({
|
|
264
|
+
type: 'clientInfo',
|
|
265
|
+
clientInfo: this.mcpClientInfo,
|
|
266
|
+
}));
|
|
267
|
+
console.error(`[WebSocket] Sent client info: ${this.mcpClientInfo.name}`);
|
|
268
|
+
}
|
|
269
|
+
close() {
|
|
270
|
+
if (this.reconnectTimer) {
|
|
271
|
+
clearTimeout(this.reconnectTimer);
|
|
272
|
+
this.reconnectTimer = null;
|
|
273
|
+
}
|
|
274
|
+
if (this.ws) {
|
|
275
|
+
this.ws.close();
|
|
276
|
+
this.ws = null;
|
|
277
|
+
}
|
|
278
|
+
this.isInitialized = false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Singleton instance
|
|
282
|
+
export const figmaWebSocket = new FigmaWebSocketClient();
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "figma-code-agent-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Figma integration via WebSocket bridge",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"figma-code-agent": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"dev": "tsc --watch"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
20
|
+
"ws": "^8.16.0",
|
|
21
|
+
"uuid": "^9.0.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.10.0",
|
|
25
|
+
"@types/ws": "^8.5.10",
|
|
26
|
+
"@types/uuid": "^9.0.7",
|
|
27
|
+
"typescript": "^5.3.0"
|
|
28
|
+
}
|
|
29
|
+
}
|