UNPKG

cafe24-admin-mcp

Version:

Cafe24 Admin API MCP Server with Memory Optimization

1,719 lines (1,534 loc) 134 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import fetch, { Response } from "node-fetch"; import fs from "node:fs/promises"; import path from "node:path"; import { createGzip } from "node:zlib"; import { pipeline } from "node:stream/promises"; import tmp from "tmp-promise"; import { createHash } from "node:crypto"; import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { createRequire } from "node:module"; // Optional Redis import using createRequire for ES modules const require = createRequire(import.meta.url); let Redis: any = null; try { Redis = require("ioredis"); console.error('[REDIS] ioredis loaded successfully'); } catch (error: any) { console.error('[REDIS] ioredis import failed:', error.message); console.error('[REDIS] ioredis not installed, caching disabled'); } // ------------------------------------------------------------------ // MCP 프로토콜 보호: stdout 오염 방지 // ------------------------------------------------------------------ // stdout으로 출력되는 모든 내용을 차단하여 JSON-RPC 프로토콜 위반 방지 const originalStdoutWrite = process.stdout.write; process.stdout.write = function(chunk: any) { // MCP 서버에서는 stdout을 오직 JSON-RPC 메시지만 사용해야 함 // 모든 debug/info 출력을 stderr로 리다이렉트 if (typeof chunk === 'string' && chunk.trim()) { process.stderr.write(`[DEBUG] Blocked stdout: ${chunk}`); } return true; } as any; // console.log도 차단 (stderr로 리다이렉트) const originalLog = console.log; console.log = (...args) => { console.error('[LOG]', ...args); }; // ------------------------------------------------------------------ // 환경 변수 // ------------------------------------------------------------------ const { MALL_ID, CLIENT_ID, CLIENT_SECRET, // Resource 업로드를 위한 설정 AWS_REGION = "ap-northeast-2", AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET_NAME = "cafe24-mcp-resources", RESOURCE_URL_EXPIRES = "3600", // 1시간 // Redis 캐싱 설정 REDIS_URL = "redis://localhost:6379", REDIS_TTL = "3600", // 1시간 캐시 REDIS_PREFIX = "cafe24_cache:", ENABLE_REDIS_CACHE = "true" } = process.env as Record<string, string>; if (!MALL_ID || !CLIENT_ID || !CLIENT_SECRET) { // MCP 프로토콜에서는 stdout은 JSON만 출력해야 하므로 stderr 사용 console.error("❌ Error: MALL_ID, CLIENT_ID, CLIENT_SECRET 환경 변수가 필요합니다."); console.error(" .env 파일을 확인하거나 환경 변수를 설정해주세요."); // 안전한 종료를 위해 약간의 지연 후 종료 setTimeout(() => process.exit(1), 100); } const API_VERSION = "2025-06-01"; const TOKEN_URL = `https://${MALL_ID}.cafe24api.com/api/v2/oauth/token`; const TOKENS_FILE = path.join( process.env.HOME || ".", ".cafe24_tokens.json", ); // ------------------------------------------------------------------ // 메모리 효율성을 위한 설정 // ------------------------------------------------------------------ const MAX_RESOURCE_SIZE = 10 * 1024 * 1024; // 10MB 초과시 resources const DEFAULT_LIMIT = 5; // 기본 페이지 크기 축소 // S3 클라이언트 초기화 (로깅 억제) let s3Client: S3Client | null = null; if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) { // AWS SDK 로깅을 억제하기 위한 설정 process.env.AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE = "1"; s3Client = new S3Client({ region: AWS_REGION, credentials: { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY, }, // AWS SDK 로깅 억제 logger: { debug: () => {}, info: () => {}, warn: (msg: any) => console.error('[AWS WARN]', msg), error: (msg: any) => console.error('[AWS ERROR]', msg), } }); } // ------------------------------------------------------------------ // Redis 관련 타입 정의 interface RedisInstance { get(key: string): Promise<string | null>; setex(key: string, ttl: number, value: string): Promise<string>; del(...keys: string[]): Promise<number>; keys(pattern: string): Promise<string[]>; dbsize(): Promise<number>; info(section?: string): Promise<string>; on(event: string, listener: (arg?: any) => void): void; // 추가 Redis 메서드들 hgetall(key: string): Promise<Record<string, string>>; hset(key: string, hash: Record<string, any>): Promise<number>; expire(key: string, ttl: number): Promise<number>; mget(keys: string[]): Promise<Array<string | null>>; } // ------------------------------------------------------------------ // Redis 캐싱 시스템 // ------------------------------------------------------------------ let redisClient: RedisInstance | null = null; const REDIS_ENABLED = ENABLE_REDIS_CACHE === "true" && Redis !== null; if (REDIS_ENABLED && Redis) { try { redisClient = new Redis(REDIS_URL, { retryDelayOnFailover: 100, enableReadyCheck: false, lazyConnect: true, // Redis 로깅 억제 showFriendlyErrorStack: false, }); if (redisClient) { redisClient.on('error', (err: any) => { console.error('[REDIS ERROR]', err.message); }); redisClient.on('connect', () => { console.error('[REDIS] Connected successfully'); }); } } catch (error: any) { console.error('[REDIS] Failed to initialize:', error.message); redisClient = null; } } /** * 범용 테이블 캐시 관리자 (Schema-Free) */ class GenericTableCache { private redis: RedisInstance | null; private prefix: string; private ttl: number; constructor(redis: RedisInstance | null, prefix: string = REDIS_PREFIX, ttl: number = parseInt(REDIS_TTL)) { this.redis = redis; this.prefix = prefix; this.ttl = ttl; } /** * 캐시 키 생성 (범용) */ private getKey(table: string, type: 'meta' | 'chunk' | 'row' | 'agg', identifier?: string): string { const base = `${this.prefix}${table}`; switch (type) { case 'meta': return `${base}:meta`; case 'chunk': return `${base}:chunk:${identifier}`; case 'row': return `${base}:row:${identifier}`; case 'agg': return `${this.prefix}agg:${table}:${identifier}`; default: return base; } } /** * 테이블 메타데이터 조회 */ async getTableMeta(table: string): Promise<{ rowCount: number; colList: string[]; lastSynced: number; chunkSize: number; totalChunks: number; } | null> { if (!this.redis) return null; try { const meta = await this.redis.hgetall(this.getKey(table, 'meta')); if (!meta || !meta.rowCount) return null; return { rowCount: parseInt(meta.rowCount || '0'), colList: JSON.parse(meta.colList || '[]'), lastSynced: parseInt(meta.lastSynced || '0'), chunkSize: parseInt(meta.chunkSize || '200'), totalChunks: parseInt(meta.totalChunks || '0') }; } catch (error: any) { console.error(`[CACHE META ERROR] ${table}: ${error.message}`); return null; } } /** * 범용 테이블 데이터 저장 (Schema-Free) */ async putTable<T extends Record<string, any>>( table: string, rows: T[], options: { primaryKey?: (row: T) => string | number; chunkSize?: number; compress?: boolean; overwrite?: boolean; } = {} ): Promise<{ success: boolean; rowCount: number; chunkCount: number; columns: string[]; cacheSize: string; message: string; }> { if (!this.redis || rows.length === 0) { return { success: false, rowCount: 0, chunkCount: 0, columns: [], cacheSize: '0KB', message: 'Redis not available or empty data' }; } const { primaryKey, chunkSize = 200, compress = true, overwrite = true } = options; try { // 1️⃣ 컬럼 분석 (동적) const columns = Array.from(new Set( rows.flatMap(row => Object.keys(row)) )).sort(); // 2️⃣ 기존 데이터 삭제 (overwrite 모드) if (overwrite) { await this.clearTable(table); } // 3️⃣ 청크 단위로 저장 let totalSize = 0; let chunkCount = 0; for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); const chunkId = String(++chunkCount).padStart(5, '0'); let serialized = JSON.stringify(chunk); if (compress) { // 간단한 압축 시뮬레이션 (실제로는 gzip 등 사용) totalSize += Buffer.byteLength(serialized, 'utf8'); } await this.redis.setex( this.getKey(table, 'chunk', chunkId), this.ttl, serialized ); } // 4️⃣ 메타데이터 업데이트 const metaKey = this.getKey(table, 'meta'); await this.redis.hset(metaKey, { rowCount: rows.length, colList: JSON.stringify(columns), lastSynced: Date.now(), chunkSize: chunkSize, totalChunks: chunkCount, tableSchema: JSON.stringify(this.inferSchema(rows.slice(0, 10))) // 샘플 기반 스키마 추론 }); await this.redis.expire(metaKey, this.ttl); // 5️⃣ 개별 Row 캐시 (PK 있는 경우) if (primaryKey) { for (const row of rows.slice(0, 1000)) { // 최대 1000개만 개별 캐시 const pk = primaryKey(row); await this.redis.setex( this.getKey(table, 'row', String(pk)), this.ttl, JSON.stringify(row) ); } } console.error(`[CACHE PUT] ${table}: ${rows.length} rows, ${chunkCount} chunks, ${(totalSize/1024).toFixed(1)}KB`); return { success: true, rowCount: rows.length, chunkCount, columns, cacheSize: `${(totalSize/1024).toFixed(1)}KB`, message: `✅ ${table} 테이블 ${rows.length}개 행이 ${chunkCount}개 청크로 캐시되었습니다.` }; } catch (error: any) { console.error(`[CACHE PUT ERROR] ${table}: ${error.message}`); return { success: false, rowCount: 0, chunkCount: 0, columns: [], cacheSize: '0KB', message: `캐시 저장 실패: ${error.message}` }; } } /** * 범용 테이블 데이터 조회 */ async getTable( table: string, options: { limit?: number; offset?: number; columns?: string[]; filter?: Record<string, any>; } = {} ): Promise<{ data: any[]; totalRows: number; returnedRows: number; fromCache: boolean; executionTime: number; }> { if (!this.redis) { return { data: [], totalRows: 0, returnedRows: 0, fromCache: false, executionTime: 0 }; } const startTime = Date.now(); const { limit, offset = 0, columns, filter } = options; try { // 메타데이터 확인 const meta = await this.getTableMeta(table); if (!meta) { return { data: [], totalRows: 0, returnedRows: 0, fromCache: false, executionTime: Date.now() - startTime }; } // 모든 청크 조회 const chunkKeys = []; for (let i = 1; i <= meta.totalChunks; i++) { chunkKeys.push(this.getKey(table, 'chunk', String(i).padStart(5, '0'))); } const chunks = await this.redis.mget(chunkKeys); let allRows: any[] = []; // 청크 데이터 병합 for (const chunkData of chunks) { if (chunkData) { const chunkRows = JSON.parse(chunkData); allRows.push(...chunkRows); } } // 필터 적용 if (filter && Object.keys(filter).length > 0) { allRows = allRows.filter(row => { return Object.entries(filter).every(([key, value]) => { if (Array.isArray(value)) { return value.includes(row[key]); } return row[key] === value; }); }); } // 컬럼 선택 if (columns && columns.length > 0) { allRows = allRows.map(row => { const filtered: any = {}; columns.forEach(col => { if (col in row) filtered[col] = row[col]; }); return filtered; }); } // 페이지네이션 const totalRows = allRows.length; if (limit || offset > 0) { const start = offset; const end = limit ? start + limit : undefined; allRows = allRows.slice(start, end); } const executionTime = Date.now() - startTime; console.error(`[CACHE GET] ${table}: ${totalRows} total, ${allRows.length} returned, ${executionTime}ms`); return { data: allRows, totalRows, returnedRows: allRows.length, fromCache: true, executionTime }; } catch (error: any) { console.error(`[CACHE GET ERROR] ${table}: ${error.message}`); return { data: [], totalRows: 0, returnedRows: 0, fromCache: false, executionTime: Date.now() - startTime }; } } /** * 범용 집계 연산 (with 캐시) */ async aggregateTable( table: string, params: { groupBy?: string[]; metrics: Array<'count' | 'sum' | 'avg' | 'min' | 'max'>; sumField?: string; avgField?: string; filter?: Record<string, any>; orderBy?: string; limit?: number; } ): Promise<{ result: any[]; totalGroups: number; executionTime: number; cached: boolean; cacheKey: string; }> { const startTime = Date.now(); // 캐시 키 생성 const cacheKey = createHash('md5') .update(JSON.stringify({ table, ...params })) .digest('hex'); const aggKey = this.getKey(table, 'agg', cacheKey); // 캐시된 결과 확인 if (this.redis) { try { const cached = await this.redis.get(aggKey); if (cached) { const result = JSON.parse(cached); console.error(`[AGG CACHE HIT] ${table}:${cacheKey.substring(0, 8)}`); return { ...result, executionTime: Date.now() - startTime, cached: true, cacheKey }; } } catch (error: any) { console.error(`[AGG CACHE ERROR] ${error.message}`); } } // 실제 집계 수행 const tableData = await this.getTable(table, { filter: params.filter }); if (tableData.totalRows === 0) { return { result: [], totalGroups: 0, executionTime: Date.now() - startTime, cached: false, cacheKey }; } // 그룹화 및 집계 const groups: Record<string, any> = {}; const keyFunc = (row: any) => params.groupBy?.map(col => row[col] || 'null').join('|') || '_total'; for (const row of tableData.data) { const groupKey = keyFunc(row); if (!groups[groupKey]) { groups[groupKey] = { group: params.groupBy ? Object.fromEntries(params.groupBy.map((col, i) => [col, groupKey.split('|')[i]])) : { _total: true }, count: 0, sum: 0, values: [] }; } groups[groupKey].count++; if (params.sumField && typeof row[params.sumField] === 'number') { groups[groupKey].sum += row[params.sumField]; groups[groupKey].values.push(row[params.sumField]); } if (params.avgField && typeof row[params.avgField] === 'number') { groups[groupKey].values.push(row[params.avgField]); } } // 최종 계산 let result = Object.values(groups).map((group: any) => { const final: any = { ...group.group }; if (params.metrics.includes('count')) final.count = group.count; if (params.metrics.includes('sum')) final.sum = group.sum; if (params.metrics.includes('avg') && group.values.length > 0) { final.avg = group.values.reduce((a: number, b: number) => a + b, 0) / group.values.length; } if (params.metrics.includes('min') && group.values.length > 0) { final.min = Math.min(...group.values); } if (params.metrics.includes('max') && group.values.length > 0) { final.max = Math.max(...group.values); } return final; }); // 정렬 및 제한 if (params.orderBy && result.length > 0) { result.sort((a, b) => (b[params.orderBy!] || 0) - (a[params.orderBy!] || 0)); } if (params.limit) { result = result.slice(0, params.limit); } const finalResult = { result, totalGroups: Object.keys(groups).length, executionTime: Date.now() - startTime, cached: false, cacheKey }; // 결과 캐시 저장 if (this.redis && finalResult.result.length > 0) { try { await this.redis.setex( aggKey, Math.min(this.ttl, 1800), // 집계 결과는 최대 30분 캐시 JSON.stringify(finalResult) ); console.error(`[AGG CACHE SET] ${table}:${cacheKey.substring(0, 8)}`); } catch (error: any) { console.error(`[AGG CACHE SET ERROR] ${error.message}`); } } return finalResult; } /** * 스키마 추론 (동적) */ private inferSchema(sampleRows: any[]): Record<string, string> { const schema: Record<string, string> = {}; for (const row of sampleRows) { for (const [key, value] of Object.entries(row)) { if (!(key in schema)) { if (typeof value === 'number') { schema[key] = Number.isInteger(value) ? 'integer' : 'number'; } else if (typeof value === 'boolean') { schema[key] = 'boolean'; } else if (value instanceof Date) { schema[key] = 'date'; } else if (typeof value === 'string') { // 날짜 패턴 체크 if (/^\d{4}-\d{2}-\d{2}/.test(value)) { schema[key] = 'date'; } else if (/^\d+$/.test(value)) { schema[key] = 'string_number'; } else { schema[key] = 'string'; } } else { schema[key] = 'mixed'; } } } } return schema; } /** * 테이블 캐시 삭제 */ async clearTable(table: string): Promise<number> { if (!this.redis) return 0; try { const patterns = [ `${this.prefix}${table}:*`, `${this.prefix}agg:${table}:*` ]; let deletedCount = 0; for (const pattern of patterns) { const keys = await this.redis.keys(pattern); if (keys.length > 0) { deletedCount += await this.redis.del(...keys); } } console.error(`[CACHE CLEAR] ${table}: ${deletedCount} keys deleted`); return deletedCount; } catch (error: any) { console.error(`[CACHE CLEAR ERROR] ${table}: ${error.message}`); return 0; } } /** * 모든 테이블 목록 조회 */ async listTables(): Promise<Array<{ name: string; rowCount: number; lastSynced: string; cacheSize: string; }>> { if (!this.redis) return []; try { const metaKeys = await this.redis.keys(`${this.prefix}*:meta`); const tables = []; for (const metaKey of metaKeys) { const tableName = metaKey.replace(`${this.prefix}`, '').replace(':meta', ''); const meta = await this.getTableMeta(tableName); if (meta) { tables.push({ name: tableName, rowCount: meta.rowCount, lastSynced: new Date(meta.lastSynced).toISOString(), cacheSize: `${(meta.rowCount * meta.chunkSize / 1024).toFixed(1)}KB` }); } } return tables.sort((a, b) => a.name.localeCompare(b.name)); } catch (error: any) { console.error(`[LIST TABLES ERROR] ${error.message}`); return []; } } /** * 캐시 통계 */ async getStats(): Promise<{ totalTables: number; totalRows: number; totalKeys: number; memoryUsage: string; cacheHits: number; cacheMisses: number; }> { if (!this.redis) { return { totalTables: 0, totalRows: 0, totalKeys: 0, memoryUsage: '0MB', cacheHits: 0, cacheMisses: 0 }; } try { const tables = await this.listTables(); const totalRows = tables.reduce((sum, table) => sum + table.rowCount, 0); const totalKeys = await this.redis.dbsize(); const memoryInfo = await this.redis.info('memory'); const memoryUsed = memoryInfo.match(/used_memory_human:([^\r\n]+)/)?.[1] || '0B'; const statsInfo = await this.redis.info('stats'); const keyspaceHits = parseInt(statsInfo.match(/keyspace_hits:(\d+)/)?.[1] || '0'); const keyspaceMisses = parseInt(statsInfo.match(/keyspace_misses:(\d+)/)?.[1] || '0'); return { totalTables: tables.length, totalRows, totalKeys, memoryUsage: memoryUsed, cacheHits: keyspaceHits, cacheMisses: keyspaceMisses }; } catch (error: any) { console.error(`[CACHE STATS ERROR] ${error.message}`); return { totalTables: 0, totalRows: 0, totalKeys: 0, memoryUsage: '0MB', cacheHits: 0, cacheMisses: 0 }; } } } // 범용 캐시 매니저 인스턴스 const genericCache = new GenericTableCache(redisClient); // 기존 cacheManager와의 호환성을 위한 래퍼 const cacheManager: { get<T = any>(endpoint: string, params: Record<string, any>): Promise<T | null>; set(endpoint: string, params: Record<string, any>, data: any): Promise<void>; invalidate(pattern: string): Promise<void>; getChunked(endpoint: string, params: Record<string, any>, chunkSize: number): Promise<{ data: any[]; totalFromCache: number; needsRefresh: boolean; }>; getAggregated<T = any>(endpoint: string, params: Record<string, any>, aggregationKey: string): Promise<T | null>; setAggregated(endpoint: string, params: Record<string, any>, aggregationKey: string, data: any, ttl?: number): Promise<void>; [key: string]: any; // 인덱스 시그니처 추가 } = { async get<T = any>(endpoint: string, params: Record<string, any>): Promise<T | null> { // 기존 API 호환성 유지 const table = endpoint.split('/').pop() || 'unknown'; const result = await genericCache.getTable(table, { filter: params, limit: 1 }); return result.data.length > 0 ? result.data[0] : null; }, async set(endpoint: string, params: Record<string, any>, data: any): Promise<void> { // 기존 API 호환성 유지 - 실제로는 putTable 사용 권장 console.error('[DEPRECATED] Use genericCache.putTable() instead of cacheManager.set()'); }, async invalidate(pattern: string): Promise<void> { if (!redisClient) return; try { const keys = await redisClient.keys(`${REDIS_PREFIX}${pattern}`); if (keys.length > 0) { await redisClient.del(...keys); console.error(`[CACHE INVALIDATE] ${keys.length} keys deleted`); } } catch (error: any) { console.error(`[CACHE INVALIDATE ERROR] ${error.message}`); } }, async getChunked(endpoint: string, params: Record<string, any>, chunkSize: number) { const table = endpoint.split('/').pop() || 'unknown'; const result = await genericCache.getTable(table, params); return { data: result.data, totalFromCache: result.totalRows, needsRefresh: !result.fromCache || result.totalRows === 0 }; }, async getAggregated<T = any>(endpoint: string, params: Record<string, any>, aggregationKey: string): Promise<T | null> { // 레거시 호환성 - 실제로는 genericCache.aggregateTable 사용 권장 const table = endpoint.split('/').pop() || 'unknown'; const aggResult = await genericCache.aggregateTable(table, { metrics: ['count'], filter: params, limit: 100 }); return aggResult.result.length > 0 ? aggResult.result[0] as T : null; }, async setAggregated(endpoint: string, params: Record<string, any>, aggregationKey: string, data: any, ttl?: number): Promise<void> { // 레거시 호환성 - 실제 집계 캐시는 genericCache에서 자동 처리 console.error('[DEPRECATED] Aggregation caching is now handled automatically by genericCache.aggregateTable()'); } }; /** * 캐시가 적용된 Cafe24 API 호출 */ async function cachedCafe24Fetch( path: string, params: Record<string, any> = {}, options: { useCache?: boolean; cacheTTL?: number; forceRefresh?: boolean; } = {} ): Promise<Response> { const { useCache = REDIS_ENABLED, cacheTTL, forceRefresh = false } = options; // 캐시 확인 (forceRefresh가 false인 경우만) if (useCache && !forceRefresh) { const cached = await cacheManager.get(path, params); if (cached) { // 캐시된 응답을 Response 객체로 변환 return new Response(JSON.stringify(cached), { status: 200, headers: { 'Content-Type': 'application/json' } }) as any; } } // 실제 API 호출 const response = await cafe24Fetch(path, params); // 성공적인 응답만 캐시 if (useCache && response.ok) { try { const responseData = await response.json(); // 캐시에 저장 if (cacheTTL) { // 임시로 TTL을 변경하여 저장 const originalTTL = cacheManager['ttl']; cacheManager['ttl'] = cacheTTL; await cacheManager.set(path, params, responseData); cacheManager['ttl'] = originalTTL; } else { await cacheManager.set(path, params, responseData); } // 새로운 Response 객체 반환 (JSON은 이미 읽혔으므로) return new Response(JSON.stringify(responseData), { status: response.status, headers: { 'Content-Type': 'application/json' } }) as any; } catch (error: any) { console.error(`[CACHE SAVE ERROR] ${error.message}`); return response; } } return response; } // ------------------------------------------------------------------ // 고급 리소스 처리 유틸 // ------------------------------------------------------------------ /** * MIME 타입 감지 */ function getMimeType(filename: string): string { const ext = path.extname(filename).toLowerCase(); const mimeTypes: Record<string, string> = { '.csv': 'text/csv', '.json': 'application/json', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.zip': 'application/zip', '.gz': 'application/gzip', '.txt': 'text/plain' }; return mimeTypes[ext] || 'application/octet-stream'; } /** * 파일 무결성 해시 생성 */ async function calculateFileHash(filePath: string): Promise<string> { const hash = createHash('sha256'); const stream = await fs.open(filePath, 'r'); for await (const chunk of stream.createReadStream()) { hash.update(chunk); } await stream.close(); return hash.digest('hex'); } /** * S3에 파일 업로드 및 presigned URL 생성 */ async function uploadToS3AndGetPresignedUrl( filePath: string, key: string, contentType: string ): Promise<string> { if (!s3Client) { throw new Error("S3 클라이언트가 초기화되지 않았습니다. AWS 자격 증명을 확인해주세요."); } const fileContent = await fs.readFile(filePath); // S3에 업로드 await s3Client.send(new PutObjectCommand({ Bucket: S3_BUCKET_NAME, Key: key, Body: fileContent, ContentType: contentType, Metadata: { 'uploaded-at': new Date().toISOString(), 'file-size': fileContent.length.toString() } })); // Presigned URL 생성 const command = new GetObjectCommand({ Bucket: S3_BUCKET_NAME, Key: key, }); const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: parseInt(RESOURCE_URL_EXPIRES) }); return presignedUrl; } /** * 로컬 파일로 fallback (S3 사용 불가능한 경우) */ async function createLocalFileResource(filePath: string, filename: string): Promise<string> { const resourceDir = path.join(process.cwd(), 'resources'); await fs.mkdir(resourceDir, { recursive: true }); const targetPath = path.join(resourceDir, filename); await fs.copyFile(filePath, targetPath); // 개발 환경에서만 file:// URL 사용 return `file://${targetPath}`; } /** * CSV 데이터를 gzip 압축된 파일로 저장 (stdout 출력 방지) */ async function createCompressedCsvResource( data: any[], baseFilename: string, options: { compress?: boolean; headers?: string[]; customKey?: string; } = {} ): Promise<{ filename: string; filePath: string; compressed: boolean; size: number; hash: string; resourceUrl: string; mimeType: string; }> { if (data.length === 0) { throw new Error("빈 데이터 배열입니다."); } const { compress = true, headers, customKey } = options; const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); const extension = compress ? '.csv.gz' : '.csv'; const filename = `${baseFilename}_${timestamp}${extension}`; // 임시 파일 생성 (tmp-promise 출력 억제) const tmpFile = await tmp.file({ postfix: extension, // tmp-promise가 stdout으로 출력하지 않도록 설정 keep: false }); try { if (compress) { // gzip 압축된 CSV 생성 - 수정된 방식 const gzipStream = createGzip({ level: 6 }); const writeStream = await fs.open(tmpFile.path, 'w'); const fileWriteStream = writeStream.createWriteStream(); // 직접 CSV 문자열 생성 (fast-csv 스트림 대신) const csvHeaders = headers || (data.length > 0 ? Object.keys(data[0]) : []); let csvContent = csvHeaders.join(',') + '\n'; for (const row of data) { const csvRow = csvHeaders.map(header => { const value = row[header] || ''; // CSV 이스케이핑: 쉼표나 따옴표가 있으면 따옴표로 감싸기 if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { return '"' + value.replace(/"/g, '""') + '"'; } return String(value); }).join(','); csvContent += csvRow + '\n'; } // gzip으로 압축하여 파일에 쓰기 await pipeline( async function* () { yield Buffer.from(csvContent, 'utf8'); }, gzipStream, fileWriteStream ); await writeStream.close(); } else { // 일반 CSV 생성 const csvHeaders = headers || Object.keys(data[0]); const csvContent = [ csvHeaders.join(','), ...data.map(row => csvHeaders.map(key => { const value = row[key] || ''; // CSV 이스케이핑 if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { return '"' + value.replace(/"/g, '""') + '"'; } return String(value); }).join(',') ) ].join('\n'); await fs.writeFile(tmpFile.path, csvContent, 'utf8'); } // 파일 정보 수집 const stats = await fs.stat(tmpFile.path); const hash = await calculateFileHash(tmpFile.path); const mimeType = compress ? 'application/gzip' : 'text/csv'; // S3 업로드 또는 로컬 파일 생성 let resourceUrl: string; const s3Key = customKey || `exports/${filename}`; try { if (s3Client) { resourceUrl = await uploadToS3AndGetPresignedUrl(tmpFile.path, s3Key, mimeType); console.error(`📁 S3 업로드 완료: ${s3Key}`); } else { resourceUrl = await createLocalFileResource(tmpFile.path, filename); console.error(`📁 로컬 파일 생성: ${filename}`); } } catch (uploadError: any) { console.error(`⚠️ S3 업로드 실패, 로컬 파일로 fallback: ${uploadError.message}`); resourceUrl = await createLocalFileResource(tmpFile.path, filename); } return { filename, filePath: tmpFile.path, compressed: compress, size: stats.size, hash, resourceUrl, mimeType }; } catch (error: any) { console.error(`❌ CSV 리소스 생성 실패: ${error.message}`); // 임시 파일 정리 try { await fs.unlink(tmpFile.path); } catch {} throw error; } } /** * 스마트 리소스 응답 생성 (개선된 버전) */ async function createAdvancedSmartResponse( data: any, options: { requestId?: string; resourcePrefix?: string; arrayField?: string; // 'products', 'orders', 'salesvolume' 등 forceResource?: boolean; compress?: boolean; customFields?: string[]; } = {} ): Promise<any> { const { requestId, resourcePrefix = "data_export", arrayField, forceResource = false, compress = true, customFields } = options; const size = getResponseSize(data); const arrayData = arrayField ? data[arrayField] : (Array.isArray(data) ? data : (data.products || data.orders || data.salesvolume || [])); // 강제 리소스 생성이거나 크기가 큰 경우 if (forceResource || size > MAX_RESOURCE_SIZE) { if (arrayData.length === 0) { return { content: [{ type: "text" as const, text: JSON.stringify({ message: "데이터가 없어 리소스를 생성할 수 없습니다.", data_size: size, array_length: 0 }, null, 2) }] }; } try { const resource = await createCompressedCsvResource(arrayData, resourcePrefix, { compress, headers: customFields, customKey: `${resourcePrefix}/${Date.now()}` }); return { content: [{ type: "text" as const, text: JSON.stringify({ message: `✅ 대용량 데이터를 ${compress ? '압축 ' : ''}파일로 저장했습니다.`, total_records: arrayData.length, file_info: { filename: resource.filename, size_bytes: resource.size, size_mb: (resource.size / (1024 * 1024)).toFixed(2), compressed: resource.compressed, compression_ratio: compress ? `${((size - resource.size) / size * 100).toFixed(1)}%` : null, mime_type: resource.mimeType, sha256: resource.hash, expires_at: new Date(Date.now() + parseInt(RESOURCE_URL_EXPIRES) * 1000).toISOString() }, download_info: { click_download_button: "Claude UI에서 다운로드 버튼을 클릭하세요", file_format: compress ? "CSV (gzipped)" : "CSV", excel_compatible: !compress }, created_at: new Date().toISOString() }, null, 2) }], resources: [{ name: resource.filename, mimeType: resource.mimeType, uri: resource.resourceUrl, description: `${arrayData.length}개 레코드의 ${compress ? '압축된 ' : ''}CSV 파일 (${resource.hash.substring(0, 8)}...)` }] }; } catch (error: any) { return { isError: true, content: [{ type: "text" as const, text: `리소스 생성 실패: ${error.message}. 인라인 응답으로 fallback합니다.` }] }; } } // 기본: 인라인 반환 return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; } /** * 응답 크기를 확인하고 적절한 전송 방식 결정 */ function getResponseSize(data: any): number { return Buffer.byteLength(JSON.stringify(data), 'utf8'); } // ------------------------------------------------------------------ // 유틸 // ------------------------------------------------------------------ async function loadTokens() { try { const txt = await fs.readFile(TOKENS_FILE, "utf-8"); return JSON.parse(txt) as { access_token: string; refresh_token: string; expires_at: string; refresh_token_expires_at: string; }; } catch { return null; } } async function saveTokens(tok: object) { await fs.writeFile(TOKENS_FILE, JSON.stringify(tok, null, 2), "utf-8"); } function isExpired(expiresAt: string, bufferMin = 5) { return ( Date.now() > new Date(expiresAt).getTime() - bufferMin * 60 * 1000 ); } async function cafe24Fetch( path: string, params: Record<string, any> = {}, ): Promise<Response> { let tok = await loadTokens(); if (!tok) throw new Error("No OAuth tokens saved. Run exchange_code first."); // 만료 시 refresh if (isExpired(tok.expires_at)) { tok = await refreshToken(tok.refresh_token); } const url = new URL( `https://${MALL_ID}.cafe24api.com${path}`, ); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v))); return fetch(url, { headers: { Authorization: `Bearer ${tok.access_token}`, "X-Cafe24-Api-Version": API_VERSION, "Content-Type": "application/json", }, }); } async function postToken( payload: string, ): Promise<{ access_token: string; refresh_token: string; expires_at: string; refresh_token_expires_at: string; }> { const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); const r = await fetch(TOKEN_URL, { method: "POST", body: payload, headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded", }, }); if (!r.ok) throw new Error(`Token error ${r.status}`); const j = (await r.json()) as any; const tokenObj = { access_token: j.access_token, refresh_token: j.refresh_token, expires_at: j.expires_at, refresh_token_expires_at: j.refresh_token_expires_at, }; await saveTokens(tokenObj); return tokenObj; } async function exchangeCode(code: string, redirect_uri: string) { const payload = `grant_type=authorization_code&code=${code}` + `&redirect_uri=${encodeURIComponent(redirect_uri)}`; return postToken(payload); } async function refreshToken(refresh_token: string) { const payload = `grant_type=refresh_token&refresh_token=${refresh_token}`; return postToken(payload); } // ------------------------------------------------------------------ // MCP 서버 정의 (메모리 최적화 버전) // ------------------------------------------------------------------ const server = new McpServer({ name: "cafe24-admin", version: "0.2.0-memory-optimized", }); // ------------------------------------------------------------------ // 인증 관련 유틸리티 함수들 // ------------------------------------------------------------------ /** * 토큰 만료 여부 확인 */ function isTokenExpired(expiresAt: string, bufferMinutes = 5): boolean { const expiresTime = new Date(expiresAt).getTime(); const now = Date.now(); const bufferTime = bufferMinutes * 60 * 1000; return now >= (expiresTime - bufferTime); } /** * OAuth 인증 URL 생성 */ function generateAuthorizationUrl( mallId: string, clientId: string, redirectUri: string, state: string = "app_install" ): string { // Cafe24에서 요구하는 권한 목록 const scopes = [ "mall.read_application", "mall.write_application", "mall.read_category", "mall.read_product", "mall.read_collection", "mall.read_supply", "mall.read_order", "mall.read_community", "mall.write_community", "mall.read_customer", "mall.read_notification", "mall.read_store", "mall.read_salesreport", "mall.read_shipping", "mall.read_analytics" ]; const scopeStr = scopes.join(","); return `https://${mallId}.cafe24api.com/api/v2/oauth/authorize?` + `response_type=code&client_id=${clientId}&state=${state}&` + `redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopeStr}`; } /** * 기본 리다이렉트 URI 생성 */ function getDefaultRedirectUri(mallId: string): string { return `https://${mallId}.cafe24.com/order/basket.html`; } /** * 유효한 토큰 확보 (자동 갱신 포함) */ async function ensureValidToken(): Promise<{ access_token: string; refresh_token: string; expires_at: string; refresh_token_expires_at: string; } | null> { const tokens = await loadTokens(); if (!tokens) { return null; } // 액세스 토큰이 만료되지 않았으면 그대로 반환 if (!isTokenExpired(tokens.expires_at)) { return tokens; } // 리프레시 토큰도 만료되었으면 null 반환 if (isTokenExpired(tokens.refresh_token_expires_at, 60)) { // 만료된 토큰 파일 삭제 try { await fs.unlink(TOKENS_FILE); } catch {} return null; } // 리프레시 토큰으로 새 토큰 발급 try { const newTokens = await refreshToken(tokens.refresh_token); return newTokens; } catch (error: any) { console.error(`[TOKEN REFRESH ERROR] ${error.message}`); return null; } } // ------------------------------------------------------------------ // 개선된 인증 관련 도구들 // ------------------------------------------------------------------ // 1) OAuth 인증 URL 생성 server.tool( "cafe24_generate_auth_url", "Generate Cafe24 OAuth authorization URL for initial authentication", { mall_id: z.string().optional().describe("쇼핑몰 ID (기본값: 환경변수 MALL_ID)"), client_id: z.string().optional().describe("클라이언트 ID (기본값: 환경변수 CLIENT_ID)"), redirect_uri: z.string().url().optional().describe("리다이렉트 URI (기본값: 자동 생성)"), state: z.string().default("app_install").describe("상태값 (기본값: app_install)") }, async ({ mall_id, client_id, redirect_uri, state = "app_install" }) => { try { const finalMallId = mall_id || MALL_ID; const finalClientId = client_id || CLIENT_ID; if (!finalMallId || !finalClientId) { return { isError: true, content: [{ type: "text" as const, text: "❌ MALL_ID와 CLIENT_ID가 필요합니다. 환경변수를 확인해주세요." }] }; } const finalRedirectUri = redirect_uri || getDefaultRedirectUri(finalMallId); const authUrl = generateAuthorizationUrl(finalMallId, finalClientId, finalRedirectUri, state); return { content: [{ type: "text" as const, text: JSON.stringify({ auth_url: authUrl, mall_id: finalMallId, client_id: finalClientId, redirect_uri: finalRedirectUri, state, instructions: [ "1. 위 auth_url을 브라우저에서 열어 인증을 진행하세요", "2. 인증 완료 후 리다이렉트 URL에서 'code' 파라미터를 복사하세요", "3. cafe24_exchange_code 도구로 코드를 토큰으로 교환하세요", "⚠️ 인증 코드는 1분 후 만료됩니다!" ], expires_in: "60초" }, null, 2) }] }; } catch (e: any) { return { isError: true, content: [{ type: "text" as const, text: `Auth URL generation failed: ${e.message}` }] }; } } ); // 2) 스마트 토큰 확보 (자동 갱신 또는 새 인증 안내) server.tool( "cafe24_ensure_valid_token", "Ensure valid access token with automatic refresh or guide for new authentication", { force_refresh: z.boolean().default(false).describe("강제 토큰 갱신 여부") }, async ({ force_refresh = false }) => { try { const tokens = await loadTokens(); // 토큰이 없는 경우 if (!tokens) { const authUrl = generateAuthorizationUrl( MALL_ID, CLIENT_ID, getDefaultRedirectUri(MALL_ID), "app_install" ); return { content: [{ type: "text" as const, text: JSON.stringify({ status: "no_tokens", message: "저장된 토큰이 없습니다. 새로운 인증이 필요합니다.", auth_url: authUrl, next_steps: [ "1. 위 auth_url을 브라우저에서 열어 인증하세요", "2. 인증 후 코드를 복사하여 cafe24_exchange_code를 실행하세요" ] }, null, 2) }] }; } // 강제 갱신이거나 액세스 토큰이 만료된 경우 if (force_refresh || isTokenExpired(tokens.expires_at)) { // 리프레시 토큰도 만료된 경우 if (isTokenExpired(tokens.refresh_token_expires_at, 60)) { // 만료된 토큰 파일 삭제 try { await fs.unlink(TOKENS_FILE); } catch {} const authUrl = generateAuthorizationUrl( MALL_ID, CLIENT_ID, getDefaultRedirectUri(MALL_ID), "app_install" ); return { content: [{ type: "text" as const, text: JSON.stringify({ status: "refresh_token_expired", message: "리프레시 토큰도 만료되었습니다. 새로운 인증이 필요합니다.", auth_url: authUrl, next_steps: [ "1. 위 auth_url을 브라우저에서 열어 인증하세요", "2. 인증 후 코드를 복사하여 cafe24_exchange_code를 실행하세요" ] }, null, 2) }] }; } // 리프레시 토큰으로 갱신 try { const newTokens = await refreshToken(tokens.refresh_token); return { content: [{ type: "text" as const, text: JSON.stringify({ status: "token_refreshed", message: "✅ 토큰이 성공적으로 갱신되었습니다.", access_token: newTokens.access_token.substring(0, 20) + "...", expires_at: newTokens.expires_at, valid_until: new Date(newTokens.expires_at).toLocaleString('ko-KR') }, null, 2) }] }; } catch (error: any) { const authUrl = generateAuthorizationUrl( MALL_ID, CLIENT_ID, getDefaultRedirectUri(MALL_ID), "app_install" ); return { content: [{ type: "text" as const, text: JSON.stringify({ status: "refresh_failed", message: "토큰 갱신에 실패했습니다. 새로운 인증이 필요합니다.", error: error.message, auth_url: authUrl, next_steps: [ "1. 위 auth_url을 브라우저에서 열어 인증하세요", "2. 인증 후 코드를 복사하여 cafe24_exchange_code를 실행하세요" ] }, null, 2) }] }; } } // 토큰이 유효한 경우 return { content: [{ type: "text" as const, text: JSON.stringify({ status: "valid", message: "✅ 현재 토큰이 유효합니다.", access_token: tokens.access_token.substring(0, 20) + "...", expires_at: tokens.expires_at, valid_until: new Date(tokens.expires_at).toLocaleString('ko-KR'), time_remaining: Math.round((new Date(tokens.expires_at).getTime() - Date.now()) / (1000 * 60)) + "분" }, null, 2) }] }; } catch (e: any) { return { isError: true, content: [{ type: "text" as const, text: `Token validation failed: ${e.message}` }] }; } } ); // 기존 exchange_code 도구 개선 server.tool( "cafe24_exchange_code", "Exchange Cafe24 OAuth authorization code for access tokens", { code: z.string().min(1).describe("인증 코드 (브라우저에서 받은 code 파라미터)"), redirect_uri: z.string().url().optional().describe("리다이렉트 URI (생성 시 사용한 것과 동일해야 함)"), mall_id: z.string().optional().describe("쇼핑몰 ID (기본값: 환경변수 MALL_ID)"), client_id: z.string().optional().describe("클라이언트 ID (기본값: 환경변수 CLIENT_ID)"), client_secret: z.string().optional().describe("클라이언트 시크릿 (기본값: 환경변수 CLIENT_SECRET)") }, async ({ code, redirect_uri, mall_id, client_id, client_secret }) => { try { const finalMallId = mall_id || MALL_ID; const finalClientId = client_id || CLIENT_ID; const finalClientSecret = client_secret || CLIENT_SECRET; const finalRedirectUri = redirect_uri || getDefaultRedirectUri(finalMallId); if (!finalMallId || !finalClientId || !finalClientSecret) { return { isError: true, content: [{ type: "text" as const, text: "❌ MALL_ID, CLIENT_ID, CLIENT_SECRET 환경 변수가 필요합니다." }] }; } const tok = await exchangeCode(code, finalRedirectUri); return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: "✅ 토큰 교환이 성공적으로 완료되었습니다!", access_token: tok.access_token.substring(0, 20) + "...", expires_at: tok.expires_at, valid_until: new Date(tok.expires_at).toLocaleString('ko-KR'), next_steps: [ "이제 Cafe24 API를 사용할 준비가 완료되었습니다.", "products_list, orders_list 등의 도구를 사용해보세요." ] }, null, 2) }] }; } catch (e: any) { // 자주 발생하는 오류에 대한 도움말 제공 let errorMessage = `Token exchange failed: ${e.message}`; let suggestions: string[] = []; if (e.message.includes("invalid_grant") || e.message.includes("400")) { suggestions = [ "인증 코드가 만료되었거나 잘못되었을 수 있습니다.", "cafe24_generate_auth_url로 새 인증 URL을 생성하여 다시 시도해보세요.", "인증 코드는 1분 후 만료됩니다." ]; } else if (e.message.includes("redirect_uri")) { suggestions = [ "redirect_uri가 일치하지 않습니다.", "인증 URL 생성 시 사용한 redirect_uri와 동일한 값을 사용해야 합니다." ]; } return { isError: true, content: [{ type: "text" as const, text: JSON.stringify({ error: errorMessage, suggestions, help: "cafe24_generate_auth_url 도구로 새 인증을 시작하세요." }, null, 2) }], }; } }, ); // 기존 refresh_token 도구 개선 server.tool( "cafe24_refresh_token", "Manually refresh Cafe24 OAuth access token using refresh token", { refresh_token: z.string().optional().describe("리프레시 토큰 (생략 시 저장된 토큰 사용)") }, async ({ refresh_token }) => { try { let tokenToUse = refresh_token; // refresh_token이 제공되지 않으면 저장된 토큰에서 가져오기 if (!tokenToUse) { const savedTokens = await loadTokens(); if (!savedTokens) { return { isError: true, content: [{ type: "text" as const, text: "❌ 저장된 토큰이 없습니다. 새로운 인증이 필요합니다." }] }; } tokenToUse = savedTokens.refresh_token; } const tok = await refreshToken(tokenToUse); return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: "✅ 토큰이 성공적으로 갱신되었습니다!", access_token: tok.access_token.substring(0, 20) + "...", expires_at: tok.expires_at, valid_until: new Date(tok.expires_at).toLocaleString('ko-KR') }, null, 2) }] }; } catch (e: any) { let suggestions: string[] = []; if (e.message.includes("invalid_grant")) { suggestions = [ "리프레시 토큰이 만료되었거나 잘못되었습니다.", "cafe24_generate_auth_url로 새 인증을 진행해주세요." ]; } return { isError: true, content: [{ type: "text" as const, text: JSON.stringify({ error: `Refresh failed: ${e.message}`, suggestions, help: "cafe24_generate_auth_url 도구로 새 인증을 시작하세요." }, null, 2) }], }; } }, ); // 개선된 토큰 상태 확인 도구 server.tool( "cafe24_get_token_status", "Get comprehensive token status with expiry information and recommendations", {}, async () => { try { const tokens = await loadTokens(); if (!tokens) { const authUrl = generateAuthorizationUrl( MALL_ID, CLIENT_ID,