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:
@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import authentik as ak
|
import authentik as ak
|
||||||
@@ -20,11 +21,30 @@ import ytdl as yt
|
|||||||
import mcpkeys
|
import mcpkeys
|
||||||
import mcp_server
|
import mcp_server
|
||||||
import mcp_gateway
|
import mcp_gateway
|
||||||
|
import mcp_hovedhus
|
||||||
|
|
||||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||||
mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True)
|
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:
|
for _mcp_route in mcp_app.routes:
|
||||||
app.router.routes.append(_mcp_route)
|
app.router.routes.append(_mcp_route)
|
||||||
# route-extend copies routes but NOT middleware — carry FastMCP's auth/context
|
# 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,
|
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
|
||||||
same_site="lax", max_age=60 * 60 * 12)
|
same_site="lax", max_age=60 * 60 * 12)
|
||||||
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static")
|
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"))
|
templates = Jinja2Templates(directory=str(config.BASE / "templates"))
|
||||||
|
|
||||||
oauth = OAuth()
|
oauth = OAuth()
|
||||||
|
|||||||
+107
@@ -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()
|
||||||
Reference in New Issue
Block a user