Files
Unraid-AppOps-Gateway-Public/backend/app/runner_statistics_mcp.py
T

165 lines
5.7 KiB
Python

"""Dedicated one-tool MCP surface for bounded, read-only CI statistics."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from contextlib import asynccontextmanager
import ipaddress
import re
from typing import Any
from urllib.parse import urlsplit
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from starlette.applications import Starlette
from starlette.responses import Response
from starlette.types import ASGIApp, Receive, Scope, Send
from .core.config import settings
from .core.version import APP_VERSION
from .services.runner_statistics import RunnerStatisticsService, sanitize_statistics, unavailable_statistics
RUNNER_STATISTICS_TOOL = "gitea.actions.runners.statistics"
_READ_ONLY_TOOL = ToolAnnotations(
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=False,
)
_DNS_HOST_PATTERN = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$")
def _validated_auth_public_host() -> tuple[str, int | None] | None:
try:
parsed = urlsplit(settings.auth_public_url)
hostname = parsed.hostname
port = parsed.port
except ValueError:
return None
if (
parsed.scheme.lower() != "https"
or not hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or (port is not None and not 1 <= port <= 65535)
):
return None
try:
normalized = hostname.rstrip(".").encode("idna").decode("ascii").lower()
except UnicodeError:
return None
if not normalized:
return None
if ":" in normalized:
try:
ipaddress.IPv6Address(normalized)
except ValueError:
return None
elif len(normalized) > 253 or not _DNS_HOST_PATTERN.fullmatch(normalized):
return None
return normalized, port
def _readonly_allowed_hosts() -> list[str]:
"""Add only the validated public auth host to this read-only transport."""
hosts = list(settings.mcp_allowed_hosts or [])
validated = _validated_auth_public_host()
if validated is None:
return hosts
normalized, port = validated
host_value = f"[{normalized}]" if ":" in normalized else normalized
candidates = [f"{host_value}:{port}" if port is not None else host_value]
for candidate in candidates:
if candidate not in hosts:
hosts.append(candidate)
return hosts
def _readonly_allowed_origins() -> list[str]:
"""Keep existing frontend origin and admit only the validated auth origin."""
origins = [settings.frontend_origin] if settings.frontend_origin else []
validated = _validated_auth_public_host()
if validated is None:
return origins
normalized, port = validated
host_value = f"[{normalized}]" if ":" in normalized else normalized
netloc = f"{host_value}:{port}" if port is not None else host_value
origin = f"https://{netloc}"
if origin not in origins:
origins.append(origin)
return origins
def create_runner_statistics_mcp(
*,
snapshot_provider: Callable[[], object] | None = None,
allowed_hosts: Sequence[str] | None = None,
allowed_origins: Sequence[str] | None = None,
) -> tuple[MCPServer, Starlette]:
"""Build an isolated MCP server exposing only the sanitized statistics tool."""
provider = snapshot_provider or RunnerStatisticsService().snapshot
server = MCPServer(
"AppOps CI Runner Statistics",
version=APP_VERSION,
instructions=(
"Read-only CI runner profile statistics. This server exposes no operational, "
"administrative, deployment, filesystem, or mutation tools."
),
)
@server.tool(name=RUNNER_STATISTICS_TOOL, annotations=_READ_ONLY_TOOL)
def gitea_actions_runner_statistics() -> dict[str, Any]:
"""Read bounded runner budgets, measured usage, and job statistics."""
try:
return sanitize_statistics(provider())
except Exception: # noqa: BLE001 - keep internal broker details off the MCP surface.
return unavailable_statistics()
transport = server.streamable_http_app(
streamable_http_path="/",
stateless_http=True,
json_response=True,
max_request_body_size=1_048_576,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=_readonly_allowed_hosts() if allowed_hosts is None else list(allowed_hosts),
allowed_origins=_readonly_allowed_origins() if allowed_origins is None else list(allowed_origins),
),
)
return server, transport
class _ReplaceableASGIApp:
"""Keep the mounted path stable while giving each app lifespan a fresh manager."""
def __init__(self) -> None:
self._app: ASGIApp | None = None
def replace(self, app: ASGIApp | None) -> None:
self._app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if self._app is None:
response = Response("Runner statistics MCP is unavailable", status_code=503)
await response(scope, receive, send)
return
await self._app(scope, receive, send)
runner_statistics_mcp_http_app = _ReplaceableASGIApp()
@asynccontextmanager
async def run_runner_statistics_mcp():
"""Run a fresh stateless transport manager for this FastAPI lifespan."""
server, transport = create_runner_statistics_mcp()
runner_statistics_mcp_http_app.replace(transport)
try:
async with server.session_manager.run():
yield
finally:
runner_statistics_mcp_http_app.replace(None)