오늘도 공부
Excalidraw MCP 서버 아키텍처 가이드 본문
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. 다이어그램 생성 플로우
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 서버는 로컬 우선, 스트리밍, 애니메이션이라는 세 가지 핵심 원칙을 중심으로 설계되었습니다.
핵심 혁신:
- 스트리밍 중 애니메이션 - morphdom으로 기존 요소 보존
- 체크포인트 시스템 - 토큰 절약 + 점진적 편집
- 카메라 시스템 - 복잡한 다이어그램도 읽기 쉽게
- 풀스크린 편집 - localStorage + 서버 이중 저장
- 표준 JSON - Excalidraw 생태계 호환
완전 로컬 동작:
- MCP 서버: 당신의 Mac
- 다이어그램 생성: 당신의 Mac
- 체크포인트: 당신의 Mac ($TMPDIR)
- 외부 통신: 폰트 로딩(한 번) + 공유(선택)
이 아키텍처는 속도, 프라이버시, 사용자 경험을 모두 만족시킵니다.
'AI' 카테고리의 다른 글
| UI/UX Pro Max 검색 시스템 분석 문서 (0) | 2026.02.07 |
|---|---|
| Remotion 완전 가이드: React로 비디오를 만드는 모든 것 (0) | 2026.01.22 |
| Ollama에서 무료로 이미지를 생성해보자 (0) | 2026.01.17 |
| AI로 프로급 디자인 뽑는 법: 7단계 계층적 프롬프트 설계 (0) | 2026.01.09 |
| 켄트백의 TDD claude.md 지침서 (0) | 2025.12.30 |
