import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { InferenceClient } from '@huggingface/inference'
import { z } from 'zod'
// Smithery 배포를 위한 설정 스키마
export const configSchema = z.object({
hfToken: z
.string()
.optional()
.describe('Hugging Face API 토큰 (이미지 생성 기능에 필요)')
})
// 설정 타입 정의
type Config = z.infer<typeof configSchema>
// 도구, 리소스, 프롬프트 등록 함수
function registerTools(server: McpServer, config: Config) {
server.registerTool(
'greet',
{
description: '이름과 언어를 입력하면 인사말을 반환합니다.',
inputSchema: z.object({
name: z.string().describe('인사할 사람의 이름'),
language: z
.enum(['ko', 'en'])
.optional()
.default('en')
.describe('인사 언어 (기본값: en)')
}),
outputSchema: z.object({
content: z
.array(
z.object({
type: z.literal('text'),
text: z.string().describe('인사말')
})
)
.describe('인사말')
})
},
async ({ name, language }) => {
const greeting =
language === 'ko'
? `안녕하세요, ${name}님!`
: `Hey there, ${name}! 👋 Nice to meet you!`
return {
content: [
{
type: 'text' as const,
text: greeting
}
],
structuredContent: {
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('연산자 (+, -, *, /)')
}),
outputSchema: z.object({
content: z
.array(
z.object({
type: z.literal('text'),
text: z.string().describe('계산 결과')
})
)
.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}`)
}
const resultText = `${num1} ${operator} ${num2} = ${result}`
return {
content: [
{
type: 'text' as const,
text: resultText
}
],
structuredContent: {
content: [
{
type: 'text' as const,
text: resultText
}
]
}
}
}
)
server.registerTool(
'time',
{
description: '타임존을 입력받아 해당 타임존의 현재 시간을 반환합니다.',
inputSchema: z.object({
timezone: z
.string()
.describe('타임존 (예: Asia/Seoul, America/New_York, Europe/London, UTC 등)')
}),
outputSchema: z.object({
content: z
.array(
z.object({
type: z.literal('text'),
text: z.string().describe('타임존별 현재 시간')
})
)
.describe('타임존별 현재 시간')
})
},
async ({ timezone }) => {
try {
const now = new Date()
const formatter = new Intl.DateTimeFormat('ko-KR', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
const formattedTime = formatter.format(now)
const resultText = `${timezone}의 현재 시간: ${formattedTime}`
return {
content: [
{
type: 'text' as const,
text: resultText
}
],
structuredContent: {
content: [
{
type: 'text' as const,
text: resultText
}
]
}
}
} catch (error) {
throw new Error(`유효하지 않은 타임존입니다: ${timezone}`)
}
}
)
server.registerTool(
'geocode',
{
description: '도시 이름이나 주소를 입력받아 위도와 경도 좌표를 반환합니다.',
inputSchema: z.object({
address: z
.string()
.describe('검색할 도시 이름이나 주소 (예: 서울, New York, 서울시 강남구 등)')
}),
outputSchema: z.object({
content: z
.array(
z.object({
type: z.literal('text'),
text: z.string().describe('위도와 경도 좌표 정보')
})
)
.describe('위도와 경도 좌표 정보')
})
},
async ({ address }) => {
try {
const url = new URL('https://nominatim.openstreetmap.org/search')
url.searchParams.set('q', address)
url.searchParams.set('format', 'json')
url.searchParams.set('limit', '1')
url.searchParams.set('addressdetails', '1')
const response = await fetch(url.toString(), {
headers: {
'User-Agent': 'MCP-Server/1.0.0'
}
})
if (!response.ok) {
throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (!Array.isArray(data) || data.length === 0) {
throw new Error(`주소를 찾을 수 없습니다: ${address}`)
}
const result = data[0]
const lat = parseFloat(result.lat)
const lon = parseFloat(result.lon)
const displayName = result.display_name || address
const resultText = `주소: ${displayName}\n위도: ${lat}\n경도: ${lon}\n좌표: (${lat}, ${lon})`
return {
content: [{ type: 'text' as const, text: resultText }],
structuredContent: { content: [{ type: 'text' as const, text: resultText }] }
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`지오코딩 오류: ${error.message}`)
}
throw new Error(`지오코딩 오류: 알 수 없는 오류가 발생했습니다`)
}
}
)
server.registerTool(
'get-weather',
{
description: '위도와 경도 좌표, 예보 기간을 입력받아 해당 위치의 현재 날씨와 예보 정보를 제공합니다.',
inputSchema: z.object({
latitude: z.number().min(-90).max(90).describe('위도 좌표 (-90 ~ 90)'),
longitude: z.number().min(-180).max(180).describe('경도 좌표 (-180 ~ 180)'),
forecast_days: z.number().int().min(0).max(16).optional().default(7).describe('예보 기간 (일 단위, 0-16일, 기본값: 7일)')
}),
outputSchema: z.object({
content: z.array(z.object({ type: z.literal('text'), text: z.string().describe('날씨 정보') })).describe('날씨 정보')
})
},
async ({ latitude, longitude, forecast_days = 7 }) => {
try {
const url = new URL('https://api.open-meteo.com/v1/forecast')
url.searchParams.set('latitude', latitude.toString())
url.searchParams.set('longitude', longitude.toString())
url.searchParams.set('current_weather', 'true')
url.searchParams.set('forecast_days', forecast_days.toString())
url.searchParams.set('daily', 'weathercode,temperature_2m_max,temperature_2m_min,precipitation_sum,sunrise,sunset,uv_index_max')
url.searchParams.set('hourly', 'temperature_2m,weathercode,precipitation,relative_humidity_2m,apparent_temperature')
url.searchParams.set('timezone', 'auto')
url.searchParams.set('temperature_unit', 'celsius')
url.searchParams.set('windspeed_unit', 'kmh')
url.searchParams.set('precipitation_unit', 'mm')
const response = await fetch(url.toString())
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API 요청 실패: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`)
}
const data = await response.json()
if (!data.current_weather) throw new Error('날씨 데이터를 가져올 수 없습니다.')
const current = data.current_weather
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: '뇌우'
}
return weatherCodes[code] || `날씨 코드: ${code}`
}
const getWindDirection = (degrees: number): string => {
const directions = ['북', '북동', '동', '남동', '남', '남서', '서', '북서']
return directions[Math.round(degrees / 45) % 8]
}
let resultText = `📍 위치: (위도 ${latitude}, 경도 ${longitude})\n🌡️ 현재 온도: ${current.temperature}°C\n☁️ 날씨: ${getWeatherDescription(current.weathercode)}\n💨 풍속: ${current.windspeed} km/h\n🧭 풍향: ${current.winddirection}° (${getWindDirection(current.winddirection)})`
if (data.daily?.time?.length > 0) {
resultText += '\n\n=== 일일 예보 ===\n'
for (let i = 0; i < Math.min(forecast_days, data.daily.time.length); i++) {
const dateStr = new Date(data.daily.time[i]).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short' })
resultText += `${dateStr}: ${getWeatherDescription(data.daily.weathercode[i])}, 최고 ${data.daily.temperature_2m_max[i]}°C, 최저 ${data.daily.temperature_2m_min[i]}°C\n`
}
}
return {
content: [{ type: 'text' as const, text: resultText }],
structuredContent: { content: [{ type: 'text' as const, text: resultText }] }
}
} catch (error) {
if (error instanceof Error) throw new Error(`날씨 정보 조회 오류: ${error.message}`)
throw new Error(`날씨 정보 조회 오류: 알 수 없는 오류가 발생했습니다`)
}
}
)
server.registerTool(
'generate-image',
{
description: '텍스트 프롬프트를 입력받아 AI 이미지를 생성합니다. FLUX.1-schnell 모델을 사용합니다.',
inputSchema: z.object({
prompt: z.string().describe('생성할 이미지를 설명하는 텍스트 프롬프트 (영어 권장)')
})
},
async ({ prompt }) => {
try {
const hfToken = config.hfToken || process.env.HF_TOKEN
if (!hfToken) throw new Error('HF_TOKEN이 설정되지 않았습니다.')
const client = new InferenceClient(hfToken)
const image = await client.textToImage(
{ provider: 'auto', model: 'black-forest-labs/FLUX.1-schnell', inputs: prompt, parameters: { num_inference_steps: 4 } },
{ outputType: 'blob' }
)
const arrayBuffer = await (image as Blob).arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
let binary = ''
for (let i = 0; i < uint8Array.length; i++) binary += String.fromCharCode(uint8Array[i])
const base64Data = btoa(binary)
return { content: [{ type: 'image' as const, data: base64Data, mimeType: 'image/png', annotations: { audience: ['user'], priority: 0.9 } }] }
} catch (error) {
if (error instanceof Error) throw new Error(`이미지 생성 오류: ${error.message}`)
throw new Error('이미지 생성 오류: 알 수 없는 오류가 발생했습니다')
}
}
)
}
function registerResources(server: McpServer) {
server.registerResource('server-info', 'server://info', { title: '서버 정보', description: '현재 서버 정보와 사용 가능한 도구 목록을 반환합니다.', mimeType: 'application/json' }, async () => {
const serverInfo = {
server: { name: 'mcp-server', version: '1.0.0', timestamp: new Date().toISOString(), uptime: process.uptime() },
tools: ['greet', 'calculator', 'time', 'geocode', 'get-weather', 'generate-image'],
resources: ['server-info'],
prompts: ['code-review']
}
return { contents: [{ uri: 'server://info', mimeType: 'application/json', text: JSON.stringify(serverInfo, null, 2) }] }
})
}
function registerPrompts(server: McpServer) {
server.registerPrompt('code-review', {
title: '코드 리뷰',
description: '코드를 입력받아 미리 정의된 코드 리뷰 프롬프트 템플릿과 결합하여 반환합니다.',
argsSchema: {
code: z.string().describe('리뷰할 코드'),
language: z.string().optional().default('auto').describe('코드 언어 (기본값: auto)'),
focusAreas: z.array(z.string()).optional().default([]).describe('집중 리뷰 영역')
}
}, async ({ code, language = 'auto', focusAreas = [] }) => {
const template = `## 코드 리뷰 체크리스트
### 1. 코드 품질 - 가독성, 명명, 중복, 주석
### 2. 기능성 - 요구사항, 엣지 케이스, 에러 처리
### 3. 성능 - 연산 효율성, 메모리, 알고리즘
### 4. 보안 - 입력 검증, 취약점, 하드코딩
### 5. 테스트 - 테스트 가능성, 단위/통합 테스트
### 6. 아키텍처 - SRP, 의존성, 확장성
## 리뷰 형식
### ✅ 잘된 점 / ### ⚠️ 개선 필요 / ### 💡 제안사항 / ### 🔧 리팩토링 제안`
const languageInfo = language !== 'auto' ? `\n**코드 언어**: ${language}` : ''
const focusText = focusAreas.length > 0 ? `\n**집중 영역**: ${focusAreas.join(', ')}` : ''
const finalPrompt = `${template}${languageInfo}${focusText}\n\n---\n\n## 리뷰할 코드\n\n\`\`\`${language !== 'auto' ? language : ''}\n${code}\n\`\`\`\n\n위 코드를 상세히 리뷰해주세요.`
return { messages: [{ role: 'user', content: { type: 'text', text: finalPrompt } }] }
})
}
// Smithery 배포를 위한 createServer 함수 (default export)
export default function createServer({ config }: { config: Config }) {
const server = new McpServer({ name: 'mcp-server', version: '1.0.0' })
registerTools(server, config)
registerResources(server)
registerPrompts(server)
return server.server
}
// 로컬 실행을 위한 코드
const isLocalRun = process.argv[1]?.includes('index') || process.env.LOCAL_RUN === 'true'
if (isLocalRun) {
const server = new McpServer({ name: 'mcp-server', version: '1.0.0' })
const config: Config = { hfToken: process.env.HF_TOKEN }
registerTools(server, config)
registerResources(server)
registerPrompts(server)
server.connect(new StdioServerTransport()).catch(console.error).then(() => console.error('MCP server started'))
}