UNPKG

sometrend-mcp-server

Version:

TM2 기반의 썸트렌드(sometrend) MCP 서버 - 트렌드 분석 및 데이터 처리

660 lines (582 loc) 24 kB
/** * 썸트렌드 MCP 서버 - SSE 모드 진입점 * HTTP + Server-Sent Events 전송 방식 사용 */ import express from 'express'; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { TM2Server } from './TM2Server.js'; import npmRegistryProduction from './npm-registry-production.js'; // 프로덕션 개선사항 import import { basicRateLimit, sseRateLimit, messageRateLimit } from './middleware/RateLimit.js'; import { ProductionLogger } from './utils/ProductionLogger.js'; import { healthCheckService } from './monitoring/HealthCheck.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ErrorCode, TextContent, ImageContent, EmbeddedResource } from "@modelcontextprotocol/sdk/types.js"; import { SERVER_BASE_INFO, AI_OPTIMIZED_SERVER_DESCRIPTION } from './constants/app-data.constants.js'; import { TOOL_ENDPOINT_MAP } from './constants/api-endpoints.constants.js'; import { CacheService } from './services/CacheService.js'; import { PromptGeneratorService } from './services/PromptGeneratorService.js'; import { PromptHandlerService } from './services/PromptHandlerService.js'; import { CategoryResourceService } from './services/CategoryResourceService.js'; import { SnsChannelService } from './services/SnsChannelService.js'; import { DatabaseService } from './services/DatabaseService.js'; import { ToolDefinitions } from './definitions/ToolDefinitions.js'; import { getPromptDefinitions } from './definitions/PromptDefinitions.js'; import { TM2ApiClient } from './services/TM2ApiClient.js'; import type { CacheConfig } from './types/index.js'; const app = express(); app.use(express.json()); // 프로덕션 NPM Registry 라우트 추가 app.use('/npm', npmRegistryProduction); // 전역 서비스 인스턴스 let apiService: TM2ApiClient; let cacheService: CacheService; let promptGeneratorService: PromptGeneratorService; let promptHandlerService: PromptHandlerService; let categoryResourceService: CategoryResourceService; let snsChannelService: SnsChannelService; let databaseService: DatabaseService; let isServicesInitialized = false; // 활성 트랜스포트 관리 const transports: Map<string, SSEServerTransport> = new Map(); /** * 서비스 초기화 함수 */ async function initializeServices(): Promise<void> { if (isServicesInitialized) { return; } // 환경 변수 설정 const baseUrl = process.env.TM2_BASE_URL || "http://10.1.41.49:9292"; const timeout = parseInt(process.env.API_TIMEOUT || "300000", 10); // 캐시 설정 const cacheConfig: CacheConfig = { maxSize: parseInt(process.env.CACHE_MAX_SIZE || "1000", 10), ttlMs: parseInt(process.env.CACHE_TTL || "300000", 10), // 5분 checkPeriodMs: parseInt(process.env.CACHE_CHECK_PERIOD || "60000", 10), // 1분 maxMemoryMB: parseInt(process.env.CACHE_MAX_MEMORY || "100", 10) }; // 서비스 초기화 cacheService = new CacheService(cacheConfig); apiService = new TM2ApiClient(baseUrl, timeout); promptGeneratorService = new PromptGeneratorService(); promptHandlerService = new PromptHandlerService(); categoryResourceService = new CategoryResourceService(cacheService, apiService); snsChannelService = new SnsChannelService(cacheService, apiService); // 데이터베이스 서비스 초기화 const dbConfig = { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '3306'), user: process.env.DB_USER || 'sometrend_mcp', password: process.env.DB_PASSWORD || 'sometrend_mcp', database: process.env.DB_NAME || 'sometrend_mcp' }; databaseService = new DatabaseService(dbConfig); console.log('✅ 데이터베이스 서비스 초기화 완료'); // 리소스 프리로딩 초기화 await categoryResourceService.initializeResources(); isServicesInitialized = true; console.log('✅ 서비스 초기화 및 리소스 프리로딩 완료'); } /** * MCP 서버 인스턴스 생성 및 설정 */ function createServer(): Server { // MCP 서버 생성 const server = new Server({ name: SERVER_BASE_INFO.name, version: SERVER_BASE_INFO.version, description: AI_OPTIMIZED_SERVER_DESCRIPTION }, { capabilities: { tools: {}, prompts: {}, resources: {} } }); // 도구 목록 반환 server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: ToolDefinitions.getAllToolDefinitions() }; }); // 프롬프트 목록 반환 server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: getPromptDefinitions() }; }); // 프롬프트 가져오기 server.setRequestHandler(GetPromptRequestSchema, async (request) => { const promptName = request.params.name; const args = request.params.arguments as Record<string, any>; try { const promptText = await promptHandlerService.getPrompt(promptName, args); return { messages: [ { role: "user" as const, content: { type: "text" as const, text: promptText } as TextContent } ] }; } catch (error: unknown) { throw new McpError( ErrorCode.InternalError, `프롬프트 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : String(error)}` ); } }); // 리소스 목록 반환 server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: "tm2://categories/sm", name: "SM 카테고리", description: "소셜미디어 분석을 위한 카테고리 목록", mimeType: "application/json" }, { uri: "tm2://categories/tsn3", name: "TSN3 카테고리", description: "고급 텍스트 분석을 위한 카테고리 목록", mimeType: "application/json" }, { uri: "tm2://channels/sns", name: "SNS 채널", description: "이용 가능한 SNS 데이터 채널 목록", mimeType: "application/json" } ] }; }); // 리소스 읽기 server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; try { let resourceData: any; if (uri === "tm2://categories/sm") { resourceData = await categoryResourceService.getAvailableCategories(true); } else if (uri === "tm2://categories/tsn3") { resourceData = await categoryResourceService.getCategorySetList(true); } else if (uri === "tm2://channels/sns") { resourceData = await snsChannelService.getAvailableSnsChannels(true); } else { throw new McpError( ErrorCode.InvalidParams, `지원되지 않는 리소스 URI: ${uri}` ); } return { contents: [ { type: "resource" as const, resource: { uri, mimeType: "application/json", text: JSON.stringify(resourceData, null, 2) } } ] }; } catch (error: unknown) { throw new McpError( ErrorCode.InternalError, `리소스 읽기 중 오류가 발생했습니다: ${error instanceof Error ? error.message : String(error)}` ); } }); // 도구 실행 처리 server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; const args = request.params.arguments as Record<string, any>; try { const endpoint = TOOL_ENDPOINT_MAP[toolName]; if (!endpoint) { throw new McpError( ErrorCode.MethodNotFound, `알 수 없는 도구: ${toolName}` ); } if (endpoint === "LOCAL") { const localResult = await handleLocalTool(toolName, args); if ('content' in localResult && Array.isArray(localResult.content)) { return { content: localResult.content }; } else if ('contents' in localResult && Array.isArray(localResult.contents)) { return { content: localResult.contents }; } else { return { content: [{ type: "text" as const, text: JSON.stringify(localResult, null, 2) } as TextContent] }; } } else { const apiResult = await apiService.callAPI(endpoint, args, toolName); if ('content' in apiResult && Array.isArray(apiResult.content)) { return { content: apiResult.content }; } else { return { content: [{ type: "text" as const, text: JSON.stringify(apiResult, null, 2) } as TextContent] }; } } } catch (error: unknown) { throw error; } }); server.onerror = (error) => { console.error("썸트렌드 MCP 서버 오류:", error); }; return server; } /** * 로컬 도구 처리 */ async function handleLocalTool(toolName: string, args: Record<string, any>) { switch (toolName) { case "availableCategories": return categoryResourceService.getAvailableCategories(args.detail || false); case "getCategorySetList": return categoryResourceService.getCategorySetList(args.detail || false); case "getAvailableDataSources": case "getAvailableSnsChannels": return snsChannelService.getAvailableSnsChannels(args.detail || false); case "searchSnsChannels": return snsChannelService.searchSnsChannels(args); case "getChannelsByDataType": if (!args.dataType) { throw new McpError( ErrorCode.InvalidParams, "dataType 매개변수가 필요합니다" ); } return snsChannelService.getChannelsByDataType(args.dataType); case "getChannelsByFeature": if (!args.feature) { throw new McpError( ErrorCode.InvalidParams, "feature 매개변수가 필요합니다" ); } return snsChannelService.getChannelsByFeature(args.feature); case "getAllSnsDataTypes": return snsChannelService.getAllDataTypes(); case "getAllSnsFeatures": return snsChannelService.getAllFeatures(); case "getRecommendedSnsChannels": if (!args.analysisType) { throw new McpError( ErrorCode.InvalidParams, "analysisType 매개변수가 필요합니다" ); } return snsChannelService.getRecommendedChannelsForAnalysis(args.analysisType); case "validateSnsChannelId": if (!args.channelId) { throw new McpError( ErrorCode.InvalidParams, "channelId 매개변수가 필요합니다" ); } return snsChannelService.validateChannelId(args.channelId); case "getSnsChannelHealthCheck": return snsChannelService.getHealthCheck(); default: throw new McpError( ErrorCode.MethodNotFound, `지원되지 않는 로컬 도구: ${toolName}` ); } } // CORS 미들웨어 추가 app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); if (req.method === 'OPTIONS') { res.sendStatus(200); return; } next(); }); // SSE 엔드포인트 - 스트림 연결 설정 app.get('/sse', async (req, res) => { console.log('SSE 연결 요청 수신 (GET /sse)'); try { // SSE 트랜스포트 생성 - POST 메시지 엔드포인트는 '/messages' const transport = new SSEServerTransport('/messages', res); const sessionId = transport.sessionId; // 트랜스포트 저장 transports.set(sessionId, transport); // 연결 종료 시 정리 transport.onclose = () => { console.log(`SSE 연결 종료: 세션 ID ${sessionId}`); transports.delete(sessionId); }; // 서비스 초기화 (최초 한 번만) await initializeServices(); // MCP 서버에 트랜스포트 연결 const server = createServer(); await server.connect(transport); console.log(`SSE 스트림 설정 완료: 세션 ID ${sessionId}`); } catch (error) { console.error('SSE 스트림 설정 오류:', error); if (!res.headersSent) { res.status(500).json({ error: 'SSE 스트림 설정 실패', message: error instanceof Error ? error.message : String(error) }); } } }); // 메시지 엔드포인트 - 클라이언트 요청 수신 (개발 모드: 세션 관리 단순화) app.post('/messages', async (req, res) => { const clientIp = req.ip || req.connection.remoteAddress || '알 수 없음'; console.log(`📥 API 요청 수신 - IP: ${clientIp}`); let sessionId = req.query.sessionId as string; let transport: any; // 개발 모드: 세션 ID가 없으면 기본 세션 생성 if (!sessionId) { sessionId = 'dev-session-default'; console.log('🔧 개발 모드: 기본 세션 사용'); } transport = transports.get(sessionId); if (!transport) { // 개발 모드: 세션이 없으면 임시 트랜스포트 생성 console.log('🔧 개발 모드: 임시 트랜스포트 생성'); // 임시 트랜스포트 객체 생성 transport = { sessionId: sessionId, handlePostMessage: async (req: any, res: any, body: any) => { console.log('📨 MCP 메시지 처리:', body?.method || '알 수 없음'); console.log('👤 클라이언트 IP:', clientIp); // 개발 모드 간단한 MCP 응답 처리 try { const method = body?.method; const id = body?.id || 1; // 데이터베이스에 API 사용 로그 기록 if (databaseService) { await databaseService.recordApiUsage({ userId: 1, // 관리자 사용자 ID sessionId: sessionId, toolName: method || 'unknown', endpoint: '/messages', requestData: body, responseStatus: 200, responseTime: Date.now() % 1000, // 임시 응답시간 clientIp: clientIp, userAgent: req.get('User-Agent') || 'Unknown', createdAt: new Date() }).catch((err: any) => console.error('DB 로그 실패:', err)); } let result: any; switch (method) { case 'initialize': result = { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: "썸트렌드 MCP 서버", version: "1.0.0" } } }; break; case 'tools/list': result = { jsonrpc: "2.0", id, result: { tools: [ { name: "get_keyword_mention_count", description: "지정된 키워드의 언급 횟수를 조회합니다", inputSchema: { type: "object", properties: { keyword: { type: "string" }, days: { type: "number" } } } } ] } }; break; case 'tools/call': const toolName = body?.params?.name; if (toolName === 'get_keyword_mention_count') { const keyword = body?.params?.arguments?.keyword || '키워드'; result = { jsonrpc: "2.0", id, result: { content: [ { type: "text", text: `"${keyword}" 키워드의 어제 언급량은 약 1,245건입니다. (개발 모드 테스트 응답)` } ] } }; } else { result = { jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } }; } break; default: result = { jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } }; } res.json(result); } catch (error) { console.error('MCP 처리 오류:', error); res.status(500).json({ jsonrpc: "2.0", id: body?.id || 1, error: { code: -32603, message: "Internal error", data: error instanceof Error ? error.message : '알 수 없는 오류' } }); } } }; // 임시 트랜스포트 저장 transports.set(sessionId, transport); } try { // 트랜스포트로 POST 메시지 처리 await transport.handlePostMessage(req, res, req.body); } catch (error) { console.error('요청 처리 오류:', error); if (!res.headersSent) { res.status(500).json({ error: '요청 처리 실패', message: error instanceof Error ? error.message : String(error), sessionId }); } } }); // 헬스체크 엔드포인트 app.get('/health', (req, res) => { res.json({ status: 'ok', server: SERVER_BASE_INFO.name, version: SERVER_BASE_INFO.version, mode: 'sse', activeSessions: transports.size, uptime: process.uptime(), timestamp: new Date().toISOString(), protocol: "MCP v1.18.0", endpoints: { sse: '/sse', messages: '/messages', health: '/health' } }); }); // MCP 서버 정보 엔드포인트 (원격 클라이언트용) app.get('/info', (req, res) => { res.json({ name: SERVER_BASE_INFO.name, version: SERVER_BASE_INFO.version, description: AI_OPTIMIZED_SERVER_DESCRIPTION, protocol: "Model Context Protocol (MCP)", transport: "Server-Sent Events (SSE)", capabilities: { tools: true, prompts: true, resources: true }, endpoints: { sse: '/sse', messages: '/messages', health: '/health', info: '/info' }, usage: { connect: "GET /sse", message: "POST /messages?sessionId={sessionId}" } }); }); // 서버 시작 const PORT = parseInt(process.env.PORT || '8000', 10); const server = app.listen(PORT, () => { console.log(` 🚀 썸트렌드 MCP 서버 (SSE 모드) 📡 서버 정보: - 이름: ${SERVER_BASE_INFO.name} - 버전: ${SERVER_BASE_INFO.version} - 포트: ${PORT} - 모드: Server-Sent Events (SSE) 🔗 엔드포인트: - GET /sse : SSE 스트림 연결 - POST /messages : 클라이언트 메시지 수신 - GET /health : 서버 상태 확인 📌 서버가 포트 ${PORT}에서 실행 중입니다. `); }); // 종료 처리 process.on('SIGINT', async () => { console.log('\n서버 종료 중...'); // 모든 활성 트랜스포트 종료 for (const [sessionId, transport] of transports) { try { console.log(`트랜스포트 종료 중: 세션 ${sessionId}`); await transport.close(); } catch (error) { console.error(`트랜스포트 종료 오류 (세션 ${sessionId}):`, error); } } // 캐시 서비스 정리 if (cacheService) { cacheService.destroy(); } // 서버 종료 server.close(() => { console.log('서버가 정상적으로 종료되었습니다.'); process.exit(0); }); }); process.on('SIGTERM', () => { process.emit('SIGINT'); });