Publish curated Unraid-AppOps-Gateway source from a506550ef83ef2d81651e37c98a89f7ee330c730
This commit is contained in:
1 parent
2facb8de34
commit
767e66921e
68 files changed
+15924
-313
No files matched your search
@@ -0,0 +1,164 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user