165 lines
5.7 KiB
Python
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)
|