Recent Posts
Recent Comments
반응형
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
Archives
Today
Total
관리 메뉴

오늘도 공부

Excalidraw MCP 서버 아키텍처 가이드 본문

AI

Excalidraw MCP 서버 아키텍처 가이드

행복한 수지아빠 2026. 2. 12. 18:19
반응형

 

 

GitHub - excalidraw/excalidraw-mcp: Fast and streamable Excalidraw MCP App

Fast and streamable Excalidraw MCP App. Contribute to excalidraw/excalidraw-mcp development by creating an account on GitHub.

github.com

 


MCP 프로토콜 개요

MCP (Model Context Protocol)란?

MCP는 Claude AI가 외부 도구와 통신하기 위한 표준 프로토콜입니다.

┌─────────────────────────────────────────────────┐
│            Claude Desktop (Host)                │
│  ┌───────────────────────────────────────────┐  │
│  │  Claude AI (대화형 인터페이스)             │  │
│  │  - 자연어 이해                             │  │
│  │  - 도구 선택 및 호출                       │  │
│  └───────────────┬───────────────────────────┘  │
│                  │                               │
│                  │ MCP Protocol                  │
│                  │ (JSON-RPC over stdio)         │
└──────────────────┼───────────────────────────────┘
                   │
┌──────────────────▼───────────────────────────────┐
│         MCP Server (Local Process)               │
│  ┌───────────────────────────────────────────┐  │
│  │  Tools (도구)                              │  │
│  │  - read_me: 치트시트                      │  │
│  │  - create_view: 다이어그램 생성           │  │
│  │  - save_checkpoint: 상태 저장             │  │
│  └───────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────┐  │
│  │  Resources (리소스)                        │  │
│  │  - mcp-app.html: React 위젯               │  │
│  └───────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

통신 방식

Stdio 모드 (프로덕션)

node dist/index.js --stdio
  • 표준 입출력(stdin/stdout)으로 JSON-RPC 메시지 교환
  • Claude Desktop이 자동으로 프로세스 시작
  • 네트워크 없이 로컬 프로세스 간 통신

HTTP 모드 (개발/테스트)

node dist/index.js
# http://localhost:3001/mcp
  • Streamable HTTP transport 사용
  • 웹 브라우저에서 테스트 가능
  • CORS 활성화

시스템 아키텍처

전체 구조

┌───────────────────────────────────────────────────────────┐
│                     사용자의 Mac (로컬)                    │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐  │
│  │            Claude Desktop Application                │  │
│  │                                                       │  │
│  │  ┌─────────────────────────────────────────────┐    │  │
│  │  │  Chat Interface (사용자 대화)               │    │  │
│  │  │  "사용자 등록 플로우를 다이어그램으로 그려줘" │    │  │
│  │  └──────────────────┬──────────────────────────┘    │  │
│  │                     │                                 │  │
│  │  ┌──────────────────▼──────────────────────────┐    │  │
│  │  │  Claude AI Agent                             │    │  │
│  │  │  - 의도 파악                                 │    │  │
│  │  │  - 도구 선택 (create_view)                  │    │  │
│  │  │  - JSON 생성 (Excalidraw elements)          │    │  │
│  │  └──────────────────┬──────────────────────────┘    │  │
│  │                     │                                 │  │
│  │  ┌──────────────────▼──────────────────────────┐    │  │
│  │  │  MCP Client (내장)                          │    │  │
│  │  │  - JSON-RPC 요청 생성                       │    │  │
│  │  │  - stdio로 메시지 전송                      │    │  │
│  │  │  - 응답 수신 및 UI 렌더링                   │    │  │
│  │  └──────────────────┬──────────────────────────┘    │  │
│  └─────────────────────┼──────────────────────────────┘  │
│                        │                                   │
│                        │ stdio (Standard I/O)             │
│                        │ JSON-RPC 2.0 Messages            │
│                        │                                   │
│  ┌─────────────────────▼──────────────────────────────┐  │
│  │         Excalidraw MCP Server Process              │  │
│  │         (Node.js - dist/index.js)                  │  │
│  │                                                      │  │
│  │  ┌────────────────────────────────────────────┐    │  │
│  │  │  main.ts (진입점)                          │    │  │
│  │  │  - 전송 모드 선택 (stdio/HTTP)             │    │  │
│  │  │  - 서버 초기화                             │    │  │
│  │  └──────┬─────────────────────────────────────┘    │  │
│  │         │                                            │  │
│  │  ┌──────▼─────────────────────────────────────┐    │  │
│  │  │  server.ts (MCP 도구 등록)                 │    │  │
│  │  │                                             │    │  │
│  │  │  Tools:                                     │    │  │
│  │  │  ├─ read_me()                               │    │  │
│  │  │  │   └─ 치트시트 반환 (393줄)              │    │  │
│  │  │  │                                           │    │  │
│  │  │  ├─ create_view(elements)                   │    │  │
│  │  │  │   ├─ JSON 파싱                           │    │  │
│  │  │  │   ├─ restoreCheckpoint 처리              │    │  │
│  │  │  │   ├─ delete 처리                         │    │  │
│  │  │  │   ├─ 체크포인트 저장                     │    │  │
│  │  │  │   └─ checkpointId 반환                  │    │  │
│  │  │  │                                           │    │  │
│  │  │  ├─ save_checkpoint(id, data)               │    │  │
│  │  │  │   └─ 사용자 편집 저장                    │    │  │
│  │  │  │                                           │    │  │
│  │  │  ├─ read_checkpoint(id)                     │    │  │
│  │  │  │   └─ 저장된 상태 로드                    │    │  │
│  │  │  │                                           │    │  │
│  │  │  └─ export_to_excalidraw(json)              │    │  │
│  │  │      └─ excalidraw.com 업로드               │    │  │
│  │  │                                             │    │  │
│  │  │  Resources:                                 │    │  │
│  │  │  └─ ui://excalidraw/mcp-app.html            │    │  │
│  │  │      └─ 번들된 React 위젯                   │    │  │
│  │  └─────────────────┬───────────────────────────┘    │  │
│  │                    │                                 │  │
│  │  ┌─────────────────▼───────────────────────────┐    │  │
│  │  │  checkpoint-store.ts (상태 관리)            │    │  │
│  │  │                                              │    │  │
│  │  │  FileCheckpointStore:                       │    │  │
│  │  │  - 경로: $TMPDIR/excalidraw-mcp-checkpoints │    │  │
│  │  │  - 형식: {checkpointId}.json                │    │  │
│  │  │  - 내용: { elements: [...] }                │    │  │
│  │  │                                              │    │  │
│  │  │  MemoryCheckpointStore:                     │    │  │
│  │  │  - Map<string, string> (Vercel 폴백)        │    │  │
│  │  │                                              │    │  │
│  │  │  RedisCheckpointStore:                      │    │  │
│  │  │  - Upstash Redis (Vercel 프로덕션)          │    │  │
│  │  │  - TTL: 30일                                 │    │  │
│  │  └──────────────────────────────────────────────┘    │  │
│  └───────────────────────────────────────────────────────┘  │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │         Claude Desktop의 iframe (위젯 렌더링)        │  │
│  │                                                         │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  mcp-app.tsx (React 앱)                         │  │  │
│  │  │                                                   │  │  │
│  │  │  ┌─────────────────────────────────────────┐    │  │  │
│  │  │  │  ExcalidrawApp (메인 컴포넌트)          │    │  │  │
│  │  │  │  - 디스플레이 모드 관리                 │    │  │  │
│  │  │  │  - MCP 앱 훅 초기화                     │    │  │  │
│  │  │  └──────┬──────────────────────────────────┘    │  │  │
│  │  │         │                                         │  │  │
│  │  │  ┌──────▼──────────────────────────────────┐    │  │  │
│  │  │  │  Inline 모드: DiagramView                │    │  │  │
│  │  │  │  ┌───────────────────────────────────┐  │    │  │  │
│  │  │  │  │  ontoolinputpartial() 핸들러       │  │    │  │  │
│  │  │  │  │  ├─ 부분 JSON 파싱                 │  │    │  │  │
│  │  │  │  │  ├─ 마지막 요소 제거 (불완전)      │  │    │  │  │
│  │  │  │  │  └─ 요소 개수 변경 시만 렌더링     │  │    │  │  │
│  │  │  │  └───────────────────────────────────┘  │    │  │  │
│  │  │  │  ┌───────────────────────────────────┐  │    │  │  │
│  │  │  │  │  ontoolinput() 핸들러              │  │    │  │  │
│  │  │  │  │  ├─ 완전 JSON 파싱                 │  │    │  │  │
│  │  │  │  │  ├─ restoreCheckpoint 로드         │  │    │  │  │
│  │  │  │  │  └─ 최종 렌더링 (원본 시드)        │  │    │  │  │
│  │  │  │  └───────────────────────────────────┘  │    │  │  │
│  │  │  │  ┌───────────────────────────────────┐  │    │  │  │
│  │  │  │  │  renderSvgPreview()                │  │    │  │  │
│  │  │  │  │  ├─ convertRawElements()           │  │    │  │  │
│  │  │  │  │  │   └─ 라벨 → 바운드 텍스트 변환  │  │    │  │  │
│  │  │  │  │  ├─ exportToSvg()                  │  │    │  │  │
│  │  │  │  │  │   └─ Excalidraw 라이브러리      │  │    │  │  │
│  │  │  │  │  ├─ morphdom()                     │  │    │  │  │
│  │  │  │  │  │   └─ DOM 비교 및 패치           │  │    │  │  │
│  │  │  │  │  └─ fixViewBox4x3()                │  │    │  │  │
│  │  │  │  └───────────────────────────────────┘  │    │  │  │
│  │  │  │  ┌───────────────────────────────────┐  │    │  │  │
│  │  │  │  │  카메라 애니메이션 시스템          │  │    │  │  │
│  │  │  │  │  - Scene 좌표 → SVG 좌표 변환     │  │    │  │  │
│  │  │  │  │  - LERP 보간 (0.03 속도)          │  │    │  │  │
│  │  │  │  │  - requestAnimationFrame 루프      │  │    │  │  │
│  │  │  │  └───────────────────────────────────┘  │    │  │  │
│  │  │  └─────────────────────────────────────────┘    │  │  │
│  │  │         │                                         │  │  │
│  │  │  ┌──────▼──────────────────────────────────┐    │  │  │
│  │  │  │  Fullscreen 모드: Excalidraw 컴포넌트   │    │  │  │
│  │  │  │  ┌───────────────────────────────────┐  │    │  │  │
│  │  │  │  │  <Excalidraw>                      │  │    │  │  │
│  │  │  │  │  - initialData: elements           │  │    │  │  │
│  │  │  │  │  - onChange: 편집 감지             │  │    │  │  │
│  │  │  │  │  - renderTopRightUI: 공유 버튼     │  │    │  │  │
│  │  │  │  └───────────────────────────────────┘  │    │  │  │
│  │  │  │  ┌───────────────────────────────────┐  │    │  │  │
│  │  │  │  │  edit-context.ts                   │  │    │  │  │
│  │  │  │  │  - localStorage 동기화             │  │    │  │  │
│  │  │  │  │  - 디바운스 저장 (2초)             │  │    │  │  │
│  │  │  │  │  - 서버 체크포인트 업데이트        │  │    │  │  │
│  │  │  │  └───────────────────────────────────┘  │    │  │  │
│  │  │  └─────────────────────────────────────────┘    │  │  │
│  │  │                                                   │  │  │
│  │  │  ┌───────────────────────────────────────────┐  │  │  │
│  │  │  │  pencil-audio.ts (사운드 효과)            │  │  │  │
│  │  │  │  - Web Audio API                          │  │  │  │
│  │  │  │  - 요소 타입별 주파수 변조                │  │  │  │
│  │  │  └───────────────────────────────────────────┘  │  │  │
│  │  │                                                   │  │  │
│  │  │  ┌───────────────────────────────────────────┐  │  │  │
│  │  │  │  global.css (애니메이션)                  │  │  │  │
│  │  │  │  - stroke-dashoffset: 선 그리기            │  │  │  │
│  │  │  │  - opacity fade-in: 도형 나타남            │  │  │  │
│  │  │  │  - 4:3 비율 자동 조정                      │  │  │  │
│  │  │  └───────────────────────────────────────────┘  │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

실행 흐름

1. 초기화 단계

Step 1: Claude Desktop 시작
├─ claude_desktop_config.json 읽기
└─ mcpServers.excalidraw 발견

Step 2: MCP 서버 프로세스 시작
├─ 명령어: /opt/homebrew/opt/node@24/bin/node
├─ 인자: [dist/index.js, --stdio]
└─ 환경: 사용자 홈 디렉토리

Step 3: main.ts 실행
├─ process.argv에서 "--stdio" 감지
├─ FileCheckpointStore 초기화
│   └─ 디렉토리 생성: $TMPDIR/excalidraw-mcp-checkpoints/
└─ startStdioServer() 호출

Step 4: MCP 서버 초기화
├─ McpServer 인스턴스 생성
│   ├─ name: "Excalidraw"
│   └─ version: "1.0.0"
├─ registerTools() 호출
│   ├─ read_me 도구 등록
│   ├─ create_view 도구 등록
│   ├─ save_checkpoint 도구 등록
│   ├─ read_checkpoint 도구 등록
│   └─ export_to_excalidraw 도구 등록
├─ mcp-app.html 리소스 등록
└─ StdioServerTransport 연결

Step 5: 준비 완료
└─ stdin/stdout으로 JSON-RPC 메시지 대기

2. 다이어그램 생성 플로우

React WidgetCheckpointStoreMCP ServerMCP ClientClaude AI AgentClaude Desktop UI사용자React WidgetCheckpointStoreMCP ServerMCP ClientClaude AI AgentClaude Desktop UI사용자스트리밍 시작loop[요소 스트리밍]alt[restoreCheckpoint 존재]alt[delete 존재]"사용자 등록 플로우를 다이어그램으로 그려줘"메시지 전달의도 분석 (다이어그램 생성 필요)tools/call: read_meJSON-RPC requestRECALL_CHEAT_SHEET 반환JSON-RPC response (치트시트)치트시트 학습Excalidraw JSON 생성 시작tools/call: create_view (partial)ontoolinputpartial (부분 JSON)parsePartialElements()excludeIncompleteLastItem()renderSvgPreview()playStroke() (사운드)SVG 업데이트 (morphdom)tools/call: create_view (final)JSON.parse(elements)extractViewportAndElements()load(checkpointId){ elements: [...] }기존 + 새 요소 병합삭제 대상 필터링containerId 체크 (bound text)checkpointId 생성 (UUID 18자)save(checkpointId, data)파일 쓰기 (JSON)response (checkpointId + 안내 메시지)ontoolresult()setStorageKey(checkpointId)loadPersistedElements()최종 렌더링 (원본 시드)다이어그램 표시 완료다이어그램 + 풀스크린 버튼

3. 풀스크린 편집 플로우

Step 1: 사용자가 풀스크린 버튼 클릭
├─ toggleFullscreen() 호출
└─ app.requestDisplayMode({ mode: "fullscreen" })

Step 2: 디스플레이 모드 전환
├─ displayMode: "inline" → "fullscreen"
├─ HTML/body 높이 설정 (containerHeight)
└─ editorReady = true

Step 3: Excalidraw 컴포넌트 마운트
├─ 폰트 로딩 대기 (Excalifont, Assistant)
├─ <Excalidraw initialData={elements} />
├─ refresh text dimensions (restore)
└─ 200ms 후 editorSettled = true (화면 표시)

Step 4: 사용자 편집
├─ onChange 이벤트 발생
├─ onEditorChange() 호출
│   ├─ captureEditAction()
│   ├─ localStorage 저장 (즉시)
│   └─ 디바운스 타이머 시작 (2초)
└─ 2초 후 서버 체크포인트 업데이트
    ├─ app.callServerTool("save_checkpoint")
    └─ CheckpointStore.save()

Step 5: 풀스크린 종료
├─ Escape 키 또는 호스트 UI
├─ displayMode: "fullscreen" → "inline"
├─ getLatestEditedElements() 호출
│   └─ localStorage에서 최신 상태 로드
├─ setElements(edited)
└─ DiagramView로 SVG 재렌더링

소스 코드 플로우

파일 구조

excalidraw-mcp-app/
├── src/
│   ├── main.ts                  # 진입점 (전송 모드 선택)
│   ├── server.ts                # MCP 도구 등록 + 로직
│   ├── mcp-app.tsx              # React 위젯 (UI)
│   ├── mcp-app.html             # 위젯 HTML 템플릿
│   ├── checkpoint-store.ts      # 상태 관리 (File/Memory/Redis)
│   ├── edit-context.ts          # 풀스크린 편집 상태 관리
│   ├── pencil-audio.ts          # 사운드 효과
│   ├── sounds.ts                # 오디오 데이터
│   └── global.css               # 애니메이션 스타일
├── dist/                        # 빌드 결과물
│   ├── index.js                 # 번들된 서버 (main + server)
│   ├── server.js                # 서버 모듈
│   ├── mcp-app.html             # 번들된 React 앱 (단일 파일)
│   └── *.d.ts                   # TypeScript 타입 정의
├── package.json
├── tsconfig.json
├── tsconfig.server.json
└── vite.config.ts

1. main.ts - 진입점

역할: 전송 모드(stdio/HTTP) 선택 및 서버 시작

// src/main.ts

// ============================================================
// 타입 임포트
// ============================================================
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { FileCheckpointStore } from "./checkpoint-store.js";
import { createServer } from "./server.js";

// ============================================================
// HTTP 서버 시작 (개발/테스트용)
// ============================================================
export async function startStreamableHTTPServer(
  createServer: () => McpServer,
): Promise<void> {
  const port = parseInt(process.env.PORT ?? "3001", 10);

  // Express 앱 생성
  const app = createMcpExpressApp({ host: "0.0.0.0" });
  app.use(cors());

  // /mcp 엔드포인트
  app.all("/mcp", async (req: Request, res: Response) => {
    // 요청마다 새 서버 인스턴스 생성 (stateless)
    const server = createServer();
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });

    // 연결 종료 시 정리
    res.on("close", () => {
      transport.close().catch(() => {});
      server.close().catch(() => {});
    });

    // MCP 요청 처리
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  });

  app.listen(port);
}

// ============================================================
// Stdio 서버 시작 (프로덕션)
// ============================================================
export async function startStdioServer(
  createServer: () => McpServer,
): Promise<void> {
  // stdin/stdout으로 JSON-RPC 통신
  await createServer().connect(new StdioServerTransport());
}

// ============================================================
// 메인 함수
// ============================================================
async function main() {
  // 체크포인트 저장소 초기화
  const store = new FileCheckpointStore();
  const factory = () => createServer(store);

  // 명령줄 인자로 모드 선택
  if (process.argv.includes("--stdio")) {
    await startStdioServer(factory);
  } else {
    await startStreamableHTTPServer(factory);
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

플로우:

1. process.argv 체크
2. FileCheckpointStore 인스턴스 생성
3. createServer 팩토리 함수 생성
4. stdio 또는 HTTP 모드 시작
5. JSON-RPC 메시지 대기

2. server.ts - MCP 도구 등록

역할: 5개 도구 + 1개 리소스 등록, 비즈니스 로직

2.1 상수 및 헬퍼

// src/server.ts

import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod/v4";
import type { CheckpointStore } from "./checkpoint-store.js";

// 빌드된 파일 디렉토리
const DIST_DIR = import.meta.filename.endsWith(".ts")
  ? path.join(import.meta.dirname, "..", "dist")
  : import.meta.dirname;

// 393줄의 치트시트 (색상 팔레트, 요소 형식, 예제)
const RECALL_CHEAT_SHEET = `# Excalidraw Element Format
...
(생략 - 19-392줄)
...
`;

2.2 도구 1: read_me

server.registerTool(
  "read_me",
  {
    description: "Returns the Excalidraw element format reference with color palettes, examples, and tips. Call this BEFORE using create_view for the first time.",
    annotations: { readOnlyHint: true },
  },
  async (): Promise<CallToolResult> => {
    return { content: [{ type: "text", text: RECALL_CHEAT_SHEET }] };
  },
);

플로우:

1. Claude가 read_me 호출
2. RECALL_CHEAT_SHEET 텍스트 반환
3. Claude가 Excalidraw 형식 학습
4. create_view 호출 시 올바른 JSON 생성

2.3 도구 2: create_view (핵심)

registerAppTool(server,
  "create_view",
  {
    title: "Draw Diagram",
    description: `Renders a hand-drawn diagram using Excalidraw elements.
Elements stream in one by one with draw-on animations.
Call read_me first to learn the element format.`,
    inputSchema: z.object({
      elements: z.string().describe(
        "JSON array string of Excalidraw elements. Must be valid JSON — no comments, no trailing commas. Keep compact. Call read_me first for format reference."
      ),
    }),
    annotations: { readOnlyHint: true },
    _meta: { ui: { resourceUri } },
  },
  async ({ elements }): Promise<CallToolResult> => {
    // ======================================
    // 1. JSON 파싱
    // ======================================
    let parsed: any[];
    try {
      parsed = JSON.parse(elements);
    } catch (e) {
      return {
        content: [{ type: "text", text: `Invalid JSON: ${e.message}` }],
        isError: true,
      };
    }

    // ======================================
    // 2. restoreCheckpoint 처리
    // ======================================
    const restoreEl = parsed.find((el: any) => el.type === "restoreCheckpoint");
    let resolvedElements: any[];

    if (restoreEl?.id) {
      // 저장된 체크포인트 로드
      const base = await store.load(restoreEl.id);
      if (!base) {
        return {
          content: [{ type: "text", text: `Checkpoint "${restoreEl.id}" not found` }],
          isError: true,
        };
      }

      // ======================================
      // 3. delete 처리
      // ======================================
      const deleteIds = new Set<string>();
      for (const el of parsed) {
        if (el.type === "delete") {
          for (const id of String(el.ids ?? el.id).split(",")) {
            deleteIds.add(id.trim());
          }
        }
      }

      // 기존 요소에서 삭제 대상 제거
      // containerId 체크: bound text도 함께 삭제
      const baseFiltered = base.elements.filter((el: any) =>
        !deleteIds.has(el.id) && !deleteIds.has(el.containerId)
      );

      // 새 요소 추출 (pseudo-element 제외)
      const newEls = parsed.filter((el: any) =>
        el.type !== "restoreCheckpoint" && el.type !== "delete"
      );

      // 병합
      resolvedElements = [...baseFiltered, ...newEls];
    } else {
      // 새 다이어그램 (delete만 제거)
      resolvedElements = parsed.filter((el: any) => el.type !== "delete");
    }

    // ======================================
    // 4. 카메라 4:3 비율 검증
    // ======================================
    const cameras = parsed.filter((el: any) => el.type === "cameraUpdate");
    const badRatio = cameras.find((c: any) => {
      if (!c.width || !c.height) return false;
      const ratio = c.width / c.height;
      return Math.abs(ratio - 4 / 3) > 0.15; // 4:3 ± 15%
    });
    const ratioHint = badRatio
      ? `\nTip: your cameraUpdate used ${badRatio.width}x${badRatio.height} — try to stick with 4:3 aspect ratio (e.g. 400x300, 800x600) in future.`
      : "";

    // ======================================
    // 5. 체크포인트 저장
    // ======================================
    const checkpointId = crypto.randomUUID().replace(/-/g, "").slice(0, 18);
    await store.save(checkpointId, { elements: resolvedElements });

    // ======================================
    // 6. 응답 반환
    // ======================================
    return {
      content: [{ type: "text", text: `Diagram displayed! Checkpoint id: "${checkpointId}".
If user asks to create a new diagram - simply create a new one from scratch.
However, if the user wants to edit something on this diagram "${checkpointId}", take these steps:
1) read widget context (using read_widget_context tool) to check if user made any manual edits first
2) decide whether you want to make new diagram from scratch OR - use this one as starting checkpoint:
  simply start from the first element [{"type":"restoreCheckpoint","id":"${checkpointId}"}, ...your new elements...]
  this will use same diagram state as the user currently sees, including any manual edits they made in fullscreen, allowing you to add elements on top.
  To remove elements, use: {"type":"delete","ids":"<id1>,<id2>"}${ratioHint}` }],
      structuredContent: { checkpointId },
    };
  },
);

플로우:

입력: { elements: '[{...},{...}]' }
  ↓
1. JSON.parse()
  ↓
2. restoreCheckpoint 찾기
  ├─ 있음 → store.load(id)
  │         └─ 기존 + 새 요소 병합
  └─ 없음 → 새 다이어그램
  ↓
3. delete 처리
  └─ deleteIds로 필터링 (containerId도 체크)
  ↓
4. 카메라 비율 검증
  └─ 4:3 아니면 경고 메시지
  ↓
5. checkpointId 생성 (UUID 18자)
  ↓
6. store.save(id, { elements })
  ↓
출력: { checkpointId, 안내 메시지 }

2.4 도구 3-5: 체크포인트 관리 + 공유

// 도구 3: save_checkpoint (위젯 전용)
registerAppTool(server,
  "save_checkpoint",
  {
    description: "Update checkpoint with user-edited state.",
    inputSchema: { id: z.string(), data: z.string() },
    _meta: { ui: { visibility: ["app"] } }, // 모델에게 숨김
  },
  async ({ id, data }) => {
    await store.save(id, JSON.parse(data));
    return { content: [{ type: "text", text: "ok" }] };
  },
);

// 도구 4: read_checkpoint (위젯 전용)
registerAppTool(server,
  "read_checkpoint",
  {
    description: "Read checkpoint state for restore.",
    inputSchema: { id: z.string() },
    _meta: { ui: { visibility: ["app"] } },
  },
  async ({ id }) => {
    const data = await store.load(id);
    if (!data) return { content: [{ type: "text", text: "" }] };
    return { content: [{ type: "text", text: JSON.stringify(data) }] };
  },
);

// 도구 5: export_to_excalidraw (위젯 전용)
registerAppTool(server,
  "export_to_excalidraw",
  {
    description: "Upload diagram to excalidraw.com and return shareable URL.",
    inputSchema: { json: z.string().describe("Serialized Excalidraw JSON") },
    _meta: { ui: { visibility: ["app"] } },
  },
  async ({ json }) => {
    // Excalidraw v2 binary format (암호화 + 압축)
    // 1. JSON → zlib deflate
    // 2. AES-GCM 128-bit 암호화
    // 3. excalidraw.com/api/v2/post/ 업로드
    // 4. URL 반환: https://excalidraw.com/#json={id},{key}

    // (구현 생략 - 565-586줄 참조)
    return { content: [{ type: "text", text: url }] };
  },
);

2.5 리소스: mcp-app.html

// React 위젯 HTML 등록
registerAppResource(server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async (): Promise<ReadResourceResult> => {
    const html = await fs.readFile(path.join(distDir, "mcp-app.html"), "utf-8");
    return {
      contents: [{
        uri: resourceUri,
        mimeType: RESOURCE_MIME_TYPE,
        text: html,
        _meta: {
          ui: {
            // CSP: Excalidraw 폰트 로딩 허용
            csp: {
              resourceDomains: ["https://esm.sh"],
              connectDomains: ["https://esm.sh"],
            },
            prefersBorder: true, // 호스트가 테두리 렌더링
            permissions: { clipboardWrite: {} }, // 클립보드 복사 허용
          },
        },
      }],
    };
  },
);

3. mcp-app.tsx - React 위젯

역할: UI 렌더링, 스트리밍 처리, 풀스크린 편집

3.1 유틸리티 함수

// src/mcp-app.tsx

// ============================================================
// 부분 JSON 파싱 (스트리밍 중)
// ============================================================
function parsePartialElements(str: string | undefined): any[] {
  if (!str?.trim().startsWith("[")) return [];

  // 완전한 JSON이면 바로 파싱
  try { return JSON.parse(str); } catch { /* partial */ }

  // 마지막 "}" 찾아서 배열 닫기
  const last = str.lastIndexOf("}");
  if (last < 0) return [];

  try {
    return JSON.parse(str.substring(0, last + 1) + "]");
  } catch {
    return [];
  }
}

// ============================================================
// 마지막 불완전 요소 제거
// ============================================================
function excludeIncompleteLastItem<T>(arr: T[]): T[] {
  if (!arr || arr.length === 0) return [];
  if (arr.length <= 1) return [];
  return arr.slice(0, -1); // 마지막 요소는 아직 스트리밍 중
}

// ============================================================
// 원시 요소 → Excalidraw 형식 변환
// ============================================================
function convertRawElements(els: any[]): any[] {
  // pseudo-element 분리 (cameraUpdate, delete, restoreCheckpoint)
  const pseudoTypes = new Set(["cameraUpdate", "delete", "restoreCheckpoint"]);
  const pseudos = els.filter((el: any) => pseudoTypes.has(el.type));
  const real = els.filter((el: any) => !pseudoTypes.has(el.type));

  // label 기본값 설정 (중앙 정렬)
  const withDefaults = real.map((el: any) =>
    el.label ? { ...el, label: { textAlign: "center", verticalAlign: "middle", ...el.label } } : el
  );

  // Excalidraw의 convertToExcalidrawElements() 호출
  // - label이 있으면 bound text element 자동 생성
  // - 컨테이너 자동 리사이징
  const converted = convertToExcalidrawElements(withDefaults, { regenerateIds: false })
    .map((el: any) =>
      // text 요소는 Excalifont(손글씨) 강제
      el.type === "text" ? { ...el, fontFamily: FONT_FAMILY.Excalifont ?? 1 } : el
    );

  // pseudo-element 복원 (처리는 위젯에서)
  return [...converted, ...pseudos];
}

// ============================================================
// SVG viewBox를 4:3 비율로 수정
// ============================================================
function fixViewBox4x3(svg: SVGSVGElement): void {
  const vb = svg.getAttribute("viewBox")?.split(" ").map(Number);
  if (!vb || vb.length !== 4) return;

  const [vx, vy, vw, vh] = vb;
  const r = vw / vh;

  if (Math.abs(r - 4 / 3) < 0.01) return; // 이미 4:3

  if (r > 4 / 3) {
    // 너무 넓음 → 높이 확장
    const h2 = Math.round(vw * 3 / 4);
    svg.setAttribute("viewBox", `${vx} ${vy - Math.round((h2 - vh) / 2)} ${vw} ${h2}`);
  } else {
    // 너무 좁음 → 너비 확장
    const w2 = Math.round(vh * 4 / 3);
    svg.setAttribute("viewBox", `${vx - Math.round((w2 - vw) / 2)} ${vy} ${w2} ${vh}`);
  }
}

// ============================================================
// pseudo-element 추출
// ============================================================
function extractViewportAndElements(elements: any[]): {
  viewport: ViewportRect | null;
  drawElements: any[];
  restoreId: string | null;
  deleteIds: Set<string>;
} {
  let viewport: ViewportRect | null = null;
  let restoreId: string | null = null;
  const deleteIds = new Set<string>();
  const drawElements: any[] = [];

  for (const el of elements) {
    if (el.type === "cameraUpdate") {
      viewport = { x: el.x, y: el.y, width: el.width, height: el.height };
    } else if (el.type === "restoreCheckpoint") {
      restoreId = el.id;
    } else if (el.type === "delete") {
      for (const id of String(el.ids ?? el.id).split(",")) {
        deleteIds.add(id.trim());
      }
    } else {
      drawElements.push(el);
    }
  }

  // 삭제된 요소는 opacity: 1로 설정 (완전히 제거하면 SVG 구조 변경 → morphdom 실패)
  const processedDraw = deleteIds.size > 0
    ? drawElements.map((el: any) =>
        (deleteIds.has(el.id) || deleteIds.has(el.containerId))
          ? { ...el, opacity: 1 }  // 거의 투명 (0은 "unset" 취급됨)
          : el
      )
    : drawElements;

  return { viewport, drawElements: processedDraw, restoreId, deleteIds };
}

3.2 DiagramView 컴포넌트 (SVG 렌더링)

function DiagramView({
  toolInput,
  isFinal,
  displayMode,
  onElements,
  editedElements,
  onViewport,
  loadCheckpoint
}: DiagramViewProps) {
  const svgRef = useRef<HTMLDivElement | null>(null);
  const latestRef = useRef<any[]>([]);
  const restoredRef = useRef<{ id: string; elements: any[] } | null>(null);
  const [, setCount] = useState(0);

  // ============================================================
  // 카메라 애니메이션 (Scene 좌표계)
  // ============================================================
  const animatedVP = useRef<ViewportRect | null>(null);  // 현재 위치
  const targetVP = useRef<ViewportRect | null>(null);    // 목표 위치
  const sceneBoundsRef = useRef({ minX: 0, minY: 0 });   // Scene 최소 좌표

  // LERP 보간으로 부드러운 이동
  const animateViewBox = useCallback(() => {
    if (!animatedVP.current || !targetVP.current) return;
    const a = animatedVP.current;
    const t = targetVP.current;

    // 0.03 = LERP_SPEED (3% 이동)
    a.x += (t.x - a.x) * 0.03;
    a.y += (t.y - a.y) * 0.03;
    a.width += (t.width - a.width) * 0.03;
    a.height += (t.height - a.height) * 0.03;

    applyViewBox(); // SVG viewBox 업데이트

    // 목표까지 거리
    const delta = Math.abs(t.x - a.x) + Math.abs(t.y - a.y)
      + Math.abs(t.width - a.width) + Math.abs(t.height - a.height);

    // 0.5px 이상 차이나면 계속 애니메이션
    if (delta > 0.5) {
      requestAnimationFrame(animateViewBox);
    }
  }, []);

  // Scene 좌표 → SVG 좌표 변환 후 viewBox 설정
  const applyViewBox = useCallback(() => {
    if (!animatedVP.current || !svgRef.current) return;
    const svg = svgRef.current.querySelector("svg");
    if (!svg) return;

    const { minX, minY } = sceneBoundsRef.current;
    const { x, y, width: w, height: h } = animatedVP.current;

    // 4:3 비율 자동 보정
    const ratio = w / h;
    const vp4x3: ViewportRect = Math.abs(ratio - 4 / 3) < 0.01
      ? animatedVP.current
      : ratio > 4 / 3
        ? { x, y, width: w, height: Math.round(w * 3 / 4) }
        : { x, y, width: Math.round(h * 4 / 3), height: h };

    // Scene → SVG 좌표 변환
    // SVG_x = scene_x - sceneMinX + EXPORT_PADDING
    const vb = {
      x: vp4x3.x - minX + 20,
      y: vp4x3.y - minY + 20,
      w: vp4x3.width,
      h: vp4x3.height,
    };

    svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
  }, []);

  // ============================================================
  // SVG 렌더링 (exportToSvg + morphdom)
  // ============================================================
  const renderSvgPreview = useCallback(async (
    els: any[],
    viewport: ViewportRect | null,
    baseElements?: any[]
  ) => {
    if ((els.length === 0 && !baseElements?.length) || !svgRef.current) return;

    try {
      // 1. 폰트 로딩 대기 (Virgil 손글씨)
      await document.fonts.load('20px Excalifont');

      // 2. 원시 요소 변환 (label → bound text)
      const convertedNew = convertRawElements(els);
      const baseReal = baseElements?.filter((el: any) => el.type !== "cameraUpdate") ?? [];
      const excalidrawEls = [...baseReal, ...convertedNew];

      // 3. Scene 좌표 범위 계산
      sceneBoundsRef.current = computeSceneBounds(excalidrawEls);

      // 4. SVG 생성 (Excalidraw 라이브러리)
      const svg = await exportToSvg({
        elements: excalidrawEls as any,
        appState: {
          viewBackgroundColor: "transparent",
          exportBackground: false
        } as any,
        files: null,
        exportPadding: 20,
        skipInliningFonts: true, // 폰트 이미 로드됨
      });

      if (!svgRef.current) return;

      // 5. SVG wrapper 준비
      let wrapper = svgRef.current.querySelector(".svg-wrapper") as HTMLDivElement | null;
      if (!wrapper) {
        wrapper = document.createElement("div");
        wrapper.className = "svg-wrapper";
        svgRef.current.appendChild(wrapper);
      }

      // 컨테이너 채우기 (4:3 비율 유지)
      svg.style.width = "100%";
      svg.style.height = "100%";
      svg.removeAttribute("width");
      svg.removeAttribute("height");

      // 6. morphdom으로 기존 SVG와 병합
      const existing = wrapper.querySelector("svg");
      if (existing) {
        // DOM diffing → 기존 요소 재사용 (애니메이션 보존)
        morphdom(existing, svg, { childrenOnly: false });
      } else {
        wrapper.appendChild(svg);
      }

      // 7. viewBox 4:3 비율 강제
      const renderedSvg = wrapper.querySelector("svg");
      if (renderedSvg) fixViewBox4x3(renderedSvg as SVGSVGElement);

      // 8. 카메라 애니메이션 시작
      if (viewport) {
        targetVP.current = { ...viewport };
        onViewport?.(viewport);

        if (!animatedVP.current) {
          // 첫 카메라 → 즉시 스냅
          animatedVP.current = { ...viewport };
        }

        applyViewBox(); // 즉시 적용 (morphdom 후 깜빡임 방지)
        requestAnimationFrame(animateViewBox); // 애니메이션 시작
      }
    } catch {
      // export 실패 (부분/잘못된 요소)
    }
  }, [applyViewBox, animateViewBox]);

  // ============================================================
  // 스트리밍 처리 (ontoolinputpartial + ontoolinput)
  // ============================================================
  useEffect(() => {
    if (!toolInput) return;
    const raw = toolInput.elements;
    if (!raw) return;

    const str = typeof raw === "string" ? raw : JSON.stringify(raw);

    if (isFinal) {
      // ======================================
      // 최종 렌더링 (완전한 JSON)
      // ======================================
      const parsed = parsePartialElements(str);
      let { viewport, drawElements, restoreId, deleteIds } = extractViewportAndElements(parsed);

      const doFinal = async () => {
        let base: any[] | undefined;

        // restoreCheckpoint 로드 (서버에서)
        if (restoreId && loadCheckpoint) {
          const saved = await loadCheckpoint(restoreId);
          if (saved) {
            base = saved.elements;

            // base에서 camera 추출 (fallback)
            if (!viewport) {
              const cam = base.find((el: any) => el.type === "cameraUpdate");
              if (cam) viewport = { x: cam.x, y: cam.y, width: cam.width, height: cam.height };
            }

            // base 변환 (이미 변환된 요소도 재변환 가능)
            base = convertRawElements(base);
          }

          // delete 필터링
          if (base && deleteIds.size > 0) {
            base = base.filter((el: any) =>
              !deleteIds.has(el.id) && !deleteIds.has(el.containerId)
            );
          }
        }

        latestRef.current = drawElements;

        // 새 요소 변환
        const convertedNew = convertRawElements(drawElements);

        // base + new 병합
        const allConverted = base ? [...base, ...convertedNew] : convertedNew;

        // 풀스크린용 초기 상태 캡처
        captureInitialElements(allConverted);

        // 사용자 편집이 없으면 업데이트
        if (!editedElements) onElements?.(allConverted);
        if (!editedElements) renderSvgPreview(drawElements, viewport, base);
      };

      doFinal();
      return;
    }

    // ======================================
    // 스트리밍 렌더링 (부분 JSON)
    // ======================================
    const parsed = parsePartialElements(str);

    // pseudo-element 추출 (작아서 불완전할 가능성 낮음)
    let streamRestoreId: string | null = null;
    const streamDeleteIds = new Set<string>();
    for (const el of parsed) {
      if (el.type === "restoreCheckpoint") streamRestoreId = el.id;
      else if (el.type === "delete") {
        for (const id of String(el.ids ?? el.id).split(",")) {
          streamDeleteIds.add(id.trim());
        }
      }
    }

    // 마지막 요소 제거 (불완전)
    const safe = excludeIncompleteLastItem(parsed);
    let { viewport, drawElements } = extractViewportAndElements(safe);

    const doStream = async () => {
      let base: any[] | undefined;

      // restoreCheckpoint 로드 (한 번만)
      if (streamRestoreId) {
        if (!restoredRef.current || restoredRef.current.id !== streamRestoreId) {
          if (loadCheckpoint) {
            const saved = await loadCheckpoint(streamRestoreId);
            if (saved) {
              const converted = convertRawElements(saved.elements);
              restoredRef.current = { id: streamRestoreId, elements: converted };
            }
          }
        }
        base = restoredRef.current?.elements;

        // base에서 camera 추출
        if (!viewport && base) {
          const cam = base.find((el: any) => el.type === "cameraUpdate");
          if (cam) viewport = { x: cam.x, y: cam.y, width: cam.width, height: cam.height };
        }

        // delete 필터링
        if (base && streamDeleteIds.size > 0) {
          base = base.filter((el: any) =>
            !streamDeleteIds.has(el.id) && !streamDeleteIds.has(el.containerId)
          );
        }
      }

      // 요소 개수 변경 시만 렌더링
      if (drawElements.length > 0 && drawElements.length !== latestRef.current.length) {
        // 사운드 재생 (새 요소마다)
        const prevCount = latestRef.current.length;
        for (let i = prevCount; i < drawElements.length; i++) {
          playStroke(drawElements[i].type ?? "rectangle");
        }

        latestRef.current = drawElements;
        setCount(drawElements.length);

        // 시드 랜덤화 (흔들리는 효과)
        const jittered = drawElements.map((el: any) =>
          ({ ...el, seed: Math.floor(Math.random() * 1e9) })
        );

        renderSvgPreview(jittered, viewport, base);
      } else if (base && base.length > 0 && latestRef.current.length === 0) {
        // 첫 렌더: base만 표시
        renderSvgPreview([], viewport, base);
      }
    };

    doStream();
  }, [toolInput, isFinal, renderSvgPreview]);

  return (
    <div
      ref={svgRef}
      className="excalidraw-container"
      style={{ display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none" }}
    />
  );
}

플로우:

ontoolinputpartial (스트리밍)
  ↓
parsePartialElements(str)
  ├─ try JSON.parse()
  └─ 실패 시 마지막 "}" 찾아서 닫기
  ↓
excludeIncompleteLastItem()
  └─ 마지막 요소 제거
  ↓
extractViewportAndElements()
  ├─ cameraUpdate → viewport
  ├─ restoreCheckpoint → restoreId
  ├─ delete → deleteIds
  └─ 나머지 → drawElements
  ↓
loadCheckpoint(restoreId) [한 번만]
  ├─ app.callServerTool("read_checkpoint")
  └─ base 캐싱 (restoredRef)
  ↓
요소 개수 변경?
  ├─ Yes → playStroke() (사운드)
  │         renderSvgPreview()
  │         └─ 시드 랜덤화 (흔들림)
  └─ No → 무시
  ↓
renderSvgPreview()
  ├─ convertRawElements()
  ├─ exportToSvg()
  ├─ morphdom(existing, new)
  ├─ fixViewBox4x3()
  └─ animateViewBox() (카메라)

ontoolinput (최종)
  ↓
parsePartialElements(str)
  ↓
extractViewportAndElements()
  ↓
loadCheckpoint(restoreId)
  ↓
delete 필터링
  ↓
base + new 병합
  ↓
captureInitialElements() (풀스크린용)
  ↓
renderSvgPreview()
  └─ 원본 시드 (안정적)

3.3 ExcalidrawApp 컴포넌트 (메인)

function ExcalidrawApp() {
  const [toolInput, setToolInput] = useState<any>(null);
  const [inputIsFinal, setInputIsFinal] = useState(false);
  const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
  const [elements, setElements] = useState<any[]>([]);
  const [userEdits, setUserEdits] = useState<any[] | null>(null);
  const [excalidrawApi, setExcalidrawApi] = useState<any>(null);

  // ============================================================
  // MCP 앱 훅
  // ============================================================
  const { app, error } = useApp({
    appInfo: { name: "Excalidraw", version: "1.0.0" },
    capabilities: {},
    onAppCreated: (app) => {
      // ======================================
      // 로깅 함수 설정
      // ======================================
      _logFn = (msg) => {
        try {
          app.sendLog({ level: "info", logger: "FS", data: msg });
        } catch {}
      };

      // ======================================
      // 컨테이너 높이 캡처 (fullscreen용)
      // ======================================
      const initDims = app.getHostContext()?.containerDimensions as any;
      if (initDims?.height) setContainerHeight(initDims.height);

      // ======================================
      // 호스트 컨텍스트 변경 감지
      // ======================================
      app.onhostcontextchanged = (ctx: any) => {
        if (ctx.containerDimensions?.height) {
          setContainerHeight(ctx.containerDimensions.height);
        }

        if (ctx.displayMode) {
          fsLog(`hostContextChanged: displayMode=${ctx.displayMode}`);

          // 풀스크린 종료 → 편집된 요소 동기화
          if (ctx.displayMode === "inline") {
            const edited = getLatestEditedElements();
            if (edited) {
              setElements(edited);
              setUserEdits(edited);
            }
          }

          setDisplayMode(ctx.displayMode as "inline" | "fullscreen");
        }
      };

      // ======================================
      // 스트리밍 입력 (부분)
      // ======================================
      app.ontoolinputpartial = async (input) => {
        const args = (input as any)?.arguments || input;
        setInputIsFinal(false);
        setToolInput(args);
      };

      // ======================================
      // 최종 입력
      // ======================================
      app.ontoolinput = async (input) => {
        const args = (input as any)?.arguments || input;
        setInputIsFinal(true);
        setToolInput(args);
      };

      // ======================================
      // 도구 결과 (checkpointId 추출)
      // ======================================
      app.ontoolresult = (result: any) => {
        const cpId = (result.structuredContent as { checkpointId?: string })?.checkpointId;
        if (cpId) {
          checkpointIdRef.current = cpId;
          setCheckpointId(cpId);

          // localStorage 키 설정
          setStorageKey(cpId);

          // 이전 편집 복원 (풀스크린에서 편집 후 나갔다가 다시 들어온 경우)
          const persisted = loadPersistedElements();
          if (persisted && persisted.length > 0) {
            elementsRef.current = persisted;
            setElements(persisted);
            setUserEdits(persisted);
          }
        }
      };
    },
  });

  // ============================================================
  // 풀스크린 토글
  // ============================================================
  const toggleFullscreen = useCallback(async () => {
    if (!appRef.current) return;
    const newMode = displayMode === "fullscreen" ? "inline" : "fullscreen";

    // 풀스크린 종료 → 편집 동기화
    if (newMode === "inline") {
      const edited = getLatestEditedElements();
      if (edited) {
        setElements(edited);
        setUserEdits(edited);
      }
    }

    try {
      const result = await appRef.current.requestDisplayMode({ mode: newMode });
      setDisplayMode(result.mode as "inline" | "fullscreen");
    } catch (err) {
      fsLog(`requestDisplayMode FAILED: ${err}`);
    }
  }, [displayMode]);

  // ============================================================
  // 렌더링
  // ============================================================
  return (
    <main className={`main${displayMode === "fullscreen" ? " fullscreen" : ""}`}>
      {/* 인라인 모드: SVG + 풀스크린 버튼 */}
      {displayMode === "inline" && (
        <>
          <div className="toolbar">
            <button className="fullscreen-btn" onClick={toggleFullscreen}>
              <ExpandIcon />
            </button>
          </div>
          <DiagramView
            toolInput={toolInput}
            isFinal={inputIsFinal}
            displayMode={displayMode}
            onElements={setElements}
            editedElements={userEdits ?? undefined}
            onViewport={(vp) => { svgViewportRef.current = vp; }}
            loadCheckpoint={async (id) => {
              const result = await appRef.current.callServerTool({
                name: "read_checkpoint",
                arguments: { id }
              });
              const text = (result.content[0] as any)?.text;
              if (!text) return null;
              return JSON.parse(text);
            }}
          />
        </>
      )}

      {/* 풀스크린 모드: Excalidraw 컴포넌트 */}
      {displayMode === "fullscreen" && editorReady && (
        <div style={{
          width: "100%",
          height: "100%",
          visibility: editorSettled ? "visible" : "hidden",
        }}>
          <Excalidraw
            excalidrawAPI={(api) => setExcalidrawApi(api)}
            initialData={{ elements: elements as any, scrollToContent: true }}
            theme="light"
            onChange={(els) => onEditorChange(app, els)}
            renderTopRightUI={() => (
              <ShareButton
                onConfirm={async () => {
                  if (excalidrawApi) await shareToExcalidraw(excalidrawApi, app);
                }}
              />
            )}
          />
        </div>
      )}

      {/* SVG 오버레이 (Excalidraw 정착 전까지) */}
      {!editorSettled && (
        <DiagramView ... />
      )}
    </main>
  );
}

4. checkpoint-store.ts - 상태 관리

// src/checkpoint-store.ts

export interface CheckpointStore {
  save(id: string, data: { elements: any[] }): Promise<void>;
  load(id: string): Promise<{ elements: any[] } | null>;
}

// ============================================================
// 로컬 파일 시스템 (개발/프로덕션)
// ============================================================
export class FileCheckpointStore implements CheckpointStore {
  private dir: string;

  constructor() {
    this.dir = path.join(os.tmpdir(), "excalidraw-mcp-checkpoints");
    fs.mkdirSync(this.dir, { recursive: true });
  }

  async save(id: string, data: { elements: any[] }): Promise<void> {
    await fs.promises.writeFile(
      path.join(this.dir, `${id}.json`),
      JSON.stringify(data)
    );
  }

  async load(id: string): Promise<{ elements: any[] } | null> {
    try {
      const raw = await fs.promises.readFile(
        path.join(this.dir, `${id}.json`),
        "utf-8"
      );
      return JSON.parse(raw);
    } catch {
      return null;
    }
  }
}

// ============================================================
// 메모리 (Vercel 폴백)
// ============================================================
const memoryStore = new Map<string, string>();

export class MemoryCheckpointStore implements CheckpointStore {
  async save(id: string, data: { elements: any[] }): Promise<void> {
    memoryStore.set(id, JSON.stringify(data));
  }

  async load(id: string): Promise<{ elements: any[] } | null> {
    const raw = memoryStore.get(id);
    if (!raw) return null;
    try { return JSON.parse(raw); } catch { return null; }
  }
}

// ============================================================
// Redis (Vercel 프로덕션)
// ============================================================
export class RedisCheckpointStore implements CheckpointStore {
  private redis: any = null;

  private async getRedis() {
    if (!this.redis) {
      const { Redis } = await import("@upstash/redis");
      const url = process.env.KV_REST_API_URL ?? process.env.UPSTASH_REDIS_REST_URL;
      const token = process.env.KV_REST_API_TOKEN ?? process.env.UPSTASH_REDIS_REST_TOKEN;
      if (!url || !token) throw new Error("Missing Redis env vars");
      this.redis = new Redis({ url, token });
    }
    return this.redis;
  }

  async save(id: string, data: { elements: any[] }): Promise<void> {
    const redis = await this.getRedis();
    await redis.set(`cp:${id}`, JSON.stringify(data), {
      ex: 30 * 24 * 60 * 60 // 30일 TTL
    });
  }

  async load(id: string): Promise<{ elements: any[] } | null> {
    const redis = await this.getRedis();
    const raw = await redis.get(`cp:${id}`);
    if (!raw) return null;
    try {
      return typeof raw === "string" ? JSON.parse(raw) : raw;
    } catch {
      return null;
    }
  }
}

// ============================================================
// Vercel 환경 감지
// ============================================================
export function createVercelStore(): CheckpointStore {
  if (process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL) {
    return new RedisCheckpointStore();
  }
  return new MemoryCheckpointStore();
}

사용 예시:

// main.ts
const store = new FileCheckpointStore();

// server.ts
const checkpointId = "a3f9e2c1b4d5";
await store.save(checkpointId, { elements: [...] });

// 나중에...
const data = await store.load(checkpointId);
// → { elements: [...] }

5. edit-context.ts - 풀스크린 편집 관리

// src/edit-context.ts

let storageKey: string | null = null;
let checkpointId: string | null = null;
let initialElements: any[] | null = null;
let latestEditedElements: any[] | null = null;
let saveTimer: any = null;
let appInstance: App | null = null;

// ============================================================
// localStorage 키 설정
// ============================================================
export function setStorageKey(key: string) {
  storageKey = `excalidraw:${key}`;
}

export function setCheckpointId(id: string) {
  checkpointId = id;
}

// ============================================================
// 초기 상태 캡처
// ============================================================
export function captureInitialElements(elements: any[]) {
  initialElements = elements;
}

// ============================================================
// 편집 감지 (onChange)
// ============================================================
export function onEditorChange(app: App, elements: any[]) {
  if (!storageKey || !initialElements) return;

  appInstance = app;
  latestEditedElements = elements;

  // localStorage 즉시 저장
  try {
    localStorage.setItem(storageKey, JSON.stringify({ elements }));
  } catch {}

  // 서버 저장 디바운스 (2초)
  if (saveTimer) clearTimeout(saveTimer);
  saveTimer = setTimeout(async () => {
    if (!checkpointId || !appInstance) return;

    try {
      await appInstance.callServerTool({
        name: "save_checkpoint",
        arguments: {
          id: checkpointId,
          data: JSON.stringify({ elements })
        },
      });
    } catch {}
  }, 2000);
}

// ============================================================
// 저장된 편집 로드
// ============================================================
export function loadPersistedElements(): any[] | null {
  if (!storageKey) return null;

  try {
    const raw = localStorage.getItem(storageKey);
    if (!raw) return null;
    const data = JSON.parse(raw);
    return data.elements ?? null;
  } catch {
    return null;
  }
}

export function getLatestEditedElements(): any[] | null {
  return latestEditedElements;
}

플로우:

ontoolresult({ checkpointId })
  ↓
setStorageKey(checkpointId)
setCheckpointId(checkpointId)
  ↓
loadPersistedElements()
  └─ localStorage.getItem("excalidraw:{id}")
  ↓
풀스크린 진입
  ↓
<Excalidraw onChange={onEditorChange} />
  ↓
onEditorChange(app, elements)
  ├─ latestEditedElements = elements
  ├─ localStorage 즉시 저장
  └─ 디바운스 타이머 시작 (2초)
      └─ app.callServerTool("save_checkpoint")
          └─ 서버 체크포인트 업데이트
  ↓
풀스크린 종료
  ↓
getLatestEditedElements()
  └─ latestEditedElements 반환
  ↓
SVG 재렌더링

데이터 흐름

완전한 다이어그램 생성 플로우

[1] 사용자 입력
    "사용자 등록 플로우를 다이어그램으로 그려줘"
    ↓
[2] Claude AI Agent
    ├─ 의도 분석: 다이어그램 생성 필요
    └─ read_me() 호출 → 치트시트 학습
    ↓
[3] Claude가 Excalidraw JSON 생성 시작
    elements = [
      {"type":"cameraUpdate","width":800,"height":600,"x":0,"y":0},
      {"type":"rectangle","id":"user","x":100,"y":100,...},
      ...
    ↓
[4] MCP Client (스트리밍)
    ontoolinputpartial({ elements: '[{' })
    ontoolinputpartial({ elements: '[{...},{' })
    ontoolinputpartial({ elements: '[{...},{...},{' })
    ...
    ↓
[5] React Widget (DiagramView)
    ├─ parsePartialElements('[{...},{...},{')
    │   └─ → [{...},{...}]
    ├─ excludeIncompleteLastItem()
    │   └─ → [{...}] (마지막 제거)
    ├─ 요소 개수 변경? → Yes
    ├─ playStroke("rectangle") (사운드)
    ├─ 시드 랜덤화 (흔들림)
    └─ renderSvgPreview()
        ├─ convertRawElements() (label → bound text)
        ├─ exportToSvg() (Excalidraw 라이브러리)
        ├─ morphdom() (기존 SVG와 병합)
        └─ animateViewBox() (카메라 LERP)
    ↓
[6] 최종 JSON 완성
    ontoolinput({ elements: '[{...},{...},{...}]' })
    ↓
[7] MCP Server (create_view)
    ├─ JSON.parse(elements)
    ├─ restoreCheckpoint 체크 (없음)
    ├─ delete 체크 (없음)
    ├─ checkpointId 생성: "a3f9e2c1b4d5"
    └─ store.save("a3f9e2c1b4d5", { elements })
        └─ 파일: $TMPDIR/excalidraw-mcp-checkpoints/a3f9e2c1b4d5.json
    ↓
[8] 응답 반환
    {
      content: "Diagram displayed! Checkpoint id: \"a3f9e2c1b4d5\"",
      structuredContent: { checkpointId: "a3f9e2c1b4d5" }
    }
    ↓
[9] Widget (ontoolresult)
    ├─ setStorageKey("excalidraw:a3f9e2c1b4d5")
    ├─ setCheckpointId("a3f9e2c1b4d5")
    └─ loadPersistedElements() (이전 편집 확인)
    ↓
[10] 최종 렌더링
    ├─ 원본 시드 (안정적)
    ├─ SVG 표시 완료
    └─ 풀스크린 버튼 활성화

다이어그램 수정 플로우 (체크포인트 복원)

[1] 사용자 요청
    "여기에 데이터베이스 박스 추가해줘"
    ↓
[2] Claude AI Agent
    ├─ 기존 checkpointId 인식: "a3f9e2c1b4d5"
    └─ JSON 생성:
        [
          {"type":"restoreCheckpoint","id":"a3f9e2c1b4d5"},
          {"type":"rectangle","id":"db","x":500,"y":200,...}
        ]
    ↓
[3] MCP Server (create_view)
    ├─ restoreCheckpoint 발견
    ├─ store.load("a3f9e2c1b4d5")
    │   └─ { elements: [기존 20개 요소] }
    ├─ 새 요소: [db 박스]
    ├─ 병합: [...기존 20개, db]
    ├─ 새 checkpointId: "b7c2d4e1f3a6"
    └─ store.save("b7c2d4e1f3a6", { elements: [...병합된 21개] })
    ↓
[4] Widget (스트리밍)
    ├─ restoreCheckpoint 감지
    ├─ app.callServerTool("read_checkpoint", { id: "a3f9e2c1b4d5" })
    │   └─ 기존 20개 요소 로드
    ├─ base로 캐싱
    ├─ 새 요소 스트리밍: [db]
    └─ renderSvgPreview(new, viewport, base)
        ├─ exportToSvg([...base, ...new])
        └─ morphdom() (기존 20개 재사용 + db만 추가)
    ↓
[5] 결과
    ├─ 기존 요소는 애니메이션 없음 (이미 렌더링됨)
    └─ db 박스만 draw-on 애니메이션

풀스크린 편집 플로우

[1] 사용자가 풀스크린 버튼 클릭
    ↓
[2] Widget
    app.requestDisplayMode({ mode: "fullscreen" })
    ↓
[3] Host (Claude Desktop)
    ├─ iframe 확장 (전체 화면)
    └─ onhostcontextchanged({ displayMode: "fullscreen" })
    ↓
[4] Widget
    ├─ displayMode = "fullscreen"
    ├─ HTML/body 높이 설정
    └─ editorReady = true
    ↓
[5] Excalidraw 컴포넌트 마운트
    <Excalidraw
      initialData={{ elements }}
      onChange={onEditorChange}
    />
    ↓
[6] 사용자 편집
    ├─ 요소 이동/추가/삭제
    └─ onChange(elements) 트리거
    ↓
[7] onEditorChange(app, elements)
    ├─ latestEditedElements = elements
    ├─ localStorage.setItem("excalidraw:a3f9e2c1b4d5", JSON.stringify({ elements }))
    └─ 디바운스 타이머 시작 (2초)
        ↓
[8] 2초 후
    app.callServerTool("save_checkpoint", {
      id: "a3f9e2c1b4d5",
      data: JSON.stringify({ elements })
    })
    ↓
[9] MCP Server
    store.save("a3f9e2c1b4d5", { elements })
    └─ 파일 업데이트: a3f9e2c1b4d5.json
    ↓
[10] 사용자가 Escape 키 또는 호스트 UI로 종료
    ↓
[11] Widget
    ├─ displayMode = "inline"
    ├─ getLatestEditedElements()
    │   └─ latestEditedElements 반환
    ├─ setElements(edited)
    └─ SVG 재렌더링
    ↓
[12] 결과
    ├─ 편집된 다이어그램이 SVG로 표시
    └─ 다음 수정 요청 시 편집된 상태 기준으로 진행

핵심 기술 결정

1. SVG 전용 렌더링 (Excalidraw React 컴포넌트 미사용)

이유:

  • 최종 렌더 시 깜빡임 없음
  • 폰트 로딩 문제 회피 (Virgil 사전 로드)
  • morphdom과 호환 (DOM 비교)

트레이드오프:

  • 대화형 기능 없음 (인라인 모드)
  • 풀스크린에서만 편집 가능

2. morphdom을 사용한 DOM 패칭

이유:

  • 기존 SVG <g> 요소 재사용
  • CSS 애니메이션 중단 없음
  • 새 요소만 draw-on 트리거

대안:

  • innerHTML 교체 → 모든 요소 재애니메이션 (부자연스러움)

3. 표준 Excalidraw JSON (확장 없음)

이유:

  • .excalidraw 파일 호환
  • Excalidraw.com에 공유 가능
  • 미래 버전과 호환성

트레이드오프:

  • 라벨 수동 계산 필요 (중앙 정렬)
  • convertToExcalidrawElements() 사용 (클라이언트)

4. 체크포인트 시스템

이유:

  • 토큰 절약 (전체 재전송 불필요)
  • 점진적 편집 가능
  • 사용자 편집 보존

구현:

  • 서버 해결 (모델은 checkpointId만 참조)
  • localStorage + 서버 이중 저장

5. 카메라 애니메이션 (LERP 보간)

이유:

  • 복잡한 다이어그램도 읽기 쉽게 안내
  • 부드러운 전환
  • 사용자 몰입감 증가

구현:

  • Scene 좌표계에서 LERP
  • SVG viewBox로 변환
  • requestAnimationFrame 루프

성능 최적화

1. 스트리밍 최적화

// 요소 개수 변경 시만 렌더링
if (drawElements.length > 0 && drawElements.length !== latestRef.current.length) {
  renderSvgPreview(...);
}

효과: 부분 JSON 업데이트마다 렌더링하지 않음

2. 체크포인트 캐싱

// restoreCheckpoint 한 번만 로드
if (!restoredRef.current || restoredRef.current.id !== streamRestoreId) {
  const saved = await loadCheckpoint(streamRestoreId);
  restoredRef.current = { id: streamRestoreId, elements: converted };
}

효과: 스트리밍 중 반복 로드 방지

3. 디바운스 저장

// 2초 대기 후 서버 저장
saveTimer = setTimeout(async () => {
  await app.callServerTool("save_checkpoint", ...);
}, 2000);

효과: 편집 중 과도한 네트워크 요청 방지

4. 폰트 사전 로드

// 마운트 시 모든 폰트 로드
useEffect(() => {
  Promise.all([
    document.fonts.load('20px Excalifont'),
    document.fonts.load('400 16px Assistant'),
    ...
  ]);
}, []);

효과: 풀스크린 진입 시 폰트 깜빡임 없음


보안 및 프라이버시

로컬 우선 아키텍처

✅ 로컬 처리:
  - 다이어그램 생성 (MCP 서버)
  - SVG 렌더링 (브라우저)
  - 체크포인트 저장 ($TMPDIR)
  - 사용자 편집 (localStorage)

❌ 외부 통신:
  - Excalidraw 폰트 (esm.sh) - 한 번만
  - excalidraw.com 공유 - 선택사항

데이터 보관 기간

로컬 개발:
  - FileCheckpointStore
  - 위치: $TMPDIR/excalidraw-mcp-checkpoints/
  - 만료: OS가 임시 파일 정리할 때

Vercel 배포:
  - RedisCheckpointStore
  - 위치: Upstash Redis
  - 만료: 30일 TTL

문제 해결

Node.js 버전 오류

TypeError: Cannot read properties of undefined (reading 'endsWith')

원인: Node.js v18에서 import.meta.filename 미지원

해결:

{
  "mcpServers": {
    "excalidraw": {
      "command": "/opt/homebrew/opt/node@24/bin/node",
      "args": [...]
    }
  }
}

풀스크린 높이 문제

원인: iframe에서 position:fixed는 body 높이를 주지 않음

해결:

// 명시적 높이 설정
document.documentElement.style.height = `${containerHeight}px`;
document.body.style.height = `${containerHeight}px`;

텍스트 dimensions 깨짐

원인: Excalidraw 컴포넌트가 Assistant 폰트를 다운로드하면서 Excalifont 측정값 무효화

해결:

// 풀스크린 진입 전 모든 폰트 사전 로드
await document.fonts.load('20px Excalifont');
await document.fonts.load('400 16px Assistant');

// 마운트 후 dimensions 재계산
const { elements: fixed } = restore(
  { elements: sceneElements },
  null, null,
  { refreshDimensions: true }
);

확장 가능성

Vercel 배포

// api/mcp.ts
import { registerTools } from "../src/server.js";
import { createVercelStore } from "../src/checkpoint-store.js";

export default async function handler(req: Request) {
  const store = createVercelStore(); // Redis or Memory
  const server = new McpServer({ name: "Excalidraw", version: "1.0.0" });
  registerTools(server, DIST_DIR, store);

  // Streamable HTTP transport
  const transport = new StreamableHTTPServerTransport({...});
  await server.connect(transport);
  await transport.handleRequest(req, ...);
}

다른 MCP 클라이언트

// HTTP 모드로 실행
node dist/index.js
// → http://localhost:3001/mcp

// 웹 브라우저, Postman 등에서 테스트 가능
fetch('http://localhost:3001/mcp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    jsonrpc: '2.0',
    method: 'tools/call',
    params: { name: 'read_me' },
    id: 1,
  }),
});

결론

Excalidraw MCP 서버는 로컬 우선, 스트리밍, 애니메이션이라는 세 가지 핵심 원칙을 중심으로 설계되었습니다.

핵심 혁신:

  1. 스트리밍 중 애니메이션 - morphdom으로 기존 요소 보존
  2. 체크포인트 시스템 - 토큰 절약 + 점진적 편집
  3. 카메라 시스템 - 복잡한 다이어그램도 읽기 쉽게
  4. 풀스크린 편집 - localStorage + 서버 이중 저장
  5. 표준 JSON - Excalidraw 생태계 호환

완전 로컬 동작:

  • MCP 서버: 당신의 Mac
  • 다이어그램 생성: 당신의 Mac
  • 체크포인트: 당신의 Mac ($TMPDIR)
  • 외부 통신: 폰트 로딩(한 번) + 공유(선택)

이 아키텍처는 속도, 프라이버시, 사용자 경험을 모두 만족시킵니다.

반응형