@ttsalpha/mirra 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/index.js +1186 -0
- package/dist/plugin/main.js +1291 -0
- package/dist/plugin/manifest.json +58 -0
- package/dist/plugin/ui.html +282 -0
- package/package.json +55 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// ../schema/dist/protocol.js
|
|
7
|
+
import { z as z2 } from "zod";
|
|
8
|
+
|
|
9
|
+
// ../schema/dist/constants.js
|
|
10
|
+
var PROTOCOL_VERSION = 1;
|
|
11
|
+
var DEFAULT_PORT = 51730;
|
|
12
|
+
var PORT_RANGE_END = 51739;
|
|
13
|
+
|
|
14
|
+
// ../schema/dist/tree.js
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
var Hex = z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/);
|
|
17
|
+
var SolidFill = z.object({
|
|
18
|
+
type: z.literal("solid"),
|
|
19
|
+
color: Hex,
|
|
20
|
+
opacity: z.number().min(0).max(1).optional()
|
|
21
|
+
});
|
|
22
|
+
var GradientStop = z.object({ at: z.number().min(0).max(1), color: Hex });
|
|
23
|
+
var GradientFill = z.object({
|
|
24
|
+
type: z.literal("gradient"),
|
|
25
|
+
kind: z.enum(["linear", "radial"]).optional(),
|
|
26
|
+
/** Degrees clockwise from left-to-right. 90 points downwards. */
|
|
27
|
+
angle: z.number().optional(),
|
|
28
|
+
stops: z.array(GradientStop).min(2)
|
|
29
|
+
});
|
|
30
|
+
var TokenRef = z.string().regex(/^\$[\w][\w .\/-]*$/);
|
|
31
|
+
var Fill = z.union([Hex, TokenRef, SolidFill, GradientFill]);
|
|
32
|
+
var Stroke = z.object({
|
|
33
|
+
color: Hex,
|
|
34
|
+
width: z.number().positive().optional(),
|
|
35
|
+
align: z.enum(["inside", "outside", "center"]).optional()
|
|
36
|
+
});
|
|
37
|
+
var ShadowEffect = z.object({
|
|
38
|
+
type: z.enum(["shadow", "inner-shadow"]),
|
|
39
|
+
x: z.number().optional(),
|
|
40
|
+
y: z.number().optional(),
|
|
41
|
+
blur: z.number().optional(),
|
|
42
|
+
spread: z.number().optional(),
|
|
43
|
+
color: Hex.optional()
|
|
44
|
+
});
|
|
45
|
+
var BlurEffect = z.object({
|
|
46
|
+
type: z.enum(["layer-blur", "background-blur"]),
|
|
47
|
+
radius: z.number().nonnegative()
|
|
48
|
+
});
|
|
49
|
+
var Effect = z.union([ShadowEffect, BlurEffect]);
|
|
50
|
+
var Sizing = z.union([z.number().nonnegative(), z.literal("fill"), z.literal("hug")]);
|
|
51
|
+
var Padding = z.union([
|
|
52
|
+
z.number(),
|
|
53
|
+
z.tuple([z.number(), z.number()]),
|
|
54
|
+
z.tuple([z.number(), z.number(), z.number(), z.number()])
|
|
55
|
+
]);
|
|
56
|
+
var Layout = z.object({
|
|
57
|
+
dir: z.enum(["horizontal", "vertical"]),
|
|
58
|
+
gap: z.number().optional(),
|
|
59
|
+
pad: Padding.optional(),
|
|
60
|
+
/** Along the layout direction. */
|
|
61
|
+
justify: z.enum(["start", "center", "end", "space-between", "space-around", "space-evenly"]).optional(),
|
|
62
|
+
/** Across the layout direction. */
|
|
63
|
+
align: z.enum(["start", "center", "end", "baseline"]).optional(),
|
|
64
|
+
wrap: z.boolean().optional()
|
|
65
|
+
});
|
|
66
|
+
var Radius = z.union([
|
|
67
|
+
z.number().nonnegative(),
|
|
68
|
+
z.tuple([z.number(), z.number(), z.number(), z.number()])
|
|
69
|
+
]);
|
|
70
|
+
var ConstraintSide = z.enum(["min", "center", "max", "stretch", "scale"]);
|
|
71
|
+
var base = {
|
|
72
|
+
/** Caller-chosen, stable across renders: `patch` and `read` address nodes by it. */
|
|
73
|
+
key: z.string().optional(),
|
|
74
|
+
name: z.string().optional(),
|
|
75
|
+
w: Sizing.optional(),
|
|
76
|
+
h: Sizing.optional(),
|
|
77
|
+
opacity: z.number().min(0).max(1).optional(),
|
|
78
|
+
visible: z.boolean().optional(),
|
|
79
|
+
/** Bounds for a node that fills or hugs. */
|
|
80
|
+
minW: z.number().nonnegative().optional(),
|
|
81
|
+
maxW: z.number().nonnegative().optional(),
|
|
82
|
+
minH: z.number().nonnegative().optional(),
|
|
83
|
+
maxH: z.number().nonnegative().optional(),
|
|
84
|
+
/** Position inside the parent. Inside an auto layout it lifts the node out of the flow. */
|
|
85
|
+
at: z.object({ x: z.number(), y: z.number() }).optional(),
|
|
86
|
+
/** How the node reacts when its parent resizes. */
|
|
87
|
+
constraints: z.object({ h: ConstraintSide, v: ConstraintSide }).optional()
|
|
88
|
+
};
|
|
89
|
+
var painted = {
|
|
90
|
+
fill: Fill.optional(),
|
|
91
|
+
stroke: Stroke.optional(),
|
|
92
|
+
radius: Radius.optional(),
|
|
93
|
+
effects: z.array(Effect).optional()
|
|
94
|
+
};
|
|
95
|
+
var typography = {
|
|
96
|
+
font: z.object({ family: z.string(), style: z.string().optional() }).optional(),
|
|
97
|
+
size: z.number().positive().optional(),
|
|
98
|
+
/** A number is px; "auto" leaves Figma's automatic line height. */
|
|
99
|
+
lineHeight: z.union([z.number().positive(), z.literal("auto")]).optional(),
|
|
100
|
+
letterSpacing: z.number().optional(),
|
|
101
|
+
align: z.enum(["left", "center", "right", "justify"]).optional(),
|
|
102
|
+
color: Fill.optional(),
|
|
103
|
+
/** A text style by name, e.g. "$text/heading". Overridden by the fields above. */
|
|
104
|
+
style: TokenRef.optional()
|
|
105
|
+
};
|
|
106
|
+
var container = {
|
|
107
|
+
layout: Layout.optional(),
|
|
108
|
+
clip: z.boolean().optional()
|
|
109
|
+
};
|
|
110
|
+
var TextNode = z.object({
|
|
111
|
+
...base,
|
|
112
|
+
...typography,
|
|
113
|
+
type: z.literal("text"),
|
|
114
|
+
text: z.string()
|
|
115
|
+
});
|
|
116
|
+
var RectNode = z.object({ ...base, ...painted, type: z.literal("rect") });
|
|
117
|
+
var asset = {
|
|
118
|
+
/** Inline content: SVG markup, or a data URI for an image. */
|
|
119
|
+
src: z.string(),
|
|
120
|
+
/** The path the server inlined `src` from, kept so `read` can name it again. */
|
|
121
|
+
srcRef: z.string().optional()
|
|
122
|
+
};
|
|
123
|
+
var SvgNode = z.object({ ...base, ...asset, type: z.literal("svg") });
|
|
124
|
+
var ImageNode = z.object({
|
|
125
|
+
...base,
|
|
126
|
+
...asset,
|
|
127
|
+
...painted,
|
|
128
|
+
type: z.literal("image"),
|
|
129
|
+
fit: z.enum(["fill", "fit", "crop", "tile"]).optional()
|
|
130
|
+
});
|
|
131
|
+
var instance = {
|
|
132
|
+
/** Component name, e.g. "Button" or "Button/Primary" for a variant. */
|
|
133
|
+
component: z.string(),
|
|
134
|
+
/** Component properties, by the name Figma shows (without its id suffix). */
|
|
135
|
+
properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
136
|
+
};
|
|
137
|
+
var InstanceNode = z.object({ ...base, ...instance, type: z.literal("instance") });
|
|
138
|
+
var EllipseNode = z.object({ ...base, ...painted, type: z.literal("ellipse") });
|
|
139
|
+
var NodeProps = z.object({
|
|
140
|
+
...base,
|
|
141
|
+
...painted,
|
|
142
|
+
...container,
|
|
143
|
+
...typography,
|
|
144
|
+
text: z.string().optional(),
|
|
145
|
+
src: z.string().optional(),
|
|
146
|
+
srcRef: z.string().optional(),
|
|
147
|
+
fit: ImageNode.shape.fit,
|
|
148
|
+
component: z.string().optional(),
|
|
149
|
+
properties: instance.properties
|
|
150
|
+
});
|
|
151
|
+
var FrameBase = z.object({
|
|
152
|
+
...base,
|
|
153
|
+
...painted,
|
|
154
|
+
...container,
|
|
155
|
+
type: z.literal("frame")
|
|
156
|
+
});
|
|
157
|
+
var FrameNode = FrameBase.extend({
|
|
158
|
+
get children() {
|
|
159
|
+
return z.array(UINode).optional();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
var UINode = z.discriminatedUnion("type", [
|
|
163
|
+
FrameNode,
|
|
164
|
+
TextNode,
|
|
165
|
+
RectNode,
|
|
166
|
+
EllipseNode,
|
|
167
|
+
InstanceNode,
|
|
168
|
+
SvgNode,
|
|
169
|
+
ImageNode
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// ../schema/dist/protocol.js
|
|
173
|
+
var OpName = z2.enum([
|
|
174
|
+
"ping",
|
|
175
|
+
"render",
|
|
176
|
+
"read",
|
|
177
|
+
"patch",
|
|
178
|
+
"screenshot",
|
|
179
|
+
"tokens",
|
|
180
|
+
"export",
|
|
181
|
+
"find",
|
|
182
|
+
"pages",
|
|
183
|
+
"exec",
|
|
184
|
+
"define"
|
|
185
|
+
]);
|
|
186
|
+
var RequestEnvelope = z2.object({
|
|
187
|
+
id: z2.string(),
|
|
188
|
+
op: OpName,
|
|
189
|
+
params: z2.unknown().optional()
|
|
190
|
+
});
|
|
191
|
+
var ErrorBody = z2.object({
|
|
192
|
+
code: z2.string(),
|
|
193
|
+
message: z2.string(),
|
|
194
|
+
hint: z2.string().optional()
|
|
195
|
+
});
|
|
196
|
+
var ResponseEnvelope = z2.discriminatedUnion("ok", [
|
|
197
|
+
z2.object({ id: z2.string(), ok: z2.literal(true), result: z2.unknown() }),
|
|
198
|
+
z2.object({ id: z2.string(), ok: z2.literal(false), error: ErrorBody })
|
|
199
|
+
]);
|
|
200
|
+
var HelloMessage = z2.object({
|
|
201
|
+
type: z2.literal("hello"),
|
|
202
|
+
protocolVersion: z2.number(),
|
|
203
|
+
pluginVersion: z2.string(),
|
|
204
|
+
fileKey: z2.string().nullable(),
|
|
205
|
+
fileName: z2.string()
|
|
206
|
+
});
|
|
207
|
+
var LogMessage = z2.object({
|
|
208
|
+
type: z2.literal("log"),
|
|
209
|
+
level: z2.enum(["debug", "info", "warn", "error"]),
|
|
210
|
+
message: z2.string()
|
|
211
|
+
});
|
|
212
|
+
var PluginMessage = z2.union([ResponseEnvelope, HelloMessage, LogMessage]);
|
|
213
|
+
var RenderParams = z2.object({
|
|
214
|
+
tree: UINode,
|
|
215
|
+
/** Where to put the root. Defaults to the current page. */
|
|
216
|
+
parentId: z2.string().optional(),
|
|
217
|
+
/** Absolute position for the root; defaults to the middle of the viewport. */
|
|
218
|
+
at: z2.object({ x: z2.number(), y: z2.number() }).optional(),
|
|
219
|
+
/** Select the new root and scroll it into view. */
|
|
220
|
+
reveal: z2.boolean().optional()
|
|
221
|
+
});
|
|
222
|
+
var RenderResult = z2.object({
|
|
223
|
+
rootId: z2.string(),
|
|
224
|
+
nodeCount: z2.number(),
|
|
225
|
+
/** Every node that carried a `key`, so later patches can address it. */
|
|
226
|
+
keys: z2.record(z2.string(), z2.string())
|
|
227
|
+
});
|
|
228
|
+
var Target = z2.union([z2.object({ key: z2.string() }), z2.object({ id: z2.string() })]);
|
|
229
|
+
var PatchOp = z2.discriminatedUnion("op", [
|
|
230
|
+
z2.object({ op: z2.literal("set"), target: Target, props: NodeProps }),
|
|
231
|
+
z2.object({
|
|
232
|
+
op: z2.literal("append"),
|
|
233
|
+
target: Target,
|
|
234
|
+
tree: UINode,
|
|
235
|
+
/** Position among the existing children; appended last by default. */
|
|
236
|
+
index: z2.number().int().nonnegative().optional()
|
|
237
|
+
}),
|
|
238
|
+
z2.object({ op: z2.literal("replace"), target: Target, tree: UINode }),
|
|
239
|
+
z2.object({ op: z2.literal("remove"), target: Target }),
|
|
240
|
+
z2.object({
|
|
241
|
+
op: z2.literal("move"),
|
|
242
|
+
target: Target,
|
|
243
|
+
/** Defaults to the node's current parent. */
|
|
244
|
+
parent: Target.optional(),
|
|
245
|
+
index: z2.number().int().nonnegative().optional()
|
|
246
|
+
})
|
|
247
|
+
]);
|
|
248
|
+
var PatchParams = z2.object({
|
|
249
|
+
ops: z2.array(PatchOp).min(1),
|
|
250
|
+
reveal: z2.boolean().optional()
|
|
251
|
+
});
|
|
252
|
+
var PatchResult = z2.object({
|
|
253
|
+
applied: z2.number(),
|
|
254
|
+
/** Keys of any nodes the patch created. */
|
|
255
|
+
keys: z2.record(z2.string(), z2.string())
|
|
256
|
+
});
|
|
257
|
+
var ScreenshotParams = z2.object({
|
|
258
|
+
/** Defaults to the current selection, or the page if nothing is selected. */
|
|
259
|
+
target: Target.optional(),
|
|
260
|
+
scale: z2.number().min(0.1).max(4).optional()
|
|
261
|
+
});
|
|
262
|
+
var ScreenshotResult = z2.object({
|
|
263
|
+
id: z2.string(),
|
|
264
|
+
name: z2.string(),
|
|
265
|
+
width: z2.number(),
|
|
266
|
+
height: z2.number(),
|
|
267
|
+
/** base64 PNG. */
|
|
268
|
+
png: z2.string()
|
|
269
|
+
});
|
|
270
|
+
var NodeKind = z2.enum([
|
|
271
|
+
"frame",
|
|
272
|
+
"text",
|
|
273
|
+
"rect",
|
|
274
|
+
"ellipse",
|
|
275
|
+
"instance",
|
|
276
|
+
"component",
|
|
277
|
+
"group",
|
|
278
|
+
"vector",
|
|
279
|
+
"any"
|
|
280
|
+
]);
|
|
281
|
+
var FindParams = z2.object({
|
|
282
|
+
/** Case-insensitive substring of the layer name. */
|
|
283
|
+
name: z2.string().optional(),
|
|
284
|
+
kind: NodeKind.optional(),
|
|
285
|
+
/** A key written by render or patch. */
|
|
286
|
+
key: z2.string().optional(),
|
|
287
|
+
/** "document" loads every page first, which can be slow in a large file. */
|
|
288
|
+
scope: z2.enum(["page", "document"]).optional(),
|
|
289
|
+
/** Select the matches in Figma and scroll them into view. */
|
|
290
|
+
select: z2.boolean().optional(),
|
|
291
|
+
limit: z2.number().int().min(1).max(200).optional()
|
|
292
|
+
});
|
|
293
|
+
var FindResult = z2.object({
|
|
294
|
+
nodes: z2.array(z2.object({
|
|
295
|
+
id: z2.string(),
|
|
296
|
+
name: z2.string(),
|
|
297
|
+
kind: z2.string(),
|
|
298
|
+
page: z2.string(),
|
|
299
|
+
key: z2.string().optional()
|
|
300
|
+
})),
|
|
301
|
+
truncated: z2.boolean()
|
|
302
|
+
});
|
|
303
|
+
var PagesParams = z2.object({
|
|
304
|
+
/** Page id or exact name to switch to. */
|
|
305
|
+
select: z2.string().optional()
|
|
306
|
+
});
|
|
307
|
+
var PagesResult = z2.object({
|
|
308
|
+
pages: z2.array(z2.object({ id: z2.string(), name: z2.string(), current: z2.boolean() }))
|
|
309
|
+
});
|
|
310
|
+
var ExecParams = z2.object({ code: z2.string() });
|
|
311
|
+
var ExecResult = z2.object({ result: z2.string() });
|
|
312
|
+
var TokenValue = z2.union([z2.string(), z2.number(), z2.boolean()]);
|
|
313
|
+
var StyleDefs = z2.object({
|
|
314
|
+
paint: z2.array(z2.object({ name: z2.string(), fill: Fill })).optional(),
|
|
315
|
+
text: z2.array(z2.object({
|
|
316
|
+
name: z2.string(),
|
|
317
|
+
font: z2.object({ family: z2.string(), style: z2.string().optional() }),
|
|
318
|
+
size: z2.number().positive(),
|
|
319
|
+
lineHeight: z2.union([z2.number().positive(), z2.literal("auto")]).optional(),
|
|
320
|
+
letterSpacing: z2.number().optional()
|
|
321
|
+
})).optional(),
|
|
322
|
+
effect: z2.array(z2.object({ name: z2.string(), effects: z2.array(Effect) })).optional()
|
|
323
|
+
});
|
|
324
|
+
var DefineParams = z2.object({
|
|
325
|
+
collection: z2.string().optional(),
|
|
326
|
+
styles: StyleDefs.optional(),
|
|
327
|
+
/** Modes to create or reuse, in order. Defaults to the collection's first mode. */
|
|
328
|
+
modes: z2.array(z2.string()).optional(),
|
|
329
|
+
variables: z2.array(z2.object({
|
|
330
|
+
/** Without the "$", e.g. "color/primary". */
|
|
331
|
+
name: z2.string(),
|
|
332
|
+
type: z2.enum(["color", "number", "string", "boolean"]),
|
|
333
|
+
/** One value, or one per mode name. A "$ref" value becomes an alias. */
|
|
334
|
+
value: z2.union([TokenValue, z2.record(z2.string(), TokenValue)])
|
|
335
|
+
})).optional()
|
|
336
|
+
});
|
|
337
|
+
var DefineResult = z2.object({
|
|
338
|
+
collection: z2.string().optional(),
|
|
339
|
+
/** The modes the collection ended up with; a plan may cap how many it allows. */
|
|
340
|
+
modes: z2.array(z2.string()),
|
|
341
|
+
styles: z2.array(z2.string()),
|
|
342
|
+
created: z2.array(z2.string()),
|
|
343
|
+
updated: z2.array(z2.string()),
|
|
344
|
+
/** What the file would not let us do, rather than failing the whole call. */
|
|
345
|
+
notes: z2.array(z2.string())
|
|
346
|
+
});
|
|
347
|
+
var TokensParams = z2.object({});
|
|
348
|
+
var VariableValue = z2.union([z2.string(), z2.number(), z2.boolean()]);
|
|
349
|
+
var TokensResult = z2.object({
|
|
350
|
+
collections: z2.array(z2.object({
|
|
351
|
+
name: z2.string(),
|
|
352
|
+
modes: z2.array(z2.string()),
|
|
353
|
+
variables: z2.array(z2.object({
|
|
354
|
+
/** The name a tree refers to, e.g. "$color/primary". */
|
|
355
|
+
ref: z2.string(),
|
|
356
|
+
type: z2.string(),
|
|
357
|
+
/** Value per mode name; an alias reads as another "$ref". */
|
|
358
|
+
values: z2.record(z2.string(), VariableValue)
|
|
359
|
+
}))
|
|
360
|
+
})),
|
|
361
|
+
styles: z2.object({
|
|
362
|
+
paint: z2.array(z2.object({ ref: z2.string(), fill: z2.string().optional() })),
|
|
363
|
+
text: z2.array(z2.object({ ref: z2.string(), font: z2.string(), size: z2.number().optional() })),
|
|
364
|
+
effect: z2.array(z2.object({ ref: z2.string() }))
|
|
365
|
+
})
|
|
366
|
+
});
|
|
367
|
+
var ExportParams = z2.object({
|
|
368
|
+
targets: z2.array(Target).min(1),
|
|
369
|
+
format: z2.enum(["png", "svg"]).optional(),
|
|
370
|
+
scale: z2.number().min(0.1).max(4).optional(),
|
|
371
|
+
/** Directory the server writes into, relative to MIRRA_ROOT or its own. */
|
|
372
|
+
outDir: z2.string()
|
|
373
|
+
});
|
|
374
|
+
var ExportResult = z2.object({
|
|
375
|
+
assets: z2.array(z2.object({ id: z2.string(), name: z2.string(), extension: z2.string(), data: z2.string() }))
|
|
376
|
+
});
|
|
377
|
+
var ReadParams = z2.object({
|
|
378
|
+
/** Defaults to the current selection. */
|
|
379
|
+
nodeId: z2.string().optional(),
|
|
380
|
+
/** How many levels of children to walk. */
|
|
381
|
+
depth: z2.number().int().min(0).max(20).optional()
|
|
382
|
+
});
|
|
383
|
+
var ReadResult = z2.object({
|
|
384
|
+
nodes: z2.array(z2.object({ id: z2.string(), tree: UINode })),
|
|
385
|
+
/** Nodes left out because the DSL has no shape for them yet, or depth ran out. */
|
|
386
|
+
skipped: z2.number()
|
|
387
|
+
});
|
|
388
|
+
var PingResult = z2.object({
|
|
389
|
+
fileName: z2.string(),
|
|
390
|
+
fileKey: z2.string().nullable(),
|
|
391
|
+
currentPage: z2.string(),
|
|
392
|
+
selectionCount: z2.number()
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// src/clients.ts
|
|
396
|
+
import { spawnSync } from "node:child_process";
|
|
397
|
+
import { access, copyFile, readFile, rename, writeFile } from "node:fs/promises";
|
|
398
|
+
import { homedir } from "node:os";
|
|
399
|
+
import { dirname, join, resolve } from "node:path";
|
|
400
|
+
import { realpath } from "node:fs/promises";
|
|
401
|
+
var SERVER_NAME = "mirra";
|
|
402
|
+
async function stableNodePath() {
|
|
403
|
+
try {
|
|
404
|
+
return await realpath(process.execPath);
|
|
405
|
+
} catch {
|
|
406
|
+
return process.execPath;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function targets() {
|
|
410
|
+
const home = homedir();
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
client: "Claude Desktop",
|
|
414
|
+
file: join(home, "Library/Application Support/Claude/claude_desktop_config.json"),
|
|
415
|
+
key: "mcpServers"
|
|
416
|
+
},
|
|
417
|
+
{ client: "Cursor", file: join(home, ".cursor/mcp.json"), key: "mcpServers" },
|
|
418
|
+
{
|
|
419
|
+
client: "VS Code",
|
|
420
|
+
file: join(home, "Library/Application Support/Code/User/mcp.json"),
|
|
421
|
+
key: "servers",
|
|
422
|
+
typed: true
|
|
423
|
+
}
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
async function registerWithClients(entryPoint) {
|
|
427
|
+
const node = await stableNodePath();
|
|
428
|
+
const reports = [];
|
|
429
|
+
for (const target of targets()) {
|
|
430
|
+
reports.push(await writeEntry(target, node, entryPoint));
|
|
431
|
+
}
|
|
432
|
+
reports.push(claudeCode(node, entryPoint));
|
|
433
|
+
return reports;
|
|
434
|
+
}
|
|
435
|
+
async function writeEntry(target, node, entryPoint) {
|
|
436
|
+
try {
|
|
437
|
+
await access(target.file);
|
|
438
|
+
} catch {
|
|
439
|
+
return { client: target.client, status: "skipped", detail: "not installed" };
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const raw = await readFile(target.file, "utf8");
|
|
443
|
+
const config = JSON.parse(raw);
|
|
444
|
+
const servers = config[target.key] ?? {};
|
|
445
|
+
servers[SERVER_NAME] = {
|
|
446
|
+
...target.typed ? { type: "stdio" } : {},
|
|
447
|
+
command: node,
|
|
448
|
+
args: [entryPoint, "serve"]
|
|
449
|
+
};
|
|
450
|
+
config[target.key] = servers;
|
|
451
|
+
await copyFile(target.file, `${target.file}.mirra-backup`);
|
|
452
|
+
const temporary = join(dirname(target.file), `.${SERVER_NAME}-${process.pid}.json`);
|
|
453
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}
|
|
454
|
+
`);
|
|
455
|
+
await rename(temporary, target.file);
|
|
456
|
+
return { client: target.client, status: "written", detail: target.file };
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return {
|
|
459
|
+
client: target.client,
|
|
460
|
+
status: "failed",
|
|
461
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function claudeCode(node, entryPoint) {
|
|
466
|
+
const probe = spawnSync("claude", ["--version"], { stdio: "ignore" });
|
|
467
|
+
if (probe.error)
|
|
468
|
+
return { client: "Claude Code", status: "skipped", detail: "claude not on PATH" };
|
|
469
|
+
const result = spawnSync(
|
|
470
|
+
"claude",
|
|
471
|
+
["mcp", "add", "-s", "user", SERVER_NAME, "--", node, entryPoint, "serve"],
|
|
472
|
+
{ stdio: "pipe", encoding: "utf8" }
|
|
473
|
+
);
|
|
474
|
+
if (result.status === 0)
|
|
475
|
+
return { client: "Claude Code", status: "written", detail: "user scope" };
|
|
476
|
+
return {
|
|
477
|
+
client: "Claude Code",
|
|
478
|
+
status: "failed",
|
|
479
|
+
detail: (result.stderr || result.stdout || "claude mcp add failed").trim().split("\n")[0] ?? ""
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function serverEntryPoint() {
|
|
483
|
+
return resolve(fileDirectory(), "index.js");
|
|
484
|
+
}
|
|
485
|
+
function fileDirectory() {
|
|
486
|
+
return dirname(new URL(import.meta.url).pathname);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/install.ts
|
|
490
|
+
import { spawn } from "node:child_process";
|
|
491
|
+
import { access as access2, cp, mkdir, readFile as readFile2 } from "node:fs/promises";
|
|
492
|
+
import { homedir as homedir2, platform } from "node:os";
|
|
493
|
+
import { join as join2, resolve as resolve2 } from "node:path";
|
|
494
|
+
import { fileURLToPath } from "node:url";
|
|
495
|
+
var PLUGIN_HOME = join2(homedir2(), ".mirra", "plugin");
|
|
496
|
+
var BUNDLED_PLUGIN = resolve2(fileURLToPath(new URL(".", import.meta.url)), "plugin");
|
|
497
|
+
async function installPlugin() {
|
|
498
|
+
await mkdir(PLUGIN_HOME, { recursive: true });
|
|
499
|
+
await cp(BUNDLED_PLUGIN, PLUGIN_HOME, { recursive: true });
|
|
500
|
+
const manifest = join2(PLUGIN_HOME, "manifest.json");
|
|
501
|
+
return { manifest, version: await installedVersion() };
|
|
502
|
+
}
|
|
503
|
+
async function installedVersion() {
|
|
504
|
+
try {
|
|
505
|
+
const manifest = JSON.parse(await readFile2(join2(PLUGIN_HOME, "manifest.json"), "utf8"));
|
|
506
|
+
return typeof manifest.name === "string" ? manifest.name : "unknown";
|
|
507
|
+
} catch {
|
|
508
|
+
return "not installed";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function isInstalled() {
|
|
512
|
+
try {
|
|
513
|
+
await access2(join2(PLUGIN_HOME, "manifest.json"));
|
|
514
|
+
return true;
|
|
515
|
+
} catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function reveal(path) {
|
|
520
|
+
if (platform() !== "darwin") return;
|
|
521
|
+
spawn("open", ["-R", path], { stdio: "ignore", detached: true }).unref();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/mcp.ts
|
|
525
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
526
|
+
|
|
527
|
+
// src/errors.ts
|
|
528
|
+
var MirraError = class extends Error {
|
|
529
|
+
constructor(message, code, hint) {
|
|
530
|
+
super(message);
|
|
531
|
+
this.code = code;
|
|
532
|
+
this.hint = hint;
|
|
533
|
+
this.name = "MirraError";
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// src/tools/text.ts
|
|
538
|
+
function text(lines, isError = false) {
|
|
539
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError };
|
|
540
|
+
}
|
|
541
|
+
function describeError(error) {
|
|
542
|
+
if (error instanceof MirraError) {
|
|
543
|
+
return error.hint ? `${error.message}
|
|
544
|
+
|
|
545
|
+
${error.hint}` : error.message;
|
|
546
|
+
}
|
|
547
|
+
return error instanceof Error ? error.message : String(error);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/tools/define.ts
|
|
551
|
+
var DESCRIPTION = `Create or update the design system itself: variables in a collection, and
|
|
552
|
+
paint, text or effect styles. A value of "$other/name" becomes an alias to that variable, and a
|
|
553
|
+
value keyed by mode name sets one value per mode. Both halves are optional.`;
|
|
554
|
+
function registerDefine(server, bridge) {
|
|
555
|
+
server.registerTool(
|
|
556
|
+
"mirra_define_tokens",
|
|
557
|
+
{ title: "Define Figma variables", description: DESCRIPTION, inputSchema: DefineParams.shape },
|
|
558
|
+
async (params) => {
|
|
559
|
+
try {
|
|
560
|
+
const result = DefineResult.parse(await bridge.request("define", params));
|
|
561
|
+
return text([
|
|
562
|
+
...result.collection ? [
|
|
563
|
+
`collection ${result.collection} (modes: ${result.modes.join(", ")})`,
|
|
564
|
+
`created: ${result.created.join(", ") || "none"}`,
|
|
565
|
+
`updated: ${result.updated.join(", ") || "none"}`
|
|
566
|
+
] : [],
|
|
567
|
+
...result.styles.length ? [`styles: ${result.styles.join(", ")}`] : [],
|
|
568
|
+
...result.notes
|
|
569
|
+
]);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
return text([describeError(error)], true);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// src/tools/exec.ts
|
|
578
|
+
var DESCRIPTION2 = `Run Plugin API code in the open file and return what it evaluates to.
|
|
579
|
+
The body is an async function receiving \`figma\`; use it only for what the typed tools cannot do.`;
|
|
580
|
+
function registerExec(server, bridge) {
|
|
581
|
+
server.registerTool(
|
|
582
|
+
"mirra_exec",
|
|
583
|
+
{ title: "Run Plugin API code", description: DESCRIPTION2, inputSchema: ExecParams.shape },
|
|
584
|
+
async (params) => {
|
|
585
|
+
try {
|
|
586
|
+
const result = ExecResult.parse(await bridge.request("exec", params));
|
|
587
|
+
return text([result.result]);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
return text([describeError(error)], true);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/tools/export.ts
|
|
596
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
597
|
+
import { resolve as resolve4 } from "node:path";
|
|
598
|
+
|
|
599
|
+
// src/tools/paths.ts
|
|
600
|
+
import { resolve as resolve3, sep } from "node:path";
|
|
601
|
+
function rootDirectory() {
|
|
602
|
+
const configured = process.env["MIRRA_ROOT"];
|
|
603
|
+
return configured ? resolve3(configured) : process.cwd();
|
|
604
|
+
}
|
|
605
|
+
function resolveInRoot(path) {
|
|
606
|
+
const root = rootDirectory();
|
|
607
|
+
const full = resolve3(root, path);
|
|
608
|
+
if (!process.env["MIRRA_ROOT"]) return full;
|
|
609
|
+
if (full !== root && !full.startsWith(root + sep)) {
|
|
610
|
+
throw new MirraError(
|
|
611
|
+
`${path} is outside MIRRA_ROOT`,
|
|
612
|
+
"outside_root",
|
|
613
|
+
`This server may only touch files under ${root}.`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
return full;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/tools/export.ts
|
|
620
|
+
var DESCRIPTION3 = `Export layers as PNG or SVG files on disk, for icons and images a tree cannot
|
|
621
|
+
describe. Targets are keys or Figma node ids; outDir is relative to where the server runs.`;
|
|
622
|
+
function registerExport(server, bridge) {
|
|
623
|
+
server.registerTool(
|
|
624
|
+
"mirra_export_assets",
|
|
625
|
+
{
|
|
626
|
+
title: "Export Figma layers to files",
|
|
627
|
+
description: DESCRIPTION3,
|
|
628
|
+
inputSchema: ExportParams.shape
|
|
629
|
+
},
|
|
630
|
+
async (params) => {
|
|
631
|
+
try {
|
|
632
|
+
const result = ExportResult.parse(await bridge.request("export", params));
|
|
633
|
+
const directory = resolveInRoot(params.outDir);
|
|
634
|
+
await mkdir2(directory, { recursive: true });
|
|
635
|
+
const written = [];
|
|
636
|
+
for (const asset2 of result.assets) {
|
|
637
|
+
const file = resolve4(directory, `${slug(asset2.name)}.${asset2.extension}`);
|
|
638
|
+
await writeFile2(file, Buffer.from(asset2.data, "base64"));
|
|
639
|
+
written.push(file);
|
|
640
|
+
}
|
|
641
|
+
return text([`wrote ${written.length} file(s):`, ...written]);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return text([describeError(error)], true);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
function slug(name) {
|
|
649
|
+
const cleaned = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
650
|
+
return cleaned || "asset";
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/tools/find.ts
|
|
654
|
+
var FIND = `Locate layers by name, kind or mirra key, so an existing design can be targeted
|
|
655
|
+
without clicking in Figma first. Searches the current page unless scope is "document".`;
|
|
656
|
+
var PAGES = `List the pages of the file, and switch to one by name or id. Every other tool works
|
|
657
|
+
on the current page, so start here when the layer you want is elsewhere.`;
|
|
658
|
+
function registerFind(server, bridge) {
|
|
659
|
+
server.registerTool(
|
|
660
|
+
"mirra_find",
|
|
661
|
+
{ title: "Find Figma layers", description: FIND, inputSchema: FindParams.shape },
|
|
662
|
+
async (params) => {
|
|
663
|
+
try {
|
|
664
|
+
const result = FindResult.parse(await bridge.request("find", params));
|
|
665
|
+
if (!result.nodes.length) return text(["no matches"]);
|
|
666
|
+
return text([
|
|
667
|
+
...result.nodes.map(
|
|
668
|
+
(node) => `${node.id} ${node.kind.toLowerCase().padEnd(12)} ${node.name}${node.key ? ` [key: ${node.key}]` : ""} (${node.page})`
|
|
669
|
+
),
|
|
670
|
+
...result.truncated ? ["", "more matches exist: raise limit or narrow the query"] : []
|
|
671
|
+
]);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
return text([describeError(error)], true);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
);
|
|
677
|
+
server.registerTool(
|
|
678
|
+
"mirra_pages",
|
|
679
|
+
{ title: "List or switch Figma pages", description: PAGES, inputSchema: PagesParams.shape },
|
|
680
|
+
async (params) => {
|
|
681
|
+
try {
|
|
682
|
+
const result = PagesResult.parse(await bridge.request("pages", params));
|
|
683
|
+
return text(
|
|
684
|
+
result.pages.map((page) => `${page.current ? "*" : " "} ${page.id} ${page.name}`)
|
|
685
|
+
);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
return text([describeError(error)], true);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/tools/assets.ts
|
|
694
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
695
|
+
import { extname } from "node:path";
|
|
696
|
+
var MIME = /* @__PURE__ */ new Map([
|
|
697
|
+
[".png", "image/png"],
|
|
698
|
+
[".jpg", "image/jpeg"],
|
|
699
|
+
[".jpeg", "image/jpeg"],
|
|
700
|
+
[".gif", "image/gif"],
|
|
701
|
+
[".webp", "image/webp"]
|
|
702
|
+
]);
|
|
703
|
+
async function inlineAssets(tree) {
|
|
704
|
+
if (Array.isArray(tree)) {
|
|
705
|
+
return await Promise.all(tree.map((item) => inlineAssets(item)));
|
|
706
|
+
}
|
|
707
|
+
if (!tree || typeof tree !== "object") return tree;
|
|
708
|
+
const node = { ...tree };
|
|
709
|
+
for (const [key, value] of Object.entries(node)) {
|
|
710
|
+
if (key !== "src") node[key] = await inlineAssets(value);
|
|
711
|
+
}
|
|
712
|
+
const src = node["src"];
|
|
713
|
+
if (typeof src === "string" && isPath(src)) {
|
|
714
|
+
node["srcRef"] = src;
|
|
715
|
+
node["src"] = node["type"] === "svg" ? await readSvg(src) : await readImage(src);
|
|
716
|
+
}
|
|
717
|
+
return node;
|
|
718
|
+
}
|
|
719
|
+
function isPath(src) {
|
|
720
|
+
if (src.startsWith("data:") || src.trimStart().startsWith("<")) return false;
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
async function readSvg(path) {
|
|
724
|
+
return readFile3(resolveInRoot(path), "utf8");
|
|
725
|
+
}
|
|
726
|
+
async function readImage(path) {
|
|
727
|
+
const file = resolveInRoot(path);
|
|
728
|
+
const type = MIME.get(extname(file).toLowerCase());
|
|
729
|
+
if (!type) throw new Error(`Unsupported image type: ${extname(file) || file}`);
|
|
730
|
+
return `data:${type};base64,${(await readFile3(file)).toString("base64")}`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/tools/patch.ts
|
|
734
|
+
var DESCRIPTION4 = `Change layers that already exist, addressed by the "key" they were rendered with
|
|
735
|
+
(or by Figma node id). Ops are "set" to change properties, "append" to add a child tree,
|
|
736
|
+
"replace" to swap a node for a new tree, and "remove". The whole patch is one undo step.`;
|
|
737
|
+
function registerPatch(server, bridge) {
|
|
738
|
+
server.registerTool(
|
|
739
|
+
"mirra_patch",
|
|
740
|
+
{
|
|
741
|
+
title: "Patch existing Figma layers",
|
|
742
|
+
description: DESCRIPTION4,
|
|
743
|
+
inputSchema: PatchParams.shape
|
|
744
|
+
},
|
|
745
|
+
async (params) => {
|
|
746
|
+
try {
|
|
747
|
+
const result = PatchResult.parse(await bridge.request("patch", await inlineAssets(params)));
|
|
748
|
+
const created = Object.entries(result.keys);
|
|
749
|
+
return text([
|
|
750
|
+
`applied ${result.applied} op(s)`,
|
|
751
|
+
...created.length ? ["new keys:", ...created.map(([key, id]) => ` ${key} -> ${id}`)] : []
|
|
752
|
+
]);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return text([describeError(error)], true);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/tools/ping.ts
|
|
761
|
+
function registerPing(server, bridge) {
|
|
762
|
+
server.registerTool(
|
|
763
|
+
"mirra_ping",
|
|
764
|
+
{
|
|
765
|
+
title: "Ping the Figma plugin",
|
|
766
|
+
description: "Check that the Mirra plugin is connected, and report the open file, current page and selection size."
|
|
767
|
+
},
|
|
768
|
+
async () => {
|
|
769
|
+
try {
|
|
770
|
+
const result = PingResult.parse(await bridge.request("ping"));
|
|
771
|
+
return text([
|
|
772
|
+
`file: ${result.fileName}${result.fileKey ? ` (${result.fileKey})` : ""}`,
|
|
773
|
+
`page: ${result.currentPage}`,
|
|
774
|
+
`selection: ${result.selectionCount} node(s)`,
|
|
775
|
+
`port: ${bridge.status.port}`
|
|
776
|
+
]);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
return text([describeError(error)], true);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/tools/read.ts
|
|
785
|
+
var DESCRIPTION5 = `Read layers out of the open Figma file as the same tree mirra_render takes,
|
|
786
|
+
so a design can be edited or turned into code without learning a second format.
|
|
787
|
+
Reads the current selection unless a nodeId is given.`;
|
|
788
|
+
function registerRead(server, bridge) {
|
|
789
|
+
server.registerTool(
|
|
790
|
+
"mirra_read",
|
|
791
|
+
{
|
|
792
|
+
title: "Read Figma layers as a UI tree",
|
|
793
|
+
description: DESCRIPTION5,
|
|
794
|
+
inputSchema: ReadParams.shape
|
|
795
|
+
},
|
|
796
|
+
async (params) => {
|
|
797
|
+
try {
|
|
798
|
+
const result = ReadResult.parse(await bridge.request("read", params));
|
|
799
|
+
const note = result.skipped ? [`${result.skipped} node(s) left out: unsupported type, or past the depth limit`] : [];
|
|
800
|
+
return text([...note, JSON.stringify(result.nodes, null, 2)]);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
return text([describeError(error)], true);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/tools/render.ts
|
|
809
|
+
var DESCRIPTION6 = `Build a whole screen in the open Figma file from one declarative tree.
|
|
810
|
+
|
|
811
|
+
The tree mirrors flexbox: a frame carries layout {dir, gap, pad, justify, align}, and a
|
|
812
|
+
child sizes itself with w/h set to a number, "fill" or "hug". Node types are frame, text,
|
|
813
|
+
rect and ellipse. Colours are hex strings; fill also takes {type:"gradient", stops}.
|
|
814
|
+
Give each node you may want to change later a stable "key" and address it from there.`;
|
|
815
|
+
function registerRender(server, bridge) {
|
|
816
|
+
server.registerTool(
|
|
817
|
+
"mirra_render",
|
|
818
|
+
{
|
|
819
|
+
title: "Render a UI tree into Figma",
|
|
820
|
+
description: DESCRIPTION6,
|
|
821
|
+
inputSchema: RenderParams.shape
|
|
822
|
+
},
|
|
823
|
+
async (params) => {
|
|
824
|
+
try {
|
|
825
|
+
const result = RenderResult.parse(
|
|
826
|
+
await bridge.request("render", await inlineAssets(params))
|
|
827
|
+
);
|
|
828
|
+
const keys = Object.entries(result.keys);
|
|
829
|
+
return text([
|
|
830
|
+
`rendered ${result.nodeCount} node(s), root ${result.rootId}`,
|
|
831
|
+
...keys.length ? ["keys:", ...keys.map(([key, id]) => ` ${key} -> ${id}`)] : []
|
|
832
|
+
]);
|
|
833
|
+
} catch (error) {
|
|
834
|
+
return text([describeError(error)], true);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/tools/screenshot.ts
|
|
841
|
+
var DESCRIPTION7 = `Export a layer as a PNG and return the image, so the rendered result can be
|
|
842
|
+
checked by eye and corrected with mirra_patch. Defaults to the current selection.`;
|
|
843
|
+
function registerScreenshot(server, bridge) {
|
|
844
|
+
server.registerTool(
|
|
845
|
+
"mirra_screenshot",
|
|
846
|
+
{
|
|
847
|
+
title: "Screenshot a Figma layer",
|
|
848
|
+
description: DESCRIPTION7,
|
|
849
|
+
inputSchema: ScreenshotParams.shape
|
|
850
|
+
},
|
|
851
|
+
async (params) => {
|
|
852
|
+
try {
|
|
853
|
+
const shot = ScreenshotResult.parse(await bridge.request("screenshot", params));
|
|
854
|
+
return {
|
|
855
|
+
content: [
|
|
856
|
+
{
|
|
857
|
+
type: "text",
|
|
858
|
+
text: `${shot.name} (${shot.width}x${shot.height}, ${shot.id})`
|
|
859
|
+
},
|
|
860
|
+
{ type: "image", data: shot.png, mimeType: "image/png" }
|
|
861
|
+
]
|
|
862
|
+
};
|
|
863
|
+
} catch (error) {
|
|
864
|
+
return text([describeError(error)], true);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/tools/tokens.ts
|
|
871
|
+
var DESCRIPTION8 = `List the variables and styles of the open file, with the "$name" each one
|
|
872
|
+
answers to. Pass those names as fill, color or style in a tree and the rendered node stays
|
|
873
|
+
bound to the design system instead of to a hardcoded value.`;
|
|
874
|
+
function registerTokens(server, bridge) {
|
|
875
|
+
server.registerTool(
|
|
876
|
+
"mirra_tokens",
|
|
877
|
+
{ title: "List design tokens", description: DESCRIPTION8 },
|
|
878
|
+
async () => {
|
|
879
|
+
try {
|
|
880
|
+
const result = TokensResult.parse(await bridge.request("tokens"));
|
|
881
|
+
return text([JSON.stringify(result, null, 2)]);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
return text([describeError(error)], true);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/mcp.ts
|
|
890
|
+
var SERVER_VERSION = "1.0.0";
|
|
891
|
+
function createMcpServer(bridge) {
|
|
892
|
+
const server = new McpServer({ name: "mirra", version: SERVER_VERSION });
|
|
893
|
+
registerPing(server, bridge);
|
|
894
|
+
registerRender(server, bridge);
|
|
895
|
+
registerRead(server, bridge);
|
|
896
|
+
registerPatch(server, bridge);
|
|
897
|
+
registerScreenshot(server, bridge);
|
|
898
|
+
registerTokens(server, bridge);
|
|
899
|
+
registerExport(server, bridge);
|
|
900
|
+
registerFind(server, bridge);
|
|
901
|
+
registerDefine(server, bridge);
|
|
902
|
+
if (isOn(process.env["MIRRA_ALLOW_EXEC"])) registerExec(server, bridge);
|
|
903
|
+
return server;
|
|
904
|
+
}
|
|
905
|
+
function isOn(value) {
|
|
906
|
+
return value !== void 0 && value !== "" && value !== "false" && value !== "0";
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// src/transport.ts
|
|
910
|
+
import { randomUUID } from "node:crypto";
|
|
911
|
+
import { createServer } from "node:http";
|
|
912
|
+
import { WebSocketServer } from "ws";
|
|
913
|
+
var HOSTS = ["127.0.0.1", "::1"];
|
|
914
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
915
|
+
function originAllowed(origin) {
|
|
916
|
+
if (!origin || origin === "null") return true;
|
|
917
|
+
if (process.env["MIRRA_ALLOW_ORIGIN"] === origin) return true;
|
|
918
|
+
try {
|
|
919
|
+
const { hostname } = new URL(origin);
|
|
920
|
+
return hostname === "figma.com" || hostname.endsWith(".figma.com");
|
|
921
|
+
} catch {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
var OPEN_PLUGIN_HINT = "Open the file in Figma Desktop, then Plugins -> Development -> Mirra, and keep the panel open.";
|
|
926
|
+
var PluginBridge = class {
|
|
927
|
+
#servers = [];
|
|
928
|
+
#socket = null;
|
|
929
|
+
#hello = null;
|
|
930
|
+
#port = null;
|
|
931
|
+
#pending = /* @__PURE__ */ new Map();
|
|
932
|
+
get status() {
|
|
933
|
+
return { port: this.#port, connected: this.#socket !== null, hello: this.#hello };
|
|
934
|
+
}
|
|
935
|
+
async listen(preferredPort2) {
|
|
936
|
+
const first = preferredPort2 ?? DEFAULT_PORT;
|
|
937
|
+
const last = preferredPort2 ?? PORT_RANGE_END;
|
|
938
|
+
let lastError;
|
|
939
|
+
for (let port = first; port <= last; port++) {
|
|
940
|
+
try {
|
|
941
|
+
const primary = await openServer(HOSTS[0], port);
|
|
942
|
+
this.#servers = [primary];
|
|
943
|
+
this.#port = actualPort(primary, port);
|
|
944
|
+
try {
|
|
945
|
+
this.#servers.push(await openServer(HOSTS[1], this.#port));
|
|
946
|
+
} catch {
|
|
947
|
+
log("no IPv6 loopback; listening on 127.0.0.1 only");
|
|
948
|
+
}
|
|
949
|
+
for (const server of this.#servers) {
|
|
950
|
+
server.on("connection", (socket) => this.#attach(socket));
|
|
951
|
+
}
|
|
952
|
+
return this.#port;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
lastError = error;
|
|
955
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
throw new MirraError(
|
|
959
|
+
`No free port in ${first}-${last} (last error: ${String(lastError)})`,
|
|
960
|
+
"no_free_port",
|
|
961
|
+
"Another Mirra session is probably running. Close the other Claude Code window, or set MIRRA_PORT to a free port."
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
async close() {
|
|
965
|
+
for (const [id, pending] of this.#pending) {
|
|
966
|
+
clearTimeout(pending.timer);
|
|
967
|
+
pending.reject(new MirraError("Server shutting down", "shutdown"));
|
|
968
|
+
this.#pending.delete(id);
|
|
969
|
+
}
|
|
970
|
+
this.#socket?.close(1001, "server shutting down");
|
|
971
|
+
await Promise.all(
|
|
972
|
+
this.#servers.map((server) => new Promise((resolve5) => server.close(() => resolve5())))
|
|
973
|
+
);
|
|
974
|
+
this.#servers = [];
|
|
975
|
+
this.#port = null;
|
|
976
|
+
}
|
|
977
|
+
async request(op, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
978
|
+
const socket = this.#socket;
|
|
979
|
+
if (!socket) {
|
|
980
|
+
throw new MirraError("No Figma plugin is connected", "plugin_offline", OPEN_PLUGIN_HINT);
|
|
981
|
+
}
|
|
982
|
+
const id = randomUUID();
|
|
983
|
+
const envelope = { id, op, params };
|
|
984
|
+
return new Promise((resolve5, reject) => {
|
|
985
|
+
const timer = setTimeout(() => {
|
|
986
|
+
this.#pending.delete(id);
|
|
987
|
+
reject(
|
|
988
|
+
new MirraError(
|
|
989
|
+
`Plugin did not answer "${op}" within ${timeoutMs}ms`,
|
|
990
|
+
"timeout",
|
|
991
|
+
"Check the Mirra panel in Figma: it may have disconnected or hit an error."
|
|
992
|
+
)
|
|
993
|
+
);
|
|
994
|
+
}, timeoutMs);
|
|
995
|
+
this.#pending.set(id, { resolve: resolve5, reject, timer });
|
|
996
|
+
socket.send(JSON.stringify(envelope));
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
#attach(socket) {
|
|
1000
|
+
if (this.#socket) this.#socket.close(4e3, "replaced by a newer plugin connection");
|
|
1001
|
+
this.#socket = socket;
|
|
1002
|
+
this.#hello = null;
|
|
1003
|
+
socket.on("message", (raw) => this.#onMessage(String(raw)));
|
|
1004
|
+
socket.on("close", () => {
|
|
1005
|
+
if (this.#socket === socket) {
|
|
1006
|
+
this.#socket = null;
|
|
1007
|
+
this.#hello = null;
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
socket.on("error", (error) => log("plugin socket error:", error.message));
|
|
1011
|
+
}
|
|
1012
|
+
#onMessage(raw) {
|
|
1013
|
+
let parsed;
|
|
1014
|
+
try {
|
|
1015
|
+
parsed = JSON.parse(raw);
|
|
1016
|
+
} catch {
|
|
1017
|
+
return log("dropped non-JSON message from plugin");
|
|
1018
|
+
}
|
|
1019
|
+
const message = PluginMessage.safeParse(parsed);
|
|
1020
|
+
if (!message.success) return log("dropped unknown message from plugin");
|
|
1021
|
+
const data = message.data;
|
|
1022
|
+
if ("type" in data) {
|
|
1023
|
+
if (data.type === "log") return log(`[plugin:${data.level}] ${data.message}`);
|
|
1024
|
+
this.#hello = data;
|
|
1025
|
+
if (data.protocolVersion !== PROTOCOL_VERSION) {
|
|
1026
|
+
log(
|
|
1027
|
+
`protocol mismatch: plugin speaks v${data.protocolVersion}, server speaks v${PROTOCOL_VERSION}. Rebuild and re-import the plugin.`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
return log(`plugin connected: ${data.fileName} (plugin v${data.pluginVersion})`);
|
|
1031
|
+
}
|
|
1032
|
+
const pending = this.#pending.get(data.id);
|
|
1033
|
+
if (!pending) return;
|
|
1034
|
+
this.#pending.delete(data.id);
|
|
1035
|
+
clearTimeout(pending.timer);
|
|
1036
|
+
if (data.ok) pending.resolve(data.result);
|
|
1037
|
+
else pending.reject(new MirraError(data.error.message, data.error.code, data.error.hint));
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
function actualPort(wss, requested) {
|
|
1041
|
+
const address = wss.options.server?.address() ?? null;
|
|
1042
|
+
return typeof address === "object" && address ? address.port : requested;
|
|
1043
|
+
}
|
|
1044
|
+
function openServer(host, port) {
|
|
1045
|
+
return new Promise((resolve5, reject) => {
|
|
1046
|
+
const http = createServer((request, response) => {
|
|
1047
|
+
const origin = request.headers.origin;
|
|
1048
|
+
log(`http ${request.method} ${request.url} from ${request.socket.remoteAddress}`);
|
|
1049
|
+
response.writeHead(200, {
|
|
1050
|
+
"content-type": "text/plain",
|
|
1051
|
+
// Only the plugin may read the body; another page can at most see a 200.
|
|
1052
|
+
...originAllowed(origin) ? { "access-control-allow-origin": origin ?? "*" } : {}
|
|
1053
|
+
});
|
|
1054
|
+
response.end("mirra");
|
|
1055
|
+
});
|
|
1056
|
+
const wss = new WebSocketServer({
|
|
1057
|
+
server: http,
|
|
1058
|
+
verifyClient: ({ origin }, done) => {
|
|
1059
|
+
if (originAllowed(origin)) return done(true);
|
|
1060
|
+
log(`refused a connection from origin ${origin}`);
|
|
1061
|
+
done(false, 403, "origin not allowed");
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
wss.on("error", (error) => log(`websocket server error: ${error.message}`));
|
|
1065
|
+
http.listen(port, host);
|
|
1066
|
+
const onError = (error) => {
|
|
1067
|
+
http.close();
|
|
1068
|
+
reject(error);
|
|
1069
|
+
};
|
|
1070
|
+
http.once("error", onError);
|
|
1071
|
+
http.once("listening", () => {
|
|
1072
|
+
http.off("error", onError);
|
|
1073
|
+
resolve5(wss);
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
function log(...parts) {
|
|
1078
|
+
console.error("[mirra]", ...parts);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// src/index.ts
|
|
1082
|
+
var USAGE = `mirra <command>
|
|
1083
|
+
|
|
1084
|
+
serve Run the MCP server over stdio (default; this is what Claude Code spawns)
|
|
1085
|
+
init Install the Figma plugin into ~/.mirra/plugin and print the next steps
|
|
1086
|
+
doctor Bind the plugin port and wait for the Figma plugin to connect
|
|
1087
|
+
|
|
1088
|
+
Flags:
|
|
1089
|
+
--no-reveal init: do not open the folder in Finder
|
|
1090
|
+
--no-config init: do not touch any client's configuration
|
|
1091
|
+
|
|
1092
|
+
Environment:
|
|
1093
|
+
MIRRA_PORT Bind this exact port instead of scanning ${DEFAULT_PORT}-${PORT_RANGE_END}
|
|
1094
|
+
`;
|
|
1095
|
+
var preferredPort = process.env["MIRRA_PORT"] ? Number(process.env["MIRRA_PORT"]) : void 0;
|
|
1096
|
+
switch (process.argv[2] ?? "serve") {
|
|
1097
|
+
case "serve":
|
|
1098
|
+
await serve();
|
|
1099
|
+
break;
|
|
1100
|
+
case "init":
|
|
1101
|
+
await init();
|
|
1102
|
+
break;
|
|
1103
|
+
case "doctor":
|
|
1104
|
+
await doctor();
|
|
1105
|
+
break;
|
|
1106
|
+
case "--help":
|
|
1107
|
+
case "-h":
|
|
1108
|
+
case "help":
|
|
1109
|
+
process.stdout.write(USAGE);
|
|
1110
|
+
break;
|
|
1111
|
+
default:
|
|
1112
|
+
process.stderr.write(USAGE);
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
async function serve() {
|
|
1116
|
+
const bridge = new PluginBridge();
|
|
1117
|
+
try {
|
|
1118
|
+
const port = await bridge.listen(preferredPort);
|
|
1119
|
+
console.error(`[mirra] listening on ws://127.0.0.1:${port}`);
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
console.error(`[mirra] ${describeError(error)}`);
|
|
1122
|
+
process.exit(1);
|
|
1123
|
+
}
|
|
1124
|
+
const server = createMcpServer(bridge);
|
|
1125
|
+
await server.connect(new StdioServerTransport());
|
|
1126
|
+
const shutdown = () => {
|
|
1127
|
+
void bridge.close().then(() => process.exit(0));
|
|
1128
|
+
};
|
|
1129
|
+
process.on("SIGINT", shutdown);
|
|
1130
|
+
process.on("SIGTERM", shutdown);
|
|
1131
|
+
}
|
|
1132
|
+
async function init() {
|
|
1133
|
+
const { manifest } = await installPlugin();
|
|
1134
|
+
const lines = [`Plugin installed at ${PLUGIN_HOME}`, ""];
|
|
1135
|
+
if (process.argv.includes("--no-config")) {
|
|
1136
|
+
lines.push("Left every client configuration alone.", "");
|
|
1137
|
+
} else {
|
|
1138
|
+
const reports = await registerWithClients(serverEntryPoint());
|
|
1139
|
+
lines.push("Clients:");
|
|
1140
|
+
for (const report of reports) {
|
|
1141
|
+
lines.push(` ${report.status.padEnd(8)} ${report.client.padEnd(15)} ${report.detail}`);
|
|
1142
|
+
}
|
|
1143
|
+
lines.push("", "A client that was already running has to be restarted to see the tools.", "");
|
|
1144
|
+
}
|
|
1145
|
+
lines.push(
|
|
1146
|
+
"Then, in Figma Desktop:",
|
|
1147
|
+
" 1. Plugins -> Development -> Import plugin from manifest...",
|
|
1148
|
+
` and choose ${manifest}`,
|
|
1149
|
+
" 2. Open a file, run Mirra, and leave the panel open",
|
|
1150
|
+
" 3. Ask the agent for mirra_ping; the panel badge should turn green"
|
|
1151
|
+
);
|
|
1152
|
+
if (!process.argv.includes("--no-reveal")) reveal(manifest);
|
|
1153
|
+
console.log(lines.join("\n"));
|
|
1154
|
+
}
|
|
1155
|
+
async function doctor() {
|
|
1156
|
+
const bridge = new PluginBridge();
|
|
1157
|
+
try {
|
|
1158
|
+
const port = await bridge.listen(preferredPort);
|
|
1159
|
+
console.log(`port ok ws://127.0.0.1:${port}`);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
console.log(`port FAIL ${describeError(error)}`);
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
}
|
|
1164
|
+
console.log(
|
|
1165
|
+
await isInstalled() ? `plugin ok installed at ${PLUGIN_HOME}` : "plugin warn not installed; run `mirra init`"
|
|
1166
|
+
);
|
|
1167
|
+
const connected = await waitFor(() => bridge.status.connected, 5e3);
|
|
1168
|
+
if (!connected) {
|
|
1169
|
+
console.log(
|
|
1170
|
+
"socket FAIL no connection after 5s\n Open the file in Figma Desktop, then Plugins -> Development -> Mirra."
|
|
1171
|
+
);
|
|
1172
|
+
await bridge.close();
|
|
1173
|
+
process.exit(1);
|
|
1174
|
+
}
|
|
1175
|
+
const hello = bridge.status.hello;
|
|
1176
|
+
console.log(`socket ok plugin v${hello?.pluginVersion} on "${hello?.fileName}"`);
|
|
1177
|
+
await bridge.close();
|
|
1178
|
+
}
|
|
1179
|
+
async function waitFor(predicate, timeoutMs) {
|
|
1180
|
+
const deadline = Date.now() + timeoutMs;
|
|
1181
|
+
while (Date.now() < deadline) {
|
|
1182
|
+
if (predicate()) return true;
|
|
1183
|
+
await new Promise((resolve5) => setTimeout(resolve5, 100));
|
|
1184
|
+
}
|
|
1185
|
+
return predicate();
|
|
1186
|
+
}
|