Back to Blog

기존 Figma MCP 서버는 왜 불안정한가 — Streamable HTTP로 해결한 이야기

|
MCPFigmaStreamable HTTPTypeScript프로토콜

들어가며

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 + 레거시 SSEWebSocket 끊김, EADDRINUSE 크래시
cursor-talk-to-figma-mcp (grab)stdio + 별도 WebSocketWebSocket 끊김 시 전체 멈춤
Figma-Context-MCP (GLips)stdio 전용원격 사용 불가
Figma 공식 MCPstdio 전용코드 생성 특화, 원격 미지원

공통점: 전부 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의 근본적 한계

단순히 "옛날 스펙"이라서가 아닙니다. 구조적으로 해결할 수 없는 문제들이 있습니다:

  1. 2개 엔드포인트 관리/sse(서버→클라이언트)와 /messages(클라이언트→서버)를 별도로 관리해야 합니다. 하나가 끊기면 다른 하나도 의미가 없어지는데, 이 동기화가 어렵습니다.
  2. 세션 개념 없음 — 연결이 끊겼다 다시 붙으면 서버 입장에서는 새로운 클라이언트입니다. 진행 중이던 작업 상태가 날아갑니다.
  3. 메시지 유실 — 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로 세션 관리

  1. 클라이언트가 POST /mcpInitializeRequest를 보냅니다 (세션 ID 없음)
  2. 서버가 200 OK와 함께 Mcp-Session-Id: session-abc123을 응답합니다
  3. 이후 모든 요청에 Mcp-Session-Id를 포함합니다
  4. 세션 만료 시 404 → 클라이언트가 새 세션을 시작합니다

서버가 클라이언트를 식별할 수 있으니, 연결이 잠시 끊겨도 세션 상태를 유지할 수 있습니다.

Last-Event-ID로 연결 재개

이게 핵심입니다. SSE 스트림의 각 이벤트에 ID를 부여하고, 연결이 끊겼을 때 마지막으로 받은 이벤트 ID를 보내면 서버가 그 이후 메시지를 재전달합니다:

  1. 서버가 클라이언트에 SSE 이벤트를 전송합니다 (id: evt-42)
  2. 연결이 끊깁니다
  3. 클라이언트가 GET /mcp + Last-Event-ID: evt-42로 재연결합니다
  4. 서버가 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_1files, nodes, images8 req/min10~20
TIER_2variables20 req/min25~100
TIER_3components, styles40 req/min50~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-mcpFigma 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