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
This commit is contained in:
agw
2026-09-29 17:27:26 +02:00
parent 52b1a2bb6c
commit 0635f4a76c
2 changed files with 135 additions and 1 deletions
+28 -1
View File
@@ -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()
+107
View File
@@ -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()