figma-rust-mcp 3.2.12 → 3.2.15

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.
@@ -1,155 +1,155 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { spawn } from 'node:child_process';
4
- import { createServer } from 'node:net';
5
- import { createInterface } from 'node:readline';
6
- import { readFileSync } from 'node:fs';
7
- import vm from 'node:vm';
8
-
9
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
- const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
11
- async function until(predicate) {
12
- const deadline = Date.now() + 5000;
13
- while (!await predicate()) { if (Date.now() > deadline) throw new Error('Timed out'); await delay(10); }
14
- }
15
- const decode = result => JSON.parse(result.content[0].text);
16
- const ok = result => { assert.notEqual(result.isError, true, result.content?.[0]?.text); return result; };
17
-
18
- // Uses only Node built-ins (Node >=22 supplies WebSocket) and the actual Rust
19
- // daemon. The canvas is simulated; this verifies transport/agent isolation.
20
- test('multiple tabs, reconnect ownership and independent frame tasks across MCP clients', { timeout: 20000 }, async t => {
21
- assert.equal(typeof WebSocket, 'function', 'This integration check requires Node >=22');
22
- const reservation = createServer();
23
- await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve));
24
- const port = reservation.address().port;
25
- await new Promise(resolve => reservation.close(resolve));
26
- const binary = process.env.FIGMA_TEST_BINARY || 'target/debug/figma-mcp';
27
- const server = spawn(binary, ['--server', '--port', String(port)], { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, FIGMA_MCP_PORT: String(port) } });
28
- let stderr = ''; server.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-4000); });
29
- const processes = [server], sockets = [], latest = new Map(), actions = [];
30
- t.after(() => { for (const socket of sockets) socket.close(); for (const process of processes) process.kill(); });
31
- const base = `http://127.0.0.1:${port}`;
32
- await until(async () => { try { return (await fetch(`${base}/health`)).ok; } catch { assert.equal(server.exitCode, null, stderr); return false; } });
33
- let rpcId = 0;
34
- async function rpc(name, args) {
35
- const response = await fetch(`${base}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
36
- body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/call', params: { name, arguments: args } }) });
37
- return (await response.json()).result;
38
- }
39
- function agent() {
40
- const process = spawn(binary, ['--stdio', '--port', String(port)], { env: { ...globalThis.process.env, FIGMA_MCP_PORT: String(port) } });
41
- processes.push(process); process.stderr.resume();
42
- let id = 0;
43
- const pending = new Map();
44
- createInterface({ input: process.stdout }).on('line', line => {
45
- const response = JSON.parse(line); const resolve = pending.get(response.id);
46
- if (resolve) { pending.delete(response.id); resolve(response.result); }
47
- });
48
- return (name, args) => new Promise(resolve => {
49
- pending.set(++id, resolve);
50
- process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }) + '\n');
51
- });
52
- }
53
- const paused = deferred(), resume = deferred();
54
- async function plugin(sid, existing) {
55
- const state = existing || {};
56
- if (!state.runtime) {
57
- const page = { id: 'page', type: 'PAGE' };
58
- const nodes = new Map();
59
- for (const suffix of ['a', 'b', 'c']) {
60
- const root = { id: `frame-${suffix}`, name: suffix, type: 'FRAME', parent: page, children: [] };
61
- const child = { id: `child-${suffix}`, type: 'TEXT', parent: root }; root.children.push(child);
62
- nodes.set(root.id, root); nodes.set(child.id, child);
63
- }
64
- state.runtime = vm.createContext({ handlers: {}, findNodeByIdAsync: async id => nodes.get(id), yieldToUI: async () => {} });
65
- vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), state.runtime);
66
- state.runtime.handlers.get_design = async params => ({ id: params.id, type: 'FRAME', name: params.id, tab: sid });
67
- state.runtime.handlers.status = async () => ({ tab: sid });
68
- state.runtime.handlers.modify = async params => {
69
- actions.push(params.note);
70
- if (params.note === 'paused') { paused.resolve(); await resume.promise; }
71
- return { id: params.id, note: params.note };
72
- };
73
- state.tail = Promise.resolve();
74
- }
75
- const socket = new WebSocket(`ws://127.0.0.1:${port}/ws?sessionId=${sid}&fileName=SameFile&documentId=same-document`);
76
- sockets.push(socket); state.socket = socket; latest.set(sid, state);
77
- socket.addEventListener('message', event => {
78
- const message = JSON.parse(event.data);
79
- if (message.type === 'server-hello') {
80
- for (const id of state.runtime.frameTasks.keys()) if (!message.activeTaskIds.includes(id)) state.runtime.frameTasks.delete(id);
81
- return;
82
- }
83
- if (!message.operation) return;
84
- if (state.ignoreNext) { state.ignoreNext = false; state.ignored.resolve(message); return; }
85
- socket.send(JSON.stringify({ type: 'ack', id: message.id }));
86
- state.tail = state.tail.then(async () => {
87
- try {
88
- const checked = await state.runtime.validateTaskOperation(message.operation, message.params);
89
- const data = await state.runtime.handlers[checked.operation](checked.params);
90
- latest.get(sid).socket.send(JSON.stringify({ id: message.id, sessionId: sid, success: true, data }));
91
- } catch (error) { latest.get(sid).socket.send(JSON.stringify({ id: message.id, sessionId: sid, success: false, error: error.message })); }
92
- });
93
- });
94
- await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
95
- socket.send(JSON.stringify({ type: 'runtime-capabilities', runtimeVersion: 'test', protocolVersion: 2, documentId: 'same-document', operations: Object.keys(state.runtime.handlers) }));
96
- return state;
97
- }
98
- const tabA = await plugin('tab-a'), tabB = await plugin('tab-b');
99
- const first = agent(), second = agent();
100
- const ambiguous = await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a' });
101
- assert.equal(ambiguous.isError, true); assert.match(ambiguous.content[0].text, /Ambiguous/);
102
- const missing = await rpc('figma_read', { operation: 'get_design', sessionId: 'missing' });
103
- assert.equal(missing.isError, true);
104
- const taskA = decode(ok(await first('figma_task', { action: 'start', sessionId: 'tab-a', frameId: 'frame-a' })));
105
- const collision = await second('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-a' });
106
- assert.equal(collision.isError, true); assert.match(collision.content[0].text, /reserved/);
107
- const taskB = decode(ok(await second('figma_task', { action: 'start', sessionId: 'tab-a', frameId: 'frame-b' })));
108
- const taskC = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
109
- assert.equal(decode(ok(await first('figma_get_selection', { taskId: taskA.taskId }))).id, 'frame-a');
110
- assert.equal(decode(ok(await second('figma_get_selection', { taskId: taskB.taskId }))).id, 'frame-b');
111
- const writeA = first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-a",note:"paused"}); await figma.modify({id:"child-a",note:"second"});' });
112
- await paused.promise;
113
- const writeB = second('figma_write', { taskId: taskB.taskId, code: 'await figma.modify({id:"child-b",note:"other-task"});' });
114
- ok(await rpc('figma_write', { taskId: taskC.taskId, code: 'await figma.modify({id:"child-c",note:"other-tab"});' }));
115
- assert.deepEqual(actions, ['paused', 'other-tab']);
116
- resume.resolve(); ok(await writeA); ok(await writeB);
117
- assert.deepEqual(actions, ['paused', 'other-tab', 'second', 'other-task']);
118
- const returned = ok(await first('figma_write', { taskId: taskA.taskId,
119
- code: 'await figma.modify({id:"child-a",note:"settled"}); return "done";' }));
120
- assert.equal(returned.content[0].text, 'Result: "done"');
121
- const rejected = await first('figma_write', { taskId: taskA.taskId,
122
- code: 'await figma.modify({id:"child-a",note:"before-error"}); throw new Error("after-await");' });
123
- assert.equal(rejected.isError, true); assert.match(rejected.content[0].text, /after-await/);
124
- const outside = await first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-b",note:"escape"});' });
125
- assert.equal(outside.isError, true); assert.ok(!actions.includes('escape'));
126
- const rawWrite = await fetch(`${base}/exec?sessionId=tab-b`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation: 'modify', params: { id: 'child-a' } }) });
127
- assert.equal((await rawWrite.json()).success, false);
128
-
129
- // A replacement socket recovers an op that the old socket never ACKed.
130
- tabA.ignoreNext = true; tabA.ignored = deferred(); const oldSocket = tabA.socket;
131
- const recovering = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
132
- const ignored = await tabA.ignored.promise;
133
- await plugin('tab-a', tabA); oldSocket.close();
134
- assert.equal(decode(ok(await recovering)).tab, 'tab-a');
135
- await delay(30);
136
- assert.equal(decode(ok(await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-b', sessionId: 'tab-a' }))).id, 'frame-b');
137
- // Cross-tab response spoofing cannot consume another tab's pending request.
138
- tabA.ignoreNext = true; tabA.ignored = deferred();
139
- const routed = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
140
- const waiting = await tabA.ignored.promise;
141
- tabB.socket.send(JSON.stringify({ id: waiting.id, success: true, data: { hijacked: true } }));
142
- const wrongHttp = await fetch(`${base}/response`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: waiting.id, sessionId: 'tab-b', success: true, data: { hijacked: true } }) });
143
- assert.equal((await wrongHttp.json()).ok, false);
144
- tabA.socket.send(JSON.stringify({ id: waiting.id, sessionId: 'tab-a', success: true, data: { id: 'frame-a', tab: 'tab-a' } }));
145
- assert.equal(decode(ok(await routed)).tab, 'tab-a');
146
- assert.ok(ignored.id);
147
- ok(await first('figma_task', { action: 'end', taskId: taskA.taskId }));
148
- ok(await second('figma_task', { action: 'end', taskId: taskB.taskId }));
149
- tabB.socket.close();
150
- await until(async () => !(await (await fetch(`${base}/sessions`)).json()).sessions.find(session => session.id === 'tab-b').connected);
151
- assert.equal(decode(ok(await rpc('figma_task', { action: 'end', taskId: taskC.taskId }))).pluginDisconnected, true);
152
- await plugin('tab-b', tabB);
153
- const restarted = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
154
- ok(await rpc('figma_task', { action: 'end', taskId: restarted.taskId }));
155
- });
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { createServer } from 'node:net';
5
+ import { createInterface } from 'node:readline';
6
+ import { readFileSync } from 'node:fs';
7
+ import vm from 'node:vm';
8
+
9
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
+ const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
11
+ async function until(predicate) {
12
+ const deadline = Date.now() + 5000;
13
+ while (!await predicate()) { if (Date.now() > deadline) throw new Error('Timed out'); await delay(10); }
14
+ }
15
+ const decode = result => JSON.parse(result.content[0].text);
16
+ const ok = result => { assert.notEqual(result.isError, true, result.content?.[0]?.text); return result; };
17
+
18
+ // Uses only Node built-ins (Node >=22 supplies WebSocket) and the actual Rust
19
+ // daemon. The canvas is simulated; this verifies transport/agent isolation.
20
+ test('multiple tabs, reconnect ownership and independent frame tasks across MCP clients', { timeout: 20000 }, async t => {
21
+ assert.equal(typeof WebSocket, 'function', 'This integration check requires Node >=22');
22
+ const reservation = createServer();
23
+ await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve));
24
+ const port = reservation.address().port;
25
+ await new Promise(resolve => reservation.close(resolve));
26
+ const binary = process.env.FIGMA_TEST_BINARY || 'target/debug/figma-rust-mcp';
27
+ const server = spawn(binary, ['--server', '--port', String(port)], { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, FIGMA_RUST_MCP_PORT: String(port) } });
28
+ let stderr = ''; server.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-4000); });
29
+ const processes = [server], sockets = [], latest = new Map(), actions = [];
30
+ t.after(() => { for (const socket of sockets) socket.close(); for (const process of processes) process.kill(); });
31
+ const base = `http://127.0.0.1:${port}`;
32
+ await until(async () => { try { return (await fetch(`${base}/health`)).ok; } catch { assert.equal(server.exitCode, null, stderr); return false; } });
33
+ let rpcId = 0;
34
+ async function rpc(name, args) {
35
+ const response = await fetch(`${base}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/call', params: { name, arguments: args } }) });
37
+ return (await response.json()).result;
38
+ }
39
+ function agent() {
40
+ const process = spawn(binary, ['--stdio', '--port', String(port)], { env: { ...globalThis.process.env, FIGMA_RUST_MCP_PORT: String(port) } });
41
+ processes.push(process); process.stderr.resume();
42
+ let id = 0;
43
+ const pending = new Map();
44
+ createInterface({ input: process.stdout }).on('line', line => {
45
+ const response = JSON.parse(line); const resolve = pending.get(response.id);
46
+ if (resolve) { pending.delete(response.id); resolve(response.result); }
47
+ });
48
+ return (name, args) => new Promise(resolve => {
49
+ pending.set(++id, resolve);
50
+ process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }) + '\n');
51
+ });
52
+ }
53
+ const paused = deferred(), resume = deferred();
54
+ async function plugin(sid, existing) {
55
+ const state = existing || {};
56
+ if (!state.runtime) {
57
+ const page = { id: 'page', type: 'PAGE' };
58
+ const nodes = new Map();
59
+ for (const suffix of ['a', 'b', 'c']) {
60
+ const root = { id: `frame-${suffix}`, name: suffix, type: 'FRAME', parent: page, children: [] };
61
+ const child = { id: `child-${suffix}`, type: 'TEXT', parent: root }; root.children.push(child);
62
+ nodes.set(root.id, root); nodes.set(child.id, child);
63
+ }
64
+ state.runtime = vm.createContext({ handlers: {}, findNodeByIdAsync: async id => nodes.get(id), yieldToUI: async () => {} });
65
+ vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), state.runtime);
66
+ state.runtime.handlers.get_design = async params => ({ id: params.id, type: 'FRAME', name: params.id, tab: sid });
67
+ state.runtime.handlers.status = async () => ({ tab: sid });
68
+ state.runtime.handlers.modify = async params => {
69
+ actions.push(params.note);
70
+ if (params.note === 'paused') { paused.resolve(); await resume.promise; }
71
+ return { id: params.id, note: params.note };
72
+ };
73
+ state.tail = Promise.resolve();
74
+ }
75
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/ws?sessionId=${sid}&fileName=SameFile&documentId=same-document`);
76
+ sockets.push(socket); state.socket = socket; latest.set(sid, state);
77
+ socket.addEventListener('message', event => {
78
+ const message = JSON.parse(event.data);
79
+ if (message.type === 'server-hello') {
80
+ for (const id of state.runtime.frameTasks.keys()) if (!message.activeTaskIds.includes(id)) state.runtime.frameTasks.delete(id);
81
+ return;
82
+ }
83
+ if (!message.operation) return;
84
+ if (state.ignoreNext) { state.ignoreNext = false; state.ignored.resolve(message); return; }
85
+ socket.send(JSON.stringify({ type: 'ack', id: message.id }));
86
+ state.tail = state.tail.then(async () => {
87
+ try {
88
+ const checked = await state.runtime.validateTaskOperation(message.operation, message.params);
89
+ const data = await state.runtime.handlers[checked.operation](checked.params);
90
+ latest.get(sid).socket.send(JSON.stringify({ id: message.id, sessionId: sid, success: true, data }));
91
+ } catch (error) { latest.get(sid).socket.send(JSON.stringify({ id: message.id, sessionId: sid, success: false, error: error.message })); }
92
+ });
93
+ });
94
+ await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
95
+ socket.send(JSON.stringify({ type: 'runtime-capabilities', runtimeVersion: 'test', protocolVersion: 2, documentId: 'same-document', operations: Object.keys(state.runtime.handlers) }));
96
+ return state;
97
+ }
98
+ const tabA = await plugin('tab-a'), tabB = await plugin('tab-b');
99
+ const first = agent(), second = agent();
100
+ const ambiguous = await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a' });
101
+ assert.equal(ambiguous.isError, true); assert.match(ambiguous.content[0].text, /Ambiguous/);
102
+ const missing = await rpc('figma_read', { operation: 'get_design', sessionId: 'missing' });
103
+ assert.equal(missing.isError, true);
104
+ const taskA = decode(ok(await first('figma_task', { action: 'start', sessionId: 'tab-a', frameId: 'frame-a' })));
105
+ const collision = await second('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-a' });
106
+ assert.equal(collision.isError, true); assert.match(collision.content[0].text, /reserved/);
107
+ const taskB = decode(ok(await second('figma_task', { action: 'start', sessionId: 'tab-a', frameId: 'frame-b' })));
108
+ const taskC = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
109
+ assert.equal(decode(ok(await first('figma_get_selection', { taskId: taskA.taskId }))).id, 'frame-a');
110
+ assert.equal(decode(ok(await second('figma_get_selection', { taskId: taskB.taskId }))).id, 'frame-b');
111
+ const writeA = first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-a",note:"paused"}); await figma.modify({id:"child-a",note:"second"});' });
112
+ await paused.promise;
113
+ const writeB = second('figma_write', { taskId: taskB.taskId, code: 'await figma.modify({id:"child-b",note:"other-task"});' });
114
+ ok(await rpc('figma_write', { taskId: taskC.taskId, code: 'await figma.modify({id:"child-c",note:"other-tab"});' }));
115
+ assert.deepEqual(actions, ['paused', 'other-tab']);
116
+ resume.resolve(); ok(await writeA); ok(await writeB);
117
+ assert.deepEqual(actions, ['paused', 'other-tab', 'second', 'other-task']);
118
+ const returned = ok(await first('figma_write', { taskId: taskA.taskId,
119
+ code: 'await figma.modify({id:"child-a",note:"settled"}); return "done";' }));
120
+ assert.equal(returned.content[0].text, 'Result: "done"');
121
+ const rejected = await first('figma_write', { taskId: taskA.taskId,
122
+ code: 'await figma.modify({id:"child-a",note:"before-error"}); throw new Error("after-await");' });
123
+ assert.equal(rejected.isError, true); assert.match(rejected.content[0].text, /after-await/);
124
+ const outside = await first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-b",note:"escape"});' });
125
+ assert.equal(outside.isError, true); assert.ok(!actions.includes('escape'));
126
+ const rawWrite = await fetch(`${base}/exec?sessionId=tab-b`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation: 'modify', params: { id: 'child-a' } }) });
127
+ assert.equal((await rawWrite.json()).success, false);
128
+
129
+ // A replacement socket recovers an op that the old socket never ACKed.
130
+ tabA.ignoreNext = true; tabA.ignored = deferred(); const oldSocket = tabA.socket;
131
+ const recovering = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
132
+ const ignored = await tabA.ignored.promise;
133
+ await plugin('tab-a', tabA); oldSocket.close();
134
+ assert.equal(decode(ok(await recovering)).tab, 'tab-a');
135
+ await delay(30);
136
+ assert.equal(decode(ok(await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-b', sessionId: 'tab-a' }))).id, 'frame-b');
137
+ // Cross-tab response spoofing cannot consume another tab's pending request.
138
+ tabA.ignoreNext = true; tabA.ignored = deferred();
139
+ const routed = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
140
+ const waiting = await tabA.ignored.promise;
141
+ tabB.socket.send(JSON.stringify({ id: waiting.id, success: true, data: { hijacked: true } }));
142
+ const wrongHttp = await fetch(`${base}/response`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: waiting.id, sessionId: 'tab-b', success: true, data: { hijacked: true } }) });
143
+ assert.equal((await wrongHttp.json()).ok, false);
144
+ tabA.socket.send(JSON.stringify({ id: waiting.id, sessionId: 'tab-a', success: true, data: { id: 'frame-a', tab: 'tab-a' } }));
145
+ assert.equal(decode(ok(await routed)).tab, 'tab-a');
146
+ assert.ok(ignored.id);
147
+ ok(await first('figma_task', { action: 'end', taskId: taskA.taskId }));
148
+ ok(await second('figma_task', { action: 'end', taskId: taskB.taskId }));
149
+ tabB.socket.close();
150
+ await until(async () => !(await (await fetch(`${base}/sessions`)).json()).sessions.find(session => session.id === 'tab-b').connected);
151
+ assert.equal(decode(ok(await rpc('figma_task', { action: 'end', taskId: taskC.taskId }))).pluginDisconnected, true);
152
+ await plugin('tab-b', tabB);
153
+ const restarted = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
154
+ ok(await rpc('figma_task', { action: 'end', taskId: restarted.taskId }));
155
+ });