Skip to content

Commit b5724ad

Browse files
authored
feat: add progress notifications and async tree walker (#305)
* refactor!: switch HTTP transport to stateless mode Each request creates its own McpServer and transport with sessionIdGenerator: undefined. Removes the session registry, SSE transport, /messages endpoint, and progress notification interval. StreamableHTTP is served at both /mcp and /sse for backward compatibility. GET and DELETE return 405. * docs: update transport references after stateless HTTP refactor * fix: catch async errors in Express 4 route handler Express 4 does not catch rejected promises from async handlers. Without a try/catch, a failure in connect() or handleRequest() causes an unhandled rejection that crashes the process. * chore: upgrade Express to v5 Aligns with the MCP SDK which already depends on Express 5. Express 5 natively catches async route handler rejections, so the manual try/catch added in the previous commit is no longer needed. * fix: add per-request cleanup and JSON-RPC error middleware Close transport and McpServer when the response ends, matching the SDK's recommended pattern for stateless servers. Add Express error-handling middleware that returns a JSON-RPC error response instead of Express's default HTML 500. * fix: use SDK's createMcpExpressApp for DNS rebinding protection Replace raw express() with the SDK's createMcpExpressApp(), which applies localhost DNS rebinding protection middleware automatically. This also handles express.json() globally, removing the need for per-route body parsing. * feat: add progress notifications to tool handlers Thread the SDK's RequestHandlerExtra through registerTool wiring to tool handlers. Add sendProgress helper that sends notifications/progress when the client provides a progressToken. get_figma_data reports progress after fetching from the Figma API and after processing design data (2 phases). download_figma_images reports progress after resolving download items and after completing all downloads (2 phases). * refactor: extract sendProgress helper into mcp/progress.ts Break the circular dependency where tools imported from their own registration module (mcp/index.ts -> tools -> mcp/index.ts). * fix: send initial progress notification before slow I/O Progress was only sent after the Figma API fetch completed, which defeats the purpose — the timeout fires during the fetch. Now sends progress at 0/3 before the slow operation starts, so clients with resetTimeoutOnProgress get an immediate signal. * feat: add progress heartbeat during long Figma API calls Figma API calls can take up to ~55 seconds. A single progress notification before the call isn't enough if the client timeout is shorter. startProgressHeartbeat sends periodic notifications every 5 seconds during slow I/O, keeping clients with resetTimeoutOnProgress alive. The heartbeat stops as soon as the operation completes or errors. * fix: add progress between synchronous processing phases The tree walker and YAML serializer are synchronous and block the event loop, preventing heartbeat intervals from firing. Send progress notifications between each phase so the client gets a fresh timeout window before each synchronous step: API fetch -> simplify -> serialize -> return. * feat: make tree walker async to unblock event loop The synchronous tree walk blocks the event loop for large Figma files (75MB+), preventing progress heartbeats from firing, SIGINT from being handled, and the HTTP server from shutting down cleanly. Make extractFromDesign and simplifyRawFigmaObject async. The tree walker yields the event loop every 500 nodes via setImmediate, allowing heartbeats and signal handlers to run during processing. Also fix progress total (3 -> 4), add diagnostic logging to sendProgress, and force-close keep-alive connections on shutdown. * fix: run heartbeat during simplification phase The heartbeat was only active during the Figma API call but not during the tree walk simplification, which is the actual slow part for large files. Now that the tree walker yields the event loop, the heartbeat can fire during simplification. * feat: show node count in heartbeat during simplification Report how many nodes have been processed in each heartbeat message (e.g. "Simplifying design data (3500 nodes processed)"). The heartbeat message function is now evaluated dynamically on each tick. * fix: tighten yield interval and heartbeat frequency Reduce yield interval from 500 to 100 nodes — later nodes in large files are deeper and more complex, so 500 nodes could take longer than the heartbeat interval. Reduce heartbeat from 5s to 3s for more margin against 10s client timeouts. * fix: gracefully close MCP connections before server shutdown Track active per-request transport/server pairs. On shutdown, close each transport and server cleanly before terminating HTTP connections. This gives clients a proper stream close instead of an abrupt disconnect. * chore: remove diagnostic logging from sendProgress * test: add tree walker regression tests Test extractFromDesign and simplifyRawFigmaObject with a representative node tree covering visibility filtering, depth limits, child recursion, VECTOR->IMAGE-SVG type mapping, and global style variable accumulation. Verified against both the original sync and new async implementations to confirm the async conversion produces identical output.
1 parent 9dfb1cb commit b5724ad

10 files changed

Lines changed: 317 additions & 32 deletions

src/extractors/design-extractor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@ import { extractFromDesign } from "./node-walker.js";
1414
/**
1515
* Extract a complete SimplifiedDesign from raw Figma API response using extractors.
1616
*/
17-
export function simplifyRawFigmaObject(
17+
export async function simplifyRawFigmaObject(
1818
apiResponse: GetFileResponse | GetFileNodesResponse,
1919
nodeExtractors: ExtractorFn[],
2020
options: TraversalOptions = {},
21-
): SimplifiedDesign {
21+
): Promise<SimplifiedDesign> {
2222
// Extract components, componentSets, and raw nodes from API response
2323
const { metadata, rawNodes, components, componentSets, extraStyles } =
2424
parseAPIResponse(apiResponse);
2525

2626
// Process nodes using the flexible extractor system
2727
const globalVars: TraversalContext["globalVars"] = { styles: {}, extraStyles };
28-
const { nodes: extractedNodes, globalVars: finalGlobalVars } = extractFromDesign(
28+
const { nodes: extractedNodes, globalVars: finalGlobalVars } = await extractFromDesign(
2929
rawNodes,
3030
nodeExtractors,
3131
options,

src/extractors/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type {
88
} from "./types.js";
99

1010
// Core traversal function
11-
export { extractFromDesign } from "./node-walker.js";
11+
export { extractFromDesign, getNodesProcessed } from "./node-walker.js";
1212

1313
// Design-level extraction (unified nodes + components)
1414
export { simplifyRawFigmaObject } from "./design-extractor.js";

src/extractors/node-walker.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@ import type {
99
SimplifiedNode,
1010
} from "./types.js";
1111

12+
// Yield the event loop every N nodes so heartbeats, SIGINT, and
13+
// other async work can run during large file processing.
14+
// Yield the event loop every N nodes so heartbeats, SIGINT, and
15+
// other async work can run during large file processing.
16+
const YIELD_INTERVAL = 100;
17+
let nodesProcessed = 0;
18+
19+
export function getNodesProcessed(): number {
20+
return nodesProcessed;
21+
}
22+
23+
async function maybeYield(): Promise<void> {
24+
nodesProcessed++;
25+
if (nodesProcessed % YIELD_INTERVAL === 0) {
26+
await new Promise<void>((resolve) => setImmediate(resolve));
27+
}
28+
}
29+
1230
/**
1331
* Extract data from Figma nodes using a flexible, single-pass approach.
1432
*
@@ -18,21 +36,25 @@ import type {
1836
* @param globalVars - Global variables for style deduplication
1937
* @returns Object containing processed nodes and updated global variables
2038
*/
21-
export function extractFromDesign(
39+
export async function extractFromDesign(
2240
nodes: FigmaDocumentNode[],
2341
extractors: ExtractorFn[],
2442
options: TraversalOptions = {},
2543
globalVars: GlobalVars = { styles: {} },
26-
): { nodes: SimplifiedNode[]; globalVars: GlobalVars } {
44+
): Promise<{ nodes: SimplifiedNode[]; globalVars: GlobalVars }> {
2745
const context: TraversalContext = {
2846
globalVars,
2947
currentDepth: 0,
3048
};
3149

32-
const processedNodes = nodes
33-
.filter((node) => shouldProcessNode(node, options))
34-
.map((node) => processNodeWithExtractors(node, extractors, context, options))
35-
.filter((node): node is SimplifiedNode => node !== null);
50+
nodesProcessed = 0;
51+
52+
const processedNodes: SimplifiedNode[] = [];
53+
for (const node of nodes) {
54+
if (!shouldProcessNode(node, options)) continue;
55+
const result = await processNodeWithExtractors(node, extractors, context, options);
56+
if (result !== null) processedNodes.push(result);
57+
}
3658

3759
return {
3860
nodes: processedNodes,
@@ -43,16 +65,18 @@ export function extractFromDesign(
4365
/**
4466
* Process a single node with all provided extractors in one pass.
4567
*/
46-
function processNodeWithExtractors(
68+
async function processNodeWithExtractors(
4769
node: FigmaDocumentNode,
4870
extractors: ExtractorFn[],
4971
context: TraversalContext,
5072
options: TraversalOptions,
51-
): SimplifiedNode | null {
73+
): Promise<SimplifiedNode | null> {
5274
if (!shouldProcessNode(node, options)) {
5375
return null;
5476
}
5577

78+
await maybeYield();
79+
5680
// Always include base metadata
5781
const result: SimplifiedNode = {
5882
id: node.id,
@@ -75,10 +99,12 @@ function processNodeWithExtractors(
7599

76100
// Use the same pattern as the existing parseNode function
77101
if (hasValue("children", node) && node.children.length > 0) {
78-
const children = node.children
79-
.filter((child) => shouldProcessNode(child, options))
80-
.map((child) => processNodeWithExtractors(child, extractors, childContext, options))
81-
.filter((child): child is SimplifiedNode => child !== null);
102+
const children: SimplifiedNode[] = [];
103+
for (const child of node.children) {
104+
if (!shouldProcessNode(child, options)) continue;
105+
const processed = await processNodeWithExtractors(child, extractors, childContext, options);
106+
if (processed !== null) children.push(processed);
107+
}
82108

83109
if (children.length > 0) {
84110
// Allow custom logic to modify parent and control which children to include

src/mcp/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { FigmaService, type FigmaAuthOptions } from "../services/figma.js";
33
import { Logger } from "../utils/logger.js";
4+
import type { ToolExtra } from "./progress.js";
45
import {
56
downloadFigmaImagesTool,
67
getFigmaDataTool,
@@ -57,8 +58,8 @@ function registerTools(
5758
inputSchema: getFigmaDataTool.parametersSchema,
5859
annotations: { readOnlyHint: true },
5960
},
60-
(params: GetFigmaDataParams) =>
61-
getFigmaDataTool.handler(params, figmaService, options.outputFormat),
61+
(params: GetFigmaDataParams, extra: ToolExtra) =>
62+
getFigmaDataTool.handler(params, figmaService, options.outputFormat, extra),
6263
);
6364

6465
if (!options.skipImageDownloads) {
@@ -70,8 +71,8 @@ function registerTools(
7071
inputSchema: downloadFigmaImagesTool.parametersSchema,
7172
annotations: { openWorldHint: true },
7273
},
73-
(params: DownloadImagesParams) =>
74-
downloadFigmaImagesTool.handler(params, figmaService, options.imageDir),
74+
(params: DownloadImagesParams, extra: ToolExtra) =>
75+
downloadFigmaImagesTool.handler(params, figmaService, options.imageDir, extra),
7576
);
7677
}
7778
}

src/mcp/progress.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
2+
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
3+
4+
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
5+
6+
/** No-ops silently when the client didn't ask for progress (no progressToken). */
7+
export async function sendProgress(
8+
extra: ToolExtra,
9+
progress: number,
10+
total?: number,
11+
message?: string,
12+
): Promise<void> {
13+
const progressToken = extra._meta?.progressToken;
14+
if (progressToken === undefined) return;
15+
16+
await extra.sendNotification({
17+
method: "notifications/progress",
18+
params: { progressToken, progress, total, message },
19+
});
20+
}
21+
22+
/**
23+
* Send periodic progress notifications during a long-running operation.
24+
* Keeps clients with resetTimeoutOnProgress alive during slow I/O like
25+
* Figma API calls that can take up to ~55 seconds. Returns a stop function
26+
* that must be called when the operation completes or errors.
27+
*/
28+
export function startProgressHeartbeat(
29+
extra: ToolExtra,
30+
message: string | (() => string),
31+
intervalMs = 3_000,
32+
): () => void {
33+
const progressToken = extra._meta?.progressToken;
34+
if (progressToken === undefined) return () => {};
35+
36+
let tick = 0;
37+
const interval = setInterval(() => {
38+
tick++;
39+
const msg = typeof message === "function" ? message() : message;
40+
extra
41+
.sendNotification({
42+
method: "notifications/progress",
43+
params: { progressToken, progress: tick, message: msg },
44+
})
45+
.catch(() => clearInterval(interval));
46+
}, intervalMs);
47+
48+
return () => clearInterval(interval);
49+
}

src/mcp/tools/download-figma-images-tool.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from "path";
22
import { z } from "zod";
33
import { FigmaService } from "../../services/figma.js";
44
import { Logger } from "../../utils/logger.js";
5+
import { sendProgress, startProgressHeartbeat, type ToolExtra } from "../progress.js";
56

67
const parameters = {
78
fileKey: z
@@ -85,7 +86,8 @@ export type DownloadImagesParams = z.infer<typeof parametersSchema>;
8586
async function downloadFigmaImages(
8687
params: DownloadImagesParams,
8788
figmaService: FigmaService,
88-
imageDir?: string,
89+
imageDir: string | undefined,
90+
extra: ToolExtra,
8991
) {
9092
try {
9193
const { fileKey, nodes, localPath, pngScale = 2 } = parametersSchema.parse(params);
@@ -109,6 +111,8 @@ async function downloadFigmaImages(
109111
};
110112
}
111113

114+
await sendProgress(extra, 0, 3, "Resolving image downloads");
115+
112116
// Process nodes: collect unique downloads and track which requests they satisfy
113117
const downloadItems = [];
114118
const downloadToRequests = new Map<number, string[]>(); // download index -> requested filenames
@@ -171,11 +175,20 @@ async function downloadFigmaImages(
171175
}
172176
}
173177

174-
const allDownloads = await figmaService.downloadImages(fileKey, resolvedPath, downloadItems, {
175-
pngScale,
176-
});
178+
await sendProgress(extra, 1, 3, `Resolved ${downloadItems.length} images, downloading`);
179+
const stopHeartbeat = startProgressHeartbeat(extra, "Downloading images");
180+
181+
let allDownloads;
182+
try {
183+
allDownloads = await figmaService.downloadImages(fileKey, resolvedPath, downloadItems, {
184+
pngScale,
185+
});
186+
} finally {
187+
stopHeartbeat();
188+
}
177189

178190
const successCount = allDownloads.filter(Boolean).length;
191+
await sendProgress(extra, 2, 3, `Downloaded ${successCount} images, formatting response`);
179192

180193
// Format results with aliases
181194
const imagesList = allDownloads

src/mcp/tools/get-figma-data-tool.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
simplifyRawFigmaObject,
66
allExtractors,
77
collapseSvgContainers,
8+
getNodesProcessed,
89
} from "~/extractors/index.js";
910
import yaml from "js-yaml";
1011
import { Logger, writeLogs } from "~/utils/logger.js";
12+
import { sendProgress, startProgressHeartbeat, type ToolExtra } from "~/mcp/progress.js";
1113

1214
const parameters = {
1315
fileKey: z
@@ -42,6 +44,7 @@ async function getFigmaData(
4244
params: GetFigmaDataParams,
4345
figmaService: FigmaService,
4446
outputFormat: "yaml" | "json",
47+
extra: ToolExtra,
4548
) {
4649
try {
4750
const { fileKey, nodeId: rawNodeId, depth } = parametersSchema.parse(params);
@@ -55,19 +58,37 @@ async function getFigmaData(
5558
} ${fileKey}`,
5659
);
5760

61+
await sendProgress(extra, 0, 4, "Fetching design data from Figma API");
62+
const stopHeartbeat = startProgressHeartbeat(extra, "Waiting for Figma API response");
63+
5864
// Get raw Figma API response
5965
let rawApiResponse: GetFileResponse | GetFileNodesResponse;
60-
if (nodeId) {
61-
rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth);
62-
} else {
63-
rawApiResponse = await figmaService.getRawFile(fileKey, depth);
66+
try {
67+
if (nodeId) {
68+
rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth);
69+
} else {
70+
rawApiResponse = await figmaService.getRawFile(fileKey, depth);
71+
}
72+
} finally {
73+
stopHeartbeat();
6474
}
6575

76+
await sendProgress(extra, 1, 4, "Fetched design data, simplifying");
77+
const stopSimplifyHeartbeat = startProgressHeartbeat(
78+
extra,
79+
() => `Simplifying design data (${getNodesProcessed()} nodes processed)`,
80+
);
81+
6682
// Use unified design extraction (handles nodes + components consistently)
67-
const simplifiedDesign = simplifyRawFigmaObject(rawApiResponse, allExtractors, {
68-
maxDepth: depth,
69-
afterChildren: collapseSvgContainers,
70-
});
83+
let simplifiedDesign;
84+
try {
85+
simplifiedDesign = await simplifyRawFigmaObject(rawApiResponse, allExtractors, {
86+
maxDepth: depth,
87+
afterChildren: collapseSvgContainers,
88+
});
89+
} finally {
90+
stopSimplifyHeartbeat();
91+
}
7192

7293
writeLogs("figma-simplified.json", simplifiedDesign);
7394

@@ -77,6 +98,8 @@ async function getFigmaData(
7798
} styles`,
7899
);
79100

101+
await sendProgress(extra, 2, 4, "Simplified design, serializing response");
102+
80103
const { nodes, globalVars, ...metadata } = simplifiedDesign;
81104
const result = {
82105
metadata,
@@ -88,6 +111,8 @@ async function getFigmaData(
88111
const formattedResult =
89112
outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result);
90113

114+
await sendProgress(extra, 3, 4, "Serialized, sending response");
115+
91116
Logger.log("Sending result to client");
92117
return {
93118
content: [{ type: "text" as const, text: formattedResult }],

src/server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
1111

1212
let httpServer: Server | null = null;
1313

14+
type ActiveConnection = {
15+
transport: StreamableHTTPServerTransport;
16+
server: McpServer;
17+
};
18+
const activeConnections = new Set<ActiveConnection>();
19+
1420
/**
1521
* Start the MCP server in either stdio or HTTP mode.
1622
*/
@@ -57,7 +63,10 @@ export async function startHttpServer(
5763
Logger.log("Received StreamableHTTP request");
5864
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
5965
const mcpServer = createMcpServer();
66+
const conn: ActiveConnection = { transport, server: mcpServer };
67+
activeConnections.add(conn);
6068
res.on("close", () => {
69+
activeConnections.delete(conn);
6170
transport.close();
6271
mcpServer.close();
6372
});
@@ -114,11 +123,19 @@ export async function stopHttpServer(): Promise<void> {
114123
throw new Error("HTTP server is not running");
115124
}
116125

126+
// Gracefully close all active MCP connections before tearing down the server
127+
for (const conn of activeConnections) {
128+
await conn.transport.close();
129+
await conn.server.close();
130+
}
131+
activeConnections.clear();
132+
117133
return new Promise((resolve, reject) => {
118134
httpServer!.close((err) => {
119135
httpServer = null;
120136
if (err) reject(err);
121137
else resolve();
122138
});
139+
httpServer!.closeAllConnections();
123140
});
124141
}

0 commit comments

Comments
 (0)