2121# Third-Party
2222import httpx
2323import orjson
24- from mcp import ClientSession , StdioServerParameters
24+ from mcp import ClientSession , McpError , StdioServerParameters
2525from mcp .client .stdio import stdio_client
2626from mcp .client .streamable_http import streamablehttp_client
2727from mcp .types import TextContent
@@ -85,6 +85,8 @@ def __init__(self, config: PluginConfig) -> None:
8585 self ._get_session_id : Optional [Callable [[], str | None ]] = None
8686 self ._session_id : Optional [str ] = None
8787 self ._http_client_factory : Optional [Callable [..., httpx .AsyncClient ]] = None
88+ self ._reconnect_attempts : int = 3
89+ self ._reconnect_delay : float = 0.1
8890
8991 async def initialize (self ) -> None :
9092 """Initialize the plugin's connection to the MCP server.
@@ -99,6 +101,10 @@ async def initialize(self) -> None:
99101 message = "The mcp section must be defined for external plugin" , plugin_name = self .name
100102 )
101103 )
104+
105+ self ._reconnect_attempts = self ._config .mcp .reconnect_attempts
106+ self ._reconnect_delay = self ._config .mcp .reconnect_delay
107+
102108 if self ._config .mcp .proto == TransportType .STDIO :
103109 if not (self ._config .mcp .script or self ._config .mcp .cmd ):
104110 raise PluginError (
@@ -405,6 +411,74 @@ def _tls_httpx_client_factory(
405411 logger .info ("Retrying in %ss..." , delay )
406412 await asyncio .sleep (delay )
407413
414+ async def _cleanup_session (self ) -> None :
415+ """Reset session state without a full shutdown (no task await/stop).
416+
417+ Used by reconnection logic to tear down stale state before re-establishing.
418+ """
419+ self ._stdio_error = None
420+
421+ if self ._exit_stack :
422+ await self ._exit_stack .aclose ()
423+ self ._exit_stack = AsyncExitStack ()
424+ if self ._stdio_task :
425+ if self ._stdio_stop :
426+ self ._stdio_stop .set ()
427+ try :
428+ await self ._stdio_task
429+ except Exception as e :
430+ logger .debug ("Error stopping stdio task during cleanup: %s" , e )
431+ self ._stdio_task = None
432+ self ._stdio_ready = None
433+ self ._stdio_stop = None
434+ if self ._stdio_exit_stack :
435+ await self ._stdio_exit_stack .aclose ()
436+ self ._stdio_exit_stack = None
437+ self ._session = None
438+ self ._http = None
439+ self ._write = None
440+ self ._stdio = None
441+ self ._get_session_id = None
442+ self ._session_id = None
443+
444+ async def _reconnect_session (self ) -> None :
445+ """Tear down old session and reconnect to MCP server with linear backoff.
446+
447+ Raises:
448+ PluginError: If reconnection fails after all attempts.
449+ """
450+ logger .info ("Attempting to reconnect to MCP server: %s" , self .name )
451+
452+ await self ._cleanup_session ()
453+
454+ last_error : Optional [Exception ] = None
455+ for attempt in range (1 , self ._reconnect_attempts + 1 ):
456+ try :
457+ logger .debug ("Reconnection attempt %d/%d to %s" , attempt , self ._reconnect_attempts , self .name )
458+
459+ if self ._config .mcp .proto == TransportType .STREAMABLEHTTP :
460+ await self .__connect_to_http_server (self ._config .mcp .url )
461+ elif self ._config .mcp .proto == TransportType .STDIO :
462+ await self .__connect_to_stdio_server (
463+ self ._config .mcp .script , self ._config .mcp .cmd , self ._config .mcp .env , self ._config .mcp .cwd
464+ )
465+
466+ logger .info ("Reconnected to MCP server on attempt %d: %s" , attempt , self .name )
467+ return
468+ except Exception as e :
469+ last_error = e
470+ if attempt < self ._reconnect_attempts :
471+ delay = self ._reconnect_delay * attempt
472+ logger .warning ("Reconnection attempt %d failed: %s. Retrying in %ss..." , attempt , e , delay )
473+ await asyncio .sleep (delay )
474+
475+ raise PluginError (
476+ error = PluginErrorModel (
477+ message = f"Failed to reconnect after { self ._reconnect_attempts } attempts: { last_error } " ,
478+ plugin_name = self .name ,
479+ )
480+ )
481+
408482 async def invoke_hook (self , hook_type : str , payload : PluginPayload , context : PluginContext ) -> PluginResult :
409483 """Invoke an external plugin hook using the MCP protocol.
410484
@@ -432,11 +506,11 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu
432506 if not self ._session :
433507 raise PluginError (error = PluginErrorModel (message = "Plugin session not initialized" , plugin_name = self .name ))
434508
435- try :
436- result = await self ._session .call_tool (
509+ async def _execute_call () -> PluginResult :
510+ call_result = await self ._session .call_tool (
437511 INVOKE_HOOK , {HOOK_TYPE : hook_type , PLUGIN_NAME : self .name , PAYLOAD : payload , CONTEXT : context }
438512 )
439- for content in result .content :
513+ for content in call_result .content :
440514 if not isinstance (content , TextContent ):
441515 continue
442516 try :
@@ -457,17 +531,38 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu
457531 if RESULT in res :
458532 return result_type .model_validate (res [RESULT ])
459533 if ERROR in res :
460- error = PluginErrorModel .model_validate (res [ERROR ])
461- raise PluginError (error )
534+ error_model = PluginErrorModel .model_validate (res [ERROR ])
535+ raise PluginError (error_model )
536+ raise PluginError (
537+ error = PluginErrorModel (
538+ message = f"Received invalid response. Result = { call_result } " , plugin_name = self .name
539+ )
540+ )
541+
542+ try :
543+ return await _execute_call ()
462544 except PluginError as pe :
545+ error_msg = str (pe .error .message ).lower () if pe .error and pe .error .message else ""
546+ if "session" in error_msg and "terminated" in error_msg :
547+ logger .warning ("Session terminated for plugin %s, attempting reconnection..." , self .name )
548+ try :
549+ await self ._reconnect_session ()
550+ return await _execute_call ()
551+ except Exception as reconn_err :
552+ logger .exception ("Reconnection failed for plugin %s: %s" , self .name , reconn_err )
463553 logger .exception (pe )
464554 raise
555+ except McpError as e :
556+ logger .warning ("McpError for plugin %s: %s" , self .name , e )
557+ try :
558+ await self ._reconnect_session ()
559+ return await _execute_call ()
560+ except Exception as reconn_err :
561+ logger .exception ("Reconnection failed for plugin %s: %s" , self .name , reconn_err )
562+ raise PluginError (error = convert_exception_to_error (e , plugin_name = self .name ))
465563 except Exception as e :
466564 logger .exception (e )
467565 raise PluginError (error = convert_exception_to_error (e , plugin_name = self .name ))
468- raise PluginError (
469- error = PluginErrorModel (message = f"Received invalid response. Result = { result } " , plugin_name = self .name )
470- )
471566
472567 async def __get_plugin_config (self ) -> PluginConfig | None :
473568 """Retrieve plugin configuration for the current plugin on the remote MCP server.
0 commit comments