Files
ITWorx_MCP_Hub-Public/services/runtime/web.py
T

144 lines
5.1 KiB
Python

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())