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>
92 lines
3.9 KiB
Python
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"
|