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

167 lines
7.3 KiB
Python

"""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()