"""LS증권 OpenAPI TR 스펙 조회 전용 MCP. 키움 kiwoom-docs-mcp / 한투 kis-code-assistant 와 같이 문서를 정확히 찾아주는 용도. 실제 REST/WS 호출·주문·토큰 발급은 절대 하지 않는다. """ 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", "ls-openapi-spec.json") HEADERISH = { "content-type", "authorization", "tr_cd", "tr_cont", "tr_cont_key", "mac_address", } 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) t = t.replace(" ", " ").replace("&", "&") return t.strip() 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: return str(api.get("category") or "기타") class LSSpecIndex: """LS OpenAPI TR 카탈로그 인덱스 (읽기 전용).""" MAX_SEARCH = 20 def __init__(self, path: str) -> None: self.path = path self.apis: dict[str, dict[str, Any]] = {} self._by_lower: dict[str, list[str]] = {} self.meta: 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 == "_meta" and isinstance(val, dict): self.meta = val continue if not isinstance(val, dict): continue api_id = str(val.get("apiId") or val.get("trCode") 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 for alias in { api_id, str(val.get("trCode") or ""), str(key), }: a = alias.strip() if not a: continue self._by_lower.setdefault(a.lower(), []) if store_id not in self._by_lower[a.lower()]: self._by_lower[a.lower()].append(store_id) def stats(self) -> dict[str, Any]: by_cat: dict[str, int] = {} by_proto: dict[str, int] = {} by_group: dict[str, int] = {} for api in self.apis.values(): cat = _category_for(api) by_cat[cat] = by_cat.get(cat, 0) + 1 proto = str(api.get("protocolType") or "?") by_proto[proto] = by_proto.get(proto, 0) + 1 gn = str(api.get("groupName") or "?") by_group[gn] = by_group.get(gn, 0) + 1 return { "api_count": len(self.apis), "by_category": dict(sorted(by_cat.items(), key=lambda x: (-x[1], x[0]))), "by_protocol": dict(sorted(by_proto.items(), key=lambda x: (-x[1], x[0]))), "by_group": dict(sorted(by_group.items(), key=lambda x: (-x[1], x[0]))), "spec_path": self.path, "fetched_at": self.meta.get("fetchedAt"), "source": self.meta.get("source"), } def _summary(self, api: dict[str, Any]) -> dict[str, Any]: return { "apiId": api.get("apiId") or api.get("trCode"), "trCode": api.get("trCode"), "apiNm": api.get("apiNm") or api.get("trName"), "method": api.get("method"), "url": api.get("url"), "domain": api.get("domain"), "mockDomain": api.get("mockDomain"), "protocolType": api.get("protocolType"), "category": _category_for(api), "groupName": api.get("groupName"), "transactionPerSec": api.get("transactionPerSec"), "docsUrl": api.get("docsUrl"), "description": _strip_html(api.get("description") or ""), } def _filter_io( self, rows: Any, *, body_fields_only: bool, ) -> list[dict[str, Any]]: if not isinstance(rows, list): return [] out: list[dict[str, Any]] = [] for it in rows: if not isinstance(it, dict): continue item_id = str(it.get("itemId") or "") bt = str(it.get("bodyType") or "") if body_fields_only: if bt in {"req_h", "res_h"}: continue if item_id.lower() in HEADERISH: continue out.append( { "itemId": item_id, "itemNm": it.get("itemNm") or "", "type": it.get("type") or "", "length": it.get("length") or "", "required": it.get("required") or "", "order": it.get("order") or "", "bodyType": bt, "desc": _strip_html(it.get("desc") or ""), } ) return out def _detail( self, api: dict[str, Any], *, include_examples: bool = True, body_fields_only: bool = False, ) -> dict[str, Any]: out = self._summary(api) out.update( { "format": api.get("format"), "contentType": api.get("contentType"), "trId": api.get("trId"), "groupId": api.get("groupId"), "requestIo": self._filter_io( api.get("requestIo"), body_fields_only=body_fields_only ), "responseIo": self._filter_io( api.get("responseIo"), body_fields_only=body_fields_only ), } ) 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 (trCode) required"} 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 trCode match: {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"trCode not found: {api_id}", "candidates": hints, } return {"status": "success", "api": self._detail(api, **kwargs)} def list_apis( self, *, category: Optional[str] = None, protocol: Optional[str] = None, url_contains: Optional[str] = None, group_contains: Optional[str] = None, limit: int = 100, ) -> dict[str, Any]: limit = max(1, min(int(limit or 100), 400)) rows: list[dict[str, Any]] = [] cat = (category or "").strip() proto = (protocol or "").strip().upper() url_q = (url_contains or "").strip().lower() grp_q = (group_contains or "").strip().lower() for api in self.apis.values(): if cat and cat not in _category_for(api) and _category_for(api) != cat: continue if proto and str(api.get("protocolType") or "").upper() != proto: continue if url_q and url_q not in str(api.get("url") or "").lower(): continue if grp_q and grp_q not in str(api.get("groupName") or "").lower(): continue rows.append(self._summary(api)) rows.sort(key=lambda x: str(x.get("trCode") or 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("trCode") or api.get("apiId") or "") anm = str(api.get("apiNm") or api.get("trName") or "") desc = _strip_html(api.get("description") or "") url = str(api.get("url") or "") cat = _category_for(api) group = str(api.get("groupName") or "") 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} {group} {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() or q_lower in group.lower(): score += 12 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("trCode") 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, } @lru_cache(maxsize=1) def get_index() -> LSSpecIndex: path = os.environ.get("LS_SPEC_PATH") or DEFAULT_SPEC return LSSpecIndex(path) mcp = FastMCP( name="ls-docs-mcp", version=VERSION, instructions=( "LS증권 OpenAPI TR 스펙 조회 전용 MCP다. " "사용자가 LS TR 코드(t8412, US3, g3101…), URL, 요청/응답 필드를 물을 때 " "반드시 이 도구로 스펙을 확인한 뒤 답하라. " "이 MCP는 실제 API를 호출하지 않는다. 주문/체결/토큰 발급을 수행하지 않는다." ), ) @mcp.tool( name="ls_api_stats", description="LS OpenAPI 스펙 요약(총 TR 수, 카테고리/프로토콜/그룹 분포). 탐색 시작 시 사용.", ) def ls_api_stats() -> dict[str, Any]: return {"status": "success", **get_index().stats()} @mcp.tool( name="search_ls_api", description=( "LS OpenAPI TR을 자연어/키워드로 검색한다. " "예: '분봉', 't8412', '조건검색', '해외차트', '호가', 'US3'. " "결과는 요약만 반환하므로, 상세 필드는 get_ls_api(tr_code)를 이어서 호출하라." ), ) def search_ls_api(query: str, limit: int = 20) -> dict[str, Any]: return get_index().search(query, limit=limit) @mcp.tool( name="get_ls_api", description=( "trCode(예: t8412, t1101, US3, g3203)로 LS TR 전체 스펙을 반환한다. " "method/url/domain/requestIo/responseIo/예제/docsUrl 포함. " "body_fields_only=true 이면 공통 헤더(content-type, authorization, tr_cd…)를 제외한다." ), ) def get_ls_api( tr_code: str, include_examples: bool = True, body_fields_only: bool = False, ) -> dict[str, Any]: return get_index().get( tr_code, include_examples=include_examples, body_fields_only=body_fields_only, ) @mcp.tool( name="list_ls_apis", description=( "LS TR 목록. category(주식_차트 등), protocol(REST|WEBSOCKET), " "url_contains(/stock/chart), group_contains(실시간)로 필터. " "카테고리 목록은 ls_api_stats 참고." ), ) def list_ls_apis( category: str = "", protocol: str = "", url_contains: str = "", group_contains: str = "", limit: int = 100, ) -> dict[str, Any]: return get_index().list_apis( category=category or None, protocol=protocol or None, url_contains=url_contains or None, group_contains=group_contains or None, limit=limit, ) def main() -> None: if "--http" in sys.argv: port = int(os.environ.get("PORT", "8083")) mcp.run(transport="http", host="127.0.0.1", port=port) return mcp.run(transport="stdio") if __name__ == "__main__": main()