feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user