khdp 0.1.0__py3-none-any.whl

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.
khdp/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """KHDP Connector — auth + MCP server for the Korea Health Data Platform."""
2
+
3
+ from khdp.config import Config, load_config
4
+ from khdp.oauth import AuthError, KhdpAuthClient, OAuthError, TokenSet
5
+ from khdp.token_store import TokenStore
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "AuthError",
11
+ "Config",
12
+ "KhdpAuthClient",
13
+ "OAuthError",
14
+ "TokenSet",
15
+ "TokenStore",
16
+ "__version__",
17
+ "load_config",
18
+ ]
khdp/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from khdp.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
khdp/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """``khdp`` command-line interface.
2
+
3
+ Subcommands:
4
+
5
+ * ``khdp login`` -- interactive email + password login against KHDP.
6
+ * ``khdp logout`` -- delete the cached token.
7
+ * ``khdp status`` -- show whether a token is cached.
8
+ * ``khdp refresh`` -- force a refresh-token rotation.
9
+ * ``khdp token`` -- print the current access token (use with care).
10
+ * ``khdp api METHOD PATH`` -- issue an authenticated API call.
11
+ * ``khdp config`` -- show the resolved configuration.
12
+ * ``khdp mcp`` -- start the MCP server on stdio.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import getpass
19
+ import json
20
+ import logging
21
+ import os
22
+ import sys
23
+ from collections.abc import Sequence
24
+
25
+ from khdp import __version__
26
+ from khdp.config import load_config
27
+ from khdp.oauth import AuthError
28
+ from khdp.session import Session
29
+
30
+
31
+ def _build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="khdp",
34
+ description="KHDP connector -- login + API calls + MCP server.",
35
+ )
36
+ parser.add_argument("--version", action="version", version=f"khdp {__version__}")
37
+ parser.add_argument(
38
+ "-v", "--verbose", action="count", default=0,
39
+ help="increase logging verbosity (repeat for debug)",
40
+ )
41
+
42
+ sub = parser.add_subparsers(dest="command", required=True)
43
+
44
+ p_login = sub.add_parser("login", help="log in with email + password")
45
+ p_login.add_argument("--email", help="account email (else $KHDP_EMAIL or prompt)")
46
+ p_login.add_argument(
47
+ "--password-stdin", action="store_true",
48
+ help="read password from stdin instead of prompting (for scripts)",
49
+ )
50
+
51
+ sub.add_parser("logout", help="delete cached tokens")
52
+ sub.add_parser("status", help="show cached token state")
53
+ sub.add_parser("refresh", help="force-refresh the access token")
54
+
55
+ p_token = sub.add_parser("token", help="print the current access token")
56
+ p_token.add_argument("--raw", action="store_true",
57
+ help="print only the token value (no JSON envelope)")
58
+
59
+ p_api = sub.add_parser("api", help="make an authenticated API call")
60
+ p_api.add_argument("method", help="HTTP method, e.g. GET / POST")
61
+ p_api.add_argument("path", help="API path or full URL")
62
+ p_api.add_argument("--query", action="append", default=[], metavar="KEY=VAL",
63
+ help="query parameter (repeatable)")
64
+ p_api.add_argument("--data", help="JSON body string")
65
+
66
+ sub.add_parser("mcp", help="run the KHDP MCP server on stdio")
67
+ sub.add_parser("config", help="show resolved configuration")
68
+
69
+ return parser
70
+
71
+
72
+ def _setup_logging(verbose: int) -> None:
73
+ level = logging.WARNING
74
+ if verbose == 1:
75
+ level = logging.INFO
76
+ elif verbose >= 2:
77
+ level = logging.DEBUG
78
+ logging.basicConfig(level=level, format="[khdp] %(levelname)s %(message)s")
79
+
80
+
81
+ def _emit(payload: object) -> None:
82
+ print(json.dumps(payload, indent=2, sort_keys=True, default=str))
83
+
84
+
85
+ def _resolve_email(args: argparse.Namespace) -> str:
86
+ email = args.email or os.environ.get("KHDP_EMAIL")
87
+ if email:
88
+ return email
89
+ if not sys.stdin.isatty():
90
+ raise SystemExit(
91
+ "[khdp] no --email given and stdin is not a TTY. "
92
+ "Set KHDP_EMAIL or pass --email."
93
+ )
94
+ return input("KHDP email: ").strip()
95
+
96
+
97
+ def _resolve_password(args: argparse.Namespace) -> str:
98
+ if args.password_stdin:
99
+ password = sys.stdin.readline().rstrip("\n")
100
+ if not password:
101
+ raise SystemExit("[khdp] --password-stdin given but stdin was empty.")
102
+ return password
103
+ env = os.environ.get("KHDP_PASSWORD")
104
+ if env:
105
+ return env
106
+ if not sys.stdin.isatty():
107
+ raise SystemExit(
108
+ "[khdp] no password available. Use --password-stdin or set KHDP_PASSWORD."
109
+ )
110
+ return getpass.getpass("KHDP password: ")
111
+
112
+
113
+ def _cmd_login(session: Session, args: argparse.Namespace) -> int:
114
+ email = _resolve_email(args)
115
+ password = _resolve_password(args)
116
+ tokens = session.login(email=email, password=password)
117
+ _emit({
118
+ "ok": True,
119
+ "app_id": tokens.app_id,
120
+ "expires_at": tokens.expires_at,
121
+ "has_refresh_token": tokens.refresh_token is not None,
122
+ })
123
+ return 0
124
+
125
+
126
+ def _cmd_logout(session: Session, _args: argparse.Namespace) -> int:
127
+ deleted = session.logout()
128
+ _emit({"ok": True, "deleted": deleted})
129
+ return 0
130
+
131
+
132
+ def _cmd_status(session: Session, _args: argparse.Namespace) -> int:
133
+ _emit(session.status())
134
+ return 0
135
+
136
+
137
+ def _cmd_refresh(session: Session, _args: argparse.Namespace) -> int:
138
+ tokens = session.store.load(session.config.app_id or None)
139
+ if not tokens or not tokens.refresh_token:
140
+ print("[khdp] no refresh token cached; run `khdp login` first.", file=sys.stderr)
141
+ return 1
142
+ refreshed = session.auth.refresh(tokens.refresh_token)
143
+ if not refreshed.refresh_token:
144
+ refreshed.refresh_token = tokens.refresh_token
145
+ if not refreshed.app_id:
146
+ refreshed.app_id = tokens.app_id or session.config.app_id
147
+ session.store.save(refreshed)
148
+ _emit({"ok": True, "expires_at": refreshed.expires_at})
149
+ return 0
150
+
151
+
152
+ def _cmd_token(session: Session, args: argparse.Namespace) -> int:
153
+ token = session.access_token()
154
+ if args.raw:
155
+ print(token)
156
+ else:
157
+ _emit({"access_token": token})
158
+ return 0
159
+
160
+
161
+ def _cmd_api(session: Session, args: argparse.Namespace) -> int:
162
+ params: dict[str, str] = {}
163
+ for kv in args.query:
164
+ if "=" not in kv:
165
+ print(f"[khdp] invalid --query (expected KEY=VAL): {kv}", file=sys.stderr)
166
+ return 2
167
+ k, v = kv.split("=", 1)
168
+ params[k] = v
169
+ body = json.loads(args.data) if args.data else None
170
+ resp = session.authed_request(args.method, args.path, params=params or None, json=body)
171
+ print(f"[khdp] {resp.status_code} {resp.reason_phrase}", file=sys.stderr)
172
+ try:
173
+ _emit(resp.json())
174
+ except ValueError:
175
+ sys.stdout.write(resp.text)
176
+ return 0 if resp.is_success else 1
177
+
178
+
179
+ def _cmd_mcp(_session: Session, _args: argparse.Namespace) -> int:
180
+ from khdp.mcp_server import run_stdio
181
+ run_stdio()
182
+ return 0
183
+
184
+
185
+ def _cmd_config(session: Session, _args: argparse.Namespace) -> int:
186
+ cfg = session.config
187
+ _emit({
188
+ "app_id": cfg.app_id or None,
189
+ "redirect_url": cfg.redirect_url or None,
190
+ "api_base": cfg.api_base,
191
+ "token_dir": str(cfg.token_dir),
192
+ "use_keyring": cfg.use_keyring,
193
+ })
194
+ return 0
195
+
196
+
197
+ _DISPATCH = {
198
+ "login": _cmd_login,
199
+ "logout": _cmd_logout,
200
+ "status": _cmd_status,
201
+ "refresh": _cmd_refresh,
202
+ "token": _cmd_token,
203
+ "api": _cmd_api,
204
+ "mcp": _cmd_mcp,
205
+ "config": _cmd_config,
206
+ }
207
+
208
+
209
+ def main(argv: Sequence[str] | None = None) -> int:
210
+ parser = _build_parser()
211
+ args = parser.parse_args(argv)
212
+ _setup_logging(args.verbose)
213
+
214
+ config = load_config()
215
+ try:
216
+ with Session.open(config=config) as session:
217
+ return _DISPATCH[args.command](session, args)
218
+ except AuthError as exc:
219
+ print(f"[khdp] {exc}", file=sys.stderr)
220
+ return 1
221
+ except KeyboardInterrupt:
222
+ print("[khdp] interrupted.", file=sys.stderr)
223
+ return 130
224
+
225
+
226
+ if __name__ == "__main__": # pragma: no cover
227
+ raise SystemExit(main())
khdp/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Configuration loading for the KHDP connector.
2
+
3
+ KHDP authentication is identified by an ``app_id`` (UUID) registered
4
+ with KHDP and a ``redirect_url`` allowlisted for that app. Both values
5
+ are required for any login or token-refresh call.
6
+
7
+ Resolution order (highest priority first):
8
+
9
+ 1. Environment variables: ``KHDP_*``
10
+ 2. ``khdp.local.toml`` in the current working directory
11
+ 3. ``$XDG_CONFIG_HOME/khdp/config.toml`` (or platform equivalent)
12
+ 4. Built-in defaults pointing at the production KHDP API.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from platformdirs import user_config_dir
24
+
25
+ if sys.version_info >= (3, 11):
26
+ import tomllib
27
+ else: # pragma: no cover
28
+ import tomli as tomllib
29
+
30
+
31
+ # KHDP central API base — observed at https://khdp.net/_api.
32
+ DEFAULT_API_BASE = "https://khdp.net/_api"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Config:
37
+ """Resolved configuration for the connector."""
38
+
39
+ # KHDP app registration. ``app_id`` is a UUID issued by KHDP at app
40
+ # registration time; ``redirect_url`` must match one of the URLs
41
+ # allowlisted for that app. Both are required even for headless
42
+ # password login because the KHDP backend validates them.
43
+ app_id: str = ""
44
+ redirect_url: str = ""
45
+ # KHDP API base. Override for staging / on-prem deployments.
46
+ api_base: str = DEFAULT_API_BASE
47
+ # Where tokens go on disk. Defaults to platform user-config dir.
48
+ token_dir: Path = field(default_factory=lambda: Path(user_config_dir("khdp")))
49
+ # Use OS keychain via the optional ``keyring`` extra when available.
50
+ use_keyring: bool = True
51
+
52
+ extras: dict[str, Any] = field(default_factory=dict)
53
+
54
+
55
+ def _config_path_user() -> Path:
56
+ return Path(user_config_dir("khdp")) / "config.toml"
57
+
58
+
59
+ def _config_path_local() -> Path:
60
+ return Path.cwd() / "khdp.local.toml"
61
+
62
+
63
+ def _read_toml(path: Path) -> dict[str, Any]:
64
+ if not path.is_file():
65
+ return {}
66
+ with path.open("rb") as fh:
67
+ return tomllib.load(fh)
68
+
69
+
70
+ def _env_overrides() -> dict[str, Any]:
71
+ mapping = {
72
+ "app_id": "KHDP_APP_ID",
73
+ "redirect_url": "KHDP_REDIRECT_URL",
74
+ "api_base": "KHDP_API_BASE",
75
+ }
76
+ out: dict[str, Any] = {}
77
+ for key, env in mapping.items():
78
+ if (val := os.environ.get(env)) is not None:
79
+ out[key] = val
80
+ if (token_dir := os.environ.get("KHDP_TOKEN_DIR")) is not None:
81
+ out["token_dir"] = Path(token_dir).expanduser()
82
+ if (use_kr := os.environ.get("KHDP_USE_KEYRING")) is not None:
83
+ out["use_keyring"] = use_kr.lower() not in {"0", "false", "no", "off"}
84
+ return out
85
+
86
+
87
+ def load_config(*, extra_path: Path | None = None) -> Config:
88
+ """Resolve config from defaults + files + environment."""
89
+ layers: list[dict[str, Any]] = [_read_toml(_config_path_user())]
90
+ if extra_path is not None:
91
+ layers.append(_read_toml(extra_path))
92
+ layers.append(_read_toml(_config_path_local()))
93
+ layers.append(_env_overrides())
94
+
95
+ merged: dict[str, Any] = {}
96
+ for layer in layers:
97
+ merged.update(layer)
98
+
99
+ if "token_dir" in merged and not isinstance(merged["token_dir"], Path):
100
+ merged["token_dir"] = Path(merged["token_dir"]).expanduser()
101
+
102
+ valid_fields = {f for f in Config.__dataclass_fields__ if f != "extras"}
103
+ extras = {k: v for k, v in merged.items() if k not in valid_fields}
104
+ primary = {k: v for k, v in merged.items() if k in valid_fields}
105
+
106
+ return Config(extras=extras, **primary)
khdp/mcp_server.py ADDED
@@ -0,0 +1,231 @@
1
+ """MCP server exposing KHDP authentication state + thin API access.
2
+
3
+ This is the Tier 1 surface from PLAN.md. It runs over stdio so it can be
4
+ spawned by any MCP-aware client (Claude Code, Codex, Gemini CLI, custom
5
+ agents). The same tool set is reused across all wrappers, which is the
6
+ whole point of MCP-as-foundation.
7
+
8
+ The MCP server **never** accepts a password through tool arguments --
9
+ passwords would otherwise flow through the LLM context window. Login
10
+ is initiated out-of-band by the user via ``khdp login`` in their
11
+ terminal; the MCP server reads the resulting token cache.
12
+
13
+ Tools exposed:
14
+ - khdp_auth_status : is the user logged in? when does the token expire?
15
+ - khdp_auth_refresh : rotate the refresh token to extend the session.
16
+ - khdp_auth_logout : delete the locally cached tokens.
17
+ - khdp_api_request : authenticated passthrough to the KHDP API.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import contextlib
24
+ import json
25
+ import logging
26
+ from typing import Any
27
+
28
+ from khdp.oauth import AuthError
29
+ from khdp.session import Session
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+
34
+ _TOOLS: list[dict[str, Any]] = [
35
+ {
36
+ "name": "khdp_auth_status",
37
+ "description": (
38
+ "Return whether the user is logged in to KHDP and, if so, when "
39
+ "the access token expires. Safe to call at any time; never "
40
+ "performs a network request."
41
+ ),
42
+ "inputSchema": {
43
+ "type": "object",
44
+ "properties": {},
45
+ "additionalProperties": False,
46
+ },
47
+ },
48
+ {
49
+ "name": "khdp_auth_refresh",
50
+ "description": (
51
+ "Rotate the refresh token to extend the session. Use when "
52
+ "khdp_auth_status reports is_expired=true. Fails with an "
53
+ "AuthError if there is no cached refresh token (the user "
54
+ "must run `khdp login` in a terminal first)."
55
+ ),
56
+ "inputSchema": {
57
+ "type": "object",
58
+ "properties": {},
59
+ "additionalProperties": False,
60
+ },
61
+ },
62
+ {
63
+ "name": "khdp_auth_logout",
64
+ "description": (
65
+ "Delete the locally cached KHDP tokens. KHDP does not expose "
66
+ "a refresh-token revocation endpoint, so this only clears "
67
+ "client-side state."
68
+ ),
69
+ "inputSchema": {
70
+ "type": "object",
71
+ "properties": {},
72
+ "additionalProperties": False,
73
+ },
74
+ },
75
+ {
76
+ "name": "khdp_api_request",
77
+ "description": (
78
+ "Make an authenticated HTTP request against the KHDP API. The "
79
+ "Authorization: Bearer header is added automatically. Use "
80
+ "this as the transport for any KHDP backend endpoint that "
81
+ "does not yet have a dedicated MCP tool."
82
+ ),
83
+ "inputSchema": {
84
+ "type": "object",
85
+ "required": ["method", "path"],
86
+ "properties": {
87
+ "method": {
88
+ "type": "string",
89
+ "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
90
+ "description": "HTTP method.",
91
+ },
92
+ "path": {
93
+ "type": "string",
94
+ "description": (
95
+ "API path (e.g. '/oauth/redirect-url') or "
96
+ "absolute URL. Relative paths are resolved "
97
+ "against KHDP_API_BASE (default https://khdp.net/_api)."
98
+ ),
99
+ },
100
+ "query": {
101
+ "type": "object",
102
+ "additionalProperties": {"type": "string"},
103
+ "description": "Query string parameters.",
104
+ },
105
+ "json": {
106
+ "description": "JSON body for POST/PUT/PATCH.",
107
+ },
108
+ },
109
+ "additionalProperties": False,
110
+ },
111
+ },
112
+ ]
113
+
114
+
115
+ def _result_text(payload: object) -> dict[str, Any]:
116
+ return {
117
+ "content": [
118
+ {"type": "text", "text": json.dumps(payload, indent=2, default=str, sort_keys=True)}
119
+ ]
120
+ }
121
+
122
+
123
+ def _error_text(message: str) -> dict[str, Any]:
124
+ return {
125
+ "content": [{"type": "text", "text": f"Error: {message}"}],
126
+ "isError": True,
127
+ }
128
+
129
+
130
+ def _dispatch(session: Session, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
131
+ try:
132
+ if name == "khdp_auth_status":
133
+ return _result_text(session.status())
134
+ if name == "khdp_auth_refresh":
135
+ tokens = session.store.load(session.config.app_id or None)
136
+ if not tokens or not tokens.refresh_token:
137
+ raise AuthError(
138
+ "No refresh token cached. Run `khdp login` in a terminal first."
139
+ )
140
+ refreshed = session.auth.refresh(tokens.refresh_token)
141
+ if not refreshed.refresh_token:
142
+ refreshed.refresh_token = tokens.refresh_token
143
+ if not refreshed.app_id:
144
+ refreshed.app_id = tokens.app_id or session.config.app_id
145
+ session.store.save(refreshed)
146
+ return _result_text({"ok": True, "expires_at": refreshed.expires_at})
147
+ if name == "khdp_auth_logout":
148
+ deleted = session.logout()
149
+ return _result_text({"ok": True, "deleted": deleted})
150
+ if name == "khdp_api_request":
151
+ method = arguments["method"]
152
+ path = arguments["path"]
153
+ query = arguments.get("query") or None
154
+ body = arguments.get("json")
155
+ resp = session.authed_request(method, path, params=query, json=body)
156
+ try:
157
+ data: object = resp.json()
158
+ except ValueError:
159
+ data = resp.text
160
+ return _result_text({
161
+ "status": resp.status_code,
162
+ "reason": resp.reason_phrase,
163
+ "body": data,
164
+ })
165
+ return _error_text(f"Unknown tool: {name}")
166
+ except AuthError as exc:
167
+ return _error_text(str(exc))
168
+ except Exception as exc:
169
+ log.exception("Tool %s failed", name)
170
+ return _error_text(f"{type(exc).__name__}: {exc}")
171
+
172
+
173
+ # --- Server wiring (official MCP SDK) ------------------------------------
174
+
175
+
176
+ async def _run_async() -> None:
177
+ # Imported here so users running just the CLI don't need the MCP extra.
178
+ import mcp.types as mt
179
+ from mcp.server.lowlevel import Server
180
+ from mcp.server.stdio import stdio_server
181
+
182
+ server = Server("khdp")
183
+ session = Session.open()
184
+
185
+ @server.list_tools()
186
+ async def _list_tools() -> list[mt.Tool]:
187
+ return [
188
+ mt.Tool(
189
+ name=t["name"],
190
+ description=t["description"],
191
+ inputSchema=t["inputSchema"],
192
+ )
193
+ for t in _TOOLS
194
+ ]
195
+
196
+ @server.call_tool()
197
+ async def _call_tool(name: str, arguments: dict[str, Any] | None) -> list[mt.TextContent]:
198
+ result = _dispatch(session, name, arguments or {})
199
+ contents = result.get("content", [])
200
+ out: list[mt.TextContent] = []
201
+ for c in contents:
202
+ if c.get("type") == "text":
203
+ out.append(mt.TextContent(type="text", text=c["text"]))
204
+ if result.get("isError"):
205
+ raise RuntimeError(contents[0]["text"] if contents else "tool error")
206
+ return out
207
+
208
+ async with stdio_server() as (read_stream, write_stream):
209
+ await server.run(
210
+ read_stream,
211
+ write_stream,
212
+ server.create_initialization_options(),
213
+ )
214
+
215
+
216
+ def run_stdio() -> None:
217
+ """Entry point used by the CLI and the ``khdp-mcp`` console script."""
218
+ logging.basicConfig(
219
+ level=logging.INFO,
220
+ format="[khdp-mcp] %(levelname)s %(message)s",
221
+ )
222
+ with contextlib.suppress(KeyboardInterrupt):
223
+ asyncio.run(_run_async())
224
+
225
+
226
+ def main() -> None: # pragma: no cover
227
+ run_stdio()
228
+
229
+
230
+ if __name__ == "__main__": # pragma: no cover
231
+ main()
khdp/oauth.py ADDED
@@ -0,0 +1,220 @@
1
+ """KHDP authentication client.
2
+
3
+ KHDP authenticates apps via an ``appId`` (UUID) registered with KHDP
4
+ plus an allowlisted ``redirectUrl``. KHDP issues a Bearer
5
+ ``accessToken`` + ``refreshToken`` pair. This module wraps the two
6
+ endpoints used by the CLI / MCP server:
7
+
8
+ * ``POST /_api/oauth/login`` -- direct ``mail + password`` login.
9
+ * ``POST /_api/member/refresh-token`` -- rotate an expired access token.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import time
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from khdp.config import Config
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class AuthError(RuntimeError):
27
+ """Raised when KHDP rejects a login or refresh request."""
28
+
29
+
30
+ # Backward-compat alias -- previous draft used the OIDC name.
31
+ OAuthError = AuthError
32
+
33
+
34
+ @dataclass
35
+ class TokenSet:
36
+ """Bearer token pair issued by KHDP."""
37
+
38
+ access_token: str
39
+ refresh_token: str | None = None
40
+ # KHDP's payload uses ``expireTime`` as an absolute unix-millis timestamp.
41
+ # We normalise to seconds and store the absolute moment of expiry.
42
+ expires_at: float = 0.0
43
+ app_id: str = ""
44
+ obtained_at: float = field(default_factory=time.time)
45
+
46
+ @property
47
+ def is_expired(self) -> bool:
48
+ if self.expires_at == 0.0:
49
+ return False
50
+ # 30 second skew so the server doesn't reject a token we just refreshed.
51
+ return time.time() >= (self.expires_at - 30)
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return asdict(self)
55
+
56
+ @classmethod
57
+ def from_dict(cls, payload: dict[str, Any]) -> TokenSet:
58
+ return cls(**payload)
59
+
60
+ @classmethod
61
+ def from_khdp_response(cls, payload: dict[str, Any], *, app_id: str) -> TokenSet:
62
+ """Normalise a KHDP token response.
63
+
64
+ KHDP's documented field is ``expireTime``. The shape observed in
65
+ the public ``khdp.net`` SPA bundle is:
66
+
67
+ ```json
68
+ { "accessToken": "...", "refreshToken": "...", "expireTime": 173... }
69
+ ```
70
+
71
+ ``expireTime`` is unix milliseconds in the SPA's localStorage
72
+ usage, but tolerate both seconds and milliseconds because
73
+ upstream documentation is not public.
74
+ """
75
+ access = payload.get("accessToken") or payload.get("access_token")
76
+ if not access:
77
+ raise AuthError(f"Login response missing accessToken: {payload}")
78
+ refresh = payload.get("refreshToken") or payload.get("refresh_token")
79
+ expire_raw = payload.get("expireTime") or payload.get("expire_time") or 0
80
+ try:
81
+ expire_num = float(expire_raw)
82
+ except (TypeError, ValueError):
83
+ expire_num = 0.0
84
+ # If the value looks like milliseconds (>= 10^12), convert to seconds.
85
+ if expire_num >= 1e12:
86
+ expire_num = expire_num / 1000.0
87
+ # If the value is < 10^9 it's almost certainly a relative duration
88
+ # in seconds (rare but seen on some KHDP environments).
89
+ if 0 < expire_num < 1e9:
90
+ expire_num = time.time() + expire_num
91
+ return cls(
92
+ access_token=access,
93
+ refresh_token=refresh,
94
+ expires_at=expire_num,
95
+ app_id=app_id,
96
+ obtained_at=time.time(),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class _Endpoints:
102
+ login: str
103
+ refresh: str
104
+ auto_login: str
105
+ consent: str
106
+
107
+
108
+ class KhdpAuthClient:
109
+ """Thin client for KHDP's ``/_api/oauth/*`` and ``/_api/member/*`` routes.
110
+
111
+ Two flows are supported:
112
+
113
+ * :meth:`password_login` -- direct ``mail + password`` exchange
114
+ against ``POST /_api/oauth/login``. The KHDP-registered app is
115
+ identified by ``appId`` and ``redirectUrl`` (both required by the
116
+ backend even though no browser redirect is involved).
117
+ * :meth:`refresh` -- rotate an expired access token via
118
+ ``POST /_api/member/refresh-token``.
119
+
120
+ """
121
+
122
+ def __init__(self, config: Config, *, http_client: httpx.Client | None = None) -> None:
123
+ self.config = config
124
+ self._http = http_client or httpx.Client(
125
+ timeout=30.0,
126
+ headers={"User-Agent": "khdp/0.1.0"},
127
+ )
128
+ base = config.api_base.rstrip("/")
129
+ self._endpoints = _Endpoints(
130
+ login=f"{base}/oauth/login",
131
+ refresh=f"{base}/member/refresh-token",
132
+ auto_login=f"{base}/member/auto-login",
133
+ consent=f"{base}/oauth/consent",
134
+ )
135
+
136
+ def close(self) -> None:
137
+ self._http.close()
138
+
139
+ def __enter__(self) -> KhdpAuthClient:
140
+ return self
141
+
142
+ def __exit__(self, *exc: object) -> None:
143
+ self.close()
144
+
145
+ # -- public API --------------------------------------------------------
146
+
147
+ def password_login(self, *, email: str, password: str) -> TokenSet:
148
+ """Log in with email + password.
149
+
150
+ Requires ``app_id`` and ``redirect_url`` to be set on the config.
151
+ Both fields are validated server-side: ``appId`` must be a UUID
152
+ and ``redirectUrl`` must be a URL. ``password`` must be 8-50
153
+ characters; KHDP returns HTTP 400 with explicit field-level
154
+ errors for any violation.
155
+ """
156
+ if not self.config.app_id:
157
+ raise AuthError(
158
+ "config.app_id is required for KHDP login. "
159
+ "Register a KHDP app and set it via KHDP_APP_ID or khdp.local.toml."
160
+ )
161
+ if not self.config.redirect_url:
162
+ raise AuthError(
163
+ "config.redirect_url is required for KHDP login. "
164
+ "Set the value registered with the KHDP app (any allowlisted URL)."
165
+ )
166
+
167
+ body = {
168
+ "appId": self.config.app_id,
169
+ "redirectUrl": self.config.redirect_url,
170
+ "mail": email,
171
+ "password": password,
172
+ }
173
+ return self._post_token(self._endpoints.login, body)
174
+
175
+ def refresh(self, refresh_token: str) -> TokenSet:
176
+ """Rotate an expired access token.
177
+
178
+ KHDP returns HTTP 403 with message
179
+ ``"Refresh Token Is Not Validate"`` (their typo, preserved) when
180
+ the token has been revoked or expired beyond the refresh window.
181
+ """
182
+ return self._post_token(self._endpoints.refresh, {"refreshToken": refresh_token})
183
+
184
+ def auto_login(self, *, access_token: str, refresh_token: str) -> TokenSet:
185
+ """Sliding-refresh login used by the KHDP web client at startup.
186
+
187
+ Useful for re-establishing a session from a previously cached
188
+ token pair without prompting for a password.
189
+ """
190
+ body = {"accessToken": access_token, "refreshToken": refresh_token}
191
+ return self._post_token(self._endpoints.auto_login, body)
192
+
193
+ # -- internals --------------------------------------------------------
194
+
195
+ def _post_token(self, url: str, body: dict[str, Any]) -> TokenSet:
196
+ try:
197
+ resp = self._http.post(url, json=body)
198
+ except httpx.HTTPError as exc:
199
+ raise AuthError(f"KHDP endpoint unreachable ({url}): {exc}") from exc
200
+
201
+ if resp.status_code == 200:
202
+ try:
203
+ payload = resp.json()
204
+ except ValueError as exc:
205
+ raise AuthError(f"KHDP returned non-JSON success: {resp.text[:200]}") from exc
206
+ return TokenSet.from_khdp_response(payload, app_id=self.config.app_id)
207
+
208
+ # Surface the most useful piece of the JSON error body.
209
+ detail: str = resp.text[:400]
210
+ try:
211
+ payload = resp.json()
212
+ if isinstance(payload, dict):
213
+ msg = payload.get("message")
214
+ if isinstance(msg, list):
215
+ detail = "; ".join(str(m) for m in msg)
216
+ elif isinstance(msg, str):
217
+ detail = msg
218
+ except ValueError:
219
+ pass
220
+ raise AuthError(f"KHDP {resp.status_code} {url}: {detail}")
khdp/session.py ADDED
@@ -0,0 +1,127 @@
1
+ """Session helpers -- combine ``KhdpAuthClient`` and ``TokenStore`` to give
2
+ callers a single ``access_token()`` style API that handles refresh
3
+ transparently."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from khdp.config import Config, load_config
14
+ from khdp.oauth import AuthError, KhdpAuthClient, TokenSet
15
+ from khdp.token_store import TokenStore
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class Session:
22
+ config: Config
23
+ auth: KhdpAuthClient
24
+ store: TokenStore
25
+
26
+ @classmethod
27
+ def open(cls, *, config: Config | None = None) -> Session:
28
+ cfg = config or load_config()
29
+ return cls(
30
+ config=cfg,
31
+ auth=KhdpAuthClient(cfg),
32
+ store=TokenStore(cfg.token_dir, use_keyring=cfg.use_keyring),
33
+ )
34
+
35
+ def close(self) -> None:
36
+ self.auth.close()
37
+
38
+ def __enter__(self) -> Session:
39
+ return self
40
+
41
+ def __exit__(self, *exc: object) -> None:
42
+ self.close()
43
+
44
+ # ------------------------------------------------------------------
45
+
46
+ def login(self, *, email: str, password: str) -> TokenSet:
47
+ tokens = self.auth.password_login(email=email, password=password)
48
+ self.store.save(tokens)
49
+ return tokens
50
+
51
+ def logout(self) -> bool:
52
+ """Delete locally cached tokens.
53
+
54
+ KHDP's public ``/_api`` surface does not expose a refresh-token
55
+ revocation endpoint at the time of writing. The web SPA logs
56
+ out by clearing local state and letting the access token expire
57
+ naturally; we follow the same approach. Returns ``True`` if a
58
+ token was deleted, ``False`` if there was nothing to delete.
59
+ """
60
+ return self.store.delete(self.config.app_id or None)
61
+
62
+ def status(self) -> dict[str, Any]:
63
+ tokens = self.store.load(self.config.app_id or None)
64
+ if not tokens:
65
+ return {
66
+ "authenticated": False,
67
+ "app_id": self.config.app_id or None,
68
+ }
69
+ return {
70
+ "authenticated": True,
71
+ "app_id": tokens.app_id or self.config.app_id,
72
+ "expires_at": tokens.expires_at,
73
+ "is_expired": tokens.is_expired,
74
+ "has_refresh_token": tokens.refresh_token is not None,
75
+ }
76
+
77
+ def access_token(self) -> str:
78
+ """Return a valid access token, refreshing if necessary.
79
+
80
+ Raises :class:`AuthError` if the user has never logged in or the
81
+ refresh token has been revoked.
82
+ """
83
+ tokens = self.store.load(self.config.app_id or None)
84
+ if tokens is None:
85
+ raise AuthError("Not logged in. Run `khdp login` first.")
86
+ if not tokens.is_expired:
87
+ return tokens.access_token
88
+ if not tokens.refresh_token:
89
+ raise AuthError("Access token expired and no refresh token is available.")
90
+ log.debug("Refreshing expired access token for app %s", self.config.app_id)
91
+ refreshed = self.auth.refresh(tokens.refresh_token)
92
+ if not refreshed.refresh_token:
93
+ # Some KHDP environments may omit refresh_token on refresh
94
+ # -- keep the previous one.
95
+ refreshed.refresh_token = tokens.refresh_token
96
+ if not refreshed.app_id:
97
+ refreshed.app_id = tokens.app_id or self.config.app_id
98
+ self.store.save(refreshed)
99
+ return refreshed.access_token
100
+
101
+ def authed_request(
102
+ self,
103
+ method: str,
104
+ path: str,
105
+ *,
106
+ params: dict[str, Any] | None = None,
107
+ json: Any = None,
108
+ ) -> httpx.Response:
109
+ """Issue an authenticated request against the KHDP API base.
110
+
111
+ ``path`` may be a full URL or a path relative to ``config.api_base``.
112
+ """
113
+ url = path if path.startswith(("http://", "https://")) else (
114
+ self.config.api_base.rstrip("/") + "/" + path.lstrip("/")
115
+ )
116
+ token = self.access_token()
117
+ with httpx.Client(timeout=30.0) as http:
118
+ return http.request(
119
+ method.upper(),
120
+ url,
121
+ params=params,
122
+ json=json,
123
+ headers={
124
+ "Authorization": f"Bearer {token}",
125
+ "User-Agent": "khdp/0.1.0",
126
+ },
127
+ )
khdp/token_store.py ADDED
@@ -0,0 +1,140 @@
1
+ """Persistent storage for KHDP access + refresh tokens.
2
+
3
+ Two backends:
4
+
5
+ * OS keyring (macOS Keychain, Windows Credential Manager, Secret Service
6
+ on Linux) -- used when the optional ``keyring`` extra is installed and
7
+ the user has not opted out via config.
8
+ * JSON file under the user config dir, written with ``0o600`` so other
9
+ local users cannot read it.
10
+
11
+ Each token set is keyed by ``app_id`` so multiple KHDP-registered apps
12
+ can coexist on one machine.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from khdp.oauth import TokenSet
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ _KEYRING_SERVICE = "khdp"
29
+
30
+
31
+ def _restrict_permissions(path: Path) -> None:
32
+ """Best-effort 0o600 on POSIX. On Windows, file ACLs default to user-only
33
+ in the per-user config dir, which is acceptable for a public client."""
34
+ if sys.platform == "win32":
35
+ return
36
+ try:
37
+ os.chmod(path, 0o600)
38
+ except OSError: # pragma: no cover
39
+ log.warning("Could not chmod %s -- token file permissions may be permissive.", path)
40
+
41
+
42
+ class TokenStore:
43
+ """Stores and retrieves :class:`TokenSet` objects keyed by ``app_id``."""
44
+
45
+ def __init__(self, token_dir: Path, *, use_keyring: bool = True) -> None:
46
+ self.token_dir = token_dir
47
+ self.token_dir.mkdir(parents=True, exist_ok=True)
48
+ self._file = token_dir / "tokens.json"
49
+ self._keyring = self._maybe_load_keyring() if use_keyring else None
50
+
51
+ @staticmethod
52
+ def _maybe_load_keyring() -> Any:
53
+ try:
54
+ import keyring # type: ignore[import-not-found]
55
+ except ImportError:
56
+ return None
57
+ try:
58
+ keyring.get_keyring()
59
+ except Exception: # pragma: no cover
60
+ return None
61
+ return keyring
62
+
63
+ # -- public API -------------------------------------------------------
64
+
65
+ def save(self, tokens: TokenSet) -> None:
66
+ key = tokens.app_id or "default"
67
+ if self._keyring is not None:
68
+ try:
69
+ self._keyring.set_password(
70
+ _KEYRING_SERVICE, key, json.dumps(tokens.to_dict())
71
+ )
72
+ self._write_index(key)
73
+ return
74
+ except Exception as exc: # pragma: no cover
75
+ log.warning("Keyring write failed (%s); falling back to file.", exc)
76
+ self._write_file(key, tokens.to_dict())
77
+
78
+ def load(self, app_id: str | None = None) -> TokenSet | None:
79
+ key = app_id or "default"
80
+ if self._keyring is not None:
81
+ try:
82
+ raw = self._keyring.get_password(_KEYRING_SERVICE, key)
83
+ except Exception: # pragma: no cover
84
+ raw = None
85
+ if raw:
86
+ return TokenSet.from_dict(json.loads(raw))
87
+ data = self._read_file()
88
+ entry = data.get(key)
89
+ if not entry or not isinstance(entry, dict) or "access_token" not in entry:
90
+ return None
91
+ return TokenSet.from_dict(entry)
92
+
93
+ def delete(self, app_id: str | None = None) -> bool:
94
+ key = app_id or "default"
95
+ deleted = False
96
+ if self._keyring is not None:
97
+ try:
98
+ self._keyring.delete_password(_KEYRING_SERVICE, key)
99
+ deleted = True
100
+ except Exception: # pragma: no cover
101
+ pass
102
+ data = self._read_file()
103
+ if key in data:
104
+ del data[key]
105
+ self._write_file_raw(data)
106
+ deleted = True
107
+ return deleted
108
+
109
+ def list_apps(self) -> list[str]:
110
+ return sorted(self._read_file().keys())
111
+
112
+ # -- internals --------------------------------------------------------
113
+
114
+ def _read_file(self) -> dict[str, Any]:
115
+ if not self._file.is_file():
116
+ return {}
117
+ try:
118
+ with self._file.open("r", encoding="utf-8") as fh:
119
+ data = json.load(fh)
120
+ except (OSError, ValueError):
121
+ return {}
122
+ return data if isinstance(data, dict) else {}
123
+
124
+ def _write_file(self, key: str, payload: dict[str, Any]) -> None:
125
+ data = self._read_file()
126
+ data[key] = payload
127
+ self._write_file_raw(data)
128
+
129
+ def _write_index(self, key: str) -> None:
130
+ data = self._read_file()
131
+ # Don't store the secret in the index -- just record presence.
132
+ data[key] = {"_in_keyring": True}
133
+ self._write_file_raw(data)
134
+
135
+ def _write_file_raw(self, data: dict[str, Any]) -> None:
136
+ tmp = self._file.with_suffix(".json.tmp")
137
+ with tmp.open("w", encoding="utf-8") as fh:
138
+ json.dump(data, fh, indent=2, sort_keys=True)
139
+ tmp.replace(self._file)
140
+ _restrict_permissions(self._file)
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: khdp
3
+ Version: 0.1.0
4
+ Summary: KHDP connector. CLI + MCP server for Korea Health Data Platform, with wrappers for Claude Code, OpenAI Codex, and Gemini.
5
+ Project-URL: Homepage, https://github.com/KoreaHealthDataPlatform/KHDPConnector
6
+ Project-URL: Repository, https://github.com/KoreaHealthDataPlatform/KHDPConnector
7
+ Project-URL: Issues, https://github.com/KoreaHealthDataPlatform/KHDPConnector/issues
8
+ Project-URL: Documentation, https://github.com/KoreaHealthDataPlatform/KHDPConnector#readme
9
+ Author-email: Korea Health Data Platform <vital@snu.ac.kr>
10
+ License: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: healthcare,khdp,mcp,oauth,omop,vitaldb
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Healthcare Industry
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: mcp>=1.0
26
+ Requires-Dist: platformdirs>=4.0
27
+ Requires-Dist: pydantic>=2.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.10; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
31
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.5; extra == 'dev'
34
+ Provides-Extra: keyring
35
+ Requires-Dist: keyring>=24.0; extra == 'keyring'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # KHDPConnector
39
+
40
+ Auth + MCP connector for the **Korea Health Data Platform (KHDP)**.
41
+
42
+ `khdp-connector` is a CLI-first Python package that handles login
43
+ against the KHDP central auth API and exposes the resulting Bearer
44
+ session through a Model Context Protocol (MCP) server. The same MCP
45
+ server backs thin wrappers for **Claude Code**, **OpenAI Codex CLI**,
46
+ and **Gemini CLI** so KHDP authentication looks the same across every
47
+ coding-agent surface.
48
+
49
+ > **Status:** alpha. APIs and tool names will move during Phase 0–1 of
50
+ > the [PLAN.md](./PLAN.md) roadmap.
51
+
52
+ ## How it talks to KHDP
53
+
54
+ The connector uses KHDP's password-based auth API. It implements two
55
+ endpoints that are safe for headless / CLI use:
56
+
57
+ * `POST /_api/oauth/login {appId, redirectUrl, mail, password}` →
58
+ `{accessToken, refreshToken, expireTime}`
59
+ * `POST /_api/member/refresh-token {refreshToken}` →
60
+ same shape, rotated.
61
+
62
+ All subsequent KHDP API calls go out with `Authorization: Bearer
63
+ <accessToken>`.
64
+
65
+ ```
66
+ ┌────────────────────────────────────────────────────────────┐
67
+ │ Claude Code · Codex CLI · Gemini CLI · … │
68
+ │ │ │ │ │
69
+ │ └──────── MCP (stdio JSON-RPC) ───────┐ │
70
+ │ ▼ │
71
+ │ khdp-connector (this) │
72
+ │ │ │
73
+ │ ▼ │
74
+ │ POST /_api/oauth/login POST /_api/member/... │
75
+ │ (login + refresh) (any KHDP endpoint) │
76
+ │ khdp.net │
77
+ └────────────────────────────────────────────────────────────┘
78
+ ```
79
+
80
+ ## Install
81
+
82
+ ```bash
83
+ pipx install khdp-connector # recommended; isolates from system Python
84
+ # or
85
+ pip install khdp-connector
86
+ # or with OS-keychain support:
87
+ pipx install 'khdp-connector[keyring]'
88
+ ```
89
+
90
+ ## One-time configuration
91
+
92
+ You need a KHDP-registered `app_id` (UUID) and a registered
93
+ `redirect_url`. Drop them into a config file or env vars:
94
+
95
+ ```toml
96
+ # ./khdp.local.toml
97
+ app_id = "00000000-0000-0000-0000-000000000000"
98
+ redirect_url = "https://example.org/khdp-cli"
99
+ api_base = "https://khdp.net/_api" # default; override for staging
100
+ ```
101
+
102
+ …or:
103
+
104
+ ```bash
105
+ export KHDP_APP_ID=00000000-0000-0000-0000-000000000000
106
+ export KHDP_REDIRECT_URL=https://example.org/khdp-cli
107
+ ```
108
+
109
+ > **Don't have an `app_id` yet?** Coordinate with the KHDP team to
110
+ > register a CLI-class app. snuh.ai's public `app_id` won't work for
111
+ > the CLI — its `redirect_url` allowlist excludes anything outside
112
+ > `snuh.ai`.
113
+
114
+ ## CLI usage
115
+
116
+ ```bash
117
+ khdp login # prompts for email + password (or use --email / --password-stdin)
118
+ khdp status # is a token cached? when does it expire?
119
+ khdp refresh # force a refresh-token rotation
120
+ khdp api GET /member/me # authenticated KHDP API call
121
+ khdp logout # delete cached tokens
122
+ khdp config # print resolved configuration
123
+ khdp mcp # run the MCP server on stdio (for agents)
124
+ ```
125
+
126
+ Configuration resolution order (highest first):
127
+
128
+ 1. `KHDP_*` environment variables
129
+ 2. `khdp.local.toml` in the current working directory
130
+ 3. `~/.config/khdp/config.toml` (or platform equivalent)
131
+ 4. Built-in defaults
132
+
133
+ For non-interactive use:
134
+
135
+ ```bash
136
+ KHDP_EMAIL=me@example.com khdp login --password-stdin <<< "$KHDP_PASSWORD"
137
+ ```
138
+
139
+ ## MCP server
140
+
141
+ ```bash
142
+ khdp mcp
143
+ # or
144
+ khdp-mcp
145
+ ```
146
+
147
+ Tools exposed on `stdio`:
148
+
149
+ | Tool | Purpose |
150
+ | --- | --- |
151
+ | `khdp_auth_status` | Is the user logged in? When does the token expire? |
152
+ | `khdp_auth_refresh` | Rotate the refresh token to extend the session. |
153
+ | `khdp_auth_logout` | Delete locally cached tokens. |
154
+ | `khdp_api_request` | Authenticated HTTP passthrough to the KHDP API. |
155
+
156
+ The MCP server **never** accepts a password through tool arguments —
157
+ passwords would otherwise flow through the LLM context window. Login
158
+ is initiated out-of-band via `khdp login` in the user's terminal; the
159
+ MCP server just reads the resulting token cache.
160
+
161
+ Future tools (per [PLAN.md](./PLAN.md)) will add dataset I/O, OMOP
162
+ queries, audit log retrieval, and IRB result-pinning.
163
+
164
+ ## Wrappers
165
+
166
+ The same MCP server backs a thin wrapper per agent platform.
167
+
168
+ ### Claude Code
169
+
170
+ ```bash
171
+ claude mcp add khdp -- khdp mcp
172
+ cp -r wrappers/claude-code/skills/khdp-auth ~/.claude/skills/
173
+ ```
174
+
175
+ ### OpenAI Codex CLI
176
+
177
+ Append `wrappers/codex/config.example.toml` to `~/.codex/config.toml`,
178
+ copy `wrappers/codex/AGENTS.md` to your project root.
179
+
180
+ ### Gemini CLI
181
+
182
+ Merge `wrappers/gemini/settings.example.json` into
183
+ `~/.gemini/settings.json`, or install as a Gemini Extension under
184
+ `.gemini/extensions/khdp/`.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ git clone https://github.com/KoreaHealthDataPlatform/KHDPConnector.git
190
+ cd KHDPConnector
191
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
192
+ pip install -e '.[dev,keyring]'
193
+ pytest
194
+ ```
195
+
196
+ ## Security model
197
+
198
+ - **No secret in the binary.** The CLI ships only the user-provided
199
+ `app_id`. There is no embedded client secret.
200
+ - **Password never leaves the local machine** unencrypted; it goes
201
+ only to KHDP's TLS endpoint, never to the LLM, never to the MCP
202
+ context. The MCP tool surface deliberately omits a password
203
+ argument.
204
+ - **Per-app token isolation.** Multiple KHDP apps on one machine are
205
+ kept separate by `app_id`.
206
+ - **Token storage.** OS keychain (Keychain / Credential Manager /
207
+ Secret Service) when the `keyring` extra is installed; otherwise a
208
+ JSON file with `0600` permissions in the platform user-config dir.
209
+ - **No revocation endpoint exposed by KHDP today.** `khdp logout` only
210
+ clears local state. Access tokens expire naturally; refresh tokens
211
+ go invalid the next time the access token is rotated.
212
+
213
+ ## Roadmap
214
+
215
+ See [PLAN.md](./PLAN.md) for the full roadmap. The current
216
+ implementation covers Phase 1 (auth) and a generic API passthrough.
217
+ Dataset I/O, OMOP analysis, and IRB-grade result pinning land in later
218
+ phases.
219
+
220
+ ## License
221
+
222
+ Apache 2.0. See [LICENSE](./LICENSE).
@@ -0,0 +1,13 @@
1
+ khdp/__init__.py,sha256=2whrZIg8tIOrK6F6Aq8mqnHoAmppFe87xsjTB8PmH48,416
2
+ khdp/__main__.py,sha256=jPHFv0QHhnOHeQV5pfpZKPZRTCTc7NGiJ8BhvRi3Iz0,83
3
+ khdp/cli.py,sha256=yoGYO5yc_k1jXGhXAcc7prP4FS0AlEsD5Z1MFEYih8o,7270
4
+ khdp/config.py,sha256=Frh0Ly8hN47RpzrJhPx8LRJDbK8i07EebfikVFPhWMw,3529
5
+ khdp/mcp_server.py,sha256=0E4ZbgCZhYvUhaSlyj8L8hco8yH4NPStUTV1dVgNLIo,7957
6
+ khdp/oauth.py,sha256=r6PKbDbuIzjB_LwAMPpU25k8WCQqiN5sILtI8fpITbw,7788
7
+ khdp/session.py,sha256=zZnt5k7hxZdlSIrgtJ3nq1jjkhD2g51B7WhZWwOuHog,4317
8
+ khdp/token_store.py,sha256=pnOSqE2Bl939g9GZhEXDvUjqRo_Bqc6hO7z9eSfS0HE,4732
9
+ khdp-0.1.0.dist-info/METADATA,sha256=kG1ZLDitT45sphNSlSLLkerSq6K4P2QDQ-DIS0QQ2VE,8098
10
+ khdp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ khdp-0.1.0.dist-info/entry_points.txt,sha256=unVt4c8rRTrhl0h79KMWwbYdPy9yexTTRNOqWmLN4cQ,71
12
+ khdp-0.1.0.dist-info/licenses/LICENSE,sha256=wrtc4PGK8P_oVbmq6DHfcSzzTLu7Yz1pur-P7zHWlh8,762
13
+ khdp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ khdp = khdp.cli:main
3
+ khdp-mcp = khdp.mcp_server:main
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Korea Health Data Platform (KHDP)
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.