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
108 lines
3.8 KiB
Python
108 lines
3.8 KiB
Python
"""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()
|