|
1 | 1 | import asyncio |
| 2 | +import logging |
2 | 3 | from typing import Optional |
3 | 4 | from contextlib import AsyncExitStack |
4 | 5 |
|
| 6 | +log = logging.getLogger(__name__) |
| 7 | + |
5 | 8 | import anyio |
6 | 9 |
|
7 | 10 | from mcp import ClientSession |
@@ -136,8 +139,39 @@ async def read_resource(self, uri: str) -> Optional[dict]: |
136 | 139 | return result_dict |
137 | 140 |
|
138 | 141 | async def disconnect(self): |
139 | | - # Clean up and close the session |
140 | | - await self.exit_stack.aclose() |
| 142 | + """Clean up and close the session. |
| 143 | +
|
| 144 | + This method is idempotent — calling it multiple times or on a |
| 145 | + client that was never connected is safe. It shields the close |
| 146 | + operation from CancelledError and adds a timeout so a hung MCP |
| 147 | + server cannot block the event loop indefinitely. |
| 148 | + """ |
| 149 | + exit_stack = self.exit_stack |
| 150 | + if exit_stack is None: |
| 151 | + return |
| 152 | + |
| 153 | + # Prevent double-close from concurrent callers |
| 154 | + self.exit_stack = None |
| 155 | + self.session = None |
| 156 | + |
| 157 | + try: |
| 158 | + await asyncio.wait_for( |
| 159 | + asyncio.shield(exit_stack.aclose()), |
| 160 | + timeout=5.0, |
| 161 | + ) |
| 162 | + except asyncio.TimeoutError: |
| 163 | + log.warning('MCPClient.disconnect() timed out after 5 s') |
| 164 | + except RuntimeError as exc: |
| 165 | + # The MCP SDK's streamable_http transport uses anyio task |
| 166 | + # groups and async generators internally. When we close |
| 167 | + # a session that was interrupted mid-flight these can |
| 168 | + # raise RuntimeError ("aclose(): asynchronous generator is |
| 169 | + # already running" or "Attempted to exit cancel scope in a |
| 170 | + # different task"). Swallowing the error here prevents the |
| 171 | + # orphaned coroutines from spinning at 100 % CPU. |
| 172 | + log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc) |
| 173 | + except Exception as exc: |
| 174 | + log.debug('MCPClient.disconnect() error: %s', exc) |
141 | 175 |
|
142 | 176 | async def __aenter__(self): |
143 | 177 | await self.exit_stack.__aenter__() |
|
0 commit comments