@@ -83,11 +83,15 @@ def __init__(self, transport_callable: Callable[[], MCPTransport], *, startup_ti
8383 self ._transport_callable = transport_callable
8484
8585 self ._background_thread : threading .Thread | None = None
86- self ._background_thread_session : ClientSession
87- self ._background_thread_event_loop : AbstractEventLoop
86+ self ._background_thread_session : ClientSession | None = None
87+ self ._background_thread_event_loop : AbstractEventLoop | None = None
8888
8989 def __enter__ (self ) -> "MCPClient" :
90- """Context manager entry point which initializes the MCP server connection."""
90+ """Context manager entry point which initializes the MCP server connection.
91+
92+ TODO: Refactor to lazy initialization pattern following idiomatic Python.
93+ Heavy work in __enter__ is non-idiomatic - should move connection logic to first method call instead.
94+ """
9195 return self .start ()
9296
9397 def __exit__ (self , exc_type : BaseException , exc_val : BaseException , exc_tb : TracebackType ) -> None :
@@ -118,9 +122,15 @@ def start(self) -> "MCPClient":
118122 self ._init_future .result (timeout = self ._startup_timeout )
119123 self ._log_debug_with_thread ("the client initialization was successful" )
120124 except futures .TimeoutError as e :
121- raise MCPClientInitializationError ("background thread did not start in 30 seconds" ) from e
125+ # Pass None for exc_type, exc_val, exc_tb since this isn't a context manager exit
126+ self .stop (None , None , None )
127+ raise MCPClientInitializationError (
128+ f"background thread did not start in { self ._startup_timeout } seconds"
129+ ) from e
122130 except Exception as e :
123131 logger .exception ("client failed to initialize" )
132+ # Pass None for exc_type, exc_val, exc_tb since this isn't a context manager exit
133+ self .stop (None , None , None )
124134 raise MCPClientInitializationError ("the client initialization failed" ) from e
125135 return self
126136
@@ -129,21 +139,29 @@ def stop(
129139 ) -> None :
130140 """Signals the background thread to stop and waits for it to complete, ensuring proper cleanup of all resources.
131141
142+ This method is defensive and can handle partial initialization states that may occur
143+ if start() fails partway through initialization.
144+
132145 Args:
133146 exc_type: Exception type if an exception was raised in the context
134147 exc_val: Exception value if an exception was raised in the context
135148 exc_tb: Exception traceback if an exception was raised in the context
136149 """
137150 self ._log_debug_with_thread ("exiting MCPClient context" )
138151
139- async def _set_close_event () -> None :
140- self ._close_event .set ()
141-
142- self ._invoke_on_background_thread (_set_close_event ()).result ()
143- self ._log_debug_with_thread ("waiting for background thread to join" )
152+ # Only try to signal close event if we have a background thread
144153 if self ._background_thread is not None :
154+ # Signal close event if event loop exists
155+ if self ._background_thread_event_loop is not None :
156+
157+ async def _set_close_event () -> None :
158+ self ._close_event .set ()
159+
160+ asyncio .run_coroutine_threadsafe (_set_close_event (), self ._background_thread_event_loop )
161+
162+ self ._log_debug_with_thread ("waiting for background thread to join" )
145163 self ._background_thread .join ()
146- self ._log_debug_with_thread ("background thread joined, MCPClient context exited" )
164+ self ._log_debug_with_thread ("background thread joined, MCPClient context exited" )
147165
148166 # Reset fields to allow instance reuse
149167 self ._init_future = futures .Future ()
@@ -165,6 +183,7 @@ def list_tools_sync(self, pagination_token: Optional[str] = None) -> PaginatedLi
165183 raise MCPClientInitializationError (CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE )
166184
167185 async def _list_tools_async () -> ListToolsResult :
186+ assert self ._background_thread_session is not None
168187 return await self ._background_thread_session .list_tools (cursor = pagination_token )
169188
170189 list_tools_response : ListToolsResult = self ._invoke_on_background_thread (_list_tools_async ()).result ()
@@ -191,6 +210,7 @@ def list_prompts_sync(self, pagination_token: Optional[str] = None) -> ListPromp
191210 raise MCPClientInitializationError (CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE )
192211
193212 async def _list_prompts_async () -> ListPromptsResult :
213+ assert self ._background_thread_session is not None
194214 return await self ._background_thread_session .list_prompts (cursor = pagination_token )
195215
196216 list_prompts_result : ListPromptsResult = self ._invoke_on_background_thread (_list_prompts_async ()).result ()
@@ -215,6 +235,7 @@ def get_prompt_sync(self, prompt_id: str, args: dict[str, Any]) -> GetPromptResu
215235 raise MCPClientInitializationError (CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE )
216236
217237 async def _get_prompt_async () -> GetPromptResult :
238+ assert self ._background_thread_session is not None
218239 return await self ._background_thread_session .get_prompt (prompt_id , arguments = args )
219240
220241 get_prompt_result : GetPromptResult = self ._invoke_on_background_thread (_get_prompt_async ()).result ()
@@ -250,6 +271,7 @@ def call_tool_sync(
250271 raise MCPClientInitializationError (CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE )
251272
252273 async def _call_tool_async () -> MCPCallToolResult :
274+ assert self ._background_thread_session is not None
253275 return await self ._background_thread_session .call_tool (name , arguments , read_timeout_seconds )
254276
255277 try :
@@ -285,6 +307,7 @@ async def call_tool_async(
285307 raise MCPClientInitializationError (CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE )
286308
287309 async def _call_tool_async () -> MCPCallToolResult :
310+ assert self ._background_thread_session is not None
288311 return await self ._background_thread_session .call_tool (name , arguments , read_timeout_seconds )
289312
290313 try :
0 commit comments