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 +18 -0
- khdp/__main__.py +4 -0
- khdp/cli.py +227 -0
- khdp/config.py +106 -0
- khdp/mcp_server.py +231 -0
- khdp/oauth.py +220 -0
- khdp/session.py +127 -0
- khdp/token_store.py +140 -0
- khdp-0.1.0.dist-info/METADATA +222 -0
- khdp-0.1.0.dist-info/RECORD +13 -0
- khdp-0.1.0.dist-info/WHEEL +4 -0
- khdp-0.1.0.dist-info/entry_points.txt +3 -0
- khdp-0.1.0.dist-info/licenses/LICENSE +17 -0
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
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,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.
|