Skip to content

Commit bb7f188

Browse files
authored
fix: clear leaked running loop in MCP client background thread (#2111)
1 parent 50b2c79 commit bb7f188

2 files changed

Lines changed: 39 additions & 0 deletions

File tree

src/strands/tools/mcp/mcp_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,9 @@ def _background_task(self) -> None:
835835
This allows for a long-running event loop.
836836
"""
837837
self._log_debug_with_thread("setting up background task event loop")
838+
# Clear any running-loop state leaked by OpenTelemetry's ThreadingInstrumentor, which wraps Thread.run()
839+
# and can propagate the parent thread's event loop reference, causing run_until_complete() to fail.
840+
asyncio._set_running_loop(None)
838841
self._background_thread_event_loop = asyncio.new_event_loop()
839842
asyncio.set_event_loop(self._background_thread_event_loop)
840843
self._background_thread_event_loop.run_until_complete(self._async_background_thread())

tests/strands/tools/mcp/test_mcp_client_contextvar.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,39 @@ def capturing_background_task(self):
8888
)
8989
# Verify it was indeed a different thread
9090
assert background_thread_value["thread_id"] != main_thread_id, "Background task should run in a different thread"
91+
92+
93+
def test_mcp_client_clears_running_loop_in_background_thread(mock_transport, mock_session):
94+
"""Test that _background_task clears any leaked running event loop state.
95+
96+
When OpenTelemetry's ThreadingInstrumentor is active, Thread.run() is wrapped to propagate
97+
trace context, which can leak the parent thread's running event loop reference into child
98+
threads. This causes "RuntimeError: Cannot run the event loop while another loop is running"
99+
when the background thread calls run_until_complete().
100+
101+
This test simulates that scenario by setting a running loop before _background_task runs
102+
and verifying it gets cleared.
103+
"""
104+
import asyncio
105+
106+
cleared_running_loop = {}
107+
108+
original_background_task = MCPClient._background_task
109+
110+
def simulating_otel_leak_background_task(self):
111+
# Simulate OTEL ThreadingInstrumentor leaking the parent's running loop
112+
fake_loop = asyncio.new_event_loop()
113+
asyncio._set_running_loop(fake_loop) # type: ignore[attr-defined]
114+
115+
# Call the real _background_task — it should clear the leaked loop and succeed
116+
try:
117+
return original_background_task(self)
118+
finally:
119+
cleared_running_loop["success"] = True
120+
fake_loop.close()
121+
122+
with patch.object(MCPClient, "_background_task", simulating_otel_leak_background_task):
123+
with MCPClient(mock_transport["transport_callable"]) as client:
124+
assert client._background_thread is not None
125+
126+
assert cleared_running_loop.get("success"), "_background_task should have run successfully despite leaked loop"

0 commit comments

Comments
 (0)