Skip to main content
yanbever
New Member
July 9, 2026
Question

Plugin API via Figma MCP: cross-page reads return empty, cross-page clones fail silently, and edits don't reliably persist

  • July 9, 2026
  • 4 replies
  • 87 views

When driving a FigJam file through the Plugin API via an MCP integration, I hit three reproducible problems in one working session. Filing together since they all stem from Plugin-API-over-MCP reliability.

1. Cross-page reads return empty. Reading a page's children for any page that is NOT the
currently active page (e.g. figma.root.children[n].children where n is not figma.currentPage) returns 0 nodes, even when the page clearly has content. Only the active page reads correctly. This makes it impossible to reliably inspect or verify content on other pages.
2. Cross-page clone/append fails silently. Calling .clone() on a sticky and appendChild
onto a different page produces no visible node and throws no error. The operation reports success but the content never appears on the target page. Recreating nodes from scratch on the active page works; moving/cloning across pages does not.
3. Edits don't reliably persist / possible transaction rollback. Text edits and deletes
sometimes don't stick. Separately, using throw new Error(...) to surface diagnostic output appears to abort the transaction before the preceding mutation commits, so the operation reports success while the change is actually rolled back.

Net effect: same-page reads and moves are reliable, but anything cross-page, or any mutation followed by a thrown diagnostic, is not, and there's no error to signal the failure.

Environment: FigJam file, Plugin API accessed via an MCP server, macOS.

4 replies

Jaycee Lewis
Figmate
Figmate
July 9, 2026

Hey ​@yanbever 👋 Thanks for the great writeup. Very helpful.

For issues 1 and 2, my gut says they are related to the same underlying behaviour. Can you confirm — in your session, did you call setCurrentPageAsync on the target page before reading or appending to it? If not, that may explain both symptoms.

For issue 3, this one, I could confirm as described. A script that mutates a node and then throws — even for something as simple as surfacing diagnostic output — discards that mutation with no error signal that anything was rolled back in my quick test. 

My setup: a 2-page test file (Sticky, Not_Sticky) with one sticky note on Sticky used for all mutation tests, and two additional stickies manually placed on Not_Sticky to serve as ground truth for the cross-page read tests.

My tests for 3:

Requests:

// Baseline — mutate, no throw
const sticky = figma.root.children.find(p => p.id === '0:1').findOne(n => n.id === '1:3');
await figma.loadFontAsync(sticky.text.fontName);
const before = sticky.text.characters;
const marker = 'BASELINE_' + Date.now();
sticky.text.characters = marker;
return { stickyId: sticky.id, before, markerWritten: marker };

// -> fresh read afterward confirms sticky.text.characters === marker (persisted normally)
 // Suspect — mutate, then throw a diagnostic error
const sticky = figma.root.children.find(p => p.id === '0:1').findOne(n => n.id === '1:3');
await figma.loadFontAsync(sticky.text.fontName);
const before = sticky.text.characters;
const marker = 'SUSPECT_' + Date.now();
sticky.text.characters = marker;
throw new Error('Diagnostic: wrote "' + marker + '", was "' + before + '"');

// -> error confirms the write executed before throwing
// -> fresh read afterward: sticky.text.characters is back to the BASELINE value, not the SUSPECT marker
// -> the pre-throw mutation was discarded

Responses:

Baseline (mutate, no throw):

json

{
"stickyId": "1:3",
"before": "",
"markerWritten": "BASELINE_1783612914532"
}

Fresh follow-up read:

{
"currentValue": "BASELINE_1783612914532"
}

→ persisted exactly as written.

Suspect (mutate, then throw):

Error: Diagnostic: wrote "SUSPECT_1783613320601", was "BASELINE_1783612914532"
at <anonymous> (PLUGIN_1_SOURCE:8:16)

Fresh follow-up read:

{
"currentValue": "BASELINE_1783612914532"
}

→ still shows the baseline value — the SUSPECT_... write was discarded.

Screenshot after both tests for issue #3

I took all 3 issues to the MCP team for guidance. I’ll post back here with any updates or next steps for us. Thanks again! — Jaycee

Nate Peo
New Member
August 31, 2026

Confirming issue 3 still reproduces on 2026-08-31, seven weeks after this was escalated. New information: this happens on a design file, not just FigJam, so it is not editor-specific.

Isolated it with an A/B where the only variable is how the script returns data. Same file, same script, back to back:

A — return the node id via throw (as in the original report):

const page = figma.currentPage;const r = figma.createRectangle();r.x = 0; r.y = 0; r.resize(50,50);r.name = "TEST_RECT_THROW";page.appendChild(r);throw new Error(JSON.stringify({id: r.id}));

Returns Error: {"id":"3:2"}, Debug UUID a38f6e4d-0e1d-4dbb-8172-7ef1da5f52dc.
Next call: getNodeByIdAsync("3:2"){"exists": false}, and figma.currentPage.children is [].

B — identical script, return instead of throw:

const page = figma.currentPage;const r = figma.createRectangle();r.x = 200; r.y = 200; r.resize(50,50);r.name = "TEST_RECT_RETURN";page.appendChild(r);return { createdNodeIds: [r.id] };

Returns {"createdNodeIds":["3:3"]}. Node persists. Independently confirmed via get_metadata:

<canvas id="0:1" name="Page 1" width="0" height="0">  <rounded-rectangle id="3:3" name="TEST_RECT_RETURN" x="200" y="200" width="50" height="50" /></canvas>

So the rollback is real and reproducible, and return is a reliable workaround.

The reason this costs people hours before they find it: the figma-use skill shipped with the MCP server says in one section "Thrown errors are automatically captured and returned, just let them propagate or throw explicitly", which reads as an invitation to use throw as an output channel, and in another section that failed scripts are atomic and roll back. Both are accurate, but together they lead you straight into silent data loss with no error indicating a rollback happened. Even without an API change, reconciling those two passages would prevent most of this.

On issues 1 and 2, I could not reproduce either on a design file: a cross-page appendChild onto a non-active page persisted, and reading a non-active page's children returned the correct count without setCurrentPageAsync. Caveat: the page I read had been created in the same session, so that is not a clean test of the incremental page-load path. Those two may well be FigJam-specific.

Jaycee Lewis
Figmate
Figmate
August 31, 2026

Hey ​@Nate Peo 👋 Thank you for taking the time to add your details. I am going to reply here and acknowledge I saw your additional post for this 3rd issue. 

Does this summary match what your result?

  • In a Design file (not FigJam this time) mutate-then-throw rolls back and still repros
  • You isolated the cause to the throw vs return path
  • return was reliable workaround in your tests

Your test: 

A: create rect > appendChild > throw with node id

  • Returned Error: {"id":"3:2"}
  • Follow-up getNodeByIdAsync("3:2") > {"exists": false}
  • figma.currentPage.children > []

B: identical, return { createdNodeIds: [r.id] }

  • Returned {"createdNodeIds":["3:3"]}
  • Node persists
  • Confirmed in separate test with get_metadata 

Thank you! — Jaycee

Jaycee Lewis
Figmate
Figmate
September 3, 2026

Thanks for reporting ​@Nate Peo (and ​@yanbever ) I took this to my resources. I’ll follow up here with updates. Thanks again, we appreciate YOU! — Jaycee