# Palette MCP Server - 문제 해결 및 개발 과정
> 이 문서는 MCP 서버 개발 과정에서 마주친 핵심 문제들과 그 해결 방법을 상세히 기술합니다.
> 향후 MCP 개발의 교본 및 참고 자료로 활용됩니다.
---
## 목차
1. [외부 패키지 컴포넌트 메타데이터 동기화](#1-외부-패키지-컴포넌트-메타데이터-동기화)
2. [TypeScript AST 분석을 통한 Props 추출](#2-typescript-ast-분석을-통한-props-추출)
3. [MCP-First + REST API 폴백 패턴](#3-mcp-first--rest-api-폴백-패턴)
4. [Local/Remote 실행 모드 분리](#4-localremote-실행-모드-분리)
5. [Figma 노드 → 디자인 시스템 컴포넌트 매핑](#5-figma-노드--디자인-시스템-컴포넌트-매핑)
6. [캐시 기반 성능 최적화](#6-캐시-기반-성능-최적화)
---
## 1. 외부 패키지 컴포넌트 메타데이터 동기화
### 문제 상황
Palette MCP 서버는 `@dealicious/design-system-react`와 `@dealicious/design-system` 패키지의 컴포넌트를 사용하여 코드를 생성합니다. 하지만 이 패키지들은:
- **비공개 npm 패키지**로 직접 의존성 추가 불가
- **별도의 GitHub 저장소(`ssm-web`)**에서 관리됨
- 컴포넌트 **메타데이터(props, 타입 등)가 수시로 변경**됨
**핵심 질문**: 외부 저장소에 있는 컴포넌트 정보를 어떻게 실시간으로 동기화할 것인가?
### 해결 방법: GitHub API + TypeScript AST 분석
#### 아키텍처 설계
```
┌─────────────────────────────────────────────────────────────────┐
│ ComponentSyncService │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ │
│ │ GitHubClient│──▶│TypeScriptAnalyzer│──▶│ CacheManager │ │
│ │ │ │ │ │ │ │
│ │ - API 호출 │ │ - AST 파싱 │ │ - 파일 캐시 │ │
│ │ - 파일 가져오기 │ │ - Props 추출 │ │ - 커밋 SHA 검증 │ │
│ └─────────────┘ └──────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
#### 구현 코드 핵심
**1단계: GitHub에서 컴포넌트 목록 가져오기**
```typescript
// src/sync/github-client.ts
export class GitHubClient {
private octokit: Octokit;
constructor(config: Partial<GitHubConfig> = {}) {
this.config = {
owner: 'dealicious-inc',
repo: 'ssm-web',
...config
};
this.octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
}
// React 컴포넌트 목록: packages/design-system-react/src/components/
async getReactComponentList(ref?: string): Promise<string[]> {
const basePath = 'packages/design-system-react/src/components';
return this.listFiles(basePath, ref); // ['ssm-button', 'ssm-input', ...]
}
// 컴포넌트 소스 코드 가져오기
async getReactComponentSource(componentDir: string, ref?: string): Promise<FileContent | null> {
const basePath = `packages/design-system-react/src/components/${componentDir}`;
// 여러 가능한 파일명 시도 (프로젝트마다 컨벤션이 다름)
const mainFiles = [
`${basePath}/${componentDir}.tsx`, // ssm-button/ssm-button.tsx
`${basePath}/index.tsx`, // ssm-button/index.tsx
`${basePath}/${this.toPascalCase(componentDir)}.tsx`, // ssm-button/SsmButton.tsx
];
// Props가 별도 파일에 있을 수 있음
const auxiliaryFiles = [
`${basePath}/types.ts`, // ssm-icon/types.ts
`${basePath}/base/${simpleComponentName}.tsx`, // ssm-accordion/base/accordion.tsx
];
// 메인 파일 + 보조 파일 병합하여 반환
// ...
}
}
```
**2단계: TypeScript Compiler API로 소스 코드 분석**
```typescript
// src/sync/typescript-analyzer.ts
import ts from 'typescript';
export class TypeScriptAnalyzer {
analyzeReactComponent(sourceCode: string, componentDir: string): AnalyzedComponent | null {
// TypeScript Compiler API로 AST 생성
const sourceFile = ts.createSourceFile(
'component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
);
// Props 인터페이스 찾기 (다양한 네이밍 컨벤션 지원)
const propsInterfaceNames = [
`${componentName}Props`, // ButtonProps
`Ssm${componentName}Props`, // SsmButtonProps
`Props`, // Props
];
// AST 순회하며 Props 추출
const visit = (node: ts.Node) => {
if (ts.isInterfaceDeclaration(node)) {
if (propsInterfaceNames.includes(node.name.text)) {
node.members.forEach((member) => {
const prop = this.extractPropFromMember(member);
if (prop) props.push(prop);
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return { name, description, props, examples };
}
}
```
**3단계: 동기화 서비스 통합**
```typescript
// src/sync/component-sync.ts
export class ComponentSyncService {
async sync(options: SyncOptions = {}): Promise<SyncResult> {
// 1. 최신 커밋 SHA 확인
const commitInfo = await this.githubClient.getLatestCommitSha();
// 2. 캐시 유효성 검사 (같은 커밋이면 캐시 사용)
if (!options.force) {
const isValid = await this.cacheManager.isValid(commitInfo.sha);
if (isValid) {
return this.cacheManager.load(); // 캐시에서 반환
}
}
// 3. GitHub에서 컴포넌트 동기화
const [reactComponents, vueComponents] = await Promise.all([
this.syncReactComponents(commitInfo.sha),
this.syncVueComponents(commitInfo.sha),
]);
// 4. 캐시 저장
await this.cacheManager.save(commitInfo.sha, reactComponents, vueComponents);
return { success: true, reactComponents, vueComponents };
}
}
```
### 핵심 교훈
| 문제 | 해결책 |
|------|--------|
| npm 패키지 직접 접근 불가 | GitHub REST API로 소스 코드 직접 가져오기 |
| 메타데이터 수동 관리의 어려움 | TypeScript Compiler API로 자동 추출 |
| API 호출 비용/속도 | 커밋 SHA 기반 캐시로 불필요한 호출 방지 |
| 다양한 파일 구조 대응 | 여러 파일명 패턴 시도 + 보조 파일 병합 |
---
## 2. TypeScript AST 분석을 통한 Props 추출
### 문제 상황
디자인 시스템 컴포넌트의 Props 정의는 매우 다양한 형태로 존재합니다:
```typescript
// 패턴 1: 직접 interface 정의
interface SsmButtonProps {
variant?: 'primary' | 'secondary';
size?: 'small' | 'medium' | 'large';
}
// 패턴 2: 타입 별칭 + Union 타입
type AccordionProps = BaseProps & {
title: string;
} | ConditionalProps;
// 패턴 3: 별도 파일에 정의 (types.ts)
// types.ts
export interface SsmIconProps {
name: string;
size?: number;
}
// 패턴 4: Vue의 defineProps
const props = defineProps<{
modelValue?: boolean;
disabled?: boolean;
}>();
// 패턴 5: withDefaults 래핑
const props = withDefaults(defineProps<SsmButtonProps>(), {
variant: 'primary'
});
```
**핵심 질문**: 이 모든 패턴을 어떻게 통일된 방식으로 분석할 것인가?
### 해결 방법: 재귀적 타입 참조 해결
```typescript
// src/sync/typescript-analyzer.ts
/**
* 타입 노드에서 props 추출 (타입 참조 재귀 해결)
*/
private extractPropsFromTypeNodeWithRefs(
typeNode: ts.TypeNode,
props: ComponentProp[],
typeDefinitions: Map<string, ts.TypeNode>,
visited: Set<string> = new Set() // 순환 참조 방지
): void {
// 1. 타입 리터럴: { prop1: string; prop2: number }
if (ts.isTypeLiteralNode(typeNode)) {
typeNode.members.forEach((member) => {
const prop = this.extractPropFromMember(member);
if (prop) props.push(prop);
});
return;
}
// 2. Intersection 타입: A & B
if (ts.isIntersectionTypeNode(typeNode)) {
typeNode.types.forEach((type) => {
this.extractPropsFromTypeNodeWithRefs(type, props, typeDefinitions, visited);
});
return;
}
// 3. Union 타입: A | B (각 분기에서 공통 props 추출)
if (ts.isUnionTypeNode(typeNode)) {
typeNode.types.forEach((type) => {
this.extractPropsFromTypeNodeWithRefs(type, props, typeDefinitions, visited);
});
return;
}
// 4. 타입 참조: ConditionalProps 같은 다른 타입 참조
if (ts.isTypeReferenceNode(typeNode)) {
const typeName = typeNode.typeName.getText();
// 유틸리티 타입은 건너뛰기
if (['Omit', 'Pick', 'Partial', 'Required'].includes(typeName)) {
return;
}
// 순환 참조 방지
if (visited.has(typeName)) return;
visited.add(typeName);
// 같은 파일에 정의된 타입 재귀적으로 해결
const referencedType = typeDefinitions.get(typeName);
if (referencedType) {
this.extractPropsFromTypeNodeWithRefs(referencedType, props, typeDefinitions, visited);
}
}
}
```
### Vue SFC 특수 처리
```typescript
/**
* Vue SFC에서 script 블록 추출
*/
private extractVueScript(sourceCode: string): string | null {
// <script setup lang="ts"> ... </script> 추출
const scriptRegex = /<script[^>]*(?:setup)?[^>]*>[\s\S]*?<\/script>/gi;
const matches = sourceCode.match(scriptRegex);
// setup script 우선
const setupMatch = matches.find((m) => m.includes('setup'));
const targetScript = setupMatch || matches[0];
// 태그 제거하고 TypeScript 코드만 반환
return targetScript
.replace(/<script[^>]*>/, '')
.replace(/<\/script>/, '')
.trim();
}
/**
* Vue defineProps에서 props 추출
*/
private extractPropsFromVue(sourceFile: ts.SourceFile): ComponentProp[] {
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const expression = node.expression;
// defineProps<{ ... }>() 패턴
if (ts.isIdentifier(expression) && expression.text === 'defineProps') {
if (node.typeArguments && node.typeArguments.length > 0) {
const typeArg = node.typeArguments[0];
// 타입 리터럴 또는 타입 참조 처리...
}
}
// withDefaults(defineProps<...>(), { ... }) 패턴
if (ts.isIdentifier(expression) && expression.text === 'withDefaults') {
// defineProps 호출 추출하여 처리...
}
}
ts.forEachChild(node, visit);
};
}
```
### 핵심 교훈
| 패턴 | 해결 방법 |
|------|-----------|
| 다양한 Props 정의 방식 | 다중 인터페이스/타입 이름 시도 |
| 중첩된 타입 참조 | 재귀적 타입 해결 + visited Set으로 순환 방지 |
| Union/Intersection 타입 | 각 분기 순회하며 모든 props 수집 후 중복 제거 |
| Vue SFC | script 블록 추출 후 TypeScript로 파싱 |
| 별도 파일의 타입 정의 | 여러 파일 병합 후 분석 |
---
## 3. MCP-First + REST API 폴백 패턴
### 문제 상황
Figma 데이터를 가져오는 방법이 두 가지 있습니다:
1. **Figma Desktop MCP**: 로컬에서 실행 중인 Figma 앱의 MCP 서버
- 장점: 인증 불필요, 빠른 응답
- 단점: Figma Desktop 실행 필요, Remote 환경에서 사용 불가
2. **Figma REST API**: 공식 웹 API
- 장점: 어디서든 사용 가능
- 단점: Access Token 필요, Rate Limit 존재
**핵심 질문**: 두 방법을 어떻게 우아하게 통합할 것인가?
### 해결 방법: 폴백 체인 패턴
```typescript
// src/services/figma.ts
export class FigmaService {
private mcpClient: FigmaMCPClient | null = null;
private useMCP: boolean;
constructor(useMCP: boolean = true, mcpBaseUrl?: string) {
this.useMCP = useMCP;
if (useMCP) {
const mcpUrl = mcpBaseUrl || process.env.FIGMA_MCP_SERVER_URL || 'http://127.0.0.1:3845/mcp';
this.mcpClient = new FigmaMCPClient(mcpUrl);
}
}
async getFigmaData(url: string, nodeId?: string): Promise<FigmaFile> {
const fileId = this.extractFileId(url);
// 1차 시도: MCP 클라이언트
if (this.useMCP && this.mcpClient !== null) {
try {
const isAvailable = await this.mcpClient.isAvailable();
if (isAvailable) {
const mcpData = await this.mcpClient.getFileData(fileId, nodeId);
if (mcpData) {
return this.transformMCPResponseToFigmaFile(mcpData);
}
}
} catch (error) {
console.warn('Figma MCP 서버 연결 실패, REST API로 폴백:', error);
// 폴백으로 계속 진행
}
}
// 2차 시도: REST API 폴백
if (!this.accessToken) {
throw new Error('Figma 액세스 토큰이 필요합니다. MCP 서버도 사용할 수 없습니다.');
}
const response = await axios.get(`${this.baseUrl}/files/${fileId}`, {
headers: { 'X-Figma-Token': this.accessToken },
params: { ids: nodeId || undefined },
});
return this.transformRestApiResponse(response.data);
}
}
```
### MCP 클라이언트 구현
```typescript
// src/utils/figma-mcp-client.ts
export class FigmaMCPClient {
private client: AxiosInstance;
constructor(baseUrl: string = 'http://127.0.0.1:3845/mcp') {
this.client = axios.create({
baseURL: baseUrl,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
}
// JSON-RPC 2.0 요청 생성
private createRequest(method: string, params?: any): any {
return {
jsonrpc: '2.0',
id: ++this.requestId,
method,
params: params || {},
};
}
async getFileData(fileId: string, nodeId?: string): Promise<any> {
// 1. 사용 가능한 도구 목록 확인
const tools = await this.listTools();
if (!tools || tools.length === 0) return null;
// 2. 파일 데이터 도구 찾기 (다양한 이름 시도)
const possibleToolNames = [
'get_file', 'getFile', 'fetch_file',
'fetchFile', 'get_figma_file', 'figma_get_file',
];
for (const toolName of possibleToolNames) {
const tool = tools.find((t: any) => t.name === toolName);
if (tool) {
const result = await this.callTool(toolName, { fileId, nodeId });
if (result) return result;
}
}
return null; // 도구를 찾지 못하면 null (폴백 트리거)
}
// 연결 가능 여부 빠르게 확인
async isAvailable(): Promise<boolean> {
try {
await this.listTools();
return true;
} catch {
return false;
}
}
}
```
### 핵심 교훈
| 원칙 | 적용 |
|------|------|
| 우아한 성능 저하 | MCP 실패 시 REST API로 자동 폴백 |
| 빠른 실패 감지 | `isAvailable()` 으로 연결 상태 사전 확인 |
| 유연한 도구 탐색 | 여러 도구 이름 시도로 다양한 MCP 서버 호환 |
| 에러 로깅 | 폴백 시 경고 로그로 디버깅 지원 |
---
## 4. Local/Remote 실행 모드 분리
### 문제 상황
Palette MCP 서버는 두 가지 환경에서 실행됩니다:
1. **Local 모드**: 개발자 PC에서 Cursor와 함께 실행
- stdio transport 사용
- Figma Desktop MCP 접근 가능 (localhost)
2. **Remote 모드**: Smithery.ai 클라우드에서 실행
- Smithery SDK transport 사용
- localhost 접근 **불가능** (Figma Desktop MCP 사용 불가)
**핵심 질문**: 하나의 코드베이스로 두 환경을 어떻게 지원할 것인가?
### 해결 방법: 공통 서버 로직 분리
```
src/
├── server.ts # 공통 로직 (Tools, Prompts, Resources 정의)
├── index.ts # Local 모드 진입점 (stdio transport)
└── smithery.ts # Remote 모드 진입점 (Smithery SDK transport)
```
**공통 서버 로직** (`server.ts`):
```typescript
// src/server.ts
export interface ServerConfig {
figmaAccessToken?: string;
githubToken?: string;
figmaMcpServerUrl?: string;
useFigmaMcp?: boolean; // Remote 모드에서는 false
syncConfig?: SyncConfig;
}
export function createPaletteServer(config: ServerConfig = {}): Server {
const server = new Server(
{ name: 'palette', version: '1.0.0' },
{ capabilities: { tools: {}, prompts: {}, resources: {} } }
);
// 서비스 초기화 - useFigmaMcp 플래그로 MCP 사용 여부 제어
const useFigmaMcp = config.useFigmaMcp !== undefined ? config.useFigmaMcp : true;
const figmaService = new FigmaService(useFigmaMcp, config.figmaMcpServerUrl);
// Tools, Prompts, Resources 핸들러 등록...
return server;
}
```
**Local 모드** (`index.ts`):
```typescript
// src/index.ts
#!/usr/bin/env node
import dotenv from 'dotenv';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createPaletteServer } from './server.js';
dotenv.config();
async function main() {
const server = createPaletteServer({
figmaAccessToken: process.env.FIGMA_ACCESS_TOKEN,
githubToken: process.env.GITHUB_TOKEN,
figmaMcpServerUrl: process.env.FIGMA_MCP_SERVER_URL,
useFigmaMcp: true, // Local에서는 MCP 사용
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Palette server running on stdio (Local mode)');
}
main().catch(console.error);
```
**Remote 모드** (`smithery.ts`):
```typescript
// src/smithery.ts
import { createSmitheryServer } from '@smithery/sdk';
import { createPaletteServer } from './server.js';
export default createSmitheryServer((config) => {
return createPaletteServer({
figmaAccessToken: config.figmaAccessToken,
githubToken: config.githubToken,
useFigmaMcp: false, // Remote에서는 MCP 비활성화 (localhost 접근 불가)
});
});
```
### 핵심 교훈
| 원칙 | 적용 |
|------|------|
| 단일 책임 원칙 | 공통 로직(server.ts)과 진입점(index.ts, smithery.ts) 분리 |
| 설정 주입 | ServerConfig로 환경별 설정 주입 |
| 환경별 제약 대응 | useFigmaMcp 플래그로 기능 비활성화 |
---
## 5. Figma 노드 → 디자인 시스템 컴포넌트 매핑
### 문제 상황
Figma의 노드 구조와 디자인 시스템 컴포넌트는 1:1 대응되지 않습니다:
```
Figma 노드 타입 디자인 시스템 컴포넌트
───────────────── ─────────────────────
TEXT Text, TextLink
FRAME div, Tab, Accordion, Modal...
RECTANGLE Button, Badge, Tag, Input...
COMPONENT 다양한 컴포넌트
```
**핵심 질문**: Figma 노드를 어떻게 적절한 디자인 시스템 컴포넌트에 매핑할 것인가?
### 해결 방법: 다층 매핑 전략
```typescript
// src/services/code-generator.ts
/**
* Figma 노드 속성에 따라 디자인 시스템 컴포넌트 찾기
*/
private findComponentByNodeProperties(
node: FigmaNode,
framework: 'react' | 'vue'
): DesignSystemComponent | null {
const nodeName = node.name.toLowerCase();
const nodeType = node.type;
// 1. 직접 이름 매칭 (가장 정확)
let component = this.designSystemService.findBestMatch(nodeName, framework);
if (component) return component;
// 2. 노드 타입 기반 매칭
switch (nodeType) {
case 'TEXT':
// 버튼 레이블인지 확인
if (this.isButtonLabel(node)) {
return this.designSystemService.getComponent('Button', framework);
}
// 링크 텍스트인지 확인
if (nodeName.includes('link') || nodeName.includes('href')) {
return this.designSystemService.getComponent('TextLink', framework);
}
return this.designSystemService.getComponent('Text', framework);
case 'FRAME':
return this.analyzeFrameNode(node, framework);
case 'RECTANGLE':
if (nodeName.includes('button') || nodeName.includes('btn')) {
return this.designSystemService.getComponent('Button', framework);
}
if (nodeName.includes('input') || nodeName.includes('field')) {
return this.designSystemService.getComponent('Input', framework);
}
if (nodeName.includes('badge') || nodeName.includes('tag')) {
return this.designSystemService.getComponent('Badge', framework);
}
break;
}
return null; // null이면 div로 처리
}
/**
* FRAME 노드 분석 - 자식 구조 기반 컴포넌트 선택
*/
private analyzeFrameNode(
node: FigmaNode,
framework: 'react' | 'vue'
): DesignSystemComponent | null {
const nodeName = node.name.toLowerCase();
// 이름 기반 매칭
if (nodeName.includes('tab') || nodeName.includes('tabs')) {
return this.designSystemService.getComponent('Tab', framework);
}
if (nodeName.includes('accordion') || nodeName.includes('collapse')) {
return this.designSystemService.getComponent('Accordion', framework);
}
if (nodeName.includes('modal') || nodeName.includes('dialog') || nodeName.includes('popup')) {
return this.designSystemService.getComponent('Modal', framework);
}
// 자식 구조 분석
if (node.children && node.children.length > 0) {
// 모든 자식이 TEXT인 경우
const allTextChildren = node.children.every(child => child.type === 'TEXT');
if (allTextChildren && node.children.length === 1) {
return this.designSystemService.getComponent('Text', framework);
}
if (allTextChildren && node.children.length === 2) {
return this.designSystemService.getComponent('LabeledText', framework);
}
// Tab 자식이 있는 경우
const hasTabChildren = node.children.some(child =>
child.name.toLowerCase().includes('tab')
);
if (hasTabChildren) {
return this.designSystemService.getComponent('Tab', framework);
}
}
return null; // 컨테이너 역할만 하면 div로 처리
}
```
### 이름 정규화 매칭
```typescript
// src/services/design-system.ts
findBestMatch(figmaComponentName: string, framework: 'react' | 'vue'): DesignSystemComponent | null {
const components = framework === 'react' ? this.reactComponents : this.vueComponents;
// 정규화: ssm- 접두사 제거, 하이픈 제거, 소문자 변환
const normalizeName = (name: string): string => {
return name
.toLowerCase()
.replace(/^ssm-/, '') // ssm- 접두사 제거
.replace(/^ssm/, '') // ssm 접두사 제거
.replace(/-/g, '') // 하이픈 제거
.trim();
};
const normalizedFigmaName = normalizeName(figmaComponentName);
// 직접 매칭
const directMatch = components.find(comp =>
normalizeName(comp.name) === normalizedFigmaName
);
if (directMatch) return directMatch;
// 부분 매칭
for (const component of components) {
const componentName = normalizeName(component.name);
if (normalizedFigmaName.includes(componentName) ||
componentName.includes(normalizedFigmaName)) {
return component;
}
}
// 일반적인 변형 확인
const variations = [
componentName + 's', // 복수형
componentName.replace('button', 'btn'),
componentName.replace('modal', 'popup'),
componentName.replace('modal', 'dialog'),
];
// ...
}
```
### 핵심 교훈
| 전략 | 설명 |
|------|------|
| 다층 매칭 | 이름 → 노드 타입 → 자식 구조 순으로 매칭 시도 |
| 정규화 | ssm-, 하이픈 등 제거하여 유연한 매칭 |
| 폴백 | 매칭 실패 시 div 컨테이너로 처리 |
| 컨텍스트 분석 | 부모/자식 관계 분석으로 정확도 향상 |
---
## 6. 캐시 기반 성능 최적화
### 문제 상황
GitHub API 호출은 비용이 높습니다:
- Rate Limit: 인증 없이 60회/시간, 인증 시 5000회/시간
- 네트워크 지연: 각 파일당 별도 요청 필요
- 중복 호출: 서버 재시작마다 같은 데이터 다시 가져옴
**핵심 질문**: API 호출을 최소화하면서 최신 데이터를 유지하려면?
### 해결 방법: 커밋 SHA 기반 캐시 무효화
```typescript
// src/sync/cache-manager.ts
import { z } from 'zod';
// Zod 스키마로 타입 안전성 보장
const CacheMetadataSchema = z.object({
version: z.string(),
lastSyncedAt: z.string(),
commitSha: z.string(),
reactComponentCount: z.number(),
vueComponentCount: z.number(),
});
export class CacheManager {
private cacheDir: string; // .cache/design-system
/**
* 캐시가 유효한지 확인 (커밋 SHA + 버전 비교)
*/
async isValid(currentCommitSha: string): Promise<boolean> {
const metadata = await this.getMetadata();
// 버전 체크 (캐시 형식 변경 대응)
if (metadata?.version !== CACHE_VERSION) {
return false;
}
// 커밋 SHA 비교 (저장소에 변경이 있으면 무효)
return metadata?.commitSha === currentCommitSha;
}
/**
* 캐시 저장 (원자적 쓰기)
*/
async save(
commitSha: string,
reactComponents: DesignSystemComponent[],
vueComponents: DesignSystemComponent[]
): Promise<void> {
await this.initialize(); // 디렉토리 생성
const metadata: CacheMetadata = {
version: CACHE_VERSION,
lastSyncedAt: new Date().toISOString(),
commitSha,
reactComponentCount: reactComponents.length,
vueComponentCount: vueComponents.length,
};
// 병렬 저장
await Promise.all([
writeFile(this.metadataFile, JSON.stringify(metadata, null, 2)),
writeFile(this.reactCacheFile, JSON.stringify(reactComponents, null, 2)),
writeFile(this.vueCacheFile, JSON.stringify(vueComponents, null, 2)),
]);
}
}
```
### 동기화 서비스에서의 활용
```typescript
// src/sync/component-sync.ts
async sync(options: SyncOptions = {}): Promise<SyncResult> {
// 1. GitHub에서 최신 커밋 SHA만 가져옴 (가벼운 API 호출)
const commitInfo = await this.githubClient.getLatestCommitSha();
// 2. 캐시 유효성 확인
if (!options.force) {
const isValid = await this.cacheManager.isValid(commitInfo.sha);
if (isValid) {
// 캐시가 유효하면 파일에서 로드 (네트워크 호출 없음!)
const cached = await this.cacheManager.load();
if (cached) {
return {
success: true,
fromCache: true, // 캐시에서 로드됨을 표시
reactComponents: cached.reactComponents,
vueComponents: cached.vueComponents,
commitSha: commitInfo.sha,
};
}
}
}
// 3. 캐시가 무효하면 GitHub에서 전체 동기화
const [reactComponents, vueComponents] = await Promise.all([
this.syncReactComponents(commitInfo.sha),
this.syncVueComponents(commitInfo.sha),
]);
// 4. 새 데이터 캐시 저장
await this.cacheManager.save(commitInfo.sha, reactComponents, vueComponents);
return { success: true, fromCache: false, ... };
}
```
### 핵심 교훈
| 원칙 | 적용 |
|------|------|
| 스마트 무효화 | 커밋 SHA 비교로 실제 변경 시에만 재동기화 |
| 버전 관리 | 캐시 형식 변경 시 자동 무효화 |
| 폴백 지원 | GitHub 접근 불가 시 캐시에서 로드 |
| 병렬 처리 | 파일 쓰기/읽기 병렬화로 I/O 최적화 |
| 타입 안전성 | Zod 스키마로 캐시 데이터 검증 |
---
## 요약: MCP 개발 핵심 원칙
이 프로젝트를 통해 도출된 MCP 개발의 핵심 원칙들:
### 1. 데이터 접근 전략
| 원칙 | 설명 |
|------|------|
| **MCP-First + Fallback** | 로컬 MCP 우선, 실패 시 API 폴백 |
| **캐시 기반 최적화** | 변경 감지 기반 스마트 캐시로 API 호출 최소화 |
| **Lazy Loading** | 무거운 의존성은 필요 시점에 로드 |
### 2. 외부 시스템 통합
| 원칙 | 설명 |
|------|------|
| **AST 분석** | 수동 메타데이터 대신 소스 코드 자동 분석 |
| **다중 패턴 지원** | 다양한 코딩 컨벤션 모두 지원 |
| **재귀적 타입 해결** | 복잡한 타입 참조도 완전히 해결 |
### 3. 환경 대응
| 원칙 | 설명 |
|------|------|
| **공통 로직 분리** | 환경별 진입점과 핵심 로직 분리 |
| **설정 주입** | 환경별 제약을 설정으로 제어 |
| **우아한 성능 저하** | 기능 일부 비활성화로 모든 환경 지원 |
### 4. 매핑 및 변환
| 원칙 | 설명 |
|------|------|
| **다층 매칭** | 여러 전략을 순차적으로 시도 |
| **정규화** | 입력 데이터 정규화로 매칭 성공률 향상 |
| **컨텍스트 분석** | 부모/자식 관계 분석으로 정확도 향상 |
---
## Phase 1: 접근 제어 - GitHub 조직 멤버십 인증
### 배경 및 목적
Palette MCP는 npm과 Smithery.ai를 통해 누구나 설치할 수 있지만, 실제 서비스 이용은 **dealicious-inc 조직 멤버로 제한**해야 합니다.
| 요구사항 | 설명 |
|----------|------|
| 설치 공개 | npm/Smithery에서 누구나 설치 가능 |
| 사용 제한 | dealicious-inc 조직 멤버만 도구 실행 가능 |
| 추가 인프라 불필요 | 이미 사용 중인 GITHUB_TOKEN 활용 |
### 구현 방식
#### 인증 플로우
```
사용자 요청 → GITHUB_TOKEN 확인 → GitHub API 호출 → 조직 멤버십 확인 → 허용/거부
```
#### 핵심 파일
| 파일 | 역할 |
|------|------|
| `src/services/auth.ts` | 인증 서비스 - 조직 멤버십 확인 로직 |
| `src/server.ts` | 도구 실행 전 인증 검증 호출 |
### 인증 서비스 구현 (`src/services/auth.ts`)
```typescript
// 허용된 GitHub 조직 목록
const ALLOWED_ORGANIZATIONS = ['dealicious-inc'];
// 인증 상태 캐시 (1시간 유효)
const authCache = new Map<string, { valid: boolean; username: string; checkedAt: Date }>();
export async function validateAccess(githubToken?: string): Promise<AuthResult> {
// 1. GITHUB_TOKEN 확인
if (!githubToken) {
return { authorized: false, error: 'GITHUB_TOKEN이 필요합니다.' };
}
// 2. 캐시 확인 (불필요한 API 호출 방지)
const cached = getCachedAuth(githubToken);
if (cached) return cached;
// 3. GitHub API로 조직 멤버십 확인
const octokit = new Octokit({ auth: githubToken });
const isMember = await checkOrganizationMembership(octokit, 'dealicious-inc');
// 4. 결과 캐시 및 반환
setCachedAuth(githubToken, isMember, user.login);
return { authorized: isMember, username: user.login };
}
```
### 서버 통합 (`src/server.ts`)
```typescript
// 도구 실행 전 인증 확인
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// 🔐 인증 확인
await ensureAuthenticated();
// 도구 실행
switch (name) {
case 'convert_figma_to_react': ...
}
} catch (error) {
return { content: [{ type: 'text', text: error.message }], isError: true };
}
});
```
### 인증 결과 시나리오
| 상황 | 결과 | 메시지 |
|------|------|--------|
| dealicious-inc 멤버 | ✅ 허용 | 도구 정상 실행 |
| 조직 멤버 아님 | ❌ 거부 | "사용자 'xxx'은(는) dealicious-inc 조직 멤버가 아닙니다" |
| GITHUB_TOKEN 없음 | ❌ 거부 | "GITHUB_TOKEN이 필요합니다" |
| 토큰 만료/무효 | ❌ 거부 | "GITHUB_TOKEN이 유효하지 않습니다" |
### 로컬 개발 옵션
개발 및 테스트 시 인증을 건너뛸 수 있습니다:
```typescript
// 로컬 개발용 - 인증 건너뛰기
createPaletteServer({
skipAuth: true,
});
```
### 성능 최적화
| 최적화 | 설명 |
|--------|------|
| 캐시 (1시간) | 동일 토큰에 대해 반복 API 호출 방지 |
| 최초 1회 검증 | 세션 내 첫 도구 호출 시만 검증 |
| 비동기 처리 | 서버 초기화 차단 없음 |
### 향후 확장 계획
| Phase | 기능 | 설명 |
|-------|------|------|
| Phase 1 (완료) | GitHub 조직 멤버십 | dealicious-inc 멤버만 허용 |
| Phase 2 (예정) | API Key 시스템 | 외부 협력사 지원, 세밀한 권한 제어 |
| Phase 3 (예정) | 사용량 추적 | 사용자별 호출 횟수, 로그 기록 |