feat: read Figma through a bridge plugin instead of the metered path - #50
Merged
Conversation
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
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
owjs3901/integration-0-4-6, notmainmainmainserver/mod.rsresolvedverifyfailed - 8 fmt violationsmainchangepack requiredfailedThe conflict was not bridge code: the commit carried
with_project_theme_validationand two rustfmt reflows from the base branch. I keptmain'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-6and its four commits remain a separate decision, untouched.Review — the trust boundary
This opens a local socket, so that is what I checked first.
plugin/scripts/gen-scripts.mjswraps 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 strfrom compiled-in constants. The plugin does noteval. The repo's standing "no user-supplied JavaScript" contract holds.127.0.0.1, not0.0.0.0. No remote reach.Worth the owner's attention: the bridge is ON by default. Unset
DEVUP_FIGMA_BRIDGE_PORTmeans devup-mcp listens on1993;offdisables 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.1invalidates the manifest outright, one file at a time, and backgrounding Figma throttles a 160 ms snapshot to about 11 s.plugin/README.mdkeeps the internals.Speed, from the original work
UsedResourcesThe 21.4 s was constant whether a screen used 33 resources or 40 - under
Promise.allthe total is the slowest lookup, and everygetVariableByIdAsyncwas waiting out a fixed timeout and returning null, so bound tokens were named after the tail of their id (bg=""fromVariableID:495:842). The file's own variables are now read once and indexed.Verification
fmt0,clippy --locked --workspace --all-targets --all-features -D warnings0,cargo test --workspace1090 passed / 0 failed / 2 ignored (mainis 1084; +6 bridge tests),insta --checkno 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.