ls증권 히스토리 구독 넣음
This commit is contained in:
448
mcp/kiwoom-docs-mcp/server.py
Normal file
448
mcp/kiwoom-docs-mcp/server.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""키움증권 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"<br\s*/?>", "\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()
|
||||
Reference in New Issue
Block a user