fix(figma): auth/retry/batch hardening, mapper fidelity, skill routing, setup docs#2112
Conversation
Two bugs from live figma-integration use: 1. `tokens` styles fallback 403s on non-Enterprise. /v1/files/:key/styles needs library_content:read — a scope the setup docs and the generic FORBIDDEN message both omitted, so the user saw "missing a read scope" with no way to know which. Each endpoint now carries a scope hint; the 403 names the exact scope (styles → library_content:read). Setup text and skill scope list updated to include Library content: Read-only. 2. `asset` (and every per-node component render) had no 429 handling — the message said "back off and retry" but the client didn't. Two imports in a row tripped the per-minute limit and hard-failed. get() now retries 429 with exponential backoff, honoring Retry-After when present, before surfacing RATE_LIMITED after maxRetries (default 3). sleep is injectable so tests don't wait. Batch multi-node asset syntax (the documented /v1/images comma-ids rate workaround) is a separate enhancement — retry makes the reported failure self-heal, including the many-node component path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the scope+retry work from the figma bug-bash (valid report:
9-bugs-with-repros; the skill-not-used report was discarded).
- 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad
PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing
scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN
with re-mint advice; a scope body surfaces figma's own diagnosis verbatim;
else falls back to the endpoint's scope hint. Reads both err and message
(variables endpoint uses message). One fix, honest messages for bugs 1/4/9.
- Batch asset fetch (requested): figma asset accepts multiple refs
(space-separated or comma-joined) of one file and renders them in a SINGLE
/v1/images call via new client.renderNodes — figma's documented per-minute
rate-limit workaround. runAssetImport delegates to runAssetImportMany;
cache-checks per node, batches only the misses, one index.md regen.
- NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling
the numbered setup list. Indent every line; single-line hints unchanged.
Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not
scope advice. Client suite 22, cli figma 33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Extended this PR with the rest of the valid bug-bash cluster (the second report interpreted its own findings against the raw MCP tools without the /figma skill — declared invalid, discarded; everything below traces to the CLI-driven report + the inline scope/retry/batch asks): 403-body parse (bug 4 + closes 1 & 9's messaging). Figma returns Batch asset fetch (requested). NO_TOKEN box (bug 8). errorBox indented only the first hint line, so the numbered setup list rendered ragged. Now indents every line; single-line hints unchanged (no impact on ~80 other callers). Deliberately not here: the mapper visual-fidelity cluster — IMAGE fills dropped to empty divs (bug 2, P0), rasterized-vector double-paint (bug 3), font affordance (bug 6) — is a separate subsystem ( Suites: core figma 114, cli figma 33, all green. |
miga-heygen
left a comment
There was a problem hiding this comment.
Review — fix(core): name missing figma scope in 403, retry 429 with backoff
Two real bugs fixed, plus a batch optimization that's the natural rate-limit workaround. Solid work.
Bug 1 — 403 scope naming
Clean design. SCOPE_HINTS gives each endpoint a named scope, readFigmaErrorMessage reads figma's own body (err and message fields), and forbiddenError classifies the 403 into BAD_TOKEN / REQUIRES_ENTERPRISE / FORBIDDEN-with-scope. The styles endpoint's library_content:read omission was a real trap — the docs said it wasn't needed, it was. Good that the NO_TOKEN setup text and the /figma skill's scope list both gained the line.
The forbiddenError reclassification of figma's "Invalid token" 403 as BAD_TOKEN is a nice catch — figma returns 403 (not 401) for bad PATs on file endpoints, so without this the user gets a confusing "missing scope" message when the real problem is a dead token.
Nit: forbiddenError mixes throw (for the "Invalid token" path) and return (for everything else). Both work — the caller already wraps the return in throw — but the inconsistency is confusing to read. Either all-throw or all-return would be cleaner.
Bug 2 — 429 retry
get() now retries 429 with exponential backoff (1s → 2s → 4s), honors Retry-After when present, and sleep is injectable for testing. retryAfterMs handles both integer-seconds and HTTP-date forms. Clean.
Test coverage is thorough: retry-then-succeed, Retry-After honored over the default, attempt count verified (1 initial + 3 retries = 4), RATE_LIMITED only after exhausting retries.
Batch renderNodes
Good refactoring. renderNode delegates to renderNodes via this (correct for test fakes that override renderNode). runAssetImport delegates to runAssetImportMany. The extracted helpers all carry new facts:
requireNodeRef— validation + error messagereuseExisting— cache-hit logic with metadata upsert (needed by both single and batch)freezeAndRecord— download + sanitize + freeze + record, deliberately omits index regeneration so the caller does it once
The batch path in runAssetImportMany is clean: resolve cache hits first, batch-render only the misses in one /v1/images call, fill in slots, regen index once. Per-node failures come back as url:null rather than aborting the batch — matches the renderNodes contract.
Cross-file batch rejection (all refs must share a fileKey) is correctly enforced with a clear error message.
SSOT check
- No duplicated decisions. Cache-hit logic has one owner (
reuseExisting), 403 classification has one owner (forbiddenError), retry has one owner (get()). - No stale concepts. The old inline status checks in
get()are fully replaced bythrowForStatus. The old inline/v1/imagescall inrenderNodeis replaced by delegation torenderNodes. safeRegenerateIndexis called exactly once per import path (end ofrunAssetImportMany), not N times per node. Good.
Tests
17/17 (up from 13). New tests cover retry-then-succeed, Retry-After honored, styles 403 scope naming, figma's own err field surfaced verbatim, "Invalid token" reclassified as BAD_TOKEN, REQUIRES_ENTERPRISE preserved, batch renderNodes (one call for many nodes, url:null for failed nodes), runAssetImportMany (batch import, cross-file rejection). All the new behavior is covered.
Verdict
LGTM. The forbiddenError throw/return inconsistency is a nit — take it or leave it.
— Review by Miga
CI typecheck caught the tokens.test mock missing the new renderNodes member on FigmaClient (asset/component mocks were updated, this one was missed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 4fc699f.
Solid bug-bash bundle — 403-body parsing, batch /v1/images, retry-with-backoff, and the multi-line NO_TOKEN indent are all well-motivated, and the test suite meaningfully covers the interesting shapes (Retry-After honored, "Invalid token" → BAD_TOKEN reclassification, batch → one call).
Blockers
- CI Typecheck is red.
packages/cli/src/commands/figma/tokens.test.ts:16-36builds aFigmaClient-typed fake withrenderNode / imageFills / variables / styles / nodeTree / fileVersion— but the newrenderNodesmethod wasn't added, so TS reports the literal isn't assignable toFigmaClient. Theasset.test.tsandcomponent.test.tsfakes both got the delegation shim;tokens.test.tsdidn't. Same one-liner fix — either addrenderNodes: () => Promise.reject(new Error("unused"))(tokens tests never render nodes), or thethis.renderNode-delegating stub used incomponent.test.tsfor symmetry.bun run --filter '@hyperframes/cli' typechecklocally to confirm before pushing.
Concerns
- Retry-loop scope widening (
client.ts:276): the retry is at theget()level, so all six read endpoints inherit it. Likely intentional, but blast-radius isn't obvious from PR title/body and only thestylespath has an explicit retry test. forbiddenErrorthrow/return asymmetry (client.ts:219): BAD_TOKEN branch throws; FORBIDDEN / REQUIRES_ENTERPRISE branches return and rely on the caller'sthrow. Works today, but fragile if wrapping ever gets added at the callsite.
Nits
retryAfterMsuncapped (client.ts:127): a spec-legal hugeRetry-Aftersilently blocks the CLI.- CLI positional comma-splitter is URL-unsafe (
asset.ts:277): a figma URL containing a literal,gets torn.
Questions
- Was
component.tsintentionally left alone (relying onrenderNode → renderNodesdelegation for the retry inheritance), or is there a follow-up to make it userenderNodesdirectly for batch rate-limit gains on many-node component imports? The PR body implies retry-only, which is what the delegation gives you — just checking. - Any reason
runAssetImportManyrejects cross-file batches with athrow new Error(...)instead of a typedFigmaClientError? Not a bug, but the surrounding module uses typed errors elsewhere.
What I didn't verify
- Live figma behavior of
Retry-After(only inferred from HTTP spec / figma docs). - Whether any other test file has a
FigmaClientfake that also needs the shim — I only inspected the failure the CI report named, but iftokens.test.tswas missed, worthrg 'FigmaClient' packagesbefore pushing to catch any sibling.
| * file endpoints), "Invalid scope(s) … requires X" is a missing scope | ||
| * surfaced verbatim. Falls back to the endpoint's scopeHint when the body | ||
| * is silent. */ | ||
| function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError { |
There was a problem hiding this comment.
🟠 forbiddenError has an asymmetric throw/return contract that will bite a future reader. The BAD_TOKEN branch throws inside the function; the REQUIRES_ENTERPRISE and FORBIDDEN branches return and rely on the caller's throw forbiddenError(...) at line 257 to actually throw. It works today because the caller unconditionally re-throws — but the function's return type says FigmaClientError, so a reader can't tell the two branches diverge without stepping through both call sites. If someone later adds a caller that does const e = forbiddenError(...) (e.g. wrapping / decorating the error), the BAD_TOKEN branch silently bypasses their wrapping and the FORBIDDEN branch doesn't. Pick one shape — either throw in every branch (return type becomes never) or return in every branch (caller does the throw once).
— Review by Rames D Jusso
There was a problem hiding this comment.
Fixed in 87e2a70 — forbiddenError now RETURNS in every branch (the BAD_TOKEN branch no longer throws inside), so the caller's single throw forbiddenError(...) covers all three cases and a future wrapping caller gets consistent behavior. Return type stays FigmaClientError.
| // per-minute, so a couple of imports in quick succession hit it and a | ||
| // short wait clears it. Honor Retry-After when present, else exponential. | ||
| let res: Response; | ||
| for (let attempt = 0; ; attempt += 1) { |
There was a problem hiding this comment.
🟠 The retry loop lives inside get(), so this PR silently changes the 429 behavior of every read endpoint — imageFills, variables, styles, nodeTree, fileVersion — not just renderNodes. That's likely the right call (per-minute limits are per-endpoint but the client's callers all benefit), but the PR title / body scopes the fix to asset and per-node component renders, and the test suite only asserts retry on the styles path. Two suggestions: (a) mention in the PR body / SKILL.md that all reads retry now — the RATE_LIMITED line already says the client retries with backoff, but the widening isn't obvious to someone reading the diff to understand blast radius; (b) if any endpoint is intentionally meant to fail fast on 429 (e.g. variables because callers already have a styles fallback and a 3-retry wait delays the fallback by up to 7s), it needs an opt-out. Otherwise the current shape looks correct.
— Review by Rames D Jusso
There was a problem hiding this comment.
Addressed in 87e2a70 (docs) — you're right it's the correct call, so I kept the widening and documented the blast radius: the SKILL error-code note now says the 429 retry lives in the shared request path and applies to EVERY read (assets, tokens, styles, node trees, versions), with Retry-After honored and capped at 60s. On (b): variables intentionally still retries — its fallback is triggered by REQUIRES_ENTERPRISE (403), not 429, so a 429 there is a transient limit worth retrying, not a gate to fail past. Added a test asserting retry on renderNodes to lock the widened behavior beyond the styles path.
|
|
||
| /** Parse a Retry-After header (figma sends integer seconds; the HTTP spec | ||
| * also allows a date) into ms, or null when absent/unparseable. */ | ||
| function retryAfterMs(res: Response): number | null { |
There was a problem hiding this comment.
🟡 retryAfterMs has no upper bound. If figma ever sends Retry-After: 3600 (spec-legal — it's the value they emit for tier-quota exhaustion on some paid-endpoint stacks), the CLI silently blocks for an hour before returning RATE_LIMITED, with no output. Same for a Retry-After date parsed to "1 hour from now." Consider capping at ~60s and using min(retryAfterMs, cap): past a minute the user's better off Ctrl-C'ing and either waiting explicitly or reducing batch size (which the message already suggests). Non-blocking, but a common footgun with header-driven waits.
— Review by Rames D Jusso
There was a problem hiding this comment.
Fixed in 87e2a70 — retryAfterMs (and the date branch) now clamp to a 60s MAX_RETRY_WAIT_MS, so a spec-legal Retry-After: 3600 waits 60s max instead of blocking for an hour. Test asserts 3600 → 60000.
| const positionals = | ||
| Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref]; | ||
| const refs = positionals | ||
| .flatMap((r) => String(r).split(",")) |
There was a problem hiding this comment.
🟡 The comma-splitter is greedy: .flatMap((r) => String(r).split(",")) treats every literal , in argv as a delimiter, even inside a figma URL query. A user pasting hyperframes figma asset 'https://…/file/KEY?node-id=1%3A2,3%3A4' (figma sometimes uses comma-separated node-id= in shared multi-select URLs — verify against a real link, but it's a plausible copy-paste) would get the URL torn in half at the comma → parse errors on both halves. Two options: (a) only split when the token doesn't look like a URL (!/^https?:/i.test(r)); (b) require the user to be explicit and pass separate positional args when using URLs, then only split unquoted fileKey:nodeId positionals. Also worth a test where one positional is a URL — the current batch test uses raw KEY:1-2 refs, which don't exercise this path.
— Review by Rames D Jusso
There was a problem hiding this comment.
Fixed in 87e2a70 — the splitter now leaves URL tokens whole (/^https?:/i → no split) and only comma-splits bare fileKey:nodeId tokens, so a multi-select figma URL (node-id=1:2,3:4) survives intact. Extracted to a pure gatherAssetRefs() with a test covering URL-stays-whole, bare-splits, and the mixed case.
…try-After, URL-safe ref split Rames's inline findings on #2112: - forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws inside) so the caller's single throw covers all cases — no mixed throw/return contract for a future wrapping caller. - retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer silently blocks the CLI for an hour before RATE_LIMITED. - asset ref gathering extracted to gatherAssetRefs() and made URL-safe: bare fileKey:nodeId tokens comma-split, but a figma URL with commas in its query (multi-select node-id=1:2,3:4) is kept whole. - Documented in SKILL that 429 retry lives in the shared request path, so EVERY read endpoint retries (not just asset) — blast-radius note the reviewer asked for. variables intentionally still retries: its fallback is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate. Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs URL-vs-bare split. client 24, cli asset 11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @james-russo-rames-d-jusso — all four inline findings fixed in 87e2a70 (replied on each thread). Answering the two questions:
Cross-file On the tokens.test.ts flag in "what I didn't verify": good instinct — CI's Typecheck caught exactly that (the |
miguel-heygen
left a comment
There was a problem hiding this comment.
Additive review on top of @miga-heygen's (which covered the client design, SSOT, and test coverage — no repeats here). I built this branch and ran it live against the Figma API with tokens at three scope levels, plus the batch path against the Simple Design System community file.
E2E verified (branch build, live API):
tokenswith a file_content+file_metadata token now surfaces Figma's verbatim diagnosis: "Invalid scope(s): … requires the library_content:read scope" — exactly the dead end this fixes.- A bogus token now gets "figma rejected the token (403 Invalid token) … Re-mint" instead of scope advice.
- NO_TOKEN box renders the numbered list correctly aligned, with the new Library content line.
- Batch:
asset 'K:A' 'K:B,K:C'→ mixed cache-hit + 2 misses, one/v1/imagescall, correct snippets; re-run all-reused; cross-file batch rejected with the clear per-file message. Unit suites 47/47 on the branch. - Not verified live: the
tokenssuccess path (needs a token withlibrary_content:read) and a real 429 (covered by the new unit tests).
blocker: docs/guides/figma.mdx is not in the diff, so merging this makes the public guide contradict the code and skill it ships:
- The scope table (~lines 30-38) still omits Library content: Read-only and still says "Skip the Variables scope — tokens automatically falls back to your published styles. That's expected behavior, not an error" — false without
library_content:read, which is the very trap this PR fixes. - The troubleshooting table still documents
BAD_TOKEN (401)while this PR (correctly) makes bad PATs surface as403 Invalid token, and the FORBIDDEN row's fix text predates the named-scope messages.
The repo's convention is that docs/skill/CLI surfaces move in lockstep; the skill got the update, the guide didn't. It's a small diff — scope table row + two troubleshooting rows — and this PR is the right vehicle for it.
nits:
asset.ts(run): "(N nodes in 1 figma request)" prints even when every node was a cache hit and zero render requests were made — gate it on at least one miss, or say "reused".runAssetImportMany: a RENDER_FAILED mid-batch throws after earlier misses were already frozen+recorded but beforesafeRegenerateIndex, leavingindex.mdstale until the next import. Regenerating in afinally(or importing the good nodes and reporting the bad ones) would be tidier.- PR body says batch multi-node asset syntax is "Not in this PR" — it is (and it's good); worth updating the description so the history reads right.
Scope note for anyone tracking the bug-bash list: this lands fixes for the tokens-scope trap, the Invalid-token misclassification + discarded 403 bodies, and the errorBox list mangling, plus 429 retry and batch rendering. Still open from the bash (separate PRs): IMAGE fills dropped in component import (empty divs, silent), double-painted fills on rasterized nodes, registry-item.json asset paths, missing font affordance, and the raw stack trace on unknown flags.
Verdict: REQUEST CHANGES
Reasoning: The code is correct and e2e-verified, but shipping it without the figma.mdx update leaves the public setup guide actively contradicting the new behavior — same-PR docs sync is the fix.
— Fable
…index in finally Addresses @miguel-heygen's review on #2112: - BLOCKER: docs/guides/figma.mdx now matches the shipped code/skill — adds the Library content: Read-only scope row (+ corrects the 'falls back, expected' line that was false without it), and the troubleshooting table now says bad PATs surface as 403 Invalid token (not 401), names the scope in FORBIDDEN, and documents RATE_LIMITED retry. - nit: the batch summary line no longer claims '1 figma request' when every node was a cache hit — says 'all reused from cache — no figma request'. - nit: index.md regen moved to a finally, so a mid-batch RENDER_FAILED leaves index.md consistent with the nodes that did freeze. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Fixed in 43af582, @miguel-heygen — thanks for the live E2E pass and the docs catch. Blocker — Nits, all taken:
Confirmed all green after the render-regression flake ( |
miga-heygen
left a comment
There was a problem hiding this comment.
Re-review — fix(core): name missing figma scope in 403, retry 429 with backoff
All prior feedback addressed:
forbiddenErrorthrow/return — fixed, now RETURNS in every branch. The comment calls it out explicitly.- CI Typecheck —
tokens.test.tsgot therenderNodesstub. retryAfterMsuncapped — capped atMAX_RETRY_WAIT_MS = 60_000. Test verifies3600 → 60_000.- URL-safe comma splitter — extracted
gatherAssetRefs()with URL detection (/^https?:/i→ no split). Test covers URL-stays-whole, bare-splits, and mixed cases. - Retry widening undocumented — SKILL.md error-code note now says all reads retry. Test verifies retry on
renderNodestoo. figma.mdxdocs sync — scope table has Library content row, troubleshooting table updated for BAD_TOKEN/FORBIDDEN/RATE_LIMITED.- Cache-hit batch message — now distinguishes "N rendered in 1 figma request" vs "all reused from cache — no figma request".
safeRegenerateIndexon mid-batch failure — wrapped intry/finally.
All CI green.
One remaining issue
docs/guides/figma.mdx troubleshooting table — duplicate RATE_LIMITED row. The new row (correct: "The client retries with backoff automatically…") was added, but the old row ("REST per-minute budget hit | Wait a minute and retry; chunk batch renders") wasn't removed. Two rows for the same error code, one reflecting the new auto-retry behavior and one not. Delete the old row — the new one is the source of truth.
This is a one-line fix. Once that stale row is removed, this is a clean LGTM.
— Review by Miga
…ens false-success nodeToHtml routed rasterize eligibility off node.type alone, so a RECTANGLE/FRAME with an IMAGE fill fell through to the generic <div> path — fillCss() has no IMAGE case, so it rendered an empty box. IMAGE-filled nodes now route to rasterize like vectors, regardless of node.type. Rasterized nodes (vectors, now image fills too) were also getting their own fill/corner-radius CSS applied on top of the already- rendered <img> — a flat color block behind/around the real art, flattening non-rectangular shapes into rounded rects. decorationCss now skips background and corner-radius/clip for rasterized nodes; opacity and effects still apply since those aren't baked into the export. tokens.ts's styles-fallback path hardcoded entries: [] regardless of how many published styles were actually found, so the CLI printed "recorded published style metadata instead" even when styles() returned zero results. Added styleCount to the result so the message reflects what happened, and points at the MCP get_variable_defs fallback when there's nothing to fall back to. Co-Authored-By: Claude Opus <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Two real incidents this week had agents skip /figma entirely and drive Figma via raw MCP tools (get_metadata/get_screenshot/get_design_context) when a figma.com URL landed inside a creation-workflow skill. Root cause: none of the creation workflows mention Figma at all, and the only routing table that does (/hyperframes) is skipped whenever a workflow is invoked directly rather than through the entry router — which is the common path. Going raw loses real infrastructure the CLI/skill guarantees: sanitizeSvg() before freezing (raw-fetched SVGs are unsanitized), .media/manifest.jsonl provenance (no cache-hit, no version tracking), and brand-token var() binding (colors bake as literals, so a later Figma brand change can't propagate without a full re-import). Added a "figma source" callout to every creation workflow that could plausibly receive a figma.com link (product-launch-video, website-to-video, general-video, motion-graphics, slideshow), plus a defense-in-depth line in /hyperframes's own routing checklist. The fix lives in the workflows themselves so it doesn't depend on the entry router being consulted. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Miga's re-review caught it: the new RATE_LIMITED row (client auto-
retries with backoff) landed alongside the old pre-retry row ("wait a
minute and retry; chunk batch renders"), leaving two rows for the same
code — one accurate, one stale. Keep only the current one.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
|
@miga-heygen fixed — removed the stale duplicate |
…n wall The old setup section was one long token-minting flow with the MCP connector as an afterthought — but motion/shaders/storyboards need only the connector (no token at all), and even brand tokens are easier via MCP on any non-Enterprise plan. A reader wanting only motion had to wade past REST scope tables meant for a different path. Restructured into a decision table (what you want → which credential) followed by two equal, independent steps. Step A's scope list now states the 3 boxes to check on a normal (non-Enterprise) account up front, instead of a generic 4-scope table the reader has to interpret themselves. Step B now states the MCP connector's actual capability (variable reads work on any plan, rate-limited by tier — 6 calls/month on Starter) instead of "no scopes to configure," which undersold what it can do. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Figma-integration bugs from the CLI-driven bug bash (the 9-bugs-with-repros report) plus the inline scope/retry/batch asks, extended with a second bug-bash report's findings (this one CLI-verified — see report vs the discarded skill-not-used one), plus a cross-cutting fix for agents skipping this integration entirely.
Transport / auth (packages/core/src/figma/client.ts)
Scope-named 403 + 403-body parse.
/v1/files/:key/stylesneedslibrary_content:read— a scope the docs omitted, so thetokensnon-Enterprise fallback dead-ended on a generic "missing a read scope" message. Now every endpoint carries a scope hint ANDget()reads Figma's 403 body:"Invalid token"reclassifies to BAD_TOKEN (Figma returns 403, not 401, for bad PATs — verified live),"Invalid scope(s)… requires X"surfaces Figma's own diagnosis verbatim, else the endpoint scope hint. Reads botherrandmessagefields.429 retry with backoff.
get()retries 429 (exponential 1s→2s→4s, honoringRetry-Aftercapped at 60s) before surfacing RATE_LIMITED aftermaxRetries(default 3). Lives in the shared request path, so every read endpoint inherits it;sleepinjectable for tests.Batch asset fetch (packages/cli/src/commands/figma/asset.ts)
figma assetaccepts multiple refs — space-separated or comma-joinedfileKey:nodeId(URLs kept whole) — of one file and renders them in a single/v1/imagescall via newclient.renderNodes, Figma's documented per-minute rate-limit workaround. Cache-checks per node, batches only the misses, regeneratesindex.mdonce (in afinally, so a mid-batch failure still leaves it consistent).runAssetImportdelegates torunAssetImportMany; cross-file batches rejected with a clear message.Mapper fidelity (packages/core/src/figma/nodeToHtml.ts, packages/cli/src/commands/figma/tokens.ts)
Independently confirmed by a second, CLI-verified bug-bash report:
node.type(VECTOR/BOOLEAN_OPERATION/etc.) — a RECTANGLE or FRAME carrying anIMAGEfill (the common case for a pasted photo/icon) fell to the generic HTML path, wherefillCss()has noIMAGEbranch and emits nothing. Now any node whose first visible fill isIMAGEroutes to rasterize regardless ofnode.type.stylestring (background + border-radius from the node's own fill/shape) was applied even to the<img data-figma-rasterize>placeholder — a flat color block behind/around the already-exported art, flattening non-rectangular shapes into rounded rects.decorationCssnow skips background/corner-radius/clip for rasterized nodes (vectors and now image fills); opacity and effects still apply since those aren't baked into the export.figma tokensclaimed a false success. The styles-fallback path hardcodedentries: []regardless of how many published styles were actually found, so the CLI printed "recorded published style metadata instead" even when the file had zero published styles. AddedstyleCountto the result so the message reflects what actually happened, and points at the MCPget_variable_defsfallback when there's nothing to record.Agents skipping this integration entirely (skills/)
Two separate incidents this week had an agent skip
/figmaaltogether and drive Figma via raw MCP tools (get_metadata/get_screenshot/get_design_context) from inside a creation-workflow skill. Root cause: no creation workflow mentioned Figma at all, and the one routing table that does (/hyperframes) is skipped whenever a workflow is invoked directly — the common path. Going raw losessanitizeSvg()before freezing,.media/manifest.jsonlprovenance, and brand-tokenvar()binding (colors bake as literals, no rebrand-on-brand-change).Added a "figma source" callout to every creation workflow that could plausibly receive a figma.com link (
product-launch-video,website-to-video,general-video,motion-graphics,slideshow), plus a defense-in-depth line in/hyperframes's own routing checklist — the fix lives in the workflows themselves so it doesn't depend on the entry router being read.Setup docs (docs/guides/figma.mdx)
Rewrote the One-time setup section to lead with "which credential do I need" instead of a token-minting wall: a decision table (asset/component → token; brand tokens → either, MCP is easier off-Enterprise; motion/shaders/storyboards → MCP only, no token), then two equally-weighted steps. Step A's scope guidance now states the 3 boxes to check on a normal (non-Enterprise) account directly, instead of a 4-row table the reader had to interpret. Step B now states what the MCP connector actually gets you — brand-token reads on any plan, rate-limited by tier (Starter: 6/month; paid Full/Dev: 200–600/day) — instead of the old "no scopes to configure" line, which undersold it. Also removed a stale duplicate
RATE_LIMITEDtroubleshooting row (caught in review) and synced the scope/troubleshooting tables to the 403/429 behavior above.CLI ergonomics
errorBoxnow indents every hint line, so theNO_TOKENnumbered setup list stops rendering ragged (single-line hints unchanged).Tests
Core figma client 24 (unchanged) +
nodeToHtml18 (+2: IMAGE-fill routing, no double-paint on rasterized nodes). CLI figma 27 across asset/tokens/component/skillContent (tokens+1: styleCount 0 on a file with no published styles). All green — retry-then-succeed, Retry-After honored + capped, scope-named / verbatim-scope / Invalid-token 403 classification, batch one-call-for-N + url:null per-node + cross-file rejection, URL-safe ref splitting, IMAGE-fill rasterize routing, rasterized-node decoration stripping, styles-mode false-success guard.Not in this PR (separate mapper subsystem)
Rasterized-vector double-paint and IMAGE-fill drop are now fixed above; still separate and out of scope here: missing font affordance, registry-item asset paths, and the unknown-flag stack trace (shared CLI dispatcher). Batch component import (one
/v1/imagesfor all rasterized nodes in a component, not just per-asset-import batches) is a follow-up on top of the retry this PR already gives.🤖 Generated with Claude Code