Skip to content

feat: support stateless HTTP mode for MCP server - #123

Merged
GoodbyePlanet merged 2 commits into
mainfrom
feat/stateless-http-mode
Sep 12, 2026
Merged

GoodbyePlanet merged 2 commits into
mainfrom
feat/stateless-http-mode

Conversation

@GoodbyePlanet

Copy link
Copy Markdown
Owner

Closes #122.

Adds MCP_STATELESS_HTTP, passed through as streamable_http_app(stateless_http=...). Stateless mode creates a fresh transport per request instead of tracking MCP sessions, so replicas can sit behind a plain round-robin load balancer with no sticky sessions.

⚠️ Default is true — behaviour change on upgrade

Existing streamable-http deployments become stateless after this lands: clients no longer receive an mcp-session-id, and server→client requests (sampling/elicitation) are unavailable. semcode uses neither today — no tool, prompt or route touches the MCP Context, and the server advertises listChanged: false across tools, prompts and resources — so this is a feat: rather than a breaking change. Operators who rely on session affinity can set MCP_STATELESS_HTTP=false.

Older transports are unaffected

MCP_TRANSPORT still accepts all three values. stateless_http only exists on the streamable-http factory — sse_app() has no such parameter and stdio has no HTTP layer — so the setting is inert for both. The validator refuses to start only when it is set explicitly on one of those transports (keyed on model_fields_set); a MCP_TRANSPORT=stdio user who never heard of this setting sees zero change.

On the lifespan question raised in the issue

Not a blocker. The streamable app's Starlette lifespan is session_manager.run(), which enters the MCP lifespan exactly once per manager and caches it as _lifespan_state; stateless=True only changes per-request transport creation and reuses that same state. _wrap_http_lifespan is therefore unchanged and still required, and the Qdrant store is not re-initialised per request.

Two corrections to the issue's premises: the installed SDK is mcp 2.1.1, not 1.28.1 (the mcp>=2.0.0 pin needs no change), and sse_app() does not accept stateless_http.

Changes

  • server/config.py — mcp_stateless_http setting + _reject_stateless_on_unsupported_transport validator
  • server/main.py — extract build_http_app() out of main() so the transport branch is testable
  • README.md, docs/docs/configuration.md, .env.example — document the setting
  • tests/test_config.py, tests/test_main.py (new) — 342 tests pass

Verification

Automated: default / opt-out / inert-for-sse-and-stdio / explicit-conflict-rejected in config, plus build_http_app() asserting the flag reaches session_manager.stateless and that SSE still builds with the flag at its default.

Manual, against a real docker compose up --build stack:

  • Sessionless POST → 200 with no mcp-session-id header; two independent tools/list calls both returned all 10 tools
  • "Qdrant collections ready" appears exactly once in the container logs after many requests
  • POST /reindex still streams NDJSON
  • MCP_STATELESS_HTTP=false → mcp-session-id returned, opt-out intact
  • MCP_TRANSPORT=sse with the flag at default true → starts clean, /sse still issues a session_id
  • MCP_TRANSPORT=stdio → imports clean, per-session lifespan still attached
  • MCP_TRANSPORT=sse MCP_STATELESS_HTTP=true → refuses to start with the intended error

🤖 Generated with Claude Code

GoodbyePlanet and others added 2 commits September 11, 2026 14:25
Add MCP_STATELESS_HTTP (default true), passed through to
streamable_http_app(stateless_http=...). Stateless mode creates a fresh
transport per request instead of tracking MCP sessions, so replicas can
sit behind a plain round-robin load balancer with no sticky sessions.

The setting only reaches the streamable-http factory — sse_app() has no
such parameter and stdio has no HTTP layer, so it is inert for both. A
validator refuses to start only when it is set explicitly on one of those
transports; the defaulted-on value stays inert so existing sse/stdio
deployments are unaffected.

Extract build_http_app() out of main() so the transport branch is
testable. _wrap_http_lifespan is unchanged and still required: the SDK
enters the MCP lifespan once per session manager and reuses that state
across stateless requests, so the Qdrant store is not re-initialised per
request (verified against a running container).

Closes #122

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GoodbyePlanet
GoodbyePlanet merged commit da2633b into main Sep 12, 2026
2 checks passed
@GoodbyePlanet
GoodbyePlanet deleted the feat/stateless-http-mode branch September 12, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: support stateless HTTP mode for MCP server

1 participant