들어가며
AI 에이전트가 Figma 디자인 파일을 읽고 코드를 생성하는 워크플로우는 이미 현실이 되었습니다. Claude, Cursor, Windsurf 같은 도구들이 MCP(Model Context Protocol)를 통해 외부 시스템과 연결되고, Figma MCP 서버도 여러 개 등장했습니다.
그런데 직접 써보면 금방 느끼게 됩니다. 연결이 자꾸 끊긴다. SSE 연결이 예고 없이 사라지고, WebSocket이 무한 재접속 루프에 빠지고, mcp-remote 어댑터를 끼워야 겨우 돌아가는 상황. 이 문제들이 단순한 버그가 아니라 프로토콜 수준의 구조적 한계에서 온다는 걸 알게 되면서, 직접 만들기로 했습니다.
1. 기존 생태계의 문제 분석
현황: 모든 Figma MCP 서버가 같은 문제를 가지고 있다
주요 Figma MCP 서버들을 분석해보면 패턴이 보입니다:
| 프로젝트 | MCP 전송 | 문제점 |
|---|---|---|
| figma-console-mcp (southleft) | stdio + 레거시 SSE | WebSocket 끊김, EADDRINUSE 크래시 |
| cursor-talk-to-figma-mcp (grab) | stdio + 별도 WebSocket | WebSocket 끊김 시 전체 멈춤 |
| Figma-Context-MCP (GLips) | stdio 전용 | 원격 사용 불가 |
| Figma 공식 MCP | stdio 전용 | 코드 생성 특화, 원격 미지원 |
공통점: 전부 stdio(로컬 전용) 또는 레거시 SSE(2024-11-05 스펙)를 사용합니다.
가장 기능이 많은 figma-console-mcp를 깊이 들여다보면
이 서버는 Local Mode와 Remote Mode 두 가지를 지원합니다:
Local Mode에서는 MCP 클라이언트↔서버가 stdio로 통신하고, 서버↔Figma 플러그인이 WebSocket(포트 9223~9232)으로 연결됩니다. 문제는 플러그인 연결이 끊길 때 500ms 간격으로 공격적인 재접속을 시도하면서 무한 교체 루프에 빠진다는 것. 멀티 인스턴스를 띄우면 EADDRINUSE로 크래시하는 것도 이 구조 때문입니다.
Remote Mode는 더 심각합니다. /sse + /messages 두 개의 엔드포인트를 사용하는 레거시 SSE 전송인데, 이 스펙은 2025-03-26에 공식적으로 Streamable HTTP로 대체되었습니다. Claude Code의 --transport sse에 버그가 있어 OAuth 완료 후 연결이 실패하고, mcp-remote 어댑터로 우회해야 하는 상황입니다.
레거시 SSE의 근본적 한계
단순히 "옛날 스펙"이라서가 아닙니다. 구조적으로 해결할 수 없는 문제들이 있습니다:
- 2개 엔드포인트 관리 —
/sse(서버→클라이언트)와/messages(클라이언트→서버)를 별도로 관리해야 합니다. 하나가 끊기면 다른 하나도 의미가 없어지는데, 이 동기화가 어렵습니다. - 세션 개념 없음 — 연결이 끊겼다 다시 붙으면 서버 입장에서는 새로운 클라이언트입니다. 진행 중이던 작업 상태가 날아갑니다.
- 메시지 유실 — SSE 연결이 끊기는 순간 서버가 보내던 메시지는 그냥 사라집니다. 재전달 메커니즘이 없습니다.
2. Streamable HTTP가 해결하는 것
MCP 2025-03-26 스펙에서 도입된 Streamable HTTP는 이 문제들을 프로토콜 수준에서 해결합니다.
단일 엔드포인트
| 전송 방식 | 엔드포인트 | 관리 포인트 |
|---|---|---|
| 레거시 SSE | /sse (GET) + /messages (POST) | 2개 |
| Streamable HTTP | /mcp (POST + GET + DELETE) | 1개 |
클라이언트는 /mcp 하나만 알면 됩니다. POST로 요청을 보내고, GET으로 서버 push를 받고, DELETE로 세션을 종료합니다.
Mcp-Session-Id로 세션 관리
- 클라이언트가
POST /mcp로InitializeRequest를 보냅니다 (세션 ID 없음) - 서버가
200 OK와 함께Mcp-Session-Id: session-abc123을 응답합니다 - 이후 모든 요청에
Mcp-Session-Id를 포함합니다 - 세션 만료 시
404→ 클라이언트가 새 세션을 시작합니다
서버가 클라이언트를 식별할 수 있으니, 연결이 잠시 끊겨도 세션 상태를 유지할 수 있습니다.
Last-Event-ID로 연결 재개
이게 핵심입니다. SSE 스트림의 각 이벤트에 ID를 부여하고, 연결이 끊겼을 때 마지막으로 받은 이벤트 ID를 보내면 서버가 그 이후 메시지를 재전달합니다:
- 서버가 클라이언트에 SSE 이벤트를 전송합니다 (
id: evt-42) - 연결이 끊깁니다
- 클라이언트가
GET /mcp+Last-Event-ID: evt-42로 재연결합니다 - 서버가
evt-42이후 메시지를 모두 재전달합니다
네트워크가 불안정해도 메시지가 유실되지 않습니다.
3. 구현: 설계 결정들
3.1 세션 아키텍처 — 공유와 격리의 균형
처음에 고민했던 부분입니다. MCP SDK는 Server와 Transport를 1:1로 강제합니다. 그런데 Figma API의 rate limit은 PAT(Personal Access Token) 단위입니다. 세션마다 독립적인 Figma 클라이언트를 만들면 rate limit을 공유할 수 없습니다.
결론: Rate Limiter와 Figma Client는 전 세션 공유, MCP Server와 Transport는 세션별 격리.
// 모듈 스코프 — 전 세션 공유
let sharedRateLimiter: FigmaRateLimiter;
let sharedFigmaClient: FigmaClient;
// 세션 생성 시 — 세션별 격리
const server = createMcpServer(sharedFigmaClient); // 새 McpServer
const transport = new StreamableHTTPServerTransport({ ... }); // 새 Transport
await server.connect(transport);
3.2 3-Tier Rate Limiter
Figma API는 엔드포인트마다 rate limit이 다릅니다. 공식 문서에 정확한 수치가 없어서, 실측 기반으로 보수적인 값을 설정했습니다:
| Tier | 대상 엔드포인트 | 설정 한도 | 실측 범위 |
|---|---|---|---|
| TIER_1 | files, nodes, images | 8 req/min | 10~20 |
| TIER_2 | variables | 20 req/min | 25~100 |
| TIER_3 | components, styles | 40 req/min | 50~150 |
슬라이딩 윈도우 방식으로, 1분 창 안에서 요청 수를 추적합니다. 한도에 도달하면 가장 오래된 요청이 윈도우에서 빠질 때까지 대기 큐에 넣고, 50ms 버퍼를 두어 경계 조건에서의 429 에러를 방지합니다.
async acquire(tier: FigmaApiTier): Promise<void> {
this.cleanup(tier); // 만료된 타임스탬프 제거
const timestamps = this.timestamps.get(tier)!;
const limit = this.limits.get(tier)!;
if (timestamps.length >= limit) {
// 대기 — 가장 오래된 요청이 윈도우에서 빠질 때까지
await new Promise<void>((resolve) => {
this.waitQueue.get(tier)!.push(resolve);
const oldest = timestamps[0];
const waitMs = WINDOW_MS - (Date.now() - oldest) + 50;
setTimeout(() => this.tryRelease(tier), waitMs);
});
}
timestamps.push(Date.now());
}
3.3 디자인 토큰 추출 — 이중 경로 설계
Figma Variables API는 Enterprise 플랜에서만 사용 가능합니다. 일반 플랜 사용자가 403을 받았을 때 에러로 끝나면 안 됩니다.
Path A (Variables API) → 실패 시 Path B (Styles API)로 자동 폴백:
const variablesResult = await figmaClient.getLocalVariables(file_key);
if (variablesResult) {
// Path A: Variables API — 색상, 스페이싱 토큰 추출
source = 'variables_api';
// ...
} else {
// Path B: Styles API — 색상, 타이포, 이펙트 추출
source = 'styles_api';
if (wantSpacing) {
warnings.push('Spacing tokens require Variables API (Enterprise plan).');
}
// ...
}
getLocalVariables()에서 FigmaPermissionError(403)을 잡으면 null을 반환하도록 설계했습니다. 에러를 던지는 대신 데이터 없음으로 변환해서, 호출하는 쪽이 자연스럽게 폴백 분기를 탈 수 있게 합니다.
출력 포맷도 3가지를 지원합니다:
raw— 토큰 원본 데이터 (다른 도구와 연계 시)css_variables—:root { --color-primary: #3B75F8; }형태tailwind—{ theme: { extend: { colors: { ... } } } }형태
3.4 Figma → CSS 자동 변환
get_node_styles 도구는 Figma 노드의 시각적 속성을 CSS 프로퍼티로 매핑합니다. Figma의 Auto Layout이 CSS Flexbox와 개념적으로 대응되는 점을 활용했습니다:
if (node.layoutMode && node.layoutMode !== 'NONE') {
css['display'] = 'flex';
css['flex-direction'] = node.layoutMode === 'VERTICAL' ? 'column' : 'row';
if (node.itemSpacing !== undefined) css['gap'] = `${node.itemSpacing}px`;
// primaryAxisAlignItems → justify-content
// counterAxisAlignItems → align-items
}
fills, strokes, effects, typography, padding, border-radius, opacity까지 — 노드 하나의 시각적 속성 대부분을 CSS로 변환합니다.
4. 기존 대비 비교
| 항목 | figma-console-mcp | Figma Bridge MCP |
|---|---|---|
| MCP 전송 (Remote) | 레거시 SSE (deprecated) | Streamable HTTP (현행) |
| 엔드포인트 | /sse + /messages (2개) | /mcp (1개) |
| 세션 관리 | 고정 세션 ID 1개 | 클라이언트별 Mcp-Session-Id |
| 연결 재개 | 미지원 | Last-Event-ID + 메시지 재전달 |
| 멀티 테넌트 | OAuth 토큰 공유 | 세션별 독립 인증 |
| 호환성 | mcp-remote 어댑터 필요 | 네이티브 호환 |
5. 마치며
이 프로젝트의 핵심은 "Figma MCP 서버를 하나 더 만든 것"이 아니라, 연결 불안정 문제의 원인이 프로토콜 수준에 있다는 것을 파악하고, 해당 계층에서 해결한 것입니다.
기존 서버들이 레거시 SSE 위에서 재접속 로직을 덧대고, 어댑터로 우회하는 동안 — 문제의 근본 원인인 전송 계층 자체를 현행 스펙으로 교체하는 접근을 택했습니다.
버그 하나를 고칠 때도, 그 버그가 어느 계층에서 발생하는지를 먼저 파악하는 습관이 중요하다고 생각합니다. 코드 레벨에서 아무리 패치해도, 프로토콜이나 아키텍처 수준의 문제는 해결되지 않으니까요.
기술 스택: TypeScript, Express, @modelcontextprotocol/sdk, Figma REST API