massgen 0.0.3__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of massgen might be problematic. Click here for more details.

Files changed (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,894 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ MCP client implementation for connecting to MCP servers. This module provides enhanced MCP client
4
+ functionality to connect with MCP servers and integrate external tools into the MassGen workflow.
5
+ """
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from datetime import timedelta
9
+ from enum import Enum
10
+ from types import TracebackType
11
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
12
+
13
+ from mcp import ClientSession, StdioServerParameters
14
+ from mcp import types as mcp_types
15
+ from mcp.client.stdio import get_default_environment, stdio_client
16
+ from mcp.client.streamable_http import streamablehttp_client
17
+
18
+ from ..logger_config import logger
19
+ from .circuit_breaker import MCPCircuitBreaker
20
+ from .config_validator import MCPConfigValidator
21
+ from .exceptions import (
22
+ MCPConnectionError,
23
+ MCPError,
24
+ MCPServerError,
25
+ MCPTimeoutError,
26
+ MCPValidationError,
27
+ )
28
+ from .security import (
29
+ prepare_command,
30
+ sanitize_tool_name,
31
+ substitute_env_variables,
32
+ validate_tool_arguments,
33
+ )
34
+
35
+
36
+ class ConnectionState(Enum):
37
+ """Connection state for MCP clients."""
38
+
39
+ DISCONNECTED = "disconnected"
40
+ CONNECTING = "connecting"
41
+ CONNECTED = "connected"
42
+ DISCONNECTING = "disconnecting"
43
+ FAILED = "failed"
44
+
45
+
46
+ # Hook types reference: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-python#hook-types
47
+ class HookType(Enum):
48
+ """Available hook types for MCP tool execution."""
49
+
50
+ PRE_TOOL_USE = "PreToolUse"
51
+
52
+
53
+ def _ensure_timedelta(value: Union[int, float, timedelta], default_seconds: float) -> timedelta:
54
+ """
55
+ Ensure a value is converted to timedelta for consistent timeout handling.
56
+
57
+ Raises:
58
+ MCPValidationError: If value is invalid
59
+ """
60
+ if isinstance(value, timedelta):
61
+ if value.total_seconds() <= 0:
62
+ raise MCPValidationError(
63
+ f"Timeout must be positive, got {value.total_seconds()} seconds",
64
+ field="timeout",
65
+ value=value.total_seconds(),
66
+ )
67
+ return value
68
+ elif isinstance(value, (int, float)):
69
+ if value <= 0:
70
+ raise MCPValidationError(
71
+ f"Timeout must be positive, got {value} seconds",
72
+ field="timeout",
73
+ value=value,
74
+ )
75
+ return timedelta(seconds=value)
76
+ else:
77
+ logger.warning(f"Invalid timeout value {value}, using default {default_seconds}s")
78
+ return timedelta(seconds=default_seconds)
79
+
80
+
81
+ @dataclass
82
+ class _ServerClient:
83
+ """Internal container for per-server state."""
84
+
85
+ session: Optional[ClientSession] = None
86
+ manager_task: Optional[asyncio.Task] = None
87
+ connected_event: asyncio.Event = None
88
+ disconnect_event: asyncio.Event = None
89
+ connection_lock: asyncio.Lock = None
90
+ connection_state: ConnectionState = ConnectionState.DISCONNECTED
91
+ initialized: bool = False
92
+
93
+ def __post_init__(self):
94
+ if self.connected_event is None:
95
+ self.connected_event = asyncio.Event()
96
+ if self.disconnect_event is None:
97
+ self.disconnect_event = asyncio.Event()
98
+ if self.connection_lock is None:
99
+ self.connection_lock = asyncio.Lock()
100
+
101
+
102
+ class MCPClient:
103
+ """
104
+ Unified MCP client for communicating with single or multiple MCP servers.
105
+ Provides improved security, error handling, and async context management.
106
+
107
+ Accepts a list of server configurations and automatically handles:
108
+ - Consistent tool naming: Always uses prefixed names (mcp__server__tool)
109
+ - Circuit breaker protection for all servers
110
+ - Parallel connection for multi-server scenarios
111
+ - Sequential connection for single-server scenarios
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ server_configs: List[Dict[str, Any]],
117
+ *,
118
+ timeout_seconds: int = 30,
119
+ allowed_tools: Optional[List[str]] = None,
120
+ exclude_tools: Optional[List[str]] = None,
121
+ status_callback: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None,
122
+ hooks: Optional[Dict[HookType, List[Callable[[str, Dict[str, Any]], Awaitable[bool]]]]] = None,
123
+ ):
124
+ """
125
+ Initialize MCP client.
126
+
127
+ Args:
128
+ server_configs: List of server configuration dicts (always a list, even for single server)
129
+ timeout_seconds: Timeout for operations in seconds
130
+ allowed_tools: Optional list of tool names to include (if None, includes all)
131
+ exclude_tools: Optional list of tool names to exclude (if None, excludes none)
132
+ status_callback: Optional async callback for status updates
133
+ hooks: Optional dict mapping hook types to lists of hook functions
134
+ """
135
+ # Validate all server configs
136
+ self._server_configs = [MCPConfigValidator.validate_server_config(config) for config in server_configs]
137
+
138
+ # Set name to first server's name for backward compatibility
139
+ self.name = self._server_configs[0]["name"]
140
+
141
+ self.timeout_seconds = timeout_seconds
142
+ self.allowed_tools = allowed_tools
143
+ self.exclude_tools = exclude_tools
144
+ self.status_callback = status_callback
145
+ self.hooks = hooks or {}
146
+
147
+ # Initialize circuit breaker for ALL scenarios
148
+ self._circuit_breaker = MCPCircuitBreaker()
149
+
150
+ # Per-server tracking
151
+ self._server_clients: Dict[str, _ServerClient] = {}
152
+ for config in self._server_configs:
153
+ self._server_clients[config["name"]] = _ServerClient()
154
+
155
+ # Unified registry for tools
156
+ self.tools: Dict[str, mcp_types.Tool] = {}
157
+ self._tool_to_server: Dict[str, str] = {}
158
+
159
+ # Connection management
160
+ self._initialized = False
161
+ self._cleanup_done = False
162
+ self._cleanup_lock = asyncio.Lock()
163
+ self._context_managed = False
164
+
165
+ @property
166
+ def session(self) -> Optional[ClientSession]:
167
+ """Return first server's session for backward compatibility."""
168
+ if self._server_configs:
169
+ first_server_name = self._server_configs[0]["name"]
170
+ server_client = self._server_clients.get(first_server_name)
171
+ if server_client:
172
+ return server_client.session
173
+ return None
174
+
175
+ def _get_server_session(self, server_name: str) -> ClientSession:
176
+ """Get session for server, raising error if not connected."""
177
+ server_client = self._server_clients.get(server_name)
178
+ if not server_client or not server_client.session:
179
+ raise MCPConnectionError(
180
+ f"Server '{server_name}' not connected",
181
+ server_name=server_name,
182
+ )
183
+ return server_client.session
184
+
185
+ async def connect(self) -> None:
186
+ """Connect to MCP server(s) and discover capabilities with circuit breaker integration."""
187
+ if self._initialized:
188
+ return
189
+
190
+ logger.info(f"Connecting to {len(self._server_configs)} MCP server(s)...")
191
+
192
+ # Send connecting status if callback is available
193
+ if self.status_callback:
194
+ await self.status_callback(
195
+ "connecting",
196
+ {
197
+ "message": f"Connecting to {len(self._server_configs)} MCP server(s)",
198
+ "server_count": len(self._server_configs),
199
+ },
200
+ )
201
+
202
+ if len(self._server_configs) > 1:
203
+ # Multi-server: connect in parallel
204
+ await self._connect_all_parallel()
205
+ else:
206
+ # Single-server: connect sequentially
207
+ await self._connect_single()
208
+
209
+ # Only mark as initialized if at least one server connected successfully
210
+ self._initialized = any(sc.initialized for sc in self._server_clients.values())
211
+
212
+ # Count successful and failed connections
213
+ successful_count = len([sc for sc in self._server_clients.values() if sc.initialized])
214
+ failed_count = len(self._server_configs) - successful_count
215
+
216
+ # Send connection summary status if callback is available
217
+ if self.status_callback:
218
+ await self.status_callback(
219
+ "connection_summary",
220
+ {
221
+ "message": f"Connected to {successful_count}/{len(self._server_configs)} server(s)" + (f" ({failed_count} failed)" if failed_count > 0 else ""),
222
+ "successful_count": successful_count,
223
+ "failed_count": failed_count,
224
+ "total_count": len(self._server_configs),
225
+ "tools_count": len(self.tools),
226
+ },
227
+ )
228
+
229
+ async def _connect_server(self, server_name: str, config: Dict[str, Any]) -> bool:
230
+ """Connect to a single server with circuit breaker integration.
231
+
232
+ Returns:
233
+ True on success, False on failure
234
+ """
235
+ server_client = self._server_clients[server_name]
236
+
237
+ async with server_client.connection_lock:
238
+ # Check circuit breaker
239
+ if self._circuit_breaker.should_skip_server(server_name):
240
+ logger.warning(f"Skipping server {server_name} due to circuit breaker")
241
+ server_client.connection_state = ConnectionState.FAILED
242
+ return False
243
+
244
+ server_client.connection_state = ConnectionState.CONNECTING
245
+
246
+ try:
247
+ # Start background manager task
248
+ server_client.manager_task = asyncio.create_task(
249
+ self._run_manager(server_name, config),
250
+ )
251
+
252
+ # Wait for connection
253
+ await asyncio.wait_for(server_client.connected_event.wait(), timeout=30.0)
254
+
255
+ if not server_client.initialized or server_client.connection_state != ConnectionState.CONNECTED:
256
+ raise MCPConnectionError(f"Failed to connect to {server_name}")
257
+
258
+ # Record success
259
+ self._circuit_breaker.record_success(server_name)
260
+ logger.info(f"✅ MCP server '{server_name}' connected successfully!")
261
+ return True
262
+
263
+ except Exception as e:
264
+ self._circuit_breaker.record_failure(server_name)
265
+ server_client.connection_state = ConnectionState.FAILED
266
+ logger.error(f"Failed to connect to {server_name}: {e}")
267
+
268
+ # Cleanup manager task to prevent resource leak
269
+ if server_client.manager_task and not server_client.manager_task.done():
270
+ server_client.disconnect_event.set()
271
+ try:
272
+ await asyncio.wait_for(server_client.manager_task, timeout=5.0)
273
+ except asyncio.TimeoutError:
274
+ logger.warning(f"Manager task for {server_name} didn't shutdown gracefully, cancelling")
275
+ server_client.manager_task.cancel()
276
+ try:
277
+ await server_client.manager_task
278
+ except asyncio.CancelledError:
279
+ pass
280
+ except Exception as cleanup_error:
281
+ logger.error(f"Error cleaning up manager task for {server_name}: {cleanup_error}")
282
+ finally:
283
+ server_client.manager_task = None
284
+
285
+ return False
286
+
287
+ async def _connect_single(self) -> None:
288
+ """Connect to single server."""
289
+ config = self._server_configs[0]
290
+ server_name = config["name"]
291
+
292
+ success = await self._connect_server(server_name, config)
293
+ if not success:
294
+ raise MCPConnectionError(f"Failed to connect to {server_name}")
295
+
296
+ async def _connect_all_parallel(self) -> None:
297
+ """Connect to all servers in parallel."""
298
+ tasks = [self._connect_server(c["name"], c) for c in self._server_configs]
299
+ results = await asyncio.gather(*tasks, return_exceptions=True)
300
+
301
+ # Log results
302
+ successful = sum(1 for r in results if r is True)
303
+ logger.info(f"Connected to {successful}/{len(self._server_configs)} servers")
304
+
305
+ def _create_transport_context(self, config: Dict[str, Any]):
306
+ """Create the appropriate transport context manager based on config."""
307
+ transport_type = config.get("type", "stdio")
308
+ server_name = config["name"]
309
+
310
+ if transport_type == "stdio":
311
+ command = config.get("command", [])
312
+ args = config.get("args", [])
313
+
314
+ logger.debug(f"Setting up stdio transport for {server_name}: command={command}, args={args}")
315
+
316
+ # Handle command preparation
317
+ if isinstance(command, str):
318
+ full_command = prepare_command(command)
319
+ if args:
320
+ full_command.extend(args)
321
+ elif isinstance(command, list):
322
+ full_command = command + (args or [])
323
+ else:
324
+ full_command = args or []
325
+
326
+ if not full_command:
327
+ raise MCPConnectionError(f"No command specified for stdio transport in {server_name}")
328
+
329
+ # Merge provided env with system env
330
+ env = config.get("env", {})
331
+ if env:
332
+ env = {**get_default_environment(), **env}
333
+ else:
334
+ env = get_default_environment()
335
+
336
+ # Perform environment variable substitution for args
337
+ substituted_args = []
338
+ for arg in full_command[1:] if len(full_command) > 1 else []:
339
+ if isinstance(arg, str):
340
+ try:
341
+ substituted_args.append(substitute_env_variables(arg))
342
+ except ValueError as e:
343
+ raise MCPConnectionError(f"Environment variable substitution failed in args: {e}", server_name=server_name) from e
344
+ else:
345
+ substituted_args.append(arg)
346
+
347
+ # Perform environment variable substitution for env dict
348
+ for key, value in list(env.items()):
349
+ if isinstance(value, str):
350
+ try:
351
+ env[key] = substitute_env_variables(value)
352
+ except ValueError as e:
353
+ raise MCPConnectionError(f"Environment variable substitution failed for {key}: {e}", server_name=server_name) from e
354
+
355
+ # Extract cwd if provided in config
356
+ cwd = config.get("cwd")
357
+
358
+ server_params = StdioServerParameters(
359
+ command=full_command[0],
360
+ args=substituted_args,
361
+ env=env,
362
+ cwd=cwd,
363
+ )
364
+
365
+ # Open errlog file to redirect MCP server stderr output
366
+ from ..logger_config import get_log_session_dir
367
+
368
+ log_dir = get_log_session_dir()
369
+ errlog_path = log_dir / f"mcp_{server_name}_stderr.log"
370
+ errlog_file = open(errlog_path, "w", encoding="utf-8")
371
+
372
+ # Store errlog file handle for cleanup
373
+ if not hasattr(self, "_errlog_files"):
374
+ self._errlog_files = {}
375
+ self._errlog_files[server_name] = errlog_file
376
+
377
+ return stdio_client(server_params, errlog=errlog_file)
378
+
379
+ elif transport_type == "streamable-http":
380
+ url = config["url"]
381
+ headers = config.get("headers", {})
382
+
383
+ # Perform environment variable substitution for headers
384
+ substituted_headers = {}
385
+ for key, value in headers.items():
386
+ if isinstance(value, str):
387
+ try:
388
+ substituted_headers[key] = substitute_env_variables(value)
389
+ except ValueError as e:
390
+ raise MCPConnectionError(f"Environment variable substitution failed in header {key}: {e}", server_name=server_name) from e
391
+ else:
392
+ substituted_headers[key] = value
393
+
394
+ timeout_raw = config.get("timeout", self.timeout_seconds)
395
+ http_read_timeout_raw = config.get("http_read_timeout", 60 * 5)
396
+
397
+ timeout = _ensure_timedelta(timeout_raw, self.timeout_seconds)
398
+ http_read_timeout = _ensure_timedelta(http_read_timeout_raw, 60 * 5)
399
+
400
+ return streamablehttp_client(
401
+ url=url,
402
+ headers=substituted_headers,
403
+ timeout=timeout,
404
+ sse_read_timeout=http_read_timeout,
405
+ )
406
+ else:
407
+ raise MCPConnectionError(f"Unsupported transport type: {transport_type}")
408
+
409
+ async def _run_manager(self, server_name: str, config: Dict[str, Any]) -> None:
410
+ """Background task that owns the transport and session contexts for a server."""
411
+ server_client = self._server_clients[server_name]
412
+ connection_successful = False
413
+
414
+ try:
415
+ transport_ctx = self._create_transport_context(config)
416
+
417
+ async with transport_ctx as session_params:
418
+ read, write = session_params[0:2]
419
+
420
+ session_timeout_timedelta = _ensure_timedelta(self.timeout_seconds, 30.0)
421
+
422
+ async with ClientSession(read, write, read_timeout_seconds=session_timeout_timedelta) as session:
423
+ # Initialize and expose session
424
+ server_client.session = session
425
+ await session.initialize()
426
+ await self._discover_capabilities(server_name, config)
427
+ server_client.initialized = True
428
+ server_client.connection_state = ConnectionState.CONNECTED
429
+ connection_successful = True
430
+ server_client.connected_event.set()
431
+
432
+ logger.info(f"✅ MCP server '{server_name}' connected successfully!")
433
+
434
+ # Send connected status if callback is available
435
+ if self.status_callback:
436
+ await self.status_callback(
437
+ "connected",
438
+ {
439
+ "server": server_name,
440
+ "message": f"Server '{server_name}' ready",
441
+ },
442
+ )
443
+
444
+ # Wait until disconnect is requested
445
+ await server_client.disconnect_event.wait()
446
+
447
+ except Exception as e:
448
+ logger.error(f"MCP manager error for {server_name}: {e}", exc_info=True)
449
+
450
+ if self.status_callback:
451
+ await self.status_callback(
452
+ "error",
453
+ {
454
+ "server": server_name,
455
+ "message": f"Failed to connect to MCP server '{server_name}': {e}",
456
+ "error": str(e),
457
+ },
458
+ )
459
+
460
+ if not server_client.connected_event.is_set():
461
+ server_client.connected_event.set()
462
+ finally:
463
+ # Clear session state
464
+ server_client.initialized = False
465
+ server_client.session = None
466
+ if not connection_successful:
467
+ server_client.connection_state = ConnectionState.FAILED
468
+ if not server_client.connected_event.is_set():
469
+ server_client.connected_event.set()
470
+ else:
471
+ server_client.connection_state = ConnectionState.DISCONNECTED
472
+
473
+ async def _discover_capabilities(self, server_name: str, config: Dict[str, Any]) -> None:
474
+ """Discover server capabilities (tools, resources, prompts) with name prefixing for multi-server."""
475
+ logger.debug(f"Discovering capabilities for {server_name}")
476
+
477
+ session = self._get_server_session(server_name)
478
+
479
+ try:
480
+ # Combine backend-level and per-server tool filtering
481
+ server_exclude = config.get("exclude_tools", [])
482
+ combined_exclude = list(set((self.exclude_tools or []) + server_exclude))
483
+
484
+ server_allowed = config.get("allowed_tools")
485
+ combined_allowed = server_allowed if server_allowed is not None else self.allowed_tools
486
+
487
+ # List tools
488
+ available_tools = await session.list_tools()
489
+ tools_list = getattr(available_tools, "tools", []) if available_tools else []
490
+
491
+ for tool in tools_list:
492
+ if combined_exclude and tool.name in combined_exclude:
493
+ continue
494
+ if combined_allowed is None or tool.name in combined_allowed:
495
+ # Always apply name prefixing for consistency
496
+ prefixed_name = sanitize_tool_name(tool.name, server_name)
497
+
498
+ self.tools[prefixed_name] = tool
499
+ self._tool_to_server[prefixed_name] = server_name
500
+
501
+ logger.info(f"Discovered capabilities for {server_name}: " f"{len([t for t, s in self._tool_to_server.items() if s == server_name])} tools")
502
+
503
+ except Exception as e:
504
+ logger.error(f"Failed to discover server capabilities for {server_name}: {e}", exc_info=True)
505
+ raise MCPConnectionError(f"Failed to discover server capabilities: {e}") from e
506
+
507
+ async def disconnect(self) -> None:
508
+ """Disconnect from all MCP servers."""
509
+ if not self._initialized:
510
+ return
511
+
512
+ # Disconnect all servers (works for single or multiple)
513
+ tasks = [self._disconnect_one(name, client) for name, client in self._server_clients.items() if client.connection_state != ConnectionState.DISCONNECTED]
514
+
515
+ if tasks:
516
+ await asyncio.gather(*tasks, return_exceptions=True)
517
+
518
+ self._initialized = False
519
+
520
+ async def _disconnect_one(self, server_name: str, server_client: _ServerClient) -> None:
521
+ """Disconnect a single server."""
522
+ server_client.connection_state = ConnectionState.DISCONNECTING
523
+
524
+ if server_client.manager_task and not server_client.manager_task.done():
525
+ server_client.disconnect_event.set()
526
+ try:
527
+ await asyncio.wait_for(server_client.manager_task, timeout=5.0)
528
+ except asyncio.TimeoutError:
529
+ logger.warning(f"Manager task for {server_name} didn't shutdown gracefully, cancelling")
530
+ server_client.manager_task.cancel()
531
+ try:
532
+ await server_client.manager_task
533
+ except asyncio.CancelledError:
534
+ logger.debug(f"Manager task for {server_name} cancelled successfully")
535
+ except Exception as e:
536
+ logger.error(f"Error during manager task shutdown for {server_name}: {e}")
537
+ finally:
538
+ server_client.manager_task = None
539
+
540
+ server_client.initialized = False
541
+ server_client.connection_state = ConnectionState.DISCONNECTED
542
+
543
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
544
+ """
545
+ Call an MCP tool with validation and timeout handling.
546
+
547
+ Args:
548
+ tool_name: Name of the tool to call (always prefixed as mcp__server__toolname)
549
+ arguments: Tool arguments
550
+
551
+ Returns:
552
+ Tool execution result
553
+
554
+ Raises:
555
+ MCPError: If tool is not available
556
+ MCPConnectionError: If no active session
557
+ MCPValidationError: If arguments are invalid
558
+ MCPTimeoutError: If tool call times out
559
+ MCPServerError: If tool execution fails
560
+ """
561
+ if tool_name not in self.tools:
562
+ available_tools = list(self.tools.keys())
563
+ raise MCPError(
564
+ f"Tool '{tool_name}' not available",
565
+ context={"available_tools": available_tools, "total": len(available_tools)},
566
+ )
567
+
568
+ # Validate tool arguments
569
+ try:
570
+ validated_arguments = validate_tool_arguments(arguments)
571
+ except ValueError as e:
572
+ raise MCPValidationError(
573
+ f"Invalid tool arguments: {e}",
574
+ field="arguments",
575
+ value=arguments,
576
+ context={"tool_name": tool_name},
577
+ ) from e
578
+
579
+ # Execute pre-tool hooks
580
+ pre_tool_hooks = self.hooks.get(HookType.PRE_TOOL_USE, [])
581
+ for hook in pre_tool_hooks:
582
+ try:
583
+ allowed = await hook(tool_name, validated_arguments)
584
+ if not allowed:
585
+ raise MCPValidationError(
586
+ "Tool call blocked by pre-tool hook",
587
+ field="tool_name",
588
+ value=tool_name,
589
+ context={"arguments": validated_arguments},
590
+ )
591
+ except Exception as e:
592
+ if isinstance(e, MCPValidationError):
593
+ raise
594
+ logger.warning(f"Pre-tool hook error for {tool_name}: {e}", exc_info=True)
595
+
596
+ # Extract server name from prefixed tool name (always prefixed)
597
+ server_name = self._tool_to_server.get(tool_name)
598
+ if not server_name:
599
+ raise MCPError(f"Tool '{tool_name}' not mapped to any server")
600
+
601
+ # Extract original tool name (remove prefix - always prefixed)
602
+ original_tool_name = tool_name[len(f"mcp__{server_name}__") :]
603
+
604
+ session = self._get_server_session(server_name)
605
+
606
+ logger.debug(f"Calling tool {original_tool_name} on {server_name} with arguments: {validated_arguments}")
607
+
608
+ # Send tool call start status if callback is available
609
+ if self.status_callback:
610
+ await self.status_callback(
611
+ "tool_call_start",
612
+ {
613
+ "server": server_name,
614
+ "tool": original_tool_name,
615
+ "message": f"Calling tool '{original_tool_name}' on server '{server_name}'",
616
+ "arguments": validated_arguments,
617
+ },
618
+ )
619
+
620
+ try:
621
+ # Add timeout to tool calls
622
+ result = await asyncio.wait_for(
623
+ session.call_tool(original_tool_name, validated_arguments),
624
+ timeout=self.timeout_seconds,
625
+ )
626
+ logger.debug(f"Tool {original_tool_name} completed successfully on {server_name}")
627
+
628
+ # Send tool call success status if callback is available
629
+ if self.status_callback:
630
+ await self.status_callback(
631
+ "tool_call_success",
632
+ {
633
+ "server": server_name,
634
+ "tool": original_tool_name,
635
+ "message": f"Tool '{original_tool_name}' executed successfully",
636
+ },
637
+ )
638
+
639
+ return result
640
+
641
+ except asyncio.TimeoutError:
642
+ if self.status_callback:
643
+ await self.status_callback(
644
+ "tool_call_timeout",
645
+ {
646
+ "server": server_name,
647
+ "tool": original_tool_name,
648
+ "message": f"Tool '{original_tool_name}' timed out after {self.timeout_seconds} seconds",
649
+ "timeout": self.timeout_seconds,
650
+ },
651
+ )
652
+
653
+ # Record failure with circuit breaker
654
+ self._circuit_breaker.record_failure(server_name)
655
+
656
+ raise MCPTimeoutError(
657
+ f"Tool call timed out after {self.timeout_seconds} seconds",
658
+ timeout_seconds=self.timeout_seconds,
659
+ operation=f"call_tool({original_tool_name})",
660
+ context={"tool_name": original_tool_name, "server_name": server_name},
661
+ )
662
+ except Exception as e:
663
+ logger.error(f"Tool call failed for {original_tool_name} on {server_name}: {e}", exc_info=True)
664
+
665
+ # Record failure with circuit breaker
666
+ self._circuit_breaker.record_failure(server_name)
667
+
668
+ if self.status_callback:
669
+ await self.status_callback(
670
+ "tool_call_error",
671
+ {
672
+ "server": server_name,
673
+ "tool": original_tool_name,
674
+ "message": f"Tool '{original_tool_name}' failed: {e}",
675
+ "error": str(e),
676
+ },
677
+ )
678
+
679
+ raise MCPServerError(
680
+ f"Tool call failed: {e}",
681
+ server_name=server_name,
682
+ context={"tool_name": original_tool_name, "arguments": validated_arguments},
683
+ ) from e
684
+
685
+ def get_available_tools(self) -> List[str]:
686
+ """Get list of available tool names."""
687
+ return list(self.tools.keys())
688
+
689
+ def is_connected(self) -> bool:
690
+ """Check if any servers are connected."""
691
+ return self._initialized and any(sc.initialized for sc in self._server_clients.values())
692
+
693
+ def get_server_names(self) -> List[str]:
694
+ """Get list of connected server names."""
695
+ return [name for name, sc in self._server_clients.items() if sc.initialized]
696
+
697
+ def get_active_sessions(self) -> List[ClientSession]:
698
+ """Return active MCP ClientSession objects for all connected servers."""
699
+ sessions = []
700
+ for server_client in self._server_clients.values():
701
+ if server_client.session is not None and server_client.initialized:
702
+ sessions.append(server_client.session)
703
+ return sessions
704
+
705
+ async def health_check_all(self) -> Dict[str, bool]:
706
+ """
707
+ Perform health check on all connected MCP servers.
708
+
709
+ Returns:
710
+ Dictionary mapping server names to health status
711
+ """
712
+ health_status = {}
713
+
714
+ for server_name, server_client in self._server_clients.items():
715
+ if not server_client.initialized or not server_client.session:
716
+ health_status[server_name] = False
717
+ continue
718
+
719
+ try:
720
+ await server_client.session.list_tools()
721
+ health_status[server_name] = True
722
+ except Exception as e:
723
+ logger.warning(f"Health check failed for {server_name}: {e}")
724
+ health_status[server_name] = False
725
+
726
+ return health_status
727
+
728
+ async def health_check(self) -> bool:
729
+ """
730
+ Perform a health check on all servers.
731
+
732
+ Returns:
733
+ True if all connected servers are healthy, False otherwise
734
+ """
735
+ health_status = await self.health_check_all()
736
+ return all(health_status.values()) if health_status else False
737
+
738
+ async def _reconnect_failed_servers(self, max_retries: int = 3) -> Dict[str, bool]:
739
+ """
740
+ Attempt to reconnect any failed servers with circuit breaker integration.
741
+
742
+ Args:
743
+ max_retries: Maximum number of reconnection attempts per server
744
+
745
+ Returns:
746
+ Dictionary mapping server names to reconnection success status
747
+ """
748
+ health_status = await self.health_check_all()
749
+ reconnect_results = {}
750
+
751
+ for server_name, is_healthy in health_status.items():
752
+ if not is_healthy:
753
+ # Check circuit breaker before reconnecting
754
+ if self._circuit_breaker.should_skip_server(server_name):
755
+ logger.warning(f"Skipping reconnection for {server_name} due to circuit breaker")
756
+ reconnect_results[server_name] = False
757
+ continue
758
+
759
+ logger.info(f"Attempting to reconnect failed server: {server_name}")
760
+
761
+ # Find the config for this server
762
+ config = next((c for c in self._server_configs if c["name"] == server_name), None)
763
+ if not config:
764
+ reconnect_results[server_name] = False
765
+ continue
766
+
767
+ success = False
768
+ for attempt in range(max_retries):
769
+ try:
770
+ if attempt > 0:
771
+ await asyncio.sleep(1.0 * (2**attempt)) # Exponential backoff
772
+
773
+ # Disconnect first
774
+ server_client = self._server_clients[server_name]
775
+ await self._disconnect_one(server_name, server_client)
776
+
777
+ # Reconnect
778
+ server_client.connected_event = asyncio.Event()
779
+ server_client.disconnect_event = asyncio.Event()
780
+ server_client.manager_task = asyncio.create_task(
781
+ self._run_manager(server_name, config),
782
+ )
783
+ await asyncio.wait_for(server_client.connected_event.wait(), timeout=30.0)
784
+
785
+ if server_client.initialized:
786
+ self._circuit_breaker.record_success(server_name)
787
+ success = True
788
+ logger.info(f"Successfully reconnected server: {server_name}")
789
+ break
790
+ except Exception as e:
791
+ logger.warning(f"Reconnection attempt {attempt + 1} failed for {server_name}: {e}")
792
+ self._circuit_breaker.record_failure(server_name)
793
+
794
+ reconnect_results[server_name] = success
795
+ else:
796
+ reconnect_results[server_name] = True
797
+
798
+ return reconnect_results
799
+
800
+ async def reconnect(self, max_retries: int = 3) -> bool:
801
+ """
802
+ Attempt to reconnect all servers with circuit breaker integration.
803
+
804
+ Args:
805
+ max_retries: Maximum number of reconnection attempts
806
+ Uses exponential backoff between retries: 2s, 4s, 8s, 16s...
807
+
808
+ Returns:
809
+ True if all reconnections successful, False otherwise
810
+ """
811
+ results = await self._reconnect_failed_servers(max_retries)
812
+ return all(results.values()) if results else False
813
+
814
+ async def _cleanup(self) -> None:
815
+ """Comprehensive cleanup of all resources."""
816
+ async with self._cleanup_lock:
817
+ if self._cleanup_done:
818
+ return
819
+
820
+ logger.debug("Starting cleanup for MCPClient")
821
+
822
+ try:
823
+ # Disconnect all servers
824
+ await self.disconnect()
825
+
826
+ # Close errlog files
827
+ if hasattr(self, "_errlog_files"):
828
+ for server_name, errlog_file in self._errlog_files.items():
829
+ try:
830
+ errlog_file.close()
831
+ except Exception as e:
832
+ logger.debug(f"Error closing errlog file for {server_name}: {e}")
833
+ self._errlog_files.clear()
834
+
835
+ # Clear all references
836
+ self.tools.clear()
837
+ self._tool_to_server.clear()
838
+
839
+ self._cleanup_done = True
840
+ logger.debug("Cleanup completed for MCPClient")
841
+
842
+ except Exception as e:
843
+ logger.error(f"Error during cleanup: {e}")
844
+ raise
845
+
846
+ async def __aenter__(self) -> "MCPClient":
847
+ """Async context manager entry."""
848
+ self._context_managed = True
849
+ await self.connect()
850
+ return self
851
+
852
+ async def __aexit__(
853
+ self,
854
+ _exc_type: Optional[type],
855
+ _exc_val: Optional[BaseException],
856
+ _exc_tb: Optional[TracebackType],
857
+ ) -> None:
858
+ """Async context manager exit."""
859
+ try:
860
+ await self._cleanup()
861
+ except Exception as e:
862
+ logger.error(f"Error during context manager cleanup: {e}")
863
+ finally:
864
+ self._context_managed = False
865
+
866
+ @classmethod
867
+ async def create_and_connect(
868
+ cls,
869
+ server_configs: List[Dict[str, Any]],
870
+ *,
871
+ timeout_seconds: int = 30,
872
+ allowed_tools: Optional[List[str]] = None,
873
+ exclude_tools: Optional[List[str]] = None,
874
+ ) -> "MCPClient":
875
+ """
876
+ Create and connect MCP client in one step.
877
+
878
+ Args:
879
+ server_configs: List of server configuration dictionaries
880
+ timeout_seconds: Timeout for operations in seconds
881
+ allowed_tools: Optional list of tool names to include
882
+ exclude_tools: Optional list of tool names to exclude
883
+
884
+ Returns:
885
+ Connected MCPClient instance
886
+ """
887
+ client = cls(
888
+ server_configs,
889
+ timeout_seconds=timeout_seconds,
890
+ allowed_tools=allowed_tools,
891
+ exclude_tools=exclude_tools,
892
+ )
893
+ await client.connect()
894
+ return client