feat: add admin panel
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
"""External MCP frontend (Phase 3.1).
|
||||
|
||||
A streamable-HTTP gateway in front of the internal MCP aggregator
|
||||
(``beaver_gateway.mcp.internal_app``). The aggregator already hosts
|
||||
every declared ``McpServer`` (``python_tool``, stdio proxy, HTTP proxy)
|
||||
(``beaver_gateway.mcp.internal_app``). The aggregator hosts every
|
||||
declared ``McpServer`` (``python_tool``, stdio proxy, HTTP proxy)
|
||||
under ``/mcp/<name>`` plus a flat ``/mcp/all`` bundle on
|
||||
``127.0.0.1:INTERNAL_MCP_PORT``. This frontend re-exposes those URLs to
|
||||
external clients with three additions:
|
||||
``127.0.0.1:INTERNAL_MCP_PORT`` — that's the *internal* shape.
|
||||
|
||||
This frontend re-exposes those namespaces on its own port directly at
|
||||
``/<name>/`` (no ``/mcp/`` prefix in the external routes — the port
|
||||
itself already disambiguates). Caddy / nginx / Cloudflare in front
|
||||
typically strips a prefix back on: ``domain.com/mcp/* → :8001/*``,
|
||||
controlled by the operator's reverse-proxy config and surfaced to the
|
||||
admin dashboard via ``public_base_url``. Three additions on top of the
|
||||
raw aggregator:
|
||||
|
||||
* **Bearer auth** — ``Authorization: Bearer <token>``, ``X-Api-Key``,
|
||||
or ``?token=<…>`` query string. All three forms verify against the
|
||||
@@ -39,6 +46,7 @@ from starlette.applications import Starlette
|
||||
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
|
||||
|
||||
@@ -86,9 +94,30 @@ __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", port: int = 8001) -> None: # noqa: S104
|
||||
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
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: Starlette | None = None
|
||||
# Single shared aiohttp session, opened once when uvicorn starts
|
||||
@@ -126,7 +155,7 @@ class McpServerFrontend(Frontend):
|
||||
# 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),
|
||||
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
@@ -138,16 +167,21 @@ class McpServerFrontend(Frontend):
|
||||
routes = [
|
||||
Route("/", self._discovery, methods=["GET"]),
|
||||
Route("/healthz", self._healthz, methods=["GET"]),
|
||||
# Two routes per namespace so both the trailing-slash and
|
||||
# sub-path forms work (``/mcp/time`` AND ``/mcp/time/foo``).
|
||||
# Starlette doesn't fold them into one route automatically.
|
||||
# Namespaces mount at the root of this port — the port
|
||||
# itself already disambiguates this from any other gateway
|
||||
# surface. Two routes per namespace so both the
|
||||
# trailing-slash and sub-path forms work (``/time`` AND
|
||||
# ``/time/foo``); Starlette doesn't fold them into one
|
||||
# route automatically. The literal routes above (``/``,
|
||||
# ``/healthz``) are listed first and win the match, so
|
||||
# they're not eaten by ``/{namespace}``.
|
||||
Route(
|
||||
"/mcp/{namespace}",
|
||||
"/{namespace}",
|
||||
self._proxy_endpoint,
|
||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
),
|
||||
Route(
|
||||
"/mcp/{namespace}/{path:path}",
|
||||
"/{namespace}/{path:path}",
|
||||
self._proxy_endpoint,
|
||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
),
|
||||
@@ -159,17 +193,19 @@ class McpServerFrontend(Frontend):
|
||||
|
||||
async def _discovery(self, request: Request) -> HTMLResponse | JSONResponse:
|
||||
runtime = self._require_runtime()
|
||||
token_name = _verify_request(request, runtime)
|
||||
if token_name is None:
|
||||
return _unauthorized()
|
||||
# Use the request's own scheme+host so the snippets work behind
|
||||
# reverse proxies / tunnels. Falls back to the configured
|
||||
# host:port if the client didn't send Host (curl --raw).
|
||||
base = _external_base_url(request, self.host, self.port)
|
||||
token_name, err = await _verify_request(request, runtime)
|
||||
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)
|
||||
html = _render_discovery_page(
|
||||
base_url=base,
|
||||
namespaces=list(runtime.mcps),
|
||||
actor=token_name,
|
||||
base_url=base, namespaces=list(runtime.mcps), actor=token_name
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@@ -177,9 +213,10 @@ class McpServerFrontend(Frontend):
|
||||
self, request: Request
|
||||
) -> StreamingResponse | JSONResponse:
|
||||
runtime = self._require_runtime()
|
||||
token_name = _verify_request(request, runtime)
|
||||
if token_name is None:
|
||||
return _unauthorized()
|
||||
token_name, err = await _verify_request(request, runtime)
|
||||
if err is not None:
|
||||
return err
|
||||
assert token_name is not None # noqa: S101 — narrow for ty
|
||||
|
||||
namespace = request.path_params["namespace"]
|
||||
subpath = request.path_params.get("path", "")
|
||||
@@ -190,16 +227,21 @@ class McpServerFrontend(Frontend):
|
||||
token_name,
|
||||
namespace,
|
||||
)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token_name}",
|
||||
kind="mcp_call",
|
||||
namespace=namespace,
|
||||
method=request.method,
|
||||
status=404,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": "unknown namespace", "namespace": namespace},
|
||||
status_code=404,
|
||||
{"error": "unknown namespace", "namespace": namespace}, status_code=404
|
||||
)
|
||||
|
||||
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 JSONResponse({"error": "frontend not ready"}, status_code=503)
|
||||
|
||||
return await _reverse_proxy(
|
||||
client=self._http,
|
||||
@@ -207,6 +249,7 @@ class McpServerFrontend(Frontend):
|
||||
upstream_url=upstream_url,
|
||||
namespace=namespace,
|
||||
actor=token_name,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
def _upstream_url(self, namespace: str, subpath: str) -> str | None:
|
||||
@@ -233,23 +276,38 @@ class McpServerFrontend(Frontend):
|
||||
return self._runtime
|
||||
|
||||
|
||||
def _verify_request(request: Request, runtime: GatewayRuntime) -> str | None:
|
||||
_MCP_SCOPE = "mcp"
|
||||
|
||||
|
||||
async def _verify_request(
|
||||
request: Request, runtime: GatewayRuntime
|
||||
) -> tuple[str | None, JSONResponse | None]:
|
||||
"""Accept ``Authorization: Bearer``, ``X-Api-Key``, or ``?token=``.
|
||||
|
||||
The third form is the escape hatch for clients that can only put
|
||||
secrets in the URL (claude.ai's MCP config historically did this).
|
||||
All three roads end at the same :class:`TokenStore`.
|
||||
All three roads end at the same :class:`TokenStore`. Returns
|
||||
``(actor_name, None)`` on success, ``(None, 401|403)`` otherwise
|
||||
— the caller forwards the response as-is. Splitting auth vs scope
|
||||
failures matters: 401 says "send me a token", 403 says "this token
|
||||
is real but not for this endpoint".
|
||||
"""
|
||||
api_key = request.headers.get("x-api-key")
|
||||
if api_key:
|
||||
return runtime.token_store.verify(api_key)
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header:
|
||||
return runtime.token_store.verify_bearer(auth_header)
|
||||
qs_token = request.query_params.get("token")
|
||||
if qs_token:
|
||||
return runtime.token_store.verify(qs_token)
|
||||
return None
|
||||
identity = await runtime.token_store.verify(api_key)
|
||||
else:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header:
|
||||
identity = await runtime.token_store.verify_bearer(auth_header)
|
||||
else:
|
||||
qs_token = request.query_params.get("token")
|
||||
identity = await runtime.token_store.verify(qs_token) if qs_token else None
|
||||
|
||||
if identity is None:
|
||||
return None, _unauthorized()
|
||||
if not identity.allows(_MCP_SCOPE):
|
||||
return None, _forbidden(identity.scope, _MCP_SCOPE)
|
||||
return identity.name, None
|
||||
|
||||
|
||||
def _unauthorized() -> JSONResponse:
|
||||
@@ -260,6 +318,13 @@ def _unauthorized() -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
def _forbidden(scope: str, required: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"error": "insufficient scope", "scope": scope, "required": required},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
def _join_subpath(base_url: str, subpath: str) -> str:
|
||||
"""Concatenate the loopback URL with the proxied sub-path.
|
||||
|
||||
@@ -287,6 +352,7 @@ async def _reverse_proxy(
|
||||
upstream_url: str,
|
||||
namespace: str,
|
||||
actor: str,
|
||||
runtime: GatewayRuntime,
|
||||
) -> StreamingResponse | JSONResponse:
|
||||
"""Bidirectionally stream an MCP request between client ↔ internal aggregator.
|
||||
|
||||
@@ -325,9 +391,16 @@ async def _reverse_proxy(
|
||||
namespace,
|
||||
exc,
|
||||
)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{actor}",
|
||||
kind="mcp_call",
|
||||
namespace=namespace,
|
||||
method=request.method,
|
||||
status=502,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": "upstream MCP unreachable", "detail": str(exc)},
|
||||
status_code=502,
|
||||
{"error": "upstream MCP unreachable", "detail": str(exc)}, status_code=502
|
||||
)
|
||||
|
||||
_log.info(
|
||||
@@ -338,6 +411,17 @@ async def _reverse_proxy(
|
||||
request.url.path,
|
||||
upstream_resp.status,
|
||||
)
|
||||
# Audit at upstream-response time: status reflects the MCP call's
|
||||
# outcome (200 / tool-error / 4xx). Streaming relay below may be
|
||||
# cut short by the client, but the row is already in by then.
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{actor}",
|
||||
kind="mcp_call",
|
||||
namespace=namespace,
|
||||
method=request.method,
|
||||
status=upstream_resp.status,
|
||||
)
|
||||
|
||||
response_headers = _response_headers(upstream_resp.headers)
|
||||
|
||||
@@ -397,29 +481,31 @@ def _scrub_query(query: str, *, drop: frozenset[str] | set[str]) -> str:
|
||||
return urlencode(kept)
|
||||
|
||||
|
||||
def _render_discovery_page(
|
||||
*, base_url: str, namespaces: list[Any], actor: str
|
||||
) -> str:
|
||||
def _render_discovery_page(*, base_url: str, namespaces: list[Any], actor: str) -> str:
|
||||
"""Render the auth-gated namespace + config-snippet page.
|
||||
|
||||
Inline HTML (no Jinja file) — keeps Phase 3 free of template-dir
|
||||
plumbing that Phase 4's AdminFrontend will own.
|
||||
"""
|
||||
name_list = [getattr(ns, "name", str(ns)) for ns in namespaces]
|
||||
rows = "\n".join(
|
||||
f""" <tr>
|
||||
rows = (
|
||||
"\n".join(
|
||||
f""" <tr>
|
||||
<td><code>{_escape(name)}</code></td>
|
||||
<td><code>{_escape(f"{base_url}/mcp/{name}/")}</code></td>
|
||||
<td><code>{_escape(f"{base_url}/{name}/")}</code></td>
|
||||
</tr>"""
|
||||
for name in name_list
|
||||
) or """ <tr><td colspan="2"><em>No MCP servers configured.</em></td></tr>"""
|
||||
for name in name_list
|
||||
)
|
||||
or """ \
|
||||
<tr><td colspan="2"><em>No MCP servers configured.</em></td></tr>"""
|
||||
)
|
||||
cursor_snippet = _CURSOR_SNIPPET.format(base_url=base_url)
|
||||
claude_desktop_snippet = _CLAUDE_DESKTOP_SNIPPET.format(base_url=base_url)
|
||||
return _DISCOVERY_TEMPLATE.format(
|
||||
actor=_escape(actor),
|
||||
base_url=_escape(base_url),
|
||||
rows=rows,
|
||||
all_url=_escape(f"{base_url}/mcp/{ALL_NAMESPACE}/"),
|
||||
all_url=_escape(f"{base_url}/{ALL_NAMESPACE}/"),
|
||||
cursor_snippet=_escape(cursor_snippet),
|
||||
claude_desktop_snippet=_escape(claude_desktop_snippet),
|
||||
)
|
||||
@@ -443,8 +529,7 @@ _DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-strin
|
||||
" <title>beaver-gateway · MCP discovery</title>",
|
||||
" <style>",
|
||||
" body {{",
|
||||
" font-family: -apple-system, 'SF Pro Display', system-ui,"
|
||||
" sans-serif;",
|
||||
" font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif;",
|
||||
" max-width: 880px;",
|
||||
" margin: 3rem auto;",
|
||||
" padding: 0 1.25rem;",
|
||||
@@ -502,7 +587,7 @@ _DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-strin
|
||||
_CURSOR_SNIPPET = """{{
|
||||
"mcpServers": {{
|
||||
"beaver-time": {{
|
||||
"url": "{base_url}/mcp/time/",
|
||||
"url": "{base_url}/time/",
|
||||
"headers": {{ "Authorization": "Bearer <YOUR_TOKEN>" }}
|
||||
}}
|
||||
}}
|
||||
@@ -513,7 +598,7 @@ _CLAUDE_DESKTOP_SNIPPET = """{{
|
||||
"mcpServers": {{
|
||||
"beaver-time": {{
|
||||
"type": "http",
|
||||
"url": "{base_url}/mcp/time/",
|
||||
"url": "{base_url}/time/",
|
||||
"headers": {{ "Authorization": "Bearer <YOUR_TOKEN>" }}
|
||||
}}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user