Skip to content

Architecture

The server is a hexagon: the core knows nothing about REST, WebSockets or LiteDB, and the MCP tools see ports only. Folder = layer = namespace.

Layer Holds
Domain NodeId, DocumentLocator, PublishedSourceException with SourceFailure, editor exceptions, RingBuffer
Application/Ports the published source split by intent, the live editor, the snapshot stores, diagnostics
Application the read pipeline, snapshot policy, sanitizer, tokens, extraction, version diffing
Adapters/Rest RestDocumentGateway, QuotaGate — the only place that speaks HTTP
Adapters/Editor the WebSocket hub, command router, session registry, server-side sibling discovery, embedded read scripts, capture store
Adapters/Persistence/LiteDb the three snapshot stores and the lazily opened database
Tools the MCP driving adapter: 139 tools and the prompt catalog
Extensions / Hosting the composition root and the process lifetime

Two channels

READ is plugin-first. While a plugin session exists for the file, reads go through it: zero REST requests, zero quota, and the current state of the canvas including unsaved edits. Without a session the server serves a LiteDB snapshot while it is fresh (Cache.FreshnessSeconds, 300 s by default), and only then calls REST. Every response carries a source field — plugin, cache, figma-api or cache-stale.

The path can be forced per call with the source parameter (auto / rest / plugin) and switched off globally with Bridge.PreferPluginForReads: false.

WRITE is plugin-only. Mutations are confirmed by the plugin's answer, never assumed. After each one the node and its ancestors are invalidated; the plugin's DOCUMENT_CHANGE event drops snapshots at the moment of the edit. Coordinates always come from the plugin (node.x / node.y, relative to the parent).

REST stays the only path for what the plugin cannot do: version history and blame, comments, other teams' libraries, svg/pdf rendering, and files that are not open in the desktop app.

One read, at most one request

A read costs at most one REST request, and none at all while the snapshot is fresh. The file version is taken from the response envelope that already arrived rather than from a separate depth=1 probe — that probe sits in the same expensive rate-limit bucket as a full read, so it would double the quota cost instead of saving it. Precise invalidation comes from the plugin instead.

Quota circuit breaker

When Figma answers 429 with a long Retry-After (a daily plan quota), the endpoint bucket is closed locally until it expires and further requests to it are refused without a network round trip — a refusal that is already known is not worth a request, and failed requests appear to count against the limit as well. Buckets are per endpoint (files, files/nodes, images), so a 429 on one does not silence the others. A successful response reopens a bucket immediately. The state is visible in csharp_diagnose under rateLimit.

Images

figma_get_component_image goes plugin → snapshot → REST, and only the last step spends quota. A render link lives 30 days at Figma and is cached for Cache.ImageTtlHours (24 h). The image cache is dropped for the whole file on any DOCUMENT_CHANGE, because what is usually cached is the render of a parent node while the plugin reports only the nodes that changed directly.

Process lifetime

The server talks JSON-RPC over stdio, so every log line goes to stderr — a single stray write to stdout would break the client. An hour without a tool call and the process exits on its own (IdleTimeoutMinutes, 0 disables it) so that orphaned servers do not pile up. A connected plugin holds the countdown: while the WebSocket session is open the server is in use, and the full timeout starts over once the last session drops.