cafe24-admin-mcp
Version:
Cafe24 Admin API MCP Server with Memory Optimization
1,306 lines • 139 kB
JavaScript
#!/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 = null;
try {
Redis = require("ioredis");
console.error('[REDIS] ioredis loaded successfully');
}
catch (error) {
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) {
// MCP 서버에서는 stdout을 오직 JSON-RPC 메시지만 사용해야 함
// 모든 debug/info 출력을 stderr로 리다이렉트
if (typeof chunk === 'string' && chunk.trim()) {
process.stderr.write(`[DEBUG] Blocked stdout: ${chunk}`);
}
return true;
};
// 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;
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 = 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) => console.error('[AWS WARN]', msg),
error: (msg) => console.error('[AWS ERROR]', msg),
}
});
}
// ------------------------------------------------------------------
// Redis 캐싱 시스템
// ------------------------------------------------------------------
let redisClient = 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) => {
console.error('[REDIS ERROR]', err.message);
});
redisClient.on('connect', () => {
console.error('[REDIS] Connected successfully');
});
}
}
catch (error) {
console.error('[REDIS] Failed to initialize:', error.message);
redisClient = null;
}
}
/**
* 범용 테이블 캐시 관리자 (Schema-Free)
*/
class GenericTableCache {
redis;
prefix;
ttl;
constructor(redis, prefix = REDIS_PREFIX, ttl = parseInt(REDIS_TTL)) {
this.redis = redis;
this.prefix = prefix;
this.ttl = ttl;
}
/**
* 캐시 키 생성 (범용)
*/
getKey(table, type, identifier) {
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) {
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) {
console.error(`[CACHE META ERROR] ${table}: ${error.message}`);
return null;
}
}
/**
* 범용 테이블 데이터 저장 (Schema-Free)
*/
async putTable(table, rows, options = {}) {
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) {
console.error(`[CACHE PUT ERROR] ${table}: ${error.message}`);
return {
success: false,
rowCount: 0,
chunkCount: 0,
columns: [],
cacheSize: '0KB',
message: `캐시 저장 실패: ${error.message}`
};
}
}
/**
* 범용 테이블 데이터 조회
*/
async getTable(table, options = {}) {
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 = [];
// 청크 데이터 병합
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 = {};
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) {
console.error(`[CACHE GET ERROR] ${table}: ${error.message}`);
return { data: [], totalRows: 0, returnedRows: 0, fromCache: false, executionTime: Date.now() - startTime };
}
}
/**
* 범용 집계 연산 (with 캐시)
*/
async aggregateTable(table, params) {
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) {
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 = {};
const keyFunc = (row) => 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) => {
const final = { ...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, b) => 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) {
console.error(`[AGG CACHE SET ERROR] ${error.message}`);
}
}
return finalResult;
}
/**
* 스키마 추론 (동적)
*/
inferSchema(sampleRows) {
const schema = {};
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) {
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) {
console.error(`[CACHE CLEAR ERROR] ${table}: ${error.message}`);
return 0;
}
}
/**
* 모든 테이블 목록 조회
*/
async listTables() {
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) {
console.error(`[LIST TABLES ERROR] ${error.message}`);
return [];
}
}
/**
* 캐시 통계
*/
async getStats() {
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) {
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 = {
async get(endpoint, params) {
// 기존 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, params, data) {
// 기존 API 호환성 유지 - 실제로는 putTable 사용 권장
console.error('[DEPRECATED] Use genericCache.putTable() instead of cacheManager.set()');
},
async invalidate(pattern) {
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) {
console.error(`[CACHE INVALIDATE ERROR] ${error.message}`);
}
},
async getChunked(endpoint, params, chunkSize) {
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(endpoint, params, aggregationKey) {
// 레거시 호환성 - 실제로는 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] : null;
},
async setAggregated(endpoint, params, aggregationKey, data, ttl) {
// 레거시 호환성 - 실제 집계 캐시는 genericCache에서 자동 처리
console.error('[DEPRECATED] Aggregation caching is now handled automatically by genericCache.aggregateTable()');
}
};
/**
* 캐시가 적용된 Cafe24 API 호출
*/
async function cachedCafe24Fetch(path, params = {}, options = {}) {
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' }
});
}
}
// 실제 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' }
});
}
catch (error) {
console.error(`[CACHE SAVE ERROR] ${error.message}`);
return response;
}
}
return response;
}
// ------------------------------------------------------------------
// 고급 리소스 처리 유틸
// ------------------------------------------------------------------
/**
* MIME 타입 감지
*/
function getMimeType(filename) {
const ext = path.extname(filename).toLowerCase();
const mimeTypes = {
'.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) {
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, key, contentType) {
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, filename) {
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, baseFilename, options = {}) {
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;
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) {
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) {
console.error(`❌ CSV 리소스 생성 실패: ${error.message}`);
// 임시 파일 정리
try {
await fs.unlink(tmpFile.path);
}
catch { }
throw error;
}
}
/**
* 스마트 리소스 응답 생성 (개선된 버전)
*/
async function createAdvancedSmartResponse(data, options = {}) {
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",
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",
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) {
return {
isError: true,
content: [{
type: "text",
text: `리소스 생성 실패: ${error.message}. 인라인 응답으로 fallback합니다.`
}]
};
}
}
// 기본: 인라인 반환
return {
content: [{
type: "text",
text: JSON.stringify(data, null, 2)
}]
};
}
/**
* 응답 크기를 확인하고 적절한 전송 방식 결정
*/
function getResponseSize(data) {
return Buffer.byteLength(JSON.stringify(data), 'utf8');
}
// ------------------------------------------------------------------
// 유틸
// ------------------------------------------------------------------
async function loadTokens() {
try {
const txt = await fs.readFile(TOKENS_FILE, "utf-8");
return JSON.parse(txt);
}
catch {
return null;
}
}
async function saveTokens(tok) {
await fs.writeFile(TOKENS_FILE, JSON.stringify(tok, null, 2), "utf-8");
}
function isExpired(expiresAt, bufferMin = 5) {
return (Date.now() >
new Date(expiresAt).getTime() - bufferMin * 60 * 1000);
}
async function cafe24Fetch(path, params = {}) {
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) {
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());
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, redirect_uri) {
const payload = `grant_type=authorization_code&code=${code}` +
`&redirect_uri=${encodeURIComponent(redirect_uri)}`;
return postToken(payload);
}
async function refreshToken(refresh_token) {
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, bufferMinutes = 5) {
const expiresTime = new Date(expiresAt).getTime();
const now = Date.now();
const bufferTime = bufferMinutes * 60 * 1000;
return now >= (expiresTime - bufferTime);
}
/**
* OAuth 인증 URL 생성
*/
function generateAuthorizationUrl(mallId, clientId, redirectUri, state = "app_install") {
// 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) {
return `https://${mallId}.cafe24.com/order/basket.html`;
}
/**
* 유효한 토큰 확보 (자동 갱신 포함)
*/
async function ensureValidToken() {
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) {
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", text: "❌ MALL_ID와 CLIENT_ID가 필요합니다. 환경변수를 확인해주세요." }]
};
}
const finalRedirectUri = redirect_uri || getDefaultRedirectUri(finalMallId);
const authUrl = generateAuthorizationUrl(finalMallId, finalClientId, finalRedirectUri, state);
return {
content: [{
type: "text",
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) {
return {
isError: true,
content: [{ type: "text", 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",
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",
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",
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) {
const authUrl = generateAuthorizationUrl(MALL_ID, CLIENT_ID, getDefaultRedirectUri(MALL_ID), "app_install");
return {
content: [{
type: "text",
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",
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) {
return {
isError: true,
content: [{ type: "text", 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", text: "❌ MALL_ID, CLIENT_ID, CLIENT_SECRET 환경 변수가 필요합니다." }]
};
}
const tok = await exchangeCode(code, finalRedirectUri);
return {
content: [{
type: "text",
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) {
// 자주 발생하는 오류에 대한 도움말 제공
let errorMessage = `Token exchange failed: ${e.message}`;
let suggestions = [];
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",
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", text: "❌ 저장된 토큰이 없습니다. 새로운 인증이 필요합니다." }]
};
}
tokenToUse = savedTokens.refresh_token;
}
const tok = await refreshToken(tokenToUse);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "✅ 토큰이 성공적으로 갱신되었습니다!",
access_token: tok.access_token.substring(0, 20) + "...",
expires_at: tok.expires_at,
valid_until: new D