MCP for AI agents
Connect
One command, using an API key from Team settings → Integrations → Manage API keys:
claude mcp add --transport http grails https://api.grails.design/mcp/v1 \ --header "Authorization: Bearer grails_sk_XXXXXXXX"For editors that use a config file:
{ "mcpServers": { "grails": { "type": "http", "url": "https://api.grails.design/mcp/v1", "headers": { "Authorization": "Bearer grails_sk_XXXXXXXX" } } }}Connect Figma’s AI Agent
To connect Figma’s AI Agent to Grails via MCP, provide the Grails server endpoint and credentials:
- Server URL:
https://api.grails.design/mcp/v1 - Transport: HTTP (Streamable HTTP)
- Authentication: Bearer token (
Authorization: Bearer grails_sk_...) - Key Scopes:
read:library: Required to inspect components, query properties, search tokens, and resolve modes.write:docs: Optional, required if the agent writes or updates documentation blocks.
- Node IDs: Query tools (
findEntities,getEntities,getComponent) accept native Figma node IDs (e.g."1:234") and component keys directly.
findEntities — when you cannot name it
findEntities({ query: "the destructive action button" }) by descriptionfindEntities({ relatedTo: "2072:9432" }) by an id you havefindEntities({ relatedTo: id, depth: 2 }) two hops outExactly one of query or relatedTo. Both, or neither, is an error that names both.
Both halves open on read:library. They needed different scopes until September 2026 —
relatedTo returns the tokens and styles a component binds, which name lookup never revealed —
and the split turned out to be the problem rather than the fix: the same key could reach those
tokens through this door and was refused them by listEntities({ type: "token" }).
With relatedTo you do not pass a type. The id determines it — and it can be a Grails id or a
Figma node id, interchangeably. The walk paginates with a cursor and does not tell you how many
are left: answering that would mean walking the whole graph for a question you might not ask.
The walk reaches patterns too: from a component, the patterns that use it; each pattern comes with its status
(draft or published). relatedTo does not take a pattern’s id: what a pattern uses is in its getPattern, and what
contains it in getBlastRadius({ entity_type: "pattern" }).
With query, read coverage first. It reports how much of the catalogue each dimension reaches.
If semantic coverage is 3%, an empty result means almost nothing is indexed, not there is nothing
like it. There is no switch to turn the semantic dimension on: it always runs, and degrades on its
own when it cannot.
Component and doc-page results carry path, their folder. A search is where two components with
the same name come back side by side, and path is what tells them apart.
listEntities — walking the catalogue
listEntities({ type: "component" }) the catalogue, paginatedlistEntities({ type: "component", q: "button", filtered syncStatus: ["synced"], hasDocs: true })listEntities({ type: "component", fields: ["id","name"] }) fewer columns, same rowslistEntities({ of: "prop-candidates", what a slot accepts componentId: id, prop: "icon" })It filters, but it does not rank. Twelve of its arguments narrow which rows come back — a name
substring, sync state, publish state, depth (exact, minimum or maximum), node type, collection, name
prefix, and whether an entity carries documentation, a spec or drift. There is no filter by parent
set: a set’s variants are not rows of their own, they are in its spec (getComponentSpec). type is not
one of them: it chooses which catalogue you are walking, and it is a closed list that takes no free
text. That is what keeps this apart from findEntities, which takes a phrase and ranks. q here is
a substring of the name, not a search.
Identity, not detail. You get id, name, type and sync status. The detail is getEntities, asked
for only where you want it — that split is the whole point of the pair.
Walk the whole set by passing pagination.next_cursor back as cursor. Do not build your own
offsets — there is no page number, by design, so a sync running mid-walk cannot make you skip or
repeat a row.
Arguments are camelCase; the response echoes snake_case. You pass hasDrift, syncStatus,
sortBy; filters_applied comes back with has_drift, sync_status, sort_by. It echoes the
filters that took effect after defaults, which is how you check the server understood you.
search_mode_used says which strategy actually served q.
Prop candidates come back whole, ready to instantiate: id, type, name, path, whether it is an asset, sync status and the Figma node id.
They come a page at a time, in the designer’s order: 50 by default, limit up to 200, and
pagination.next_cursor to continue. A slot can suggest more than a thousand components, so the spec
serves only the first 20 of each property and says how many there are in
props[name].candidates (shown, total, at most what the next page costs, and this call). The first page
here starts at the first suggestion, so it repeats the ones the spec showed. A cursor issued before
the component synced again is refused: the new spec is another list. Restart without one.
Over REST the same door is GET /v1/components/{id}/props/{prop}/candidates, with {prop}
url-encoded.
getEntities — one door, any entity
getEntities({ ids: ["2072:9432"] }) the abstractgetEntities({ ids: [id1, id2, id3] }) three at once, one callgetEntities({ ids: [id], include: ["include-props"] }) add a layerIds are interchangeable. Where an id goes, a Grails id or the Figma reference you have both work:
a node id (1234:5678), a node id with its file (<file key>/<node id>), a Figma URL with node-id,
or a component’s key. A variant’s node id or key answers its set, with variant: { configuration }
saying which one, and getComponentSpec serves that variant. If you are coming from Figma you should
not have to translate anything.
Ask for several at once. ids is always plural, so one id and ten are the same call — and ten
ids in one call cost far less than ten calls. There is no separate batch tool to choose.
Layers accumulate; none replaces the abstract. include-props, include-tokens,
include-values, include-mentioned-by.
include-mentioned-by answers which documentation talks about this entity, and it is the only
layer that works on every type — anything documentation can name can be discussed, including types
that have no other layer at all.
A layer that a given type cannot serve is skipped rather than refused — a token has no properties,
and asking for them is not an error. Read included to see what was actually applied, which is how
you tell not applicable from empty. An empty included where you asked for a component layer
means that component has no extracted spec: re-sync it from the plugin, because asking again will
not change it.
Variants
For a component set the abstract lists every configuration it has under variants, each priced
on its own:
getEntities({ ids: [setId] }) → variants: [ { configuration: { Size: "Medium", Type: "Initial" }, est_tokens: 590 }, { configuration: { Size: "Large", Type: "Initial" }, est_tokens: 1130 }, … ]Nothing is truncated, and that changed on purpose. The list used to cut off at 20 and point the rest at a layer that returned every tree — so a caller who wanted configuration 21 had exactly one route: pull the whole document. Measured against the largest set in production: all 397 configurations cost 10,959 bytes against 771,103 for the trees — 1.4%. Listing them all is a rounding error next to the document it used to force you into buying.
variants_how closes the abstract with the exact, copy-pasteable calls to getComponentSpec — one
configuration, several, or ["all-variants"] — plus est_tokens_all, the cost of asking for
everything:
getComponentSpec({ componentId: setId, variants: [<one configuration above>] })getComponentSpec({ componentId: setId, variants: [<configurations above>] })getComponentSpec({ componentId: setId, variants: ["all-variants"] })Ids that resolve to nothing come back in unresolved, with the reason, and the rest still
return. Ten ids where one is wrong give you nine entities — not an error.
A Figma node id can be ambiguous as well: it is unique inside one Figma file, and a project can
hold several. When two files have a component under the same node id, matched names both, each
with its file_key: ask again as <file key>/<node id>.
An id can also come back not-indexed: nothing matched, and the project has sets whose variants
have that kind of id unrecorded (sets_without_ids says how many), so it may be one of them and the
API cannot tell. A variant’s key is recorded from the next time its set’s whole spec is synced; until
then, in its library file, the variant’s node id resolves it.
Recipes — the sequences that do real work
A single tool call rarely answers a real question. These are the paths worth knowing.
Build one component
getEntities({ ids: [componentId] }) the abstract — start heregetEntities({ ids: [componentId], include: ["include-props"] }) what it acceptsgetComponentSpec({ componentId }) the default treegetComponentSpec({ componentId, variants: [{ … }] }) one configurationThe first call gives you the abstract: id, name, path (for a component or a doc page),
figma_id, context, sync status, and — for a
variant set — every configuration that exists, each priced under variants. It is a fraction of the
cost of the whole document, and it is enough to decide what to ask for next.
The abstract carries neither props nor anatomy. include-props, on getEntities, is what
the component accepts. anatomy — the element trees — is not a layer of this tool at all: it lives
in getComponentSpec, a door of its own, because a layered call is stateless and would resend this
abstract on every variant it fetched. more tells you how many entities each remaining getEntities
layer covers and the exact call that fetches it; variants_how, on the abstract itself, does the
same for getComponentSpec.
To turn bindings into values, add include-tokens. data[].tokens comes back with data[] — each
variable resolved per mode, the alias chain in via — plus names_in_spec, the count of $token
names over the whole document, not_in_this_project, and style_refs. The style keys under style_refs
go straight to getEntities({ ids: ["S:…,"] }), which takes them raw.
If the component also lives in your code, getComponentSpec answers pairing_map: which Figma prop, value and
element is which code one (Size → size, Medium → default). Use it to translate the spec instead of guessing
the names.
Build something composed
getComponentBuildOrder({ componentId }) → ordered_components[] ← the component you asked for is LASTgetComponentSpec({ componentId: orderedIds[0] })getComponentSpec({ componentId: orderedIds[1] }) → one call per id — getComponentSpec does not batch across componentsBuild order returns dependencies first, so you can walk the list top to bottom and never
reference something you have not built yet. Each entry is { position, component_id, name, path, component_depth, is_leaf, grails_sync_status }.
One component per call, and that is on purpose. The build order of ten unrelated components is not a list of anything — you build one card at a time, and what the first teaches you changes what you ask next. It is also computed for your request rather than read from a stored column, so a batch would be N sequential computations hiding inside one response.
Fetching the trees afterwards costs one call per component too: getComponentSpec takes a single
componentId — it can batch variants of one component, never several components at once.
Find the thing you want to reference
findEntities({ query: "brand", types: ["token"] })Use it before writing documentation or any content that points at another entity. References are uuids, and this is the only way to turn a name into one.
It reaches all eight documentable kinds — components, tokens, styles, token collections, screens, flows, doc pages and the project itself — so the set of things you can find is the set of things you can document.
Document an entity
describeDocumentationBlockTypes() → the guide: the order of steps, every writable shape, a valid example of eachgetEntityDocumentation({ entityType, entityId }) → what is already there, plus `mentions`: what it references, resolvedwriteDocumentationBlocks({ mode: "append", → iterate here, this never writes entityType, entityId, blocks, dryRun: true })writeDocumentationBlocks({ mode: "append", entityType, entityId, blocks })Read before you write. writeDocumentationBlocks({ mode: "replace" }) replaces everything on the
entity — it does not merge and it does not ask. getEntityDocumentation({ entityType, entityId })
returns blocks in the same shape you send, which is what makes it possible to compare instead of
overwriting blind. When in doubt, append.
Then read dropped_mentions in the answer. The dry run and the write both list each {{type:id}}
that names nothing in this project ([] when none). Its text stays in the block but is never recorded
as a mention: find the entity with findEntities and write the block again.
This writes documentation about something that already exists. Writing a standalone document — one with a title, a URL and a folder — is the next recipe, and the difference is spelled out under Two kinds of documentation.
Write a standalone page
listDocumentationPages() → what exists, and which folders existcreateDocumentationPage({ title: "Motion principles", → READ the `slug` it returns path: "Foundations" })writeDocumentationBlocks({ mode: "append", entityType: "doc-page", entityId: <the id it returned>, blocks })The page is created empty, and the blocks go in separately — the same writer that documents a
component, pointed at entityType: "doc-page".
List before you create. A folder is not an entity: the only way to know Foundations already
exists is to see a page carrying it, so inventing the name blind is how a project ends up with two
folders spelled the same.
Audit before a refactor
checkHealth() → what is running on an old capturelistEntities({ type: "component", hasDrift: true }) → the same set, paginatedcheckHealth({ entityIds }) → which variant combinations are missingBoth scales open on read:library. Until September 2026 the argumentless one asked for
read:drift — a scope the key-minting screen never offered, so no key anyone could create was
able to make that call.
Complete a component’s pairing
listEntities({ type: "component", pairing: "open" }) → the components with parts left to pairgetComponentPairing({ id }) → each side's open parts, by key, and the links so farwriteComponentPairing({ …, operations, dryRun: true }) → what the links would be, nothing writtenwriteComponentPairing({ …, operations, expect }) → written, once a person agreedGrails links a part by itself only when the match is certain, so what stays open is what needs judgement: a layer
named Label keyed Label2 against a code element label, a variant value Medium against default. Read the open
parts of both sides, propose the links, run them with dryRun: true and show a person what would change before
writing. Pass the read’s pair.expect when you write, so a side synced in between refuses the write instead of
landing it on another layer. Each component in a listing carries pairing (linked, open in Figma, open in code), so
you can see how far each one is before opening it.
The tools
getComponent
One component by id, with figma_key — the Figma id that persists across files, used to find the
component in the library — and its derived signals: chi_status, has_docs, has_spec.
has_drift is not among them — it is an audit signal, requestable through fields.
name is the component’s own name ("Services"), never the Figma layer path above it, and path
is its Figma folder — null until the component re-syncs, "" at the top level. Every row that is
a component carries that pair: the catalogue, getEntities, findEntities and the build order. A
reference to a component inside another response — a graph end, a via step, origin, a
mention — carries only its name. display_name was retired: name carries its value.
A component deleted in Figma is still readable by id — check sync_status to tell.
getComponentSpec — reading the anatomy
The component as captured at sync time — anatomy, variantConfigurations, default, _grails and
everything else the sync extracted. It is its own tool, not a layer of getEntities, because a
layered call is stateless and would resend the shell on every request: a blind probe was metered
making seventeen consecutive calls for the variants of one component, paying again each time for an
index it already had.
Read the entity first. getEntities({ ids: [componentId] }) returns the shell: every
configuration this component has, each priced under variants, plus variants_how with the exact
call for one, some, or all of them. Come here with that list and ask for exactly what you decided you
need.
getComponentSpec({ componentId }) the default treegetComponentSpec({ componentId, variants: [{ "Size": "Medium" }] }) one configurationgetComponentSpec({ componentId, variants: ["Size=Medium"] }) the Figma spelling works toogetComponentSpec({ componentId, variants: ["all-variants"] }) every tree — no cap, no pagingcomponentId accepts a Grails id or a Figma node id, interchangeably, as everywhere else on this
surface. A name is not an id: it comes back as a 404. variants takes exactly one of four shapes,
and they do not mix:
| you pass | you get |
|---|---|
nothing, or ["default"] | the default tree alone — the cheap answer to “what does this look like” |
| a list of configurations | those trees, and only those |
["all-variants"] | every tree — the read costs this server the same either way, and you already have the price from variants_how.est_tokens_all |
A wildcard cannot be mixed with configurations — ["all-variants", { "Size": "Medium" }] is a
400 GRAILS_INVALID_FILTER, because the two readings serve very different amounts and the server
will not guess which one you meant.
A configuration that does not exist is a 404 naming it, not an empty result:
{ "error": { "code": "GRAILS_NOT_FOUND", "message": "No variant of this component matches [{\"size\":\"huge\",\"type\":\"success\"}]", "details": { "missed": [{ "size": "huge", "type": "success" }], "how": "getEntities({ ids: [...] }) lists every configuration this component has" } }}A typo and a component that genuinely lacks that combination look identical otherwise, and only one
of them is worth another call. Copy configurations straight out of the abstract’s variants; names
and values match case-insensitively.
The response is one document, not a batch: { component_id, requested, spec, spec_schema_version, served }. served echoes what was actually returned — "default", or the list of configurations —
so a caller tracking several calls does not have to re-derive it.
Each entry of spec.props carries canonical_name beside the Figma name that keys it, unless
that name has no letter or digit (↳) or another property of the component shares its canonical
name. The canonical name is the Figma name split at every run of characters that are not letters or
digits, in any script,
with the first letter of the first word lowered and the first letter of each other word raised;
nothing else changes. ↳ Supporting label → supportingLabel, Show Placeholder →
showPlaceholder, ShowCenterContent → showCenterContent, and a letter outside ASCII stays in its word: Tamaño → tamaño. Set the property with the KEY: that is
the string Figma matches on. Use canonical_name to match a property you know by another spelling.
listEntities({ of: "prop-candidates", prop }) accepts either spelling and echoes prop with the
exact one; a canonical name two properties share answers 404 with both in details.ambiguous.
getComponentBuildOrder — what to build first
getComponentBuildOrder({ componentId })Every component it depends on, transitively, ordered so that everything an entry needs appears before it — and the component you asked for last. Iterate the list top to bottom and you never reach something whose parts you have not built.
componentId takes a Grails id or a Figma node id. A name is not an id — it comes back as a
404 for a component that exists. Find one with findEntities({ query }) first. An id that matches
more than one component is a 404 too, with each match in details.matched: ask again with one of
those ids.
One component per call. The build order of ten unrelated components is not a list of anything: you build one card at a time. The order is computed for your request rather than read from a stored column, so a batch would be N sequential computations hiding inside one response.
| Field | What it is |
|---|---|
status | computed (it has dependencies) or trivial (it depends on nothing) |
ordered_components | The list. Never null, never []; the component asked for is last |
grails_sync_status | Per entry. deleted_in_figma means that step cannot be built |
dependency_count | ordered_components.length - 1 |
requested | Echo of the id you sent — component_id carries the resolved Grails uuid, which is not what you sent |
unresolved_deps | Always [] today — see below |
Each entry is { position, component_id, name, path, component_depth, is_leaf, grails_sync_status }
— name the component’s own and path its folder, null until it re-syncs. position is 1-based and agrees with the array order. component_depth may be null — the sync
has not ranked that component — and it is never 0, because real leaves are depth 1. The order does
not depend on it: it is computed from the dependency edges themselves.
A component that does not exist in this project is a 404. An id that resolves to something that is
not a component — a token, a style — is also a 404, with GRAILS_COMPONENT_NOT_FOUND, because the
remedy differs: you named the wrong kind of thing rather than a thing that is not here.
checkHealth
Says whether what this server returns is current with Figma. Everything here comes from a sync, not from Figma live: between the last sync and now, a designer can have changed anything.
The two scales answer different questions and come back in different shapes. With no arguments:
the project-wide picture, under summary — when the last sync ran and how many entities have
drifted since. With entityIds: a coherence check on those entities, under entities and shaped
{ valid, issues, checked } — no sync timestamp and no drift count in it. The two never share a
response.
checkHealth() → summary: { minimal_spec_version, drifting_count, truncated }checkHealth({ entityIds }) → entities: [{ valid, issues, checked }]The project-wide scale reports every component whose spec was captured before the minimum supported schema version — the ones whose data may be incomplete relative to what the platform extracts now.
The scoped check finds things the project-wide one cannot. missing_variant is the one to know: a
variant set that declares a combination and does not have it in Figma. A component can report
twelve of those while the project reports drifting_count: 0 — they are different faults, and
drift does not cover completeness.
Components only, despite the name. A token id comes back valid: false with no_spec, which
answers a question you did not ask; a Figma node id is rejected. Pass Grails component ids.
spec.invalidPropCombinations is ignored here. It is derived from the same fact this check
reads, which configurations have an element tree, so subtracting it would leave nothing to report —
and Figma offers no way
for a designer to mark a combination invalid, so it never carried intent to begin with. A component
with no variant properties is reported as not_a_variant_set and does not make the set invalid,
so a mixed list of atoms and variant sets validates cleanly.
Ask it before building on what you read, not after. And before an expensive decision — generating code, replicating a whole component — ask it for those entities specifically.
findEntities
Find any documentable entity by term or concept, and get its id.
component · token · style · token-collection · screen · flow · project · doc-pageThose eight are exactly the entityType values the documentation tools accept, so anything you
can document, you can find — which is what makes a coverage audit possible. entity_types
defaults to all eight; narrowing is worth it twice over, because each type is a separate query and
coverage costs one count per requested type.
It replaces searchEntities, which was this tool running only its name engine.
Five engines, and the result says which matched
| Engine | Over what | Finds |
|---|---|---|
semantic | the entity’s indexed documentation, by vector similarity | meaning — text input → Input Field |
text | the same documentation, full-text | the words the documentation actually uses |
name | the entity’s name, a literal substring (with a pg_trgm fuzzy floor below it) | the name, and only the name — but over 100% of the catalogue |
graph | edges measured by the Figma sync, expanded outward from the other engines’ hits | entities structurally connected to a match |
declared | relationships a person asserted between entities — not measured | an opinion about connection: weigh it high for relevance, low for impact |
They are fused with Reciprocal Rank Fusion, which combines rankings, not scores: a cosine similarity and a text rank do not live on the same scale, and averaging them would mean choosing a normalisation nothing in the data justifies.
Every match carries matched_by (a list), surfaces (how many engines found it) and signals,
where each raw figure stays tagged with its engine and is never fused into one number.
One of the engines is not a measurement
Every engine but declared measures something: a name matches, a meaning is close, an edge was
extracted from the Figma file. declared is the one where a PERSON asserted the connection —
“these two go together”, “this one replaces that one”.
It arrives as its own entry in matched_by, never folded into graph, and that separation is the
point: the same list would otherwise mean two different things depending on the row behind it, with
nothing in the response to tell them apart.
Weigh it high for relevance and low for impact. A person saying two components belong together is excellent evidence for “what should I look at?” and no evidence at all for “what breaks if I change this?” — an opinion is not a dependency. For that second question use the graph endpoint, which defaults to measured relationships only.
coverage.declared.ran: true with no results is a real answer, and today it is the usual one:
nobody has declared anything yet. It is not the same as the engine failing.
There is no confidence
score is a fused reciprocal-rank sum: comparable within one response, meaningless across
responses, and it supports no threshold. Neither does a cosine — it measures an angle, not a
probability.
What answers should I choose, or ask again? is shape.verdict, per group:
one_stands_out— the top match beat the second on evidence you can re-derive from the same response: more engines found it (surfaces), its name matched in a stronger tier (signals.name.matched:exact>segment>prefix>substring), or its cosine led by at least 0.05.flat_ambiguous— none of that held. The query was ambiguous: ask a narrower question instead of taking the first row.
Grouped by type, and honest about what it cut
Results are grouped by entity type, because your next call depends on the type: a component takes
getComponentSpec({ componentId }), a token takes include-values. A type you asked for that is absent from
groups matched nothing — filters_applied.entity_types echoes what was actually searched.
Each group carries truncated: { shown, total }, and each match carries excerpt — the fragment of
documentation that matched, so you can judge a hit without spending another call. An excerpt of
null means the match came from the name alone.
Each hit also carries context, and what it holds depends on the type — a token’s collection, a
screen’s Figma node id, a doc page’s slug. It is null when nothing in that table tells two
same-named rows apart.
For a doc page that context is not decoration — it is the handle. Pass it straight back as
entityId to getEntityDocumentation or writeDocumentationBlocks, or as page to
getDocumentationPage, no uuid needed. It is how you learn a slug without knowing one in advance;
read the limits under getEntityDocumentation before storing it anywhere.
sync_status is null for screen, flow, project and doc-page. That means the concept
does not apply, not “unknown”: only the four Figma-synced kinds have a deleted-in-Figma state, so
include_deleted does nothing for the other four.
Names, ids and a documentation excerpt. No token values, no bindings. It does not search Figma keys
— for a style key out of a spec, use getEntities({ ids: ["S:…,"] }).
getEntityGraph · findEntities({ relatedTo })
Two tools over the same edges, answering two different questions.
getEntityGraph({ entity_type, entity_id }) the EDGES that touch this node, with their metadatafindEntities({ relatedTo: entity_id }) the ENTITIES reachable from it, with hops and viagetEntityGraph takes provenance, and findEntities({ relatedTo }) does not. A derived
edge is what the sync measured off the Figma file; a declared one is what a person asserted about
two entities. The default is derived, because “what breaks if I change this?” is not answered by
somebody’s opinion. Pass declared or any to see the rest; every edge carries provenance.
⚠️ origin in a relatedTo response is a different thing — it is the node the walk started
from. That is why the graph field is called provenance and not origin: one word for two meanings
in the same section would be learned on one door and misread on the other.
The walk itself follows measured relationships only: it reports structural distance, so an asserted relationship is not a hop.
getEntityGraph is the one with metadata — a token binding carries property, variants and
nodeNames, so the answer is not “this component uses this token” but “in itemSpacing, on the
Mobile variant, in the Card Grid Image node”. When the token sits on one side of a value, property is the path
(padding.bottom, cornerRadius.topStart, effects.shadows.0.color, typography.fontFamily); a token used on two sides reports only the first. One hop, raw. It is also the one that still takes
entity_type and entity_id as a pair, in snake_case.
findEntities({ relatedTo }) deduplicates and walks. Every result in related[] carries hops —
a number, not a “direct / indirect” label — and via, the route: one entry per edge crossed, naming
the edge type, the direction, and (on every step but the last) the node it passed through. That is
the difference between “this component binds the token” and “a style this component uses binds
it”, which a label cannot express. A result whose type lives in a folder also carries path; the
via steps name the node they passed through and nothing more. The response also echoes what it
resolved your id to, under origin and resolved.
relatedTo takes no type. The id determines it, and a Figma node id works where a Grails id
does. Both tools default direction to both: with one node and no direction stated, returning one
side would hide half the graph. Ask inbound for the impact question — what uses THIS.
getBlastRadius
“If I change this, what do I affect?” (since v1.317.0). Everything that uses a component, token, style or pattern, directly or not: the aliases of a token, the styles and components that bind it, the components that use those styles or components, in Figma and in code, and the patterns that use those components or contain those patterns.
getBlastRadius({ entity_type: "token", entity_id }) direct uses only (max_depth 0, the default)getBlastRadius({ entity_type: "token", entity_id, max_depth: 16 }) the whole radiusmax_depth is 0 by default: direct uses only. Ask 16 for everything. The walk is bounded by entities × levels, so a
deep one stays cheap.
Each row of affected[] is one entity, by its canonical id: a component that lives in Figma and in code is one row,
with sides: ["design", "code"], and a variant counts for its set. A deleted entity is not an answer. Each row says
depth (0 = a direct use), via (direct, or what it uses on its way: alias, style, component, pattern) and
through, the entity it uses one level closer (null at depth 0), so the map can be drawn as a tree. A pattern has
sides: [] and its status (draft or published): drafts are answered too, because a change breaks them as well.
counts covers the whole answer, every page, by kind, with levels. next_cursor is at the root and walks the
rest. types keeps only some kinds in the answer; the walk still goes through every kind.
It is not findEntities({ relatedTo }). That one is the neighbourhood: every relation, both ways, at most 3 hops.
This one follows only the relations of use, inbound, up to 16 levels. A 504 GRAILS_TIMEOUT means the walk did not
finish within the time budget: ask a lower max_depth.
describeTokens · listEntities({ type: "token", includeValues: true })
Three layers, because serving the whole token tree costs on the order of 58,000 model tokens and the job in front of you usually needs five values.
describeTokens() the map — collections, modes, counts, the pricelistEntities({ type: "token", collection, the index — ids and names, no values nodeType, namePrefix })listEntities({ type: "token", includeValues: true }) the values — per mode, alias chain in `via`describeTokens is not going anywhere: it takes no ids, so getEntities cannot absorb it. It
answers “what is there?”, which is a different question from “what is this?”.
The token index moved into listEntities, where nodeType is the Figma type — COLOR, FLOAT,
STRING, BOOLEAN — because type now selects the entity family.
Values come with includeValues: true, and they are scoped to the page — the rows limit
returned, not everything the filter matches. A values payload that ignored limit would be an
unbounded response wearing a paginated shape, and a project-wide dump of every value is the most
expensive thing this API can produce. Ask without the flag and more tells you what adding it
costs.
A value is null only when unresolved says why, and the reasons are actionable —
ambiguous_target_mode carries the value under each candidate mode, so the answer is a set rather
than a failure. Colours come back twice: value is hex rounded to 8 bits per channel, rgba is
exactly what Figma stored.
listEntities({ type: "token" }) with no further filter returns the first page and the
describeTokens map, under unfiltered, rather than the whole index. Page order is by internal id
— stable, not alphabetical.
describeTokenExport
The fourth layer, and the only one that is not for reading tokens one at a time: “what would a DTCG export of this design system contain, and what would be missing from it?”
describeTokenExport({ includeHidden }) the report, the stamp, and the route to the documentsGET /v1/tokens:export the documents themselves, over RESTThe tool returns no tokens, deliberately. A full bundle is ~139 KB of JSON for the largest design system we host — served into your context that is a dump, not an answer. What comes back is:
report.findings | One entry per thing the export cannot carry, each with a consequence: lost (not in the export at all), approximated (present, but not exactly as Figma holds it), info. An empty list means nothing was left out |
report.notes | The same findings as plain sentences |
etag | The stamp of this export |
download | The method and path to fetch the documents |
Download with the same API key: GET /v1/tokens:export, adding ?include_hidden=true if you
passed includeHidden. It returns { files, resolver, report } — files maps a path to a DTCG
document, resolver is the DTCG resolver document describing how the modes combine.
Never in the export: anything deleted in Figma, with no option. Out by default: variables
hidden from publishing — includeHidden turns them on. While they are out, the omission is declared as a
lost finding, so leaving it off never costs you silently; turn it on and nothing is omitted, so no
such finding appears.
Some tokens have no $type, on purpose. DTCG has no type for a boolean, or for text that is not a
font family. A Figma BOOLEAN variable and a STRING variable that is not a font family arrive with no
$type, a string $value and the real value in $extensions["design.grails.value"]: { "type": "boolean" | "string", "value": … }. No finding says so, because nothing is missing from them. A colour
whose opacity comes from a token keeps the applied colour in $value and adds
$extensions["design.grails.composed"]: { "color": …, "opacity": "{path}" }, where the opacity points
at the token as it is (for Figma, 0 to 100, not 0 to 1). That token is a number with no unit, never px,
unless a scope on the variable in Figma says otherwise.
If it refuses with GRAILS_AUTH_PLAN_REQUIRED (403), this team’s plan does not include exporting
tokens through the API. That is not about your key or your scopes, and retrying will not help; the
team owner changes it. The export inside the Grails app is unaffected.
getCodeTokenMatch · writeTokenMatchRules — your code’s tokens against Figma’s
For a codebase synced with Grails Code Sync. getCodeTokenMatch({ source }) answers which Figma token each code
token is, and whether their values agree: every pair is same, differs, unaligned (no mode in common) or
conflict (two code tokens that disagree about one Figma token; you decide, it never does). Without source it
lists the project’s code sources. When a side has no snapshot yet, status says so instead of calling everything
missing.
Colours are equal under thresholds.color_delta_e, a ΔE2000 that is the project’s setting, else 1.5;
thresholds.color_delta_e_from says which.
A naming rule renames a code word to the word Figma uses (gray → neutral), for one code source. The
answer lists the source’s rules and suggestions read from tokens with the same value and one different
word. Suggestions are never applied; accepting one is writeTokenMatchRules({ mode: "set", source, from, to }),
which is idempotent. mode: "delete" deletes a rule; mode: "threshold" sets the project’s colour threshold.
Editing needs write:tokens; reading, read:library. Rules live in Grails, never in your repository.
describeStyles · listEntities({ type: "style", includeValues: true })
The same three layers for styles, on the same read:library scope. Styles are the other half of
one question — what is the thing this component binds actually worth?
describeStyles() the four types, their counts, the price of each layerlistEntities({ type: "style", nodeType, the index — and `figma_style_id`, the raw spec key namePrefix })getEntities({ ids: ["S:…,"] }) the propertiesdescribeStyles, like describeTokens, takes no ids and is not absorbed by anything. Here
nodeType is TEXT, PAINT, EFFECT or GRID.
Style properties come from listEntities({ type: "style", includeValues: true }), page by page, or
from getEntities({ ids, include: ["include-values"] }) when you already have the ids.
properties is passed through exactly as Figma stored it, and its keys depend on type:
TEXT carries fontName, fontSize, lineHeight, letterSpacing, textCase, textDecoration,
paragraphIndent and paragraphSpacing; PAINT carries paints[]; EFFECT carries effects[];
GRID carries layoutGrids[]. Read the keys rather than assuming them — the set varies with what
the design file contained.
bound_variables lists the properties driven by a token rather than by the literal beside them.
Where one is present, the literal is what that token was worth at the last sync; follow
token_id into getEntities({ ids, include: ["include-values"] }) for the live value. A token_id of null means no variable in
this project carries that key — or, when the binding also carries candidate_token_ids, that several
do and none is in the style’s own Figma file, so none was picked.
Two kinds of documentation, and the names now say which
Everything below writes into the same block format, but there are two different subjects, and picking the wrong one is the mistake this vocabulary exists to prevent.
| Documentation OF an entity | A documentation page | |
|---|---|---|
| What it is | What a team wrote about a component, a token or a style — when to use it, when not to, accessibility decisions | A standalone document, with a title, a URL slug and a folder |
| Where it lives | In Library and Foundations, attached to the thing it describes | In the docs section, as a document of its own |
| Who organises it | Nobody. It hangs off its entity and goes wherever the entity goes | The team. Pages are created, renamed, moved between folders and deleted |
| Read it with | getEntityDocumentation({ entityType, entityId }) | listDocumentationPages · getDocumentationPage |
| Write it with | writeDocumentationBlocks({ entityType, entityId, … }) | createDocumentationPage first, then the same writeDocumentationBlocks with entityType: "doc-page" |
A page’s content is blocks, exactly like an entity’s, and one writer writes both. What a page has and an entity does not is an identity of its own — a title, a URL, a place in a folder — and that is the whole of the difference.
describeDocumentationBlockTypes
describeDocumentationBlockTypes() → the guide, and every writable shapedescribeDocumentationBlockTypes({ section: "criteria" }) → how to decide WHICH shape content belongs indescribeDocumentationBlockTypes({ section: "shapes" }) → the exact JSON of each shape, field by fieldOne thing where there used to be two: how documentation is organised in this system, the order of the steps, which skill covers each part, and every block type that exists with what it means. Start here — it is a callable tool and not only a document because an MCP client always enumerates tools but may never enumerate skills. Read it before writing for the first time: blocks are typed, not markdown, and guessing the shape wastes a write.
Each type under types carries:
| Field | Why you need it |
|---|---|
description | What the shape does. Present on every type |
question | The selection criterion — pick the shape by the question the reader came to ask. Optional: sections has none, and the absence is the signal. Any question that describes the catch-all matches everything, which is precisely how an agent ends up putting every block in it |
whenNot | The half that is usually missing — and where it says to go instead |
example | A complete block you can pass to writeDocumentationBlocks({ dryRun: true }) unchanged. It is checked against the same validator that runs on write |
allowedOwners | null means no restriction. It is not an empty list, which would mean the opposite |
It also returns categories — the other axis.
The authoring guide, fetched by name
The answer closes with more.authoring_guide, which announces two sections and what each costs. They
are not attached by default: together they are around 4,850 model tokens, and an agent that
already knows which shape it wants should not pay for them.
section | Answers | Ask for it when |
|---|---|---|
criteria | Which shape a piece of content belongs in, and why — the two axes, the boundaries between shapes that look alike, the mapping heuristics, the antipatterns | You are mapping content you did not write |
shapes | The exact JSON of every shape, field by field, with what the validator rejects — plus the alt discipline, the data/text split and how references work | You are about to write the JSON |
The split is by moment, not by file: choosing a shape and writing one are different questions asked at different times.
getEntityDocumentation
getEntityDocumentation({ entityType, entityId }) → the documentation on one entitygetEntityDocumentation({ entityType: "doc-page", entityId: "brand-guidelines" }) → a doc page's blocks BY ITS SLUGBoth arguments are required now, and one without the other is a 422 whose message names
describeDocumentationBlockTypes. Omitting them used to return the guide, which is how one name came
to answer two questions — and it is exactly what the rename undid.
The documentation a team wrote about an entity: when to use it, when not to, accessibility
decisions, content rules. It is human intent, not data extracted from Figma — which is why it
lives in its own tool and not as a layer of getEntities.
It returns that entity’s blocks, ordered, in the same shape you write. Call it before writing.
A doc page can be named by its slug, and it is the only thing here that can. Every other entity is addressed by a uuid or a Figma id; a component name moves whenever a designer renames it. The slug does not: it is not a field of the edit action, so renaming the page title leaves it alone.
⚠️ That is not the same as “it can never change”, and the difference matters if you are storing one. Changing a page URL breaks every link anyone already shared, so it is deliberately a separate operation with its own confirmation — one that does not exist yet, and is expected to.
So the contract is not immutability. It is this: a slug never silently becomes someone else.
- A rename of the page does not move it. Only an explicit URL change would, and that is a decision a person takes on purpose.
- A slug that does not resolve is a
404naming it, never a silent empty result and never a different page. That is what makes the day it changes legible instead of wrong. - Unique per project, not globally. Two projects may hold the same slug. The uuid stays the canonical id, and is what to store if you need one that cannot move at all.
⚠️ A deleted page frees its slug. Nothing reserves it, so a slug you stored can later resolve to
a different page with no error at all — which is why deleteDocumentationPage refuses one.
Documentation is sparse by nature and that is normal: no team documents everything. An entity without documentation is not an anomaly and not something to report.
Next to blocks comes a second branch — mentions, the entities this documentation references,
already resolved:
{ "blocks": [ … ], "mentions": [ { "entity_type": "component", "entity_id": "f2809b69-…", "count": 2, "name": "Section Button Group S", "exists": true }, { "entity_type": "token", "entity_id": "0f8b1e2a-…", "count": 1, "name": null, "exists": false } ]}| Field | |
|---|---|
entity_type · entity_id | What the reference points at. The same pair every other tool takes |
count | How many times it is mentioned across all the blocks of this entity, not one |
name | Resolved, so a reference does not cost you a lookup each. null when exists is false. No path: a mention is a reference, not a row |
exists | Whether it still points at something in this design system |
It is a branch beside the blocks and not a field inside them, so reading the text alone costs you
nothing extra. Each block still carries its own raw mentions — that is the only thing that tells
you which block references what, which the merged branch cannot say.
The branch is always there, [] included. A branch that shows up only when it has data is
indistinguishable from one that does not exist — which is exactly how this capability went unused:
the raw field travelled in every response for months and no tool named it.
writeDocumentationBlocks
writeDocumentationBlocks({ mode: "append", entityType, entityId, blocks }) adds at the endwriteDocumentationBlocks({ mode: "replace", entityType, entityId, blocks }) replaces, and loses what was therewriteDocumentationBlocks({ mode: "delete", entityType, entityId, blockIds }) removes blocks by idwriteDocumentationBlocks({ …, dryRun: true }) validates without writingThis is the only write of documentation CONTENT on this entire server — nothing else here modifies the design system, which stays read-only and is fed by the Figma sync. Creating, moving and deleting a page are their own operations: they touch the page row, not its blocks.
It is also how a page gets its content: pass entityType: "doc-page" and the page’s id or slug.
mode is required, and it is the one thing to get right:
mode | What it does |
|---|---|
append | Adds at the end. The safe one. Deletes nothing |
replace | ⚠️ Destructive. Deletes every existing block on the entity and writes the ones you pass. No merge, no confirmation, no undo |
delete | Hard delete by id — there is no trash for this. Requires blockIds |
When in doubt, append.
Give each block a category. It is what the block is ABOUT — usage, accessibility,
dev_specs, content_guidelines, brand, general — and it is how a reader finds the part they
need instead of reading everything. describeDocumentationBlockTypes() lists them under
categories.
Documentation pages
listDocumentationPages() → the pages, and the folders they reveallistDocumentationPages({ path: "Foundations" }) → only the pages in that exact foldergetDocumentationPage({ page }) → one page, whole: its identity AND its blockscreateDocumentationPage({ title, path?, slug? }) → a new, EMPTY pageupdateDocumentationPage({ page, title?, path?, → rename, move, edit the abstract or icon abstract?, icon? }) (`page` is the uuid, not the slug)deleteDocumentationPage({ page }) → ⚠️ hard delete: the page, its blocks, its filesrenameDocumentationPath({ from, to }) → rename a folder across every page in itclearDocumentationPath({ path }) → unfile every page in a folderA page is created empty. createDocumentationPage writes the row and nothing else; the content
goes in afterwards with writeDocumentationBlocks({ entityType: "doc-page", entityId: <the id> }).
Until these existed, only blocks could be written — into pages somebody had made by hand in the web
app — so an agent with something new to document had nowhere to put it.
listDocumentationPages paginates: limit is 1–100 and defaults to 20 (above the cap it truncates
rather than failing), and cursor is the next_cursor of the previous response. path filters on
an exact folder, not a prefix: a folder is a whole string, and there is no nesting for a prefix
to mean anything against. getDocumentationPage takes the uuid or the slug.
A folder is not an entity
It is the value of a page’s path, and it exists exactly as long as some page carries it. There
is no table, no parent_id and no hierarchy — and three things follow that nothing else would tell
you:
- There is no “create a folder”, because it is not an operation. A folder happens as a side
effect of filing the first page into it: pass a
pathno page carries yet and it exists from then on. An empty folder cannot be represented at all. - There is no folder catalogue to query.
listDocumentationPagesreturns apathsbranch derived from the rows it returned, and it is the only surface that aggregates the folders of a project — the only one at all for a key carrying justread:docs. Call it before moving a page anywhere. (listEntities({ type: "doc-page" })andfindEntitiesdo carry each page’spathon its row, but both takeread:libraryand neither adds them up for you.) updated: 0from a rename is a valid answer, not an error. A folder that “does not exist” is simply a string no page carries, and answering404would invent an entity this model does not have.
null is a member of paths rather than something dropped from it: “some pages here are unfiled”
is a fact you act on, and it is the state path: null and clearDocumentationPath leave a page in.
findPatterns · getPattern · writePattern — patterns
A pattern describes how components are composed for one use (an empty state, a PIN step, an account home) and
the rules for doing it. findPatterns lists the project’s patterns, the most recently changed first, without their
spec; with no filter it lists every one. Each comes with its kind (section: one section; screen: several
sections; flow: several screens), its steps (screens), its shape (sections per screen) and who changed it last.
To narrow it: kind, status, query (a piece of the name), component (the patterns that use it, by row id or Figma node id or key; one that matches several
components answers 404 GRAILS_COMPONENT_NOT_FOUND with details.matched),
domain (an exact, case-sensitive match), platform, components (several, with usesMode: any, the default, or
all) and createdBy / updatedBy (people’s ids, as updated_by.id answers them). order lists by name_asc or
created_desc instead of the most recently changed; a cursor keeps its order. include adds more to each item, each at its own cost: intent (the problem it solves, domain,
platforms), created (who and when), review (whether it still fits your design system) and uses (the components
and patterns it names, the heaviest).
getPattern reads one whole, with review: ok when everything it names is still in your design system as the
spec describes it, or needs-review with each issue and its path into the spec. Both need read:patterns.
writePattern
Creates a pattern (no id) or replaces one (id): its name, status and whole spec. What a replace leaves out
is removed. Needs write:patterns. A new pattern is a draft unless you send status: "published".
The spec, specVersion: 2, has three parts:
intent: the problem it solves, when to use it and when not (optionally naming another pattern to use instead), its domains and its platforms.screens: each screen lists itssectionsfrom top to bottom. A section says what it is for, how many items it holds (cardinality) and what it accepts: components, optionally narrowed to some values of their props, or other patterns, never both in one section. It can also carry guidance for its text, andparts: sections nested inside it, with the same shape. One section makes a section pattern, several make a screen, and several screens make a flow, in order.rules: statements with a strength (must,shouldormay) and a category, about the whole pattern, one screen or one section.
Screen, section and part ids are lowercase with dashes, unique in the pattern, and rules point at them. A
canonId is the id findEntities answers for a component, or a pattern’s id. At most one component per section
can be the default: the one to use when nothing else decides.
{ "specVersion": 2, "intent": { "problem": "A list with nothing in it yet has to say so and offer the first step.", "useWhen": ["A list the person has not filled yet"], "avoidWhen": [{ "situation": "A filter hides every item" }], "domain": ["banking"], "platforms": ["web", "ios", "android"] }, "screens": [{ "id": "empty-list", "name": "Empty list", "sections": [ { "id": "message", "name": "Message", "purpose": "Say what is missing", "region": "body", "cardinality": { "min": 1, "max": 1 }, "accepts": [{ "kind": "component", "canonId": "00000000-0000-4000-8000-000000000001" }], "content": { "guidance": "One sentence that says what will appear here.", "maxLength": 80 } }, { "id": "action", "name": "Action", "purpose": "Start the first one", "region": "footer", "cardinality": { "min": 1, "max": 1 }, "accepts": [{ "kind": "component", "canonId": "00000000-0000-4000-8000-000000000002", "constraints": [{ "prop": "variant", "values": ["primary"] }], "default": true }] } ] }], "rules": [{ "id": "one-action", "statement": "Offer exactly one action.", "strength": "must", "scope": { "kind": "section", "sectionId": "action" }, "category": "composition" }]}Every problem comes back at once as 422 GRAILS_VALIDATION_FAILED, each with its path
(screens[0].sections[1].accepts[0].canonId), so fix them all and send again. A spec with specVersion: 1 is a
422 too. A value that a constraint names and the component does not have is not refused: review reports it.
Saving a pattern records which components and patterns it uses, and getEntityGraph shows those relationships.
To replace a pattern without undoing someone else’s change, send expectedUpdatedAt: the updated_at you read
from getPattern or from your previous write. If the pattern was saved since, nothing is written and the answer is
409 GRAILS_PATTERN_CHANGED, with details.current_updated_at; read it again and redo your change on what is there.
Every write answers its new updated_at. Without expectedUpdatedAt, a replace overwrites whatever is there.
getComponentPairing · writeComponentPairing — which Figma part is which code part
When a component lives both in Figma and in your code, Grails keeps which element, prop and prop value of one is which
of the other. getComponentPairing({ id }) reads those links; id is the Figma component, the code component or the
entity. Grails links a part by itself when the match is certain (the same name, and nothing else on either side it
could be). What it could not match stays unlinked until a person or an agent links it: Grails does not try again.
A component in several Figma files has one pair per file. The answer always lists pairs; with more than one it reads
none of them (choose_pair: true) until you ask again with pair: { design, code } from that list.
Each link names its two parts by their key (design, code), with the name each side shows beside it
(design_label, code_label). A layer named “Label” can be keyed Label2, and writing takes the key. method says
whether Grails made the link (auto) or a person or an agent did (manual). Links belong to the component, not to a
variant. reconcile_failed: true means the links may be behind the specs until the next sync succeeds.
pair.state is what the Both view shows, computed the same way. For each side it lists the props and elements,
each linked, ok or open, by the key a write takes; an enum prop lists its values. summary counts the links
that match, drift (partial) or do not match at all, and what each side has with no counterpart. links says, by link
id, how far each one agrees (wire): for a prop, how many of its values are settled on each side; for an element, each
property both sides set (its size, padding, gap, radius, colours, font size) with its value and tokens on each side,
and how many differ. A side that sets a property only through a token whose value Grails could not read (usually one
it has not synced) has value: null, the token’s name in tokens and unresolved: true (since v1.378.0; a side with
a value does not carry it): the property is set, its value is unknown, so same is null and its wire is
partial, or token when both tokens are one canon (since v1.376.3; before, the property was left out). A variable
Grails has not synced whose spec carries the value Figma resolved it to (a component synced since v1.377.0) has that
value, no unresolved and in_figma: true, and is compared by it (since v1.378.1): it is the value in Figma’s mode
for the layer, paint opacity included, so it does not follow modes. A Color Style is read that way too, synced or not (since v1.389.4): its row carries no colour. An element link whose shared properties are all of this kind is
uncompared; one with others compared is partial whatever those say, because what was not compared may agree, and
it is counted as drifting. Two tokens that Grails matched as one are a token wire even when their values differ, and
token_drift says they do. An element link with nothing to compare is uncompared, never match: no property is set
on both sides, or the Figma element is not shown in the variant compared. summary.uncompared counts these links. A
link with no wire at all (null) has a code element that is not part of the code configuration compared, or a
part that is no longer in its spec.
Elements are compared in one Figma variant, one code variant and one mode per token collection. compared_in says
which, and can_ask lists what you can ask for instead: pass configuration (one value per Figma axis),
codeConfiguration, modes (a collection left out shows its default) or booleans. A name the pair does not have is
refused with the valid ones (422, missing). state is null while a side has no spec yet.
writeComponentPairing({ design, code, operations }) links and unlinks parts of one pair, in order and all or
nothing. An operation is { op: "link", part, design, code } — part is element, prop or prop_value; a value
names its two props in design/code and its values in designValue/codeValue, and needs its props linked — or
{ op: "unlink", decisionId }. A part has one link per side: to link it elsewhere, unlink it first, in the same call.
Linking a prop also links its values whose names match (brought); unlinking it removes them (removed). Pass the
read’s pair.expect as expect: if a side was synced in between, nothing is written (409). dryRun: true checks
and answers without writing. A failing operation names itself in details.field (operations[2]), and nothing of the
call is written. Links are written in the name of the key’s owner. Unlinking a part never unmatches the two
components. Writing needs write:pairing; reading, read:library.
Every component also says how far its pairing is without this tool. getComponent, getEntities and
listEntities answer pairing, one entry per pair: { design, code, linked, open: { design, code } }. open is
null when a side has no spec yet, so there is nothing to compare; [] means the component has no pair, and null
that it could not be read. getComponentSpec answers pairing_map, the links themselves: the props with their values
and the elements, by key. To find components by their pairing, listEntities takes livesIn (design, code or
both) and pairing (open or complete), exactly; findEntities takes the same two on a search, applied to the
ranked results, so a page can hold fewer than limit; each match keeps its rank, and truncated.total then counts the
candidates before the filter (counted_before_filter: true).
Request limits
Three limits apply to every MCP call, before any tool runs:
- Body: at most 4.5 MB, the hosting platform’s limit for a request. The largest image
uploadDocumentationImagetakes (3 MiB) fits as base64, also wrapped in lines or with its/escaped as\/, but not both at once. A larger body is a413; for bigger files use the presigned upload. - Elements: the arguments hold at most 50,000 array items and object keys, counted at any depth.
- Lists: a list argument (
ids,types,fields,blockIds, …) takes at most 1,000 items, and a variant configuration ingetComponentSpecat most 100 props. Each tool’s own, lower limit still applies and answers with its own error;blocksis the one list without this limit.
A call over the elements or the lists limit is answered with an invalid-arguments error that names the limit. A
configuration over 100 props is the exception: it answers Invalid input at variants[N], naming the item, not the limit.
Uploading an image or a file
An image or file block points at stored bytes. Nothing is visible until a block references
them: an uploaded object nothing points at is an orphan the collector eventually clears.
uploadDocumentationImage({ entityType, entityId, → ONE call. The image as base64, or a contentType, data }) `sourceUrl` on the allow-listprepareDocumentationUpload({ kind, entityType, → a URL for YOU to PUT the bytes to entityId, contentType, sizeBytes })completeDocumentationUpload({ r2Key }) → measures what landed, settles the reservationcompleteDocumentationUpload answers with the measured size, which may differ from what you
declared: a presigned URL signs the key and the content type but not the size.
Then write the block
writeDocumentationBlocks({ entityType, entityId, mode: "append", blocks: [ { type: "image", category: "usage", content: { r2_key: "<what the upload returned>", layout: "below", i18n: { es: { alt: "…" } } } }]})alt is required. describeDocumentationBlockTypes() returns the authoring guide, which says what makes a good one.
A file’s text is extracted afterwards, and that is not your problem
The upload is complete whether or not the extraction works. To see how it went, read the block:
extraction.status is done, no-text (a scanned document — it FINISHED and there was no text),
unsupported or failed, and chars says how much came out. There is no separate tool to ask.
The extracted text itself never comes back to you: it feeds the search index, and serving it would add up to 30,000 characters to every documentation read.
The documentation contract
What a block looks like
{ "type": "guidelines", "category": "accessibility", "content": { "…": "the shape of `type`, from describeDocumentationBlockTypes()" }}Three fields, and only three. type is the shape and category is the subject — they are
orthogonal: a guidelines block can be accessibility or content_guidelines, and a specs can
be either too.
categories comes from describeDocumentationBlockTypes(). A category outside that set is rejected with 400 naming
the value — it used to fail the whole insert with an error that named nothing.
What you must NOT send
mentions and version are not parameters. The server computes both.
mentions is derived data with a single writer: it is recalculated from the content on every
write. A client that supplied it would be asserting a relationship its own text does not make — and
a mention can be a warning ("do not use X here"), where an edge would claim the opposite of
what was written.
position is not a parameter either. The order of the array is the order of the blocks.
Referencing another entity
Inside a rich text field, a reference is a marker:
Use {{component:9f3a…}} for navigation instead.The uuid comes from findEntities. Never invent one. The marker carries type and id, never
the name — the label you see is the entity’s current name, resolved at render time, so a rename
in Figma updates the documentation on its own. If the entity is gone it renders as a broken
mention, which is a useful signal rather than an error.
All or nothing, per request
Every write validates completely before touching anything. If one block in the array is invalid, nothing is written and the error names the block index and the field.
writeDocumentationBlocks({ mode: "replace" }) runs its delete and its insert in one transaction,
so a failure mid-write leaves
the entity exactly as it was — never empty.
Errors you can act on
| Status | Means |
|---|---|
400 GRAILS_VALIDATION_FAILED | Your payload. The message names the field, and details.errors carries the block index |
403 GRAILS_AUTH_INSUFFICIENT_SCOPE | The key lacks read:docs or write:docs. The message names which |
500 GRAILS_INTERNAL_ERROR | Ours. Retrying the same payload will not help |
What is not here yet
Variables and artifacts are designed but not built. If you need one, say so — the order they land in is driven by what people actually ask for.
Tokens and styles are built, in three layers each — see the sections above.
Documentation is built, with one gap worth knowing before you rely on it:
writeDocumentationBlocks({ mode: "append" }) has no idempotency key, so a retry after a timeout
duplicates. Check with getEntityDocumentation({ entityType, entityId }) first.
Documentation pages are built too — listed, read, created, renamed, moved, deleted, and their
folders renamed or emptied. See Documentation pages above.
The entity graph is built and documented — see getEntityGraph · findEntities({ relatedTo })
above.
Token scopes — what a token may be applied to
A token’s scopes is what Figma lets a designer apply it to, and it has three states. The empty one
is the one to understand, because it reads like missing data and is not.
| in the database | means | how it comes back |
|---|---|---|
["ALL_SCOPES"] | applicable anywhere its type allows | "ALL" |
[] | hidden from Figma’s property pickers — deliberate | "NONE" |
["CORNER_RADIUS", "GAP"] | only there | the list |
Ask for the catalogue before you filter. listEntities({ of: "token-scopes" }) returns the valid
scopes per resolved type with how many tokens of this project carry each. The valid set is Figma’s
and is not derivable from the data: a scope this project happens not to use is still a legal filter,
and a catalogue built from the rows would teach you it does not exist.
listEntities({ of: "token-scopes" }) the vocabulary + countslistEntities({ type: "token", scope: "CORNER_RADIUS" }) which tokens fit a radiuslistEntities({ type: "token", scope: "NONE" }) the ones hidden from pickerslistEntities({ type: "token", includeScopes: true }) attach the state to each rowincludeScopes is off by default because it is per-token, not per-collection: it grows with the
page. An unknown scope is refused with a 400, not answered with an empty page — the two look
identical to you and only one is worth another call.