"""키움증권 REST API 스펙 조회 전용 MCP. KIS Code Assistant MCP 와 같이 문서를 정확히 찾아주는 용도. 실제 REST 호출·주문·토큰 발급은 절대 하지 않는다. """ from __future__ import annotations import json import os import re import sys from functools import lru_cache from typing import Any, Optional from fastmcp import FastMCP VERSION = "0.1.0" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_SPEC = os.path.join(SCRIPT_DIR, "data", "kiwoom-rest-api-spec.json") # URL path → 사람이 읽기 쉬운 카테고리 URL_CATEGORY = { "/oauth2/token": "인증", "/oauth2/revoke": "인증", "/api/dostk/acnt": "국내_계좌", "/api/dostk/ordr": "국내_주문", "/api/dostk/crdordr": "국내_신용주문", "/api/dostk/chart": "국내_차트", "/api/dostk/stkinfo": "국내_종목정보", "/api/dostk/mrkcond": "국내_시장조건", "/api/dostk/rkinfo": "국내_순위", "/api/dostk/sect": "국내_업종", "/api/dostk/thme": "국내_테마", "/api/dostk/elw": "국내_ELW", "/api/dostk/etf": "국내_ETF", "/api/dostk/frgnistt": "국내_외국인", "/api/dostk/slb": "국내_대차", "/api/dostk/watchlist": "국내_관심종목", "/api/dostk/websocket": "국내_웹소켓", "/api/us/acnt": "해외_계좌", "/api/us/ordr": "해외_주문", "/api/us/chart": "해외_차트", "/api/us/stkinfo": "해외_종목정보", "/api/us/mrkcond": "해외_시장조건", "/api/us/rkinfo": "해외_순위", "/api/us/sect": "해외_업종", "/api/us/watchlist": "해외_관심종목", "/api/us/exchange": "해외_환율", "/api/us/websocket": "해외_웹소켓", } PREFIX_CATEGORY = { "au": "인증", "ka": "국내주식", "kt": "국내계좌/주문", "us": "해외주식", "0": "국내_실시간(웹소켓 FID)", } def _strip_html(text: str) -> str: if not text: return "" t = re.sub(r"", "\n", str(text), flags=re.I) t = re.sub(r"<[^>]+>", "", t) return t.strip() def _normalize_fields(items: Any) -> list[dict[str, Any]]: if not isinstance(items, list): return [] out: list[dict[str, Any]] = [] for it in items: if not isinstance(it, dict): continue out.append( { "itemId": it.get("itemId") or "", "itemNm": it.get("itemNm") or "", "type": it.get("type") or "", "length": it.get("length") or "", "desc": _strip_html(it.get("desc") or ""), } ) return out def _parse_example(ex: Any) -> Any: if ex is None: return None if isinstance(ex, (dict, list)): return ex if isinstance(ex, str): s = ex.strip() if not s: return None try: return json.loads(s) except Exception: return s return ex def _category_for(api: dict[str, Any]) -> str: url = (api.get("url") or "").strip() if url in URL_CATEGORY: return URL_CATEGORY[url] api_id = str(api.get("apiId") or "") for pref, name in PREFIX_CATEGORY.items(): if api_id.startswith(pref): return name return "기타" class KiwoomSpecIndex: """키움 TR 카탈로그 인덱스 (읽기 전용).""" MAX_SEARCH = 20 def __init__(self, path: str) -> None: self.path = path # 원본 apiId 키 유지 (0g / 0G 등 대소문자 구분) self.apis: dict[str, dict[str, Any]] = {} # 소문자 → 원본 apiId 목록 (조회 편의) self._by_lower: dict[str, list[str]] = {} self.error_codes: list[dict[str, Any]] = [] self._load(path) def _load(self, path: str) -> None: with open(path, "r", encoding="utf-8") as f: raw = json.load(f) if not isinstance(raw, dict): raise ValueError("spec root must be object") for key, val in raw.items(): if key == "errorCodeList" and isinstance(val, list): self.error_codes = [x for x in val if isinstance(x, dict)] continue if not isinstance(val, dict): continue api_id = str(val.get("apiId") or key).strip() if not api_id: continue # 동일 키 충돌 시 스펙 키로 구분 보존 store_id = api_id if store_id in self.apis and self.apis[store_id] is not val: store_id = str(key).strip() or api_id self.apis[store_id] = val self._by_lower.setdefault(api_id.lower(), []) if store_id not in self._by_lower[api_id.lower()]: self._by_lower[api_id.lower()].append(store_id) def stats(self) -> dict[str, Any]: by_cat: dict[str, int] = {} by_prefix: dict[str, int] = {} for api in self.apis.values(): cat = _category_for(api) by_cat[cat] = by_cat.get(cat, 0) + 1 aid = str(api.get("apiId") or "") pref = aid[:2] if len(aid) >= 2 else aid by_prefix[pref] = by_prefix.get(pref, 0) + 1 return { "api_count": len(self.apis), "error_code_count": len(self.error_codes), "by_category": dict(sorted(by_cat.items(), key=lambda x: (-x[1], x[0]))), "by_prefix": dict(sorted(by_prefix.items(), key=lambda x: (-x[1], x[0]))), "spec_path": self.path, } def _summary(self, api: dict[str, Any]) -> dict[str, Any]: return { "apiId": api.get("apiId"), "apiNm": api.get("apiNm"), "method": api.get("method"), "url": api.get("url"), "domain": api.get("domain"), "mockDomain": api.get("mockDomain"), "category": _category_for(api), "description": _strip_html(api.get("description") or ""), } def _detail( self, api: dict[str, Any], *, include_examples: bool = True, body_fields_only: bool = False, ) -> dict[str, Any]: req = _normalize_fields(api.get("requestIo")) res = _normalize_fields(api.get("responseIo")) if body_fields_only: # 헤더성 공통 필드(api-id, authorization, cont-yn, next-key) 제외 headerish = {"api-id", "authorization", "cont-yn", "next-key"} req = [x for x in req if (x.get("itemId") or "").lower() not in headerish] res = [x for x in res if (x.get("itemId") or "").lower() not in headerish] out = self._summary(api) out.update( { "format": api.get("format"), "contentType": api.get("contentType"), "requestIo": req, "responseIo": res, } ) if include_examples: out["requestExample"] = _parse_example(api.get("requestExample")) out["responseExample"] = _parse_example(api.get("responseExample")) return out def get(self, api_id: str, **kwargs: Any) -> dict[str, Any]: raw = (api_id or "").strip() if not raw: return {"status": "error", "message": "api_id required"} # 1) 정확 일치 2) 대소문자 무시 api = self.apis.get(raw) matches = self._by_lower.get(raw.lower(), []) if api is None and len(matches) == 1: api = self.apis.get(matches[0]) elif api is None and len(matches) > 1: return { "status": "ambiguous", "message": f"multiple apiId match (case): {raw}", "candidates": matches, "apis": [self._summary(self.apis[m]) for m in matches if m in self.apis], } if not api: hints = [k for k in self.apis if raw.lower() in k.lower()][:8] return { "status": "not_found", "message": f"api_id not found: {api_id}", "candidates": hints, } return { "status": "success", "api": self._detail(api, **kwargs), } def list_apis( self, *, prefix: Optional[str] = None, category: Optional[str] = None, url_contains: Optional[str] = None, limit: int = 100, ) -> dict[str, Any]: limit = max(1, min(int(limit or 100), 300)) rows: list[dict[str, Any]] = [] pref = (prefix or "").strip().lower() cat = (category or "").strip() url_q = (url_contains or "").strip().lower() for api in self.apis.values(): aid = str(api.get("apiId") or "") if pref and not aid.lower().startswith(pref): continue if cat and _category_for(api) != cat and cat not in _category_for(api): continue if url_q and url_q not in str(api.get("url") or "").lower(): continue rows.append(self._summary(api)) rows.sort(key=lambda x: str(x.get("apiId") or "")) total = len(rows) return { "status": "success", "total_count": total, "showing": min(total, limit), "results": rows[:limit], } def search(self, query: str, limit: Optional[int] = None) -> dict[str, Any]: q = (query or "").strip() if not q: return {"status": "error", "message": "query required", "total_count": 0, "results": []} lim = max(1, min(int(limit or self.MAX_SEARCH), 50)) q_lower = q.lower() tokens = [t for t in re.split(r"\s+", q_lower) if t] scored: list[tuple[int, dict[str, Any]]] = [] for api in self.apis.values(): aid = str(api.get("apiId") or "") anm = str(api.get("apiNm") or "") desc = _strip_html(api.get("description") or "") url = str(api.get("url") or "") cat = _category_for(api) field_blob = " ".join( f"{x.get('itemId','')} {x.get('itemNm','')} {x.get('desc','')}" for x in (api.get("requestIo") or []) + (api.get("responseIo") or []) if isinstance(x, dict) ) hay = f"{aid} {anm} {desc} {url} {cat} {field_blob}".lower() score = 0 if q_lower == aid.lower(): score += 100 elif aid.lower().startswith(q_lower): score += 60 elif q_lower in aid.lower(): score += 40 if q_lower in anm.lower(): score += 35 if q_lower in desc.lower(): score += 15 if q_lower in url.lower(): score += 20 if q_lower in cat.lower(): score += 10 # 토큰 AND 매칭 가산 if tokens and all(t in hay for t in tokens): score += 25 + 5 * len(tokens) elif tokens: hit = sum(1 for t in tokens if t in hay) if hit: score += hit * 8 else: continue elif score == 0: continue scored.append((score, self._summary(api))) scored.sort(key=lambda x: (-x[0], str(x[1].get("apiId") or ""))) results = [row for _, row in scored[:lim]] if not results: return { "status": "no_results", "message": f"No APIs matched: {query}", "total_count": 0, "results": [], } return { "status": "success", "message": f"Found {len(scored)} (showing {len(results)})", "total_count": len(scored), "results": results, } def lookup_error(self, code: str) -> dict[str, Any]: c = (code or "").strip() if not c: return {"status": "error", "message": "code required"} hits = [] for row in self.error_codes: ec = str(row.get("errCode") or row.get("code") or "") em = str(row.get("errMsg") or row.get("message") or "") if c == ec or c.lower() in ec.lower() or c.lower() in em.lower(): hits.append({"errCode": ec, "errMsg": _strip_html(em)}) if not hits: return {"status": "no_results", "message": f"No error matched: {code}", "results": []} return {"status": "success", "total_count": len(hits), "results": hits[:30]} @lru_cache(maxsize=1) def get_index() -> KiwoomSpecIndex: path = os.environ.get("KIWOOM_SPEC_PATH") or DEFAULT_SPEC return KiwoomSpecIndex(path) mcp = FastMCP( name="kiwoom-docs-mcp", version=VERSION, instructions=( "키움증권 REST API 문서(TR 스펙) 조회 전용 MCP다. " "사용자가 키움 API ID(ka/kt/us/au…), URL, 요청/응답 필드를 물을 때 " "반드시 이 도구로 스펙을 확인한 뒤 답하라. " "이 MCP는 실제 API를 호출하지 않는다. 주문/체결/토큰 발급을 수행하지 않는다." ), ) @mcp.tool( name="kiwoom_api_stats", description="키움 REST 스펙 요약(총 API 수, 카테고리/접두사 분포). 탐색 시작 시 사용.", ) def kiwoom_api_stats() -> dict[str, Any]: return {"status": "success", **get_index().stats()} @mcp.tool( name="search_kiwoom_api", description=( "키움 REST API를 자연어/키워드로 검색한다. " "예: '일봉', 'ka10081', '잔고', 'chart', '해외 주문'. " "결과는 요약만 반환하므로, 상세 필드가 필요하면 get_kiwoom_api(api_id)를 이어서 호출하라." ), ) def search_kiwoom_api(query: str, limit: int = 20) -> dict[str, Any]: return get_index().search(query, limit=limit) @mcp.tool( name="get_kiwoom_api", description=( "apiId(예: ka10081, kt00018, au10001)로 키움 TR 전체 스펙을 반환한다. " "method/url/domain/requestIo/responseIo/예제 포함. " "body_fields_only=true 이면 공통 헤더 필드(api-id, authorization, cont-yn, next-key)를 제외한다." ), ) def get_kiwoom_api( api_id: str, include_examples: bool = True, body_fields_only: bool = False, ) -> dict[str, Any]: return get_index().get( api_id, include_examples=include_examples, body_fields_only=body_fields_only, ) @mcp.tool( name="list_kiwoom_apis", description=( "키움 API 목록. prefix(ka|kt|us|au|0), category(국내_차트 등), url_contains(/api/dostk/chart)로 필터. " "카테고리 목록은 kiwoom_api_stats 참고." ), ) def list_kiwoom_apis( prefix: str = "", category: str = "", url_contains: str = "", limit: int = 100, ) -> dict[str, Any]: return get_index().list_apis( prefix=prefix or None, category=category or None, url_contains=url_contains or None, limit=limit, ) @mcp.tool( name="lookup_kiwoom_error", description="키움 REST 에러코드/메시지 조회 (스펙의 errorCodeList).", ) def lookup_kiwoom_error(code: str) -> dict[str, Any]: return get_index().lookup_error(code) def main() -> None: # Cursor 등록은 stdio. --stdio 없어도 Cursor가 args로 줄 수 있어 기본 stdio. if "--http" in sys.argv: # 로컬 디버그용 (실매/운영 불필요) port = int(os.environ.get("PORT", "8082")) mcp.run(transport="http", host="127.0.0.1", port=port) return mcp.run(transport="stdio") if __name__ == "__main__": main()