From 0635f4a76cb0ec4048ae50cdb3128156d1fa9c67 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Tue, 29 Sep 2026 17:27:26 +0200 Subject: [PATCH] feat(mcp): hovedhusets egen gateway paa /hovedhus/mcp Selvstaendig under-app med egen Authentik-provider (hovedhus-mcp, gruppe Hovedhus), egen OIDCProxy/noegle/lager og backends fra data/hovedhus_gateway.json, aldrig fra mcp_registry.json. Ingen statiske noegler; UserGate kraever navngiven bruger og fejler lukket. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG --- main.py | 29 ++++++++++++- mcp_hovedhus.py | 107 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 mcp_hovedhus.py diff --git a/main.py b/main.py index 581c102..c103222 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware +import contextlib import time import authentik as ak @@ -20,11 +21,30 @@ import ytdl as yt import mcpkeys import mcp_server import mcp_gateway +import mcp_hovedhus # 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) + +# Hovedhusets gateway (/hovedhus/mcp) er en selvstændig under-app med egen +# auth, så dens middleware kun gælder under /hovedhus. Begge apps har en +# lifespan (StreamableHTTP-sessionerne), og FastAPI tager kun én. +hh_app = (mcp_hovedhus.gateway.http_app(path="/mcp", json_response=True) + if mcp_hovedhus.gateway else None) + + +@contextlib.asynccontextmanager +async def _lifespan(a): + async with mcp_app.lifespan(a): + if hh_app is None: + yield + else: + async with hh_app.lifespan(hh_app): + yield + + +app = FastAPI(title="Weircon Portal", lifespan=_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 @@ -34,6 +54,13 @@ for _mcp_mw in reversed(mcp_app.user_middleware): app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12) app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static") +if hh_app is not None: + # OAuth-metadata skal ligge i roden (RFC 8414/9728) med stien som suffiks. + # Den bare /.well-known/openid-configuration tilhører hovedgatewayen. + for _r in mcp_hovedhus.gateway.auth.get_well_known_routes(mcp_path="/mcp"): + if _r.path != "/.well-known/openid-configuration": + app.router.routes.append(_r) + app.mount(mcp_hovedhus.PREFIX, hh_app) templates = Jinja2Templates(directory=str(config.BASE / "templates")) oauth = OAuth() diff --git a/mcp_hovedhus.py b/mcp_hovedhus.py new file mode 100644 index 0000000..6a4a812 --- /dev/null +++ b/mcp_hovedhus.py @@ -0,0 +1,107 @@ +"""Hovedhusets MCP-gateway på /hovedhus/mcp: Jørn og Lottes egen bro til deres +Claude, helt adskilt fra Asgers gateway på /mcp. + +Adskillelsen er hele pointen, så den ligger i konstruktionen og ikke i en +filterregel: +- Egen Authentik-provider (slug ``hovedhus-mcp``, bundet til gruppen Hovedhus) + og egen OIDCProxy med egen nøgle og eget lager. Et token fra /mcp kan ikke + bruges her og omvendt. +- Backends læses fra ``data/hovedhus_gateway.json``, ALDRIG fra + ``mcp_registry.json``; ellers ville Asgers gateway montere dem også, og + hovedhusets backends kan ikke se noget der står i registry'et. +- Ingen statiske portal-nøgler: kun OAuth-brugere fra ``allowed_users``. + +Monteres som under-app i main.py, så dens auth-middleware kun gælder her. +Mangler konfigurationsfilen, monteres intet. +""" +import json +import logging +from pathlib import Path + +from fastmcp import FastMCP +from fastmcp.exceptions import ToolError +from fastmcp.server import create_proxy +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 + +from mcp_gateway import CLAUDE_REDIRECTS + +log = logging.getLogger("portal.hovedhus") + +CONF = Path("/opt/portal/data/hovedhus_gateway.json") +PREFIX = "/hovedhus" +BASE_URL = "https://weircon.dk" + PREFIX + + +def _load() -> dict | None: + try: + return json.loads(CONF.read_text()) + except FileNotFoundError: + return None + + +class UserGate(Middleware): + """Kun navngivne brugere. Authentik-bindingen til gruppen Hovedhus er den + primære lås; denne er den anden, så en glemt binding eller et refresh-token + fra en der er meldt ud ikke åbner for husstandens økonomi. Fejler lukket.""" + + def __init__(self, users): + self._users = set(users) + + def _ok(self) -> bool: + try: + tok = get_access_token() + except Exception: + return False + if tok is None: + return False + claims = getattr(tok, "claims", None) or {} + sub = claims.get("sub") or getattr(tok, "subject", None) + if sub in self._users: + return True + log.warning("hovedhus-mcp: afvist sub=%r", sub) + return False + + async def on_list_tools(self, context, call_next): + if not self._ok(): + return [] + return await call_next(context) + + async def on_call_tool(self, context, call_next): + if not self._ok(): + raise ToolError("ingen adgang til hovedhusets økonomi") + return await call_next(context) + + +def build() -> FastMCP | None: + conf = _load() + if not conf: + return None + o = conf["oidc"] + auth = OIDCProxy( + config_url=o["config_url"], client_id=o["client_id"], + client_secret=o["client_secret"], + base_url=BASE_URL, redirect_path="/oauth/callback", + allowed_client_redirect_uris=CLAUDE_REDIRECTS, + client_storage=DiskStore(directory="/opt/portal/data/mcp_oauth_hovedhus"), + forward_resource=False, + # offline_access giver refresh-token, så planlagte opgaver kan køre + # uden at nogen logger ind igen (se mcp_gateway._build_auth). + required_scopes=["openid", "offline_access"], + require_authorization_consent=False, + ) + gw = FastMCP("hovedhus", auth=auth, + instructions="Hovedhusets (Jørn og Lottes) fælles økonomi: " + "bankposteringer, kategorier, budget og rapporter.") + gw.add_middleware(UserGate(conf.get("allowed_users") or [])) + for b in conf.get("backends") or []: + proxy = create_proxy({"mcpServers": {b["id"]: { + "transport": "http", "url": b["url"], + "headers": {"Authorization": f"Bearer {b['token']}"}}}}) + gw.mount(proxy, namespace=b["id"]) + return gw + + +gateway = build()