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