figma-edit-mcp (v2.3.1)What this is. A safety manual for the project: it states the safety guarantees the system makes, the assumptions under which those guarantees hold, the residual risks it does not cover, and the controls (cross-cutting invariants + a per-tool gate matrix) that implement them. The framing is borrowed — informally — from functional-safety safety manuals (IEC 61508 / ISO 26262), whose job is to state a component’s guarantees and the conditions of safe use. It is adapted to an MCP server and is not a certification artifact.
What this is not. It is not a
SECURITY.mdvulnerability-disclosure policy — there is no reporting process, supported-versions table, or security contact here. This documents what the software enforces, not how to report a flaw.Audience. Contributors changing the enforcement code; host / integrator authors wiring the plugin into an agent; and auditors or agents reasoning about which edits are possible.
Applies to: v2.3.1 (the Bind-Variable Guardrails release). It describes the enforcement state as of that release — the v2.1.0 scope-lock / name-verification / batch-atomicity model plus the structural-integrity guards (§1–§16) and the three-axis permission model (§14). Bare
§Nreferences point into the v2.2.0 PRD, where those structural guards were specified; sections taggedv2.3.0 §N/v2.3.1 §Npoint into their own release PRDs.Ground truth. The enforcement lives in three places, in this order of authority:
- The plugin dispatcher
figma_plugin/src/main.ts— the per-command gate stack; the only layer an agent cannot bypass.- The handlers under
figma_plugin/handlers/— type/range/structural checks performed during execution.- The MCP input schemas under
src/mcp_server/tools/— Zod shape/enum/range validation, before the WebSocket round-trip.If this document and the code disagree, the code is correct and this document is stale — fix the doc.
The system exists to prevent an LLM agent from making unsafe or unintended edits to a live, user-owned Figma document. The agent is untrusted with respect to edit safety: it may hold a stale node ID, hallucinate a name, or misjudge whether an edit is wanted.
The Figma plugin is the trust boundary. It holds the user-granted scope and permissions and refuses anything outside them — returning a structured "Operation Denied: …" error rather than relying on the agent’s judgement. The MCP server and the agent’s host are not trust boundaries (see Assumption AS1). Because the plugin cannot trust the agent’s judgement about whether an edit is safe, it enforces mechanical, checkable properties (where, which node, what type, what protection) and leaves intent to the user (see Residual Risk R2).
The system guarantees, subject to the Assumptions below, that:
| # | Guarantee | Implemented by |
|---|---|---|
| G1 | Bounded write surface — no node write outside the user-selected scope subtree; no node writes at all without a scope link. | A2 |
| G2 | Right-node assurance — no write proceeds unless the caller-supplied name matches the resolved node’s actual name (every write tool, including reaction_update and variable_delete). |
A3 |
| G3 | Explicit placement — no node is created without an explicit, resolved parent. | A4 |
| G4 | All-or-nothing batches — a batch containing any invalid member mutates nothing. | A5 |
| G5 | Reads cannot mutate — discovery/navigation never change the document and are never blocked. | A6 |
| G6 | Self-preservation — the session’s scope anchor cannot be destroyed or replaced by an edit (node_delete/node_flatten/node_ungroup/create_component). |
A9 · §1 |
| G7 | Respect explicit protection — locked nodes, remote (shared-library) assets, and the interiors of component instances are not edited around. | A9 · §2/§4/§7 |
| G8 | Least authority for assets — document-global variable and style edits each require an explicit, separate opt-in, independent of node-edit permission. | A2 · §14 |
Each guarantee holds only while these conditions hold. Violating one voids the corresponding guarantee — this is the heart of the safety-manual contract.
node_info/page_info and not normalizing, translating, or “cleaning up” them. The plugin can verify a name but cannot reconstruct intent from a wrong one.Explicitly not guaranteed — accepted trade-offs a safe integration must account for:
try/catch reports whatever Figma then throws. (A8.)node_export_visual can render a node outside scope (read-surface only; low risk, since node_info already exposes full reads). (A6 / PRD D5.)INSTANCE_SWAP values are shape-checked only (§5/D10), and instance-interior override writes are permitted with Figma as the final arbiter (§4/D7). A structurally-valid call that Figma still refuses degrades to a normal handler error rather than a pre-emptive "Operation Denied".Two limitations present before v2.2.0 are now closed and have moved into the guarantees: document-global asset reach from a node-scoped session is gated by the new permission axes (G8 / §14), and remote-library-asset edits are blocked by a structured pre-check (G7 / §7).
To keep the guarantees valid, the human and host must:
node_info/page_info and passes them back unchanged (AS3)."Operation Denied: …" as a stop signal, not a retry prompt — adjust the target/scope/permission or reconnect; never attempt a workaround.These cross-cutting invariants are the mechanisms behind the G-claims. Part B says which apply to each tool.
All access control runs inside the Figma plugin at execution time and returns a structured "Operation Denied: …" error. The MCP server’s Zod schemas validate shape (types, enums, numeric ranges) but are not a security boundary — they exist for ergonomics and fail-fast input errors (AS6). Per PRD D3, the v2.2.0 guards are plugin-only, not mirrored MCP-side.
Connection state carries four fields, set at connect time and locked for the session (changing any requires disconnect + reconnect — AS5):
scopeRootId // enforcement anchor for node edits; null ⇒ no node edits
allowEditNode // false | "page" | "node" — truthy ⇔ scopeRootId set
allowEditVariable // boolean — document-global, independent of scope
allowEditStyle // boolean — document-global, independent of scope
The three permission axes are independent — none implies another:
| Axis | Granted by | Gate | Failure |
|---|---|---|---|
| Node edits | a Page/Layer scope link | allowEditNode set and target within scopeRootId |
READ_ONLY_MODE (node-only) / OUTSIDE_SCOPE |
| Variable edits | “Allow … Variables” checkbox | allowEditVariable |
VARIABLE_EDITS_DISABLED |
| Style edits | “Allow … Styles” checkbox | allowEditStyle |
STYLE_EDITS_DISABLED |
checkScopeAccess/checkScopeAccessRef). Creation checks the parent; reparent checks both parent and child. Scope cannot be widened programmatically.node_bind_variable / node_apply_style are node edits (gated by allowEditNode + scope + name), not asset edits — they reference an asset but mutate a node. Binding a variable into a style (style_manage) needs only allowEditStyle.get_connect_payload surfaces { allowEditNode, allowEditVariable, allowEditStyle } so the agent knows its capabilities up front. (Full 8-combination matrix: PRD §14.)Every modify tool requires a nodeName; every create tool a parentNodeName; batch tools a name per item. The plugin resolves the node by ID and rejects on mismatch (verifyNodeName; absent name ⇒ rejected). This catches stale/fabricated IDs. Names must be passed back verbatim (AS3). As of v2.2.0 this is universal — reaction_update (§6A) and variable_delete (§6B, both id and collection modes) now verify names, and style_delete’s guard matches the strict rule.
No “current page” fallback. create_shape/create_frame/create_text/create_svg/create_instance require a resolvable parentId + parentNodeName.
Batch tools validate every item (existence, scope, name, type, locked, instance-interior, scope-root) before any mutation. A single bad member aborts the whole batch with zero mutations. Once mutation begins, handlers process sequentially, stop on first failure, and return a completed-vs-failed report — no auto-rollback. (text_set_content, annotation_set, instance_set_overrides, create_component_set, node_delete.)
node_delete (deleteMultipleNodes) runs resilient parallel chunks and is excluded from stop-on-first-failure — but its pre-validation still runs, so it never starts on an invalid target.Discovery and navigation ignore the node/variable/style permission axes, scope, and locks: node_info, page_info, all *_list, instance_get_overrides, reaction_list, annotation_list, view_navigate, and node_export_visual (PRD D5). Accepted residual R3: node_export_visual can render an off-scope node.
Figma-URL IDs use dashes (20485-41); the API expects colons (20485:41). The MCP server converts before forwarding; pass URL-format IDs through unchanged.
A node can be locked/unlocked, reparented, or deleted by the user between validation and mutation. Guards do not hold a lock across the gap; the handler’s try/catch reports whatever Figma throws.
Four families of plugin-side guard, each returning "Operation Denied: …":
scopeRootId — it would brick the session with SCOPE_DELETED. Covers node_delete, node_flatten, node_ungroup, and create_component (which replaces the source frame with a new component id). → G6locked (findLockedAncestor). Single-target writes check the target; batch writes check each item; creation/reparent check the parent (and, for reparent, the child). → G7INSTANCE (findInstanceAncestor). Property/override writes remain allowed, with Figma as final arbiter (R5). → G7.remote). Instances of remote components stay fully editable (local overrides), so instance_set_property is not remote-gated. → G7Gate order in the dispatcher (most-specific error wins): permission → scope → name → locked → instance-interior / scope-root → handler checks. Shorthand: node-perm = allowEditNode + scope link; var-perm = allowEditVariable; style-perm = allowEditStyle.
| Tool | Enforced gate stack |
|---|---|
node_set_fill |
node-perm · scope · name · locked |
node_set_stroke |
node-perm · scope · name · locked |
node_set_corner_radius |
node-perm · scope · name · locked |
node_set_effects |
node-perm · scope · name · locked |
node_set_auto_layout |
node-perm · scope · name · locked · enum checks · FILL needs auto-layout parent (§8) · NONE-frame silent-drop rejected (§8) · BASELINE horizontal-only · counterAxisSpacing WRAP-only |
node_rename |
node-perm · scope · name · locked |
node_transform |
node-perm · scope · name · locked · layout-controlled x/y hard-reject (§9) · resize-resets-sizing warning (§9) |
node_bind_variable |
node-perm · scope · name · locked · unsupported node / mixed paint guard (v2.3.1 §1) · auto-layout precheck (v2.3.1 §3) · SOLID-only paint bind (type-mismatch guard, gated by node-perm not var-perm) |
node_apply_style |
node-perm · scope · name · locked (gated by node-perm, not style-perm) |
node_clone |
node-perm · scope(source) · name · locked(source) |
node_flatten |
node-perm · scope · name · locked · scope-root (§1) |
node_ungroup |
node-perm · scope · name · locked · scope-root (§1) · instance-interior (§4) · must be GROUP |
text_set_style |
node-perm · scope · name · locked · type TEXT · mixed-font load via getStyledTextSegments (§10) · full schema↔handler contract incl. fontName + lineHeight AUTO (§15) |
instance_set_property |
node-perm · scope · name · locked · type INSTANCE · value type validation BOOLEAN/TEXT/VARIANT/INSTANCE_SWAP (§5) · not remote-gated (local override) |
reaction_update |
node-perm · scope · name (§6A) · locked |
| Tool | Enforced gate stack (per item unless noted) |
|---|---|
node_delete |
node-perm · scopeRoot present · exists · scope · name · locked · instance-interior (§4) · scope-root (§1) |
node_group |
node-perm · scope · name · same-parent · locked · instance-interior (§4) |
text_set_content |
node-perm · scopeRoot · exists · scope · name · type TEXT · locked · correct characters contract (§16) |
annotation_set |
node-perm · scopeRoot · exists · scope · name · supports-annotations · locked |
instance_set_overrides |
node-perm · scopeRoot · source exists+INSTANCE · per-target exists+scope+name+INSTANCE+locked |
create_component_set |
node-perm · scopeRoot · per-component exists+scope+name+propValues-count+COMPONENT-type · parent scope+name+locked · duplicate-variant uniqueness (§11) |
| Tool | Enforced gate stack |
|---|---|
create_shape |
node-perm · parent scope+name+locked+instance-interior (§4) · shape-param checks (arcData=ellipse, pointCount≥3, innerRadius=star) · color 0–1 (Zod) |
create_frame |
node-perm · parent scope+name+locked+instance-interior (§4) · color 0–1 (Zod) · opacity normalized, no NaN (§12) |
create_text |
node-perm · parent scope+name+locked+instance-interior (§4) · color 0–1 (Zod) · opacity normalized, no NaN (§12) |
create_svg |
node-perm · parent scope+name+locked+instance-interior (§4) |
create_instance |
node-perm · parent scope+name+locked+instance-interior (§4) |
create_component |
node-perm · scope · name · locked · scope-root self-destruction (§1) |
node_insert_child |
node-perm · parent scope+name · child scope+name · locked(parent & child) · self/cyclic-parent (§3) · instance-interior, both ids (§4) · index bounds (§13) |
create_connection |
node-perm · connector scope (if set) · per-connection start/end scope+name · locked |
| Tool | Enforced gate stack |
|---|---|
variable_manage |
var-perm (§14) · remote block on UPDATE (§7) |
variable_delete |
var-perm (§14) · ids-xor-collection · required name verification, both modes (§6B) · remote block (§7) · full-document consumer scan refuses in-use deletes |
style_manage |
style-perm (§14) · remote block on edit-existing (§7) · (binding a variable into a style needs only style-perm) |
style_delete |
style-perm (§14) · styleName verification (strict) · remote block (§7) |
component_manage_property |
node-perm · scope · name · locked · COMPONENT/COMPONENT_SET · blocks VARIANT add · value type validation (§5) · variant-member guard (§5) · remote block (§7) |
component_delete_property |
node-perm · scope · name · locked · remote block (§7) |
Note:
component_*_propertyedit a main component definition and remain node edits (node-perm + scope + name), plus the §7 remote block. Onlyvariable_*/style_*move onto the new asset permission axes.
| Tool | Requirement | Gated? |
|---|---|---|
node_info |
empty-args → falls back to scopeRootId; node-read-only empty-args returns {nodes:[]} |
No (read) |
page_info, style_list, component_list, variable_list, annotation_list |
— | No (read) |
instance_get_overrides |
requires instanceNodeId |
No (read) |
reaction_list |
requires nodeIds[] |
No (read) |
node_export_visual |
— | No (read; accepted off-scope residual R3) |
view_navigate |
resolves ids; rejects DOCUMENT root, mixed page/node, cross-page selection | No — deliberately scope-exempt (A6) |
get_connect_payload |
— | No (handshake; surfaces the three permission axes) |
These run in the MCP server before the plugin and reject malformed input early (not a control — see A1/AS6). Notable ones:
r,g,b,a constrained 0–1 on create_shape/create_frame/create_text and style_manage paints (min(0).max(1)). (Plugin-side, create_frame/create_text also normalize alpha so a missing a never yields NaN opacity — §12.)node_set_fill requires exactly one of a solid color (r,g,b), an image payload (url or bytesBase64) (v2.3.0 §1), or clear:true (v2.3.1 §4); variable_delete ids-xor-collection; create_connection connector-vs-connections.layoutMode, primaryAxisAlignItems, counterAxisAlignItems, layoutSizingHorizontal/Vertical), textCase, textDecoration, shape type, paint type, grid pattern.node_bind_variable constrains bindVariables keys to a strict typings-derived allowlist BINDABLE_FIELDS instead of an open record (v2.3.1 §2).pointCount ≥ 3, innerRadius/arcData.innerRadius 0–1, strokeWeight positive.lineHeight: both style_manage and text_set_style accept the {unit:"AUTO"} union (§15).nodeName/parentNodeName on every write; variableNames/collectionName on variable_delete (§6B); nodeName on reaction_update (§6A).The plugin returns these from main.ts ERRORS and inline throws. Full recovery guidance lives in skills/figma-edit/references/error-playbook.md.
| Code / message | Meaning |
|---|---|
READ_ONLY_MODE |
No scope link → node writes blocked (asset edits gated separately). |
VARIABLE_EDITS_DISABLED / STYLE_EDITS_DISABLED |
The corresponding asset permission axis is off (§14). |
OUTSIDE_SCOPE / PARENT_OUTSIDE_SCOPE / CLONING_SOURCE_NODE_OUTSIDE_SCOPE |
Target/parent/clone-source not under scopeRootId. |
SCOPE_DELETED |
scopeRootId no longer resolves — the bricking the §1 scope-root guard prevents. |
NAME_MISMATCH / PARENT_NAME_MISMATCH |
Resolved name ≠ supplied name (stale/fabricated id). |
"… is locked …" (§2) |
Target or an ancestor is locked. |
"… is inside a component instance …" (§4) |
Structural edit inside an INSTANCE. |
"… is a remote library asset …" (§7) |
Edit targets a remote style/variable/main-component. |
"… is the current Editable Scope root …" (§1) |
Destructive/replacing op on the scope anchor. |
"… cannot be inserted into itself …" / cyclic (§3) |
Self- or cyclic reparent. |
"Sizing 'FILL' requires … Auto-Layout parent" (§8); index-bounds (§13); duplicate-variant (§11); auto-layout child transform (§9) |
The remaining structured "Operation Denied: …" strings. |
| Actionable Prechecks | node_bind_variable blocks missing auto-layout (v2.3.1 §3) and non-solid paint binds (v2.3.1 §1). |
MISSING_* / type errors |
Parameter/shape/type violations from the dispatcher and handlers. |
SAFETY.md — the contributor / integrator / auditor-facing companion to the agent-facing guides in skills/figma-edit/references/ (constraints.md et al.) and to DESIGN_PHILOSOPHY.md. The end-user-facing safety value proposition lives in README.md, which links here for the full contract. If the project ever accepts external vulnerability reports, add a separate thin SECURITY.md for disclosure — it is a different document from this one.constraints.md; per-error recovery → error-playbook.md; the v2.3.1 change rationale → prd.md; review provenance → figma-documentation-check.md and critique.md.