sometrend-mcp-server
Version:
TM2 기반의 썸트렌드(sometrend) MCP 서버 - 트렌드 분석 및 데이터 처리
660 lines (582 loc) • 24 kB
text/typescript
/**
* 썸트렌드 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');
});