kiwoom-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: kiwoom-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for kiwoom-cli — 키움증권 REST API 236종을 AI 에이전트에 연결합니다
5
+ Author: Yu Jin
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/gejyn14/kiwoom-plugin
8
+ Project-URL: Repository, https://github.com/gejyn14/kiwoom-plugin
9
+ Project-URL: Issues, https://github.com/gejyn14/kiwoom-plugin/issues
10
+ Project-URL: kiwoom-cli, https://github.com/gejyn14/kiwoom-cli
11
+ Keywords: mcp,model-context-protocol,kiwoom,trading,stock,korea
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial :: Investment
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: kiwoom-cli>=2.14.0
24
+ Requires-Dist: mcp<2,>=1.27
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
28
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
29
+ Requires-Dist: ruff>=0.4; extra == "dev"
30
+
31
+ # kiwoom-mcp (server)
32
+
33
+ 이 디렉터리는 [kiwoom-plugin](https://github.com/gejyn14/kiwoom-plugin)의 MCP 서버 본체입니다. 플러그인이 `uvx`로 이 디렉터리를 직접 실행하므로 별도 배포(PyPI/npm)가 없습니다.
34
+
35
+ kiwoom-cli 236종 API를 MCP 도구로 노출합니다. 안전장치(주문 확인 게이트, dry-run, 멱등키, envelope)는 kiwoom-cli의 것을 그대로 통과시킵니다.
36
+
37
+ ## 단독 실행
38
+
39
+ ```bash
40
+ uvx --from "git+https://github.com/gejyn14/kiwoom-plugin@v0.1.0#subdirectory=server" kiwoom-mcp
41
+ uvx --from "git+https://github.com/gejyn14/kiwoom-plugin@v0.1.0#subdirectory=server" kiwoom-mcp --allow-orders
42
+ ```
43
+
44
+ 전송·인증·안전 모델은 상위 [README](../README.md)를 참고하세요.
45
+
46
+ ## 개발
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ pytest tests/ -q
51
+ ruff check kiwoom_mcp/
52
+ ```
53
+
54
+ ## 도구
55
+
56
+ | 도구 | 설명 |
57
+ |---|---|
58
+ | `kiwoom_describe` | 명령 트리 조회 |
59
+ | `kiwoom_find` | 키워드로 명령·API 검색 |
60
+ | `kiwoom_run` | 임의의 kiwoom-cli 명령 실행 |
61
+ | `kiwoom_stream_snapshot` | 실시간 스트림에서 N개 이벤트 |
62
+ | `kiwoom_order_validate` | 주문 사전점검 (read-only) |
63
+ | `kiwoom_order` | 주문 전송 — `--allow-orders` 시에만 등록 |
64
+
65
+ ## 라이선스
66
+
67
+ Apache-2.0.
@@ -0,0 +1,37 @@
1
+ # kiwoom-mcp (server)
2
+
3
+ 이 디렉터리는 [kiwoom-plugin](https://github.com/gejyn14/kiwoom-plugin)의 MCP 서버 본체입니다. 플러그인이 `uvx`로 이 디렉터리를 직접 실행하므로 별도 배포(PyPI/npm)가 없습니다.
4
+
5
+ kiwoom-cli 236종 API를 MCP 도구로 노출합니다. 안전장치(주문 확인 게이트, dry-run, 멱등키, envelope)는 kiwoom-cli의 것을 그대로 통과시킵니다.
6
+
7
+ ## 단독 실행
8
+
9
+ ```bash
10
+ uvx --from "git+https://github.com/gejyn14/kiwoom-plugin@v0.1.0#subdirectory=server" kiwoom-mcp
11
+ uvx --from "git+https://github.com/gejyn14/kiwoom-plugin@v0.1.0#subdirectory=server" kiwoom-mcp --allow-orders
12
+ ```
13
+
14
+ 전송·인증·안전 모델은 상위 [README](../README.md)를 참고하세요.
15
+
16
+ ## 개발
17
+
18
+ ```bash
19
+ pip install -e ".[dev]"
20
+ pytest tests/ -q
21
+ ruff check kiwoom_mcp/
22
+ ```
23
+
24
+ ## 도구
25
+
26
+ | 도구 | 설명 |
27
+ |---|---|
28
+ | `kiwoom_describe` | 명령 트리 조회 |
29
+ | `kiwoom_find` | 키워드로 명령·API 검색 |
30
+ | `kiwoom_run` | 임의의 kiwoom-cli 명령 실행 |
31
+ | `kiwoom_stream_snapshot` | 실시간 스트림에서 N개 이벤트 |
32
+ | `kiwoom_order_validate` | 주문 사전점검 (read-only) |
33
+ | `kiwoom_order` | 주문 전송 — `--allow-orders` 시에만 등록 |
34
+
35
+ ## 라이선스
36
+
37
+ Apache-2.0.
@@ -0,0 +1,3 @@
1
+ """MCP server for kiwoom-cli."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,180 @@
1
+ """`kiwoom-mcp` 실행 진입점."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import secrets
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .runner import ServeSettings, credentials_available
13
+
14
+ LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
15
+
16
+
17
+ def _env_or_file(name: str) -> str | None:
18
+ """NAME 또는 NAME_FILE. 컨테이너 시크릿은 파일로 마운트된다."""
19
+ direct = os.environ.get(name)
20
+ path = os.environ.get(f"{name}_FILE")
21
+ if direct and path:
22
+ _die(f"{name}와 {name}_FILE이 동시에 설정되었습니다. 하나만 쓰세요.")
23
+ if direct:
24
+ return direct.strip() or None
25
+ if path:
26
+ try:
27
+ return Path(path).read_text(encoding="utf-8").strip() or None
28
+ except OSError as exc:
29
+ _die(f"{name}_FILE을 읽을 수 없습니다 ({path}): {exc}")
30
+ return None
31
+
32
+
33
+ def _die(message: str) -> None:
34
+ print(f"kiwoom-mcp: {message}", file=sys.stderr)
35
+ raise SystemExit(2)
36
+
37
+
38
+ def _env_flag(name: str) -> bool:
39
+ return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
40
+
41
+
42
+ def build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(
44
+ prog="kiwoom-mcp",
45
+ description="키움증권 kiwoom-cli를 MCP 서버로 노출합니다.",
46
+ )
47
+ parser.add_argument("--version", action="version", version=f"kiwoom-mcp {__version__}")
48
+ parser.add_argument("--transport", choices=("stdio", "http"), default="stdio",
49
+ help="기본 stdio. http는 streamable HTTP.")
50
+ parser.add_argument("--host", default="127.0.0.1", help="http 바인드 주소 (기본 127.0.0.1)")
51
+ parser.add_argument("--port", type=int, default=8765, help="http 포트 (기본 8765)")
52
+ parser.add_argument("--profile", default=os.environ.get("KIWOOM_PROFILE") or None,
53
+ help="kiwoom-cli 프로필. 모든 호출에 고정 적용됩니다.")
54
+ parser.add_argument("--allow-orders", action="store_true",
55
+ default=_env_flag("KIWOOM_MCP_ALLOW_ORDERS"),
56
+ help="주문 도구를 활성화합니다 (기본: 조회 전용).")
57
+ parser.add_argument("--allowed-host", action="append", default=[],
58
+ help="http Host 헤더 허용 목록에 추가합니다.")
59
+ return parser
60
+
61
+
62
+ def _resolve_http_auth(settings: ServeSettings) -> None:
63
+ """비루프백 바인드에는 토큰을 강제한다.
64
+
65
+ 증권 계좌에 닿는 엔드포인트를 네트워크에 인증 없이 여는 모드는 두지 않는다.
66
+ 조회 전용이어도 마찬가지다 — 잔고와 보유종목도 남에게 보일 것은 아니다.
67
+ """
68
+ token = _env_or_file("KIWOOM_MCP_AUTH_TOKEN")
69
+ settings.auth_token = token
70
+ is_loopback = settings.host in LOOPBACK_HOSTS
71
+ if not is_loopback and not token:
72
+ _die(
73
+ f"--host {settings.host}는 루프백이 아닙니다. KIWOOM_MCP_AUTH_TOKEN "
74
+ "(또는 _FILE)을 설정하세요. 컨테이너에서는 0.0.0.0 바인드가 정상이며, "
75
+ "호스트 노출은 -p 127.0.0.1:8765:8765로 제한하세요."
76
+ )
77
+ if is_loopback and not token:
78
+ print("kiwoom-mcp: [경고] 인증 토큰 없이 루프백에 바인드합니다 — "
79
+ "이 머신의 모든 로컬 프로세스가 호출할 수 있습니다.", file=sys.stderr)
80
+
81
+
82
+ def _banner(settings: ServeSettings) -> None:
83
+ """기동 상태를 stderr로 알린다 (stdout은 stdio 전송이 쓴다)."""
84
+ from kiwoom_cli import config
85
+
86
+ try:
87
+ env = config.get_domain_key(settings.profile)
88
+ except Exception:
89
+ env = "unknown"
90
+ # 자격증명 출처는 보고하지 않는다: config.appkey_source()가 키체인을 읽고,
91
+ # 그 읽기가 macOS에서 GUI 승인 창을 띄워 서버를 멈추게 할 수 있다.
92
+ # env 자격증명 여부만 키체인 없이 확인한다.
93
+ creds = "환경변수" if credentials_available() else "환경변수 없음 (키체인 또는 KIWOOM_TOKEN 사용)"
94
+
95
+ lines = [
96
+ f"kiwoom-mcp {__version__} — transport={settings.transport}",
97
+ f" 도메인: {env}" + (" [실거래]" if env == "prod" else ""),
98
+ f" 프로필: {settings.profile or '(기본)'}",
99
+ f" 자격증명: {creds}",
100
+ f" 주문: {'허용 (--allow-orders)' if settings.allow_orders else '차단 (조회 전용)'}",
101
+ ]
102
+ if settings.transport == "http":
103
+ lines.append(f" 바인드: {settings.host}:{settings.port} "
104
+ f"인증: {'bearer' if settings.auth_token else '없음'}")
105
+ print("\n".join(lines), file=sys.stderr)
106
+
107
+
108
+ def _serve_http(mcp, settings: ServeSettings) -> None:
109
+ import uvicorn
110
+ from starlette.responses import JSONResponse
111
+
112
+ app = mcp.streamable_http_app()
113
+ expected = settings.auth_token
114
+
115
+ if expected:
116
+ async def auth_middleware(scope, receive, send):
117
+ if scope["type"] != "http":
118
+ await app(scope, receive, send)
119
+ return
120
+ headers = dict(scope.get("headers") or [])
121
+ presented = headers.get(b"authorization", b"").decode()
122
+ expected_header = f"Bearer {expected}"
123
+ if not secrets.compare_digest(presented, expected_header):
124
+ response = JSONResponse(
125
+ {"error": "unauthorized"},
126
+ status_code=401,
127
+ headers={"WWW-Authenticate": "Bearer"},
128
+ )
129
+ await response(scope, receive, send)
130
+ return
131
+ await app(scope, receive, send)
132
+
133
+ served = auth_middleware
134
+ else:
135
+ served = app
136
+
137
+ uvicorn.run(served, host=settings.host, port=settings.port, log_level="info")
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ args = build_parser().parse_args(argv)
142
+
143
+ settings = ServeSettings(
144
+ allow_orders=args.allow_orders,
145
+ profile=args.profile,
146
+ transport=args.transport,
147
+ host=args.host,
148
+ port=args.port,
149
+ allowed_hosts=tuple(args.allowed_host),
150
+ )
151
+
152
+ if settings.transport == "http":
153
+ _resolve_http_auth(settings)
154
+ elif _env_or_file("KIWOOM_MCP_AUTH_TOKEN"):
155
+ print("kiwoom-mcp: [알림] stdio 전송에는 인증 개념이 없어 "
156
+ "KIWOOM_MCP_AUTH_TOKEN을 무시합니다.", file=sys.stderr)
157
+
158
+ _banner(settings)
159
+
160
+ # 기동 시에는 토큰을 만지지 않는다. auth.load_token()이 키체인을 읽는데,
161
+ # macOS는 처음 보는 바이너리의 키체인 접근에 GUI 승인 창을 띄우고, 헤드리스
162
+ # MCP 서버에는 그 창에 답할 사람이 없다 — 서버가 initialize에 응답하지 못하고
163
+ # 클라이언트는 "Failed to connect"으로 끝난다.
164
+ #
165
+ # 토큰은 첫 호출에서 필요할 때 확보한다. runner가 exit 3(AUTH_REQUIRED/
166
+ # TOKEN_EXPIRED)을 보면 env 자격증명으로 발급을 시도한다. MCP 서버는 빨리
167
+ # 뜨고 일은 늦게 하는 편이 옳기도 하다.
168
+ from .server import build_server
169
+
170
+ mcp = build_server(settings)
171
+
172
+ if settings.transport == "http":
173
+ _serve_http(mcp, settings)
174
+ else:
175
+ mcp.run(transport="stdio")
176
+ return 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ raise SystemExit(main())
@@ -0,0 +1,151 @@
1
+ """무엇을 MCP로 실행하게 둘지 분류한다.
2
+
3
+ argv를 **Click의 파서로** 해석한다. 문자열을 직접 훑으면 `--opt=value`,
4
+ 옵션 사이에 끼인 인자, 축약 옵션에서 실제 실행 경로와 분류가 갈릴 수 있다.
5
+ 분류기가 "조회"라고 판단한 argv가 실제로는 주문을 보내는 상황을 원리적으로
6
+ 막으려면 실행과 같은 해석기를 써야 한다.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Literal
13
+
14
+ import click
15
+ from kiwoom_cli.api_spec import MUTATION_APIS
16
+ from kiwoom_cli.main import cli
17
+
18
+ Kind = Literal["ok", "denied", "local_admin", "mutation"]
19
+
20
+ # 어떤 모드에서도 MCP로 실행하지 않는다.
21
+ # mcp* — 서버가 자기 자신을 띄우는 재귀
22
+ # watch — Rich Live TUI. 터미널을 점유하고 끝나지 않는다
23
+ DENIED_PREFIXES: tuple[tuple[str, ...], ...] = (
24
+ ("mcp",),
25
+ ("watch",),
26
+ )
27
+
28
+ # 로컬 상태를 바꾸는 명령. --allow-orders와 무관하게 차단한다: 서버의 자격증명
29
+ # 수명주기는 서버가 소유한다. 모델이 config.toml을 다시 쓰거나, 토큰을 폐기하거나,
30
+ # 멱등성 원장을 잘라내는 것은 어느 모드에서도 이득이 없다.
31
+ # (config show/profiles, auth status 같은 조회는 통과시킨다.)
32
+ LOCAL_ADMIN_PATHS: tuple[tuple[str, ...], ...] = (
33
+ ("config", "setup"),
34
+ ("config", "set"),
35
+ ("config", "use"),
36
+ ("config", "prune-ledger"),
37
+ ("auth", "login"),
38
+ ("auth", "logout"),
39
+ )
40
+
41
+ # --allow-orders가 있어야 실행되는 경로.
42
+ MUTATION_PREFIXES: tuple[tuple[str, ...], ...] = (
43
+ ("order",),
44
+ ("account", "exchange", "apply"),
45
+ )
46
+
47
+ # order 하위지만 아무것도 전송하지 않는 read-only 사전점검.
48
+ MUTATION_EXCEPTIONS: tuple[tuple[str, ...], ...] = (
49
+ ("order", "validate"),
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Classification:
55
+ path: tuple[str, ...]
56
+ kind: Kind
57
+ detail: str = ""
58
+
59
+
60
+ def _starts_with(path: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
61
+ return path[: len(prefix)] == prefix
62
+
63
+
64
+ def _remaining_args(ctx: click.Context) -> list[str]:
65
+ """Group.parse_args 이후 남은 인자 전부.
66
+
67
+ Click의 Group.parse_args는 첫 번째 남은 토큰(= 하위 명령 이름)을 반환값이
68
+ 아니라 protected_args에 넣는다. 반환값만 보면 하위 명령이 통째로 사라져
69
+ 트리를 한 칸도 내려가지 못한다 — 실제로 이 실수로 주문 경로가 조회로
70
+ 분류됐다. 속성 이름은 Click 8.2에서 _protected_args로 바뀌었으므로 둘 다 본다.
71
+ """
72
+ # _protected_args를 먼저 본다: Click 8.4의 공개 protected_args는 접근만 해도
73
+ # DeprecationWarning을 낸다. Click 9에서는 둘 다 없고 ctx.args가 전부 담으므로
74
+ # 이 함수는 그대로 옳다.
75
+ protected = getattr(ctx, "_protected_args", None)
76
+ if protected is None:
77
+ protected = getattr(ctx, "protected_args", [])
78
+ return [*protected, *ctx.args]
79
+
80
+
81
+ def resolve_path(argv: list[str]) -> tuple[tuple[str, ...], list[str]]:
82
+ """argv를 Click 트리로 걸어 (명령 경로, 남은 인자)를 돌려준다.
83
+
84
+ resilient_parsing=True는 부작용 없이 옵션을 소비한다 — 필수 인자 누락으로
85
+ 죽지 않고, 프롬프트도 뜨지 않는다. 해석할 수 없는 경로는 거기서 멈춘다
86
+ (그대로 실행하면 CLI 자신의 INVALID_INPUT으로 끝나므로 안전하다).
87
+ """
88
+ command: click.Command = cli
89
+ path: list[str] = []
90
+ args = list(argv)
91
+
92
+ while isinstance(command, click.Group):
93
+ ctx = click.Context(command, info_name=path[-1] if path else "kiwoom",
94
+ resilient_parsing=True)
95
+ try:
96
+ command.parse_args(ctx, list(args))
97
+ except Exception:
98
+ return tuple(path), args
99
+ remaining = _remaining_args(ctx)
100
+ if not remaining:
101
+ return tuple(path), []
102
+ name = remaining[0]
103
+ try:
104
+ sub = command.get_command(ctx, name)
105
+ except Exception:
106
+ sub = None
107
+ if sub is None:
108
+ # 알 수 없는 하위 명령 — 여기까지가 경로다
109
+ return tuple(path), remaining
110
+ path.append(sub.name or name)
111
+ command = sub
112
+ args = remaining[1:]
113
+
114
+ return tuple(path), args
115
+
116
+
117
+ def _mutation_api_id(path: tuple[str, ...], rest: list[str]) -> str | None:
118
+ """`kiwoom api <id>` 형태에서 주문성 api_id를 찾는다.
119
+
120
+ 문자열을 훑지 않고 남은 인자의 첫 위치 인자만 본다. `api list`는 통과.
121
+ """
122
+ if path != ("api",):
123
+ return None
124
+ for token in rest:
125
+ if token.startswith("-"):
126
+ continue
127
+ return token if token in MUTATION_APIS else None
128
+ return None
129
+
130
+
131
+ def classify_argv(argv: list[str]) -> Classification:
132
+ path, rest = resolve_path(argv)
133
+
134
+ for prefix in DENIED_PREFIXES:
135
+ if _starts_with(path, prefix):
136
+ return Classification(path, "denied", f"{'/'.join(prefix)}는 MCP로 실행하지 않습니다")
137
+
138
+ if path in LOCAL_ADMIN_PATHS:
139
+ return Classification(path, "local_admin", "로컬 설정·자격증명 변경은 MCP로 실행하지 않습니다")
140
+
141
+ api_id = _mutation_api_id(path, rest)
142
+ if api_id is not None:
143
+ return Classification(path, "mutation", f"주문성 API ({api_id})")
144
+
145
+ for prefix in MUTATION_PREFIXES:
146
+ if _starts_with(path, prefix):
147
+ if any(_starts_with(path, exc) for exc in MUTATION_EXCEPTIONS):
148
+ break
149
+ return Classification(path, "mutation", f"{' '.join(path)}는 주문을 전송할 수 있습니다")
150
+
151
+ return Classification(path, "ok")