@ttsalpha/mirra 1.1.0 → 1.2.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.
@@ -104,7 +108,7 @@ the tool when `MIRRA_ALLOW_EXEC` is set, and the plugin only runs it while **All
104
108
  execution** is ticked in its panel. The tick resets every time the plugin runs.
105
109
 
106
110
  ```sh
107
- claude mcp add -s user mirra-exec -e MIRRA_ALLOW_EXEC=1 -- npx -y @ttsalpha/mirra serve
111
+ claude mcp add -s user Mirra-exec -e MIRRA_ALLOW_EXEC=1 -- npx -y @ttsalpha/mirra serve
108
112
  ```
109
113
 
110
114
  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"],
@@ -32724,7 +32734,7 @@ function claudeCode(node2, entryPoint) {
32724
32734
 
32725
32735
  // src/install.ts
32726
32736
  import { spawn } from "node:child_process";
32727
- import { access as access2, cp, mkdir, readFile as readFile2 } from "node:fs/promises";
32737
+ import { access as access2, cp, mkdir, readFile as readFile2, rm } from "node:fs/promises";
32728
32738
  import { homedir as homedir2, platform } from "node:os";
32729
32739
  import { join as join2, resolve, sep } from "node:path";
32730
32740
  import { fileURLToPath } from "node:url";
@@ -32733,9 +32743,9 @@ var PLUGIN_HOME = join2(MIRRA_HOME, "plugin");
32733
32743
  var SERVER_HOME = join2(MIRRA_HOME, "server");
32734
32744
  var DIST = fileURLToPath(new URL(".", import.meta.url));
32735
32745
  var BUNDLED_PLUGIN = resolve(DIST, "plugin");
32736
- async function installPlugin() {
32746
+ async function installPlugin(from = BUNDLED_PLUGIN) {
32737
32747
  await mkdir(PLUGIN_HOME, { recursive: true });
32738
- await cp(BUNDLED_PLUGIN, PLUGIN_HOME, { recursive: true });
32748
+ await cp(from, PLUGIN_HOME, { recursive: true });
32739
32749
  const manifest = join2(PLUGIN_HOME, "manifest.json");
32740
32750
  return { manifest, version: await installedVersion() };
32741
32751
  }
@@ -32755,11 +32765,18 @@ async function isInstalled() {
32755
32765
  return false;
32756
32766
  }
32757
32767
  }
32758
- async function installServer() {
32759
- const running = resolve(DIST, "index.js");
32760
- if (!DIST.includes(`${sep}_npx${sep}`)) return running;
32768
+ function isTransientInstall(directory) {
32769
+ return directory.includes(`${sep}_npx${sep}`);
32770
+ }
32771
+ async function installServer(from = DIST) {
32772
+ if (!isTransientInstall(from)) return resolve(from, "index.js");
32773
+ const plugin = resolve(from, "plugin");
32774
+ await rm(SERVER_HOME, { recursive: true, force: true });
32761
32775
  await mkdir(SERVER_HOME, { recursive: true });
32762
- await cp(DIST, SERVER_HOME, { recursive: true });
32776
+ await cp(from, SERVER_HOME, {
32777
+ recursive: true,
32778
+ filter: (source) => source !== plugin && !source.startsWith(plugin + sep)
32779
+ });
32763
32780
  return join2(SERVER_HOME, "index.js");
32764
32781
  }
32765
32782
  function reveal(path) {
@@ -40803,6 +40820,9 @@ var EMPTY_COMPLETION_RESULT = {
40803
40820
  }
40804
40821
  };
40805
40822
 
40823
+ // src/version.ts
40824
+ var SERVER_VERSION = "1.2.0";
40825
+
40806
40826
  // src/errors.ts
40807
40827
  var MirraError = class extends Error {
40808
40828
  constructor(message, code, hint) {
@@ -41048,11 +41068,18 @@ function registerPing(server, bridge) {
41048
41068
  async () => {
41049
41069
  try {
41050
41070
  const result = PingResult.parse(await bridge.request("ping"));
41071
+ const drifted = result.pluginVersion !== SERVER_VERSION;
41051
41072
  return text([
41052
41073
  `file: ${result.fileName}${result.fileKey ? ` (${result.fileKey})` : ""}`,
41053
41074
  `page: ${result.currentPage}`,
41054
41075
  `selection: ${result.selectionCount} node(s)`,
41055
- `port: ${bridge.status.port}`
41076
+ `port: ${bridge.status.port}`,
41077
+ `server ${SERVER_VERSION}, plugin ${result.pluginVersion}`,
41078
+ ...drifted ? [
41079
+ "",
41080
+ "The two halves are different versions. Run `npx @ttsalpha/mirra init`, then run",
41081
+ "the plugin again in Figma so both come from the same release."
41082
+ ] : []
41056
41083
  ]);
41057
41084
  } catch (error62) {
41058
41085
  return text([describeError(error62)], true);
@@ -41064,7 +41091,8 @@ function registerPing(server, bridge) {
41064
41091
  // src/tools/read.ts
41065
41092
  var DESCRIPTION5 = `Read layers out of the open Figma file as the same tree mirra_render takes,
41066
41093
  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.`;
41094
+ Reads the current selection unless a nodeId is given, and at most 200 nodes unless limit
41095
+ says otherwise, so a large design cannot fill the context in one call.`;
41068
41096
  function registerRead(server, bridge) {
41069
41097
  server.registerTool(
41070
41098
  "mirra_read",
@@ -41076,7 +41104,9 @@ function registerRead(server, bridge) {
41076
41104
  async (params) => {
41077
41105
  try {
41078
41106
  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`] : [];
41107
+ const note = result.skipped ? [
41108
+ `${result.skipped} node(s) left out: unsupported type, or past the depth or node limit`
41109
+ ] : [];
41080
41110
  return text([...note, JSON.stringify(result.nodes, null, 2)]);
41081
41111
  } catch (error62) {
41082
41112
  return text([describeError(error62)], true);
@@ -41119,7 +41149,8 @@ function registerRender(server, bridge) {
41119
41149
 
41120
41150
  // src/tools/screenshot.ts
41121
41151
  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.`;
41152
+ checked by eye and corrected with mirra_patch. Defaults to the current selection. The scale
41153
+ is lowered when it has to be, so the longest edge stays within maxEdge, 1200px by default.`;
41123
41154
  function registerScreenshot(server, bridge) {
41124
41155
  server.registerTool(
41125
41156
  "mirra_screenshot",
@@ -41167,9 +41198,8 @@ function registerTokens(server, bridge) {
41167
41198
  }
41168
41199
 
41169
41200
  // src/mcp.ts
41170
- var SERVER_VERSION = "1.1.0";
41171
41201
  function createMcpServer(bridge) {
41172
- const server = new McpServer({ name: "mirra", version: SERVER_VERSION });
41202
+ const server = new McpServer({ name: "Mirra", version: SERVER_VERSION });
41173
41203
  registerPing(server, bridge);
41174
41204
  registerRender(server, bridge);
41175
41205
  registerRead(server, bridge);
@@ -41203,6 +41233,15 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
41203
41233
  // src/transport.ts
41204
41234
  var HOSTS = ["127.0.0.1", "::1"];
41205
41235
  var DEFAULT_TIMEOUT_MS = 3e4;
41236
+ var TIMEOUT_MS = {
41237
+ ping: 5e3,
41238
+ pages: 1e4,
41239
+ render: 12e4,
41240
+ patch: 12e4,
41241
+ export: 12e4,
41242
+ define: 6e4,
41243
+ exec: 12e4
41244
+ };
41206
41245
  function originAllowed(origin) {
41207
41246
  if (!origin || origin === "null") return true;
41208
41247
  if (process.env["MIRRA_ALLOW_ORIGIN"] === origin) return true;
@@ -41213,16 +41252,22 @@ function originAllowed(origin) {
41213
41252
  return false;
41214
41253
  }
41215
41254
  }
41255
+ var UPDATE_HINT = "Run `npx @ttsalpha/mirra init` to install both halves from the same version, then run the plugin again in Figma.";
41216
41256
  var OPEN_PLUGIN_HINT = "Open the file in Figma Desktop, then Plugins -> Development -> Mirra, and keep the panel open.";
41217
41257
  var PluginBridge = class {
41218
41258
  #servers = [];
41219
41259
  #socket = null;
41220
41260
  #hello = null;
41261
+ #protocolMismatch = null;
41221
41262
  #port = null;
41222
41263
  #pending = /* @__PURE__ */ new Map();
41223
41264
  get status() {
41224
41265
  return { port: this.#port, connected: this.#socket !== null, hello: this.#hello };
41225
41266
  }
41267
+ /** Set while a connected plugin speaks a protocol this server does not. */
41268
+ get protocolMismatch() {
41269
+ return this.#protocolMismatch;
41270
+ }
41226
41271
  async listen(preferredPort2) {
41227
41272
  const first = preferredPort2 ?? DEFAULT_PORT;
41228
41273
  const last = preferredPort2 ?? PORT_RANGE_END;
@@ -41265,11 +41310,14 @@ var PluginBridge = class {
41265
41310
  this.#servers = [];
41266
41311
  this.#port = null;
41267
41312
  }
41268
- async request(op, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
41313
+ async request(op, params, timeoutMs = TIMEOUT_MS[op] ?? DEFAULT_TIMEOUT_MS) {
41269
41314
  const socket = this.#socket;
41270
41315
  if (!socket) {
41271
41316
  throw new MirraError("No Figma plugin is connected", "plugin_offline", OPEN_PLUGIN_HINT);
41272
41317
  }
41318
+ if (this.#protocolMismatch && op !== "ping") {
41319
+ throw new MirraError(this.#protocolMismatch, "protocol_mismatch", UPDATE_HINT);
41320
+ }
41273
41321
  const id = randomUUID();
41274
41322
  const envelope = { id, op, params };
41275
41323
  return new Promise((resolve4, reject) => {
@@ -41313,11 +41361,8 @@ var PluginBridge = class {
41313
41361
  if ("type" in data) {
41314
41362
  if (data.type === "log") return log(`[plugin:${data.level}] ${data.message}`);
41315
41363
  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
- }
41364
+ this.#protocolMismatch = data.protocolVersion === PROTOCOL_VERSION ? null : `The plugin speaks protocol v${data.protocolVersion}, this server speaks v${PROTOCOL_VERSION}`;
41365
+ if (this.#protocolMismatch) log(this.#protocolMismatch);
41321
41366
  return log(`plugin connected: ${data.fileName} (plugin v${data.pluginVersion})`);
41322
41367
  }
41323
41368
  const pending = this.#pending.get(data.id);
@@ -949,30 +949,39 @@
949
949
  return node;
950
950
  }
951
951
 
952
+ // src/version.ts
953
+ var PLUGIN_VERSION = "1.2.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.2.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",