feat: add MCP server core, stdio and streamable HTTP transports and serve command

Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
This commit is contained in:
Latte committed 2026-10-14 16:13:11 +02:00
1 parent 581ca67bf8
commit c9e2c481e7
11 files changed
+2846 -14

No files matched your search

+393
View File
@@ -0,0 +1,393 @@
"""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 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 aegis_gitea_mcp import __version__
from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.rate_limit import get_rate_limiter
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.
"""
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
def load_oauth_components(settings: Settings) -> HttpAuthExtras | None:
"""Return the per-user OAuth components for ``OAUTH_ENABLED``, or None when unavailable."""
del settings
return None
# 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."""
def __init__(self, app: ASGIApp, mcp_path: str) -> None:
self.app = app
self._mcp_path = mcp_path
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path:
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 = get_rate_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)
# 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 _auth_settings(
settings: Settings,
client_registration_options: ClientRegistrationOptions | None,
revocation_options: RevocationOptions | None,
) -> AuthSettings:
issuer = _base_url(settings)
return AuthSettings(
issuer_url=issuer, # type: ignore[arg-type]
resource_server_url=f"{issuer}{settings.mcp_path}", # type: ignore[arg-type]
required_scopes=None,
# The static bearer carries no RFC 8707 resource; a per-user verifier
# that binds tokens to this resource checks the audience itself.
validate_token_resource=False,
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,
) -> 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).
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()))
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)
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)
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)."""
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 and not bearer:
errors.append("OAUTH_ENABLED=true but per-user OAuth is not available in this build")
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:
errors.append("GITEA_TOKEN is required for admin-token mode (static bearer or no auth)")
return errors
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.
"""
extras = load_oauth_components(settings) if settings.oauth_enabled else None
errors = http_startup_errors(settings, extras)
if errors:
for message in errors:
_fail(message)
return EXIT_USAGE
try:
server = build_server(settings, resolver=extras.resolver 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,
)
uvicorn.run(
app,
host=settings.mcp_host,
port=settings.mcp_port,
log_level=settings.log_level.lower(),
proxy_headers=True,
forwarded_allow_ips=getattr(settings, "forwarded_allow_ips", None) or "127.0.0.1",
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 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")
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