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
167 lines
7.3 KiB
Python
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()
|