Spaces:
Running
Running
| """ | |
| 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 | |
| # ============================================================ | |
| 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="*" | |
| ) | |