Repository navigation
Possible ressource leak / race condition in streamable_http_client #1805
Copy link
Copy link
Open
Labels
bugSomething isn't workingSomething isn't workingneeds confirmationNeeds confirmation that the PR is actually required or needed.Needs confirmation that the PR is actually required or needed.
Description
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
Observed Issue
When using the MCP SDK with the streamable-http transport, the cpu usage spikes and never goes back down after sending multiple requests and exiting the client context. I observed this bahavior especially in the google adk-python when the mcp-toolset tries to close the client.
When running the testcode to reproduce i get:
Client-Side Testcode shows Thread Leaks
Iteration 1: 1 thread → 2 threads
Iteration 2: 2 threads → 7 threads
Iteration 3: 7 threads → 7 threads
Final: 7 threads (6 leaked asyncio_* threads)
Server-Side: Exceptions
Session crashed: unhandled errors in a TaskGroup (1 sub-exception)
ClosedResourceError at session.py:349
→ _write_stream.send() fails - stream already closed
BrokenResourceError at streamable_http.py:638
→ SSE writer has no receiver
Possible Cause
- Client sends multiple requests via
write_stream.send() - Client exits
async with streamable_http_clientcontext tg.cancel_scope.cancel()is called during cleanup- Server is still processing requests (e.g.,
slow_echowith delay) - Server tries to send responses via
_write_stream.send() - Stream is already closed →
ClosedResourceError - Background asyncio threads handling responses become orphaned
- Threads never terminate → memory/resource leak
Expected Behavior
- No thread leaks after exiting
streamable_http_clientcontext - Graceful handling of client disconnection on server side
Impact
- Memory leak from accumulated threads
- Resource exhaustion in long-running applications
Affected Code Paths
mcp/client/streamable_http.py-streamable_http_client()cleanupmcp/shared/session.py:237-238-cancel_scope.cancel()and__aexit__mcp/server/streamable_http.py:638- SSE response handling
Example Code
"""
This script demonstrates a possible bug in the MCP SDK where cleanup of
streamable_http_client causes BrokenResourceError and leaks threads.
Bug?: When tg.cancel_scope.cancel() is called during cleanup, child tasks
spawned by post_writer (like _handle_json_response) are still trying to
use read_stream_writer. The stream gets closed before they finish,
causing BrokenResourceError and preventing proper httpx client cleanup.
"""
import asyncio
import threading
import httpx
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCRequest, JSONRPCMessage
async def make_mcp_request(url: str, trigger_race: bool = False) -> None:
"""Make an MCP request and observe cleanup behavior."""
print(f"[Before] Active threads: {threading.active_count()}")
print(f"[Before] Thread names: {[t.name for t in threading.enumerate()]}")
# Create custom httpx client with short timeouts
http_client = httpx.AsyncClient(
headers={"Authorization": "test"},
timeout=httpx.Timeout(5.0, read=10.0),
)
try:
async with streamable_http_client(
url=url,
http_client=http_client,
) as (read_stream, write_stream, get_session_id):
print(f"[Connected] Session ID: {get_session_id()}")
print(f"[Connected] Active threads: {threading.active_count()}")
# Send an initialize request
init_request = JSONRPCRequest(
jsonrpc="2.0",
id="1",
method="initialize",
params={
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
)
await write_stream.send(SessionMessage(JSONRPCMessage(init_request)))
print("[Sent] Initialize request")
# Read the response
async for message in read_stream:
print(f"[Received] {type(message).__name__}")
if isinstance(message, Exception):
print(f"[Error in stream] {message}")
break
if trigger_race:
# Send multiple requests quickly to create race during cleanup
for i in range(5):
tool_request = JSONRPCRequest(
jsonrpc="2.0",
id=f"tool-{i}",
method="tools/call",
params={
"name": "slow_echo",
"arguments": {"message": f"test-{i}"},
},
)
await write_stream.send(SessionMessage(JSONRPCMessage(tool_request)))
print("[Sent] 5 tool requests - exiting immediately to trigger race")
# Exit immediately without reading responses - this should trigger the race
except Exception as e:
import traceback
print(f"[Error] {type(e).__name__}: {e}")
traceback.print_exc()
print(f"[After cleanup] Active threads: {threading.active_count()}")
print(f"[After cleanup] Thread names: {[t.name for t in threading.enumerate()]}")
# Wait and check if threads persist
await asyncio.sleep(5)
print(f"[After 5s] Active threads: {threading.active_count()}")
print(f"[After 5s] Thread names: {[t.name for t in threading.enumerate()]}")
async def main():
# Replace with your MCP server URL
MCP_URL = "http://localhost:8000/mcp"
print("=" * 60)
print("MCP SDK Cleanup Race Condition Reproduction")
print("=" * 60)
# Run multiple iterations to accumulate leaked threads
for i in range(3):
print(f"\n--- Iteration {i + 1} ---")
# Trigger race condition on iterations 2 and 3
await make_mcp_request(MCP_URL, trigger_race=(i > 0))
await asyncio.sleep(2)
print("\n" + "=" * 60)
print("Final State")
print("=" * 60)
print(f"Active threads: {threading.active_count()}")
for t in threading.enumerate():
print(f" - {t.name} (daemon={t.daemon})")
if __name__ == "__main__":
asyncio.run(main())
-----
"""Minimal MCP server for race condition testing."""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Test Server")
@mcp.tool()
def echo(message: str) -> str:
"""Echo the message back."""
return f"Echo: {message}"
@mcp.tool()
def slow_echo(message: str) -> str:
"""Echo with a delay to simulate work."""
import time
time.sleep(0.5)
return f"Slow Echo: {message}"
if __name__ == "__main__":
mcp.run(transport="streamable-http")Python & MCP Python SDK
- Python: 3.13
- MCP SDK: 1.25.0
- Transport: streamable-http
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workingneeds confirmationNeeds confirmation that the PR is actually required or needed.Needs confirmation that the PR is actually required or needed.
Activity
Brid9e commentedon Dec 21, 2025
I'm experiencing the exact same issue: when connections fail or I manually shut down the streamable_http service, the CPU usage spikes to 100% instantly.
ivanbelenky commentedon Dec 23, 2025
Just did a couple of tests here,
Not really as far as Im concerned, the asyncio threads are due to shutdown when the event loop ends exits. What is happening here is the following
getaddrinfogets called as part of theconnect_tcpcall inhttpcore.getaddrinfoas any otherblocking syscalllibrary function that hosts many blocking syscalls, gets executed on its own thread (lets say it gets called atasynciodefaultExecutorisexecutor = concurrent.futures.ThreadPoolExecutor(thread_name_prefix='asyncio')(you can find that line in the cpython stdlib here). This means that themax_workerssetting is set by default to the maximum, usually 2 times the cores you got.getaddrinfotakes approximatelygetaddrinfohappens during the rangeIf you modify your codebase slightly and introduce a minor delay after the
sendyou will get no more threads, just the one that got started since the first call. Furthermore, if you want to constrain this further, you can always run something likeThreads should be spending 0 cpu cycles @Brid9e, would you mind providing something for reproducibility?
challenger71498 commentedon Dec 26, 2025
Hi @h-filzer ,
I also had same issue you've encountered, and find out the BaseSession cleanup logic is being cancelled on
__aexit__, causing respond streams never finish.I think your issue is could be fixed by this PR #1817, you may look into it if you are interested in.
ivanbelenky commentedon Dec 26, 2025
@challenger71498 awesome fix, thx for it, I was able to replicate the issue above by @h-filzer rebasing locally to your fork, so I think your PR has no effect on this. I still believe this is happening because of what was explained here.
challenger71498 commentedon Dec 26, 2025
@ivanbelenky Yup, seems like this issue is not related to the my PR.
I will remove this issue from the PR. Thx 👍
h-filzer commentedon Jan 2, 2026
Hey @challenger71498 , Thanks for digging into it. It seems that your PR is on the right track, as propagating cancel events might be bogus in other places too? I tried your PR, but unfortunately the thread count stays at the number of CPUs. I follow your work in google-adk python :-) Thx 👍
Remove stateless_http=True - causes hangs per PrefectHQ/fastmcp#678
major commentedon Feb 25, 2026
Can confirm this. Seeing the same behavior with
mcp==1.26.0/anyio==4.12.1/ Python 3.12.My setup uses streamable HTTP transport to a FastMCP server. After the first tool call completes and the client context exits, the process sits at 90%+ CPU permanently with zero incoming requests.
py-spy consistently catches the main thread in
_deliver_cancellation:2 out of 3 rapid samples land there — the third catches
select()momentarily between bursts. Tasks stuck in_must_cancelstate keepshould_retry=True, so_deliver_cancellationreschedules itself viacall_soon()every tick.fix: CPU busy-loop on streamable HTTP / SSE client shutdown
akshan-main commentedon Feb 25, 2026
Opened a fix at #2147 - closes streams before cancelling the task group so blocked send() calls exit cooperatively, and guards all send paths against ClosedResourceError/BrokenResourceError during teardown.
Also applied the same pattern to the SSE transport and BaseSession.aexit.
fix: CPU busy-loop on streamable HTTP / SSE client shutdown
fix: CPU busy-loop on streamable HTTP / SSE client shutdown
fix: CPU busy-loop on streamable HTTP / SSE client shutdown
billoneil commentedon Mar 24, 2026
Any updates on the proposed fix? #2147
maxisbey commentedon Apr 8, 2026
From looking into this, the CPU spin happens when the client is opened in one
asyncio.Taskand closed from a different one, typically via anAsyncExitStackthat getsaclose()d from a cleanup/shutdown path. That'sthe pattern adk's
MCPSessionManageruses, and it's what produces the_deliver_cancellationloop in @major's py-spy trace.The repro in the issue body (single-task
async with) doesn't trigger it. Thethreads it shows are asyncio's
getaddrinfoexecutor pool (capped, idle, not aleak). @ivanbelenky's analysis is right on that.
minimal cross-task repro
These context managers wrap
anyio.create_task_group(), which requiresenter/exit from the same task. There isn't a clean anyio-portable way to make
them cross-task-safe from inside the SDK, so for now:
ClientSession/Client.owned task and signal shutdown via
anyio.Event. adk'sSessionContextalready does this for the inner layer; the outer
MCPSessionManagerwrappingit in another
AsyncExitStackand closing that cross-task is what trips it.We might add a helper for this in the SDK (something that takes a caller
task group and runs the client in it for you), but I'm not sure yet whether
that's worth the extra surface area. Open to thoughts.
Same root cause as #577 / #915 (and closed #79 / #521 / #252 / #788).
AI Disclaimer