Files
mcp-server/gunicorn.conf.py
ClaudeandClaude Fable 5 ce868a7717 fix(gunicorn): warn at startup when GUNICORN_WORKERS>1 breaks MCP sessions
MCP sessions live in a per-process in-memory dict on whatever worker
handled the initialize request (StreamableHTTPSessionManager._server_instances).
gunicorn round-robins HTTP requests across workers, so the same
mcp-session-id frequently lands on a different worker and returns
'Session not found' / 'Invalid or expired session ID'. fastapi-mcp
hardcodes stateless=False, and gunicorn has no built-in sticky-session
support, so there is no in-process workaround.

This change keeps the existing workers=2 default (the project does not
want to give up the modest parallelism it gives) but adds:

  * A comment block above workers= explaining the constraint, the
    workers=1 fix, and the nginx sticky-session escape hatch.
  * An on_starting(server) hook that logs a WARNING whenever
    workers > 1, naming the symptom and the fix so the misconfig is
    loud at deploy time rather than silent at request time.
  * Three regression tests in tests/unit/test_gunicorn_config.py
    asserting the hook exists, is silent for workers=1, and warns
    (with the expected text) for workers>1.
  * A 'MCP session affinity' note in CLAUDE.md key conventions so
    the constraint is recorded for future maintainers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 17:02:37 +08:00

92 lines
3.9 KiB
Python

# 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"