feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars

This commit is contained in:
hh
2026-08-29 00:49:06 +02:00
parent 540327efa1
commit 16b6bbddda
32 changed files with 691 additions and 441 deletions
+16 -76
View File
@@ -37,7 +37,6 @@ from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
@@ -47,6 +46,7 @@ from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
from starlette.routing import Route
from beaver_gateway.core import audit
from beaver_gateway.frontends._urls import external_base
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
@@ -94,76 +94,31 @@ __all__ = ["McpServerFrontend"]
class McpServerFrontend(Frontend):
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8001,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy uses to reach this
# frontend. Internal routes mount namespaces at the port root
# (``/<ns>/`` and ``/all/``) — Caddy / nginx / Cloudflare in
# front decides what external prefix they sit under. Typical
# symmetric setup:
#
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
# config: public_base_url -> https://api.example.com/mcp
# dashboard advertises: https://api.example.com/mcp/<ns>/
#
# The frontend's ``/<ns>/`` segment gets appended verbatim. Set
# it to whatever matches your proxy. ``None`` means "advertise
# raw ``host:port`` derived from the inbound request" (dev /
# no proxy).
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
path = "/mcp"
def __init__(self) -> None:
self._runtime: GatewayRuntime | None = None
self._app: Starlette | None = None
# Single shared aiohttp session, opened once when uvicorn starts
# the Starlette lifespan. Reused across every proxied request —
# MCP clients (esp. claude.ai) reconnect frequently, and a fresh
# ClientSession per call would cost a TCP handshake every time.
self._http: aiohttp.ClientSession | None = None
def configure(self, runtime: GatewayRuntime) -> None:
self._runtime = runtime
self._app = self._build_app(runtime)
def app(self) -> Starlette | None:
return self._app
async def serve(self) -> None:
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
self._http = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
)
server = uvicorn.Server(config)
await server.serve()
try:
await asyncio.Event().wait()
finally:
await self._http.close()
self._http = None
def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002
# The Starlette lifespan owns the shared aiohttp session: opened
# at startup, closed at shutdown so we don't leak sockets when
# uvicorn is restarted under the same process. Starlette wants a
# plain context manager, not an async-generator function — we
# decorate to match.
@asynccontextmanager
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
self._http = aiohttp.ClientSession(
# Long sock_read because MCP tool calls can take a while
# (a claude tool over HTTP can easily stretch beyond 30s
# on a real tool).
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
)
try:
yield
finally:
if self._http is not None:
await self._http.close()
self._http = None
routes = [
Route("/", self._discovery, methods=["GET"]),
Route("/healthz", self._healthz, methods=["GET"]),
@@ -186,7 +141,7 @@ class McpServerFrontend(Frontend):
methods=["GET", "POST", "DELETE", "OPTIONS"],
),
]
return Starlette(routes=routes, lifespan=lifespan)
return Starlette(routes=routes)
async def _healthz(self, _request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})
@@ -197,13 +152,7 @@ class McpServerFrontend(Frontend):
if err is not None:
return err
assert token_name is not None # noqa: S101 — narrow for ty
# ``public_base_url`` wins if configured — it's the operator's
# explicit statement of "this is the URL my reverse proxy puts
# in front of me". Otherwise: use the request's own scheme+host
# so snippets work behind generic reverse proxies / tunnels;
# and fall back to the configured host:port if the client
# didn't send Host (curl --raw).
base = self.public_base_url or _external_base_url(request, self.host, self.port)
base = external_base(request, runtime)
html = _render_discovery_page(
base_url=base, namespaces=list(runtime.mcps), actor=token_name
)
@@ -240,7 +189,6 @@ class McpServerFrontend(Frontend):
)
if self._http is None:
# Lifespan hasn't run yet (shouldn't happen with uvicorn).
return JSONResponse({"error": "frontend not ready"}, status_code=503)
return await _reverse_proxy(
@@ -337,14 +285,6 @@ def _join_subpath(base_url: str, subpath: str) -> str:
return base_url
def _external_base_url(request: Request, fallback_host: str, fallback_port: int) -> str:
host = request.headers.get("host")
if host:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
return f"{scheme}://{host}"
return f"http://{fallback_host}:{fallback_port}"
async def _reverse_proxy(
*,
client: aiohttp.ClientSession,