| 1 | Шаги |
| 2 | 1. Взять mcp-token.py. Нужен только python3, внешних зависимостей нет. |
| 3 | 2. Один раз запустить python3 mcp-token.py в терминале. Откроется браузер, логин обычный, с 2FA. На macOS страница откроется сама, на Linux и Windows URL и код печатаются в терминал. |
| 4 | 3. Прописать в Claude Code (~/.claude.json на user scope или .mcp.json проекта): |
| 5 | {"mcpServers":{"superset":{"type":"http","url":"https://superset.swiftdrive-tech.ru/mcp", |
| 6 | "headersHelper":"python3 /путь/к/mcp-token.py --header"}}} |
| 7 | Дальше токен обновляется сам. Когда закончится сессия Keycloak, Claude Code покажет ошибку подключения. Тогда снова выполнить шаг 2. |
fnt / gistfile1.txt
Last active 3 weeks ago
Revision f994039dd9570ce6b66175e761592b731e3acf1d
| 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() |
| 116 |