fix(schema): accept create_* field names on set_solid_fill/set_text_content - #39
Conversation
…ontent
The create_* tools name their colour and content fields `fillHex` and
`characters`, while `set_solid_fill` and `set_text_content` name the same
concepts `hex` and `text`. Agents that just called `create_text({ characters })`
naturally reach for `set_text_content({ characters })` and get back
"Required" for a field they did supply — the value is silently dropped as an
unknown key before validation runs.
Accept the create_* spelling as an alias on both tools:
- `set_solid_fill` takes `fillHex`/`fillOpacity` in addition to `hex`/`opacity`
- `set_text_content` takes `characters` in addition to `text`
Aliases are declared on the advertised shape so the MCP SDK keeps them, then
normalised onto the canonical field by a zod preprocessor. The canonical name
still wins when both are supplied, invalid values are still rejected, and the
wire format sent to the plugin is unchanged — so the plugin needs no update
and existing callers are unaffected.
`toolInputSchemas.set_text_content` now points at the aliasing schema, so
`validateRpc` accepts the same spellings on the follower RPC path.
📝 WalkthroughWalkthroughAdds alias-aware Zod schemas for solid-fill and text-content inputs, changes RPC validation to return normalized parameters, updates tool and RPC forwarding paths, and adds a standalone regression script covering RPC and MCP alias handling. ChangesAlias-aware validation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Hey @yavon007 thanks for the PR 🙏 The underlying problem seems real and worth fixing. The server strips the undeclared key before validation, so the error names a field the caller believes it supplied and the agent has no way to self correct 🥲 Two things I'd want changed before it lands. 1/ The RPC path accepts the alias but never applies it
const result = toolInputSchemas[name].safeParse(rpcToArgs[name](nodeIds, params));
return result.success ? null : result.error.issues[0].message;The leader then forwards So an aliased call over HTTP now goes through the gate and reaches the plugin with the wrong key, where it dies on I've attached a script at the end that reproduces it with no live Figma connection needed. It calls the real That last line is the control. Identical input succeeds on the MCP tool path because those handlers forward The fix is to have 2/ Each schema is written twice
You do need the aliases on the advertised shape, since export const setSolidFillInput = setSolidFillShape.transform(
({ fillHex, fillOpacity, ...rest }, ctx) => {
const hex = rest.hex ?? fillHex;
if (hex === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["hex"],
message: "hex is required (fillHex is accepted as an alias)",
});
return z.NEVER;
}
return { ...rest, hex, opacity: rest.opacity ?? fillOpacity };
}
);I ran your own test matrix against this and it reproduces every case, canonical winning included, with the bad alias value still rejected by It also lines up with what the file already does. 3/ Smaller pointMarking Not blocking, but worth thinking aboutAliases are a reasonable shim. They do become a treadmill if you add them pairwise every time an agent trips over one. This is the second (#40 does the same for At some point the answer is to pick one vocabulary, apply it across every tool, and do the rename at a version boundary with these aliases kept around as deprecated for a release or two. Which is a decent argument for getting the alias machinery clean now, since it's the migration path later anyway (1-2 patch versions after the shims land). Test code example:Testing codeThis is heavily AI generated, but illustrated the point. To run: cd server
bun run scripts/repro-alias-rpc.ts/**
* Reproduces the alias normalisation gap on the HTTP RPC path.
*
* PR #39 teaches `set_solid_fill` / `set_text_content` to accept the create_*
* spellings (`fillHex`, `fillOpacity`, `characters`) via a zod preprocessor.
* That works for the MCP tool handlers, which forward `parsed.data`. It does
* not work for the HTTP RPC path, because `validateRpc` throws away the parsed
* result and the leader forwards the caller's raw params to the plugin.
*
* Run with: bun run scripts/repro-alias-rpc.ts
* Exits 1 while the bug is present, 0 once it is fixed.
*/
import { validateRpc, toolInputSchemas } from "../src/schema.js";
type Outcome =
| { status: "rejected-by-server"; message: string }
| { status: "rejected-by-plugin"; message: string }
| { status: "applied"; payload: Record<string, unknown> };
/**
* The plugin's own guards, copied from plugin/src/main/code.ts so this script
* runs without a live Figma connection.
*/
const pluginGuards: Record<
string,
(nodeIds: string[], params: Record<string, unknown>) => string | null
> = {
// plugin/src/main/code.ts:692-697
set_text_content: (nodeIds, params) => {
if (!nodeIds[0]) return "nodeIds is required for set_text_content";
if (typeof params.text !== "string")
return "text is required for set_text_content";
return null;
},
// plugin/src/main/code.ts:906-915
set_solid_fill: (nodeIds, params) => {
if (!nodeIds[0]) return "nodeIds is required for set_solid_fill";
if (typeof params.hex !== "string") return "hex is required";
return null;
},
};
/**
* Mirrors LeaderServer.handleRPC: validate, then forward the caller's original
* params. See server/src/leader.ts:94-140 — note that `rpcReq.params` is sent,
* not the object `validateRpc` parsed internally.
*/
function simulateRpcCall(
tool: string,
nodeIds: string[],
params: Record<string, unknown>
): Outcome {
const validationError = validateRpc(tool, nodeIds, params);
if (validationError) {
return { status: "rejected-by-server", message: validationError };
}
const pluginError = pluginGuards[tool](nodeIds, params);
if (pluginError) {
return { status: "rejected-by-plugin", message: pluginError };
}
return { status: "applied", payload: params };
}
/**
* Mirrors the MCP tool handlers in server/src/tools.ts, which parse first and
* forward `parsed.data`. Included as a control: this path handles aliases fine.
*/
function simulateMcpToolCall(
tool: "set_text_content" | "set_solid_fill",
args: Record<string, unknown>
): Outcome {
const parsed = toolInputSchemas[tool].safeParse(args);
if (!parsed.success) {
return {
status: "rejected-by-server",
message: parsed.error.issues[0].message,
};
}
const { nodeId, fileKey, ...rest } = parsed.data;
const payload =
tool === "set_text_content" ? { text: rest.text } : { ...rest };
const pluginError = pluginGuards[tool]([nodeId], payload);
if (pluginError) {
return { status: "rejected-by-plugin", message: pluginError };
}
return { status: "applied", payload };
}
function describe(outcome: Outcome): string {
return outcome.status === "applied"
? `applied ${JSON.stringify(outcome.payload)}`
: `${outcome.status}: ${outcome.message}`;
}
interface Case {
name: string;
outcome: Outcome;
expected: Outcome["status"];
}
const cases: Case[] = [
// The bug: aliases pass validation, then die inside the plugin.
{
name: "RPC set_text_content { characters }",
outcome: simulateRpcCall("set_text_content", ["1:2"], {
characters: "hello",
}),
expected: "applied",
},
{
name: "RPC set_solid_fill { fillHex }",
outcome: simulateRpcCall("set_solid_fill", ["1:2"], {
fillHex: "#1B66D2",
}),
expected: "applied",
},
{
name: "RPC set_solid_fill { fillHex, fillOpacity }",
outcome: simulateRpcCall("set_solid_fill", ["1:2"], {
fillHex: "#1B66D2",
fillOpacity: 0.5,
}),
expected: "applied",
},
// Canonical spellings over RPC still work.
{
name: "RPC set_text_content { text }",
outcome: simulateRpcCall("set_text_content", ["1:2"], { text: "hello" }),
expected: "applied",
},
{
name: "RPC set_solid_fill { hex }",
outcome: simulateRpcCall("set_solid_fill", ["1:2"], { hex: "#1B66D2" }),
expected: "applied",
},
// Genuinely bad input must still be caught by the server, not the plugin.
{
name: "RPC set_solid_fill { } (neither spelling)",
outcome: simulateRpcCall("set_solid_fill", ["1:2"], {}),
expected: "rejected-by-server",
},
{
name: "RPC set_solid_fill { fillHex: 'not-a-hex' }",
outcome: simulateRpcCall("set_solid_fill", ["1:2"], {
fillHex: "not-a-hex",
}),
expected: "rejected-by-server",
},
// Control: the MCP tool path forwards parsed.data, so aliases work there.
{
name: "TOOL set_text_content { characters }",
outcome: simulateMcpToolCall("set_text_content", {
nodeId: "1:2",
characters: "hello",
}),
expected: "applied",
},
{
name: "TOOL set_solid_fill { fillHex }",
outcome: simulateMcpToolCall("set_solid_fill", {
nodeId: "1:2",
fillHex: "#1B66D2",
}),
expected: "applied",
},
];
const failures = cases.filter((c) => c.outcome.status !== c.expected);
for (const { name, outcome, expected } of cases) {
const ok = outcome.status === expected;
console.log(
`${ok ? "PASS" : "FAIL"} ${name.padEnd(46)} -> ${describe(outcome)}`
);
}
console.log();
if (failures.length === 0) {
console.log("All cases behave as expected — the RPC path is fixed.");
process.exit(0);
}
console.log(`${failures.length} case(s) reproduce the bug:`);
for (const { name, outcome, expected } of failures) {
console.log(` ${name}\n expected ${expected}, got ${describe(outcome)}`);
}
console.log(
"\nRoot cause: validateRpc (server/src/schema.ts:981) discards the parsed\n" +
"result, and the leader (server/src/leader.ts:135) forwards rpcReq.params\n" +
"verbatim — so the alias is never rewritten to the canonical key."
);
process.exit(1); |
Addresses review feedback on gethopp#39. validateRpc parsed the request and discarded the result, and the leader forwarded rpcReq.params verbatim. Any schema that rewrites its input — such as the create_* field aliases — cleared validation and then reached the plugin with the caller's original key, where it failed on "text is required" / "hex is required". That path was worse off than before the aliases landed, since it previously got a fast 400. validateRpc now returns { error, params } and the leader forwards the parsed params. nodeId and fileKey are dropped from that object: rpcToArgs folds nodeIds into nodeId so the tool schema can validate it, but the plugin reads node ids off request.nodeIds, and fileKey travels beside the params rather than inside them. The alias schemas are now derived from the advertised shape with .transform() instead of a second z.object behind .preprocess(). The duplicate declaration was a drift risk that failed silently — a field added to the shape alone would be stripped during parse with no error. This also matches setTextPropertiesInput and setEffectsInput, which already derive from their shapes, and lets the failure name the alias ("hex is required (fillHex is accepted as an alias)") instead of emitting a bare "Required" — the same confusing error the PR set out to remove. The "supply one of the two" rule is now in the .describe() strings as well, since the generated JSON Schema lists only nodeId as required. scripts/repro-alias-rpc.ts is the reviewer's reproduction, kept as a regression check; it inlines the plugin guards so it runs without a live Figma connection. Verified: tsc --noEmit clean; the repro passes all 9 cases; the original alias matrix still passes (canonical wins, invalid values rejected, create_* spellings untouched); and validateRpc's other 30+ tools return unchanged verdicts with no params dropped.
|
Thanks for the thorough review — all three points were right, and the RPC one was a real hole I'd left. Pushed in d2441f0. 1/ RPC pathConfirmed with your script before touching anything, and it reproduced exactly as you described:
Your script passes all 9 cases: I kept it at Since 2/ Duplicate schemasSwitched to One thing didn't work out as hoped: I tried dropping the widened So 3/ Describe stringsAdded — On the treadmillAgreed, and thanks for spelling out the endgame. #40 turned out to be a duplicate of #36 so I closed it, which leaves |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
server/scripts/repro-alias-rpc.ts (1)
39-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
fillOpacityalias isn't actually asserted by the regression suite.
set_solid_fill's guard only checksparams.hex; it never inspectsparams.opacity. As a result, the{ fillHex, fillOpacity }case (Lines 123-129) can report"applied"even iffillOpacitywere silently dropped during normalization — the guard has no way to catch that. Since normalizingfillOpacityis one of the two behaviors this PR is meant to guard against regressing, consider either extending the guard to requiretypeof params.opacity === "number"when supplied, or assertingoutcome.payload.opacity === 0.5directly in that test case.Proposed fix
set_solid_fill: (nodeIds, params) => { if (!nodeIds[0]) return "nodeIds is required for set_solid_fill"; if (typeof params.hex !== "string") return "hex is required"; + if (params.opacity !== undefined && typeof params.opacity !== "number") { + return "opacity must be a number when provided"; + } return null; },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/scripts/repro-alias-rpc.ts` around lines 39 - 44, Strengthen the regression coverage for the fillOpacity alias in set_solid_fill: require params.opacity to be a number when the alias is supplied, or directly assert outcome.payload.opacity equals 0.5 in the { fillHex, fillOpacity } test case. Ensure the test fails if fillOpacity is dropped during normalization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@server/scripts/repro-alias-rpc.ts`:
- Around line 39-44: Strengthen the regression coverage for the fillOpacity
alias in set_solid_fill: require params.opacity to be a number when the alias is
supplied, or directly assert outcome.payload.opacity equals 0.5 in the {
fillHex, fillOpacity } test case. Ensure the test fails if fillOpacity is
dropped during normalization.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: faceb053-3e77-4581-821b-ff559905e435
📒 Files selected for processing (4)
server/scripts/repro-alias-rpc.tsserver/src/leader.tsserver/src/schema.tsserver/src/tools.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- server/src/tools.ts
|
Thanks for the fixes @yavon007 those will be included in 0.0.18 |
Problem
The
create_*tools and theset_*tools use different names for the same concepts:create_frame/create_text/create_shapeset_solid_fill/set_text_contentfillHexhexfillOpacityopacitycharacterstextAn agent that just called
create_text({ characters: "Hi", fillHex: "#000" })naturally reaches forset_text_content({ characters: "Bye" })— and gets back a validation error for a field it did supply:The message is confusing because the SDK strips the undeclared
characterskey before validation, so the error names a field the caller never omitted. I hit this repeatedly while driving the bridge to build a multi-screen mockup — everyset_solid_fillandset_text_contentin a batch failed while thecreate_*calls beside them succeeded.Fix
Accept the
create_*spelling as an alias on both tools:set_solid_fillacceptsfillHex/fillOpacityin addition tohex/opacityset_text_contentacceptscharactersin addition totextAliases are declared on the advertised shape so the MCP SDK keeps them instead of stripping them, then a small
acceptAliaszod preprocessor normalises them onto the canonical field before validation.Deliberately conservative:
createHexColorSchema()validationhex/text, so no plugin update is needed and no version skew between server and plugintoolInputSchemas.set_text_contentnow points at the aliasing schema, sovalidateRpcaccepts the same spellings on the follower RPC pathparseToolInput's signature was widened toz.ZodType<T, z.ZodTypeDef, unknown>so it can take preprocessed schemas.Testing
tsc --noEmitpasses clean.Schema-level, covering both tools:
End-to-end against a live Figma file through the real stdio MCP transport — the two calls that failed before now succeed, canonical spellings still work, and invalid input is still rejected:
Note
Happy to take this in a different direction if you'd prefer — e.g. renaming the
set_*fields to matchcreate_*as a breaking change, or leaving the schemas alone and only improving the error message to name the alias. This felt like the least disruptive option since it needs no plugin change and breaks nothing.Summary by CodeRabbit