genable-mcp 0.1.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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/httpBridge.js +186 -0
- package/dist/index.js +121 -0
- package/dist/tools-schema.json +1220 -0
- package/dist/wsRelay.js +272 -0
- package/package.json +54 -0
package/dist/wsRelay.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @file wsRelay.ts
|
|
4
|
+
* @description WebSocket relay between MCP/HTTP server and Figma plugin.
|
|
5
|
+
* Creates a WS server on a configurable port. The plugin connects as a client.
|
|
6
|
+
* MCP server calls `callTool()` which sends a request and awaits the response.
|
|
7
|
+
*
|
|
8
|
+
* Supports multiple concurrent Figma files. Each plugin instance sends
|
|
9
|
+
* { type: 'identify', name, fileKey, fileName } on connect. Tool calls
|
|
10
|
+
* can target a specific file via `callToolForFile(fileKey, ...)`.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.createWsRelay = createWsRelay;
|
|
14
|
+
const ws_1 = require("ws");
|
|
15
|
+
const child_process_1 = require("child_process");
|
|
16
|
+
const WS_PORT = process.env.MCP_WS_PORT ? parseInt(process.env.MCP_WS_PORT, 10) : 3458;
|
|
17
|
+
const KEEPALIVE_INTERVAL_MS = 15_000;
|
|
18
|
+
const HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
19
|
+
const RELAY_SECRET = process.env.RELAY_SECRET || '';
|
|
20
|
+
// Per-tool timeout: heavy tools (design, replace) need more headroom than reads.
|
|
21
|
+
const TOOL_TIMEOUTS = {
|
|
22
|
+
jsx: 120_000, // creates many nodes + font loading + icon fetch
|
|
23
|
+
replace: 90_000, // recursive tree traversal + font loading per text node
|
|
24
|
+
get_screenshot: 60_000, // exportAsync can be slow on complex nodes
|
|
25
|
+
};
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
27
|
+
// Generate unique client ID
|
|
28
|
+
let clientIdCounter = 0;
|
|
29
|
+
function generateClientId() {
|
|
30
|
+
return `client_${Date.now()}_${++clientIdCounter}`;
|
|
31
|
+
}
|
|
32
|
+
/** Try to kill whatever process is listening on WS_PORT. */
|
|
33
|
+
function killPortOccupant(port) {
|
|
34
|
+
try {
|
|
35
|
+
const out = (0, child_process_1.execSync)(`lsof -ti :${port}`, { encoding: 'utf8' }).trim();
|
|
36
|
+
if (out) {
|
|
37
|
+
for (const pid of out.split('\n')) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(Number(pid), 'SIGTERM');
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
console.error(`[WS] Killed stale process(es) on port ${port}: ${out.replace(/\n/g, ', ')}`);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// lsof returns non-zero when no matches — that's fine
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function setupClient(wss, state, pending) {
|
|
53
|
+
wss.on('connection', (ws) => {
|
|
54
|
+
let identified = false;
|
|
55
|
+
let clientId = null;
|
|
56
|
+
const handshakeTimer = setTimeout(() => {
|
|
57
|
+
if (!identified) {
|
|
58
|
+
console.error(`[WS] Client failed to identify within ${HANDSHAKE_TIMEOUT_MS / 1000}s — closing`);
|
|
59
|
+
ws.close();
|
|
60
|
+
}
|
|
61
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
62
|
+
ws.on('message', (raw) => {
|
|
63
|
+
try {
|
|
64
|
+
const msg = JSON.parse(raw.toString());
|
|
65
|
+
// Handle identify / re-identify (file info may arrive later)
|
|
66
|
+
if (msg.type === 'identify') {
|
|
67
|
+
if (RELAY_SECRET && msg.secret !== RELAY_SECRET) {
|
|
68
|
+
console.error(`[WS] Rejecting "${msg.name || '(unnamed)'}" — invalid secret`);
|
|
69
|
+
ws.close(4003, 'auth-failed');
|
|
70
|
+
clearTimeout(handshakeTimer);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
clearTimeout(handshakeTimer);
|
|
74
|
+
if (!identified) {
|
|
75
|
+
// First identify — register client
|
|
76
|
+
identified = true;
|
|
77
|
+
clientId = generateClientId();
|
|
78
|
+
const pingTimer = setInterval(() => {
|
|
79
|
+
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
80
|
+
ws.ping();
|
|
81
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
82
|
+
state.clients.set(clientId, {
|
|
83
|
+
ws,
|
|
84
|
+
name: msg.name || '(unnamed)',
|
|
85
|
+
fileKey: msg.fileKey || '',
|
|
86
|
+
fileName: msg.fileName || '',
|
|
87
|
+
pingTimer,
|
|
88
|
+
});
|
|
89
|
+
console.error(`[WS] Client connected: "${msg.name}" (${clientId}) file=${msg.fileName || '?'} [${msg.fileKey || '?'}]`);
|
|
90
|
+
}
|
|
91
|
+
else if (clientId) {
|
|
92
|
+
// Re-identify — update file info (plugin sends this when file info becomes available)
|
|
93
|
+
const client = state.clients.get(clientId);
|
|
94
|
+
if (client) {
|
|
95
|
+
client.fileKey = msg.fileKey || client.fileKey;
|
|
96
|
+
client.fileName = msg.fileName || client.fileName;
|
|
97
|
+
console.error(`[WS] Client updated: "${msg.name}" (${clientId}) file=${client.fileName} [${client.fileKey}]`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Ignore messages from unidentified clients
|
|
103
|
+
if (!identified || !clientId)
|
|
104
|
+
return;
|
|
105
|
+
// Handle tool result response
|
|
106
|
+
const { requestId, response } = msg;
|
|
107
|
+
const req = pending.get(requestId);
|
|
108
|
+
if (req) {
|
|
109
|
+
if (req.clientId !== clientId) {
|
|
110
|
+
console.error(`[WS] Security: Client ${clientId} tried to respond to request from ${req.clientId}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
clearTimeout(req.timer);
|
|
114
|
+
pending.delete(requestId);
|
|
115
|
+
req.resolve(response);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
console.error('[WS] Failed to parse message:', e);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
ws.on('close', () => {
|
|
123
|
+
clearTimeout(handshakeTimer);
|
|
124
|
+
if (clientId && state.clients.has(clientId)) {
|
|
125
|
+
const client = state.clients.get(clientId);
|
|
126
|
+
clearInterval(client.pingTimer);
|
|
127
|
+
state.clients.delete(clientId);
|
|
128
|
+
console.error(`[WS] Client disconnected: "${client.name}" (${clientId}) file=${client.fileName}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
ws.on('error', (err) => {
|
|
132
|
+
console.error('[WS] WebSocket error:', err.message);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function createWsRelay() {
|
|
137
|
+
const pending = new Map();
|
|
138
|
+
const state = { clients: new Map() };
|
|
139
|
+
let reqCounter = 0;
|
|
140
|
+
let wss = null;
|
|
141
|
+
// --- Async boot ---
|
|
142
|
+
(async () => {
|
|
143
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
144
|
+
try {
|
|
145
|
+
wss = await listenOnPort(WS_PORT);
|
|
146
|
+
setupClient(wss, state, pending);
|
|
147
|
+
console.error(`[WS] WebSocket relay listening on port ${WS_PORT}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err.code === 'EADDRINUSE' && attempt === 0) {
|
|
152
|
+
console.error(`[WS] Port ${WS_PORT} in use, killing stale occupant…`);
|
|
153
|
+
killPortOccupant(WS_PORT);
|
|
154
|
+
await sleep(1000);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
console.error(`[WS] WebSocket relay failed to start: ${err.message}`);
|
|
158
|
+
console.error('[WS] Running in degraded mode — Figma-dependent tools will error.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
function findClient(fileIdentifier) {
|
|
164
|
+
if (state.clients.size === 0)
|
|
165
|
+
return null;
|
|
166
|
+
// If identifier specified, match against fileKey OR fileName (case-insensitive)
|
|
167
|
+
if (fileIdentifier) {
|
|
168
|
+
const needle = fileIdentifier.toLowerCase();
|
|
169
|
+
for (const client of state.clients.values()) {
|
|
170
|
+
if (client.ws.readyState !== ws_1.WebSocket.OPEN)
|
|
171
|
+
continue;
|
|
172
|
+
if (client.fileKey === fileIdentifier)
|
|
173
|
+
return client;
|
|
174
|
+
if (client.fileName.toLowerCase() === needle)
|
|
175
|
+
return client;
|
|
176
|
+
// Partial match on fileName (e.g. "test" matches "opencode genable test")
|
|
177
|
+
if (client.fileName.toLowerCase().includes(needle))
|
|
178
|
+
return client;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
// No identifier — use first available (backward compatible)
|
|
183
|
+
for (const client of state.clients.values()) {
|
|
184
|
+
if (client.ws.readyState === ws_1.WebSocket.OPEN)
|
|
185
|
+
return client;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
function doCallTool(client, clientId, toolName, parameters) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const timeoutMs = TOOL_TIMEOUTS[toolName] ?? DEFAULT_TIMEOUT_MS;
|
|
192
|
+
const requestId = `mcp_${Date.now()}_${++reqCounter}`;
|
|
193
|
+
const startTime = Date.now();
|
|
194
|
+
console.error(`[WS] → ${toolName} (${requestId}, file=${client.fileName}, timeout ${timeoutMs / 1000}s)`);
|
|
195
|
+
const timer = setTimeout(() => {
|
|
196
|
+
pending.delete(requestId);
|
|
197
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
198
|
+
reject(new Error(`Tool call "${toolName}" timed out after ${elapsed}s (limit: ${timeoutMs / 1000}s)`));
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
pending.set(requestId, {
|
|
201
|
+
clientId,
|
|
202
|
+
resolve: (response) => {
|
|
203
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
204
|
+
console.error(`[WS] ← ${toolName} (${requestId}, ${elapsed}s)`);
|
|
205
|
+
resolve(response);
|
|
206
|
+
},
|
|
207
|
+
reject,
|
|
208
|
+
timer,
|
|
209
|
+
});
|
|
210
|
+
client.ws.send(JSON.stringify({ requestId, toolName, parameters }));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
callTool(toolName, parameters) {
|
|
215
|
+
const client = findClient();
|
|
216
|
+
if (!client) {
|
|
217
|
+
return Promise.reject(new Error('Figma plugin is not connected. Open Figma and run the plugin first.'));
|
|
218
|
+
}
|
|
219
|
+
// Find clientId for this client
|
|
220
|
+
const clientId = [...state.clients.entries()].find(([_, c]) => c === client)?.[0];
|
|
221
|
+
if (!clientId)
|
|
222
|
+
return Promise.reject(new Error('Client not found'));
|
|
223
|
+
return doCallTool(client, clientId, toolName, parameters);
|
|
224
|
+
},
|
|
225
|
+
callToolForFile(fileKey, toolName, parameters) {
|
|
226
|
+
const client = findClient(fileKey);
|
|
227
|
+
if (!client) {
|
|
228
|
+
const available = [...state.clients.values()].map(c => `${c.fileName} [${c.fileKey}]`).join(', ');
|
|
229
|
+
return Promise.reject(new Error(`No Figma file with key "${fileKey}" is connected. Connected files: ${available || 'none'}`));
|
|
230
|
+
}
|
|
231
|
+
const clientId = [...state.clients.entries()].find(([_, c]) => c === client)?.[0];
|
|
232
|
+
if (!clientId)
|
|
233
|
+
return Promise.reject(new Error('Client not found'));
|
|
234
|
+
return doCallTool(client, clientId, toolName, parameters);
|
|
235
|
+
},
|
|
236
|
+
isPluginConnected() {
|
|
237
|
+
return findClient() !== null;
|
|
238
|
+
},
|
|
239
|
+
listClients() {
|
|
240
|
+
return [...state.clients.entries()]
|
|
241
|
+
.filter(([_, c]) => c.ws.readyState === ws_1.WebSocket.OPEN)
|
|
242
|
+
.map(([id, c]) => ({ clientId: id, fileKey: c.fileKey, fileName: c.fileName }));
|
|
243
|
+
},
|
|
244
|
+
close() {
|
|
245
|
+
state.clients.forEach((client) => {
|
|
246
|
+
clearInterval(client.pingTimer);
|
|
247
|
+
client.ws.close();
|
|
248
|
+
});
|
|
249
|
+
state.clients.clear();
|
|
250
|
+
pending.forEach((req) => {
|
|
251
|
+
clearTimeout(req.timer);
|
|
252
|
+
req.reject(new Error('Relay shutting down'));
|
|
253
|
+
});
|
|
254
|
+
pending.clear();
|
|
255
|
+
wss?.close();
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// --- helpers ---
|
|
260
|
+
function listenOnPort(port) {
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const server = new ws_1.WebSocketServer({ port });
|
|
263
|
+
server.once('listening', () => resolve(server));
|
|
264
|
+
server.once('error', (err) => {
|
|
265
|
+
server.close();
|
|
266
|
+
reject(err);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function sleep(ms) {
|
|
271
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
272
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "genable-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The write-side complement to Figma's official MCP. Build, edit, and search Figma nodes from any MCP client (Claude Code, Cursor, etc.) via the Genable plugin.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"genable-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"extract-schema": "tsx extract-schema.ts",
|
|
16
|
+
"build": "npm run extract-schema && tsc && node -e \"require('fs').copyFileSync('tools-schema.json','dist/tools-schema.json')\"",
|
|
17
|
+
"dev": "npm run extract-schema && tsx index.ts",
|
|
18
|
+
"start": "node dist/index.js",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"figma",
|
|
23
|
+
"mcp",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"figma-plugin",
|
|
26
|
+
"ai",
|
|
27
|
+
"claude-code",
|
|
28
|
+
"cursor",
|
|
29
|
+
"genable"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
36
|
+
"ws": "^8.18.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.0.0",
|
|
40
|
+
"@types/ws": "^8.5.0",
|
|
41
|
+
"tsx": "^4.0.0",
|
|
42
|
+
"typescript": ">=5"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/muse40007/figma-ai-generator-dogfood#readme",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/muse40007/figma-ai-generator-dogfood.git",
|
|
51
|
+
"directory": "tools/mcp-server"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT"
|
|
54
|
+
}
|