418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""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"<br\s*/?>", "\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()
|