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>
This commit is contained in:
Claude
2026-06-29 17:02:37 +08:00
co-authored by Claude Fable 5
parent 606e965a54
commit ce868a7717
3 changed files with 158 additions and 0 deletions
+1
View File
@@ -85,6 +85,7 @@ docs/superpowers/plans/ # implementation plans
- **Exception handlers in `server.py`:** `KeyError` → 404, `ValueError` → 400, Pydantic validation → 422. Use the existing handlers — don't add try/except in route bodies.
- **Tool functions raise `KeyError` for "unknown id" and `ValueError` for invalid state transitions** (e.g. confirming a CANCELLED pending). The handlers translate these to clean HTTP statuses.
- **The two-step submit flow is core, not optional.** `prepare_submit_job` snapshots the connection's `master` / `deploy_mode` / `spark_conf` into the `PendingSubmission`; `confirm_submit_job` is the only place `spark-submit` is invoked. Editing a connection between prepare and confirm does **not** retarget the pending job.
- **MCP session affinity: keep `GUNICORN_WORKERS=1` (or front with a sticky-session LB).** The `mcp` library stores each session in a per-process dict (`StreamableHTTPSessionManager._server_instances`); gunicorn round-robins requests across workers, so a multi-worker deploy returns "Session not found" / "Invalid or expired session ID" for the same `mcp-session-id` whenever it lands on a worker that didn't create it. `fastapi-mcp` hardcodes `stateless=False`, so there is no in-process workaround. The `on_starting` hook in `gunicorn.conf.py` logs a WARNING whenever `workers > 1` so the misconfig is loud, not silent. See the comment block above `workers =` in `gunicorn.conf.py` for full context and the nginx-sticky escape hatch.
- **Test fixtures rebind module-level singletons** (`connections.store`, `submit.conn_store`, `submit.pending_store`) in `monkeypatch.setattr` because the tool modules captured the originals at import time. See `tests/integration/test_mcp_routes.py` for the pattern.
- **86 tests pass** as of the last Stage 1 cleanup; run `uv run pytest` after any change.