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:
+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()
|
||||
Reference in New Issue
Block a user