UNPKG

epl-mcp-server

Version:

EPL 축구 소식을 위한 MCP Server

555 lines (490 loc) 17.4 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { createClient } from '@supabase/supabase-js'; import dotenv from 'dotenv'; // 환경변수 로드 dotenv.config(); // 디버깅을 위한 로깅 console.error('🚀 EPL MCP Server starting...'); console.error('📍 Current working directory:', process.cwd()); console.error('🔑 Environment check:'); console.error(' - SUPABASE_URL:', process.env.SUPABASE_URL ? '✅ Set' : '❌ Missing'); console.error(' - SUPABASE_ANON_KEY:', process.env.SUPABASE_ANON_KEY ? '✅ Set' : '❌ Missing'); interface TweetData { id: string; content: string; author: string; created_at: string; hashtags: string[]; retweet_count: number; like_count: number; category: 'match' | 'player' | 'club' | 'director' | 'transfer'; keywords: string[]; } class EPLMCPServer { private server: Server; private supabase: any; constructor() { console.error('🏗️ Constructing EPL MCP Server...'); try { // 환경변수 검증 if (!process.env.SUPABASE_URL || !process.env.SUPABASE_ANON_KEY) { const missingVars = []; if (!process.env.SUPABASE_URL) missingVars.push('SUPABASE_URL'); if (!process.env.SUPABASE_ANON_KEY) missingVars.push('SUPABASE_ANON_KEY'); throw new Error(`❌ Missing required environment variables: ${missingVars.join(', ')}`); } this.server = new Server( { name: 'epl-twitter-content-server', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); console.error('✅ MCP Server instance created'); // Supabase 클라이언트 초기화 this.supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY ); console.error('✅ Supabase client created'); this.setupHandlers(); console.error('✅ Handlers setup complete'); } catch (error) { console.error('❌ Error in constructor:', error); throw error; } } private setupHandlers() { try { console.error('🔧 Setting up handlers...'); // 도구 목록 제공 this.server.setRequestHandler(ListToolsRequestSchema, async () => { console.error('📋 Listing tools...'); return { tools: [ { name: 'test_connection', description: 'Supabase 연결을 테스트합니다', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'get_hot_issues', description: '특정 기간의 핫한 EPL 이슈들을 조회합니다', inputSchema: { type: 'object', properties: { timeframe: { type: 'string', enum: ['today', 'week', 'month'], description: '조회 기간' }, limit: { type: 'number', description: '결과 개수 제한 (기본값: 10)', default: 10 } }, required: ['timeframe'] } }, { name: 'search_tweets', description: '키워드로 트윗을 검색합니다', inputSchema: { type: 'object', properties: { keywords: { type: 'array', items: { type: 'string' }, description: '검색 키워드들' }, timeframe: { type: 'string', description: '검색 기간' }, limit: { type: 'number', default: 20 } }, required: ['keywords', 'timeframe'] } }, { name: 'get_player_news', description: '특정 선수의 최신 뉴스와 소식을 조회합니다', inputSchema: { type: 'object', properties: { player_name: { type: 'string', description: '선수 이름' }, timeframe: { type: 'string', description: '조회 기간 (예: 7days, 1month)' } }, required: ['player_name', 'timeframe'] } }, { name: 'get_images', description: '키워드들로 관련 이미지를 검색합니다', inputSchema: { type: 'object', properties: { keywords: { type: 'array', items: { type: 'string' }, description: '검색 키워드들' }, limit: { type: 'number', description: '최대 이미지 개수 (기본값: 10)', default: 10 } }, required: ['keywords'] } } ] }; }); // 도구 실행 핸들러 this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; console.error(`🔧 Executing tool: ${name}`); try { switch (name) { case 'test_connection': return await this.testConnection(); case 'get_hot_issues': return await this.getHotIssues(args); case 'search_tweets': return await this.searchTweets(args); case 'get_player_news': return await this.getPlayerNews(args); case 'get_images': return await this.getImages(args); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } } catch (error) { console.error(`❌ Error executing ${name}:`, error); throw new McpError( ErrorCode.InternalError, `Error executing ${name}: ${error instanceof Error ? error.message : 'Unknown error'}` ); } }); console.error('✅ Request handlers set up successfully'); } catch (error) { console.error('❌ Error setting up handlers:', error); throw error; } } private async testConnection() { console.error('🧪 Testing Supabase connection...'); try { // 간단한 테이블 존재 확인 const { data, error } = await this.supabase .from('tweets') .select('count', { count: 'exact', head: true }); if (error) { console.error('❌ Supabase connection failed:', error); return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `Supabase 연결 실패: ${error.message}`, error_details: error }, null, 2) }] }; } console.error('✅ Supabase connection successful'); return { content: [{ type: 'text', text: JSON.stringify({ status: 'success', message: 'Supabase 연결 성공!', table_exists: true, timestamp: new Date().toISOString() }, null, 2) }] }; } catch (error) { console.error('❌ Connection test failed:', error); return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `연결 테스트 실패: ${error instanceof Error ? error.message : 'Unknown error'}`, timestamp: new Date().toISOString() }, null, 2) }] }; } } private async getHotIssues(args: any) { console.error('🔥 Getting hot issues...', args); try { const { timeframe, limit = 10 } = args; let dateFilter = new Date(); switch (timeframe) { case 'today': // 현재 시간 기준으로 24시간 전부터 지금까지 dateFilter = new Date(dateFilter.getTime() - 24 * 60 * 60 * 1000); break; case 'week': dateFilter = new Date(dateFilter.getTime() - 7 * 24 * 60 * 60 * 1000); break; case 'month': dateFilter = new Date(dateFilter.getTime() - 30 * 24 * 60 * 60 * 1000); break; default: dateFilter = new Date(dateFilter.getTime() - 24 * 60 * 60 * 1000); } console.error('📅 Date filter:', dateFilter.toISOString()); const { data, error } = await this.supabase .from('tweets') .select('*') .gte('created_at', dateFilter.toISOString()) .order('created_at', { ascending: false }) // .order('engagement', { ascending: false }) .limit(limit); if (error) { console.error('❌ Supabase query error:', error); throw new Error(`Supabase 쿼리 오류: ${error.message}`); } console.error(`✅ Found ${data?.length || 0} tweets`); return { content: [{ type: 'text', text: JSON.stringify({ timeframe, total_found: data?.length || 0, hot_issues: data?.map((tweet: TweetData) => ({ content: tweet.content, author: tweet.author, engagement: (tweet.retweet_count || 0) + (tweet.like_count || 0), hashtags: tweet.hashtags || [], keywords: tweet.keywords || [], created_at: tweet.created_at, category: tweet.category })) || [] }, null, 2) }] }; } catch (error) { console.error('❌ Error in getHotIssues:', error); throw error; } } private async getPlayerNews(args: any) { console.error('⚽ Getting player news...', args); try { const { player_name, timeframe, limit = 20 } = args; // timeframe이 없으면 기본값 '7days' 사용 const safeTimeframe = timeframe || '7days'; const days = this.parseTimeframe(safeTimeframe); const dateFilter = new Date(Date.now() - days * 24 * 60 * 60 * 1000); console.error('📅 Date filter:', dateFilter.toISOString()); console.error('👤 Player:', player_name); // 방법 1: 더 간단한 방법 // const { data, error } = await this.supabase // .from('tweets') // .select('*') // .eq('category', 'player') // .or(`content.ilike.%${player_name}%,category.eq.player`) // .gte('created_at', dateFilter.toISOString()) // .order('created_at', { ascending: false }) // .limit(20); // 방법 2: 좀 더 정확한 JSONB 검색을 원한다면 const { data, error } = await this.supabase .from('tweets') .select('*') .or(`content.ilike.%${player_name}%,player_mentions.cs.["${player_name}"]`) .eq('category', 'player') .gte('created_at', dateFilter.toISOString()) .order('created_at', { ascending: false }) .limit(limit); if (error) { console.error('❌ Supabase query error:', error); throw new Error(`Supabase 쿼리 오류: ${error.message}`); } console.error(`✅ Found ${data?.length || 0} player news`); return { content: [{ type: 'text', text: JSON.stringify({ player_name, timeframe, total_found: data?.length || 0, player_news: data?.map((tweet: TweetData) => ({ content: tweet.content, author: tweet.author, created_at: tweet.created_at, engagement: (tweet.retweet_count || 0) + (tweet.like_count || 0), hashtags: tweet.hashtags || [] })) || [] }, null, 2) }] }; } catch (error) { console.error('❌ Error in getPlayerNews:', error); throw error; } } private async searchTweets(args: any) { console.error('🔍 Searching tweets...', args); try { const { keywords, timeframe, limit = 20 } = args; // timeframe이 없으면 기본값 '7days' 사용 const safeTimeframe = timeframe || '7days'; const days = this.parseTimeframe(safeTimeframe); const dateFilter = new Date(Date.now() - days * 24 * 60 * 60 * 1000); console.error('📅 Date filter:', dateFilter.toISOString()); console.error('🔎 Keywords:', keywords); const orConditions = keywords.map((keyword: string) => `content.ilike.%${keyword}%`).join(','); const { data, error } = await this.supabase .from('tweets') .select('*') .or(orConditions) .gte('created_at', dateFilter.toISOString()) .order('created_at', { ascending: false }) .limit(limit); if (error) { console.error('❌ Supabase query error:', error); throw new Error(`Supabase 쿼리 오류: ${error.message}`); } console.error(`✅ Found ${data?.length || 0} tweets`); return { content: [{ type: 'text', text: JSON.stringify({ keywords, timeframe, total_found: data?.length || 0, tweets: data || [] }, null, 2) }] }; } catch (error) { console.error('❌ Error in searchTweets:', error); throw error; } } private async getImages(args: any) { console.error('🖼️ Getting images...', args); try { const { keywords, limit = 10 } = args; console.error('🔎 Keywords:', keywords); // 키워드들을 team_mentions, player_mentions, director_mentions에서 검색 const orConditions = keywords.map((keyword: string) => `team_mentions.cs.["${keyword}"],player_mentions.cs.["${keyword}"],director_mentions.cs.["${keyword}"]` ).join(','); const { data, error } = await this.supabase .from('tweets') .select('id, content, author, created_at, image_urls, team_mentions, player_mentions, director_mentions') .or(orConditions) .not('image_urls', 'is', null) .order('created_at', { ascending: false }) .limit(limit); if (error) { console.error('❌ Supabase query error:', error); throw new Error(`Supabase 쿼리 오류: ${error.message}`); } console.error(`✅ Found ${data?.length || 0} tweets with images`); // 이미지 URL들을 추출하고 중복 제거 const allImageUrls: string[] = []; data?.forEach((tweet: any) => { if (tweet.image_urls && Array.isArray(tweet.image_urls)) { allImageUrls.push(...tweet.image_urls); } }); // 중복 제거 const uniqueImageUrls = [...new Set(allImageUrls)]; return { content: [{ type: 'text', text: JSON.stringify({ keywords, total_tweets_found: data?.length || 0, total_images_found: uniqueImageUrls.length, images: uniqueImageUrls.slice(0, limit) }, null, 2) }] }; } catch (error) { console.error('❌ Error in getImages:', error); throw error; } } private parseTimeframe(timeframe: string): number { if (!timeframe) return 7; // 기본값 7일 if (timeframe.includes('day')) { return parseInt(timeframe.replace(/\D/g, '')) || 1; } else if (timeframe.includes('week')) { return (parseInt(timeframe.replace(/\D/g, '')) || 1) * 7; } else if (timeframe.includes('month')) { return (parseInt(timeframe.replace(/\D/g, '')) || 1) * 30; } return 7; // 기본값 } async run() { try { console.error('🏃 Starting MCP server transport...'); const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('✅ EPL Twitter Content MCP server running on stdio'); } catch (error) { console.error('❌ Error starting server:', error); throw error; } } } // 전역 에러 핸들링 process.on('uncaughtException', (error) => { console.error('❌ Uncaught Exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); // 서버 시작 console.error('🚀 Creating and starting EPL MCP Server...'); try { const server = new EPLMCPServer(); server.run().catch((error) => { console.error('❌ Fatal error running server:', error); process.exit(1); }); } catch (error) { console.error('❌ Fatal error creating server:', error); process.exit(1); }