Skip to content

Commit 4eda9d0

Browse files
committed
feat(m2): add set_opacity / set_visible / rename_node / delete_nodes
Fan out the write pattern: three single-property setters (opacity / visibility / name) and the first multi-node op, delete_nodes (resolves targets in parallel, removes sequentially, skips missing/already-removed, returns the affected ids). shared: BatchNodeResult. All idempotency-wrapped and registered in WRITE_TOOLS. 7 write tools total; +11 tests (307). lint / typecheck / build green.
1 parent 0eec93a commit 4eda9d0

16 files changed

Lines changed: 331 additions & 1 deletion

packages/plugin/src/code.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createPluginContextEvent, SELECTION_DETAIL_LIMIT } from '@figma-mcp-rel
33
import { dispatchSandboxMessage, type SandboxHandlers } from './dispatcher.js';
44
import { createIdempotencyCache, idempotent } from './idempotency.js';
55
import { createCreateFrameHandler } from './handlers/create-frame.js';
6+
import { createDeleteNodesHandler } from './handlers/delete-nodes.js';
67
import { createGetAnnotationsHandler } from './handlers/get-annotations.js';
78
import { createGetDesignContextHandler } from './handlers/get-design-context.js';
89
import { createGetDocumentHandler } from './handlers/get-document.js';
@@ -23,8 +24,11 @@ import { createPingHandler } from './handlers/ping.js';
2324
import { createScanNodesByTypesHandler } from './handlers/scan-nodes-by-types.js';
2425
import { createScanTextNodesHandler } from './handlers/scan-text-nodes.js';
2526
import { createSearchNodesHandler } from './handlers/search-nodes.js';
27+
import { createRenameNodeHandler } from './handlers/rename-node.js';
2628
import { createSetFillsHandler } from './handlers/set-fills.js';
29+
import { createSetOpacityHandler } from './handlers/set-opacity.js';
2730
import { createSetTextHandler } from './handlers/set-text.js';
31+
import { createSetVisibleHandler } from './handlers/set-visible.js';
2832

2933
figma.showUI(__html__, { width: 320, height: 400, themeColors: true });
3034

@@ -81,6 +85,10 @@ const handlers: SandboxHandlers = {
8185
set_fills: idempotent(idempotencyCache, createSetFillsHandler(figma)),
8286
set_text: idempotent(idempotencyCache, createSetTextHandler(figma)),
8387
create_frame: idempotent(idempotencyCache, createCreateFrameHandler(figma)),
88+
set_opacity: idempotent(idempotencyCache, createSetOpacityHandler(figma)),
89+
set_visible: idempotent(idempotencyCache, createSetVisibleHandler(figma)),
90+
rename_node: idempotent(idempotencyCache, createRenameNodeHandler(figma)),
91+
delete_nodes: idempotent(idempotencyCache, createDeleteNodesHandler(figma)),
8492
};
8593

8694
figma.ui.onmessage = (raw: unknown) => {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { BatchNodeResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createDeleteNodesHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeIds?: unknown };
9+
if (!Array.isArray(p.nodeIds) || p.nodeIds.some(id => typeof id !== 'string')) {
10+
throw new TypeError('delete_nodes: nodeIds must be a string[]');
11+
}
12+
const ids = p.nodeIds as readonly string[];
13+
const nodes = await Promise.all(ids.map(id => figmaCtx.getNodeByIdAsync(id)));
14+
15+
// Resolve in parallel, then remove sequentially. Missing / already-removed / non-removable
16+
// nodes are skipped (not in `affected`) rather than failing the whole call.
17+
const affected: string[] = [];
18+
nodes.forEach((node, i) => {
19+
if (node === null || !('remove' in node)) return;
20+
try {
21+
(node as { remove: () => void }).remove();
22+
affected.push(ids[i]!);
23+
} catch {
24+
/* node was already removed (e.g. its ancestor was deleted first) */
25+
}
26+
});
27+
28+
const result: BatchNodeResult = { ok: true, affected };
29+
return result;
30+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createRenameNodeHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeId?: unknown; name?: unknown };
9+
if (typeof p.nodeId !== 'string') throw new TypeError('rename_node: nodeId must be a string');
10+
if (typeof p.name !== 'string' || p.name.length === 0) {
11+
throw new TypeError('rename_node: name must be a non-empty string');
12+
}
13+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
14+
if (node === null) throw new Error(`rename_node: node ${p.nodeId} not found`);
15+
node.name = p.name;
16+
const result: MutateResult = { ok: true, nodeId: node.id };
17+
return result;
18+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createSetOpacityHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeId?: unknown; opacity?: unknown };
9+
if (typeof p.nodeId !== 'string') throw new TypeError('set_opacity: nodeId must be a string');
10+
if (typeof p.opacity !== 'number' || p.opacity < 0 || p.opacity > 1) {
11+
throw new TypeError('set_opacity: opacity must be a number in 0–1');
12+
}
13+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
14+
if (node === null || !('opacity' in node)) {
15+
throw new Error(`set_opacity: node ${p.nodeId} not found or has no opacity`);
16+
}
17+
(node as { opacity: number }).opacity = p.opacity;
18+
const result: MutateResult = { ok: true, nodeId: node.id };
19+
return result;
20+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createSetVisibleHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeId?: unknown; visible?: unknown };
9+
if (typeof p.nodeId !== 'string') throw new TypeError('set_visible: nodeId must be a string');
10+
if (typeof p.visible !== 'boolean') throw new TypeError('set_visible: visible must be a boolean');
11+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
12+
if (node === null || !('visible' in node)) {
13+
throw new Error(`set_visible: node ${p.nodeId} not found or cannot be hidden`);
14+
}
15+
(node as SceneNode).visible = p.visible;
16+
const result: MutateResult = { ok: true, nodeId: node.id };
17+
return result;
18+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { BatchNodeResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createDeleteNodesHandler } from '../../src/handlers/delete-nodes.js';
5+
6+
const fakeFigma = (lookup: Record<string, unknown>): typeof figma =>
7+
({ getNodeByIdAsync: async (id: string) => lookup[id] ?? null }) as unknown as typeof figma;
8+
9+
describe('delete_nodes handler', () => {
10+
it('removes found nodes and reports only the affected ids', async () => {
11+
const a = { id: '1:1', remove: vi.fn<() => void>() };
12+
const b = { id: '1:2', remove: vi.fn<() => void>() };
13+
const handler = createDeleteNodesHandler(fakeFigma({ '1:1': a, '1:2': b }));
14+
const result = (await handler({ nodeIds: ['1:1', '9:9', '1:2'] })) as BatchNodeResult;
15+
16+
expect(a.remove).toHaveBeenCalled();
17+
expect(b.remove).toHaveBeenCalled();
18+
expect(result).toEqual({ ok: true, affected: ['1:1', '1:2'] }); // 9:9 missing → skipped
19+
});
20+
21+
it('skips nodes whose remove() throws (e.g. already removed via an ancestor)', async () => {
22+
const a = {
23+
id: '1:1',
24+
remove: () => {
25+
throw new Error('already removed');
26+
},
27+
};
28+
const handler = createDeleteNodesHandler(fakeFigma({ '1:1': a }));
29+
const result = (await handler({ nodeIds: ['1:1'] })) as BatchNodeResult;
30+
expect(result.affected).toEqual([]);
31+
});
32+
33+
it('throws on bad input', async () => {
34+
const handler = createDeleteNodesHandler(fakeFigma({}));
35+
await expect(handler({ nodeIds: 'x' })).rejects.toThrow(/nodeIds/);
36+
await expect(handler({ nodeIds: [1] })).rejects.toThrow(/nodeIds/);
37+
});
38+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { createRenameNodeHandler } from '../../src/handlers/rename-node.js';
5+
6+
const fakeFigma = (lookup: Record<string, unknown>): typeof figma =>
7+
({ getNodeByIdAsync: async (id: string) => lookup[id] ?? null }) as unknown as typeof figma;
8+
9+
describe('rename_node handler', () => {
10+
it('renames a node and returns ok + nodeId', async () => {
11+
const node = { id: '1:1', name: 'Old' };
12+
const handler = createRenameNodeHandler(fakeFigma({ '1:1': node }));
13+
const result = (await handler({ nodeId: '1:1', name: 'New' })) as MutateResult;
14+
expect(node.name).toBe('New');
15+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
16+
});
17+
18+
it('rejects empty name, bad input, and missing nodes', async () => {
19+
const handler = createRenameNodeHandler(fakeFigma({ '1:1': { id: '1:1', name: 'Old' } }));
20+
await expect(handler({ nodeId: '1:1', name: '' })).rejects.toThrow(/name/);
21+
await expect(handler({ nodeId: '1:1' })).rejects.toThrow(/name/);
22+
await expect(handler({ nodeId: '9:9', name: 'x' })).rejects.toThrow(/not found/);
23+
});
24+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { createSetOpacityHandler } from '../../src/handlers/set-opacity.js';
5+
6+
const fakeFigma = (lookup: Record<string, unknown>): typeof figma =>
7+
({ getNodeByIdAsync: async (id: string) => lookup[id] ?? null }) as unknown as typeof figma;
8+
9+
describe('set_opacity handler', () => {
10+
it('sets opacity and returns ok + nodeId', async () => {
11+
const node = { id: '1:1', opacity: 1 };
12+
const handler = createSetOpacityHandler(fakeFigma({ '1:1': node }));
13+
const result = (await handler({ nodeId: '1:1', opacity: 0.5 })) as MutateResult;
14+
expect(node.opacity).toBe(0.5);
15+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
16+
});
17+
18+
it('rejects out-of-range opacity, bad input, and missing nodes', async () => {
19+
const handler = createSetOpacityHandler(fakeFigma({ '1:1': { id: '1:1', opacity: 1 } }));
20+
await expect(handler({ nodeId: '1:1', opacity: 2 })).rejects.toThrow(/opacity/);
21+
await expect(handler({ nodeId: '1:1' })).rejects.toThrow(/opacity/);
22+
await expect(handler({ opacity: 0.5 })).rejects.toThrow(/nodeId/);
23+
await expect(handler({ nodeId: '9:9', opacity: 0.5 })).rejects.toThrow(/not found/);
24+
});
25+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { createSetVisibleHandler } from '../../src/handlers/set-visible.js';
5+
6+
const fakeFigma = (lookup: Record<string, unknown>): typeof figma =>
7+
({ getNodeByIdAsync: async (id: string) => lookup[id] ?? null }) as unknown as typeof figma;
8+
9+
describe('set_visible handler', () => {
10+
it('toggles visibility and returns ok + nodeId', async () => {
11+
const node = { id: '1:1', visible: true };
12+
const handler = createSetVisibleHandler(fakeFigma({ '1:1': node }));
13+
const result = (await handler({ nodeId: '1:1', visible: false })) as MutateResult;
14+
expect(node.visible).toBe(false);
15+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
16+
});
17+
18+
it('rejects non-boolean visible, bad input, and missing nodes', async () => {
19+
const handler = createSetVisibleHandler(fakeFigma({ '1:1': { id: '1:1', visible: true } }));
20+
await expect(handler({ nodeId: '1:1', visible: 'yes' })).rejects.toThrow(/visible/);
21+
await expect(handler({ visible: true })).rejects.toThrow(/nodeId/);
22+
await expect(handler({ nodeId: '9:9', visible: true })).rejects.toThrow(/not found/);
23+
});
24+
});

packages/server/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,12 @@ import { scanNodesByTypesToolDefinition } from './tools/scan-nodes-by-types.js';
4343
import { scanTextNodesToolDefinition } from './tools/scan-text-nodes.js';
4444
import { searchNodesToolDefinition } from './tools/search-nodes.js';
4545
import { CREATE_FRAME_TOOL_NAME, createFrameToolDefinition } from './tools/create-frame.js';
46+
import { DELETE_NODES_TOOL_NAME, deleteNodesToolDefinition } from './tools/delete-nodes.js';
47+
import { RENAME_NODE_TOOL_NAME, renameNodeToolDefinition } from './tools/rename-node.js';
4648
import { SET_FILLS_TOOL_NAME, setFillsToolDefinition } from './tools/set-fills.js';
49+
import { SET_OPACITY_TOOL_NAME, setOpacityToolDefinition } from './tools/set-opacity.js';
4750
import { SET_TEXT_TOOL_NAME, setTextToolDefinition } from './tools/set-text.js';
51+
import { SET_VISIBLE_TOOL_NAME, setVisibleToolDefinition } from './tools/set-visible.js';
4852

4953
const SERVER_NAME = '@figma-mcp-relay/server';
5054
const SERVER_VERSION = '0.0.0';
@@ -108,6 +112,10 @@ mcp.setRequestHandler(ListToolsRequestSchema, () => ({
108112
setFillsToolDefinition,
109113
setTextToolDefinition,
110114
createFrameToolDefinition,
115+
setOpacityToolDefinition,
116+
setVisibleToolDefinition,
117+
renameNodeToolDefinition,
118+
deleteNodesToolDefinition,
111119
],
112120
}));
113121

@@ -117,6 +125,10 @@ const WRITE_TOOLS = new Set<string>([
117125
SET_FILLS_TOOL_NAME,
118126
SET_TEXT_TOOL_NAME,
119127
CREATE_FRAME_TOOL_NAME,
128+
SET_OPACITY_TOOL_NAME,
129+
SET_VISIBLE_TOOL_NAME,
130+
RENAME_NODE_TOOL_NAME,
131+
DELETE_NODES_TOOL_NAME,
120132
]);
121133

122134
mcp.setRequestHandler(CallToolRequestSchema, async request => {

0 commit comments

Comments
 (0)