Skip to content

Commit a717c75

Browse files
committed
feat: backport MCP client session reconnection (backport CF #3639)
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent ae0213a commit a717c75

3 files changed

Lines changed: 539 additions & 9 deletions

File tree

cpex/framework/external/mcp/client.py

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
# Third-Party
2222
import httpx
2323
import orjson
24-
from mcp import ClientSession, StdioServerParameters
24+
from mcp import ClientSession, McpError, StdioServerParameters
2525
from mcp.client.stdio import stdio_client
2626
from mcp.client.streamable_http import streamablehttp_client
2727
from 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.

cpex/framework/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ class MCPClientConfig(BaseModel):
589589
cwd (Optional[str]): Working directory for STDIO server process.
590590
uds (Optional[str]): Unix domain socket path for streamable HTTP.
591591
tls (Optional[MCPClientTLSConfig]): Client-side TLS configuration for mTLS.
592+
reconnect_attempts (int): Number of reconnection attempts on session failure.
593+
reconnect_delay (float): Base delay between reconnection attempts (seconds, linear backoff).
592594
"""
593595

594596
proto: TransportType
@@ -599,6 +601,8 @@ class MCPClientConfig(BaseModel):
599601
cwd: Optional[str] = None
600602
uds: Optional[str] = None
601603
tls: Optional[MCPClientTLSConfig] = None
604+
reconnect_attempts: int = Field(default=3, description="Number of reconnection attempts on session failure")
605+
reconnect_delay: float = Field(default=0.1, description="Base delay between reconnection attempts (seconds)")
602606

603607
@field_validator(URL, mode="after")
604608
@classmethod

0 commit comments

Comments
 (0)