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:
1 parent
581ca67bf8
commit
c9e2c481e7
11 files changed
+2846
-14
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user