eodi-mcp / src /mcp /server_streamable.py
lovelymango's picture
Upload 25 files
978996e verified
"""
Eodi MCP Server - Streamable HTTP Transport (MCP 2025-03-26)
============================================================
PlayMCP ๋“ฑ๋ก์„ ์œ„ํ•œ Streamable HTTP Transport ์ง€์› ์„œ๋ฒ„์ž…๋‹ˆ๋‹ค.
MCP Protocol Revision: 2025-03-26
- ๋‹จ์ผ MCP endpoint์—์„œ POST/GET ๋ชจ๋‘ ์ฒ˜๋ฆฌ
- POST: JSON-RPC ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ , JSON ๋˜๋Š” SSE ์ŠคํŠธ๋ฆผ์œผ๋กœ ์‘๋‹ต
- GET: SSE ์ŠคํŠธ๋ฆผ์œผ๋กœ ์„œ๋ฒ„ โ†’ ํด๋ผ์ด์–ธํŠธ ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ 
- Mcp-Session-Id ํ—ค๋”๋กœ ์„ธ์…˜ ๊ด€๋ฆฌ
์‹คํ–‰:
uvicorn server_streamable:app --host 0.0.0.0 --port 7860
"""
import os
import sys
import json
import uuid
import asyncio
import logging
from pathlib import Path
from typing import Any, Dict, Optional
from datetime import datetime
# ๋กœ๊น… ์„ค์ •
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("mcp_server")
# ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ ๊ฒฝ๋กœ ์ถ”๊ฐ€ (src/mcp์—์„œ 2๋‹จ๊ณ„ ์œ„๋กœ)
PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute()
sys.path.insert(0, str(PROJECT_ROOT))
# .env ๋กœ๋“œ (๋กœ์ปฌ ๊ฐœ๋ฐœ์šฉ - HF Space์—์„œ๋Š” Secrets๊ฐ€ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์ž๋™ ์ฃผ์ž…๋จ)
from dotenv import load_dotenv
env_file = PROJECT_ROOT / ".env"
if env_file.exists():
load_dotenv(env_file)
logger.info(f"Loaded .env from {env_file}")
else:
logger.info("No .env file found, using system environment variables (HF Space mode)")
from starlette.applications import Starlette
from starlette.responses import JSONResponse, StreamingResponse, Response
from starlette.routing import Route
from starlette.requests import Request
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
# ============================================================
# 0. Rate Limiting (์Šคํฌ๋ž˜ํ•‘ ๋ฐฉ์–ด)
# ============================================================
from collections import defaultdict
import time as time_module
class RateLimiter:
"""
๊ฐ„๋‹จํ•œ IP ๊ธฐ๋ฐ˜ Rate Limiter.
ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์„ค์ • ๊ฐ€๋Šฅ:
RATE_LIMIT_MAX_REQUESTS: ์œˆ๋„์šฐ๋‹น ์ตœ๋Œ€ ์š”์ฒญ ์ˆ˜ (๊ธฐ๋ณธ: 60)
RATE_LIMIT_WINDOW_SECONDS: ์œˆ๋„์šฐ ์‹œ๊ฐ„(์ดˆ) (๊ธฐ๋ณธ: 60)
"""
def __init__(
self,
max_requests: int = None,
window_seconds: int = None
):
self.max_requests = max_requests or int(os.getenv("RATE_LIMIT_MAX_REQUESTS", "60"))
self.window = window_seconds or int(os.getenv("RATE_LIMIT_WINDOW_SECONDS", "60"))
self.requests: Dict[str, list] = defaultdict(list)
self._last_cleanup = time_module.time()
def _cleanup_old_requests(self):
"""์˜ค๋ž˜๋œ ์š”์ฒญ ๊ธฐ๋ก ์ •๋ฆฌ (๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ)"""
now = time_module.time()
# 5๋ถ„๋งˆ๋‹ค ์ •๋ฆฌ
if now - self._last_cleanup < 300:
return
self._last_cleanup = now
for ip in list(self.requests.keys()):
self.requests[ip] = [t for t in self.requests[ip] if now - t < self.window]
if not self.requests[ip]:
del self.requests[ip]
def is_allowed(self, client_ip: str) -> bool:
"""์š”์ฒญ ํ—ˆ์šฉ ์—ฌ๋ถ€ ํ™•์ธ"""
now = time_module.time()
# ์ฃผ๊ธฐ์  ๋ฉ”๋ชจ๋ฆฌ ์ •๋ฆฌ
self._cleanup_old_requests()
# ์œˆ๋„์šฐ ๋‚ด ์š”์ฒญ๋งŒ ์œ ์ง€
self.requests[client_ip] = [
t for t in self.requests[client_ip]
if now - t < self.window
]
# ์ œํ•œ ํ™•์ธ
if len(self.requests[client_ip]) >= self.max_requests:
logger.warning(f"Rate limit exceeded for IP: {client_ip[:20]}...")
return False
self.requests[client_ip].append(now)
return True
def get_remaining(self, client_ip: str) -> int:
"""๋‚จ์€ ์š”์ฒญ ์ˆ˜ ๋ฐ˜ํ™˜"""
now = time_module.time()
current_requests = len([
t for t in self.requests.get(client_ip, [])
if now - t < self.window
])
return max(0, self.max_requests - current_requests)
# ์ „์—ญ Rate Limiter ์ธ์Šคํ„ด์Šค
rate_limiter = RateLimiter()
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Rate Limiting ๋ฏธ๋“ค์›จ์–ด"""
# Rate Limit ์ œ์™ธ ๊ฒฝ๋กœ
EXEMPT_PATHS = {"/health", "/"}
async def dispatch(self, request: Request, call_next):
# ํ—ฌ์Šค์ฒดํฌ ๋“ฑ์€ ์ œ์™ธ
if request.url.path in self.EXEMPT_PATHS:
return await call_next(request)
# ํด๋ผ์ด์–ธํŠธ IP ์ถ”์ถœ (ํ”„๋ก์‹œ ๊ณ ๋ ค)
client_ip = request.headers.get(
"X-Forwarded-For",
request.client.host if request.client else "unknown"
)
# X-Forwarded-For๋Š” ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„๋œ IP ๋ชฉ๋ก์ผ ์ˆ˜ ์žˆ์Œ
if "," in client_ip:
client_ip = client_ip.split(",")[0].strip()
# Rate Limit ํ™•์ธ
if not rate_limiter.is_allowed(client_ip):
return JSONResponse(
{
"error": "Rate limit exceeded",
"message": f"Too many requests. Please wait and try again.",
"retry_after_seconds": rate_limiter.window
},
status_code=429,
headers={
"Retry-After": str(rate_limiter.window),
"X-RateLimit-Limit": str(rate_limiter.max_requests),
"X-RateLimit-Remaining": "0"
}
)
# ์ •์ƒ ์ฒ˜๋ฆฌ
response = await call_next(request)
# Rate Limit ํ—ค๋” ์ถ”๊ฐ€
response.headers["X-RateLimit-Limit"] = str(rate_limiter.max_requests)
response.headers["X-RateLimit-Remaining"] = str(rate_limiter.get_remaining(client_ip))
return response
# 1. MCP Protocol Constants & Tools Definition
# ============================================================
PROTOCOL_VERSION = "2025-03-26"
SERVER_NAME = "eodi-kb"
SERVER_VERSION = "1.0.0"
# Tool ์ •์˜ (MCP ํ‘œ์ค€ ํ˜•์‹) - Phase 4 ํ™•์žฅ: ์—ฌํ–‰ ํ”Œ๋žซํผ (ํ˜ธํ…”/ํ•ญ๊ณต/์นด๋“œ/๋‰ด์Šค)
# โš ๏ธ ์ค‘์š”: ์ด ์ •์˜๋Š” src/mcp/tools.py์˜ TOOLS์™€ ๋™๊ธฐํ™”๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค!
TOOLS = [
{
"name": "kb_search",
"description": (
"Search the travel benefits knowledge base using vector search. "
"Covers hotel loyalty programs, airline mileage programs, credit card benefits, and travel deals/news. "
"Supports both Korean and English queries."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (Korean or English)"
},
"domain": {
"type": "string",
"enum": ["hotel", "airline", "card", "news", "all"],
"description": "Filter by travel domain: hotel, airline, card, news (deals/promotions), or all (default)"
},
"chain": {
"type": "string",
"description": "Filter by chain/airline/card issuer. Comma-separated for multiple (e.g., 'MARRIOTT,HILTON'). "
"Hotels: IHG, MARRIOTT, ACCOR, HILTON, HYATT. "
"Airlines: KOREAN_AIR, ASIANA, DELTA, UNITED. "
"Cards: AMEX, SHINHAN, HYUNDAI, KB, LOTTE, HANA, SAMSUNG, WOORI"
},
"type": {
"type": "string",
"enum": [
"loyalty_program", "membership_tier", "credit_card",
"subscription_program", "points_system", "milestone_program",
"airline_program", "airline_tier", "award_chart",
"hotel_property", "tier_implementation", "best_rate_guarantee",
"deal_alert", "news_update", "channel_benefit", "member_rate"
],
"description": "Filter by document type (optional)"
},
"threshold": {
"type": "number",
"default": 0.3,
"minimum": 0.1,
"maximum": 0.9,
"description": "Similarity threshold (0.1-0.9). Lower = more results, higher = more precise."
},
"limit": {
"type": "integer",
"default": 5,
"minimum": 1,
"maximum": 20,
"description": "Maximum number of results to return"
}
},
"required": ["query"]
}
},
{
"name": "kb_get_document",
"description": (
"Retrieve the full content of a specific document. "
"Includes original metadata and extracted knowledge."
),
"inputSchema": {
"type": "object",
"properties": {
"doc_id": {
"type": "string",
"description": "Unique document ID (obtained from search results)"
}
},
"required": ["doc_id"]
}
},
{
"name": "kb_get_context",
"description": (
"Search the travel benefits knowledge base and return relevant raw text context. "
"Covers hotel loyalty, airline mileage, credit card benefits, and travel deals/news. "
"The client AI generates the answer based on this context. "
"This is a Thin Server pattern - no AI API cost on server side."
),
"inputSchema": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "User question (Korean or English)"
},
"domain": {
"type": "string",
"enum": ["hotel", "airline", "card", "news", "all"],
"description": "Filter by travel domain: hotel, airline, card, news (deals/promotions), or all (default)"
},
"chain": {
"type": "string",
"description": "Filter by chain/airline/card issuer. Comma-separated for multiple (e.g., 'MARRIOTT,HILTON')"
},
"threshold": {
"type": "number",
"default": 0.3,
"minimum": 0.1,
"maximum": 0.9,
"description": "Similarity threshold. Lower = more results."
},
"limit": {
"type": "integer",
"default": 5,
"minimum": 1,
"maximum": 10,
"description": "Number of context chunks to return"
}
},
"required": ["question"]
}
},
{
"name": "kb_get_metadata",
"description": (
"Get available filter values (chains, card issuers, data types) in the knowledge base. "
"Use this to understand the KB structure before complex searches."
),
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
# ============================================================
# Travel Utility Tools (ํ™˜์œจ, ์‹œ๊ฐ„)
# ============================================================
{
"name": "get_current_time",
"description": (
"Get current time for a specific timezone. "
"Useful for travel planning and scheduling."
),
"inputSchema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"default": "Asia/Seoul",
"description": "IANA timezone (e.g., Asia/Seoul, Asia/Tokyo, Europe/Paris)"
}
},
"required": []
}
},
{
"name": "get_exchange_rates",
"description": (
"Get current exchange rates. Uses Frankfurter API (ECB data) as primary, "
"Exchange Rate API as fallback for VND, TWD, etc. "
"Always shows the source and timestamp of rates."
),
"inputSchema": {
"type": "object",
"properties": {
"base_currency": {
"type": "string",
"default": "USD",
"description": "Base currency code (e.g., USD, EUR, KRW)"
},
"target_currencies": {
"type": "string",
"description": "Comma-separated target currencies (e.g., 'KRW,JPY,VND'). If omitted, returns major travel currencies."
}
},
"required": []
}
},
{
"name": "convert_currency",
"description": (
"Convert an amount from one currency to another. "
"Shows the exchange rate used in the calculation. "
"Users can provide a custom_rate to recalculate with a different rate."
),
"inputSchema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "Amount to convert"
},
"from_currency": {
"type": "string",
"description": "Source currency code (e.g., USD)"
},
"to_currency": {
"type": "string",
"description": "Target currency code (e.g., KRW)"
},
"custom_rate": {
"type": "number",
"description": "Optional: Use this exchange rate instead of API rate"
}
},
"required": ["amount", "from_currency", "to_currency"]
}
},
{
"name": "get_travel_info",
"description": (
"Get comprehensive travel info including current time and exchange rates. "
"Useful for quick travel overview."
),
"inputSchema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"default": "Asia/Seoul",
"description": "Timezone for current time"
},
"base_currency": {
"type": "string",
"default": "USD",
"description": "Base currency for exchange rates"
}
},
"required": []
}
},
# ============================================================
# User Tools (User Gate - ์‚ฌ์šฉ์ž ์ธ์ฆ/๋ฉค๋ฒ„์‹ญ)
# ============================================================
{
"name": "user_get_profile",
"description": (
"ํ˜„์žฌ ์‚ฌ์šฉ์ž์˜ ํ”„๋กœํ•„๊ณผ ๋ฉค๋ฒ„์‹ญ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. "
"์—ฐ๊ฒฐ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ auth_url์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค."
),
"inputSchema": {
"type": "object",
"properties": {
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ (์ด์ „ ์ธ์ฆ์—์„œ ๋ฐ›์€ ๊ฐ’)"
}
},
"required": []
}
},
{
"name": "user_update_membership",
"description": "ํ˜ธํ…” ์ฒด์ธ ๋ฉค๋ฒ„์‹ญ์„ ๋“ฑ๋กํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.",
"inputSchema": {
"type": "object",
"properties": {
"chain": {
"type": "string",
"enum": ["HILTON", "MARRIOTT", "IHG", "ACCOR", "HYATT"],
"description": "ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ"
},
"tier": {
"type": "string",
"description": "๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ (์˜ˆ: Gold, Platinum, Diamond)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["chain", "tier", "session_token"]
}
},
{
"name": "user_delete_membership",
"description": "ํ˜ธํ…” ์ฒด์ธ ๋ฉค๋ฒ„์‹ญ์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.",
"inputSchema": {
"type": "object",
"properties": {
"chain": {
"type": "string",
"enum": ["HILTON", "MARRIOTT", "IHG", "ACCOR", "HYATT"],
"description": "์‚ญ์ œํ•  ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["chain", "session_token"]
}
},
{
"name": "user_request_auth",
"description": (
"Magic Link ์ธ์ฆ์„ ์š”์ฒญํ•ฉ๋‹ˆ๋‹ค. "
"์ด๋ฉ”์ผ๋กœ ๋กœ๊ทธ์ธ ๋งํฌ๊ฐ€ ๋ฐœ์†ก๋ฉ๋‹ˆ๋‹ค."
),
"inputSchema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "์ธ์ฆ์— ์‚ฌ์šฉํ•  ์ด๋ฉ”์ผ ์ฃผ์†Œ"
}
},
"required": ["email"]
}
},
{
"name": "user_verify_code",
"description": "Magic Link ์ธ์ฆ ํ›„ ๋ฐ›์€ 6์ž๋ฆฌ ์ฝ”๋“œ๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.",
"inputSchema": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "6์ž๋ฆฌ ์ธ์ฆ ์ฝ”๋“œ"
}
},
"required": ["code"]
}
},
# ============================================================
# Credit Card Tools (์‹ ์šฉ์นด๋“œ ๊ด€๋ฆฌ/ํ˜œํƒ ์ถ”์ฒœ)
# ============================================================
{
"name": "user_add_credit_card",
"description": (
"๋ณด์œ  ์‹ ์šฉ์นด๋“œ๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค. "
"๋“ฑ๋ก๋œ ์นด๋“œ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํฌ๋ ˆ๋”ง/ํ˜œํƒ์„ ์ถ”์ ํ•˜๊ณ  ์ถ”์ฒœ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
),
"inputSchema": {
"type": "object",
"properties": {
"card_id": {
"type": "string",
"description": (
"์นด๋“œ ๊ณ ์œ  ID. ์˜ˆ: AMEX_PLATINUM_US, CHASE_SAPPHIRE_RESERVE. "
"์ง€์› ๋ฐœ๊ธ‰์‚ฌ: AMEX, CHASE, CITI, CAPITAL_ONE"
)
},
"card_name": {
"type": "string",
"description": "์นด๋“œ ์ด๋ฆ„ (์„ ํƒ, card_id๋กœ ์ž๋™ ์ถ”๋ก  ๊ฐ€๋Šฅ)"
},
"issuer_code": {
"type": "string",
"description": "๋ฐœ๊ธ‰์‚ฌ ์ฝ”๋“œ (์„ ํƒ, card_id๋กœ ์ž๋™ ์ถ”๋ก  ๊ฐ€๋Šฅ)"
},
"region": {
"type": "string",
"default": "USA",
"description": "๋ฐœ๊ธ‰ ๊ตญ๊ฐ€ (๊ธฐ๋ณธ: USA)"
},
"card_open_date": {
"type": "string",
"description": "์นด๋“œ ๊ฐœ์„ค์ผ (์„ ํƒ, YYYY-MM-DD)"
},
"anniversary_month": {
"type": "integer",
"minimum": 1,
"maximum": 12,
"description": "์—ฐํšŒ๋น„ ๊ฐฑ์‹ /์ฒญ๊ตฌ ์›” (์„ ํƒ, 1-12)"
},
"annual_fee": {
"type": "number",
"description": "์—ฐํšŒ๋น„ ๊ธˆ์•ก (์„ ํƒ)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["card_id", "session_token"]
}
},
{
"name": "user_get_credit_cards",
"description": "๋“ฑ๋ก๋œ ๋ณด์œ  ์‹ ์šฉ์นด๋“œ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.",
"inputSchema": {
"type": "object",
"properties": {
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["session_token"]
}
},
{
"name": "user_delete_credit_card",
"description": "๋“ฑ๋ก๋œ ์‹ ์šฉ์นด๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.",
"inputSchema": {
"type": "object",
"properties": {
"card_id": {
"type": "string",
"description": "์‚ญ์ œํ•  ์นด๋“œ ID"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["card_id", "session_token"]
}
},
{
"name": "user_update_credit_usage",
"description": (
"์‹ ์šฉ์นด๋“œ ํฌ๋ ˆ๋”ง/ํ˜œํƒ ์‚ฌ์šฉ ๊ธฐ๋ก์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. "
"์˜ˆ: AMEX Platinum FHR ํฌ๋ ˆ๋”ง $300 ์‚ฌ์šฉ ์™„๋ฃŒ"
),
"inputSchema": {
"type": "object",
"properties": {
"card_id": {
"type": "string",
"description": "์นด๋“œ ID (์˜ˆ: AMEX_PLATINUM_US)"
},
"benefit_id": {
"type": "string",
"description": (
"ํ˜œํƒ ID. ์˜ˆ: amex_plat_hotel_credit (FHR), "
"amex_plat_saks_credit, amex_plat_uber_cash, "
"amex_plat_digital_entertainment, amex_plat_resy_credit"
)
},
"amount_used": {
"type": "number",
"description": "์‚ฌ์šฉ ๊ธˆ์•ก (์„ ํƒ, ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ํ•œ๋„๋กœ ๊ธฐ๋ก)"
},
"usage_date": {
"type": "string",
"description": "์‚ฌ์šฉ์ผ (์„ ํƒ, YYYY-MM-DD, ๋ฏธ์ž…๋ ฅ ์‹œ ์˜ค๋Š˜)"
},
"description": {
"type": "string",
"description": "์‚ฌ์šฉ ๋‚ด์—ญ ๋ฉ”๋ชจ (์„ ํƒ)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["card_id", "benefit_id", "session_token"]
}
},
{
"name": "user_get_credit_recommendations",
"description": (
"ํ˜„์žฌ ์‹œ์ ์—์„œ ์‚ฌ์šฉํ•ด์•ผ ํ•  ํฌ๋ ˆ๋”ง/ํ˜œํƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. "
"๊ธฐ๊ฐ„๋ณ„ ๋งˆ๊ฐ์ผ์ด ์ž„๋ฐ•ํ•œ ํ˜œํƒ์„ ์šฐ์„ ์ˆœ์œ„๋กœ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. "
"์˜ˆ: 'AMEX Platinum FHR ์ƒ๋ฐ˜๊ธฐ ํฌ๋ ˆ๋”ง $300 - 6์›” 30์ผ ๋งˆ๊ฐ'"
),
"inputSchema": {
"type": "object",
"properties": {
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["session_token"]
}
},
{
"name": "user_get_credit_usage_summary",
"description": (
"ํŠน์ • ์นด๋“œ ๋˜๋Š” ์ „์ฒด ์นด๋“œ์˜ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ํ˜„ํ™ฉ ์š”์•ฝ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. "
"๊ฐ ํ˜œํƒ๋ณ„ ์‚ฌ์šฉ/์ž”์—ฌ ๊ธˆ์•ก๊ณผ ๋น„์œจ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค."
),
"inputSchema": {
"type": "object",
"properties": {
"card_id": {
"type": "string",
"description": "ํŠน์ • ์นด๋“œ ID๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ, ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ์นด๋“œ)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["session_token"]
}
},
# ============================================================
# Shadow Valuation Tools (ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ๊ฐ€์น˜ ํ‰๊ฐ€)
# ============================================================
{
"name": "user_get_asset_valuation",
"description": (
"๋ณด์œ  ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€์˜ ํ˜„์žฌ ๊ฐ€์น˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. "
"์‚ฌ์šฉ์ž์˜ ์—ฌํ–‰ ์Šคํƒ€์ผ(PREMIUM/VALUE/CASHBACK)์— ๋”ฐ๋ผ ๋™์ผํ•œ ํฌ์ธํŠธ๋„ ๋‹ค๋ฅธ ๊ฐ€์น˜๋กœ ํ‰๊ฐ€๋ฉ๋‹ˆ๋‹ค. "
"์˜ˆ: AMEX MR 50,000์ ์€ PREMIUM ์Šคํƒ€์ผ์—์„œ $1,100, CASHBACK ์Šคํƒ€์ผ์—์„œ $300๋กœ ํ‰๊ฐ€๋ฉ๋‹ˆ๋‹ค."
),
"inputSchema": {
"type": "object",
"properties": {
"assets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"program": {
"type": "string",
"description": "ํ”„๋กœ๊ทธ๋žจ ID (์˜ˆ: AMEX_MR, CHASE_UR, KOREAN_AIR, MARRIOTT_BONVOY)"
},
"amount": {
"type": "number",
"description": "ํฌ์ธํŠธ/๋งˆ์ผ ์ˆ˜๋Ÿ‰"
}
},
"required": ["program", "amount"]
},
"description": "ํ‰๊ฐ€ํ•  ์ž์‚ฐ ๋ชฉ๋ก"
},
"style": {
"type": "string",
"enum": ["PREMIUM", "VALUE", "CASHBACK"],
"description": "์ด๋ฒˆ ๊ณ„์‚ฐ์— ์‚ฌ์šฉํ•  ์Šคํƒ€์ผ (์„ ํƒ, ๋ฏธ์ž…๋ ฅ ์‹œ ์ €์žฅ๋œ ์‚ฌ์šฉ์ž ์Šคํƒ€์ผ ์‚ฌ์šฉ)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["assets", "session_token"]
}
},
{
"name": "user_update_valuation_style",
"description": (
"ํฌ์ธํŠธ ๊ฐ€์น˜ ํ‰๊ฐ€์— ์‚ฌ์šฉํ•  ์—ฌํ–‰ ์Šคํƒ€์ผ์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. "
"PREMIUM: ๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธ ๋ฐœ๊ถŒ ๋ชฉํ‘œ (๋†’์€ ๊ฐ€์น˜), "
"VALUE: ์ผ๋ฐ˜ ์‚ฌ์šฉ (ํ‘œ์ค€ ๊ฐ€์น˜), "
"CASHBACK: ํ˜„๊ธˆ์„ฑ ์‚ฌ์šฉ ์„ ํ˜ธ (๋‚ฎ์€ ๊ฐ€์น˜)"
),
"inputSchema": {
"type": "object",
"properties": {
"style": {
"type": "string",
"enum": ["PREMIUM", "VALUE", "CASHBACK"],
"description": "์„ค์ •ํ•  ์—ฌํ–‰ ์Šคํƒ€์ผ"
},
"custom_valuations": {
"type": "object",
"description": (
"๊ฐœ๋ณ„ ํ”„๋กœ๊ทธ๋žจ ๊ฐ€์น˜ ์˜ค๋ฒ„๋ผ์ด๋“œ (์„ ํƒ). "
"์˜ˆ: {\"AMEX_MR\": 2.5, \"KOREAN_AIR\": 20}"
)
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["session_token"]
}
},
{
"name": "user_get_valuation_styles",
"description": (
"์ง€์›ํ•˜๋Š” ์—ฌํ–‰ ์Šคํƒ€์ผ๊ณผ ํฌ์ธํŠธ ํ”„๋กœ๊ทธ๋žจ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. "
"๊ฐ ์Šคํƒ€์ผ๋ณ„ ์˜ˆ์‹œ ๊ฐ€์น˜์™€ ํ˜„์žฌ ์‚ฌ์šฉ์ž ์„ค์ •์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
),
"inputSchema": {
"type": "object",
"properties": {
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["session_token"]
}
},
# ============================================================
# Asset Parser / Calculator / Deeplink Tools
# ============================================================
{
"name": "user_parse_asset_text",
"description": (
"์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ํ…์ŠคํŠธ์—์„œ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ์ •๋ณด๋ฅผ ์ž๋™์œผ๋กœ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. "
"์˜ˆ: '๋Œ€ํ•œํ•ญ๊ณต 45000 ๋งˆ์ผ, AMEX MR 50000์ ' โ†’ ๊ตฌ์กฐํ™”๋œ ์ž์‚ฐ ๋ชฉ๋ก์œผ๋กœ ๋ณ€ํ™˜"
),
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": (
"ํŒŒ์‹ฑํ•  ํ…์ŠคํŠธ. ํ”„๋กœ๊ทธ๋žจ๋ช…๊ณผ ์ˆซ์ž๋ฅผ ํฌํ•จํ•ด์ฃผ์„ธ์š”. "
"์˜ˆ: '๋Œ€ํ•œํ•ญ๊ณต 45000\\n์•„์‹œ์•„๋‚˜ 12000\\nAMEX MR 50000'"
)
},
"save_to_profile": {
"type": "boolean",
"default": False,
"description": "ํŒŒ์‹ฑ๋œ ์ž์‚ฐ์„ ํ”„๋กœํ•„์— ์ €์žฅํ• ์ง€ ์—ฌ๋ถ€ (ํ–ฅํ›„ ๊ธฐ๋Šฅ)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["text", "session_token"]
}
},
{
"name": "calculate_miles_vs_cashback",
"description": (
"๋งˆ์ผ๋ฆฌ์ง€ ์ ๋ฆฝ ์นด๋“œ vs ํ˜„๊ธˆ ํ• ์ธ ์นด๋“œ ์ค‘ ์–ด๋А ๊ฒƒ์ด ๋” ์œ ๋ฆฌํ•œ์ง€ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. "
"์˜ˆ: 10๋งŒ์› ๊ฒฐ์ œ ์‹œ '1000์›๋‹น 1๋งˆ์ผ' vs '1.5% ํ• ์ธ' ๋น„๊ต"
),
"inputSchema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "๊ฒฐ์ œ ๊ธˆ์•ก (์›)"
},
"mile_rate": {
"type": "number",
"default": 1,
"description": "์ ๋ฆฝ ๋งˆ์ผ ์ˆ˜ (๊ธฐ๋ณธ: 1)"
},
"mile_per": {
"type": "number",
"default": 1000,
"description": "๋งˆ์ผ ์ ๋ฆฝ ๊ธฐ์ค€ ๊ธˆ์•ก (์›, ๊ธฐ๋ณธ: 1000)"
},
"mile_program": {
"type": "string",
"default": "KOREAN_AIR",
"description": "๋งˆ์ผ๋ฆฌ์ง€ ํ”„๋กœ๊ทธ๋žจ (๊ธฐ๋ณธ: KOREAN_AIR)"
},
"discount_percent": {
"type": "number",
"default": 1.5,
"description": "ํ• ์ธ์œจ (%, ๊ธฐ๋ณธ: 1.5)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["amount", "session_token"]
}
},
{
"name": "generate_award_search_link",
"description": (
"ํŠน๊ฐ€์„ ๊ฒ€์ƒ‰์„ ์œ„ํ•œ Seats.aero/Point.me URL์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. "
"๋„์‹œ๋ช…(ํ•œ๊ธ€/์˜๋ฌธ)์„ ๊ณตํ•ญ ์ฝ”๋“œ๋กœ ์ž๋™ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. "
"์˜ˆ: '์„œ์šธ โ†’ ํŒŒ๋ฆฌ ๋น„์ฆˆ๋‹ˆ์Šค' โ†’ ๊ฒ€์ƒ‰ URL ์ƒ์„ฑ"
),
"inputSchema": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"default": "ICN",
"description": "์ถœ๋ฐœ์ง€ (๋„์‹œ๋ช… ๋˜๋Š” ๊ณตํ•ญ ์ฝ”๋“œ, ๊ธฐ๋ณธ: ICN)"
},
"destination": {
"type": "string",
"description": "๋ชฉ์ ์ง€ (๋„์‹œ๋ช… ๋˜๋Š” ๊ณตํ•ญ ์ฝ”๋“œ)"
},
"date": {
"type": "string",
"description": "๋‚ ์งœ (YYYY-MM ๋˜๋Š” YYYY-MM-DD ํ˜•์‹, ๋ฏธ์ž…๋ ฅ ์‹œ ์œ ์—ฐ ๊ฒ€์ƒ‰)"
},
"cabin_class": {
"type": "string",
"default": "J",
"description": "์ขŒ์„ ํด๋ž˜์Šค (Y/W/J/F ๋˜๋Š” ์ด์ฝ”๋…ธ๋ฏธ/๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธ)"
},
"session_token": {
"type": "string",
"description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
}
},
"required": ["destination", "session_token"]
}
}
]
# ============================================================
# 1.5 MCP Prompts Definition (ํด๋ผ์ด์–ธํŠธ AI ์ถ”๋ก  ๊ฐ€์ด๋“œ)
# ============================================================
PROMPTS = [
{
"name": "hotel_dining_expert",
"description": (
"ํ˜ธํ…” ๋‹ค์ด๋‹ ์ „๋ฌธ๊ฐ€ ๋ชจ๋“œ. ํŠน์ • ํ˜ธํ…”์—์„œ ์‹์‚ฌํ•  ๋•Œ "
"์ตœ์ ์˜ ํ˜œํƒ์„ ๋ฐ›๋Š” ๋ฐฉ๋ฒ•์„ ๋‹จ๊ณ„๋ณ„๋กœ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. "
"์ฒด์ธ ํ™•์ธ โ†’ ๋ฉค๋ฒ„์‹ญ ํ˜œํƒ โ†’ ๋“ฑ๊ธ‰ ํš๋“ ์ „๋žต๊นŒ์ง€ ์ข…ํ•ฉ ๋ถ„์„."
),
"arguments": [
{
"name": "hotel_name",
"description": "์กฐํšŒํ•  ํ˜ธํ…”๋ช… (์˜ˆ: ์ฝ˜๋ž˜๋“œ ์„œ์šธ, JW ๋ฉ”๋ฆฌ์–ดํŠธ ์„œ์šธ)",
"required": True
},
{
"name": "dining_intent",
"description": "์‹์‚ฌ ๋ชฉ์  (์˜ˆ: ์กฐ์‹, ๋Ÿฐ์น˜, ๋””๋„ˆ, ๋ผ์šด์ง€)",
"required": False
}
]
},
{
"name": "status_match_advisor",
"description": (
"Status Match ์ „๋žต ๊ณ ๋ฌธ. ์›ํ•˜๋Š” ํ˜ธํ…” ์ฒด์ธ์˜ ์—˜๋ฆฌํŠธ ๋“ฑ๊ธ‰์„ "
"๊ฐ€์žฅ ๋น ๋ฅด๊ณ  ์ €๋ ดํ•˜๊ฒŒ ํš๋“ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. "
"์‹ ์šฉ์นด๋“œ, ์œ ๋ฃŒ ๋ฉค๋ฒ„์‹ญ, Status Match ๊ฒฝ๋กœ๋ฅผ ์ข…ํ•ฉ ๋ถ„์„."
),
"arguments": [
{
"name": "target_chain",
"description": "๋ชฉํ‘œ ํ˜ธํ…” ์ฒด์ธ (์˜ˆ: HILTON, MARRIOTT, IHG, ACCOR)",
"required": True
},
{
"name": "current_status",
"description": "ํ˜„์žฌ ๋ณด์œ ํ•œ ํ˜ธํ…” ๋ฉค๋ฒ„์‹ญ/๋“ฑ๊ธ‰ (์—†์œผ๋ฉด ์ƒ๋žต)",
"required": False
}
]
},
{
"name": "hotel_benefit_analyzer",
"description": (
"ํ˜ธํ…” ํ˜œํƒ ๋ถ„์„๊ธฐ. ํŠน์ • ํ˜ธํ…”์—์„œ ํˆฌ์ˆ™ํ•  ๋•Œ "
"๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰๋ณ„๋กœ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ํ˜œํƒ์„ ์ƒ์„ธํžˆ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. "
"์กฐ์‹, ๋ผ์šด์ง€, ์—…๊ทธ๋ ˆ์ด๋“œ, ๋ ˆ์ดํŠธ ์ฒดํฌ์•„์›ƒ ๋“ฑ."
),
"arguments": [
{
"name": "hotel_name",
"description": "์กฐํšŒํ•  ํ˜ธํ…”๋ช…",
"required": True
},
{
"name": "membership_tier",
"description": "ํ™•์ธํ•  ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ (์˜ˆ: Gold, Platinum, Diamond)",
"required": False
}
]
}
]
def get_prompt_messages(prompt_name: str, arguments: Dict[str, Any]) -> list:
"""ํ”„๋กฌํ”„ํŠธ๋ณ„ ์‹œ์Šคํ…œ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ"""
if prompt_name == "hotel_dining_expert":
hotel_name = arguments.get("hotel_name", "ํ˜ธํ…”")
dining_intent = arguments.get("dining_intent", "์‹์‚ฌ")
return [
{
"role": "user",
"content": {
"type": "text",
"text": f"""๋‹น์‹ ์€ ํ˜ธํ…” ๋‹ค์ด๋‹ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ {hotel_name}์—์„œ {dining_intent}์„ ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.
๋‹ค์Œ ๋‹จ๊ณ„์— ๋”ฐ๋ผ ๋ถ„์„ํ•˜์„ธ์š”. ๊ฐ ๋‹จ๊ณ„์—์„œ kb_search ๋˜๋Š” kb_get_context ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•˜์„ธ์š”:
## 1๋‹จ๊ณ„: ํ˜ธํ…” ์ฒด์ธ ํ™•์ธ
- "{hotel_name}" ๊ฒ€์ƒ‰์œผ๋กœ ์–ด๋А ์ฒด์ธ(ํžํŠผ, ๋ฉ”๋ฆฌ์–ดํŠธ, IHG, ์•„์ฝ”๋ฅด ๋“ฑ) ์†Œ์†์ธ์ง€ ํ™•์ธํ•˜์„ธ์š”.
## 2๋‹จ๊ณ„: ๋‹ค์ด๋‹ ํ˜œํƒ ๊ฒ€์ƒ‰
- ํ•ด๋‹น ์ฒด์ธ์˜ ๋ฉค๋ฒ„์‹ญ ๋‹ค์ด๋‹ ํ˜œํƒ์„ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”.
- ์˜ˆ: "ํžํŠผ ๋‹ค์ด๋‹ ํ• ์ธ", "๋ฉ”๋ฆฌ์–ดํŠธ ๋ ˆ์Šคํ† ๋ž‘ ํ˜œํƒ" ๋“ฑ
- ํ• ์ธ์œจ, ์ ๋ฆฝ ํฌ์ธํŠธ, ์ ์šฉ ์กฐ๊ฑด ๋“ฑ์„ ํŒŒ์•…ํ•˜์„ธ์š”.
## 3๋‹จ๊ณ„: ๋“ฑ๊ธ‰ ํš๋“ ์ „๋žต (์ค‘์š”!)
- ์‚ฌ์šฉ์ž๊ฐ€ ํ•ด๋‹น ์ฒด์ธ์˜ ๋“ฑ๊ธ‰์ด ์—†์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
- ๋‹ค์Œ์„ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”:
a) ์‹ ์šฉ์นด๋“œ๋กœ ์ž๋™ ๋ถ€์—ฌ๋˜๋Š” ๋“ฑ๊ธ‰ (์˜ˆ: "์•„๋ฉ•์Šค ํ”Œ๋ž˜ํ‹ฐ๋„˜ ํžํŠผ ๊ณจ๋“œ")
b) Status Match ๊ฐ€๋Šฅ ์—ฌ๋ถ€ (์˜ˆ: "IHG ambassador status match ํžํŠผ")
c) ์œ ๋ฃŒ ๋ฉค๋ฒ„์‹ญ (์˜ˆ: "IHG ์•ฐ๋ฒ„์„œ๋”", "์•„์ฝ”๋ฅด ํ”Œ๋Ÿฌ์Šค")
## 4๋‹จ๊ณ„: ์ข…ํ•ฉ ๋‹ต๋ณ€
- ์œ„ ์ •๋ณด๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ์ตœ์ ์˜ ๋‹ค์ด๋‹ ์ „๋žต์„ ์ œ์•ˆํ•˜์„ธ์š”.
- "๋“ฑ๊ธ‰์ด ์—†๋‹ค๋ฉด ~ํ•˜๋Š” ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค" ํ˜•์‹์œผ๋กœ ์•ˆ๋‚ดํ•˜์„ธ์š”.
์ง€๊ธˆ ๋ถ„์„์„ ์‹œ์ž‘ํ•˜์„ธ์š”."""
}
}
]
elif prompt_name == "status_match_advisor":
target_chain = arguments.get("target_chain", "ํ˜ธํ…” ์ฒด์ธ")
current_status = arguments.get("current_status", "์—†์Œ")
return [
{
"role": "user",
"content": {
"type": "text",
"text": f"""๋‹น์‹ ์€ ํ˜ธํ…” Status Match ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ {target_chain}์˜ ์—˜๋ฆฌํŠธ ๋“ฑ๊ธ‰์„ ํš๋“ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.
ํ˜„์žฌ ์ƒํƒœ: {current_status}
๋‹ค์Œ ๊ฒฝ๋กœ๋“ค์„ ์กฐ์‚ฌํ•˜๊ณ  ์ตœ์ ์˜ ๋ฐฉ๋ฒ•์„ ์ถ”์ฒœํ•˜์„ธ์š”:
## ๊ฒฝ๋กœ 1: ์‹ ์šฉ์นด๋“œ (๊ฐ€์žฅ ๋น ๋ฆ„)
- "{target_chain} ์‹ ์šฉ์นด๋“œ" ๊ฒ€์ƒ‰
- ์ž๋™ ๋ถ€์—ฌ ๋“ฑ๊ธ‰, ์—ฐํšŒ๋น„, ์ถ”๊ฐ€ ํ˜œํƒ ํŒŒ์•…
## ๊ฒฝ๋กœ 2: Status Match
- "status match {target_chain}" ๊ฒ€์ƒ‰
- ์–ด๋–ค ์ฒด์ธ์—์„œ ๋งค์นญ ๊ฐ€๋Šฅํ•œ์ง€, ์กฐ๊ฑด์€ ๋ฌด์—‡์ธ์ง€ ํŒŒ์•…
## ๊ฒฝ๋กœ 3: ์œ ๋ฃŒ ๋ฉค๋ฒ„์‹ญ โ†’ Status Match
- ์ €๋ ดํ•œ ์œ ๋ฃŒ ๋ฉค๋ฒ„์‹ญ์œผ๋กœ ๊ธฐ๋ณธ ๋“ฑ๊ธ‰ ํš๋“ ํ›„ ๋งค์นญํ•˜๋Š” ๋ฐฉ๋ฒ•
- ์˜ˆ: IHG Ambassador($225) โ†’ ํƒ€ ์ฒด์ธ ๋งค์นญ
## ๊ฒฝ๋กœ 4: ์ฑŒ๋ฆฐ์ง€/ํ”„๋กœ๋ชจ์…˜
- ์‹ ๊ทœ ๊ฐ€์ž…์ž ํ”„๋กœ๋ชจ์…˜์ด๋‚˜ ์Šคํ…Œ์ดํ„ฐ์Šค ์ฑŒ๋ฆฐ์ง€ ๊ฒ€์ƒ‰
๊ฐ ๊ฒฝ๋กœ์˜ ๋น„์šฉ, ์†Œ์š” ์‹œ๊ฐ„, ๋‚œ์ด๋„๋ฅผ ๋น„๊ตํ•˜์—ฌ ์ตœ์ ์˜ ๋ฐฉ๋ฒ•์„ ์ถ”์ฒœํ•˜์„ธ์š”."""
}
}
]
elif prompt_name == "hotel_benefit_analyzer":
hotel_name = arguments.get("hotel_name", "ํ˜ธํ…”")
membership_tier = arguments.get("membership_tier")
tier_query = f" {membership_tier}" if membership_tier else ""
return [
{
"role": "user",
"content": {
"type": "text",
"text": f"""๋‹น์‹ ์€ ํ˜ธํ…” ํ˜œํƒ ๋ถ„์„ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. {hotel_name}์˜{tier_query} ๋ฉค๋ฒ„์‹ญ ํ˜œํƒ์„ ๋ถ„์„ํ•ด์ฃผ์„ธ์š”.
## 1๋‹จ๊ณ„: ํ˜ธํ…” ์ •๋ณด ํ™•์ธ
- "{hotel_name}" ๊ฒ€์ƒ‰์œผ๋กœ ํ˜ธํ…” ์ƒ์„ธ ์ •๋ณด ํ™•์ธ
- ์†Œ์† ์ฒด์ธ, ๋ธŒ๋žœ๋“œ, ๋ ˆ์Šคํ† ๋ž‘, ๋ผ์šด์ง€ ์ •๋ณด ํŒŒ์•…
## 2๋‹จ๊ณ„: ๋“ฑ๊ธ‰๋ณ„ ํ˜œํƒ ๊ฒ€์ƒ‰
- ํ•ด๋‹น ํ˜ธํ…”/์ฒด์ธ์˜ ๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰๋ณ„ ํ˜œํƒ ๊ฒ€์ƒ‰
- ์กฐ์‹, ๋ผ์šด์ง€ ์ ‘๊ทผ๊ถŒ, ๊ฐ์‹ค ์—…๊ทธ๋ ˆ์ด๋“œ, ๋ ˆ์ดํŠธ ์ฒดํฌ์•„์›ƒ ๋“ฑ
## 3๋‹จ๊ณ„: ์‹ค์ œ ์ ์šฉ ์ƒํ™ฉ
- ํ•ด๋‹น ํ˜ธํ…”์—์„œ ํ˜œํƒ์ด ์‹ค์ œ๋กœ ์–ด๋–ป๊ฒŒ ์ ์šฉ๋˜๋Š”์ง€ ํ™•์ธ
- ์˜ˆ: "์กฐ์‹์€ ~๋ ˆ์Šคํ† ๋ž‘์—์„œ ์ œ๊ณต", "๋ผ์šด์ง€ ์ด์šฉ์‹œ๊ฐ„์€ ~"
ํ‘œ ํ˜•์‹์œผ๋กœ ์ •๋ฆฌํ•˜์—ฌ ์ œ๊ณตํ•˜์„ธ์š”."""
}
}
]
else:
return [
{
"role": "user",
"content": {
"type": "text",
"text": f"ํ”„๋กฌํ”„ํŠธ '{prompt_name}'์— ๋Œ€ํ•œ ์ •์˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."
}
}
]
class SessionManager:
"""MCP ์„ธ์…˜ ๊ด€๋ฆฌ์ž"""
def __init__(self):
self.sessions: Dict[str, Dict[str, Any]] = {}
def create_session(self) -> str:
"""์ƒˆ ์„ธ์…˜ ์ƒ์„ฑ"""
session_id = str(uuid.uuid4()).replace("-", "")
self.sessions[session_id] = {
"created_at": datetime.utcnow().isoformat(),
"initialized": False,
"client_info": None
}
logger.info(f"Session created: {session_id[:8]}...")
return session_id
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""์„ธ์…˜ ์กฐํšŒ"""
return self.sessions.get(session_id)
def update_session(self, session_id: str, data: Dict[str, Any]):
"""์„ธ์…˜ ์—…๋ฐ์ดํŠธ"""
if session_id in self.sessions:
self.sessions[session_id].update(data)
def delete_session(self, session_id: str) -> bool:
"""์„ธ์…˜ ์‚ญ์ œ"""
if session_id in self.sessions:
del self.sessions[session_id]
logger.info(f"Session deleted: {session_id[:8]}...")
return True
return False
def is_valid(self, session_id: str) -> bool:
"""์„ธ์…˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ"""
return session_id in self.sessions
session_manager = SessionManager()
# ============================================================
# 3. Tool Handler (Lazy Loading)
# ============================================================
_handler = None
def get_handler():
"""Lazy ๋กœ๋”ฉ๋œ KB Tool Handler"""
global _handler
if _handler is None:
try:
from src.mcp.tools import get_handler as _get_handler
_handler = _get_handler()
logger.info("KB Handler initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize handler: {e}")
raise
return _handler
def execute_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Tool ์‹คํ–‰"""
handler = get_handler()
if name == "kb_search":
return handler.handle_search(**arguments)
elif name == "kb_get_document":
return handler.handle_get_document(**arguments)
elif name == "kb_get_context":
return handler.handle_get_context(**arguments)
elif name == "kb_get_metadata":
return handler.handle_get_metadata()
else:
return {"success": False, "error": f"Unknown tool: {name}"}
async def execute_tool_async(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""๋น„๋™๊ธฐ Tool ์‹คํ–‰ (travel utilities๋Š” async)"""
# KB ๋„๊ตฌ๋Š” ๋™๊ธฐ ์‹คํ–‰
if name.startswith("kb_"):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: execute_tool(name, arguments)
)
# Travel utility ๋„๊ตฌ๋Š” ๋น„๋™๊ธฐ ์‹คํ–‰
from src.utils.travel_utils import (
get_current_time,
get_exchange_rates,
convert_currency,
get_travel_info
)
if name == "get_current_time":
return await get_current_time(**arguments)
elif name == "get_exchange_rates":
return await get_exchange_rates(**arguments)
elif name == "convert_currency":
return await convert_currency(**arguments)
elif name == "get_travel_info":
return await get_travel_info(**arguments)
# User ๋„๊ตฌ (User Gate)
if name.startswith("user_"):
from src.auth.tool_handlers import execute_user_tool
return await execute_user_tool(name, arguments)
return {"success": False, "error": f"Unknown tool: {name}"}
# ============================================================
# 4. JSON-RPC Message Handlers
# ============================================================
async def handle_initialize(params: Dict[str, Any], session_id: str) -> Dict[str, Any]:
"""initialize ์š”์ฒญ ์ฒ˜๋ฆฌ"""
client_info = params.get("clientInfo", {})
protocol_version = params.get("protocolVersion", "unknown")
logger.info(f"Initialize from {client_info.get('name', 'unknown')} "
f"(protocol: {protocol_version})")
session_manager.update_session(session_id, {
"initialized": True,
"client_info": client_info,
"protocol_version": protocol_version
})
return {
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION
},
"capabilities": {
"tools": {"listChanged": False},
"resources": {"listChanged": False},
"prompts": {"listChanged": False}
}
}
async def handle_tools_list() -> Dict[str, Any]:
"""tools/list ์š”์ฒญ ์ฒ˜๋ฆฌ"""
logger.info(f"Returning {len(TOOLS)} tools")
return {"tools": TOOLS}
async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
"""tools/call ์š”์ฒญ ์ฒ˜๋ฆฌ"""
tool_name = params.get("name")
arguments = params.get("arguments", {})
logger.info(f"Tool call: {tool_name}")
if not tool_name:
raise ValueError("Tool name is required")
# ๋น„๋™๊ธฐ ๋„๊ตฌ ์‹คํ–‰ (travel utils๋Š” async, kb๋Š” executor)
result = await execute_tool_async(tool_name, arguments)
# ์‘๋‹ต ํฌ๊ธฐ ์ฒดํฌ (PlayMCP 24k ์ œํ•œ)
result_text = json.dumps(result, ensure_ascii=False, indent=2)
if len(result_text) > 23000: # ์•ฝ๊ฐ„์˜ ์—ฌ์œ 
logger.warning(f"Response size {len(result_text)} may exceed PlayMCP limit")
return {
"content": [
{
"type": "text",
"text": result_text
}
]
}
async def handle_resources_list() -> Dict[str, Any]:
"""resources/list ์š”์ฒญ ์ฒ˜๋ฆฌ"""
return {
"resources": [
{
"uri": "eodi://kb/stats",
"name": "KB Statistics",
"description": "Knowledge base statistics",
"mimeType": "application/json"
}
]
}
async def handle_prompts_list() -> Dict[str, Any]:
"""prompts/list ์š”์ฒญ ์ฒ˜๋ฆฌ"""
logger.info(f"Returning {len(PROMPTS)} prompts")
return {"prompts": PROMPTS}
async def handle_prompts_get(params: Dict[str, Any]) -> Dict[str, Any]:
"""prompts/get ์š”์ฒญ ์ฒ˜๋ฆฌ - ํ”„๋กฌํ”„ํŠธ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜"""
prompt_name = params.get("name")
arguments = params.get("arguments", {})
logger.info(f"Getting prompt: {prompt_name}")
if not prompt_name:
raise ValueError("Prompt name is required")
# ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ
prompt_exists = any(p["name"] == prompt_name for p in PROMPTS)
if not prompt_exists:
raise ValueError(f"Unknown prompt: {prompt_name}")
# ํ”„๋กฌํ”„ํŠธ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ
messages = get_prompt_messages(prompt_name, arguments)
return {
"description": next(
(p["description"] for p in PROMPTS if p["name"] == prompt_name),
""
),
"messages": messages
}
async def handle_message(message: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""JSON-RPC ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ"""
method = message.get("method", "")
params = message.get("params", {})
msg_id = message.get("id")
logger.info(f"<- {method}")
try:
if method == "initialize":
result = await handle_initialize(params, session_id)
elif method == "tools/list":
result = await handle_tools_list()
elif method == "tools/call":
result = await handle_tools_call(params)
elif method == "resources/list":
result = await handle_resources_list()
elif method == "prompts/list":
result = await handle_prompts_list()
elif method == "prompts/get":
result = await handle_prompts_get(params)
elif method == "notifications/initialized":
# ์•Œ๋ฆผ์€ ์‘๋‹ต ์—†์Œ
return None
elif method == "ping":
result = {}
else:
logger.warning(f"Unknown method: {method}")
if msg_id is not None:
return {
"jsonrpc": "2.0",
"id": msg_id,
"error": {
"code": -32601,
"message": f"Method not found: {method}"
}
}
return None
if msg_id is not None:
return {
"jsonrpc": "2.0",
"id": msg_id,
"result": result
}
return None
except Exception as e:
logger.error(f"Error handling {method}: {e}")
if msg_id is not None:
return {
"jsonrpc": "2.0",
"id": msg_id,
"error": {
"code": -32603,
"message": str(e)
}
}
return None
# ============================================================
# 5. HTTP Endpoints (Streamable HTTP Transport)
# ============================================================
async def mcp_endpoint(request: Request) -> Response:
"""
MCP Endpoint - Streamable HTTP Transport
POST: JSON-RPC ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ 
GET: SSE ์ŠคํŠธ๋ฆผ (์„œ๋ฒ„ โ†’ ํด๋ผ์ด์–ธํŠธ)
DELETE: ์„ธ์…˜ ์ข…๋ฃŒ
"""
# ์„ธ์…˜ ID ํ™•์ธ
session_id = request.headers.get("Mcp-Session-Id")
if request.method == "POST":
return await handle_post(request, session_id)
elif request.method == "GET":
return await handle_get(request, session_id)
elif request.method == "DELETE":
return await handle_delete(request, session_id)
elif request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "*",
}
)
else:
return JSONResponse(
{"error": "Method not allowed"},
status_code=405
)
async def handle_post(request: Request, session_id: Optional[str]) -> Response:
"""POST ์š”์ฒญ ์ฒ˜๋ฆฌ - JSON-RPC ๋ฉ”์‹œ์ง€"""
# Content-Type ํ™•์ธ
content_type = request.headers.get("Content-Type", "")
if "application/json" not in content_type:
return JSONResponse(
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Invalid content type"}},
status_code=400
)
# Accept ํ—ค๋” ํ™•์ธ
accept = request.headers.get("Accept", "")
wants_sse = "text/event-stream" in accept
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}},
status_code=400
)
# ๋ฐฐ์น˜ ์š”์ฒญ ์ฒ˜๋ฆฌ
is_batch = isinstance(body, list)
messages = body if is_batch else [body]
# Initialize ์š”์ฒญ์ธ์ง€ ํ™•์ธ (์„ธ์…˜ ์ƒ์„ฑ)
has_initialize = any(m.get("method") == "initialize" for m in messages)
if has_initialize:
# ์ƒˆ ์„ธ์…˜ ์ƒ์„ฑ
session_id = session_manager.create_session()
elif session_id and not session_manager.is_valid(session_id):
# ์œ ํšจํ•˜์ง€ ์•Š์€ ์„ธ์…˜
return JSONResponse(
{"jsonrpc": "2.0", "error": {"code": -32000, "message": "Invalid session"}},
status_code=404
)
elif not session_id and not has_initialize:
# ์„ธ์…˜ ์—†์ด ์ผ๋ฐ˜ ์š”์ฒญ (ํ—ˆ์šฉ - ์„ธ์…˜ ์—†์ด๋„ ๋™์ž‘)
session_id = session_manager.create_session()
# ๋ชจ๋“  ๋ฉ”์‹œ์ง€๊ฐ€ notification/response์ธ์ง€ ํ™•์ธ
all_notifications = all(
m.get("id") is None or "result" in m or "error" in m
for m in messages
)
if all_notifications:
# notification๋งŒ ์žˆ์œผ๋ฉด 202 Accepted
for msg in messages:
await handle_message(msg, session_id)
return Response(status_code=202)
# ์š”์ฒญ ์ฒ˜๋ฆฌ
responses = []
for msg in messages:
response = await handle_message(msg, session_id)
if response:
responses.append(response)
# ์‘๋‹ต ํ—ค๋”
headers = {"Access-Control-Allow-Origin": "*"}
# Initialize ์‘๋‹ต์—๋Š” ์„ธ์…˜ ID ํฌํ•จ
if has_initialize and session_id:
headers["Mcp-Session-Id"] = session_id
# SSE ๋˜๋Š” JSON ์‘๋‹ต
if wants_sse and len(responses) > 0:
# SSE ์ŠคํŠธ๋ฆผ์œผ๋กœ ์‘๋‹ต
async def generate_sse():
for resp in responses:
data = json.dumps(resp, ensure_ascii=False)
yield f"data: {data}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers=headers
)
else:
# JSON ์‘๋‹ต
if is_batch:
return JSONResponse(responses, headers=headers)
elif responses:
return JSONResponse(responses[0], headers=headers)
else:
return Response(status_code=202, headers=headers)
async def handle_get(request: Request, session_id: Optional[str]) -> Response:
"""GET ์š”์ฒญ ์ฒ˜๋ฆฌ - SSE ์ŠคํŠธ๋ฆผ (์„œ๋ฒ„ โ†’ ํด๋ผ์ด์–ธํŠธ)"""
# Accept ํ—ค๋” ํ™•์ธ
accept = request.headers.get("Accept", "")
if "text/event-stream" not in accept:
return JSONResponse(
{"error": "Accept header must include text/event-stream"},
status_code=400
)
# ์„ธ์…˜ ์—†์œผ๋ฉด SSE ๋ฏธ์ง€์› ์‘๋‹ต
if not session_id or not session_manager.is_valid(session_id):
return Response(status_code=405) # Method Not Allowed
# SSE ์ŠคํŠธ๋ฆผ (ํ˜„์žฌ๋Š” keep-alive๋งŒ)
async def generate_sse():
try:
while True:
# ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ ๋ณด๋‚ผ ๋ฉ”์‹œ์ง€๊ฐ€ ์žˆ์œผ๋ฉด ์ „์†ก
# ํ˜„์žฌ๋Š” ๋‹จ์ˆœ keep-alive
yield ": keep-alive\n\n"
await asyncio.sleep(30)
except asyncio.CancelledError:
logger.info(f"SSE stream closed for session {session_id[:8]}...")
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
)
async def handle_delete(request: Request, session_id: Optional[str]) -> Response:
"""DELETE ์š”์ฒญ ์ฒ˜๋ฆฌ - ์„ธ์…˜ ์ข…๋ฃŒ"""
if not session_id:
return JSONResponse(
{"error": "Session ID required"},
status_code=400
)
if session_manager.delete_session(session_id):
return Response(status_code=204) # No Content
else:
return Response(status_code=404) # Not Found
# ============================================================
# 6. Additional Endpoints
# ============================================================
async def health_check(request: Request) -> JSONResponse:
"""Health Check"""
return JSONResponse({
"status": "healthy",
"service": SERVER_NAME,
"version": SERVER_VERSION,
"protocol": PROTOCOL_VERSION,
"transport": "streamable-http"
})
async def root(request: Request) -> JSONResponse:
"""Root Endpoint"""
return JSONResponse({
"name": "Eodi MCP Server",
"description": "Travel Benefits Knowledge Base (Hotel Loyalty, Airline Miles, Credit Cards)",
"version": SERVER_VERSION,
"protocol": PROTOCOL_VERSION,
"documentation": "https://github.com/lovelymango/eodi-mcp",
"endpoints": {
"mcp": "/mcp (Streamable HTTP Transport)",
"sse": "/sse (Legacy SSE Transport)",
"sse_capabilities": "/sse?mode=capabilities (Standard MCP)",
"health": "/health",
"openapi": "/openapi.json (ChatGPT GPTs Actions)",
"api_search": "/api/search (REST API)",
"api_context": "/api/context (REST API)"
},
"clients": {
"Claude Desktop": "Use /mcp or /sse endpoint",
"PlayMCP": "Use /sse endpoint",
"ChatGPT GPTs": "Use /openapi.json for Actions setup",
"Standard MCP": "Use /sse?mode=capabilities"
}
})
# ============================================================
# 7. Legacy SSE Endpoint (Backwards Compatibility)
# ============================================================
async def legacy_sse_endpoint(request: Request) -> Response:
"""
๊ตฌ๋ฒ„์ „ HTTP+SSE Transport ํ˜ธํ™˜ (2024-11-05)
GET /sse โ†’ endpoint ์ด๋ฒคํŠธ ๋ฐ˜ํ™˜ (PlayMCP/Claude)
GET /sse?mode=capabilities โ†’ capabilities ์ด๋ฒคํŠธ ๋ฐ˜ํ™˜ (๋ฒ”์šฉ MCP ํด๋ผ์ด์–ธํŠธ)
์ดํ›„ ํด๋ผ์ด์–ธํŠธ๋Š” /messages/{session_id}๋กœ POST
"""
if request.method == "GET":
session_id = session_manager.create_session()
# ํด๋ผ์ด์–ธํŠธ ๋ชจ๋“œ ๊ฐ์ง€
mode = request.query_params.get("mode", "").lower()
user_agent = request.headers.get("User-Agent", "").lower()
# capabilities ๋ชจ๋“œ: ๋ฒ”์šฉ MCP ํด๋ผ์ด์–ธํŠธ์šฉ (ChatGPT ์ปค๋„ฅํ„ฐ ๋“ฑ)
use_capabilities = (
mode == "capabilities" or
"chatgpt" in user_agent or
"openai" in user_agent or
mode == "standard"
)
async def generate_sse():
if use_capabilities:
# MCP ํ‘œ์ค€ capabilities ์ด๋ฒคํŠธ (๋ฒ”์šฉ ํ˜ธํ™˜)
capabilities_data = {
"protocol": "mcp",
"version": "2024-11-05",
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION
},
"capabilities": {
"tools": True
},
"tools": [
{
"name": tool["name"],
"description": tool["description"],
"inputSchema": tool["inputSchema"]
}
for tool in TOOLS
],
"session_id": session_id,
"messages_endpoint": f"/messages/?session_id={session_id}"
}
yield f"event: capabilities\ndata: {json.dumps(capabilities_data)}\n\n"
else:
# PlayMCP/Claude ํ˜ธํ™˜ endpoint ์ด๋ฒคํŠธ
yield f"event: endpoint\ndata: /messages/?session_id={session_id}\n\n"
# ์—ฐ๊ฒฐ ์œ ์ง€
try:
while True:
yield ": keep-alive\n\n"
await asyncio.sleep(30)
except asyncio.CancelledError:
pass
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache"
}
)
elif request.method == "POST":
# Streamable HTTP๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
return await handle_post(request, request.headers.get("Mcp-Session-Id"))
elif request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
}
)
else:
return Response(status_code=405)
async def legacy_messages_endpoint(request: Request) -> Response:
"""๊ตฌ๋ฒ„์ „ /messages ์—”๋“œํฌ์ธํŠธ"""
session_id = request.query_params.get("session_id")
if request.method == "POST":
return await handle_post(request, session_id)
elif request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
}
)
else:
return Response(status_code=405)
# ============================================================
# 8. ChatGPT GPTs Actions Support (OpenAPI)
# ============================================================
def generate_openapi_schema() -> dict:
"""Generate OpenAPI 3.1.0 schema for ChatGPT GPTs Actions"""
return {
"openapi": "3.1.0",
"info": {
"title": "Eodi Travel Benefits API",
"description": "Search hotel loyalty programs, airline miles, credit card benefits, and travel deals. Supports Korean and English queries.",
"version": SERVER_VERSION
},
"servers": [
{"url": "https://lovelymango-eodi-mcp.hf.space"}
],
"paths": {
"/api/search": {
"post": {
"operationId": "searchKnowledgeBase",
"summary": "Search travel benefits knowledge base",
"description": "Vector search for hotel loyalty, airline miles, credit cards, and travel deals.",
"x-openai-isConsequential": False,
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"description": "Search query in Korean or English"
},
"domain": {
"type": "string",
"enum": ["hotel", "airline", "card", "news", "all"],
"description": "Filter by domain: hotel, airline, card, news, or all"
},
"chain": {
"type": "string",
"description": "Filter by chain (MARRIOTT, HILTON, IHG, HYATT, ACCOR)"
},
"limit": {
"type": "integer",
"default": 5,
"maximum": 10,
"description": "Max results to return (1-10)"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Search results with matching chunks",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"results": {"type": "array"},
"total": {"type": "integer"}
}
}
}
}
}
}
}
},
"/api/context": {
"post": {
"operationId": "getContext",
"summary": "Get context for a travel benefits question",
"description": "Retrieve relevant context chunks to answer a question about travel benefits.",
"x-openai-isConsequential": False,
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["question"],
"properties": {
"question": {
"type": "string",
"description": "User question about travel benefits in Korean or English"
},
"domain": {
"type": "string",
"enum": ["hotel", "airline", "card", "news", "all"],
"description": "Filter by domain"
},
"chain": {
"type": "string",
"description": "Filter by chain/brand"
},
"limit": {
"type": "integer",
"default": 5,
"maximum": 10,
"description": "Number of context chunks (1-10)"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Context chunks for answering the question",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"context_chunks": {"type": "array"},
"question": {"type": "string"}
}
}
}
}
}
}
}
}
}
}
async def openapi_schema(request: Request) -> JSONResponse:
"""OpenAPI schema for ChatGPT GPTs Actions"""
return JSONResponse(generate_openapi_schema())
async def api_search(request: Request) -> JSONResponse:
"""REST API for kb_search (ChatGPT Actions compatible)"""
try:
body = await request.json()
handler = get_handler()
result = handler.handle_search(
query=body.get("query", ""),
domain=body.get("domain"),
chain=body.get("chain"),
limit=min(body.get("limit", 5), 10) # Max 10 for Actions
)
return JSONResponse(result)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
async def api_context(request: Request) -> JSONResponse:
"""REST API for kb_get_context (ChatGPT Actions compatible)"""
try:
body = await request.json()
handler = get_handler()
result = handler.handle_get_context(
question=body.get("question", ""),
domain=body.get("domain"),
chain=body.get("chain"),
limit=min(body.get("limit", 5), 10)
)
return JSONResponse(result)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
# ============================================================
# 9. Application Setup
# ============================================================
routes = [
# ๋ฉ”์ธ ์—”๋“œํฌ์ธํŠธ
Route("/", root),
Route("/health", health_check),
# OpenAPI schema for ChatGPT GPTs Actions
Route("/openapi.json", openapi_schema),
Route("/.well-known/openapi.json", openapi_schema),
# REST API for ChatGPT GPTs Actions
Route("/api/search", api_search, methods=["POST", "OPTIONS"]),
Route("/api/context", api_context, methods=["POST", "OPTIONS"]),
# Streamable HTTP Transport (2025-03-26)
Route("/mcp", mcp_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS"]),
# Legacy HTTP+SSE Transport (2024-11-05) - ํ•˜์œ„ ํ˜ธํ™˜
Route("/sse", legacy_sse_endpoint, methods=["GET", "POST", "OPTIONS"]),
Route("/messages", legacy_messages_endpoint, methods=["POST", "OPTIONS"]),
Route("/messages/", legacy_messages_endpoint, methods=["POST", "OPTIONS"]),
]
# Auth routes ํ™•์žฅ (User Gate)
try:
from src.auth.routes import auth_routes
routes.extend(auth_routes)
logger.info(f"Auth routes loaded: {len(auth_routes)} endpoints")
except ImportError as e:
logger.warning(f"Auth routes not loaded: {e}")
middleware = [
Middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
),
# Rate Limiting (์Šคํฌ๋ž˜ํ•‘ ๋ฐฉ์–ด)
Middleware(RateLimitMiddleware),
]
app = Starlette(
routes=routes,
middleware=middleware,
debug=False
)
# ============================================================
# 9. Startup
# ============================================================
@app.on_event("startup")
async def startup():
logger.info(f"๐Ÿš€ Eodi MCP Server v{SERVER_VERSION} starting...")
logger.info(f" Protocol: {PROTOCOL_VERSION}")
logger.info(f" Transport: Streamable HTTP + Legacy SSE")
logger.info(f" SUPABASE_URL: {'set' if os.getenv('SUPABASE_URL') else 'NOT SET'}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"server_streamable:app",
host="0.0.0.0",
port=7860,
proxy_headers=True,
forwarded_allow_ips="*"
)