epl-mcp-server
Version:
EPL 축구 소식을 위한 MCP Server
555 lines (490 loc) • 17.4 kB
text/typescript
#!/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);
}