"""Transports: the stdio runner and the streamable HTTP app with its runner. Startup is fail-closed. A non-loopback bind without HTTP authentication, a half-configured OAuth setup or a missing Gitea token end in exit code 2 with an actionable message on stderr (never on stdout, which carries JSON-RPC on stdio). """ from __future__ import annotations import asyncio import errno import ipaddress import logging import sys import time from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any from urllib.parse import urlsplit import anyio import httpx import uvicorn from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions from mcp.server.stdio import stdio_server from mcp.server.transport_security import TransportSecuritySettings from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from aegis_gitea_mcp import __version__ from aegis_gitea_mcp.audit import get_audit_logger from aegis_gitea_mcp.auth import ( MCP_SCOPE, CompositeVerifier, IdentityResolver, StaticBearerVerifier, ) from aegis_gitea_mcp.config import Settings, is_loopback_host from aegis_gitea_mcp.gitea import GiteaExecutor from aegis_gitea_mcp.oauth import OAuthSetupError from aegis_gitea_mcp.rate_limit import RequestRateLimiter from aegis_gitea_mcp.server import ( SERVER_NAME, AegisServer, ServerConfigError, build_server, max_request_body_size, ) logger = logging.getLogger(__name__) HEALTH_CACHE_SECONDS = 10.0 _PROBE_TIMEOUT_SECONDS = 5.0 _WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", "[::]"}) # noqa: S104 (compared, never bound) EXIT_USAGE = 2 def _fail(message: str) -> int: """Print an actionable startup error to stderr and return the usage exit code.""" print(f"aegis-gitea-mcp: {message}", file=sys.stderr) return EXIT_USAGE @dataclass(frozen=True) class HttpAuthExtras: """Optional per-user OAuth pieces plugged into the HTTP app (admin-token mode needs none). Attributes: token_verifier: Verifier accepting OAuth-issued tokens (combined with the static bearer verifier when ``MCP_BEARER_TOKEN`` is set). auth_server_provider: SDK authorization server provider (mounts the OAuth routes). resolver: Identity resolver for OAuth tokens. extra_routes: Extra unauthenticated routes (for example ``/oauth/callback``). client_registration_options: Dynamic client registration settings. revocation_options: Token revocation settings. executor: Gitea executor shared by the server and the resolver. rate_limited_paths: Unauthenticated paths that get the per-IP rate limit. """ token_verifier: TokenVerifier | None = None auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None resolver: IdentityResolver | None = None extra_routes: tuple[Route, ...] = () client_registration_options: ClientRegistrationOptions | None = None revocation_options: RevocationOptions | None = None executor: GiteaExecutor | None = None rate_limited_paths: tuple[str, ...] = () def load_oauth_components(settings: Settings) -> HttpAuthExtras: """Build the per-user OAuth components for ``OAUTH_ENABLED``. Raises: OAuthSetupError: Incomplete configuration or an unusable token store. """ from aegis_gitea_mcp.oauth.components import build_oauth_components return build_oauth_components(settings) # Health probe class GiteaProbe: """Cached reachability check of the Gitea API (``GET /api/v1/version``). Never exposes URLs, tokens or exception text: callers only learn up or down. """ def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None: self._url = f"{settings.gitea_api_url}/version" token = settings.gitea_token self._headers = {"Authorization": f"token {token.get_secret_value()}"} if token else {} self._client = client or httpx.AsyncClient( timeout=httpx.Timeout( min(_PROBE_TIMEOUT_SECONDS, float(settings.request_timeout_seconds)) ), follow_redirects=False, ) self._owns_client = client is None self._lock = asyncio.Lock() self._checked_at = 0.0 self._reachable = False async def reachable(self) -> bool: """Return whether Gitea answered successfully within the last 10 seconds.""" async with self._lock: now = time.monotonic() if self._checked_at and now - self._checked_at < HEALTH_CACHE_SECONDS: return self._reachable try: response = await self._client.get(self._url, headers=self._headers) self._reachable = response.is_success except Exception: self._reachable = False self._checked_at = time.monotonic() return self._reachable async def aclose(self) -> None: """Close the HTTP client when this probe created it.""" if self._owns_client: await self._client.aclose() # Rate limiting class RateLimitMiddleware: """ASGI middleware applying the per-IP and per-token limits to the MCP path. ``extra_paths`` (for example the unauthenticated OAuth endpoints) share the limits. """ def __init__( self, app: ASGIApp, mcp_path: str, limiter: RequestRateLimiter, extra_paths: Sequence[str] = (), ) -> None: self.app = app self._paths = frozenset({mcp_path, *(path.rstrip("/") for path in extra_paths)}) self._limiter = limiter async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or scope["path"].rstrip("/") not in self._paths: await self.app(scope, receive, send) return client = scope.get("client") client_ip = client[0] if client else "unknown" authorization: str | None = None for name, value in scope.get("headers", []): if name == b"authorization": authorization = value.decode("latin-1") break decision = self._limiter.check(client_ip, authorization) if decision.allowed: await self.app(scope, receive, send) return response = JSONResponse( {"error": {"kind": "rate_limited", "message": "Too many requests"}}, status_code=429, headers={"Retry-After": "60"}, ) await response(scope, receive, send) # Forwarded headers class ForwardedHeadersMiddleware: """Applies ``X-Forwarded-For`` and ``-Proto`` from trusted peers and warns about the rest. Replaces uvicorn's own proxy-header handling (the runner passes ``proxy_headers=False``) so the original peer address is still known here: a request that carries ``X-Forwarded-For`` from a peer outside ``FORWARDED_ALLOW_IPS`` is not honoured (the peer's own address is used) and logs one warning, because that usually means the reverse proxy is not listed (for example a Docker network address) and every client then shares the proxy's rate-limit bucket. Args: app: The ASGI application. trusted: ``FORWARDED_ALLOW_IPS`` (``*`` or a comma list of IPs and networks). """ def __init__(self, app: ASGIApp, trusted: str) -> None: self.trusted = trusted self._all = trusted.strip() == "*" self._networks = [ ipaddress.ip_network(item.strip(), strict=False) for item in trusted.split(",") if item.strip() and item.strip() != "*" ] self._app = ProxyHeadersMiddleware(app, trusted_hosts=trusted) # type: ignore[arg-type] self._warned = False def _peer_trusted(self, host: str) -> bool: if self._all: return True try: address = ipaddress.ip_address(host.split("%")[0]) except ValueError: return False return any( address in network for network in self._networks if network.version == address.version ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "http" and not self._warned: client = scope.get("client") has_forwarded = any(name == b"x-forwarded-for" for name, _ in scope.get("headers", [])) if has_forwarded and client and not self._peer_trusted(client[0]): self._warned = True logger.warning( "a request carried X-Forwarded-For from a peer that is not in " "FORWARDED_ALLOW_IPS, so the header is ignored and the peer address is used " "as the client address; if this peer is your reverse proxy, add its address " "to FORWARDED_ALLOW_IPS (all clients otherwise share one rate-limit bucket)" ) await self._app(scope, receive, send) # type: ignore[arg-type] # HTTP app def _base_url(settings: Settings) -> str: return settings.public_base_url or f"http://127.0.0.1:{settings.mcp_port}" def transport_security(settings: Settings) -> TransportSecuritySettings: """DNS-rebinding protection: allow loopback, the public host and ``ALLOWED_HOSTS``.""" port = settings.mcp_port hosts = [f"127.0.0.1:{port}", f"localhost:{port}", f"[::1]:{port}"] origins = [f"http://127.0.0.1:{port}", f"http://localhost:{port}", f"http://[::1]:{port}"] if settings.mcp_host.strip("[]") not in _WILDCARD_HOSTS and not settings.is_loopback_bind(): hosts.append(f"{settings.mcp_host}:{port}") if settings.public_base_url: parts = urlsplit(settings.public_base_url) hosts.extend([parts.netloc, parts.hostname or parts.netloc]) origins.append(f"{parts.scheme}://{parts.netloc}") for host in settings.allowed_hosts: hosts.append(host) origins.extend([f"https://{host}", f"http://{host}"]) return TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=list(dict.fromkeys(hosts)), allowed_origins=list(dict.fromkeys(origins)), ) def _resource_url(settings: Settings) -> str: return f"{_base_url(settings)}{settings.mcp_path}" def _auth_settings( settings: Settings, client_registration_options: ClientRegistrationOptions | None, revocation_options: RevocationOptions | None, *, authorization_server: bool, ) -> AuthSettings: issuer = _base_url(settings) return AuthSettings( issuer_url=issuer, # type: ignore[arg-type] resource_server_url=_resource_url(settings), # type: ignore[arg-type] # With our own authorization server every token (OAuth-issued and the # static bearer) names this endpoint as its RFC 8707 resource and carries # the one scope we grant, so the SDK enforces both. Without it, extra # verifiers are trusted to check the audience themselves. required_scopes=[MCP_SCOPE] if authorization_server else None, validate_token_resource=authorization_server, client_registration_options=client_registration_options, revocation_options=revocation_options, ) def build_http_app( settings: Settings, *, server: AegisServer | None = None, token_verifier: TokenVerifier | None = None, auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, extra_routes: Sequence[Route] = (), client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, probe: GiteaProbe | None = None, rate_limited_paths: Sequence[str] = (), ) -> Starlette: """Build the streamable HTTP ASGI app (MCP endpoint, ``/health``, ``/``). Authentication: ``MCP_BEARER_TOKEN`` (when set) and ``token_verifier`` (when passed) are combined; any accepted token reaches the MCP endpoint. With neither, the endpoint is unauthenticated, which is only acceptable on a loopback bind (a warning is logged). Args: settings: Application settings. server: Server to serve (defaults to ``build_server(settings)``). token_verifier: Extra verifier, for example the per-user OAuth verifier. auth_server_provider: SDK authorization server provider (needs a verifier). extra_routes: Additional routes served without MCP authentication. client_registration_options: Dynamic client registration settings. revocation_options: Token revocation settings. probe: Gitea reachability probe for ``/health`` (defaults to a new one). rate_limited_paths: Further paths (besides the MCP endpoint) that get the rate limit, for example the OAuth endpoints. Raises: ValueError: ``auth_server_provider`` given without any token verifier. """ mcp_server = server or build_server(settings) gitea_probe = probe or GiteaProbe(settings) verifiers: list[TokenVerifier] = [] if settings.mcp_bearer_token is not None: verifiers.append( StaticBearerVerifier( settings.mcp_bearer_token.get_secret_value(), resource=_resource_url(settings) ) ) if token_verifier is not None: verifiers.append(token_verifier) if auth_server_provider is not None and not verifiers: raise ValueError("auth_server_provider needs a token verifier to protect the MCP endpoint") verifier: TokenVerifier | None = None if verifiers: verifier = verifiers[0] if len(verifiers) == 1 else CompositeVerifier(*verifiers) else: logger.warning( "HTTP endpoint runs WITHOUT authentication (loopback development only); " "set MCP_BEARER_TOKEN before exposing it" ) async def health(request: Request) -> Response: if await gitea_probe.reachable(): return JSONResponse( { "status": "ok", "version": __version__, "tools": mcp_server.tool_count, "gitea": "reachable", } ) return JSONResponse({"status": "degraded", "gitea": "unreachable"}, status_code=503) async def index(request: Request) -> Response: return JSONResponse( {"name": SERVER_NAME, "version": __version__, "mcp_endpoint": settings.mcp_path} ) routes = [ Route("/health", health, methods=["GET"]), Route("/", index, methods=["GET"]), *extra_routes, ] app = mcp_server.streamable_http_app( streamable_http_path=settings.mcp_path, stateless_http=True, json_response=False, max_request_body_size=max_request_body_size(settings), transport_security=transport_security(settings), host=settings.mcp_host, auth=( _auth_settings( settings, client_registration_options, revocation_options, authorization_server=auth_server_provider is not None, ) if verifier is not None else None ), token_verifier=verifier, auth_server_provider=auth_server_provider, custom_starlette_routes=routes, ) sdk_lifespan = app.router.lifespan_context @asynccontextmanager async def lifespan(application: Starlette) -> AsyncIterator[None]: try: async with sdk_lifespan(application): yield finally: await gitea_probe.aclose() app.router.lifespan_context = lifespan app.add_middleware( RateLimitMiddleware, mcp_path=settings.mcp_path, limiter=RequestRateLimiter(settings), extra_paths=tuple(rate_limited_paths), ) return app # Runners def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> list[str]: """List reasons the HTTP server must not start (empty when startup may proceed). An OAuth-only deployment (OAuth on, no ``MCP_BEARER_TOKEN``, no ``GITEA_TOKEN``) may start: every user brings their own Gitea token and admin-token mode is simply unavailable. ``GITEA_TOKEN`` stays required whenever admin-token mode is reachable (a static bearer, or no HTTP auth). """ errors: list[str] = [] bearer = settings.mcp_bearer_token is not None if settings.oauth_enabled: errors.extend(settings.oauth_config_errors()) if not settings.oauth_config_errors() and extras is None: errors.append("OAUTH_ENABLED=true but per-user OAuth could not be set up") public = urlsplit(settings.public_base_url or "") if public.scheme == "http" and not is_loopback_host(public.hostname or ""): errors.append("PUBLIC_BASE_URL must use https unless the host is loopback") authenticated = bearer or extras is not None if not settings.is_loopback_bind() and not authenticated: errors.append( f"refusing to bind to {settings.mcp_host} without HTTP authentication: set " "MCP_BEARER_TOKEN (or enable OAuth), or bind to 127.0.0.1" ) if (bearer or extras is None) and settings.gitea_token is None: message = "GITEA_TOKEN is required for admin-token mode (static bearer or no auth)" if settings.oauth_enabled and bearer: message += "; for an OAuth-only deployment unset MCP_BEARER_TOKEN" errors.append(message) return errors def http_startup_warnings(settings: Settings, extras: HttpAuthExtras | None) -> list[str]: """List non-fatal startup problems of the HTTP server (printed to stderr).""" warnings: list[str] = [] if ( not settings.is_loopback_bind() and not settings.public_base_url and not settings.allowed_hosts ): warnings.append( f"MCP_HOST={settings.mcp_host} is not loopback and neither PUBLIC_BASE_URL nor " "ALLOWED_HOSTS is set: requests whose Host header is not a loopback name or the " "bind address are rejected with 421. Set PUBLIC_BASE_URL (the public https URL) " "or ALLOWED_HOSTS (comma separated host names clients use)" ) if settings.forwarded_allow_ips == "*": warnings.append( "FORWARDED_ALLOW_IPS=*: X-Forwarded-For is trusted from any peer, so clients can " "spoof their IP and dodge the per-IP rate limit. List the proxy addresses instead" ) return warnings def _audit_startup_error(settings: Settings) -> str | None: """Open the audit log now; return an error message when it is not writable. A server that cannot record its calls must not start: the dispatcher refuses calls without an audit log, so every call would fail anyway. """ try: get_audit_logger() except OSError as exc: reason = exc.strerror or str(exc) or type(exc).__name__ if exc.errno == errno.ELOOP: reason = "the path is a symbolic link, which is refused" return ( f"cannot open the audit log {settings.resolved_audit_log_path} for append " f"({reason}). Fix the permissions or point AUDIT_LOG_PATH (or DATA_DIR) at a " "writable location" ) return None class AccessLogQueryFilter(logging.Filter): """Drops query strings from uvicorn access log lines. OAuth redirects carry one-time codes and states in the query string (``/oauth/callback?code=...&state=...``); they must not reach the logs. """ def filter(self, record: logging.LogRecord) -> bool: """Rewrite the path argument of an access log record; never drops the record.""" args = record.args if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str): path, separator, _ = args[2].partition("?") if separator: record.args = (*args[:2], f"{path}?[redacted]", *args[3:]) return True def run_http(settings: Settings) -> int: """Run the streamable HTTP server; return a process exit code. Returns 2 without starting when a startup check fails (including an audit log that cannot be opened for append). """ extras: HttpAuthExtras | None = None if settings.oauth_enabled and not settings.oauth_config_errors(): try: extras = load_oauth_components(settings) except OAuthSetupError as exc: return _fail(f"per-user OAuth cannot start: {exc}") errors = http_startup_errors(settings, extras) if errors: for message in errors: _fail(message) return EXIT_USAGE audit_error = _audit_startup_error(settings) if audit_error is not None: return _fail(audit_error) for message in http_startup_warnings(settings, extras): print(f"aegis-gitea-mcp: warning: {message}", file=sys.stderr) try: server = build_server( settings, resolver=extras.resolver if extras else None, executor=extras.executor if extras else None, ) except ServerConfigError as exc: return _fail(str(exc)) app = build_http_app( settings, server=server, token_verifier=extras.token_verifier if extras else None, auth_server_provider=extras.auth_server_provider if extras else None, extra_routes=extras.extra_routes if extras else (), client_registration_options=extras.client_registration_options if extras else None, revocation_options=extras.revocation_options if extras else None, rate_limited_paths=extras.rate_limited_paths if extras else (), ) access_logger = logging.getLogger("uvicorn.access") if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters): access_logger.addFilter(AccessLogQueryFilter()) uvicorn.run( ForwardedHeadersMiddleware(app, settings.forwarded_allow_ips), host=settings.mcp_host, port=settings.mcp_port, log_level=settings.log_level.lower(), proxy_headers=False, # ForwardedHeadersMiddleware applies them (and warns) server_header=False, ) return 0 async def _serve_stdio(server: AegisServer) -> None: async with stdio_server() as (read, write): await server.run(read, write, server.create_initialization_options()) def run_stdio(settings: Settings) -> int: """Run the local stdio server (single identity: the owner of ``GITEA_TOKEN``). Returns 2 without starting when ``GITEA_TOKEN`` is missing, the audit log is not writable or the tool filters are invalid. All logging goes to stderr; stdout carries JSON-RPC only. """ if settings.gitea_token is None: return _fail("stdio needs GITEA_TOKEN; create a Gitea access token and set it in .env") audit_error = _audit_startup_error(settings) if audit_error is not None: return _fail(audit_error) try: server = build_server(settings) except ServerConfigError as exc: return _fail(str(exc)) try: anyio.run(_serve_stdio, server) except KeyboardInterrupt: # pragma: no cover - interactive shutdown return 0 return 0