feat: read Figma through a bridge plugin instead of the metered path - #48
feat: read Figma through a bridge plugin instead of the metered path#48owjs3901 wants to merge 14 commits into
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.
|
Superseded by #50, which is merged. #48 could not land as it stood: it was based on Two things were added on top of the original work:
|
Reads a Figma file through a plugin we install ourselves, so ordinary
design reads stop spending the metered path.
Why
Collection went through the official MCP's
use_figma, and that is whatthe quota counts. The existing third-party bridge could not replace it:
it has no code-execution tool at all, so the twelve scripts devup-mcp
sends had nowhere to run. Every one of those scripts uses only the
standard Plugin API, so a plugin of our own can serve them.
Shape
A plugin cannot accept connections, so devup-mcp is the WebSocket
server (
ws://localhost:1993/plugin) and the plugin dials it. Readsare routed before the call by
PreferredUpstream::can_serve, not bycatching a failure -
CapabilityUnavailableandTransportcollapseinto the same error code, so a failure cannot tell them apart.
The scripts are not copied.
plugin/scripts/gen-scripts.mjswrapscrates/devup-mcp-figma/src/scripts/*.jsinto functions at build timeand the build fails if a placeholder is left unsubstituted. A build gate
imports the generated module, runs one script and checks the twelve
names against
BuiltinScript::plugin_name().What a real install taught
Three of these were invisible until the plugin ran against Figma:
ws://127.0.0.1is rejected outright -allowedDomainsonly acceptsws://localhost.figma.fileKeycomes back empty. Skipping registration for a keylessplugin left the window saying "connected" while reading nothing, so a
keyless plugin now registers and serves any key while it is alone.
"permissions": ["fileKey"]invalidates the whole manifest and theplugin will not load. Reverted.
Speed
Two fixes, both measured against a real screen.
Pagination was sized for a response the Figma MCP truncates at 20,480
bytes. Over a local socket nothing is truncated, so a screen now arrives
in one page:
NodeSnapshotwent from roughly twenty calls to one, andchunk-readfrom about twenty to none.Then resources: variables were fetched eight at a time because that is
what fits in a truncated result.
BatchBudgetlets the transport sayhow much one question carries, and the collector takes it from the
request rather than a constant.
The defect the speed work exposed
With the round trips gone, one call still took 21.4 seconds - and took
the same 21.4 seconds whether the screen used 33 resources or 40. Under
Promise.allthe total is the slowest single lookup, not the sum, soeach
getVariableByIdAsyncwas waiting out a fixed timeout and thenreturning null. Every variable on the screen came back unresolved and
the generated TSX named them after the tail of their id: a bound colour
read
bg="$842"fromVariableID:495:842.Meanwhile the call that lists the file's own collections answered in
12ms on the same file through the same transport. So the file's
variables are fetched once and indexed by id, and only ids that are
genuinely not in the file - library variables, whose ids carry a
VariableID:<40-hex>/1140:6shape - still need an individual lookup.UsedResourcesStill broken
Asset export fails on every request, each taking a fixed ~11 seconds -
the same signature as the variable defect, so the asset path likely
needs the same treatment. Not fixed here.
Backgrounding Figma also throttles the plugin to roughly one wake per
minute; a snapshot that takes 160 ms in front takes about 11 s behind.
Fewer round trips soften it but cannot remove it.
Verification
cargo test --workspacepasses,cargo clippy --workspace --all-targets -- -D warningsis clean, the plugin typechecks and builds, and thebridge was driven against a real Figma install: three screens collected
with 0 unresolved resources and every token resolved to a name the
project defines.
Base
Stacked on
owjs3901/integration-0-4-6, which carries four commits ofits own and needs a separate decision.