import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { InferenceClient } from '@huggingface/inference'
import { z } from 'zod'
// 서버 정보
const SERVER_NAME = 'mcp-server-251215'
const SERVER_VERSION = '1.0.0'
// Smithery 배포를 위한 설정 스키마
export const configSchema = z.object({
hfToken: z
.string()
.optional()
.describe('Hugging Face API 토큰 (이미지 생성 기능에 필요)')
})
// 설정 타입 정의
type Config = z.infer<typeof configSchema>
// Smithery 배포를 위한 createServer 함수 (기본 export)
export default function createServer({ config }: { config?: Config } = {}) {
// Hugging Face Inference Client 초기화 (토큰이 있는 경우에만)
const hfToken = config?.hfToken || process.env.HF_TOKEN
const hfClient = hfToken ? new InferenceClient(hfToken) : null
// MCP 서버 생성
const server = new McpServer({
name: SERVER_NAME,
version: SERVER_VERSION
})
// 서버 정보 및 도구 정보를 반환하는 리소스
server.registerResource(
'server-info',
'mcp://server-info',
{
title: '서버 정보',
description: 'MCP 서버 정보 및 사용 가능한 도구 목록',
mimeType: 'application/json'
},
async () => {
const serverInfo = {
server: {
name: SERVER_NAME,
version: SERVER_VERSION
},
tools: [
{
name: 'greet',
description: '이름과 언어를 입력하면 인사말을 반환합니다.',
parameters: {
name: { type: 'string', description: '인사할 사람의 이름' },
language: { type: 'string', enum: ['ko', 'en'], optional: true, default: 'en', description: '인사 언어 (기본값: en)' }
}
},
{
name: 'calculator',
description: '두 개의 숫자와 연산자를 입력받아 사칙연산 결과를 반환합니다.',
parameters: {
num1: { type: 'number', description: '첫 번째 숫자' },
num2: { type: 'number', description: '두 번째 숫자' },
operator: { type: 'string', enum: ['+', '-', '*', '/'], description: '연산자 (+, -, *, /)' }
}
},
{
name: 'geocode',
description: '도시 이름이나 주소를 입력받아서 위도와 경도 좌표를 반환합니다.',
parameters: {
address: { type: 'string', description: '도시 이름이나 주소' }
}
},
{
name: 'get-weather',
description: '위도와 경도 좌표, 예보 기간을 입력받아서 해당 위치의 현재 날씨와 예보 정보를 제공합니다.',
parameters: {
latitude: { type: 'number', description: '위도 좌표 (WGS84)' },
longitude: { type: 'number', description: '경도 좌표 (WGS84)' },
forecastDays: { type: 'number', optional: true, default: 7, minimum: 0, maximum: 16, description: '예보 기간 (일수, 기본값: 7, 최대: 16)' }
}
},
{
name: 'get-time',
description: '현재 시간을 가져옵니다. 타임존을 지정할 수 있습니다.',
parameters: {
timezone: { type: 'string', optional: true, description: '타임존 (예: Asia/Seoul, America/New_York, Europe/London)' }
}
},
{
name: 'generate-image',
description: '텍스트 프롬프트를 기반으로 이미지를 생성합니다. (FLUX.1-schnell 모델 사용)',
parameters: {
prompt: { type: 'string', description: '생성할 이미지에 대한 설명 (영어 권장)' }
}
}
],
timestamp: new Date().toISOString()
}
return {
contents: [
{
uri: 'mcp://server-info',
mimeType: 'application/json',
text: JSON.stringify(serverInfo, null, 2)
}
]
}
}
)
server.registerTool(
'greet',
{
description: '이름과 언어를 입력하면 인사말을 반환합니다.',
inputSchema: z.object({
name: z.string().describe('인사할 사람의 이름'),
language: z.enum(['ko', 'en']).optional().default('en').describe('인사 언어 (기본값: en)')
})
},
async ({ name, language }) => {
const greeting = language === 'ko'
? `안녕하세요, ${name}님!`
: `Hey there, ${name}! 👋 Nice to meet you!`
return {
content: [{ type: 'text' as const, text: greeting }]
}
}
)
server.registerTool(
'calculator',
{
description: '두 개의 숫자와 연산자를 입력받아 사칙연산 결과를 반환합니다.',
inputSchema: z.object({
num1: z.number().describe('첫 번째 숫자'),
num2: z.number().describe('두 번째 숫자'),
operator: z.enum(['+', '-', '*', '/']).describe('연산자 (+, -, *, /)')
})
},
async ({ num1, num2, operator }) => {
let result: number
switch (operator) {
case '+': result = num1 + num2; break
case '-': result = num1 - num2; break
case '*': result = num1 * num2; break
case '/':
if (num2 === 0) throw new Error('0으로 나눌 수 없습니다.')
result = num1 / num2
break
default: throw new Error(`지원하지 않는 연산자: ${operator}`)
}
return {
content: [{ type: 'text' as const, text: `${num1} ${operator} ${num2} = ${result}` }]
}
}
)
server.registerTool(
'geocode',
{
description: '도시 이름이나 주소를 입력받아서 위도와 경도 좌표를 반환합니다.',
inputSchema: z.object({
address: z.string().describe('도시 이름이나 주소')
})
},
async ({ address }) => {
const baseUrl = 'https://nominatim.openstreetmap.org/search'
const params = new URLSearchParams({ q: address, format: 'json', limit: '1', addressdetails: '1' })
const url = `${baseUrl}?${params.toString()}`
const response = await fetch(url, {
headers: { 'User-Agent': 'MCP-Geocode-Tool/1.0' }
})
if (!response.ok) {
throw new Error(`Nominatim API 요청 실패: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (!data || data.length === 0) {
return {
content: [{ type: 'text' as const, text: `주소 "${address}"를 찾을 수 없습니다.` }]
}
}
const result = data[0]
const lat = parseFloat(result.lat)
const lon = parseFloat(result.lon)
const displayName = result.display_name || address
return {
content: [{ type: 'text' as const, text: `주소: ${displayName}\n위도: ${lat}\n경도: ${lon}\n좌표: (${lat}, ${lon})` }]
}
}
)
server.registerTool(
'get-weather',
{
description: '위도와 경도 좌표, 예보 기간을 입력받아서 해당 위치의 현재 날씨와 예보 정보를 제공합니다.',
inputSchema: z.object({
latitude: z.number().describe('위도 좌표 (WGS84)'),
longitude: z.number().describe('경도 좌표 (WGS84)'),
forecastDays: z.number().int().min(0).max(16).optional().default(7).describe('예보 기간 (일수, 기본값: 7, 최대: 16)')
})
},
async ({ latitude, longitude, forecastDays = 7 }) => {
const baseUrl = 'https://api.open-meteo.com/v1/forecast'
const params = new URLSearchParams({
latitude: latitude.toString(),
longitude: longitude.toString(),
hourly: 'temperature_2m,relativehumidity_2m,precipitation,weathercode,windspeed_10m,winddirection_10m',
daily: 'temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode',
forecast_days: forecastDays.toString(),
timezone: 'auto'
})
const response = await fetch(`${baseUrl}?${params.toString()}`)
if (!response.ok) {
throw new Error(`Open-Meteo API 요청 실패: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Open-Meteo API 오류: ${data.reason || '알 수 없는 오류'}`)
}
const currentHourly = data.hourly
const daily = data.daily
const getWeatherDescription = (code: number): string => {
const weatherCodes: Record<number, string> = {
0: '맑음', 1: '대체로 맑음', 2: '부분적으로 흐림', 3: '흐림',
45: '안개', 48: '서리 안개',
51: '약한 이슬비', 53: '중간 이슬비', 55: '강한 이슬비',
61: '약한 비', 63: '중간 비', 65: '강한 비',
71: '약한 눈', 73: '중간 눈', 75: '강한 눈',
80: '약한 소나기', 81: '중간 소나기', 82: '강한 소나기',
95: '뇌우', 96: '우박과 함께하는 뇌우', 99: '강한 우박과 함께하는 뇌우'
}
return weatherCodes[code] || `코드 ${code}`
}
let resultText = `=== 현재 날씨 ===\n`
resultText += `위치: 위도 ${latitude}, 경도 ${longitude}\n`
resultText += `시간: ${currentHourly.time[0]}\n`
resultText += `온도: ${currentHourly.temperature_2m[0]}°C\n`
resultText += `습도: ${currentHourly.relativehumidity_2m[0]}%\n`
resultText += `날씨: ${getWeatherDescription(currentHourly.weathercode[0])}\n`
resultText += `풍속: ${currentHourly.windspeed_10m[0]}km/h\n\n`
resultText += `=== ${forecastDays}일 예보 ===\n`
for (let i = 0; i < Math.min(daily.time.length, forecastDays); i++) {
const date = new Date(daily.time[i])
const dateStr = date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' })
resultText += `\n${dateStr}:\n`
resultText += ` 최고: ${daily.temperature_2m_max[i]}°C / 최저: ${daily.temperature_2m_min[i]}°C\n`
resultText += ` 날씨: ${getWeatherDescription(daily.weathercode[i])}\n`
}
return {
content: [{ type: 'text' as const, text: resultText }]
}
}
)
server.registerTool(
'get-time',
{
description: '현재 시간을 가져옵니다. 타임존을 지정할 수 있습니다.',
inputSchema: z.object({
timezone: z.string().optional().describe('타임존 (예: Asia/Seoul, America/New_York, Europe/London)')
})
},
async ({ timezone }) => {
const now = new Date()
let timeText = ''
if (timezone) {
try {
const formatter = new Intl.DateTimeFormat('ko-KR', {
timeZone: timezone,
year: 'numeric', month: 'long', day: 'numeric', weekday: 'long',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
})
const offsetFormatter = new Intl.DateTimeFormat('ko-KR', {
timeZone: timezone,
timeZoneName: 'shortOffset'
})
const parts = offsetFormatter.formatToParts(now)
const offset = parts.find(part => part.type === 'timeZoneName')?.value || ''
timeText = `타임존: ${timezone}\n`
timeText += `현재 시간: ${formatter.format(now)} ${offset}\n`
timeText += `ISO 8601: ${now.toISOString()}`
} catch {
throw new Error(`유효하지 않은 타임존입니다: ${timezone}`)
}
} else {
const formatter = new Intl.DateTimeFormat('ko-KR', {
year: 'numeric', month: 'long', day: 'numeric', weekday: 'long',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
})
const timeZoneName = Intl.DateTimeFormat().resolvedOptions().timeZone
timeText = `타임존: ${timeZoneName} (로컬)\n`
timeText += `현재 시간: ${formatter.format(now)}\n`
timeText += `ISO 8601: ${now.toISOString()}\n`
timeText += `Unix 타임스탬프: ${Math.floor(now.getTime() / 1000)}`
}
return {
content: [{ type: 'text' as const, text: timeText }]
}
}
)
server.registerTool(
'generate-image',
{
description: '텍스트 프롬프트를 기반으로 이미지를 생성합니다. (FLUX.1-schnell 모델 사용)',
inputSchema: z.object({
prompt: z.string().describe('생성할 이미지에 대한 설명 (영어 권장)')
})
},
async ({ prompt }) => {
if (!hfClient || !hfToken) {
throw new Error('HF_TOKEN이 설정되지 않았습니다. Hugging Face API 토큰이 필요합니다.')
}
const image = await hfClient.textToImage({
provider: 'auto',
model: 'black-forest-labs/FLUX.1-schnell',
inputs: prompt,
parameters: { num_inference_steps: 5 }
})
let base64Data: string
if (typeof image === 'object' && image !== null && 'arrayBuffer' in image) {
const arrayBuffer = await (image as Blob).arrayBuffer()
base64Data = Buffer.from(arrayBuffer).toString('base64')
} else {
base64Data = image as string
}
return {
content: [{
type: 'image' as const,
data: base64Data,
mimeType: 'image/png'
}]
}
}
)
// 코드 리뷰 프롬프트
server.registerPrompt(
'code-review',
{
title: '코드 리뷰',
description: '제공된 코드를 리뷰하고 개선 사항을 제안합니다.',
argsSchema: {
code: z.string().describe('리뷰할 코드'),
language: z.string().optional().default('typescript').describe('프로그래밍 언어 (기본값: typescript)'),
focus: z.enum(['performance', 'security', 'best-practices', 'all']).optional().default('all').describe('리뷰 포커스 영역 (기본값: all)')
}
},
async ({ code, language = 'typescript', focus = 'all' }) => {
let reviewPrompt = `다음 ${language} 코드를 리뷰해주세요:\n\n\`\`\`${language}\n${code}\n\`\`\`\n\n`
const focusInstructions: Record<string, string> = {
performance: '성능 최적화에 중점을 두고 리뷰해주세요.',
security: '보안 취약점에 중점을 두고 리뷰해주세요.',
'best-practices': '코딩 베스트 프랙티스에 중점을 두고 리뷰해주세요.',
all: '전반적인 코드 품질을 리뷰해주세요.'
}
reviewPrompt += focusInstructions[focus] || focusInstructions.all
reviewPrompt += `\n\n다음 형식으로 리뷰 결과를 제공해주세요:\n`
reviewPrompt += `1. **전체 평가**: 코드의 전반적인 품질 평가\n`
reviewPrompt += `2. **장점**: 잘 작성된 부분\n`
reviewPrompt += `3. **개선 사항**: 개선이 필요한 부분과 구체적인 제안\n`
reviewPrompt += `4. **보안 이슈**: 발견된 보안 취약점 (있는 경우)\n`
reviewPrompt += `5. **성능 이슈**: 성능 개선이 필요한 부분 (있는 경우)\n`
reviewPrompt += `6. **리팩토링 제안**: 코드 구조 개선 제안 (있는 경우)`
return {
messages: [{
role: 'user',
content: { type: 'text', text: reviewPrompt }
}],
description: `${language} 코드 리뷰 (포커스: ${focus})`
}
}
)
// Smithery 배포 시 server.server 반환
return server.server
}