# coding=utf-8 """ @Time :2026/6/24 @Author :tao.chen Gunicorn config for the Spark Executor MCP service. ASGI workers (uvicorn.workers.UvicornWorker) so we get gunicorn's process supervision, graceful shutdown, and graceful reload semantics on top of uvicorn's ASGI implementation. All knobs are env-var driven so the same image runs in dev (workers=1) and prod (workers=2-4) without rebuilding. """ import os # --- Network --- bind = os.environ.get("GUNICORN_BIND", "0.0.0.0:8000") # --- Process model --- # 2 is a sensible default for a small containerized MCP server: lets a slow # yarn logs request run in parallel with a status check. Scale up by # setting GUNICORN_WORKERS at deploy time. # # ⚠️ MCP SESSION AFFINITY CONSTRAINT ⚠️ # ---------------------------------------- # The official `mcp` library (used under fastapi-mcp) keeps each MCP # session's state in a per-process in-memory dict # (`StreamableHTTPSessionManager._server_instances`). gunicorn round-robins # HTTP requests across worker processes, so a client that does: # 1) POST /initialize → worker A creates session_id=xyz # 2) POST /tools/list → worker B has no record of xyz # 3) → "Session not found" / "Invalid or expired session ID" # gunicorn has no built-in sticky-session support, and fastapi-mcp hardcodes # `stateless=False`, so there is no in-process way to share sessions across # workers without an external store (Redis, etc.). # # Trade-off: `workers=1` eliminates the bug entirely. A single uvicorn-based # worker still handles many concurrent connections via the asyncio event # loop — the lost capacity is parallel CPU work, which this I/O-bound MCP # service rarely needs. If you ever need true multi-core parallelism, you # MUST front gunicorn with a sticky-session reverse proxy (nginx # `ip_hash` / `hash $mcp_session_id consistent`) AND give every session # a shared backing store — do NOT just bump GUNICORN_WORKERS. # # The `on_starting` hook below makes this footgun loud rather than silent. workers = int(os.environ.get("GUNICORN_WORKERS", "2")) worker_class = "uvicorn.workers.UvicornWorker" # 1 thread per worker is enough for ASGI handlers (no blocking I/O). threads = int(os.environ.get("GUNICORN_THREADS", "1")) def on_starting(server): """Master-process hook: gunicorn calls this once before forking workers. We use it to scream at anyone who raises GUNICORN_WORKERS above 1 without putting a sticky-session LB in front — see the workers= comment above for why MCP sessions cannot survive worker-local storage across processes. """ if workers > 1: server.log.warning( "GUNICORN_WORKERS=%d — MCP sessions are stored in per-process " "memory, so the same mcp-session-id will frequently land on a " "different worker and return 'Session not found'. Either set " "GUNICORN_WORKERS=1 or front this service with a sticky-session " "reverse proxy (see gunicorn.conf.py for details).", workers, ) # --- Lifecycle --- # Generous timeout because the slowest tool call here is yarn logs (which # can take 30+ seconds on a busy cluster). uvicorn standalone defaults to # 30s; gunicorn's default is 30s too — both too tight for log fetch. timeout = int(os.environ.get("GUNICORN_TIMEOUT", "120")) graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "30")) keepalive = int(os.environ.get("GUNICORN_KEEPALIVE", "5")) # --- Logging --- # Stream access + error to stdout/stderr so docker logs / k8s logs capture # them. gunicorn's "[INFO] Booting worker" lines interleave with loguru's # output — both go to stderr. accesslog = "-" errorlog = "-" loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info") access_log_format = ( '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(L)s' ) # --- Process naming (visible in `ps aux`) --- proc_name = "mcp-server"