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
This commit is contained in:
agw
2026-09-20 09:20:35 +02:00
parent 2c146b0711
commit 9675a5b76b
6 changed files with 943 additions and 4 deletions
+4 -1
View File
@@ -33,6 +33,8 @@ USERPREFS = DATA / "userprefs.json"
IP_LABELS_LOCAL = DATA / "ip_labels.json"
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "")
SETTINGS_PATH = DATA / "settings.json"
MCP_REGISTRY = DATA / "mcp_registry.json"
MCP_SELF_URL = os.environ.get("MCP_SELF_URL", "https://weircon.dk/mcp")
# ---- Medie-oprydning (Emby + Sonarr/Radarr) ----
EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/")
@@ -56,7 +58,6 @@ MEDIA_DEFAULTS = {
"do_series": True,
}
# ---- YouTube offline (yt-dlp + Emby) ----
YT_DOWNLOAD_DIR = os.environ.get("YT_DOWNLOAD_DIR", "/media/yt-offline")
YT_DLP_BIN = os.environ.get("YT_DLP_BIN", "/usr/local/bin/yt-dlp")
@@ -101,4 +102,6 @@ ADMIN_MODULES = [
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
{"key": "yt", "title": "YouTube offline", "icon": "📺",
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
"desc": "Opret og se API-nøgler til alle MCP-servere (crawlkit, Gitea, HA, Paperless, portal)."},
]
+112 -3
View File
@@ -7,7 +7,6 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
import asyncio
import time
import authentik as ak
@@ -18,8 +17,20 @@ import services as svc
import store
import versions
import ytdl as yt
import mcpkeys
import mcp_server
import mcp_gateway
app = FastAPI(title="Weircon Portal")
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True)
app = FastAPI(title="Weircon Portal", lifespan=mcp_app.lifespan)
for _mcp_route in mcp_app.routes:
app.router.routes.append(_mcp_route)
# route-extend copies routes but NOT middleware — carry FastMCP's auth/context
# middleware over so the /mcp auth (OIDC + static dual-auth) actually runs.
for _mcp_mw in reversed(mcp_app.user_middleware):
app.add_middleware(_mcp_mw.cls, *_mcp_mw.args, **_mcp_mw.kwargs)
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
same_site="lax", max_age=60 * 60 * 12)
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static")
@@ -511,7 +522,8 @@ async def search_index(request: Request):
{"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"},
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"},
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}]
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"},
{"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}]
try:
for a in await ak.fetch_user_apps(user["username"]):
items.append({"type": "app", "title": a["name"], "url": a["url"],
@@ -526,6 +538,103 @@ async def healthz():
return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)}
# ---------------- MCP-nøgler (central registry) ----------------
@app.get("/admin/mcp-keys", response_class=HTMLResponse)
async def admin_mcp_keys(request: Request, saved: str = ""):
user = require_admin(request)
external = [{**e, "command": mcpkeys.build_command(
mcp_id=e["id"], transport=e.get("transport", "http"), url=e.get("url"),
token=e.get("token"), header=e.get("auth_header", "Authorization"),
command=e.get("command"))} for e in mcpkeys.list_external()]
portal_tokens = mcpkeys.list_portal_tokens()
new_token = request.session.pop("mcp_new", None)
oauth_access = mcpkeys.get_oauth_access()
configured = oauth_access.get("users") or {}
# Service accounts (outposts etc.) are not people and would only clutter the
# picker. If Authentik is unreachable the page still renders — the rows fall
# back to the bare username.
try:
people = [u for u in await ak.list_users()
if not u["username"].startswith("ak-outpost-")
and (u.get("type") or "internal") != "service_account"]
except Exception:
people = []
by_name = {u["username"]: u for u in people}
def _label(username):
u = by_name.get(username)
full = (u or {}).get("name") or ""
return f"{full} ({username})" if full else username
oauth_rows = [{"username": u, "allowed": v, "label": _label(u)}
for u, v in sorted(configured.items())]
ak_users_available = sorted(
({"username": u["username"], "label": _label(u["username"])}
for u in people if u["username"] not in configured),
key=lambda x: x["label"].lower())
return templates.TemplateResponse(request, "admin_mcp_keys.html", {
"request": request, "user": user, "is_admin": True, "external": external,
"portal_tokens": portal_tokens, "self_url": mcpkeys.SELF_URL, "saved": saved,
"new_token": new_token, "namespaces": mcpkeys.namespaces(),
"oauth_access": oauth_access, "oauth_rows": oauth_rows,
"ak_users_available": ak_users_available})
@app.post("/admin/mcp-keys")
async def admin_mcp_keys_post(request: Request):
require_admin(request)
form = await request.form()
action = form.get("action", "")
if action == "save_external":
eid = (form.get("id") or "").strip()
if eid:
mcpkeys.upsert_external({
"id": eid, "label": (form.get("label") or eid).strip(),
"transport": (form.get("transport") or "http").strip(),
"url": (form.get("url") or "").strip(),
"auth_header": (form.get("auth_header") or "Authorization").strip(),
"token": (form.get("token") or "").strip(),
"command": (form.get("command") or "").strip(),
"notes": (form.get("notes") or "").strip()})
return RedirectResponse("/admin/mcp-keys?saved=Gemt", status_code=303)
if action == "delete_external":
mcpkeys.delete_external((form.get("id") or "").strip())
return RedirectResponse("/admin/mcp-keys?saved=Slettet", status_code=303)
if action == "set_allowed":
mcpkeys.set_token_allowed((form.get("name") or "").strip(),
form.getlist("allowed"))
return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303)
if action == "set_oauth_default":
mcpkeys.set_oauth_default(form.getlist("default"))
return RedirectResponse("/admin/mcp-keys?saved=Standard gemt", status_code=303)
if action == "set_oauth_user":
who = (form.get("name") or "").strip()
if who:
mcpkeys.set_oauth_user(who, form.getlist("allowed"))
return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303)
if action == "del_oauth_user":
mcpkeys.del_oauth_user((form.get("name") or "").strip())
return RedirectResponse("/admin/mcp-keys?saved=Fjernet", status_code=303)
if action == "mint_portal":
name = (form.get("name") or "").strip() or "ny-nøgle"
tok = mcpkeys.mint_portal_token(name, form.getlist("allowed"))
cmd = mcpkeys.build_command(mcp_id="weircon", transport="http",
url=mcpkeys.SELF_URL, token=tok)
request.session["mcp_new"] = {"name": name, "token": tok, "command": cmd}
return RedirectResponse("/admin/mcp-keys?saved=Token oprettet — kopiér det nu, det vises kun én gang", status_code=303)
if action == "revoke_portal":
mcpkeys.revoke_portal_token((form.get("name") or "").strip())
return RedirectResponse("/admin/mcp-keys?saved=Token spærret", status_code=303)
if action == "healthcheck":
eid = (form.get("id") or "").strip()
for e in mcpkeys.list_external():
if e["id"] == eid:
e["last_check"] = await mcpkeys.health_check(e)
mcpkeys.upsert_external(e)
return RedirectResponse("/admin/mcp-keys?saved=Tjekket", status_code=303)
return RedirectResponse("/admin/mcp-keys", status_code=303)
# ---------------- YouTube offline ----------------
@app.get("/admin/yt", response_class=HTMLResponse)
async def admin_yt(request: Request, saved: str = ""):
+166
View File
@@ -0,0 +1,166 @@
"""MCP-gateway/router: ÉT FastMCP-endpoint der føderer portalens egne tools
(in-process) + alle registrerede eksterne MCP'er (crawlkit, Home Assistant, …).
**Dual-auth** (FastMCP MultiAuth):
- Authentik-OAuth (OIDCProxy) — så gatewayen kan tilføjes som Custom Connector på
claude.ai og bruges i mobil/web-appen (forbruger-appen kan kun OAuth).
- Statisk portal-token (PortalTokenVerifier mod registry-hash) — så den headless
Claude Code CLI virker uændret uden OAuth-flow.
Bygges fra registry'et ved opstart; genstart portalen efter ændringer i registry'et.
"""
import logging
import os
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server import create_proxy
from fastmcp.server.auth import AccessToken, MultiAuth, TokenVerifier
from fastmcp.server.auth.oidc_proxy import OIDCProxy
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.middleware import Middleware
from key_value.aio.stores.disk import DiskStore
import mcpkeys
import mcp_server
log = logging.getLogger("portal.mcpscope")
CLAUDE_REDIRECTS = ["https://claude.ai/api/mcp/auth_callback",
"https://claude.com/api/mcp/auth_callback"]
class PortalTokenVerifier(TokenVerifier):
"""Accept the portal's static bearer tokens (headless CLI), validated against the
registry hash (mcpkeys.verify_portal_token)."""
async def verify_token(self, token: str):
rec = mcpkeys.resolve_portal_token(token)
if rec is None:
return None
# Carry the key's allow-list into the request so ScopeMiddleware can
# enforce it without looking the token up a second time.
# Must carry every required scope: required_scopes is enforced for all
# verifiers, so a static key missing one would start getting 403.
return AccessToken(token=token, client_id="portal-static",
scopes=["openid", "offline_access"],
claims={"portal_allowed": list(rec.get("allowed") or []),
"portal_key": rec.get("name", "")})
def _build_auth():
static = PortalTokenVerifier()
cfg = os.environ.get("MCP_OIDC_CONFIG_URL")
cid = os.environ.get("MCP_OIDC_CLIENT_ID")
csec = os.environ.get("MCP_OIDC_CLIENT_SECRET")
if not (cfg and cid and csec):
return static # OIDC not configured → static-token only
oidc = OIDCProxy(
config_url=cfg, client_id=cid, client_secret=csec,
base_url="https://weircon.dk", redirect_path="/mcp/oauth/callback",
allowed_client_redirect_uris=CLAUDE_REDIRECTS,
client_storage=DiskStore(directory="/opt/portal/data/mcp_oauth"),
forward_resource=False, # Authentik rejects the RFC-8707 resource param ("malformed")
# offline_access is what makes Authentik issue a refresh token. claude.ai
# sends no scope of its own, so whatever is listed here IS the upstream
# scope. With only "openid" the connector got a 1-hour access token and
# nothing to renew it with, and went 401 an hour into every session.
required_scopes=["openid", "offline_access"],
require_authorization_consent=False, # claude.ai is the only allowed client
# (redirect allowlist) → skip extra consent
)
return MultiAuth(server=oidc, verifiers=[static])
def _namespace_of(tool_name: str, known: list) -> str | None:
"""Map a namespaced tool name back to its backend.
Longest prefix wins, so a backend id that is a prefix of another (``mail``
vs ``mail_asger``) cannot swallow the wrong tools.
"""
for ns in sorted(known, key=len, reverse=True):
if tool_name == ns or tool_name.startswith(f"{ns}_"):
return ns
return None
def _allowed_for_request() -> list:
"""Which backends the current caller may reach.
Static portal keys carry their list in the token claims; Authentik callers
are looked up by subject in the registry. Anything we cannot identify gets
nothing — this is an access-control decision, so it fails closed.
"""
try:
tok = get_access_token()
except Exception as exc: # pragma: no cover - diagnostic path
log.warning("mcp-scope: get_access_token failed (%r) -> oauth default", exc)
return mcpkeys.allowed_for_oauth_any([])
if tok is None:
# Authenticated but unidentifiable: FastMCP already 401s anything that
# is not authenticated, so this is a known-good caller we simply cannot
# name. Denying everything would strand it with an empty tool list;
# fall back to the admin-configured OAuth default instead, which is an
# explicit allow-list and does not include the mailboxes.
log.warning("mcp-scope: authenticated caller with no access token in "
"context -> falling back to the oauth default")
return mcpkeys.allowed_for_oauth_any([])
claims = getattr(tok, "claims", None) or {}
if "portal_allowed" in claims:
return list(claims["portal_allowed"])
candidates = [getattr(tok, "subject", None), claims.get("preferred_username"),
claims.get("nickname"), claims.get("email"), claims.get("upn"),
claims.get("username"), claims.get("uid"), claims.get("sub")]
allowed = mcpkeys.allowed_for_oauth_any(candidates)
log.warning("mcp-scope: oauth caller candidates=%r claim_keys=%s -> %s",
[c for c in candidates if c], sorted(claims), allowed)
return allowed
class ScopeMiddleware(Middleware):
"""Per-key access control over the federated backends.
Filtering list_tools is not enough on its own: a caller that already knows a
tool name could still invoke it. So call_tool is checked against the same
allow-list, and that check — not the listing — is the actual gate.
"""
def __init__(self, namespaces_fn):
self._namespaces = namespaces_fn
async def on_list_tools(self, context, call_next):
tools = await call_next(context)
allowed = set(_allowed_for_request())
known = self._namespaces()
return [t for t in tools
if _namespace_of(getattr(t, "name", ""), known) in allowed]
async def on_call_tool(self, context, call_next):
name = getattr(getattr(context, "message", None), "name", "") or ""
known = self._namespaces()
ns = _namespace_of(name, known)
if ns not in set(_allowed_for_request()):
raise ToolError(
f"denne nøgle har ikke adgang til '{ns or name}'"
)
return await call_next(context)
def build_gateway() -> FastMCP:
gw = FastMCP("weircon-gateway", auth=_build_auth())
# Namespaces are read per request, not captured, so adding a backend to the
# registry does not need a code change here.
gw.add_middleware(ScopeMiddleware(lambda: ["portal"] + [
e["id"] for e in mcpkeys.list_external() if e.get("id")]))
gw.mount(mcp_server.mcp, namespace="portal")
for e in mcpkeys.list_external():
if e.get("url") and e.get("token") and e.get("transport") in ("http", "sse"):
conf = {"mcpServers": {e["id"]: {
"transport": e["transport"], "url": e["url"],
"headers": {e.get("auth_header", "Authorization"): f"Bearer {e['token']}"}}}}
gw.mount(create_proxy(conf), namespace=e["id"])
return gw
gateway = build_gateway()
+200
View File
@@ -0,0 +1,200 @@
"""Portalens egen MCP-server (FastMCP) — read-only indblik i portal/homelab-data
plus en ntfy-notify-handling. Monteres in-process i main.py (uvicorn-lifespan).
Tools genbruger de eksisterende portal-moduler. Alt pakkes i try/except, så en
downstream-fejl giver en pæn fejl frem for at vælte MCP-kaldet.
"""
import asyncio
from fastmcp import FastMCP
import authentik as ak
import config
import mediaclean as mc
import services as svc
import store
import versions
import ytdl as yt
mcp = FastMCP("weircon-portal")
def _safe(v):
return v
@mcp.tool
async def service_versions() -> dict:
"""Versions/opdaterings-overblik for de overvågede tjenester (current vs latest)."""
try:
return {"services": await versions.all_versions()}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def matomo_summary() -> dict:
"""Kort Matomo-trafikoverblik for weircon.dk."""
try:
return await svc.matomo_summary()
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def egress_status() -> dict:
"""Random-proxy (CT140) egress-status: tunneler, rollover, inflight."""
try:
return await svc.rp_status()
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def portal_apps() -> dict:
"""Katalog over Authentik-apps (navn, gruppe, host) som portalen kender."""
try:
return {"apps": await ak.fetch_all_apps_admin()}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
def media_cleanup_status() -> dict:
"""Status for medie-oprydningen: indstillinger, state (på-vej-ud/behold) og om den er konfigureret."""
try:
return {"configured": mc.configured(), "settings": mc.get_settings(),
"state": mc.get_state()}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
def ip_labels() -> dict:
"""IP/præfiks → label & land som vist i statistikken."""
try:
return {"labels": store.load_json(config.IP_LABELS_LOCAL, {})}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def notify(message: str, title: str = "Claude") -> dict:
"""Send en push-notifikation til dig via ntfy."""
try:
await mc.ntfy(title, message)
return {"sent": True}
except Exception as e:
return {"sent": False, "error": str(e)[:200]}
# ---------------- YouTube offline ----------------
@mcp.tool
def yt_status() -> dict:
"""Status for YouTube-offline: hentede videoer (set/favorit/hvornår de slettes),
download-kø, overvågede playlister/kanaler og de aktuelle indstillinger.
Videoer slettes 7 dage efter de er markeret som set i Emby. Favoritter slettes aldrig.
"""
try:
st = yt.get_state()
owned = yt.owned_paths()
leaving = st["leaving"]
videos = [{"titel": t, "sti": p, "stoerrelse": yt._human(yt.file_size(p)),
"slettes_efter": (leaving.get(p) or {}).get("delete_after")}
for p, t in owned.items()]
jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True)
return {
"konfigureret": yt.configured(),
"indstillinger": yt.get_settings(),
"videoer": videos,
"paa_vej_ud": len(leaving),
"koe": [{"url": j["url"], "status": j["status"], "titel": j.get("title"),
"fejl": j.get("error")} for j in jobs[:15]
if j["status"] in ("queued", "running", "error")],
"abonnementer": [
{"id": sb["id"], "navn": sb["title"], "type": sb["kind"],
"cycle": sb.get("cycle") or None, "aktiv": sb.get("enabled", True),
"paa_disken": len(yt.sub_files(st, sb["id"]))}
for sb in st["subs"].values()],
}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def yt_download(url: str, playlist: bool = False) -> dict:
"""Hent en YouTube-video ned til Emby-biblioteket, klar til offline-brug.
Returnerer med det samme; selve hentningen kører i baggrunden (kan tage minutter
for en lang video). Brug yt_status til at følge den. Sæt playlist=True for at
hente hele playlisten bag URL'en i stedet for kun den enkelte video.
"""
try:
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
return {"error": "Ikke en URL"}
job = yt.add_job(url, playlist=bool(playlist))
asyncio.create_task(yt.process_queue())
return {"lagt_i_koe": True, "job": job["id"], "url": job["url"],
"bemaerk": "hentes i baggrunden — se yt_status"}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def yt_watch(url: str, kind: str = "", cycle: int = 0) -> dict:
"""Overvåg en playliste eller kanal og hent nye videoer automatisk.
kind = "playlist" eller "channel" (tom = gæt ud fra URL'en).
For en kanal SKAL cycle sættes: cycle=X holder de X nyeste videoer liggende,
og en ny video skubber den ældste ud. Favoritter tæller ikke med i X og slettes
aldrig. En playliste henter kun videoer der lægges i den fremover — ikke dem
der allerede ligger der.
"""
try:
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
return {"error": "Ikke en URL"}
kind = kind if kind in ("playlist", "channel") else yt.sub_kind_from_url(url)
if kind == "channel" and int(cycle or 0) < 1:
return {"error": "En kanal kraever cycle >= 1 (antal nyeste videoer der holdes)"}
sub = await yt.add_sub(url, kind=kind, cycle=int(cycle or 0) if kind == "channel" else 0)
asyncio.create_task(_yt_sync_all())
return {"overvaages": True, "id": sub["id"], "navn": sub["title"],
"type": sub["kind"], "cycle": sub["cycle"] or None,
"bemaerk": "tjekker og henter i baggrunden"}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
def yt_unwatch(sub_id: str) -> dict:
"""Stop med at overvåge en playliste/kanal. Allerede hentede videoer bliver
liggende og følger herefter kun den almindelige set-regel. Id'et fås fra yt_status."""
try:
return {"fjernet": yt.del_sub(sub_id)}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def yt_check_subs() -> dict:
"""Tjek alle overvågede playlister/kanaler nu i stedet for at vente på natkørslen."""
try:
res = await yt.sync_subs()
asyncio.create_task(_yt_sync_all())
return {"tjekket": res["checked"], "nye_i_koe": res["queued"],
"fejl": res["errors"]}
except Exception as e:
return {"error": str(e)[:200]}
async def _yt_sync_all():
"""Baggrundsarbejde: hent kø og haandhaev kanal-cyklus. Fejl slugges bevidst —
den natlige timer koerer det samme igen."""
try:
await yt.process_queue()
await yt.enforce_cycles()
except Exception:
pass
+276
View File
@@ -0,0 +1,276 @@
"""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
+185
View File
@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Admin — MCP-nøgler{% endblock %}
{% block body %}
<h1>🔑 MCP-nøgler</h1>
<p class="wt-lead">Ét sted at oprette og se API-nøgler til alle MCP-servere. Kopiér en
<code>claude mcp add</code>-kommando og kør den på den maskine hvor Claude kører.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<h2 style="margin-top:1.5rem">Eksterne MCP-servere</h2>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Transport</th><th>URL / kommando</th><th>Status</th><th>Token</th><th></th></tr></thead>
<tbody>
{% for e in external %}
<tr>
<td><strong>{{ e.label }}</strong><br><small>{{ e.id }}</small></td>
<td>{{ e.transport }}</td>
<td>
{% if e.url %}<div><small>{{ e.url }}</small></div>{% endif %}
<code class="cmd" style="display:block;word-break:break-all;margin-top:.3rem">{{ e.command }}</code>
<button type="button" class="wt-btn" onclick="cp(this)">Kopiér kommando</button>
</td>
<td>
{% if e.last_check %}
{% if e.last_check.ok %}<span style="color:var(--ok,green)">●</span>{% elif e.last_check.ok == false %}<span style="color:var(--err,crimson)">●</span>{% else %}<span>○</span>{% endif %}
<small>{{ e.last_check.detail }}</small>
{% else %}<small>—</small>{% endif %}
<form method="post" style="margin-top:.3rem"><input type="hidden" name="action" value="healthcheck"><input type="hidden" name="id" value="{{ e.id }}"><button class="wt-btn" type="submit">Tjek</button></form>
</td>
<td><small>…{{ e.token[-4:] if e.token else "" }}</small></td>
<td>
<form method="post" onsubmit="return confirm('Slet {{ e.label }}?')"><input type="hidden" name="action" value="delete_external"><input type="hidden" name="id" value="{{ e.id }}"><button class="wt-btn" type="submit">✕</button></form>
</td>
</tr>
{% else %}
<tr><td colspan="6"><small>Ingen endnu.</small></td></tr>
{% endfor %}
</tbody>
</table>
<h3 style="margin-top:1.2rem">Tilføj / rediger ekstern MCP</h3>
<form method="post" class="wt-card" style="max-width:680px">
<input type="hidden" name="action" value="save_external">
<p><label>ID (kort, fx <code>gitea</code>)<br><input name="id" required></label></p>
<p><label>Navn<br><input name="label"></label></p>
<p><label>Transport<br>
<select name="transport"><option value="http">http</option><option value="sse">sse</option><option value="stdio">stdio</option></select></label></p>
<p><label>URL (http/sse)<br><input name="url" placeholder="https://…/mcp"></label></p>
<p><label>Token<br><input name="token"></label></p>
<p><label>Auth-header (default Authorization)<br><input name="auth_header" placeholder="Authorization"></label></p>
<p><label>Kommando (kun stdio, fx <code>npx -y paperless-mcp …</code>)<br><input name="command"></label></p>
<p><label>Note<br><input name="notes"></label></p>
<p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
</form>
<h2 style="margin-top:2rem">Gateway — alle MCP'er bag ét token</h2>
<p class="wt-lead"><code>{{ self_url }}</code> føderer portalens egne værktøjer + alle eksterne MCP'er ovenfor. Opret ét token (fx "remote-desktop") og giv en remote Claude den ene kommando — så har den adgang til alt bagved. <strong>Genstart portalen efter ændringer i listen ovenfor</strong> for at de slår igennem i gatewayen.</p>
{% if new_token %}
<div class="wt-flash" style="border-left:4px solid var(--ok,green)">
<strong>Nyt token "{{ new_token.name }}" — kopiér det nu, det vises kun én gang.</strong>
<code class="cmd" style="display:block;word-break:break-all;margin:.4rem 0">{{ new_token.token }}</code>
<div><small>Færdig kommando:</small></div>
<code class="cmd" style="display:block;word-break:break-all;margin-top:.2rem">{{ new_token.command }}</code>
<button type="button" class="wt-btn" onclick="cp(this)">Kopiér kommando</button>
</div>
{% endif %}
<form method="post" class="wt-card" style="margin-bottom:1rem;max-width:760px">
<input type="hidden" name="action" value="mint_portal">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input name="name" placeholder="fx remote-desktop" required>
<button class="wt-btn wt-btn--primary" type="submit">Opret token</button>
</div>
<div style="margin-top:.7rem">
<small><strong>Adgang til:</strong></small><br>
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"> {{ ns }}</label>
{% endfor %}
<div style="margin-top:.3rem"><small>Uden markering får nøglen ingen adgang til noget.</small></div>
</div>
</form>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Adgang</th><th>Oprettet</th><th>Sidst brugt</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for t in portal_tokens %}
<tr>
<td>{{ t.name }}</td>
<td>
{% if t.revoked %}<small>—</small>{% else %}
<form method="post" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="action" value="set_allowed">
<input type="hidden" name="name" value="{{ t.name }}">
{% for ns in namespaces %}
<label style="white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"{% if ns in t.allowed %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
<button class="wt-btn" type="submit">Gem</button>
</form>
{% endif %}
</td>
<td><small>{{ t.created_at }}</small></td>
<td><small>{{ t.last_used_at or "—" }}</small></td>
<td>{% if t.revoked %}<span style="color:var(--err,crimson)">spærret</span>{% else %}<span style="color:var(--ok,green)">aktiv</span>{% endif %}</td>
<td>{% if not t.revoked %}<form method="post" onsubmit="return confirm('Spær {{ t.name }}?')"><input type="hidden" name="action" value="revoke_portal"><input type="hidden" name="name" value="{{ t.name }}"><button class="wt-btn" type="submit">Spær</button></form>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6"><small>Ingen tokens endnu.</small></td></tr>
{% endfor %}
</tbody>
</table>
<p><small>Tokens vises kun ved oprettelse og gemmes kun som hash — mistet token spærres og et nyt oprettes.</small></p>
<h3 style="margin-top:2rem">Authentik-brugere</h3>
<p><small>Connectors i Claude-appen logger ind som en <strong>Authentik-bruger</strong> og har ikke et token her,
så deres adgang sættes på identitet i stedet. Brugere uden egen linje får standarden.</small></p>
<form method="post" class="wt-card" style="max-width:760px;margin-bottom:1rem">
<input type="hidden" name="action" value="set_oauth_default">
<div><small><strong>Standard</strong> — gælder Authentik-brugere uden egen linje</small></div>
<div style="margin:.35rem 0 .7rem">
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="default" value="{{ ns }}"{% if ns in oauth_access.default %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
</div>
<button class="wt-btn" type="submit">Gem standard</button>
</form>
<table class="wt-table">
<thead><tr><th>Bruger</th><th>Adgang</th><th></th></tr></thead>
<tbody>
{% for u in oauth_rows %}
<tr>
<td>{{ u.label }}</td>
<td>
<form method="post" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="action" value="set_oauth_user">
<input type="hidden" name="name" value="{{ u.username }}">
{% for ns in namespaces %}
<label style="white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"{% if ns in u.allowed %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
<button class="wt-btn" type="submit">Gem</button>
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Fjern egen adgang for {{ u.username }}? Brugeren falder tilbage til standarden.')">
<input type="hidden" name="action" value="del_oauth_user">
<input type="hidden" name="name" value="{{ u.username }}">
<button class="wt-btn" type="submit">✕</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3"><small>Ingen brugere med egen adgang — alle bruger standarden.</small></td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" class="wt-card" style="max-width:760px;margin-top:1rem">
<input type="hidden" name="action" value="set_oauth_user">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<select name="name" required>
<option value="">— vælg Authentik-bruger —</option>
{% for u in ak_users_available %}<option value="{{ u.username }}">{{ u.label }}</option>{% endfor %}
</select>
<button class="wt-btn wt-btn--primary" type="submit">Tilføj</button>
</div>
<div style="margin-top:.6rem">
<small><strong>Adgang til:</strong></small><br>
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"> {{ ns }}</label>
{% endfor %}
</div>
{% if not ak_users_available %}<div style="margin-top:.4rem"><small>Alle Authentik-brugere har allerede en egen linje (eller Authentik svarer ikke).</small></div>{% endif %}
</form>
<script>
function cp(btn){
var code = btn.previousElementSibling;
while (code && !code.classList.contains("cmd")) { code = code.previousElementSibling; }
if (!code) return;
navigator.clipboard.writeText(code.textContent.trim()).then(function(){
var o = btn.textContent; btn.textContent = "Kopieret ✓"; setTimeout(function(){btn.textContent=o;}, 1200);
});
}
</script>
{% endblock %}