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:
@@ -33,6 +33,8 @@ USERPREFS = DATA / "userprefs.json"
|
|||||||
IP_LABELS_LOCAL = DATA / "ip_labels.json"
|
IP_LABELS_LOCAL = DATA / "ip_labels.json"
|
||||||
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "")
|
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "")
|
||||||
SETTINGS_PATH = DATA / "settings.json"
|
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) ----
|
# ---- Medie-oprydning (Emby + Sonarr/Radarr) ----
|
||||||
EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/")
|
EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/")
|
||||||
@@ -56,7 +58,6 @@ MEDIA_DEFAULTS = {
|
|||||||
"do_series": True,
|
"do_series": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---- YouTube offline (yt-dlp + Emby) ----
|
# ---- YouTube offline (yt-dlp + Emby) ----
|
||||||
YT_DOWNLOAD_DIR = os.environ.get("YT_DOWNLOAD_DIR", "/media/yt-offline")
|
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")
|
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."},
|
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
"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)."},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import authentik as ak
|
import authentik as ak
|
||||||
@@ -18,8 +17,20 @@ import services as svc
|
|||||||
import store
|
import store
|
||||||
import versions
|
import versions
|
||||||
import ytdl as yt
|
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,
|
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
|
||||||
same_site="lax", max_age=60 * 60 * 12)
|
same_site="lax", max_age=60 * 60 * 12)
|
||||||
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static")
|
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": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"},
|
||||||
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"},
|
{"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": "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:
|
try:
|
||||||
for a in await ak.fetch_user_apps(user["username"]):
|
for a in await ak.fetch_user_apps(user["username"]):
|
||||||
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
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)}
|
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 ----------------
|
# ---------------- YouTube offline ----------------
|
||||||
@app.get("/admin/yt", response_class=HTMLResponse)
|
@app.get("/admin/yt", response_class=HTMLResponse)
|
||||||
async def admin_yt(request: Request, saved: str = ""):
|
async def admin_yt(request: Request, saved: str = ""):
|
||||||
|
|||||||
+166
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user