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