Multi-Worker Session Affinity for SSE and Streamable HTTP#2536
Merged
crivetimihai merged 45 commits intomainfrom Feb 6, 2026
Merged
Multi-Worker Session Affinity for SSE and Streamable HTTP#2536crivetimihai merged 45 commits intomainfrom
crivetimihai merged 45 commits intomainfrom
Conversation
46a3f26 to
c087d95
Compare
b8f4b76 to
1ea85b8
Compare
624dbdf to
0da4496
Compare
Member
|
Solid feature with a well-written ADR and thorough implementation. The Redis Pub/Sub approach for cross-worker session coordination is the right pattern for horizontal scaling. One cleanup needed: ~12 unrelated files under |
Collaborator
Author
|
Removed unrelated commits to |
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…nity code Convert print() statements to appropriate logger.debug()/logger.info()/logger.warning() calls for proper log management in the multi-worker session affinity feature. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Add docstrings to _pool_owner_key, _rehydrate_content_items, and send_with_capture to achieve 100% docstring coverage. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
3b22a7c to
6c6f79d
Compare
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Fix darglint DAR101/DAR201 errors by adding missing parameter and return documentation to docstrings. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
kcostell06
pushed a commit
to kcostell06/mcp-context-forge
that referenced
this pull request
Feb 24, 2026
* Add x-mcp-session-id to default identity headers Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Pass x-mcp-session-id to mcp_session_pool headers and prioritize if found * wip sa Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * add e2e test Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * flake8 fix Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * remove plan Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * pylint fix Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Implement multi worker Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Implement multi worker for mcp session pool Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * linting fixes Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Minor bug fixes Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix critical bugs Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix sse session_id, add logging and fix test Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * fix url of rpc from nginx Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * add stateful sessions in http Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * WIP fixes to streamable http Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix streamable http Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Updated ADR Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Update ADR Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * black fixes Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix failing doctests Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix more tests Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * flake8 fixes Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * pylint fixes Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * pylint fixes Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix streamable http for single gunicorn Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Revert base_url Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix test Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * revert replica count Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix bandit test Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * remove plan Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix bug for local Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Update ADR and remove print Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix lint issues Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Fix test Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * Remove accidental utf-8 headers from incorrect rebase Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * fix: replace debug print statements with logger calls in session affinity code Convert print() statements to appropriate logger.debug()/logger.info()/logger.warning() calls for proper log management in the multi-worker session affinity feature. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: harden session affinity and redis event store Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * lint Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: avoid broad exception in streamable http header parsing Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * lint Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * docs: add missing docstrings for interrogate compliance Add docstrings to _pool_owner_key, _rehydrate_content_items, and send_with_capture to achieve 100% docstring coverage. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: add missing newline at end of redis_event_store.py Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * docs: complete docstrings with Args and Returns sections Fix darglint DAR101/DAR201 errors by adding missing parameter and return documentation to docstrings. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * lint Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
✨ Feature: Multi-Worker Session Affinity for SSE and Streamable HTTP
🔗 Epic / Issue
Implements ADR: 038-multi-worker-session-affinity.md
Closes #1986
🚀 Summary
Enables horizontal scaling with multi-worker deployments (e.g.,
gunicorn -w 4) while maintaining session affinity to upstream MCP servers. Routes all client requests to the same upstream session regardless of which worker receives the HTTP request, using Redis Pub/Sub for cross-worker coordination.🔍 Problem
MCP Gateway supports horizontal scaling with multiple worker processes. However, without session affinity, a client's requests may hit different workers, causing:
Example scenario:
Result: Two upstream sessions for one client, wasting resources and breaking stateful backends.
Goal: Route all requests from the same client to the same upstream MCP session, regardless of which worker receives the HTTP request.
✅ Solution
Implement unified session affinity using Redis for cross-worker coordination:
Architecture
Key Components
Session Ownership Registry (Redis
SETNX)Transport-Specific Routing:
broadcast()→respond())forward_streamable_http_to_owner())Worker Identification:
{hostname}:{pid}format ensures uniqueness across containers and processesUnified
/rpcEndpoint: All tool/prompt/resource invocations route through single handlerWhy Redis Pub/Sub?
mcpgw:pool_{rpc|http}:{worker_id}📝 Changes
Core Implementation Files
mcpgateway/services/mcp_session_pool.py(~400 lines added)register_session_mapping()(line 545): Atomic session ownership with Redis SETNX_get_pool_session_owner()(line 1309): Retrieve session owner from Redisforward_request_to_owner()(line 1348): SSE transport RPC forwarding via Redis Pub/Substart_rpc_listener()(line 1438): Listen on both RPC and HTTP Redis channels_execute_forwarded_request()(line 1486): Execute forwarded RPC requests locally_execute_forwarded_http_request()(line 1554): Execute forwarded HTTP requests locallyget_streamable_http_session_owner()(line 1545): Check Streamable HTTP session ownershipforward_streamable_http_to_owner()(line 1559): HTTP forwarding via Redis Pub/SubWORKER_ID(line 61): Worker identification{hostname}:{pid}mcpgateway/cache/session_registry.py(SSE transport)_register_session_mapping()(line 900): Pre-register ownership before tool callsbroadcast()(line 961): Publish messages to session owner via Redisrespond()(line 1119): Listen for messages and execute via/rpcgenerate_response()(line 1863): Internal/rpccall for tool invocationmcpgateway/transports/streamablehttp_transport.py(Streamable HTTP transport)handle_streamable_http()(line 1252): Check session ownership and forward if needed/rpcendpoint for session-bound requestsmcpgateway/main.py/rpcendpoint (line 5259): Unified handler for all JSON-RPC methodsmcpgateway/config.pymcpgateway_session_affinity_enabled(line 1368): Feature flagmcpgateway_session_affinity_ttl(line 1369): Ownership TTLmcpgateway_pool_rpc_forward_timeout(line 1371): Forward timeoutdocs/docs/architecture/adr/038-multi-worker-session-affinity.md(651 lines)Code Impact
Redis Data Structures
mcpgw:pool_owner:{session_id}mcpgw:session_mapping:{...}mcpgw:pool_rpc:{worker_id}mcpgw:pool_rpc_response:{uuid}mcpgw:pool_http:{worker_id}mcpgw:pool_http_response:{uuid}🧪 Testing
Manual Verification
Test Scenarios
SSE Transport
{session_id}/rpcStreamable HTTP Transport
initializeto Worker A, getsmcp-session-idtools/callto Worker B withmcp-session-idheadermcpgw:pool_http:host_a:1Deployment Scenarios
Edge Cases
x-forwarded-internallyheader)🧪 Checks
make lintpassesmake testpasses📊 Performance Impact
Latency
Resource Usage
Scalability
📓 Architecture Diagrams
SSE Transport Flow
sequenceDiagram participant Client participant LB as Load Balancer participant WB as WORKER_B participant Redis participant WA as WORKER_A (SSE Owner) participant Backend as Backend MCP Server Note over Client,WA: 1. SSE Connection Established Client->>LB: GET /sse LB->>WA: Route to WORKER_A WA->>WA: Start respond() loop WA-->>Client: SSE Stream Connected Note over Client,Backend: 2. Tool Call (hits different worker) Client->>LB: POST /message {tools/call} LB->>WB: Route to WORKER_B WB->>Redis: SETNX pool_owner (fails - WORKER_A owns) WB->>Redis: Publish to session channel Note over WA: respond() receives message Redis-->>WA: Message received WA->>WA: POST /rpc (internal) WA->>Backend: Execute tool Backend-->>WA: Result WA-->>Client: SSE: {result}Streamable HTTP Transport Flow
sequenceDiagram participant Client participant LB as Load Balancer participant WB as WORKER_B participant Redis participant WA as WORKER_A (Session Owner) participant Backend as Backend MCP Server Note over Client,Backend: 1. Initialize - Establishes Ownership Client->>LB: POST /mcp {initialize} LB->>WA: Route to WORKER_A WA->>Redis: SETNX pool_owner:ABC = host_a:1 WA-->>Client: {mcp-session-id: ABC} Note over Client,Backend: 2. Subsequent Request - Different Worker Client->>LB: POST /mcp {tools/call, session: ABC} LB->>WB: Route to WORKER_B WB->>Redis: GET pool_owner:ABC → host_a:1 (not us!) WB->>Redis: Subscribe to response channel WB->>Redis: PUBLISH to pool_http:host_a:1 Note over WA: start_rpc_listener() receives message Redis-->>WA: HTTP forward message WA->>WA: POST /rpc (internal) WA->>Backend: Execute tool Backend-->>WA: Result WA->>Redis: PUBLISH response Redis-->>WB: Response received WB-->>Client: HTTP Response {result}🔧 Configuration
Environment Variables
Startup
The RPC listener is automatically started during application lifespan:
🔗 Related Documentation
✅ Benefits
gunicorn -w Nwithout session issuesPositive
Negative
Neutral
🚀 Future Improvements
🔐 Security Considerations
x-forwarded-internallyheader prevents external spoofing (internal-only)🎯 Success Criteria