Skip to content

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

Closed
owjs3901 wants to merge 14 commits into
owjs3901/integration-0-4-6from
owjs3901/figma-bridge-transport
Closed

feat: read Figma through a bridge plugin instead of the metered path#48
owjs3901 wants to merge 14 commits into
owjs3901/integration-0-4-6from
owjs3901/figma-bridge-transport

Conversation

@owjs3901

Copy link
Copy Markdown
Contributor

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 what
the 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. Reads
are routed before the call by PreferredUpstream::can_serve, not by
catching a failure - CapabilityUnavailable and Transport collapse
into the same error code, so a failure cannot tell them apart.

The scripts are not copied. plugin/scripts/gen-scripts.mjs wraps
crates/devup-mcp-figma/src/scripts/*.js into functions at build time
and 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.1 is rejected outright - allowedDomains only accepts
    ws://localhost.
  • figma.fileKey comes back empty. Skipping registration for a keyless
    plugin 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 the
    plugin 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: NodeSnapshot went from roughly twenty calls to one, and
chunk-read from about twenty to none.

Then resources: variables were fetched eight at a time because that is
what fits in a truncated result. BatchBudget lets the transport say
how 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.all the total is the slowest single lookup, not the sum, so
each getVariableByIdAsync was waiting out a fixed timeout and then
returning 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" from VariableID: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:6 shape - still need an individual lookup.

before after
UsedResources 21,421 ms 24 ms
unresolved 33 0
variables / styles 0 / 0 30 / 3
project token check 10 unknown 0 unknown

Still 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 --workspace passes, cargo clippy --workspace --all-targets -- -D warnings is clean, the plugin typechecks and builds, and the
bridge 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 of
its own and needs a separate decision.

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.
@owjs3901

Copy link
Copy Markdown
Contributor Author

Superseded by #50, which is merged.

#48 could not land as it stood: it was based on owjs3901/integration-0-4-6 rather than main, sat 48 commits behind, and its CI failed on a missing changepack plus eight fmt violations inherited from that base. #50 rebased the fourteen bridge commits onto main, resolved the one server/mod.rs conflict — which was base-branch code (with_project_theme_validation and two rustfmt reflows), not bridge code — and added the changepack and the main-README install section.

Two things were added on top of the original work: plugin/dist/ is now committed so the plugin can be imported into Figma without running a build, guarded by a CI step that rebuilds and refuses a byte difference; and source maps are off, so the committed bundle is exactly code.js and ui.html.

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

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