-
Notifications
You must be signed in to change notification settings - Fork 3.2k
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
We use the Python MCP SDK to integrate MCP into our codebase.
We connect to the remote Tavily MCP server using Streamable HTTP transport.
We follow the exit_stack pattern to initialize the read/write streams and the ClientSession, which works fine. However, when trying to close the exit_stack, the first attempt almost always fails, so we made a workaround to try to close it twice, as shown in the following code (this is a portion of our MCP client wrapper):
async def _connect(self):
read_stream, write_stream = await self._get_read_and_write_streams()
await self._create_and_initialize_session(read_stream, write_stream)
async def _get_read_and_write_streams(self):
read_stream, write_stream, _ = await self._exit_stack.enter_async_context(
streamablehttp_client(
self._config.url,
self._config.headers,
)
)
return read_stream, write_stream
async def _create_and_initialize_session(
self,
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
write_stream: MemoryObjectSendStream[SessionMessage],
) -> None:
self._session = await self._exit_stack.enter_async_context(
ClientSession(
read_stream,
write_stream,
timedelta(seconds=self._config.connection_attempt_timeout),
)
)
await self._session.initialize()
async def _cleanup_exit_stack(self):
try:
await self._exit_stack.aclose()
except (Exception, asyncio.CancelledError):
logger.exception(
self._make_error_message(
"The first attempt to close the exit stack failed. Trying to close it again ..."
)
)
try:
await self._exit_stack.aclose()
except (Exception, asyncio.CancelledError):
logger.exception(
self._make_error_message(
f"Failed to clean up the exit stack. Replacing with a new exit stack ..."
)
)
self._exit_stack = AsyncExitStack()Our MCP client wrapper tries to connect to the server, and if it fails, it cleans up the exit stack to allow subsequent connection trials. So, I disabled my internet connection and tried to connect to the remote Tavily MCP server, which fails and triggers the _cleanup_exit_stack method.
Here is a full log trace for what happened, which should help understand why the first attempt fails.
[07/29/25 14:51:28] DEBUG Connecting to StreamableHTTP endpoint: https://mcp.tavily.com/mcp/?tavilyApiKey=****************************************** streamable_http.py:476
DEBUG Sending client message: root=JSONRPCRequest(method='initialize', params={'protocolVersion': '2025-06-18', 'capabilities': {}, 'clientInfo': {'name': streamable_http.py:385
'mcp', 'version': '0.1.0'}}, jsonrpc='2.0', id=0) main.py:265
DEBUG connect_tcp.started host='mcp.tavily.com' port=443 local_address=None timeout=30 socket_options=None _trace.py:87
DEBUG connect_tcp.failed exception=ConnectError(gaierror(11001, 'getaddrinfo failed')) _trace.py:87
ERROR Error in MCP client (tavily_mcp): The first attempt to close the exit stack failed. Trying to close it again ... base_client.py:242
╭────────────────────────────────────────────────────────── Traceback (most recent call last) ───────────────────────────────────────────────────────────╮
│ C:************************************************************************************************\base_client.py:240 in _cleanup_exit_stack │
│ │
│ 237 │ │
│ 238 │ async def _cleanup_exit_stack(self): │
│ 239 │ │ try: │
│ ❱ 240 │ │ │ await self._exit_stack.aclose() │
│ 241 │ │ except (Exception, asyncio.CancelledError): │
│ 242 │ │ │ logger.exception( │
│ 243 │ │ │ │ self._make_error_message( │
│ │
│ C:\*********************************************************************************************\Lib\contextlib.py:696 in aclose │
│ │
│ 693 │ │
│ 694 │ async def aclose(self): │
│ 695 │ │ """Immediately unwind the context stack.""" │
│ ❱ 696 │ │ await self.__aexit__(None, None, None) │
│ 697 │ │
│ 698 │ def _push_async_cm_exit(self, cm, cm_exit): │
│ 699 │ │ """Helper to correctly register coroutine function to __aexit__ │
│ │
│ C:\*********************************************************************************************\Lib\contextlib.py:754 in __aexit__ │
│ │
│ 751 │ │ │ │ # bare "raise exc_details[1]" replaces our carefully │
│ 752 │ │ │ │ # set-up context │
│ 753 │ │ │ │ fixed_ctx = exc_details[1].__context__ │
│ ❱ 754 │ │ │ │ raise exc_details[1] │
│ 755 │ │ │ except BaseException: │
│ 756 │ │ │ │ exc_details[1].__context__ = fixed_ctx │
│ 757 │ │ │ │ raise │
│ │
│ C:\*********************************************************************************************\Lib\contextlib.py:737 in __aexit__ │
│ │
│ 734 │ │ │ │ if is_sync: │
│ 735 │ │ │ │ │ cb_suppress = cb(*exc_details) │
│ 736 │ │ │ │ else: │
│ ❱ 737 │ │ │ │ │ cb_suppress = await cb(*exc_details) │
│ 738 │ │ │ │ │
│ 739 │ │ │ │ if cb_suppress: │
│ 740 │ │ │ │ │ suppressed_exc = True │
│ │
│ C:\*********************************************************************************************\Lib\contextlib.py:231 in __aexit__ │
│ │
│ 228 │ │ │ │ # tell if we get the same exception back │
│ 229 │ │ │ │ value = typ() │
│ 230 │ │ │ try: │
│ ❱ 231 │ │ │ │ await self.gen.athrow(value) │
│ 232 │ │ │ except StopAsyncIteration as exc: │
│ 233 │ │ │ │ # Suppress StopIteration *unless* it's the same exception that │
│ 234 │ │ │ │ # was passed to throw(). This prevents a StopIteration │
│ │
│ C:\********************************\.venv\Lib\site-packages\mcp\client\streamable_http.py:474 in streamablehttp_client │
│ │
│ 471 │ read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | │
│ Exception](0) │
│ 472 │ write_stream, write_stream_reader = │
│ anyio.create_memory_object_stream[SessionMessage](0) │
│ 473 │ │
│ ❱ 474 │ async with anyio.create_task_group() as tg: │
│ 475 │ │ try: │
│ 476 │ │ │ logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") │
│ 477 │
│ │
│ C:\********************************\.venv\Lib\site-packages\anyio\_backends\_asyncio.py:772 in __aexit__ │
│ │
│ 769 │ │ │ │ │ # added to self._exceptions so it's ok to break exception │
│ 770 │ │ │ │ │ # chaining and avoid adding a "During handling of above..." │
│ 771 │ │ │ │ │ # for each nesting level. │
│ ❱ 772 │ │ │ │ │ raise BaseExceptionGroup( │
│ 773 │ │ │ │ │ │ "unhandled errors in a TaskGroup", self._exceptions │
│ 774 │ │ │ │ │ ) from None │
│ 775 │ │ │ │ elif exc_val: │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
╭─────────────────────────────────────────────────────────────────── Sub-exception #1 ───────────────────────────────────────────────────────────────────╮
│ ╭──────────────────────────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────────────────────────╮ │
│ │ C:\*********************************************************************************************\Lib\asyncio\tasks.py:316 in │ │
│ │ __step_run_and_handle_result │ │
│ │ │ │
│ │ 313 │ │ │ │ # don't have `__iter__` and `__next__` methods. │ │
│ │ 314 │ │ │ │ result = coro.send(None) │ │
│ │ 315 │ │ │ else: │ │
│ │ ❱ 316 │ │ │ │ result = coro.throw(exc) │ │
│ │ 317 │ │ except StopIteration as exc: │ │
│ │ 318 │ │ │ if self._must_cancel: │ │
│ │ 319 │ │ │ │ # Task is cancelled right before coro stops. │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\mcp\client\streamable_http.py:405 in handle_request_async │ │
│ │ │ │
│ │ 402 │ │ │ │ │ │ if is_resumption: │ │
│ │ 403 │ │ │ │ │ │ │ await self._handle_resumption_request(ctx) │ │
│ │ 404 │ │ │ │ │ │ else: │ │
│ │ ❱ 405 │ │ │ │ │ │ │ await self._handle_post_request(ctx) │ │
│ │ 406 │ │ │ │ │ │ │
│ │ 407 │ │ │ │ │ # If this is a request, start a new task to handle it │ │
│ │ 408 │ │ │ │ │ if isinstance(message.root, JSONRPCRequest): │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\mcp\client\streamable_http.py:259 in _handle_post_request │ │
│ │ │ │
│ │ 256 │ │ message = ctx.session_message.message │ │
│ │ 257 │ │ is_initialization = self._is_initialization_request(message) │ │
│ │ 258 │ │ │ │
│ │ ❱ 259 │ │ async with ctx.client.stream( │ │
│ │ 260 │ │ │ "POST", │ │
│ │ 261 │ │ │ self.url, │ │
│ │ 262 │ │ │ json=message.model_dump(by_alias=True, mode="json", exclude_none=True), │ │
│ │ │ │
│ │ C:\*********************************************************************************************\Lib\contextlib.py:210 in __aenter__ │ │
│ │ │ │
│ │ 207 │ │ # they are only needed for recreation, which is not possible anymore │ │
│ │ 208 │ │ del self.args, self.kwds, self.func │ │
│ │ 209 │ │ try: │ │
│ │ ❱ 210 │ │ │ return await anext(self.gen) │ │
│ │ 211 │ │ except StopAsyncIteration: │ │
│ │ 212 │ │ │ raise RuntimeError("generator didn't yield") from None │ │
│ │ 213 │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_client.py:1583 in stream │ │
│ │ │ │
│ │ 1580 │ │ │ timeout=timeout, │ │
│ │ 1581 │ │ │ extensions=extensions, │ │
│ │ 1582 │ │ ) │ │
│ │ ❱ 1583 │ │ response = await self.send( │ │
│ │ 1584 │ │ │ request=request, │ │
│ │ 1585 │ │ │ auth=auth, │ │
│ │ 1586 │ │ │ follow_redirects=follow_redirects, │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_client.py:1629 in send │ │
│ │ │ │
│ │ 1626 │ │ │ │
│ │ 1627 │ │ auth = self._build_request_auth(request, auth) │ │
│ │ 1628 │ │ │ │
│ │ ❱ 1629 │ │ response = await self._send_handling_auth( │ │
│ │ 1630 │ │ │ request, │ │
│ │ 1631 │ │ │ auth=auth, │ │
│ │ 1632 │ │ │ follow_redirects=follow_redirects, │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_client.py:1657 in _send_handling_auth │ │
│ │ │ │
│ │ 1654 │ │ │ request = await auth_flow.__anext__() │ │
│ │ 1655 │ │ │ │ │
│ │ 1656 │ │ │ while True: │ │
│ │ ❱ 1657 │ │ │ │ response = await self._send_handling_redirects( │ │
│ │ 1658 │ │ │ │ │ request, │ │
│ │ 1659 │ │ │ │ │ follow_redirects=follow_redirects, │ │
│ │ 1660 │ │ │ │ │ history=history, │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_client.py:1694 in _send_handling_redirects │ │
│ │ │ │
│ │ 1691 │ │ │ for hook in self._event_hooks["request"]: │ │
│ │ 1692 │ │ │ │ await hook(request) │ │
│ │ 1693 │ │ │ │ │
│ │ ❱ 1694 │ │ │ response = await self._send_single_request(request) │ │
│ │ 1695 │ │ │ try: │ │
│ │ 1696 │ │ │ │ for hook in self._event_hooks["response"]: │ │
│ │ 1697 │ │ │ │ │ await hook(response) │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_client.py:1730 in _send_single_request │ │
│ │ │ │
│ │ 1727 │ │ │ ) │ │
│ │ 1728 │ │ │ │
│ │ 1729 │ │ with request_context(request=request): │ │
│ │ ❱ 1730 │ │ │ response = await transport.handle_async_request(request) │ │
│ │ 1731 │ │ │ │
│ │ 1732 │ │ assert isinstance(response.stream, AsyncByteStream) │ │
│ │ 1733 │ │ response.request = request │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_transports\default.py:393 in handle_async_request │ │
│ │ │ │
│ │ 390 │ │ │ content=request.stream, │ │
│ │ 391 │ │ │ extensions=request.extensions, │ │
│ │ 392 │ │ ) │ │
│ │ ❱ 393 │ │ with map_httpcore_exceptions(): │ │
│ │ 394 │ │ │ resp = await self._pool.handle_async_request(req) │ │
│ │ 395 │ │ │ │
│ │ 396 │ │ assert isinstance(resp.stream, typing.AsyncIterable) │ │
│ │ │ │
│ │ C:\*********************************************************************************************\Lib\contextlib.py:158 in __exit__ │ │
│ │ │ │
│ │ 155 │ │ │ │ # tell if we get the same exception back │ │
│ │ 156 │ │ │ │ value = typ() │ │
│ │ 157 │ │ │ try: │ │
│ │ ❱ 158 │ │ │ │ self.gen.throw(value) │ │
│ │ 159 │ │ │ except StopIteration as exc: │ │
│ │ 160 │ │ │ │ # Suppress StopIteration *unless* it's the same exception that │ │
│ │ 161 │ │ │ │ # was passed to throw(). This prevents a StopIteration │ │
│ │ │ │
│ │ C:\********************************\.venv\Lib\site-packages\httpx\_transports\default.py:118 in map_httpcore_exceptions │ │
│ │ │ │
│ │ 115 │ │ │ raise │ │
│ │ 116 │ │ │ │
│ │ 117 │ │ message = str(exc) │ │
│ │ ❱ 118 │ │ raise mapped_exc(message) from exc │ │
│ │ 119 │ │
│ │ 120 │ │
│ │ 121 class ResponseStream(SyncByteStream): │ │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ ConnectError: [Errno 11001] getaddrinfo failed │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯From my understanding, I think the error happens because the httpx client is waiting for stream chunks from the server, and then we try to close it.
I think this should be handled on the MCP SDK level so that trying the exit_stack twice is not necessary.
Example Code
Python & MCP Python SDK
Python: 3.12.10
MCP: 1.12.2