Files
agw 9675a5b76b feat(mcp): eksponér YouTube-offline i portalens MCP + importér MCP-laget i repoet
Fem nye værktøjer i portal-navnerummet: yt_status, yt_download, yt_watch,
yt_unwatch, yt_check_subs. Gatewayen monterer mcp_server under namespace
"portal", så de arver portal_-præfikset og de scopes nøglerne allerede har —
ingen ekstra registrering.

Download og abonnement-tjek returnerer med det samme og arbejder videre i
baggrunden, så et MCP-kald ikke blokerer i minutter på en lang video.

Importerer samtidig mcp_server.py, mcp_gateway.py, mcpkeys.py og
templates/admin_mcp_keys.html, som kun har eksisteret i prod (CT151) og aldrig
i repoet.

ÅRSAG TIL IMPORTEN: den manglende viden i repoet kostede nedetid. Da jeg
udrullede repoets main.py og config.py til prod i går, overskrev jeg
MCP-monteringen (mcp_app.lifespan + route/middleware-overførsel), hele
/admin/mcp-keys-modulet og konstanterne MCP_REGISTRY/MCP_SELF_URL.
weircon.dk/mcp svarede 404 i ~13 timer. Rettet ved at genopbygge begge filer
fra prod-backuppen plus YouTube-ændringerne, ikke omvendt.

Repo og prod er nu identiske bortset fra static/portal.css og
templates/admin.html, hvor repoet er FORAN (wt-*-temaklasser, aldrig udrullet).

Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
2026-09-20 09:20:35 +02:00

277 lines
8.8 KiB
Python

"""Central MCP-nøgleregister/vault for portalen.
Ét sted for homelab'ens MCP-servere:
- **external**-entries (crawlkit, Gitea, HA, Paperless): vi gemmer deres token i
klartekst, så `claude mcp add`-kommandoen altid kan vises. Portalen autentificerer
dem IKKE — det gør den enkelte downstream-server.
- **portal_tokens**: bearer-tokens til portalens EGEN MCP (hash til auth + klartekst
til visning).
Filen skrives mode 600 (samme niveau som /etc/portal.env).
"""
import hashlib
import hmac
import json
import os
import secrets
from datetime import datetime, timezone
import httpx
import config
REG = config.MCP_REGISTRY
SELF_URL = config.MCP_SELF_URL
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _hash(t: str) -> str:
return hashlib.sha256(t.encode("utf-8")).hexdigest()
def _load() -> dict:
try:
d = json.loads(REG.read_text("utf-8"))
except Exception:
d = {}
d.setdefault("external", [])
d.setdefault("portal_tokens", [])
d.setdefault("oauth_access", {"default": None, "users": {}})
_backfill_allowed(d)
return d
def _namespaces(d: dict) -> list:
"""Every backend the gateway can expose: the portal itself + each external."""
return ["portal"] + [e["id"] for e in d.get("external", []) if e.get("id")]
def _backfill_allowed(d: dict) -> None:
"""Give pre-scoping tokens an explicit allow-list of what exists TODAY.
Deliberately not a wildcard: a token minted before scoping keeps exactly the
access it already had, and any backend added later (mail, say) is denied
until someone ticks it. Default-deny for new backends is the whole point.
"""
current = _namespaces(d)
for t in d.get("portal_tokens", []):
if t.get("allowed") is None:
t["allowed"] = list(current)
oa = d.get("oauth_access") or {}
if oa.get("default") is None:
oa["default"] = list(current)
d["oauth_access"] = oa
def _save(d: dict) -> None:
REG.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8")
try:
os.chmod(REG, 0o600)
except OSError:
pass
# ---------------- external MCP-entries ----------------
def list_external() -> list:
return _load()["external"]
def upsert_external(entry: dict) -> None:
d = _load()
d["external"] = [e for e in d["external"] if e.get("id") != entry["id"]] + [entry]
_save(d)
def delete_external(eid: str) -> None:
d = _load()
d["external"] = [e for e in d["external"] if e.get("id") != eid]
_save(d)
# ---------------- portalens egne MCP-tokens ----------------
def list_portal_tokens() -> list:
return _load()["portal_tokens"]
def mint_portal_token(name: str, allowed: list | None = None) -> str:
token = secrets.token_urlsafe(32)
d = _load()
d["portal_tokens"].append({
"name": name, "token_hash": _hash(token),
"allowed": list(allowed) if allowed is not None else [],
"created_at": _now(), "last_used_at": None, "revoked": False})
_save(d)
return token
def namespaces() -> list:
"""Backends the gateway can expose (for the admin UI's checkboxes)."""
return _namespaces(_load())
def set_token_allowed(name: str, allowed: list) -> None:
d = _load()
for t in d["portal_tokens"]:
if t.get("name") == name:
t["allowed"] = list(allowed)
_save(d)
def get_oauth_access() -> dict:
return _load()["oauth_access"]
def set_oauth_access(default: list, users: dict) -> None:
d = _load()
d["oauth_access"] = {"default": list(default),
"users": {k: list(v) for k, v in users.items()}}
_save(d)
def set_oauth_default(allowed: list) -> None:
d = _load()
oa = d.get("oauth_access") or {}
oa["default"] = list(allowed)
d["oauth_access"] = oa
_save(d)
def set_oauth_user(username: str, allowed: list) -> None:
d = _load()
oa = d.get("oauth_access") or {}
users = oa.get("users") or {}
users[username] = list(allowed)
oa["users"] = users
d["oauth_access"] = oa
_save(d)
def del_oauth_user(username: str) -> None:
d = _load()
oa = d.get("oauth_access") or {}
(oa.get("users") or {}).pop(username, None)
d["oauth_access"] = oa
_save(d)
def allowed_for_oauth(subject: str | None) -> list:
"""What an Authentik-authenticated caller may reach.
Falls back to the configured default when the user has no explicit entry —
the default is itself a list, so it can be narrowed to nothing.
"""
oa = _load()["oauth_access"]
users = oa.get("users") or {}
for key in filter(None, [subject, (subject or "").lower()]):
if key in users:
return list(users[key])
return list(oa.get("default") or [])
def allowed_for_oauth_any(candidates) -> list:
"""What an Authentik caller may reach, matched on ANY of its identifiers.
Authentik's ``sub`` is a UUID, but the admin UI stores entries by *username*
— so matching on the subject alone never hits and everyone silently gets the
default. Try every identity claim the token carries, case-insensitively.
"""
oa = _load()["oauth_access"]
users = oa.get("users") or {}
lowered = {str(k).lower(): v for k, v in users.items()}
for c in candidates:
if not c:
continue
if c in users:
return list(users[c])
hit = lowered.get(str(c).lower())
if hit is not None:
return list(hit)
return list(oa.get("default") or [])
def resolve_portal_token(token: str) -> dict | None:
"""Return the token record (incl. its allow-list), or None. Bumps
last_used_at at most ~1/min, like verify_portal_token."""
if not token:
return None
h = _hash(token)
d = _load()
now = _now()
for t in d["portal_tokens"]:
if not t.get("revoked") and hmac.compare_digest(t.get("token_hash", ""), h):
last = t.get("last_used_at") or ""
if last < now[:16]:
t["last_used_at"] = now
_save(d)
return dict(t)
return None
def revoke_portal_token(name: str) -> None:
d = _load()
for t in d["portal_tokens"]:
if t.get("name") == name:
t["revoked"] = True
_save(d)
def verify_portal_token(token: str) -> bool:
"""True for a valid, non-revoked portal token."""
return resolve_portal_token(token) is not None
# ---------------- helpers ----------------
def build_command(*, mcp_id, transport="http", url=None, token=None,
header="Authorization", command=None) -> str:
if transport == "stdio":
return f"claude mcp add --scope user {mcp_id} -- {(command or '').strip()}".strip()
hdr = f' --header "{header}: Bearer {token}"' if token else ""
return f"claude mcp add --scope user --transport {transport} {mcp_id} {url}{hdr}"
class PortalMCPAuthMiddleware:
"""Pure-ASGI gate: require a valid portal bearer token on the /mcp endpoint;
everything else (incl. lifespan + the web UI) passes through untouched."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") == "http" and scope.get("path", "").rstrip("/") == "/mcp":
headers = dict(scope.get("headers") or [])
raw = headers.get(b"authorization", b"").decode("latin-1")
token = raw[7:].strip() if raw[:7].lower() == "bearer " else ""
if not verify_portal_token(token):
body = b'{"error":"unauthorized"}'
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode())]})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
async def health_check(entry: dict) -> dict:
"""Ping an external MCP endpoint. Any 2xx/4xx = reachable (401 = auth required)."""
res = {"at": _now(), "ok": None, "detail": ""}
url = entry.get("url")
if entry.get("transport") == "stdio" or not url:
res["detail"] = "stdio (lokal) — ikke pinget"
return res
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.post(url, json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {}},
headers={"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"})
res["ok"] = 200 <= r.status_code < 500
res["detail"] = f"HTTP {r.status_code}" + (" — nåbar (auth kræves)"
if r.status_code == 401 else "")
except Exception as e:
res["ok"] = False
res["detail"] = str(e)[:160]
return res