from __future__ import annotations import os import re from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path import httpx from fastapi import FastAPI, Request, status from fastapi import Response as FastAPIResponse from fastapi.responses import FileResponse, Response from .common import run_asgi from .oauth_metadata import OAuthMetadataSettings, protected_resource_metadata _REQUEST_EXCLUDED = frozenset({"host", "content-length", "connection"}) _RESPONSE_ALLOWED = frozenset( { "cache-control", "content-type", "expires", "location", "pragma", "set-cookie", "www-authenticate", "x-trace-id", } ) _MCP_RESOURCE_PATH = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}/[a-z0-9][a-z0-9-]{0,62}$") def build_app() -> FastAPI: control_url = os.environ.get("HUB_CONTROL_URL", "http://control-api:8080").rstrip("/") worker_url = os.environ.get("HUB_WORKER_URL", "http://worker:8080").rstrip("/") static_root = Path(os.environ.get("HUB_WEB_ROOT", "/opt/hub/web-dist")).resolve() if not (static_root / "index.html").is_file(): raise ValueError("built Hub web assets are unavailable") oauth_metadata = OAuthMetadataSettings.from_environment() @asynccontextmanager async def lifespan(application: FastAPI) -> AsyncIterator[None]: application.state.control = httpx.AsyncClient(base_url=control_url, timeout=30) application.state.worker = httpx.AsyncClient(base_url=worker_url, timeout=30) try: yield finally: await application.state.control.aclose() await application.state.worker.aclose() app = FastAPI( title="ITWorx MCP Hub web", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan, ) @app.get("/healthz") @app.get("/health") def health() -> dict[str, str]: return {"status": "healthy", "component": "web"} @app.get("/ready") @app.get("/health/ready") async def ready(response: FastAPIResponse) -> dict[str, str]: try: upstream = await app.state.control.get("/ready") if upstream.status_code != 200: raise RuntimeError("control API not ready") worker = await app.state.worker.get("/health/ready") if worker.status_code != 200: raise RuntimeError("worker not ready") except Exception: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return {"status": "not-ready", "component": "web"} return {"status": "ready", "component": "web"} @app.get("/version") def version() -> dict[str, str]: return {"version": os.environ.get("HUB_RELEASE_VERSION", "1.0.0-rc")} def protected_resource(resource_path: str) -> dict[str, object]: if not _MCP_RESOURCE_PATH.fullmatch(resource_path): resource_path = "gitea-all/prod" return protected_resource_metadata(resource_path, oauth_metadata) @app.get("/.well-known/oauth-protected-resource") def default_oauth_protected_resource_metadata() -> dict[str, object]: return protected_resource("gitea-all/prod") @app.get("/.well-known/oauth-protected-resource/mcp/{project}/{environment}") def oauth_protected_resource_metadata(project: str, environment: str) -> dict[str, object]: return protected_resource(f"{project}/{environment}") async def proxy(request: Request, path: str) -> Response: headers = { key: value for key, value in request.headers.items() if key.lower() not in _REQUEST_EXCLUDED } upstream = await app.state.control.request( request.method, f"/{path}", params=request.query_params, content=await request.body(), headers=headers, follow_redirects=False, ) response_headers = { key: value for key, value in upstream.headers.items() if key.lower() in _RESPONSE_ALLOWED } return Response( content=upstream.content, status_code=upstream.status_code, headers=response_headers, media_type=None, ) @app.api_route("/auth/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def auth_proxy(request: Request, path: str) -> Response: return await proxy(request, f"auth/{path}") @app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def api_proxy(request: Request, path: str) -> Response: return await proxy(request, f"api/{path}") @app.get("/{path:path}", response_model=None) async def static(path: str) -> FileResponse: candidate = (static_root / path).resolve() if candidate != static_root and static_root not in candidate.parents: candidate = static_root / "index.html" if not candidate.is_file(): candidate = static_root / "index.html" return FileResponse(candidate) return app def run() -> None: run_asgi(build_app())