modelcontextprotocol / modelcontextprotocol/python-sdk
StreamableHTTP server accumulates CLOSE_WAIT sockets behind reverse proxy due to missing disconnect cleanup
還沒有人認領這個 Issue。
- 主要語言
- Python
- 星號
- 24.3k
- 分支
- 4k
- 平均合併
- 1 天 1 小時
- 30 天內合併 PR
- 31
描述
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
When running a StreamableHTTP MCP server behind a reverse proxy (nginx, Nginx Proxy Manager, etc.), TCP sockets accumulate in CLOSE_WAIT state after each tool call. After 10-20 calls, the server becomes unresponsive and stops accepting new connections, requiring a full process restart.
The root cause is in StreamableHTTPServerTransport._handle_post_request() — when the reverse proxy closes its side of the connection after a completed request, the sse_writer coroutine remains blocked on an in-memory stream (request_stream_reader) and never exits. Because the ASGI callable never fully returns, uvicorn never closes the server side of the socket, leaving it in CLOSE_WAIT indefinitely.
Environment
- MCP Python SDK: 1.28.0
- Python: 3.13
- Server: uvicorn (via
mcp.run(transport="streamable-http")) - Reverse proxy: Nginx Proxy Manager (nginx-based)
- OS: Windows 11 (but the bug is platform-independent)
To Reproduce
- Set up a FastMCP server with StreamableHTTP transport behind any reverse proxy (nginx, NPM, Caddy, etc.)
- Connect a client and make several tool calls
- Monitor sockets:
netstat -ano | findstr <port> - Observe CLOSE_WAIT connections accumulating after each call
- After ~10-20 calls, the server stops responding to new connections
TCP 0.0.0.0:8849 0.0.0.0:0 LISTENING 43056
TCP 192.168.0.150:8849 192.168.0.248:41992 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42224 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42264 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42274 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42284 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42292 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42394 CLOSE_WAIT 43056
TCP 192.168.0.150:8849 192.168.0.248:42402 CLOSE_WAIT 43056
...
Root Cause Analysis
In streamable_http.py, the SSE response path in _handle_post_request() (~line 380):
async with anyio.create_task_group() as tg:
tg.start_soon(response, scope, receive, send)
session_message = self._create_session_message(message, request, request_id, protocol_version)
await writer.send(session_message)
When the reverse proxy closes the TCP connection:
EventSourceResponse(from sse-starlette) detects the disconnect via ASGIreceive()and its task completes- However,
sse_writer()is still alive inside the response, blocked onasync for event_message in request_stream_reader— this is an in-memory stream, not a socket, so it has no awareness of the TCP disconnect - Nobody closes
request_stream_reader, sosse_writerhangs indefinitely - The ASGI callable never fully returns because the streams are never cleaned up
- uvicorn never sends FIN on the server side → socket remains in CLOSE_WAIT
Additionally, session_idle_timeout in StreamableHTTPSessionManager defaults to None (no timeout), meaning orphaned sessions are never reaped. The docstring itself recommends 1800 seconds for most deployments, but the default doesn't reflect this.
Proposed Fix
1. Transport layer — disconnect-aware cleanup in _handle_post_request()
Wrap the response() call so that when it returns (whether from normal completion or client disconnect), the request streams are immediately cleaned up, unblocking sse_writer:
async with anyio.create_task_group() as tg:
async def run_response_with_cleanup():
try:
await response(scope, receive, send)
finally:
# Response finished — client disconnected or normal completion.
# Close request streams to unblock sse_writer if it's still
# waiting on the in-memory stream.
await self._clean_up_memory_streams(request_id)
writer_ref = self._sse_stream_writers.pop(request_id, None)
if writer_ref:
writer_ref.close()
tg.start_soon(run_response_with_cleanup)
session_message = self._create_session_message(
message, request, request_id, protocol_version
)
await writer.send(session_message)
When the client disconnects:
response()returns (EventSourceResponse detects disconnect)finallyblock fires, closes request streamssse_writerunblocks withClosedResourceError(which it already catches gracefully)sse_writerexits cleanly- ASGI callable returns → uvicorn sends FIN → socket closes properly
2. Session manager — sensible default for session_idle_timeout
In streamable_http_manager.py, change the default from None to 1800 (30 minutes), consistent with the existing docstring recommendation:
session_idle_timeout: float | None = 1800,
This provides a safety net: even if disconnect detection misses an edge case, orphaned sessions will eventually be cleaned up rather than accumulating indefinitely.
Related Issues
- #1272 — Server hangs when shutting down if a connection is still open (same family: connection lifecycle cleanup)
- #831 — Errors during cleanup when using streamablehttp_client with AsyncExitStack
- #1227 — SSE: ConnectionClosed exception after session disconnect
Impact
This affects every StreamableHTTP MCP server deployed behind a reverse proxy. Direct connections (localhost) are less affected because the OS handles TCP teardown more aggressively, but the underlying resource leak (orphaned in-memory streams and sessions) still exists.
貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
研究方向
從 streamable_http.py 中的 StreamableHTTPServerTransport._handle_post_request() 開始,追蹤 response、request_stream_reader、sse_writer 以及 memory-stream 清理路徑。接著檢查 streamable_http_manager.py,了解 session_idle_timeout 的預設值及其文件中的建議。透過反向代理重現問題,同時監控 netstat;當已完成或已中斷連線的請求釋放 streams,且 CLOSE_WAIT sockets 不再累積時,即表示完成。
由索引模型根據 Issue 內容生成。
評估
- 技術堆疊
- python
- 領域
- api, backend, networking
- Issue 類型
- 缺陷
- 難度
- 4/5
- 預估耗時
- 3-5 天
- 活躍度
- 冷清
- 描述清晰度
- 描述清楚
- 新手友好度
- 70/100