""" 영웅문 S# AI 메뉴검색 — MCP 서버 (컨시어지 연동용) ================================================================ 컨시어지(MCP 클라이언트)가 tools/call 로 호출하는 5개 tool을 노출한다. 프로토콜(initialize / tools/list / tools/call / JSON-RPC)은 FastMCP가 자동 처리하며, 여기서는 tool 함수 본체 + 코어 연결만 정의한다. 코어(core.agent.run_agent / search_engine)는 그대로 재사용한다. 실행: # stdio (컨시어지가 subprocess로 실행) .venv/Scripts/python.exe mcp_server.py # SSE/HTTP (별도 서비스로 띄우고 네트워크 연결) MCP_TRANSPORT=sse .venv/Scripts/python.exe mcp_server.py 전송 방식은 컨시어지 측과 합의 후 MCP_TRANSPORT 로 선택한다(기본 stdio). [중요] stdio 전송에서는 stdout이 JSON-RPC 채널이라 print() 오염이 프로토콜을 깨뜨린다. 따라서 이 서버를 stdio로 띄울 때는 코어의 모든 로그를 stderr로 보내야 한다(아래 _silence_stdout 로 일괄 처리). """ import os import sys import io import time import uuid import json import logging import contextlib from pathlib import Path from typing import Optional sys.path.insert(0, str(Path(__file__).parent)) # ── 로깅: 항상 stderr로 (stdio 전송 시 stdout 오염 방지) ────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [mcp] %(message)s", stream=sys.stderr, ) logger = logging.getLogger("menu-search-mcp") from mcp.server.fastmcp import FastMCP # ── 응답 스펙 버전 ──────────────────────────────────────────────────────────── SPEC_VERSION = "v1" AGENT_NAME = "menu-search" AGENT_VERSION = "1.0.0" CATEGORIES = [ "국내주식", "해외주식", "파생상품", "자산/뱅킹", "금융상품", "주식더모으기", "랭킹/영웅전", "대출/카드/보험", "환전", "생활/혜택", ] mcp = FastMCP( AGENT_NAME, host=os.getenv("MCP_HOST", "0.0.0.0"), port=int(os.getenv("MCP_PORT", "9000")), ) # ── stdout 오염 방지 ────────────────────────────────────────────────────────── # stdio 전송 시 stdout은 JSON-RPC 채널이다. 코어 모듈들이 print()로 stdout에 # 찍으면 프로토콜이 깨지므로, 코어 호출 구간만 stdout을 stderr로 리다이렉트한다. @contextlib.contextmanager def _quiet_stdout(): old = sys.stdout sys.stdout = sys.stderr try: yield finally: sys.stdout = old # ── 코어 지연 로드 (싱글톤) ─────────────────────────────────────────────────── _engine = None _raw_menus = None def _get_engine(): global _engine if _engine is None: with _quiet_stdout(): from core.search_engine import MenuSearchEngine _engine = MenuSearchEngine.get_instance() return _engine def _get_raw_menus() -> dict: """real_menus.json 을 menu_id → 메뉴 dict 로 로드(원본 데이터 조회용).""" global _raw_menus if _raw_menus is None: from config import RAW_DIR path = RAW_DIR / "real_menus.json" data = json.loads(path.read_text(encoding="utf-8")) _raw_menus = {m["menu_id"]: m for m in data} return _raw_menus # ── 공통 응답 포맷 (v1) — 모든 tool 응답을 감싸는 공통 메타 ────────────────────── def _envelope(payload: dict, trace_id: Optional[str], started: float, ok: bool = True, error: Optional[dict] = None) -> dict: return { "agent": AGENT_NAME, "agentVersion": AGENT_VERSION, "specVersion": SPEC_VERSION, "traceId": trace_id or str(uuid.uuid4()), "ok": ok, "elapsedMs": int((time.time() - started) * 1000), "error": error, # {code, message, detail} or None **payload, } def _err(code: str, message: str, detail: str = "") -> dict: return {"code": code, "message": message, "detail": detail} # ── 컨시어지 게이트웨이 호환 tool ────────────────────────────────────────────── # 게이트웨이 tools/list 스키마(search_menu / fetch_menu_raw / health)와 동일한 # tool 이름으로 노출하고, 응답은 우리 공통 응답 포맷(v1)으로 통일한다. # 내부 검색 엔진: 임베딩(bge-m3) + BM25 + HyDE 하이브리드(hybrid-v1). ENGINE_VERSION = "hybrid-v1" # ════════════════════════════════════════════════════════════════════════════ # Tool) health — 상태(liveness/readiness) / 버전 / 데이터 건수 # ════════════════════════════════════════════════════════════════════════════ @mcp.tool() def health() -> dict: """서버 상태(liveness/readiness)·버전·참조 데이터 건수·엔진을 공통 응답 포맷으로 반환한다.""" started = time.time() try: engine = _get_engine() stats = engine.vectorstore.get_stats() return _envelope({"status": "ok", "live": True, "ready": True, "menuCount": stats.get("total_menus", 0), "engine": ENGINE_VERSION}, None, started) except Exception as e: logger.exception("health 실패") return _envelope({"status": "error", "live": True, "ready": False}, None, started, ok=False, error=_err("ENGINE_NOT_READY", "검색엔진 미준비", str(e))) # ════════════════════════════════════════════════════════════════════════════ # Tool) search_menu — 자연어 메뉴 검색 # ════════════════════════════════════════════════════════════════════════════ @mcp.tool() def search_menu( query: str, limit: int = 5, agentHint: Optional[str] = None, version: str = "hybrid-v1", verbose: bool = False, debug: bool = False, ) -> dict: """키움 영웅문S# 메뉴를 검색한다. (엔진: 임베딩 bge-m3 + BM25 + HyDE 하이브리드) verbose/debug로 응답 상세도를 조절한다. Args: query: 사용자 질의 (예: "잔고 조회", "내 주식 얼마야"). limit: 최대 결과 수 (기본 5, 최대 30). agentHint: 도메인 힌트(선택). 유효 카테고리면 필터, 아니면 자체 감지. version: 검색 엔진 버전 (기본 hybrid-v1). verbose: true면 각 결과에 category/keywords + 의도/품질 포함. debug: true면 재작성 이력·단계 정보 포함. Returns: 공통 응답 포맷 + results:[{rank, menu_id, menu_name, menu_path, similarity}]. """ started = time.time() if not query or not query.strip(): return _envelope({"query": query, "resultCount": 0, "results": []}, None, started, ok=False, error=_err("BAD_REQUEST", "query가 비어있음")) try: from core.agent import run_agent limit = max(1, min(int(limit), 30)) # agentHint가 유효 카테고리면 필터로, 아니면 자체 카테고리 감지 사용 cat = agentHint if agentHint in CATEGORIES else None with _quiet_stdout(): # run_agent 내부 print → stderr (stdout 오염 방지) # HyDE 유지(의미 갭 브릿지). 리랭커는 OFF — CPU cross-encoder가 # 후보 재점수에 6~11초 소요 + 짧은 메뉴명(예: "OTP")을 과소평가. result = run_agent(query=query, top_n=limit, category_filter=cat, use_hyde=True, use_reranker=False) hits = result.get("search_results", []) raw_menus = _get_raw_menus() # menu_id → 원본 dict (category, keywords 조회용) results = [] for i, r in enumerate(hits, 1): menu_id = r.get("menu_id", "") sim = round(r.get("similarity", 0.0), 4) raw = raw_menus.get(menu_id, {}) item = { "rank": i, "id": menu_id, "name": r.get("menu_name", ""), "path": r.get("menu_path", ""), "score": sim, # 공통 랭킹 점수 (0~1.0) — DIO/BIX 통합 필드 "similarity": sim, # BIX 고유 임베딩 유사도 (현재 score와 동일값) "matchType": "semantic", "category": raw.get("category", ""), "keywords": raw.get("keywords") or [], } results.append(item) payload = {"query": query, "resultCount": len(results), "results": results} if verbose: payload["detectedCategory"] = result.get("detected_category") payload["intent"] = result.get("intent") payload["qualityScore"] = round(result.get("quality_score", 0.0), 4) if debug: payload["debug"] = { "refinedQuery": result.get("refined_query", query), "retryCount": result.get("retry_count", 0), "rewriteHistory": result.get("rewrite_history", []), "detectedCategory": result.get("detected_category"), "intent": result.get("intent"), } return _envelope(payload, None, started) except Exception as e: logger.exception("search_menu 실패") return _envelope({"query": query, "resultCount": 0, "results": []}, None, started, ok=False, error=_err("INTERNAL", "검색 중 오류", str(e))) # ════════════════════════════════════════════════════════════════════════════ # Tool) fetch_menu_raw — 참조 데이터 전체 반환(대조 검증용) # ════════════════════════════════════════════════════════════════════════════ @mcp.tool() def fetch_menu_raw() -> dict: """메뉴 검색이 참조하는 원본 데이터(영웅문S# flat JSON) 전체를 반환한다. 외부에서 참조 데이터 전체를 받아 검색 결과를 대조 검증하는 용도. Returns: 공통 응답 포맷 + count + menus:[원본 메뉴 dict, ...]. """ started = time.time() try: menus = _get_raw_menus() return _envelope({"count": len(menus), "menus": list(menus.values())}, None, started) except Exception as e: logger.exception("fetch_menu_raw 실패") return _envelope({"count": 0, "menus": []}, None, started, ok=False, error=_err("INTERNAL", "조회 오류", str(e))) # ════════════════════════════════════════════════════════════════════════════ # ── Bearer 토큰 인증 미들웨어 ───────────────────────────────────────────────── # MCP_TOKEN 이 설정돼 있으면 모든 HTTP 요청에 'Authorization: Bearer '을 # 요구한다(공개 HF Space URL 보호용). 미설정이면 인증 비활성(로컬 개발). from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse class _BearerAuthMiddleware(BaseHTTPMiddleware): def __init__(self, app, token: str): super().__init__(app) self._expected = f"Bearer {token}" async def dispatch(self, request, call_next): # keep-alive 핑(/healthz)은 인증 면제 — Space를 깨어있게만 유지 if request.url.path == "/healthz": return await call_next(request) if request.headers.get("authorization", "") != self._expected: return JSONResponse( {"ok": False, "error": {"code": "UNAUTHORIZED", "message": "유효하지 않은 토큰"}}, status_code=401, ) return await call_next(request) def _run_streamable_http(): """streamable_http_app()에 토큰 인증 + keep-alive 엔드포인트를 붙여 실행.""" import uvicorn from starlette.routing import Route from starlette.responses import PlainTextResponse app = mcp.streamable_http_app() # ── keep-alive 핑 엔드포인트 (인증 면제, 엔진 로드 없이 즉시 200) ────────── # 외부 cron이 주기적으로 GET /healthz 를 호출하면 HF Space가 sleep에 빠지지 # 않아 콜드스타트(첫 MCP 호출 ~100초)를 방지한다. async def _healthz(request): return PlainTextResponse("ok") app.router.routes.append(Route("/healthz", _healthz, methods=["GET"])) token = os.getenv("MCP_TOKEN", "") if token: app.add_middleware(_BearerAuthMiddleware, token=token) logger.info("토큰 인증 활성 (MCP_TOKEN)") else: logger.warning("MCP_TOKEN 미설정 → 인증 비활성 (공개 배포 시 반드시 설정)") host = os.getenv("MCP_HOST", "0.0.0.0") port = int(os.getenv("MCP_PORT", "9000")) uvicorn.run(app, host=host, port=port, log_level="info") def _preload_engine_os_quiet(): """JSON-RPC(stdout) 시작 전에 엔진을 미리 로드한다. 모델 라이브러리(sentence-transformers 등)는 C 레벨로 fd 1(stdout)에 직접 출력하기도 해서, 파이썬 레벨 stdout 교체로는 못 막는다. 로드 구간 동안 OS 레벨로 fd 1을 fd 2(stderr)로 dup2 하여 JSON-RPC 채널 오염을 차단한다. """ saved = os.dup(1) try: sys.stdout.flush() os.dup2(2, 1) # stdout(fd1) → stderr(fd2) _get_engine() except Exception: logger.exception("엔진 preload 실패") finally: sys.stdout.flush() os.dup2(saved, 1) # fd1 복원 (이후 JSON-RPC는 깨끗한 stdout) os.close(saved) if __name__ == "__main__": transport = os.getenv("MCP_TRANSPORT", "stdio").lower() logger.info(f"MCP 서버 기동 (transport={transport}, spec={SPEC_VERSION}) — 엔진 preload 중...") _preload_engine_os_quiet() logger.info("엔진 준비 완료, 서버 시작") if transport in ("streamable-http", "http"): _run_streamable_http() # 토큰 인증 포함 (배포 기본) elif transport == "sse": mcp.run(transport="sse") else: mcp.run(transport="stdio")