|
|
@@ -0,0 +1,115 @@ |
|
1 |
+ |
#!/usr/bin/env python3 |
|
2 |
+ |
"""Bearer token for the Superset MCP server (https://superset.swiftdrive-tech.ru/mcp). |
|
3 |
+ |
|
|
4 |
+ |
Keycloak realm `infra`, public client `superset-mcp`, Device Authorization Grant. |
|
5 |
+ |
First run opens a browser login (realm 2FA applies); after that the cached |
|
6 |
+ |
refresh token renews silently until the Keycloak session ends. |
|
7 |
+ |
|
|
8 |
+ |
mcp-token.py print the access token |
|
9 |
+ |
mcp-token.py --header print {"Authorization": "Bearer ..."} (Claude Code headersHelper) |
|
10 |
+ |
mcp-token.py --logout drop the cached token |
|
11 |
+ |
""" |
|
12 |
+ |
import json |
|
13 |
+ |
import os |
|
14 |
+ |
import subprocess |
|
15 |
+ |
import sys |
|
16 |
+ |
import time |
|
17 |
+ |
import urllib.error |
|
18 |
+ |
import urllib.parse |
|
19 |
+ |
import urllib.request |
|
20 |
+ |
|
|
21 |
+ |
ISSUER = os.environ.get("SUPERSET_MCP_ISSUER", "https://auth.swiftdrive-tech.ru/realms/infra") |
|
22 |
+ |
CLIENT_ID = os.environ.get("SUPERSET_MCP_CLIENT_ID", "superset-mcp") |
|
23 |
+ |
CACHE = os.path.expanduser("~/.cache/superset-mcp/token.json") |
|
24 |
+ |
MIN_TTL = 300 # renew when fewer seconds than this remain |
|
25 |
+ |
|
|
26 |
+ |
|
|
27 |
+ |
def post(path, **form): |
|
28 |
+ |
body = urllib.parse.urlencode({"client_id": CLIENT_ID, **form}).encode() |
|
29 |
+ |
req = urllib.request.Request(f"{ISSUER}/protocol/openid-connect/{path}", body) |
|
30 |
+ |
try: |
|
31 |
+ |
with urllib.request.urlopen(req, timeout=30) as resp: |
|
32 |
+ |
return json.load(resp) |
|
33 |
+ |
except urllib.error.HTTPError as e: |
|
34 |
+ |
try: |
|
35 |
+ |
return json.load(e) |
|
36 |
+ |
except ValueError: |
|
37 |
+ |
return {"error": f"HTTP {e.code}"} |
|
38 |
+ |
except urllib.error.URLError as e: |
|
39 |
+ |
sys.exit(f"superset-mcp: {ISSUER} unreachable: {e.reason}") |
|
40 |
+ |
|
|
41 |
+ |
|
|
42 |
+ |
def save(tok): |
|
43 |
+ |
now = time.time() |
|
44 |
+ |
tok["expires_at"] = now + tok["expires_in"] |
|
45 |
+ |
tok["refresh_expires_at"] = now + tok.get("refresh_expires_in", 0) |
|
46 |
+ |
cache_dir = os.path.dirname(CACHE) |
|
47 |
+ |
os.makedirs(cache_dir, mode=0o700, exist_ok=True) |
|
48 |
+ |
os.chmod(cache_dir, 0o700) |
|
49 |
+ |
tmp = f"{CACHE}.{os.getpid()}" |
|
50 |
+ |
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
|
51 |
+ |
with os.fdopen(fd, "w") as f: |
|
52 |
+ |
json.dump(tok, f) |
|
53 |
+ |
os.replace(tmp, CACHE) # concurrent headersHelper runs never see a torn file |
|
54 |
+ |
return tok |
|
55 |
+ |
|
|
56 |
+ |
|
|
57 |
+ |
def load(): |
|
58 |
+ |
try: |
|
59 |
+ |
with open(CACHE) as f: |
|
60 |
+ |
return json.load(f) |
|
61 |
+ |
except (OSError, ValueError): |
|
62 |
+ |
return None |
|
63 |
+ |
|
|
64 |
+ |
|
|
65 |
+ |
def device_login(): |
|
66 |
+ |
dev = post("auth/device") |
|
67 |
+ |
if "device_code" not in dev: |
|
68 |
+ |
sys.exit(f"device authorization failed: {dev}") |
|
69 |
+ |
url = dev.get("verification_uri_complete") or dev["verification_uri"] |
|
70 |
+ |
print(f"Superset MCP login: open {url} (code {dev['user_code']})", file=sys.stderr) |
|
71 |
+ |
if sys.platform == "darwin": |
|
72 |
+ |
subprocess.run(["open", url], check=False) |
|
73 |
+ |
interval = dev.get("interval", 5) |
|
74 |
+ |
deadline = time.time() + dev.get("expires_in", 600) |
|
75 |
+ |
while time.time() < deadline: |
|
76 |
+ |
time.sleep(interval) |
|
77 |
+ |
tok = post("token", grant_type="urn:ietf:params:oauth:grant-type:device_code", |
|
78 |
+ |
device_code=dev["device_code"]) |
|
79 |
+ |
if "access_token" in tok: |
|
80 |
+ |
return save(tok) |
|
81 |
+ |
err = tok.get("error") |
|
82 |
+ |
if err == "slow_down": |
|
83 |
+ |
interval += 5 |
|
84 |
+ |
elif err != "authorization_pending": |
|
85 |
+ |
sys.exit(f"login failed: {tok}") |
|
86 |
+ |
sys.exit("login timed out") |
|
87 |
+ |
|
|
88 |
+ |
|
|
89 |
+ |
def token(): |
|
90 |
+ |
tok = load() |
|
91 |
+ |
now = time.time() |
|
92 |
+ |
if tok and tok["expires_at"] - now > MIN_TTL: |
|
93 |
+ |
return tok |
|
94 |
+ |
if tok and tok.get("refresh_token") and tok["refresh_expires_at"] > now: |
|
95 |
+ |
new = post("token", grant_type="refresh_token", refresh_token=tok["refresh_token"]) |
|
96 |
+ |
if "access_token" in new: |
|
97 |
+ |
return save(new) |
|
98 |
+ |
if not sys.stdin.isatty() and not sys.stderr.isatty(): |
|
99 |
+ |
# headersHelper runs non-interactively: don't hang on a login nobody sees |
|
100 |
+ |
sys.exit("superset-mcp: not logged in, run mcp-token.py in a terminal") |
|
101 |
+ |
return device_login() |
|
102 |
+ |
|
|
103 |
+ |
|
|
104 |
+ |
def main(): |
|
105 |
+ |
arg = sys.argv[1] if len(sys.argv) > 1 else "" |
|
106 |
+ |
if arg == "--logout": |
|
107 |
+ |
if os.path.exists(CACHE): |
|
108 |
+ |
os.remove(CACHE) |
|
109 |
+ |
return |
|
110 |
+ |
access = token()["access_token"] |
|
111 |
+ |
print(json.dumps({"Authorization": f"Bearer {access}"}) if arg == "--header" else access) |
|
112 |
+ |
|
|
113 |
+ |
|
|
114 |
+ |
if __name__ == "__main__": |
|
115 |
+ |
main() |