Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/mcp/src/tools/binding-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from 'zod';

/**
* Variable bindings on the object that owns them — `{ field: variableId }` — as get_node /
* get_styles report them. Shared by the paint / effect / grid input schemas so a value read out of
* Figma writes straight back with its bindings intact instead of being flattened to the literal
* beside it (issue #164).
*
* These arrays replace rather than patch: a paint or effect written back WITHOUT `boundVariables`
* clears whatever it was bound to. (set_text_range is the exception — being a patch, it takes an
* explicit null to unbind.)
*/
export const boundVariablesSchema = z
.record(z.string(), z.string())
.describe(
'Variable bindings for this object as { field: variableId }, e.g. { "color": "VariableID:5:12" } ' +
'— round-trips what get_node / get_styles report. The bound field then tracks the variable ' +
'instead of the literal next to it. Omit to leave the value unbound: writing without it ' +
'CLEARS any binding the object had.',
);
13 changes: 2 additions & 11 deletions packages/mcp/src/tools/create-grid-style.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

import { gridItemSchema } from './grid-schema.js';
import type { ToolSpec } from './spec.js';

export const CREATE_GRID_STYLE_TOOL_NAME = 'create_grid_style';
Expand All @@ -12,17 +13,7 @@ export const createGridStyleTool: ToolSpec = {
'to frames with apply_style_to_node. Returns { ok, styleId, name }.',
inputSchema: z.object({
name: z.string().describe('Style name, e.g. "Layout/8pt"'),
grids: z.array(
z.object({
pattern: z.enum(['GRID', 'ROWS', 'COLUMNS']),
visible: z.boolean(),
sectionSize: z.number().optional(),
count: z.number().optional(),
gutterSize: z.number().optional(),
alignment: z.enum(['MIN', 'MAX', 'CENTER', 'STRETCH']).optional(),
offset: z.number().optional(),
}),
),
grids: z.array(gridItemSchema),
description: z.string().optional(),
}),
kind: 'write',
Expand Down
7 changes: 7 additions & 0 deletions packages/mcp/src/tools/effect-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from 'zod';

import { boundVariablesSchema } from './binding-schema.js';

// Shared Zod effect schema, reused by set_effects / create_effect_style so the shadow + blur shape
// can't drift between them (they previously copy-pasted the same inline JSON shape). Loose so an
// effect read back from get_node round-trips into a write. The plugin's toFigmaEffect enforces that
Expand All @@ -19,5 +21,10 @@ export const effectItemSchema = z
.describe('Shadow offset in px. Required for shadows.')
.optional(),
spread: z.number().optional(),
boundVariables: boundVariablesSchema
.describe(
'Bindable fields: color (COLOR variable) and radius / spread / offsetX / offsetY (FLOAT).',
)
.optional(),
})
.loose();
28 changes: 28 additions & 0 deletions packages/mcp/src/tools/grid-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from 'zod';

import { boundVariablesSchema } from './binding-schema.js';

// Shared Zod layout-grid schema, reused by set_layout_grids / create_grid_style so the shape can't
// drift between them (they previously carried the same inline object twice, and the copies had
// already diverged in their descriptions). Loose, like the paint and effect schemas, so a grid read
// back from get_node round-trips into a write. The plugin's toFigmaLayoutGrid enforces the rest
// (GRID needs sectionSize; ROWS/COLUMNS need count + gutterSize; CENTER rejects offset).

/** One layout grid: GRID (uniform squares) or ROWS / COLUMNS (count + gutter + alignment). */
export const gridItemSchema = z
.object({
pattern: z.enum(['GRID', 'ROWS', 'COLUMNS']),
visible: z.boolean(),
sectionSize: z
.number()
.optional()
.describe('Cell size for GRID; section size for ROWS/COLUMNS (ignored when STRETCH)'),
count: z.number().optional().describe('Number of columns/rows (ROWS/COLUMNS)'),
gutterSize: z.number().optional().describe('Gap between columns/rows (ROWS/COLUMNS)'),
alignment: z.enum(['MIN', 'MAX', 'CENTER', 'STRETCH']).optional(),
offset: z.number().optional().describe('Page margin from the frame edge (ignored when CENTER)'),
boundVariables: boundVariablesSchema
.describe('Bindable fields: sectionSize / count / offset / gutterSize (FLOAT variables).')
.optional(),
})
.loose();
14 changes: 13 additions & 1 deletion packages/mcp/src/tools/paint-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from 'zod';

import { boundVariablesSchema } from './binding-schema.js';

// Shared Zod paint schema, reused by set_fills / set_strokes / create_paint_style /
// update_paint_style so the SOLID + gradient shape can't drift between them. Loose (only `type` is
// required and unknown keys pass through) so a paint read back from get_node round-trips into a
Expand All @@ -22,7 +24,14 @@ export const paintItemSchema = z
]),
color: rgb.describe('SOLID color (r/g/b in 0–1)').optional(),
gradientStops: z
.array(z.object({ position: z.number(), color: rgba }))
.array(
z.object({
position: z.number(),
color: rgba,
// A stop binds its colour independently of its siblings, so the binding rides here.
boundVariables: boundVariablesSchema.optional(),
}),
)
.describe('Gradient stops (position 0–1 + RGBA color); required for gradient types')
.optional(),
gradientTransform: z
Expand All @@ -31,5 +40,8 @@ export const paintItemSchema = z
.optional(),
opacity: z.number().optional(),
visible: z.boolean().optional(),
boundVariables: boundVariablesSchema
.describe('SOLID only: { "color": variableId } binds the paint colour to a COLOR variable.')
.optional(),
})
.loose();
4 changes: 3 additions & 1 deletion packages/mcp/src/tools/set-fills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export const setFillsTool: ToolSpec = {
description:
"Set a node's fills. SOLID: { type:'SOLID', color:{r,g,b} } (0–1). Gradient: " +
"{ type:'GRADIENT_LINEAR'|…, gradientStops:[{position,color:{r,g,b,a}}], gradientTransform } " +
'(round-trips get_node output). Returns { ok, nodeId }.',
'(round-trips get_node output). A SOLID paint may carry boundVariables ({ color: variableId }) ' +
'and a gradient stop its own — the paint then tracks that variable instead of the literal. ' +
'Returns { ok, nodeId }.',
inputSchema: z.object({
nodeId: z.string().describe('Figma node id to repaint'),
fills: z.array(paintItemSchema).describe('Paints to apply'),
Expand Down
19 changes: 2 additions & 17 deletions packages/mcp/src/tools/set-layout-grids.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

import { gridItemSchema } from './grid-schema.js';
import type { ToolSpec } from './spec.js';

export const SET_LAYOUT_GRIDS_TOOL_NAME = 'set_layout_grids';
Expand All @@ -15,23 +16,7 @@ export const setLayoutGridsTool: ToolSpec = {
inputSchema: z.object({
nodeId: z.string().describe('Frame (or component/instance) node id'),
grids: z
.array(
z.object({
pattern: z.enum(['GRID', 'ROWS', 'COLUMNS']),
visible: z.boolean(),
sectionSize: z
.number()
.optional()
.describe('Cell size for GRID; section size for ROWS/COLUMNS (ignored when STRETCH)'),
count: z.number().optional().describe('Number of columns/rows (ROWS/COLUMNS)'),
gutterSize: z.number().optional().describe('Gap between columns/rows (ROWS/COLUMNS)'),
alignment: z.enum(['MIN', 'MAX', 'CENTER', 'STRETCH']).optional(),
offset: z
.number()
.optional()
.describe('Page margin from the frame edge (ignored when CENTER)'),
}),
)
.array(gridItemSchema)
.describe('Layout grids to set; [] clears all grids on the frame'),
}),
kind: 'write',
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/src/tools/update-effect-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export const updateEffectStyleTool: ToolSpec = {
'Update an existing effect style by id. Any of name / effects / description may be omitted to ' +
'leave unchanged; effects, when given, replaces the whole list. Shadows (DROP_SHADOW / ' +
'INNER_SHADOW) need color + offset; blurs (LAYER_BLUR / BACKGROUND_BLUR) need radius. Use this ' +
'to keep a shared style in sync with code instead of creating a duplicate. Returns { ok, ' +
'to keep a shared style in sync with code instead of creating a duplicate. Because effects ' +
'replace wholesale, an effect written back WITHOUT its boundVariables clears the variables it ' +
'was bound to — re-send them, or change the variable itself instead. Returns { ok, ' +
'styleId, name }.',
inputSchema: z.object({
styleId: z.string().describe('Effect style id to update'),
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/src/tools/update-paint-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export const updatePaintStyleTool: ToolSpec = {
name: UPDATE_PAINT_STYLE_TOOL_NAME,
description:
'Update an existing paint style by id. Any of name / paints / description may be omitted to ' +
'leave unchanged. Returns { ok, styleId, name }.',
'leave unchanged. Because paints replace wholesale, a paint written back WITHOUT its ' +
'boundVariables clears the variable it was bound to — re-send the binding, or change the ' +
'variable itself instead. Returns { ok, styleId, name }.',
inputSchema: z.object({
styleId: z.string().describe('Paint style id to update'),
name: z.string().optional(),
Expand Down
15 changes: 15 additions & 0 deletions packages/mcp/test/plugin-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
],
"set_fills": [
"fills",
"fills[].boundVariables",
"fills[].color",
"fills[].color.b",
"fills[].color.g",
"fills[].color.r",
"fills[].gradientStops",
"fills[].gradientStops[].boundVariables",
"fills[].gradientStops[].color",
"fills[].gradientStops[].color.a",
"fills[].gradientStops[].color.b",
Expand Down Expand Up @@ -87,11 +89,13 @@
"ranges[].end",
"ranges[].fillStyleId",
"ranges[].fills",
"ranges[].fills[].boundVariables",
"ranges[].fills[].color",
"ranges[].fills[].color.b",
"ranges[].fills[].color.g",
"ranges[].fills[].color.r",
"ranges[].fills[].gradientStops",
"ranges[].fills[].gradientStops[].boundVariables",
"ranges[].fills[].gradientStops[].color",
"ranges[].fills[].gradientStops[].color.a",
"ranges[].fills[].gradientStops[].color.b",
Expand Down Expand Up @@ -151,11 +155,13 @@
"strokeTopWeight",
"strokeWeight",
"strokes",
"strokes[].boundVariables",
"strokes[].color",
"strokes[].color.b",
"strokes[].color.g",
"strokes[].color.r",
"strokes[].gradientStops",
"strokes[].gradientStops[].boundVariables",
"strokes[].gradientStops[].color",
"strokes[].gradientStops[].color.a",
"strokes[].gradientStops[].color.b",
Expand Down Expand Up @@ -207,6 +213,7 @@
"set_layout_grids": [
"grids",
"grids[].alignment",
"grids[].boundVariables",
"grids[].count",
"grids[].gutterSize",
"grids[].offset",
Expand All @@ -226,6 +233,7 @@
"clone_node": ["nodeId", "requestId"],
"set_effects": [
"effects",
"effects[].boundVariables",
"effects[].color",
"effects[].color.a",
"effects[].color.b",
Expand All @@ -245,11 +253,13 @@
"description",
"name",
"paints",
"paints[].boundVariables",
"paints[].color",
"paints[].color.b",
"paints[].color.g",
"paints[].color.r",
"paints[].gradientStops",
"paints[].gradientStops[].boundVariables",
"paints[].gradientStops[].color",
"paints[].gradientStops[].color.a",
"paints[].gradientStops[].color.b",
Expand Down Expand Up @@ -281,6 +291,7 @@
"create_effect_style": [
"description",
"effects",
"effects[].boundVariables",
"effects[].color",
"effects[].color.a",
"effects[].color.b",
Expand All @@ -300,6 +311,7 @@
"description",
"grids",
"grids[].alignment",
"grids[].boundVariables",
"grids[].count",
"grids[].gutterSize",
"grids[].offset",
Expand All @@ -313,11 +325,13 @@
"description",
"name",
"paints",
"paints[].boundVariables",
"paints[].color",
"paints[].color.b",
"paints[].color.g",
"paints[].color.r",
"paints[].gradientStops",
"paints[].gradientStops[].boundVariables",
"paints[].gradientStops[].color",
"paints[].gradientStops[].color.a",
"paints[].gradientStops[].color.b",
Expand Down Expand Up @@ -351,6 +365,7 @@
"update_effect_style": [
"description",
"effects",
"effects[].boundVariables",
"effects[].color",
"effects[].color.a",
"effects[].color.b",
Expand Down
Loading