Skip to content

Commit 7437460

Browse files
committed
feat(write): per-corner set_corner_radius + new set_mask tool
Write counterparts for the read fidelity fields added in the previous commit, completing read↔write symmetry (blendMode already had set_blend_mode): - set_corner_radius: radius is now optional; adds per-corner topLeftRadius / topRightRadius / bottomRightRadius / bottomLeftRadius. A per-corner value overrides the uniform radius (set after it), mirroring set_strokes' per-side weights. Nodes without individual corners are rejected; at least one of radius or a corner is required. - set_mask (new, tool 84→85): isMask boolean + optional maskType (ALPHA/LUMINANCE/GEOMETRY, applied only when enabling). Masking has no composable alternative, so it's a genuine authoring primitive (same bar as combine_as_variants). Both carry the Zod ToolSpec + registry wiring + gating tests (write-tools schema assertions, handler tests for override / corner-only / unsupported-node / mask on-off + maskType gating). 605 tests, typecheck/lint/build green.
1 parent cf8f07d commit 7437460

9 files changed

Lines changed: 183 additions & 6 deletions

File tree

packages/plugin/src/handlers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { createSetCornerRadiusHandler } from './set-corner-radius.js';
6767
import { createSetEffectsHandler } from './set-effects.js';
6868
import { createSetFillsHandler } from './set-fills.js';
6969
import { createSetLayoutPropsHandler } from './set-layout-props.js';
70+
import { createSetMaskHandler } from './set-mask.js';
7071
import { createSetOpacityHandler } from './set-opacity.js';
7172
import { createSetReactionsHandler } from './set-reactions.js';
7273
import { createSetStrokesHandler } from './set-strokes.js';
@@ -107,6 +108,7 @@ export const createSandboxHandlers = (figmaCtx: typeof figma): SandboxHandlers =
107108
set_auto_layout: createSetAutoLayoutHandler(figmaCtx),
108109
set_layout_props: createSetLayoutPropsHandler(figmaCtx),
109110
set_blend_mode: createSetBlendModeHandler(figmaCtx),
111+
set_mask: createSetMaskHandler(figmaCtx),
110112
set_constraints: createSetConstraintsHandler(figmaCtx),
111113
rotate_nodes: createRotateNodesHandler(figmaCtx),
112114
lock_nodes: createSetLockedHandler(figmaCtx, true),

packages/plugin/src/handlers/set-corner-radius.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,52 @@ import type { MutateResult } from '@figma-mcp-relay/shared';
22

33
import type { SandboxToolHandler } from '../dispatcher.js';
44

5+
const PER_CORNER = [
6+
'topLeftRadius',
7+
'topRightRadius',
8+
'bottomRightRadius',
9+
'bottomLeftRadius',
10+
] as const;
11+
512
export const createSetCornerRadiusHandler =
613
(figmaCtx: typeof figma): SandboxToolHandler =>
714
async params => {
8-
const p = (params ?? {}) as { nodeId?: unknown; radius?: unknown };
15+
const p = (params ?? {}) as {
16+
nodeId?: unknown;
17+
radius?: unknown;
18+
topLeftRadius?: unknown;
19+
topRightRadius?: unknown;
20+
bottomRightRadius?: unknown;
21+
bottomLeftRadius?: unknown;
22+
};
923
if (typeof p.nodeId !== 'string')
1024
throw new TypeError('set_corner_radius: nodeId must be a string');
11-
if (typeof p.radius !== 'number' || p.radius < 0) {
25+
if (p.radius !== undefined && (typeof p.radius !== 'number' || p.radius < 0)) {
1226
throw new TypeError('set_corner_radius: radius must be a non-negative number');
1327
}
28+
const corners = PER_CORNER.filter(c => p[c] !== undefined);
29+
for (const c of corners) {
30+
const v = p[c];
31+
if (typeof v !== 'number' || v < 0) {
32+
throw new TypeError(`set_corner_radius: ${c} must be a non-negative number`);
33+
}
34+
}
35+
if (typeof p.radius !== 'number' && corners.length === 0) {
36+
throw new TypeError('set_corner_radius: provide radius or at least one corner radius');
37+
}
1438
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
1539
if (node === null || !('cornerRadius' in node)) {
1640
throw new Error(`set_corner_radius: node ${p.nodeId} not found or has no cornerRadius`);
1741
}
18-
(node as { cornerRadius: number }).cornerRadius = p.radius;
42+
if (typeof p.radius === 'number') (node as { cornerRadius: number }).cornerRadius = p.radius;
43+
// Per-corner radii live on RectangleCornerMixin (rects/frames/components/instances). Set them
44+
// after the uniform radius so a per-corner value overrides it; reject nodes that lack them.
45+
for (const c of corners) {
46+
if (!(c in node)) {
47+
throw new Error(`set_corner_radius: node ${p.nodeId} does not support per-corner radii`);
48+
}
49+
(node as unknown as Record<string, number>)[c] = p[c] as number;
50+
}
1951
const result: MutateResult = { ok: true, nodeId: node.id };
2052
return result;
2153
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createSetMaskHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeId?: unknown; isMask?: unknown; maskType?: unknown };
9+
if (typeof p.nodeId !== 'string') throw new TypeError('set_mask: nodeId must be a string');
10+
if (typeof p.isMask !== 'boolean') throw new TypeError('set_mask: isMask must be a boolean');
11+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
12+
if (node === null || !('isMask' in node)) {
13+
throw new Error(`set_mask: node ${p.nodeId} not found or cannot be a mask`);
14+
}
15+
(node as { isMask: boolean }).isMask = p.isMask;
16+
// maskType only matters while masking; set it when enabling and the node exposes it.
17+
if (p.isMask && typeof p.maskType === 'string' && 'maskType' in node) {
18+
(node as { maskType: MaskType }).maskType = p.maskType as MaskType;
19+
}
20+
const result: MutateResult = { ok: true, nodeId: node.id };
21+
return result;
22+
};

packages/plugin/test/handlers/set-corner-radius.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,50 @@ describe('set_corner_radius handler', () => {
1515
expect(result).toEqual({ ok: true, nodeId: '1:1' });
1616
});
1717

18+
it('sets per-corner radii, overriding the uniform radius for given corners', async () => {
19+
const node = {
20+
id: '1:1',
21+
cornerRadius: 0,
22+
topLeftRadius: 0,
23+
topRightRadius: 0,
24+
bottomRightRadius: 0,
25+
bottomLeftRadius: 0,
26+
};
27+
const handler = createSetCornerRadiusHandler(fakeFigma({ '1:1': node }));
28+
const result = (await handler({
29+
nodeId: '1:1',
30+
radius: 4,
31+
topLeftRadius: 8,
32+
topRightRadius: 8,
33+
})) as MutateResult;
34+
expect(node.cornerRadius).toBe(4); // uniform applied first
35+
expect(node.topLeftRadius).toBe(8); // per-corner overrides
36+
expect(node.topRightRadius).toBe(8);
37+
expect(node.bottomRightRadius).toBe(0); // untouched corners keep uniform
38+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
39+
});
40+
41+
it('accepts per-corner only (no uniform radius)', async () => {
42+
const node = { id: '1:1', cornerRadius: 0, bottomLeftRadius: 0 };
43+
const handler = createSetCornerRadiusHandler(fakeFigma({ '1:1': node }));
44+
await handler({ nodeId: '1:1', bottomLeftRadius: 12 });
45+
expect(node.bottomLeftRadius).toBe(12);
46+
});
47+
1848
it('rejects negative radius, bad input, and nodes without cornerRadius', async () => {
1949
const handler = createSetCornerRadiusHandler(
2050
fakeFigma({ '1:1': { id: '1:1', cornerRadius: 0 } }),
2151
);
2252
await expect(handler({ nodeId: '1:1', radius: -1 })).rejects.toThrow(/radius/);
53+
await expect(handler({ nodeId: '1:1', topLeftRadius: -1 })).rejects.toThrow(/radius/);
2354
await expect(handler({ nodeId: '1:1' })).rejects.toThrow(/radius/);
2455
await expect(handler({ nodeId: '9:9', radius: 4 })).rejects.toThrow(/not found/);
2556
});
57+
58+
it('rejects per-corner radii on a node that lacks individual corners', async () => {
59+
const handler = createSetCornerRadiusHandler(
60+
fakeFigma({ '1:1': { id: '1:1', cornerRadius: 0 } }),
61+
);
62+
await expect(handler({ nodeId: '1:1', topLeftRadius: 8 })).rejects.toThrow(/per-corner/);
63+
});
2664
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { createSetMaskHandler } from '../../src/handlers/set-mask.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_mask handler', () => {
10+
it('enables masking and sets maskType', async () => {
11+
const node = { id: '1:1', isMask: false, maskType: 'ALPHA' };
12+
const handler = createSetMaskHandler(fakeFigma({ '1:1': node }));
13+
const result = (await handler({
14+
nodeId: '1:1',
15+
isMask: true,
16+
maskType: 'LUMINANCE',
17+
})) as MutateResult;
18+
expect(node.isMask).toBe(true);
19+
expect(node.maskType).toBe('LUMINANCE');
20+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
21+
});
22+
23+
it('disables masking and ignores maskType when off', async () => {
24+
const node = { id: '1:1', isMask: true, maskType: 'ALPHA' };
25+
const handler = createSetMaskHandler(fakeFigma({ '1:1': node }));
26+
await handler({ nodeId: '1:1', isMask: false, maskType: 'GEOMETRY' });
27+
expect(node.isMask).toBe(false);
28+
expect(node.maskType).toBe('ALPHA'); // unchanged — maskType only applied when enabling
29+
});
30+
31+
it('throws on bad input or a node that cannot be a mask', async () => {
32+
const handler = createSetMaskHandler(fakeFigma({ '1:1': { id: '1:1', isMask: false } }));
33+
await expect(handler({ nodeId: '1:1' })).rejects.toThrow(/isMask/);
34+
await expect(handler({ nodeId: '9:9', isMask: true })).rejects.toThrow(/not found/);
35+
await expect(handler({ nodeId: '2:2', isMask: true })).rejects.toThrow(/not found/);
36+
});
37+
});

packages/server/src/tools/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import { setCornerRadiusTool } from './set-corner-radius.js';
7575
import { setEffectsTool } from './set-effects.js';
7676
import { setFillsTool } from './set-fills.js';
7777
import { setLayoutPropsTool } from './set-layout-props.js';
78+
import { setMaskTool } from './set-mask.js';
7879
import { setOpacityTool } from './set-opacity.js';
7980
import { setReactionsTool } from './set-reactions.js';
8081
import { setStrokesTool } from './set-strokes.js';
@@ -138,6 +139,7 @@ export const ALL_TOOL_SPECS: readonly ToolSpec[] = [
138139
setAutoLayoutTool,
139140
setLayoutPropsTool,
140141
setBlendModeTool,
142+
setMaskTool,
141143
setConstraintsTool,
142144
rotateNodesTool,
143145
lockNodesTool,

packages/server/src/tools/set-corner-radius.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@ export const SET_CORNER_RADIUS_TOOL_NAME = 'set_corner_radius';
66

77
export const setCornerRadiusTool: ToolSpec = {
88
name: SET_CORNER_RADIUS_TOOL_NAME,
9-
description: "Set a node's uniform corner radius (≥ 0). Returns { ok, nodeId }.",
9+
description:
10+
"Set a node's corner radius. Pass radius for a uniform radius, and/or per-corner " +
11+
'topLeftRadius / topRightRadius / bottomRightRadius / bottomLeftRadius (for nodes that support ' +
12+
'individual corners, e.g. a card rounded only on top, a tab or a chat bubble). A per-corner ' +
13+
'value overrides radius for that corner. At least one of radius or a corner is required. ' +
14+
'Returns { ok, nodeId }.',
1015
inputShape: {
1116
nodeId: z.string().describe('Figma node id'),
12-
radius: z.number().min(0).describe('Corner radius in px'),
17+
radius: z.number().min(0).optional().describe('Uniform corner radius in px'),
18+
topLeftRadius: z.number().min(0).optional().describe('Top-left corner radius in px'),
19+
topRightRadius: z.number().min(0).optional().describe('Top-right corner radius in px'),
20+
bottomRightRadius: z.number().min(0).optional().describe('Bottom-right corner radius in px'),
21+
bottomLeftRadius: z.number().min(0).optional().describe('Bottom-left corner radius in px'),
1322
},
1423
kind: 'write',
1524
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { z } from 'zod';
2+
3+
import type { ToolSpec } from './spec.js';
4+
5+
export const SET_MASK_TOOL_NAME = 'set_mask';
6+
7+
export const setMaskTool: ToolSpec = {
8+
name: SET_MASK_TOOL_NAME,
9+
description:
10+
'Set whether a node is a mask — a mask clips its later siblings to its own shape. Pass isMask ' +
11+
'true/false, and optionally maskType (ALPHA / LUMINANCE / GEOMETRY) when enabling. Returns ' +
12+
'{ ok, nodeId }.',
13+
inputShape: {
14+
nodeId: z.string().describe('Figma node id'),
15+
isMask: z.boolean().describe('Whether the node masks its later siblings'),
16+
maskType: z
17+
.enum(['ALPHA', 'LUMINANCE', 'GEOMETRY'])
18+
.optional()
19+
.describe('How the mask clips (only applied when enabling)'),
20+
},
21+
kind: 'write',
22+
};

packages/server/test/tools/write-tools.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
setCornerRadiusTool,
2424
} from '../../src/tools/set-corner-radius.js';
2525
import { SET_FILLS_TOOL_NAME, setFillsTool } from '../../src/tools/set-fills.js';
26+
import { SET_MASK_TOOL_NAME, setMaskTool } from '../../src/tools/set-mask.js';
2627
import { SET_OPACITY_TOOL_NAME, setOpacityTool } from '../../src/tools/set-opacity.js';
2728
import { SET_STROKES_TOOL_NAME, setStrokesTool } from '../../src/tools/set-strokes.js';
2829
import {
@@ -51,6 +52,7 @@ const setBlendModeToolDefinition = toToolDefinition(setBlendModeTool);
5152
const setConstraintsToolDefinition = toToolDefinition(setConstraintsTool);
5253
const setCornerRadiusToolDefinition = toToolDefinition(setCornerRadiusTool);
5354
const setFillsToolDefinition = toToolDefinition(setFillsTool);
55+
const setMaskToolDefinition = toToolDefinition(setMaskTool);
5456
const setOpacityToolDefinition = toToolDefinition(setOpacityTool);
5557
const setStrokesToolDefinition = toToolDefinition(setStrokesTool);
5658
const setTextPropertiesToolDefinition = toToolDefinition(setTextPropertiesTool);
@@ -130,7 +132,12 @@ describe('M2 write tool definitions', () => {
130132
it('set_corner_radius / set_strokes / move_nodes / resize_nodes declare their inputs', () => {
131133
expect(setCornerRadiusToolDefinition.name).toBe(SET_CORNER_RADIUS_TOOL_NAME);
132134
expect(setCornerRadiusToolDefinition.inputSchema).toMatchObject({
133-
required: ['nodeId', 'radius'],
135+
required: ['nodeId'],
136+
properties: {
137+
radius: { type: 'number' },
138+
topLeftRadius: { type: 'number' },
139+
bottomRightRadius: { type: 'number' },
140+
},
134141
});
135142
expect(setStrokesToolDefinition.name).toBe(SET_STROKES_TOOL_NAME);
136143
expect(setStrokesToolDefinition.inputSchema).toMatchObject({ required: ['nodeId', 'strokes'] });
@@ -156,6 +163,11 @@ describe('M2 write tool definitions', () => {
156163
expect(setConstraintsToolDefinition.inputSchema).toMatchObject({
157164
required: ['nodeId', 'horizontal', 'vertical'],
158165
});
166+
expect(setMaskToolDefinition.name).toBe(SET_MASK_TOOL_NAME);
167+
expect(setMaskToolDefinition.inputSchema).toMatchObject({
168+
required: ['nodeId', 'isMask'],
169+
properties: { maskType: { enum: ['ALPHA', 'LUMINANCE', 'GEOMETRY'] } },
170+
});
159171
expect(rotateNodesToolDefinition.name).toBe(ROTATE_NODES_TOOL_NAME);
160172
expect(rotateNodesToolDefinition.inputSchema).toMatchObject({
161173
required: ['nodeIds', 'rotation'],
@@ -228,6 +240,7 @@ describe('M2 write tool definitions', () => {
228240
resizeNodesToolDefinition,
229241
setAutoLayoutToolDefinition,
230242
setBlendModeToolDefinition,
243+
setMaskToolDefinition,
231244
setConstraintsToolDefinition,
232245
rotateNodesToolDefinition,
233246
lockNodesToolDefinition,

0 commit comments

Comments
 (0)