Skip to content

feat: read Figma through a bridge plugin instead of the metered path - #50

Merged
owjs3901 merged 16 commits into
mainfrom
integration/figma-bridge
Sep 13, 2026
Merged

feat: read Figma through a bridge plugin instead of the metered path#50
owjs3901 merged 16 commits into
mainfrom
integration/figma-bridge

Conversation

@owjs3901

Copy link
Copy Markdown
Contributor

Rebase of #48 onto current main, with the CI failures fixed and the install written up where a user will actually find it.

Collection runs through the official MCP's use_figma, and that is the call Figma meters - a handful of screens exhausts the allowance. Every script devup-mcp sends uses only the standard Plugin API, so a plugin of our own can serve them.

What I changed from #48

#48 could not merge as it stood:

Problem Resolution
Based on owjs3901/integration-0-4-6, not main Rebased the 14 bridge commits onto main
48 commits behind main Now current; one conflict in server/mod.rs resolved
verify failed - 8 fmt violations Inherited from the base branch, which has 10 on its own. Gone on main
changepack required failed Added
Bridge undocumented in the main README Added, see below

The conflict was not bridge code: the commit carried with_project_theme_validation and two rustfmt reflows from the base branch. I kept main's versions and applied only the two hunks the bridge actually needs - three imports and the constructor's choice of upstream.

owjs3901/integration-0-4-6 and its four commits remain a separate decision, untouched.

Review — the trust boundary

This opens a local socket, so that is what I checked first.

  • No code crosses the wire. plugin/scripts/gen-scripts.mjs wraps the twelve scripts into the plugin at build time from the same sources the remote path uses, and the build fails on an unsubstituted placeholder. devup-mcp sends only a script name, typed &'static str from compiled-in constants. The plugin does not eval. The repo's standing "no user-supplied JavaScript" contract holds.
  • Bound to 127.0.0.1, not 0.0.0.0. No remote reach.
  • Read-only. Non-script calls are refused outright: "the bridge serves script reads only".
  • Keyless plugins are bounded. A plugin that cannot report its file key serves any key only while it is the sole connection; two or more and the read goes remote.
  • Fails open, not closed. No plugin attached, or the port already taken, and every call falls straight through to the remote path. Installing nothing changes nothing.

Worth the owner's attention: the bridge is ON by default. Unset DEVUP_FIGMA_BRIDGE_PORT means devup-mcp listens on 1993; off disables it. A local process could impersonate a plugin and feed false design data - that needs local code execution first, so I judged it acceptable for a dev tool, but the default is a decision you may want to flip.

Installation docs

The last step cannot be automated: Figma's plugin registration is a desktop menu action. The build is one command; the import is three clicks. I wrote it up in the main README under Figma 연결 설정 so someone who never opens plugin/ still finds it - build command, the exact menu path, how to tell it is working (green vs grey), how to turn it off, and the traps: the port lives in three places and changing one silently reverts to the metered path, ws://127.0.0.1 invalidates the manifest outright, one file at a time, and backgrounding Figma throttles a 160 ms snapshot to about 11 s.

plugin/README.md keeps the internals.

Speed, from the original work

before after
UsedResources 21,421 ms 24 ms
unresolved resources 33 0
variables / styles 0 / 0 30 / 3

The 21.4 s was constant whether a screen used 33 resources or 40 - under Promise.all the total is the slowest lookup, and every getVariableByIdAsync was waiting out a fixed timeout and returning null, so bound tokens were named after the tail of their id (bg="" from VariableID:495:842). The file's own variables are now read once and indexed.

Verification

fmt 0, clippy --locked --workspace --all-targets --all-features -D warnings 0, cargo test --workspace 1090 passed / 0 failed / 2 ignored (main is 1084; +6 bridge tests), insta --check no drift.

I did not re-run the author's live-Figma probe - that needs a real install and a real file.

Still broken, per the original report

Asset export fails on every request at a fixed ~11 s, the same signature as the variable defect, so the asset path likely needs the same treatment. Not fixed here and not claimed.

devup-mcp reads Figma by shipping JS to the official MCP's use_figma
tool, and that path is rate limited. The scripts only touch documented
Plugin API - getNodeByIdAsync, getLocalPaintStylesAsync, variables -
so a plugin we run ourselves can execute them against the open file
with no API budget at all.

Build-time codegen wraps the existing scripts as functions rather than
copying them, so remote and bridge stay on one source. Placeholders
become parameters; __DEVUP_SNAPSHOT_CURSOR__ stays literal because it
is a marker node id shared with the Rust decoder, not a value. An
unsubstituted placeholder fails the build.

No eval: the plugin sandbox blocks dynamic code, and a bundler has to
see the call sites. The UI bundle is inlined into ui.html because the
iframe is injected via srcdoc and cannot fetch a relative script.

Rust transport lands separately; this half builds and typechecks.
The script reads that do the collecting all route to the official MCP's
use_figma, which is what the rate limit applies to. They only call
documented Plugin API, so the plugin we ship can run them against the
open file for nothing.

The plugin cannot accept connections, so devup-mcp listens and the
plugin dials in. Binding is synchronous because the site that builds
the upstream is; only the serving loop is spawned. A taken port or no
runtime means no bridge, not an error - other MCP clients on the same
machine are normal, and the remote path still works.

Routing is decided before the call, not after a failure. Capability and
transport failures fold to one ErrorCode, so an error alone cannot tell
"the bridge does not do this" from "the bridge tried and failed", and
retrying the second on the metered path would spend what was saved.

Results are wrapped exactly as use_figma returns them. Decoders hunt
for JSON inside content[].text, so a different shape would pass some
and fail others, splitting the two paths screen by screen.
Figma itself cannot be started here, so a WebSocket client stands in
for the plugin. Everything on this side of it is covered: registration,
the script name and parameter mapping, request correlation, the result
envelope, and the routing decision.

Binding port 0 lets the kernel pick, so the tests do not fight the
default port or each other; BridgeServer now reports what it bound.

The routing test is the one that matters. It pins that a read is not
handed to the bridge before a plugin for that file is connected, that
official-tool reads stay on the remote path even when one is, and that
another file's plugin does not answer for this one.
The port is the trap. Figma fixes the addresses a plugin may dial in
its manifest, and that list does not change at runtime, so moving
DEVUP_FIGMA_BRIDGE_PORT alone leaves devup-mcp listening somewhere the
plugin never knocks. Nothing errors - reads just go back to the metered
path, spending the allowance this exists to save. Three places have to
move together.
Typechecking and bundling say nothing about whether the codegen wrapped
the scripts correctly. A top-level return landing outside its function,
or a placeholder resolving to something odd, bundles cleanly and only
breaks inside Figma - where it is hard to tell whether the plugin or the
script is at fault.

Importing the module proves the wrapping parses, and pageCatalog is run
against a stub because it touches the smallest figma surface, so the
stub cannot stand in for what is being tested. The name list is checked
against Rust's plugin_name() here too: a drift there is invisible until
a read reaches a plugin that does not know the name.

Wired into build, next to the placeholder check that already fails it.
The socket tests put a WebSocket client where the plugin goes, so the
part they cannot reach is the one that matters most: the generated
scripts running against a real document. This binds the default port,
waits for the plugin, and reads the open file's page list.

page_catalog is the read to start with because it needs no node id, so
a first check cannot fail merely for pointing at the wrong node.

    cargo run -p devup-mcp-figma --example bridge_probe
Verified by importing the plugin into Figma and reading a live file.
Two defects only that could find.

Figma rejected the manifest outright: allowedDomains took
ws://localhost but called ws://127.0.0.1 "not a valid URL", so the
plugin never ran. The UI dialled 127.0.0.1 as well and had to move to
localhost with it.

Then the plugin connected and served nothing. figma.fileKey came back
empty, and registration skipped anything without a key - the window
said "connected" while no read ever arrived, which is the hardest
version of this to diagnose. Keyless plugins now register, and a
keyless one answers only when it is the only one connected; with
several there is no telling which file is open, and falling back to
the metered path beats reading the wrong document.

Probe output against the open file:

    PROBE_OK fileKey="" pages=5
      "0:1" "Devup UI 랜딩페이지"
      "530:2305" "Components"
      ...
Both are silent failures. ws://127.0.0.1 in allowedDomains makes Figma
reject the manifest, so the plugin never starts. An empty figma.fileKey
leaves a connection that reports itself healthy while answering
nothing, which is why a keyless plugin now only serves when it is
alone.
The keyless path was found on a real install and fixed, but nothing
held it in place: the next edit could drop back to skipping
registration and the only symptom would be reads quietly going to the
metered path again.

Two cases, because the rule is a trade rather than a default. Alone, a
keyless plugin answers for whatever file is asked - it is the only one
open, so it is that file. With another plugin connected there is no
telling which document is in front of it, and the remote path costs
money where a wrong answer costs correctness.
The decoder wants a non-empty fileKey and the scripts copy it straight
from figma.fileKey, which comes back empty on a real install. Every
node arrived - eight of them - and the whole envelope was thrown away
for the one field.

Not invented: the caller named the file this read was for, and
resolve_key already decided this plugin serves it.

A manifest permission looked like the cleaner fix, but Figma rejects
"permissions": ["fileKey"] outright and refuses to load the plugin, so
the value has to be restored on this side.
The 15 KB page and 4 KB field budgets exist because the Figma MCP cuts a
text result at 20,480 bytes. A local socket does not cut, so the same
screen that took 32 round-trips now takes one or two.

That matters more than it sounds. Chromium throttles a background window
to one wake per minute, and a measured run spent 59.8s, 59.8s and 59.7s
on three separate reads while the other twenty-nine took 9-72ms each. A
silent AudioContext did not lift it. Fewer round-trips is the fix that
does not depend on which window the user is looking at.

The ceilings become defaults rather than constants: absent an override
both scripts pack exactly as measured. The contract test now pins the
19 KiB bound and the 18000 packing ceiling instead of the literal that
used to carry them.

Also queues jobs in the plugin. Snapshot scripts move the current page
with setCurrentPageAsync, so two running at once would read each other's
screen and return plausible nodes from the wrong one.
Variables and styles were asked for eight at a time and used-resources
twelve, because that is what fits in a result the Figma MCP will cut.
Over a local socket nothing is cut, and a measured run spent 10.5s and
11.0s on two consecutive UsedResources reads that had no reason to be
two reads.

The collector holds no upstream - it plans calls and something else
makes them - so the budget rides in on the request, set once where the
session is built.

Fallback reports the bridge's budget whenever a plugin is connected.
Batches are cut long before the call, so which transport answers is not
known yet; every read that may be batched is a script read, and those
go to the bridge while a plugin is there. If one closes mid-collection
the remaining batch is too large for the remote and is refused, which
is the right outcome - the collection could not have continued anyway.
Every variable on a real screen came back unresolved - 33 of 33 - and
the generated TSX named them after the tail of their id, so a bound
colour read �g="$842" from VariableID:495:842.

used_resources.js is the resolver itself, not a discovery pass: it
calls getVariableByIdAsync per id and reports the nulls as unresolved.
So nothing was skipped; Figma returned nothing for all 33.

Two things could do that, and they are fixed in different places. The
batch is now 33 in one call where it used to be 12, so the plugin may
not survive that many concurrent lookups. Or the file's variables live
in a shared library the plugin has no permission to read - the ids
include VariableID:<40-hex>/1140:6, which is a library variable.

This env var lets the first one be ruled out by restarting rather than
rebuilding. It is a diagnostic handle, not a setting anyone should
need; the default stays one call.
Every variable on two real screens came back unresolved, and the TSX
named them after the tail of their id: a bound colour read �g="$842"
from VariableID:495:842. Ten such tokens, none of them in devup.json.

The timings said what it was. UsedResources took 21421, 21471, 21919,
22014 and 21714 ms across five runs - 33 resources on one screen, 40 on
another, and the same 21.5s either way. The lookups run under
Promise.all, so the total is the slowest single one, not the sum: each
getVariableByIdAsync was waiting out the same fixed timeout and then
returning null. Meanwhile VariableCatalog, which lists the file's own
collections and styles, answered in 12ms on the same file through the
same transport.

So ask the file for its variables once and index them by id. Only ids
that are genuinely not in the file - library variables, whose ids carry
a VariableID:<40-hex>/1140:6 shape - still need an individual lookup,
and those now say which way they failed instead of sharing one reason
with everything else.

The contract test asserted this script never calls
getLocalVariablesAsync. That ban existed to keep the script from
hauling back everything in the file when it was asked for a few ids,
and that still holds: the list is an index here, and the response is
built from the requested ids alone. The test now checks that fact
rather than the name of a call.
chore: record the bridge transport as a minor changepack
@owjs3901
owjs3901 merged commit 0bbb445 into main Sep 13, 2026
8 checks passed
@owjs3901
owjs3901 deleted the integration/figma-bridge branch September 13, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant