@ttsalpha/mirra 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # Mirra
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/ttsalpha/mirra/refs/heads/main/assets/icon.png" width="96" height="96" alt="" />
3
+ </p>
4
+
5
+ <h1 align="center">Mirra</h1>
2
6
 
3
7
  Two-way MCP server for Figma: an agent reads a design and renders one back through the
4
8
  Figma Plugin API, so it needs no REST token and has no per-month call limit.
@@ -61,11 +65,13 @@ skips all of it. The command is written with the real path of the Node binary th
61
65
  because a version manager puts `node` behind a path that changes with every shell and a
62
66
  GUI client never sees it.
63
67
 
64
- Then, by hand:
68
+ It also registers the plugin with Figma Desktop by writing into Figma's own settings,
69
+ which only works while Figma is **closed** — that file is rewritten from memory on quit.
70
+ If Figma is running, or the format turns out to be one Mirra does not recognise, `init` says
71
+ so and falls back to the manual route: Figma Desktop → Plugins → Development → Import plugin
72
+ from manifest… → `~/.mirra/plugin/manifest.json`.
65
73
 
66
- 1. Figma Desktop → Plugins → Development → Import plugin from manifest… →
67
- `~/.mirra/plugin/manifest.json`
68
- 2. **Restart the clients**, or they will not see the tools
74
+ Either way, **restart the clients**, or they will not see the tools.
69
75
 
70
76
  And once per working session: open a file in Figma, run the Mirra plugin, leave its panel
71
77
  open. Figma has no background plugins, so closing the panel ends the connection.
@@ -104,7 +110,7 @@ the tool when `MIRRA_ALLOW_EXEC` is set, and the plugin only runs it while **All
104
110
  execution** is ticked in its panel. The tick resets every time the plugin runs.
105
111
 
106
112
  ```sh
107
- claude mcp add -s user mirra-exec -e MIRRA_ALLOW_EXEC=1 -- npx -y @ttsalpha/mirra serve
113
+ claude mcp add -s user Mirra-exec -e MIRRA_ALLOW_EXEC=1 -- npx -y @ttsalpha/mirra serve
108
114
  ```
109
115
 
110
116
  The socket listens on loopback and refuses any browser origin other than Figma's, so a page
package/dist/index.js CHANGED
@@ -32495,15 +32495,19 @@ var PatchResult = external_exports.object({
32495
32495
  keys: external_exports.record(external_exports.string(), external_exports.string())
32496
32496
  });
32497
32497
  var ScreenshotParams = external_exports.object({
32498
- /** Defaults to the current selection, or the page if nothing is selected. */
32498
+ /** Defaults to the current selection. */
32499
32499
  target: Target.optional(),
32500
- scale: external_exports.number().min(0.1).max(4).optional()
32500
+ scale: external_exports.number().min(0.1).max(4).optional(),
32501
+ /** Longest edge in pixels; the scale is lowered to fit, not refused. */
32502
+ maxEdge: external_exports.number().int().min(64).max(4096).optional()
32501
32503
  });
32502
32504
  var ScreenshotResult = external_exports.object({
32503
32505
  id: external_exports.string(),
32504
32506
  name: external_exports.string(),
32505
32507
  width: external_exports.number(),
32506
32508
  height: external_exports.number(),
32509
+ /** What the export used, once maxEdge had its say. */
32510
+ scale: external_exports.number(),
32507
32511
  /** base64 PNG. */
32508
32512
  png: external_exports.string()
32509
32513
  });
@@ -32620,7 +32624,9 @@ var ReadParams = external_exports.object({
32620
32624
  /** Defaults to the current selection. */
32621
32625
  nodeId: external_exports.string().optional(),
32622
32626
  /** How many levels of children to walk. */
32623
- depth: external_exports.number().int().min(0).max(20).optional()
32627
+ depth: external_exports.number().int().min(0).max(20).optional(),
32628
+ /** Most nodes to return, so one call cannot fill the context. */
32629
+ limit: external_exports.number().int().min(1).max(2e3).optional()
32624
32630
  });
32625
32631
  var ReadResult = external_exports.object({
32626
32632
  nodes: external_exports.array(external_exports.object({ id: external_exports.string(), tree: UINode })),
@@ -32631,7 +32637,8 @@ var PingResult = external_exports.object({
32631
32637
  fileName: external_exports.string(),
32632
32638
  fileKey: external_exports.string().nullable(),
32633
32639
  currentPage: external_exports.string(),
32634
- selectionCount: external_exports.number()
32640
+ selectionCount: external_exports.number(),
32641
+ pluginVersion: external_exports.string()
32635
32642
  });
32636
32643
 
32637
32644
  // src/clients.ts
@@ -32640,7 +32647,8 @@ import { access, copyFile, readFile, rename, writeFile } from "node:fs/promises"
32640
32647
  import { homedir } from "node:os";
32641
32648
  import { dirname, join } from "node:path";
32642
32649
  import { realpath } from "node:fs/promises";
32643
- var SERVER_NAME = "mirra";
32650
+ var SERVER_NAME = "Mirra";
32651
+ var LEGACY_NAME = "mirra";
32644
32652
  async function stableNodePath() {
32645
32653
  try {
32646
32654
  return await realpath(process.execPath);
@@ -32684,6 +32692,7 @@ async function writeEntry(target, node2, entryPoint) {
32684
32692
  const raw = await readFile(target.file, "utf8");
32685
32693
  const config2 = JSON.parse(raw);
32686
32694
  const servers = config2[target.key] ?? {};
32695
+ delete servers[LEGACY_NAME];
32687
32696
  servers[SERVER_NAME] = {
32688
32697
  ...target.typed ? { type: "stdio" } : {},
32689
32698
  command: node2,
@@ -32708,6 +32717,7 @@ function claudeCode(node2, entryPoint) {
32708
32717
  const probe = spawnSync("claude", ["--version"], { stdio: "ignore" });
32709
32718
  if (probe.error)
32710
32719
  return { client: "Claude Code", status: "skipped", detail: "claude not on PATH" };
32720
+ spawnSync("claude", ["mcp", "remove", "-s", "user", LEGACY_NAME], { stdio: "ignore" });
32711
32721
  const result = spawnSync(
32712
32722
  "claude",
32713
32723
  ["mcp", "add", "-s", "user", SERVER_NAME, "--", node2, entryPoint, "serve"],
@@ -32722,9 +32732,16 @@ function claudeCode(node2, entryPoint) {
32722
32732
  };
32723
32733
  }
32724
32734
 
32735
+ // src/figma-settings.ts
32736
+ import { execFile } from "node:child_process";
32737
+ import { readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
32738
+ import { homedir as homedir3 } from "node:os";
32739
+ import { dirname as dirname2, join as join3, sep as sep2 } from "node:path";
32740
+ import { promisify } from "node:util";
32741
+
32725
32742
  // src/install.ts
32726
32743
  import { spawn } from "node:child_process";
32727
- import { access as access2, cp, mkdir, readFile as readFile2 } from "node:fs/promises";
32744
+ import { access as access2, cp, mkdir, readFile as readFile2, rm } from "node:fs/promises";
32728
32745
  import { homedir as homedir2, platform } from "node:os";
32729
32746
  import { join as join2, resolve, sep } from "node:path";
32730
32747
  import { fileURLToPath } from "node:url";
@@ -32733,9 +32750,9 @@ var PLUGIN_HOME = join2(MIRRA_HOME, "plugin");
32733
32750
  var SERVER_HOME = join2(MIRRA_HOME, "server");
32734
32751
  var DIST = fileURLToPath(new URL(".", import.meta.url));
32735
32752
  var BUNDLED_PLUGIN = resolve(DIST, "plugin");
32736
- async function installPlugin() {
32753
+ async function installPlugin(from = BUNDLED_PLUGIN) {
32737
32754
  await mkdir(PLUGIN_HOME, { recursive: true });
32738
- await cp(BUNDLED_PLUGIN, PLUGIN_HOME, { recursive: true });
32755
+ await cp(from, PLUGIN_HOME, { recursive: true });
32739
32756
  const manifest = join2(PLUGIN_HOME, "manifest.json");
32740
32757
  return { manifest, version: await installedVersion() };
32741
32758
  }
@@ -32755,11 +32772,18 @@ async function isInstalled() {
32755
32772
  return false;
32756
32773
  }
32757
32774
  }
32758
- async function installServer() {
32759
- const running = resolve(DIST, "index.js");
32760
- if (!DIST.includes(`${sep}_npx${sep}`)) return running;
32775
+ function isTransientInstall(directory) {
32776
+ return directory.includes(`${sep}_npx${sep}`);
32777
+ }
32778
+ async function installServer(from = DIST) {
32779
+ if (!isTransientInstall(from)) return resolve(from, "index.js");
32780
+ const plugin = resolve(from, "plugin");
32781
+ await rm(SERVER_HOME, { recursive: true, force: true });
32761
32782
  await mkdir(SERVER_HOME, { recursive: true });
32762
- await cp(DIST, SERVER_HOME, { recursive: true });
32783
+ await cp(from, SERVER_HOME, {
32784
+ recursive: true,
32785
+ filter: (source) => source !== plugin && !source.startsWith(plugin + sep)
32786
+ });
32763
32787
  return join2(SERVER_HOME, "index.js");
32764
32788
  }
32765
32789
  function reveal(path) {
@@ -32767,6 +32791,106 @@ function reveal(path) {
32767
32791
  spawn("open", ["-R", path], { stdio: "ignore", detached: true }).unref();
32768
32792
  }
32769
32793
 
32794
+ // src/figma-settings.ts
32795
+ var FIGMA_SETTINGS = settingsPath();
32796
+ function settingsPath() {
32797
+ if (process.platform === "darwin") {
32798
+ return join3(homedir3(), "Library/Application Support/Figma/settings.json");
32799
+ }
32800
+ if (process.platform === "win32") {
32801
+ const local = process.env["LOCALAPPDATA"] ?? join3(homedir3(), "AppData/Local");
32802
+ return join3(local, "Figma/settings.json");
32803
+ }
32804
+ return join3(homedir3(), ".config/Figma/settings.json");
32805
+ }
32806
+ var PLUGIN_ID = "mirra-figma-bridge";
32807
+ async function registerPluginWithFigma(pluginDir) {
32808
+ if (await isFigmaRunning()) {
32809
+ return { status: "skipped", reason: "Figma Desktop is running; quit it first" };
32810
+ }
32811
+ let raw;
32812
+ try {
32813
+ raw = await readFile3(FIGMA_SETTINGS, "utf8");
32814
+ } catch {
32815
+ return { status: "skipped", reason: `no settings file at ${FIGMA_SETTINGS}` };
32816
+ }
32817
+ try {
32818
+ const settings = JSON.parse(raw);
32819
+ const entries = settings["localFileExtensions"];
32820
+ if (!Array.isArray(entries)) {
32821
+ return { status: "skipped", reason: "unfamiliar settings format" };
32822
+ }
32823
+ const next = rebuild(entries, pluginDir);
32824
+ if (!next) return { status: "already" };
32825
+ settings["localFileExtensions"] = next;
32826
+ const temporary = join3(dirname2(FIGMA_SETTINGS), `.mirra-${process.pid}.json`);
32827
+ await writeFile2(temporary, JSON.stringify(settings, null, 2));
32828
+ await rename2(temporary, FIGMA_SETTINGS);
32829
+ return { status: "registered" };
32830
+ } catch (error62) {
32831
+ return { status: "skipped", reason: error62 instanceof Error ? error62.message : String(error62) };
32832
+ }
32833
+ }
32834
+ function rebuild(entries, pluginDir) {
32835
+ const mine = entries.filter((entry) => isMine(entry, entries));
32836
+ if (isCleanTrio(mine, pluginDir)) return null;
32837
+ const others = entries.filter((entry) => !mine.includes(entry));
32838
+ return [...others, ...entriesFor(pluginDir, nextId(others))];
32839
+ }
32840
+ function isCleanTrio(mine, pluginDir) {
32841
+ const manifest = mine.find((entry) => entry.fileMetadata.type === "manifest");
32842
+ if (mine.length !== 3 || !manifest || manifest.fileMetadata.type !== "manifest") return false;
32843
+ if (manifest.manifestPath !== join3(pluginDir, "manifest.json")) return false;
32844
+ const { codeFileId, uiFileIds } = manifest.fileMetadata;
32845
+ const paths = new Map(mine.map((entry) => [entry.id, entry.manifestPath]));
32846
+ return paths.get(codeFileId) === join3(pluginDir, "main.js") && uiFileIds.length === 1 && paths.get(uiFileIds[0]) === join3(pluginDir, "ui.html");
32847
+ }
32848
+ function isMine(entry, all) {
32849
+ if (entry.lastKnownPluginId === PLUGIN_ID) return true;
32850
+ if (entry.manifestPath.startsWith(`${MIRRA_HOME}${sep2}`)) return true;
32851
+ const metadata = entry.fileMetadata;
32852
+ return metadata.type !== "manifest" && !all.some((e) => e.id === metadata.manifestFileId);
32853
+ }
32854
+ async function isFigmaRunning() {
32855
+ const run = promisify(execFile);
32856
+ try {
32857
+ if (process.platform === "win32") {
32858
+ const { stdout } = await run("tasklist", ["/fi", "imagename eq Figma.exe", "/nh"]);
32859
+ return stdout.includes("Figma.exe");
32860
+ }
32861
+ await run("pgrep", ["-x", "Figma"]);
32862
+ return true;
32863
+ } catch {
32864
+ return false;
32865
+ }
32866
+ }
32867
+ function nextId(entries) {
32868
+ return entries.reduce((highest, entry) => Math.max(highest, entry.id), 0) + 1;
32869
+ }
32870
+ function entriesFor(pluginDir, first) {
32871
+ const [manifestId, codeId, uiId] = [first, first + 1, first + 2];
32872
+ return [
32873
+ {
32874
+ id: manifestId,
32875
+ manifestPath: join3(pluginDir, "manifest.json"),
32876
+ lastKnownName: "Mirra",
32877
+ lastKnownPluginId: PLUGIN_ID,
32878
+ cachedContainsWidget: false,
32879
+ fileMetadata: { type: "manifest", codeFileId: codeId, uiFileIds: [uiId] }
32880
+ },
32881
+ {
32882
+ id: codeId,
32883
+ manifestPath: join3(pluginDir, "main.js"),
32884
+ fileMetadata: { type: "code", manifestFileId: manifestId }
32885
+ },
32886
+ {
32887
+ id: uiId,
32888
+ manifestPath: join3(pluginDir, "ui.html"),
32889
+ fileMetadata: { type: "ui", manifestFileId: manifestId }
32890
+ }
32891
+ ];
32892
+ }
32893
+
32770
32894
  // ../../node_modules/.pnpm/zod@4.6.7/node_modules/zod/v3/helpers/util.js
32771
32895
  var util;
32772
32896
  (function(util2) {
@@ -40803,6 +40927,22 @@ var EMPTY_COMPLETION_RESULT = {
40803
40927
  }
40804
40928
  };
40805
40929
 
40930
+ // src/version.ts
40931
+ var SERVER_VERSION = "1.3.0";
40932
+
40933
+ // src/tools/annotations.ts
40934
+ var READS = { readOnlyHint: true, openWorldHint: false };
40935
+ var ADDS = {
40936
+ readOnlyHint: false,
40937
+ destructiveHint: false,
40938
+ openWorldHint: false
40939
+ };
40940
+ var CHANGES = {
40941
+ readOnlyHint: false,
40942
+ destructiveHint: true,
40943
+ openWorldHint: false
40944
+ };
40945
+
40806
40946
  // src/errors.ts
40807
40947
  var MirraError = class extends Error {
40808
40948
  constructor(message, code, hint) {
@@ -40833,7 +40973,12 @@ value keyed by mode name sets one value per mode. Both halves are optional.`;
40833
40973
  function registerDefine(server, bridge) {
40834
40974
  server.registerTool(
40835
40975
  "mirra_define_tokens",
40836
- { title: "Define Figma variables", description: DESCRIPTION, inputSchema: DefineParams.shape },
40976
+ {
40977
+ annotations: CHANGES,
40978
+ title: "Define Figma variables",
40979
+ description: DESCRIPTION,
40980
+ inputSchema: DefineParams.shape
40981
+ },
40837
40982
  async (params) => {
40838
40983
  try {
40839
40984
  const result = DefineResult.parse(await bridge.request("define", params));
@@ -40859,7 +41004,12 @@ The body is an async function receiving \`figma\`; use it only for what the type
40859
41004
  function registerExec(server, bridge) {
40860
41005
  server.registerTool(
40861
41006
  "mirra_exec",
40862
- { title: "Run Plugin API code", description: DESCRIPTION2, inputSchema: ExecParams.shape },
41007
+ {
41008
+ annotations: CHANGES,
41009
+ title: "Run Plugin API code",
41010
+ description: DESCRIPTION2,
41011
+ inputSchema: ExecParams.shape
41012
+ },
40863
41013
  async (params) => {
40864
41014
  try {
40865
41015
  const result = ExecResult.parse(await bridge.request("exec", params));
@@ -40872,11 +41022,11 @@ function registerExec(server, bridge) {
40872
41022
  }
40873
41023
 
40874
41024
  // src/tools/export.ts
40875
- import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
41025
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "node:fs/promises";
40876
41026
  import { resolve as resolve3 } from "node:path";
40877
41027
 
40878
41028
  // src/tools/paths.ts
40879
- import { resolve as resolve2, sep as sep2 } from "node:path";
41029
+ import { resolve as resolve2, sep as sep3 } from "node:path";
40880
41030
  function rootDirectory() {
40881
41031
  const configured = process.env["MIRRA_ROOT"];
40882
41032
  return configured ? resolve2(configured) : process.cwd();
@@ -40885,7 +41035,7 @@ function resolveInRoot(path) {
40885
41035
  const root = rootDirectory();
40886
41036
  const full = resolve2(root, path);
40887
41037
  if (!process.env["MIRRA_ROOT"]) return full;
40888
- if (full !== root && !full.startsWith(root + sep2)) {
41038
+ if (full !== root && !full.startsWith(root + sep3)) {
40889
41039
  throw new MirraError(
40890
41040
  `${path} is outside MIRRA_ROOT`,
40891
41041
  "outside_root",
@@ -40902,6 +41052,7 @@ function registerExport(server, bridge) {
40902
41052
  server.registerTool(
40903
41053
  "mirra_export_assets",
40904
41054
  {
41055
+ annotations: CHANGES,
40905
41056
  title: "Export Figma layers to files",
40906
41057
  description: DESCRIPTION3,
40907
41058
  inputSchema: ExportParams.shape
@@ -40914,7 +41065,7 @@ function registerExport(server, bridge) {
40914
41065
  const written = [];
40915
41066
  for (const asset2 of result.assets) {
40916
41067
  const file2 = resolve3(directory, `${slug(asset2.name)}.${asset2.extension}`);
40917
- await writeFile2(file2, Buffer.from(asset2.data, "base64"));
41068
+ await writeFile3(file2, Buffer.from(asset2.data, "base64"));
40918
41069
  written.push(file2);
40919
41070
  }
40920
41071
  return text([`wrote ${written.length} file(s):`, ...written]);
@@ -40937,7 +41088,12 @@ tool works on the current page, so start here when the layer you want is elsewhe
40937
41088
  function registerFind(server, bridge) {
40938
41089
  server.registerTool(
40939
41090
  "mirra_find",
40940
- { title: "Find Figma layers", description: FIND, inputSchema: FindParams.shape },
41091
+ {
41092
+ annotations: READS,
41093
+ title: "Find Figma layers",
41094
+ description: FIND,
41095
+ inputSchema: FindParams.shape
41096
+ },
40941
41097
  async (params) => {
40942
41098
  try {
40943
41099
  const result = FindResult.parse(await bridge.request("find", params));
@@ -40955,7 +41111,13 @@ function registerFind(server, bridge) {
40955
41111
  );
40956
41112
  server.registerTool(
40957
41113
  "mirra_pages",
40958
- { title: "List or switch Figma pages", description: PAGES, inputSchema: PagesParams.shape },
41114
+ {
41115
+ // Not read-only: it can add a page and change which one is current.
41116
+ annotations: ADDS,
41117
+ title: "List, add or switch Figma pages",
41118
+ description: PAGES,
41119
+ inputSchema: PagesParams.shape
41120
+ },
40959
41121
  async (params) => {
40960
41122
  try {
40961
41123
  const result = PagesResult.parse(await bridge.request("pages", params));
@@ -40970,7 +41132,7 @@ function registerFind(server, bridge) {
40970
41132
  }
40971
41133
 
40972
41134
  // src/tools/assets.ts
40973
- import { readFile as readFile3 } from "node:fs/promises";
41135
+ import { readFile as readFile4 } from "node:fs/promises";
40974
41136
  import { extname } from "node:path";
40975
41137
  var MIME = /* @__PURE__ */ new Map([
40976
41138
  [".png", "image/png"],
@@ -41000,13 +41162,13 @@ function isPath(src) {
41000
41162
  return true;
41001
41163
  }
41002
41164
  async function readSvg(path) {
41003
- return readFile3(resolveInRoot(path), "utf8");
41165
+ return readFile4(resolveInRoot(path), "utf8");
41004
41166
  }
41005
41167
  async function readImage(path) {
41006
41168
  const file2 = resolveInRoot(path);
41007
41169
  const type = MIME.get(extname(file2).toLowerCase());
41008
41170
  if (!type) throw new Error(`Unsupported image type: ${extname(file2) || file2}`);
41009
- return `data:${type};base64,${(await readFile3(file2)).toString("base64")}`;
41171
+ return `data:${type};base64,${(await readFile4(file2)).toString("base64")}`;
41010
41172
  }
41011
41173
 
41012
41174
  // src/tools/patch.ts
@@ -41018,6 +41180,7 @@ function registerPatch(server, bridge) {
41018
41180
  server.registerTool(
41019
41181
  "mirra_patch",
41020
41182
  {
41183
+ annotations: CHANGES,
41021
41184
  title: "Patch existing Figma layers",
41022
41185
  description: DESCRIPTION4,
41023
41186
  inputSchema: PatchParams.shape
@@ -41042,17 +41205,25 @@ function registerPing(server, bridge) {
41042
41205
  server.registerTool(
41043
41206
  "mirra_ping",
41044
41207
  {
41208
+ annotations: READS,
41045
41209
  title: "Ping the Figma plugin",
41046
41210
  description: "Check that the Mirra plugin is connected, and report the open file, current page and selection size."
41047
41211
  },
41048
41212
  async () => {
41049
41213
  try {
41050
41214
  const result = PingResult.parse(await bridge.request("ping"));
41215
+ const drifted = result.pluginVersion !== SERVER_VERSION;
41051
41216
  return text([
41052
41217
  `file: ${result.fileName}${result.fileKey ? ` (${result.fileKey})` : ""}`,
41053
41218
  `page: ${result.currentPage}`,
41054
41219
  `selection: ${result.selectionCount} node(s)`,
41055
- `port: ${bridge.status.port}`
41220
+ `port: ${bridge.status.port}`,
41221
+ `server ${SERVER_VERSION}, plugin ${result.pluginVersion}`,
41222
+ ...drifted ? [
41223
+ "",
41224
+ "The two halves are different versions. Run `npx @ttsalpha/mirra init`, then run",
41225
+ "the plugin again in Figma so both come from the same release."
41226
+ ] : []
41056
41227
  ]);
41057
41228
  } catch (error62) {
41058
41229
  return text([describeError(error62)], true);
@@ -41064,11 +41235,13 @@ function registerPing(server, bridge) {
41064
41235
  // src/tools/read.ts
41065
41236
  var DESCRIPTION5 = `Read layers out of the open Figma file as the same tree mirra_render takes,
41066
41237
  so a design can be edited or turned into code without learning a second format.
41067
- Reads the current selection unless a nodeId is given.`;
41238
+ Reads the current selection unless a nodeId is given, and at most 200 nodes unless limit
41239
+ says otherwise, so a large design cannot fill the context in one call.`;
41068
41240
  function registerRead(server, bridge) {
41069
41241
  server.registerTool(
41070
41242
  "mirra_read",
41071
41243
  {
41244
+ annotations: READS,
41072
41245
  title: "Read Figma layers as a UI tree",
41073
41246
  description: DESCRIPTION5,
41074
41247
  inputSchema: ReadParams.shape
@@ -41076,7 +41249,9 @@ function registerRead(server, bridge) {
41076
41249
  async (params) => {
41077
41250
  try {
41078
41251
  const result = ReadResult.parse(await bridge.request("read", params));
41079
- const note = result.skipped ? [`${result.skipped} node(s) left out: unsupported type, or past the depth limit`] : [];
41252
+ const note = result.skipped ? [
41253
+ `${result.skipped} node(s) left out: unsupported type, or past the depth or node limit`
41254
+ ] : [];
41080
41255
  return text([...note, JSON.stringify(result.nodes, null, 2)]);
41081
41256
  } catch (error62) {
41082
41257
  return text([describeError(error62)], true);
@@ -41096,6 +41271,7 @@ function registerRender(server, bridge) {
41096
41271
  server.registerTool(
41097
41272
  "mirra_render",
41098
41273
  {
41274
+ annotations: ADDS,
41099
41275
  title: "Render a UI tree into Figma",
41100
41276
  description: DESCRIPTION6,
41101
41277
  inputSchema: RenderParams.shape
@@ -41119,11 +41295,13 @@ function registerRender(server, bridge) {
41119
41295
 
41120
41296
  // src/tools/screenshot.ts
41121
41297
  var DESCRIPTION7 = `Export a layer as a PNG and return the image, so the rendered result can be
41122
- checked by eye and corrected with mirra_patch. Defaults to the current selection.`;
41298
+ checked by eye and corrected with mirra_patch. Defaults to the current selection. The scale
41299
+ is lowered when it has to be, so the longest edge stays within maxEdge, 1200px by default.`;
41123
41300
  function registerScreenshot(server, bridge) {
41124
41301
  server.registerTool(
41125
41302
  "mirra_screenshot",
41126
41303
  {
41304
+ annotations: READS,
41127
41305
  title: "Screenshot a Figma layer",
41128
41306
  description: DESCRIPTION7,
41129
41307
  inputSchema: ScreenshotParams.shape
@@ -41135,7 +41313,7 @@ function registerScreenshot(server, bridge) {
41135
41313
  content: [
41136
41314
  {
41137
41315
  type: "text",
41138
- text: `${shot.name} (${shot.width}x${shot.height}, ${shot.id})`
41316
+ text: `${shot.name} (${shot.width}x${shot.height}, ${shot.id})` + (shot.scale === 1 ? "" : ` at ${shot.scale.toFixed(2)}x`)
41139
41317
  },
41140
41318
  { type: "image", data: shot.png, mimeType: "image/png" }
41141
41319
  ]
@@ -41154,7 +41332,7 @@ bound to the design system instead of to a hardcoded value.`;
41154
41332
  function registerTokens(server, bridge) {
41155
41333
  server.registerTool(
41156
41334
  "mirra_tokens",
41157
- { title: "List design tokens", description: DESCRIPTION8 },
41335
+ { annotations: READS, title: "List design tokens", description: DESCRIPTION8 },
41158
41336
  async () => {
41159
41337
  try {
41160
41338
  const result = TokensResult.parse(await bridge.request("tokens"));
@@ -41167,9 +41345,8 @@ function registerTokens(server, bridge) {
41167
41345
  }
41168
41346
 
41169
41347
  // src/mcp.ts
41170
- var SERVER_VERSION = "1.1.0";
41171
41348
  function createMcpServer(bridge) {
41172
- const server = new McpServer({ name: "mirra", version: SERVER_VERSION });
41349
+ const server = new McpServer({ name: "Mirra", version: SERVER_VERSION });
41173
41350
  registerPing(server, bridge);
41174
41351
  registerRender(server, bridge);
41175
41352
  registerRead(server, bridge);
@@ -41203,6 +41380,15 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
41203
41380
  // src/transport.ts
41204
41381
  var HOSTS = ["127.0.0.1", "::1"];
41205
41382
  var DEFAULT_TIMEOUT_MS = 3e4;
41383
+ var TIMEOUT_MS = {
41384
+ ping: 5e3,
41385
+ pages: 1e4,
41386
+ render: 12e4,
41387
+ patch: 12e4,
41388
+ export: 12e4,
41389
+ define: 6e4,
41390
+ exec: 12e4
41391
+ };
41206
41392
  function originAllowed(origin) {
41207
41393
  if (!origin || origin === "null") return true;
41208
41394
  if (process.env["MIRRA_ALLOW_ORIGIN"] === origin) return true;
@@ -41213,16 +41399,22 @@ function originAllowed(origin) {
41213
41399
  return false;
41214
41400
  }
41215
41401
  }
41402
+ var UPDATE_HINT = "Run `npx @ttsalpha/mirra init` to install both halves from the same version, then run the plugin again in Figma.";
41216
41403
  var OPEN_PLUGIN_HINT = "Open the file in Figma Desktop, then Plugins -> Development -> Mirra, and keep the panel open.";
41217
41404
  var PluginBridge = class {
41218
41405
  #servers = [];
41219
41406
  #socket = null;
41220
41407
  #hello = null;
41408
+ #protocolMismatch = null;
41221
41409
  #port = null;
41222
41410
  #pending = /* @__PURE__ */ new Map();
41223
41411
  get status() {
41224
41412
  return { port: this.#port, connected: this.#socket !== null, hello: this.#hello };
41225
41413
  }
41414
+ /** Set while a connected plugin speaks a protocol this server does not. */
41415
+ get protocolMismatch() {
41416
+ return this.#protocolMismatch;
41417
+ }
41226
41418
  async listen(preferredPort2) {
41227
41419
  const first = preferredPort2 ?? DEFAULT_PORT;
41228
41420
  const last = preferredPort2 ?? PORT_RANGE_END;
@@ -41265,11 +41457,14 @@ var PluginBridge = class {
41265
41457
  this.#servers = [];
41266
41458
  this.#port = null;
41267
41459
  }
41268
- async request(op, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
41460
+ async request(op, params, timeoutMs = TIMEOUT_MS[op] ?? DEFAULT_TIMEOUT_MS) {
41269
41461
  const socket = this.#socket;
41270
41462
  if (!socket) {
41271
41463
  throw new MirraError("No Figma plugin is connected", "plugin_offline", OPEN_PLUGIN_HINT);
41272
41464
  }
41465
+ if (this.#protocolMismatch && op !== "ping") {
41466
+ throw new MirraError(this.#protocolMismatch, "protocol_mismatch", UPDATE_HINT);
41467
+ }
41273
41468
  const id = randomUUID();
41274
41469
  const envelope = { id, op, params };
41275
41470
  return new Promise((resolve4, reject) => {
@@ -41313,11 +41508,8 @@ var PluginBridge = class {
41313
41508
  if ("type" in data) {
41314
41509
  if (data.type === "log") return log(`[plugin:${data.level}] ${data.message}`);
41315
41510
  this.#hello = data;
41316
- if (data.protocolVersion !== PROTOCOL_VERSION) {
41317
- log(
41318
- `protocol mismatch: plugin speaks v${data.protocolVersion}, server speaks v${PROTOCOL_VERSION}. Rebuild and re-import the plugin.`
41319
- );
41320
- }
41511
+ this.#protocolMismatch = data.protocolVersion === PROTOCOL_VERSION ? null : `The plugin speaks protocol v${data.protocolVersion}, this server speaks v${PROTOCOL_VERSION}`;
41512
+ if (this.#protocolMismatch) log(this.#protocolMismatch);
41321
41513
  return log(`plugin connected: ${data.fileName} (plugin v${data.pluginVersion})`);
41322
41514
  }
41323
41515
  const pending = this.#pending.get(data.id);
@@ -41377,7 +41569,7 @@ var USAGE = `mirra <command>
41377
41569
  doctor Bind the plugin port and wait for the Figma plugin to connect
41378
41570
 
41379
41571
  Flags:
41380
- --no-reveal init: do not open the folder in Finder
41572
+ --no-reveal init: do not open the folder in Finder on a manual import
41381
41573
  --no-config init: do not touch any client's configuration
41382
41574
 
41383
41575
  Environment:
@@ -41433,14 +41625,26 @@ async function init() {
41433
41625
  }
41434
41626
  lines.push("", "A client that was already running has to be restarted to see the tools.", "");
41435
41627
  }
41436
- lines.push(
41437
- "Then, in Figma Desktop:",
41438
- " 1. Plugins -> Development -> Import plugin from manifest...",
41439
- ` and choose ${manifest}`,
41440
- " 2. Open a file, run Mirra, and leave the panel open",
41441
- " 3. Ask the agent for mirra_ping; the panel badge should turn green"
41442
- );
41443
- if (!process.argv.includes("--no-reveal")) reveal(manifest);
41628
+ const figma = await registerPluginWithFigma(PLUGIN_HOME);
41629
+ const manual = figma.status === "skipped";
41630
+ if (manual) {
41631
+ lines.push(
41632
+ `Could not register the plugin with Figma Desktop: ${figma.reason}`,
41633
+ "Then, in Figma Desktop:",
41634
+ " 1. Plugins -> Development -> Import plugin from manifest...",
41635
+ ` and choose ${manifest}`,
41636
+ " 2. Open a file, run Mirra, and leave the panel open",
41637
+ " 3. Ask the agent for mirra_ping; the panel badge should turn green"
41638
+ );
41639
+ } else {
41640
+ lines.push(
41641
+ figma.status === "registered" ? "Registered the plugin with Figma Desktop." : "Figma Desktop already knows the plugin.",
41642
+ "Then, in Figma Desktop:",
41643
+ " 1. Open a file, run Plugins -> Development -> Mirra, and leave the panel open",
41644
+ " 2. Ask the agent for mirra_ping; the panel badge should turn green"
41645
+ );
41646
+ }
41647
+ if (manual && !process.argv.includes("--no-reveal")) reveal(manifest);
41444
41648
  console.log(lines.join("\n"));
41445
41649
  }
41446
41650
  async function doctor() {
@@ -949,30 +949,39 @@
949
949
  return node;
950
950
  }
951
951
 
952
+ // src/version.ts
953
+ var PLUGIN_VERSION = "1.3.0";
954
+
952
955
  // src/ops/ping.ts
953
956
  function ping() {
954
957
  return {
955
958
  fileName: figma.root.name,
956
959
  fileKey: readFileKey(),
957
960
  currentPage: figma.currentPage.name,
958
- selectionCount: figma.currentPage.selection.length
961
+ selectionCount: figma.currentPage.selection.length,
962
+ pluginVersion: PLUGIN_VERSION
959
963
  };
960
964
  }
961
965
 
962
966
  // src/figma/read.ts
963
- function readNode(node, depth, names = EMPTY_NAMES) {
964
- const skipped = { count: 0 };
965
- const tree = toSpec(node, depth, skipped, names);
966
- return { tree, skipped: skipped.count };
967
- }
968
- function toSpec(node, depth, skipped, names) {
967
+ function readNode(node, depth, names = EMPTY_NAMES, limit = Number.MAX_SAFE_INTEGER) {
968
+ const budget = { count: 0, left: limit };
969
+ const tree = toSpec(node, depth, budget, names);
970
+ return { tree, skipped: budget.count };
971
+ }
972
+ function toSpec(node, depth, budget, names) {
973
+ if (budget.left <= 0) {
974
+ budget.count += 1;
975
+ return null;
976
+ }
977
+ budget.left -= 1;
969
978
  const svg = node.getPluginData(SVG_FIELD);
970
979
  if (svg) return { type: "svg", ...common(node), src: svg };
971
980
  const src = node.getPluginData(SRC_FIELD);
972
981
  if (src) return { type: "image", ...common(node), ...painted(node, names), src };
973
982
  switch (node.type) {
974
983
  case "FRAME":
975
- return frameSpec(node, depth, skipped, names);
984
+ return frameSpec(node, depth, budget, names);
976
985
  case "TEXT":
977
986
  return textSpec(node, names);
978
987
  case "RECTANGLE":
@@ -982,19 +991,19 @@
982
991
  case "INSTANCE":
983
992
  return instanceSpec(node);
984
993
  default:
985
- skipped.count += 1;
994
+ budget.count += 1;
986
995
  return null;
987
996
  }
988
997
  }
989
- function frameSpec(node, depth, skipped, names) {
998
+ function frameSpec(node, depth, budget, names) {
990
999
  const children = [];
991
1000
  if (depth > 0) {
992
1001
  for (const child of node.children) {
993
- const spec = toSpec(child, depth - 1, skipped, names);
1002
+ const spec = toSpec(child, depth - 1, budget, names);
994
1003
  if (spec) children.push(spec);
995
1004
  }
996
1005
  } else if (node.children.length) {
997
- skipped.count += node.children.length;
1006
+ budget.count += node.children.length;
998
1007
  }
999
1008
  return {
1000
1009
  type: "frame",
@@ -1151,6 +1160,7 @@
1151
1160
 
1152
1161
  // src/ops/read.ts
1153
1162
  var DEFAULT_DEPTH = 6;
1163
+ var DEFAULT_LIMIT2 = 200;
1154
1164
  async function read(params) {
1155
1165
  const targets = params.nodeId ? [await resolve(params.nodeId)] : [...figma.currentPage.selection];
1156
1166
  if (!targets.length) {
@@ -1160,10 +1170,14 @@
1160
1170
  const nodes = [];
1161
1171
  let skipped = 0;
1162
1172
  for (const target of targets) {
1163
- const outcome = readNode(target, params.depth ?? DEFAULT_DEPTH, names);
1173
+ const outcome = readNode(
1174
+ target,
1175
+ params.depth ?? DEFAULT_DEPTH,
1176
+ names,
1177
+ params.limit ?? DEFAULT_LIMIT2
1178
+ );
1164
1179
  skipped += outcome.skipped;
1165
1180
  if (outcome.tree) nodes.push({ id: target.id, tree: outcome.tree });
1166
- else skipped += 1;
1167
1181
  }
1168
1182
  return { nodes, skipped };
1169
1183
  }
@@ -1211,21 +1225,29 @@
1211
1225
  }
1212
1226
 
1213
1227
  // src/ops/screenshot.ts
1228
+ var DEFAULT_MAX_EDGE = 1200;
1229
+ function fittingScale(wanted, box, maxEdge) {
1230
+ const longest = Math.max(box.width, box.height) * wanted;
1231
+ if (longest <= maxEdge) return wanted;
1232
+ return Math.max(0.01, wanted * maxEdge / longest);
1233
+ }
1214
1234
  async function screenshot(params) {
1215
1235
  const node = params.target ? await new NodeIndex().resolve(params.target) : figma.currentPage.selection[0];
1216
1236
  if (!node) {
1217
1237
  throw new Error("Nothing is selected: select a layer in Figma, or pass a target");
1218
1238
  }
1239
+ const box = node;
1240
+ const scale = fittingScale(params.scale ?? 1, box, params.maxEdge ?? DEFAULT_MAX_EDGE);
1219
1241
  const bytes = await node.exportAsync({
1220
1242
  format: "PNG",
1221
- constraint: { type: "SCALE", value: params.scale ?? 1 }
1243
+ constraint: { type: "SCALE", value: scale }
1222
1244
  });
1223
- const box = node;
1224
1245
  return {
1225
1246
  id: node.id,
1226
1247
  name: node.name,
1227
- width: Math.round(box.width),
1228
- height: Math.round(box.height),
1248
+ width: Math.round(box.width * scale),
1249
+ height: Math.round(box.height * scale),
1250
+ scale,
1229
1251
  png: figma.base64Encode(bytes)
1230
1252
  };
1231
1253
  }
@@ -1269,7 +1291,6 @@
1269
1291
  }
1270
1292
 
1271
1293
  // src/main.ts
1272
- var PLUGIN_VERSION = "1.1.0";
1273
1294
  figma.showUI(__html__, { width: 300, height: 300, themeColors: true });
1274
1295
  var queue = Promise.resolve();
1275
1296
  figma.ui.onmessage = (message2) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsalpha/mirra",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Two-way MCP server for Figma: read a design and render one back through the Figma Plugin API",
5
5
  "keywords": [
6
6
  "figma",