Skip to content

Commit 0eec93a

Browse files
committed
feat(m2): write-parity kickoff — idempotency infra + 3 tools
Establishes the write pattern on top of the existing dispatch/relay/sandbox chain: - Idempotency (beats figma-mcp-go, which has none): server injects a stable requestId for WRITE_TOOLS before the retrying dispatch; plugin wraps each write handler in an LRU/TTL(60s) cache (idempotency.ts) so replayed retries return the original result instead of re-applying. - Three bellwether tools: set_fills (paint write), set_text (async loadFontAsync, loads all fonts for mixed text), create_frame (create + parent + new id; removes the orphan frame on invalid parent). shared: MutateResult / CreateResult. - Writes reuse the dispatchTool fall-through (election + resilient reconnect); only requestId injection is special-cased in the server. +18 tests (296 total): idempotency 5x-replay acceptance, all three handlers, tool defs. lint / typecheck / build green.
1 parent 1f49745 commit 0eec93a

16 files changed

Lines changed: 580 additions & 2 deletions

File tree

packages/plugin/src/code.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createPluginContextEvent, SELECTION_DETAIL_LIMIT } from '@figma-mcp-relay/shared';
22

33
import { dispatchSandboxMessage, type SandboxHandlers } from './dispatcher.js';
4+
import { createIdempotencyCache, idempotent } from './idempotency.js';
5+
import { createCreateFrameHandler } from './handlers/create-frame.js';
46
import { createGetAnnotationsHandler } from './handlers/get-annotations.js';
57
import { createGetDesignContextHandler } from './handlers/get-design-context.js';
68
import { createGetDocumentHandler } from './handlers/get-document.js';
@@ -21,6 +23,8 @@ import { createPingHandler } from './handlers/ping.js';
2123
import { createScanNodesByTypesHandler } from './handlers/scan-nodes-by-types.js';
2224
import { createScanTextNodesHandler } from './handlers/scan-text-nodes.js';
2325
import { createSearchNodesHandler } from './handlers/search-nodes.js';
26+
import { createSetFillsHandler } from './handlers/set-fills.js';
27+
import { createSetTextHandler } from './handlers/set-text.js';
2428

2529
figma.showUI(__html__, { width: 320, height: 400, themeColors: true });
2630

@@ -50,6 +54,8 @@ const emitContext = (): void => {
5054
figma.ui.postMessage(event);
5155
};
5256

57+
const idempotencyCache = createIdempotencyCache();
58+
5359
const handlers: SandboxHandlers = {
5460
ping: createPingHandler(figma),
5561
get_selection: createGetSelectionHandler(figma),
@@ -71,6 +77,10 @@ const handlers: SandboxHandlers = {
7177
list_files: createListFilesHandler(figma),
7278
get_design_context: createGetDesignContextHandler(figma),
7379
get_screenshot: createGetScreenshotHandler(figma),
80+
// Write tools: wrapped with idempotency so retries (same requestId) apply the effect once.
81+
set_fills: idempotent(idempotencyCache, createSetFillsHandler(figma)),
82+
set_text: idempotent(idempotencyCache, createSetTextHandler(figma)),
83+
create_frame: idempotent(idempotencyCache, createCreateFrameHandler(figma)),
7484
};
7585

7686
figma.ui.onmessage = (raw: unknown) => {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { CreateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createCreateFrameHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as {
9+
parentId?: unknown;
10+
name?: unknown;
11+
x?: unknown;
12+
y?: unknown;
13+
width?: unknown;
14+
height?: unknown;
15+
};
16+
17+
const frame = figmaCtx.createFrame();
18+
if (typeof p.name === 'string') frame.name = p.name;
19+
if (typeof p.width === 'number' && typeof p.height === 'number') {
20+
frame.resize(p.width, p.height);
21+
}
22+
if (typeof p.x === 'number') frame.x = p.x;
23+
if (typeof p.y === 'number') frame.y = p.y;
24+
25+
if (typeof p.parentId === 'string') {
26+
const parent = await figmaCtx.getNodeByIdAsync(p.parentId);
27+
if (parent === null || !('appendChild' in parent)) {
28+
frame.remove();
29+
throw new Error(`create_frame: parent ${p.parentId} not found or cannot contain children`);
30+
}
31+
(parent as ChildrenMixin).appendChild(frame);
32+
} else {
33+
figmaCtx.currentPage.appendChild(frame);
34+
}
35+
36+
const result: CreateResult = { ok: true, nodeId: frame.id, name: frame.name, type: frame.type };
37+
return result;
38+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { MutateResult, SerializedPaint } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
/** Convert a serialized paint back to a Figma Paint. Only SOLID is supported for writes so far. */
6+
const toFigmaPaint = (paint: SerializedPaint): Paint => {
7+
if (paint.type !== 'SOLID') {
8+
throw new TypeError(`set_fills: only SOLID paints are supported (got ${paint.type})`);
9+
}
10+
return {
11+
type: 'SOLID',
12+
color: { r: paint.color.r, g: paint.color.g, b: paint.color.b },
13+
opacity: paint.opacity,
14+
visible: paint.visible,
15+
};
16+
};
17+
18+
export const createSetFillsHandler =
19+
(figmaCtx: typeof figma): SandboxToolHandler =>
20+
async params => {
21+
const p = (params ?? {}) as { nodeId?: unknown; fills?: unknown };
22+
if (typeof p.nodeId !== 'string') throw new TypeError('set_fills: nodeId must be a string');
23+
if (!Array.isArray(p.fills)) throw new TypeError('set_fills: fills must be an array');
24+
25+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
26+
if (node === null || !('fills' in node)) {
27+
throw new Error(`set_fills: node ${p.nodeId} not found or cannot have fills`);
28+
}
29+
(node as GeometryMixin).fills = (p.fills as SerializedPaint[]).map(toFigmaPaint);
30+
31+
const result: MutateResult = { ok: true, nodeId: node.id };
32+
return result;
33+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
export const createSetTextHandler =
6+
(figmaCtx: typeof figma): SandboxToolHandler =>
7+
async params => {
8+
const p = (params ?? {}) as { nodeId?: unknown; characters?: unknown };
9+
if (typeof p.nodeId !== 'string') throw new TypeError('set_text: nodeId must be a string');
10+
if (typeof p.characters !== 'string') throw new TypeError('set_text: characters must be a string');
11+
12+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
13+
if (node === null || node.type !== 'TEXT') {
14+
throw new Error(`set_text: node ${p.nodeId} is not a TEXT node`);
15+
}
16+
const text = node as TextNode;
17+
18+
// Figma requires every font in the node to be loaded before mutating characters.
19+
const fonts =
20+
text.fontName === figmaCtx.mixed && text.characters.length > 0
21+
? text.getRangeAllFontNames(0, text.characters.length)
22+
: [text.fontName as FontName];
23+
await Promise.all(fonts.map(font => figmaCtx.loadFontAsync(font)));
24+
25+
text.characters = p.characters;
26+
27+
const result: MutateResult = { ok: true, nodeId: text.id };
28+
return result;
29+
};

packages/plugin/src/idempotency.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { SandboxToolHandler } from './dispatcher.js';
2+
3+
export const DEFAULT_IDEMPOTENCY_TTL_MS = 60_000;
4+
5+
interface Entry {
6+
result: unknown;
7+
ts: number;
8+
}
9+
10+
export interface IdempotencyCache {
11+
/** Run fn unless this requestId already produced a result within the TTL; then replay it. */
12+
run(requestId: string | undefined, fn: () => unknown | Promise<unknown>): Promise<unknown>;
13+
size(): number;
14+
}
15+
16+
/**
17+
* Dedupe write side-effects across retries. The server reuses one requestId for all retries of a
18+
* logical tool call (our resilience layer retries on transport errors), so a replayed write must
19+
* not apply twice — it returns the original result instead.
20+
*/
21+
export const createIdempotencyCache = (
22+
ttlMs: number = DEFAULT_IDEMPOTENCY_TTL_MS,
23+
now: () => number = Date.now,
24+
): IdempotencyCache => {
25+
const map = new Map<string, Entry>();
26+
27+
const prune = (): void => {
28+
const cutoff = now() - ttlMs;
29+
for (const [key, entry] of map) {
30+
if (entry.ts < cutoff) map.delete(key);
31+
}
32+
};
33+
34+
return {
35+
async run(requestId, fn) {
36+
if (requestId === undefined) return fn(); // no key → no dedup
37+
const hit = map.get(requestId);
38+
if (hit !== undefined && now() - hit.ts < ttlMs) return hit.result;
39+
const result = await fn();
40+
map.set(requestId, { result, ts: now() });
41+
prune();
42+
return result;
43+
},
44+
size: () => map.size,
45+
};
46+
};
47+
48+
/**
49+
* Wrap a write handler so repeated calls carrying the same `requestId` apply the effect once.
50+
* Calls without a requestId run normally (reads, or writes invoked without idempotency).
51+
*/
52+
export const idempotent =
53+
(cache: IdempotencyCache, handler: SandboxToolHandler): SandboxToolHandler =>
54+
params => {
55+
const requestId = (params as { requestId?: unknown } | null)?.requestId;
56+
return cache.run(typeof requestId === 'string' ? requestId : undefined, () => handler(params));
57+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { CreateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createCreateFrameHandler } from '../../src/handlers/create-frame.js';
5+
6+
interface FakeFrame {
7+
id: string;
8+
name: string;
9+
type: string;
10+
x: number;
11+
y: number;
12+
width: number;
13+
height: number;
14+
resize: (w: number, h: number) => void;
15+
remove: () => void;
16+
}
17+
18+
const makeFrame = (): FakeFrame => {
19+
const frame: FakeFrame = {
20+
id: '2:1',
21+
name: 'Frame',
22+
type: 'FRAME',
23+
x: 0,
24+
y: 0,
25+
width: 100,
26+
height: 100,
27+
resize(w, h) {
28+
frame.width = w;
29+
frame.height = h;
30+
},
31+
remove: vi.fn<() => void>(),
32+
};
33+
return frame;
34+
};
35+
36+
const fakeFigma = (
37+
frame: FakeFrame,
38+
currentPage: { appendChild: (n: unknown) => void },
39+
lookup: Record<string, unknown> = {},
40+
): typeof figma =>
41+
({
42+
createFrame: () => frame,
43+
currentPage,
44+
getNodeByIdAsync: async (id: string) => lookup[id] ?? null,
45+
}) as unknown as typeof figma;
46+
47+
describe('create_frame handler', () => {
48+
it('creates a sized/named frame on the current page by default', async () => {
49+
const frame = makeFrame();
50+
const currentPage = { appendChild: vi.fn<(n: unknown) => void>() };
51+
const handler = createCreateFrameHandler(fakeFigma(frame, currentPage));
52+
const result = (await handler({ name: 'Card', width: 200, height: 120, x: 10, y: 20 })) as CreateResult;
53+
54+
expect(frame.name).toBe('Card');
55+
expect([frame.width, frame.height]).toEqual([200, 120]);
56+
expect([frame.x, frame.y]).toEqual([10, 20]);
57+
expect(currentPage.appendChild).toHaveBeenCalledWith(frame);
58+
expect(result).toEqual({ ok: true, nodeId: '2:1', name: 'Card', type: 'FRAME' });
59+
});
60+
61+
it('appends into a given parent', async () => {
62+
const frame = makeFrame();
63+
const parent = { id: '1:1', appendChild: vi.fn<(n: unknown) => void>() };
64+
const handler = createCreateFrameHandler(
65+
fakeFigma(frame, { appendChild: vi.fn<(n: unknown) => void>() }, { '1:1': parent }),
66+
);
67+
await handler({ parentId: '1:1' });
68+
expect(parent.appendChild).toHaveBeenCalledWith(frame);
69+
});
70+
71+
it('removes the orphan frame and throws when the parent is invalid', async () => {
72+
const frame = makeFrame();
73+
const handler = createCreateFrameHandler(fakeFigma(frame, { appendChild: vi.fn<(n: unknown) => void>() }, {}));
74+
await expect(handler({ parentId: '9:9' })).rejects.toThrow(/parent/);
75+
expect(frame.remove).toHaveBeenCalled();
76+
});
77+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { createSetFillsHandler } from '../../src/handlers/set-fills.js';
5+
6+
const fakeFigma = (lookup: Record<string, unknown>): typeof figma =>
7+
({ getNodeByIdAsync: async (id: string) => lookup[id] ?? null }) as unknown as typeof figma;
8+
9+
describe('set_fills handler', () => {
10+
it('applies SOLID fills to a node and returns ok + nodeId', async () => {
11+
const node = { id: '1:1', fills: [] as unknown };
12+
const handler = createSetFillsHandler(fakeFigma({ '1:1': node }));
13+
const result = (await handler({
14+
nodeId: '1:1',
15+
fills: [{ type: 'SOLID', visible: true, opacity: 1, color: { r: 1, g: 0, b: 0 } }],
16+
})) as MutateResult;
17+
18+
expect(node.fills).toEqual([
19+
{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: 1, visible: true },
20+
]);
21+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
22+
});
23+
24+
it('throws on non-SOLID paint, missing node, or bad input', async () => {
25+
const node = { id: '1:1', fills: [] as unknown };
26+
const handler = createSetFillsHandler(fakeFigma({ '1:1': node }));
27+
await expect(
28+
handler({ nodeId: '1:1', fills: [{ type: 'GRADIENT_LINEAR', visible: true, opacity: 1 }] }),
29+
).rejects.toThrow(/SOLID/);
30+
await expect(handler({ nodeId: '9:9', fills: [] })).rejects.toThrow(/not found/);
31+
await expect(handler({ fills: [] })).rejects.toThrow(/nodeId/);
32+
await expect(handler({ nodeId: '1:1', fills: 'x' })).rejects.toThrow(/fills/);
33+
});
34+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createSetTextHandler } from '../../src/handlers/set-text.js';
5+
6+
const MIXED = Symbol('figma.mixed');
7+
8+
const fakeFigma = (
9+
lookup: Record<string, unknown>,
10+
loadFontAsync = vi.fn<() => Promise<void>>(async () => {}),
11+
): typeof figma =>
12+
({
13+
mixed: MIXED,
14+
loadFontAsync,
15+
getNodeByIdAsync: async (id: string) => lookup[id] ?? null,
16+
}) as unknown as typeof figma;
17+
18+
describe('set_text handler', () => {
19+
it('loads the font then replaces characters', async () => {
20+
const node = {
21+
id: '1:1',
22+
type: 'TEXT',
23+
fontName: { family: 'Inter', style: 'Regular' },
24+
characters: 'old',
25+
};
26+
const loadFontAsync = vi.fn<() => Promise<void>>(async () => {});
27+
const handler = createSetTextHandler(fakeFigma({ '1:1': node }, loadFontAsync));
28+
const result = (await handler({ nodeId: '1:1', characters: 'new' })) as MutateResult;
29+
30+
expect(loadFontAsync).toHaveBeenCalledWith({ family: 'Inter', style: 'Regular' });
31+
expect(node.characters).toBe('new');
32+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
33+
});
34+
35+
it('loads every font for mixed-font text before mutating', async () => {
36+
const fonts = [
37+
{ family: 'Inter', style: 'Regular' },
38+
{ family: 'Inter', style: 'Bold' },
39+
];
40+
const node = {
41+
id: '1:2',
42+
type: 'TEXT',
43+
fontName: MIXED,
44+
characters: 'ab',
45+
getRangeAllFontNames: () => fonts,
46+
};
47+
const loadFontAsync = vi.fn<() => Promise<void>>(async () => {});
48+
const handler = createSetTextHandler(fakeFigma({ '1:2': node }, loadFontAsync));
49+
await handler({ nodeId: '1:2', characters: 'cd' });
50+
51+
expect(loadFontAsync).toHaveBeenCalledTimes(2);
52+
expect(node.characters).toBe('cd');
53+
});
54+
55+
it('throws for non-TEXT nodes and bad input', async () => {
56+
const handler = createSetTextHandler(
57+
fakeFigma({ '1:1': { id: '1:1', type: 'RECTANGLE' } }),
58+
);
59+
await expect(handler({ nodeId: '1:1', characters: 'x' })).rejects.toThrow(/TEXT/);
60+
await expect(handler({ nodeId: '1:1' })).rejects.toThrow(/characters/);
61+
});
62+
});

0 commit comments

Comments
 (0)