gobby 0.2.5__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.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,798 @@
1
+ """
2
+ Manager for multiple MCP client connections.
3
+
4
+ Supports lazy initialization where servers are connected on-demand
5
+ rather than at startup, reducing resource usage and startup time.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import time
11
+ from collections.abc import Callable, Coroutine
12
+ from typing import Any, cast
13
+
14
+ from mcp import ClientSession
15
+
16
+ from gobby.mcp_proxy.lazy import (
17
+ CircuitBreakerOpen,
18
+ LazyServerConnector,
19
+ RetryConfig,
20
+ )
21
+ from gobby.mcp_proxy.models import (
22
+ ConnectionState,
23
+ HealthState,
24
+ MCPConnectionHealth,
25
+ MCPError,
26
+ MCPServerConfig,
27
+ )
28
+ from gobby.mcp_proxy.transports.base import BaseTransportConnection
29
+ from gobby.mcp_proxy.transports.factory import create_transport_connection
30
+
31
+ # Alias for backward compatibility with tests
32
+ _create_transport_connection = create_transport_connection
33
+
34
+ __all__ = [
35
+ "MCPClientManager",
36
+ "MCPServerConfig",
37
+ "ConnectionState",
38
+ "HealthState",
39
+ "MCPConnectionHealth",
40
+ "MCPError",
41
+ ]
42
+
43
+ logger = logging.getLogger("gobby.mcp.manager")
44
+
45
+
46
+ class MCPClientManager:
47
+ """
48
+ Manages multiple MCP client connections with shared authentication.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ server_configs: list[MCPServerConfig] | None = None,
54
+ token_refresh_callback: Callable[[], Coroutine[Any, Any, str]] | None = None,
55
+ health_check_interval: float = 60.0,
56
+ external_id: str | None = None,
57
+ project_path: str | None = None,
58
+ project_id: str | None = None,
59
+ mcp_db_manager: Any | None = None,
60
+ lazy_connect: bool = True,
61
+ preconnect_servers: list[str] | None = None,
62
+ connection_timeout: float = 30.0,
63
+ max_connection_retries: int = 3,
64
+ metrics_manager: Any | None = None,
65
+ ):
66
+ """
67
+ Initialize manager.
68
+
69
+ Args:
70
+ server_configs: Initial list of server configurations
71
+ token_refresh_callback: Async callback that returns fresh auth token
72
+ health_check_interval: Seconds between health checks
73
+ external_id: Optional external ID (e.g. CLI key)
74
+ project_path: Optional project path
75
+ project_id: Optional project ID
76
+ mcp_db_manager: LocalMCPManager instance for database-backed server/tool storage.
77
+ When provided with project_id, loads servers from the database automatically.
78
+ lazy_connect: If True, defer connections until first use (default: True)
79
+ preconnect_servers: List of server names to connect eagerly even in lazy mode
80
+ connection_timeout: Timeout in seconds for connection attempts
81
+ max_connection_retries: Maximum retry attempts for failed connections
82
+ metrics_manager: ToolMetricsManager instance for recording call metrics
83
+ """
84
+ self._connections: dict[str, BaseTransportConnection] = {}
85
+ self._configs: dict[str, MCPServerConfig] = {}
86
+ # Changed to public health attribute to match tests
87
+ self.health: dict[str, MCPConnectionHealth] = {}
88
+ self._token_refresh_callback = token_refresh_callback
89
+ self._health_check_interval = health_check_interval
90
+ self._health_check_task: asyncio.Task[None] | None = None
91
+ self._reconnect_tasks: set[asyncio.Task[None]] = set()
92
+ self._auth_token: str | None = None
93
+ self._running = False
94
+ self.external_id = external_id
95
+ self.project_path = project_path
96
+ self.project_id = project_id
97
+ self.mcp_db_manager = mcp_db_manager
98
+ self.metrics_manager = metrics_manager
99
+
100
+ # Lazy connection settings
101
+ self.lazy_connect = lazy_connect
102
+ self.preconnect_servers = set(preconnect_servers or [])
103
+ self.connection_timeout = connection_timeout
104
+ self.max_connection_retries = max_connection_retries
105
+
106
+ # Initialize lazy connector with retry config
107
+ self._lazy_connector = LazyServerConnector(
108
+ retry_config=RetryConfig(max_retries=max_connection_retries),
109
+ )
110
+
111
+ # Load server configs from database if not provided explicitly
112
+ if server_configs is None and mcp_db_manager is not None:
113
+ if project_id:
114
+ # Load servers for specific project
115
+ db_servers = mcp_db_manager.list_servers(
116
+ project_id=project_id,
117
+ enabled_only=False,
118
+ )
119
+ else:
120
+ # Load all servers (daemon startup)
121
+ db_servers = mcp_db_manager.list_all_servers(enabled_only=False)
122
+
123
+ for s in db_servers:
124
+ config = MCPServerConfig(
125
+ name=s.name,
126
+ transport=s.transport,
127
+ url=s.url,
128
+ command=s.command,
129
+ args=s.args,
130
+ env=s.env,
131
+ headers=s.headers,
132
+ enabled=s.enabled,
133
+ description=s.description,
134
+ project_id=s.project_id,
135
+ tools=self._load_tools_from_db(mcp_db_manager, s.name, s.project_id),
136
+ )
137
+ self._configs[config.name] = config
138
+ # Register with lazy connector for deferred connection
139
+ self._lazy_connector.register_server(config.name)
140
+ logger.info(f"Loaded {len(self._configs)} MCP servers from database")
141
+ elif server_configs:
142
+ for config in server_configs:
143
+ self._configs[config.name] = config
144
+ # Register with lazy connector for deferred connection
145
+ self._lazy_connector.register_server(config.name)
146
+
147
+ @staticmethod
148
+ def _load_tools_from_db(
149
+ mcp_db_manager: Any, server_name: str, project_id: str
150
+ ) -> list[dict[str, str]] | None:
151
+ """
152
+ Load cached tools from database for a server.
153
+
154
+ Returns lightweight tool metadata for MCPServerConfig.tools field.
155
+ """
156
+ try:
157
+ tools = mcp_db_manager.get_cached_tools(server_name, project_id=project_id)
158
+ if not tools:
159
+ return None
160
+ return [
161
+ {
162
+ "name": tool.name,
163
+ "brief": (tool.description or "")[:100], # Truncate to brief
164
+ }
165
+ for tool in tools
166
+ ]
167
+ except Exception as e:
168
+ logger.warning(f"Failed to load cached tools for '{server_name}': {e}")
169
+ return None
170
+
171
+ @property
172
+ def connections(self) -> dict[str, BaseTransportConnection]:
173
+ """Get active connections."""
174
+ return self._connections
175
+
176
+ def list_connections(self) -> list[MCPServerConfig]:
177
+ """List active server connections."""
178
+ return [self._configs[name] for name in self._connections.keys()]
179
+
180
+ def get_available_servers(self) -> list[str]:
181
+ """Get list of available server names."""
182
+ return list(self._configs.keys())
183
+
184
+ def get_client(self, server_name: str) -> BaseTransportConnection:
185
+ """Get client connection by name."""
186
+ if server_name not in self._configs:
187
+ raise ValueError(f"Unknown MCP server: '{server_name}'")
188
+ if server_name in self._connections:
189
+ return self._connections[server_name]
190
+ raise ValueError(f"Client '{server_name}' not connected")
191
+
192
+ def has_server(self, server_name: str) -> bool:
193
+ """Check if server is configured and exists."""
194
+ return server_name in self._configs
195
+
196
+ async def add_server(self, config: MCPServerConfig) -> dict[str, Any]:
197
+ """Add and connect to a server."""
198
+ if config.name in self._configs:
199
+ raise ValueError(f"MCP server '{config.name}' already exists")
200
+
201
+ self._configs[config.name] = config
202
+
203
+ # Persist to database if manager is available
204
+ if self.mcp_db_manager and config.project_id:
205
+ self.mcp_db_manager.upsert(
206
+ name=config.name,
207
+ transport=config.transport,
208
+ project_id=config.project_id,
209
+ url=config.url,
210
+ command=config.command,
211
+ args=config.args,
212
+ env=config.env,
213
+ headers=config.headers,
214
+ enabled=config.enabled,
215
+ description=config.description,
216
+ )
217
+
218
+ tool_schemas: list[dict[str, Any]] = []
219
+ # Attempt connect
220
+ if config.enabled:
221
+ session = await self._connect_server(config)
222
+ if session:
223
+ try:
224
+ tools_result = await session.list_tools()
225
+ # Convert Tool objects to dicts
226
+ for t in tools_result.tools:
227
+ tool_schemas.append(
228
+ {
229
+ "name": t.name,
230
+ "description": getattr(t, "description", "") or "",
231
+ "inputSchema": getattr(t, "inputSchema", {}) or {},
232
+ }
233
+ )
234
+ except Exception as e:
235
+ logger.warning(f"Failed to list tools for {config.name}: {e}")
236
+
237
+ return {
238
+ "success": True,
239
+ "name": config.name,
240
+ "full_tool_schemas": tool_schemas,
241
+ }
242
+
243
+ async def remove_server(self, name: str, project_id: str | None = None) -> dict[str, Any]:
244
+ """Remove a server."""
245
+ if name not in self._configs:
246
+ raise ValueError(f"MCP server '{name}' not found")
247
+
248
+ # Get project_id from config if not provided
249
+ config = self._configs[name]
250
+ effective_project_id = project_id or config.project_id
251
+
252
+ # Disconnect
253
+ if name in self._connections:
254
+ await self._connections[name].disconnect()
255
+ del self._connections[name]
256
+
257
+ del self._configs[name]
258
+ if name in self.health:
259
+ del self.health[name]
260
+
261
+ # Remove from database if manager is available
262
+ if self.mcp_db_manager and effective_project_id:
263
+ self.mcp_db_manager.remove_server(name, effective_project_id)
264
+
265
+ return {"success": True, "name": name}
266
+
267
+ async def get_health_report(self) -> dict[str, Any]:
268
+ """Get async health report."""
269
+ return self.get_server_health()
270
+
271
+ @property
272
+ def server_configs(self) -> list[MCPServerConfig]:
273
+ """Get all server configurations."""
274
+ return list(self._configs.values())
275
+
276
+ async def connect_all(self, configs: list[MCPServerConfig] | None = None) -> dict[str, bool]:
277
+ """
278
+ Connect to multiple MCP servers.
279
+
280
+ In lazy mode (default), only connects servers in preconnect_servers list.
281
+ In eager mode (lazy_connect=False), connects all enabled servers.
282
+
283
+ Args:
284
+ configs: List of server configurations. If None, uses registered configs.
285
+
286
+ Returns:
287
+ Dict mapping server names to success status
288
+ """
289
+ self._running = True
290
+ results = {}
291
+
292
+ configs_to_connect = configs if configs is not None else self.server_configs
293
+
294
+ # Store configs if provided
295
+ if configs:
296
+ for config in configs:
297
+ self._configs[config.name] = config
298
+ self._lazy_connector.register_server(config.name)
299
+
300
+ # Initialize health tracking for all configs
301
+ for config in self.server_configs:
302
+ if config.name not in self.health:
303
+ self.health[config.name] = MCPConnectionHealth(
304
+ name=config.name,
305
+ state=ConnectionState.DISCONNECTED,
306
+ )
307
+
308
+ # Start health check task if not running
309
+ if self._health_check_task is None:
310
+ self._health_check_task = asyncio.create_task(self._monitor_health())
311
+
312
+ # In lazy mode, only connect preconnect servers
313
+ if self.lazy_connect:
314
+ configs_to_connect = [
315
+ c for c in configs_to_connect if c.name in self.preconnect_servers
316
+ ]
317
+ if configs_to_connect:
318
+ logger.info(
319
+ f"Lazy mode: preconnecting {len(configs_to_connect)} servers "
320
+ f"({', '.join(c.name for c in configs_to_connect)})"
321
+ )
322
+ else:
323
+ logger.info(
324
+ f"Lazy mode: no preconnect servers configured, "
325
+ f"{len(self._configs)} servers available on-demand"
326
+ )
327
+
328
+ # Connect concurrently
329
+ connect_tasks = []
330
+ bound_configs = []
331
+ for config in configs_to_connect:
332
+ if not config.enabled:
333
+ logger.debug(f"Skipping disabled server: {config.name}")
334
+ results[config.name] = False
335
+ continue
336
+
337
+ task = asyncio.create_task(self._connect_server(config))
338
+ connect_tasks.append(task)
339
+ bound_configs.append(config)
340
+
341
+ if not connect_tasks:
342
+ return results
343
+
344
+ task_results = await asyncio.gather(*connect_tasks, return_exceptions=True)
345
+
346
+ for config, result in zip(bound_configs, task_results, strict=False):
347
+ if isinstance(result, Exception):
348
+ logger.error(f"Failed to connect to {config.name}: {result}")
349
+ results[config.name] = False
350
+ else:
351
+ results[config.name] = bool(result)
352
+ if result:
353
+ self._lazy_connector.mark_connected(config.name)
354
+
355
+ return results
356
+
357
+ def get_lazy_connection_states(self) -> dict[str, dict[str, Any]]:
358
+ """
359
+ Get lazy connection states for all registered servers.
360
+
361
+ Returns:
362
+ Dict mapping server names to connection state info including:
363
+ - is_connected: Whether server is connected
364
+ - configured_at: When server was configured
365
+ - connected_at: When server was connected (if connected)
366
+ - last_error: Last error message (if any)
367
+ - circuit_state: Circuit breaker state (closed/open/half_open)
368
+ """
369
+ return self._lazy_connector.get_all_states()
370
+
371
+ async def health_check_all(self) -> dict[str, Any]:
372
+ """Perform immediate health check on all connections."""
373
+ tasks = []
374
+ server_names = []
375
+
376
+ for name, connection in self._connections.items():
377
+ if connection.is_connected:
378
+ tasks.append(connection.health_check(timeout=5.0))
379
+ server_names.append(name)
380
+
381
+ if not tasks:
382
+ return {}
383
+
384
+ results = await asyncio.gather(*tasks, return_exceptions=True)
385
+
386
+ health_status = {}
387
+ for name, result in zip(server_names, results, strict=False):
388
+ if isinstance(result, Exception) or result is False:
389
+ self.health[name].record_failure("Health check failed")
390
+ health_status[name] = False
391
+ else:
392
+ self.health[name].record_success()
393
+ health_status[name] = True
394
+
395
+ return health_status
396
+
397
+ async def _connect_server(self, config: MCPServerConfig) -> ClientSession | None:
398
+ """Connect to a single server."""
399
+ # Ensure health record exists before we try to update it
400
+ if config.name not in self.health:
401
+ self.health[config.name] = MCPConnectionHealth(
402
+ name=config.name, state=ConnectionState.DISCONNECTED
403
+ )
404
+
405
+ try:
406
+ # Create transport if doesn't exist or if config changed
407
+ # (Simplification: always recreate for now if not connected)
408
+ if config.name not in self._connections:
409
+ connection = create_transport_connection(
410
+ config,
411
+ self._auth_token,
412
+ self._token_refresh_callback,
413
+ )
414
+ self._connections[config.name] = connection
415
+
416
+ connection = self._connections[config.name]
417
+
418
+ # Update health state
419
+ self.health[config.name].state = ConnectionState.CONNECTING
420
+
421
+ session = await connection.connect()
422
+
423
+ # Update health state
424
+ self.health[config.name].state = ConnectionState.CONNECTED
425
+ self.health[config.name].record_success()
426
+
427
+ return cast(ClientSession | None, session)
428
+
429
+ except Exception as e:
430
+ self.health[config.name].state = ConnectionState.FAILED
431
+ self.health[config.name].record_failure(str(e))
432
+ raise
433
+
434
+ async def disconnect_all(self) -> None:
435
+ """Disconnect all active connections."""
436
+ self._running = False
437
+
438
+ if self._health_check_task:
439
+ self._health_check_task.cancel()
440
+ try:
441
+ await self._health_check_task
442
+ except asyncio.CancelledError:
443
+ pass
444
+ self._health_check_task = None
445
+
446
+ # Cancel any pending reconnect tasks
447
+ for task in list(self._reconnect_tasks):
448
+ task.cancel()
449
+ if self._reconnect_tasks:
450
+ await asyncio.gather(*self._reconnect_tasks, return_exceptions=True)
451
+ self._reconnect_tasks.clear()
452
+
453
+ async def disconnect_with_timeout(name: str, connection: Any) -> None:
454
+ try:
455
+ await asyncio.wait_for(connection.disconnect(), timeout=5.0)
456
+ except TimeoutError:
457
+ logger.warning(f"Connection disconnect timed out for {name}")
458
+ except Exception as e:
459
+ logger.warning(f"Error disconnecting {name}: {e}")
460
+
461
+ tasks = []
462
+ for name, connection in self._connections.items():
463
+ if connection.is_connected:
464
+ tasks.append(asyncio.create_task(disconnect_with_timeout(name, connection)))
465
+ self.health[name].state = ConnectionState.DISCONNECTED
466
+
467
+ if tasks:
468
+ await asyncio.gather(*tasks, return_exceptions=True)
469
+
470
+ self._connections.clear()
471
+
472
+ async def ensure_connected(self, server_name: str) -> ClientSession:
473
+ """
474
+ Ensure a server is connected, connecting lazily if needed.
475
+
476
+ This is the main entry point for lazy connection. It handles:
477
+ - First-time connection for unconfigured servers
478
+ - Reconnection for disconnected servers
479
+ - Circuit breaker protection against repeated failures
480
+ - Exponential backoff retry on connection failure
481
+
482
+ Args:
483
+ server_name: Name of server to connect
484
+
485
+ Returns:
486
+ Active ClientSession for the server
487
+
488
+ Raises:
489
+ KeyError: If server not configured
490
+ CircuitBreakerOpen: If circuit breaker is open (too many failures)
491
+ MCPError: If connection fails after retries
492
+ """
493
+ if server_name not in self._configs:
494
+ raise KeyError(f"Server '{server_name}' not configured")
495
+
496
+ config = self._configs[server_name]
497
+
498
+ # Check if server is disabled
499
+ if not config.enabled:
500
+ raise MCPError(f"Server '{server_name}' is disabled")
501
+
502
+ # Check if already connected
503
+ if server_name in self._connections:
504
+ connection = self._connections[server_name]
505
+ if connection.is_connected and connection.session:
506
+ return connection.session
507
+
508
+ # Check circuit breaker
509
+ if not self._lazy_connector.can_attempt_connection(server_name):
510
+ state = self._lazy_connector.get_state(server_name)
511
+ if state and state.circuit_breaker.last_failure_time:
512
+ elapsed = time.time() - state.circuit_breaker.last_failure_time
513
+ recovery_in = max(0, state.circuit_breaker.recovery_timeout - elapsed)
514
+ raise CircuitBreakerOpen(server_name, recovery_in)
515
+ raise MCPError(f"Circuit breaker open for '{server_name}'")
516
+
517
+ # Use lock to prevent concurrent connection attempts
518
+ async with self._lazy_connector.get_connection_lock(server_name):
519
+ # Double-check after acquiring lock
520
+ if server_name in self._connections:
521
+ connection = self._connections[server_name]
522
+ if connection.is_connected and connection.session:
523
+ return connection.session
524
+
525
+ # Attempt connection with retry
526
+ retry_config = self._lazy_connector.retry_config
527
+ last_error: Exception | None = None
528
+
529
+ for attempt in range(retry_config.max_retries + 1):
530
+ try:
531
+ state = self._lazy_connector.get_state(server_name)
532
+ if state:
533
+ state.record_connection_attempt()
534
+
535
+ session = await asyncio.wait_for(
536
+ self._connect_server(config),
537
+ timeout=self.connection_timeout,
538
+ )
539
+
540
+ if session:
541
+ self._lazy_connector.mark_connected(server_name)
542
+ return session
543
+ else:
544
+ raise MCPError(f"Connection returned no session for '{server_name}'")
545
+
546
+ except TimeoutError:
547
+ last_error = MCPError(f"Connection timeout after {self.connection_timeout}s")
548
+ self._lazy_connector.mark_failed(server_name, str(last_error))
549
+ except Exception as e:
550
+ last_error = e
551
+ self._lazy_connector.mark_failed(server_name, str(e))
552
+
553
+ # If not last attempt, wait with exponential backoff
554
+ if attempt < retry_config.max_retries:
555
+ delay = retry_config.get_delay(attempt)
556
+ logger.warning(
557
+ f"Connection to '{server_name}' failed (attempt {attempt + 1}/"
558
+ f"{retry_config.max_retries + 1}), retrying in {delay:.1f}s: {last_error}"
559
+ )
560
+ await asyncio.sleep(delay)
561
+
562
+ # All retries exhausted
563
+ raise MCPError(
564
+ f"Failed to connect to '{server_name}' after "
565
+ f"{retry_config.max_retries + 1} attempts: {last_error}"
566
+ ) from last_error
567
+
568
+ async def get_client_session(self, server_name: str) -> ClientSession:
569
+ """
570
+ Get active MCP client session for server, connecting lazily if needed.
571
+
572
+ Args:
573
+ server_name: Name of server
574
+
575
+ Returns:
576
+ Active ClientSession
577
+
578
+ Raises:
579
+ KeyError: If server not configured
580
+ MCPError: If not connected and connection fails
581
+ """
582
+ # Use ensure_connected for lazy connection
583
+ return await self.ensure_connected(server_name)
584
+
585
+ async def call_tool(
586
+ self,
587
+ server_name: str,
588
+ tool_name: str,
589
+ arguments: dict[str, Any] | None = None,
590
+ timeout: float | None = None,
591
+ ) -> Any:
592
+ """Call a tool on a specific server."""
593
+ start_time = time.perf_counter()
594
+ success = False
595
+ try:
596
+ session = await self.get_client_session(server_name)
597
+ if timeout:
598
+ result = await asyncio.wait_for(
599
+ session.call_tool(tool_name, arguments or {}), timeout=timeout
600
+ )
601
+ else:
602
+ result = await session.call_tool(tool_name, arguments or {})
603
+ self.health[server_name].record_success()
604
+ success = True
605
+ return result
606
+ except Exception as e:
607
+ if server_name in self.health:
608
+ self.health[server_name].record_failure(str(e))
609
+ raise
610
+ finally:
611
+ # Record metrics if manager is configured
612
+ if self.metrics_manager:
613
+ latency_ms = (time.perf_counter() - start_time) * 1000
614
+ # Get project_id from server config (servers are project-scoped)
615
+ server_config = self._configs.get(server_name)
616
+ metrics_project_id = server_config.project_id if server_config else self.project_id
617
+ if metrics_project_id:
618
+ try:
619
+ self.metrics_manager.record_call(
620
+ server_name=server_name,
621
+ tool_name=tool_name,
622
+ project_id=metrics_project_id,
623
+ latency_ms=latency_ms,
624
+ success=success,
625
+ )
626
+ except Exception:
627
+ # Don't let metrics recording failures affect tool calls
628
+ logger.debug(f"Failed to record metrics for {server_name}.{tool_name}")
629
+
630
+ async def read_resource(self, server_name: str, uri: str) -> Any:
631
+ """Read a resource from a specific server."""
632
+ try:
633
+ session = await self.get_client_session(server_name)
634
+ # Ensure uri is string and cast for type checker if needed,
635
+ # though runtime usually handles string -> AnyUrl coercion in pydantic
636
+ result = await session.read_resource(cast(Any, str(uri)))
637
+ self.health[server_name].record_success()
638
+ return result
639
+ except Exception as e:
640
+ if server_name in self.health:
641
+ self.health[server_name].record_failure(str(e))
642
+ raise
643
+
644
+ async def list_tools(self, server_name: str | None = None) -> dict[str, list[dict[str, Any]]]:
645
+ """
646
+ List tools from one or all servers.
647
+
648
+ Args:
649
+ server_name: Optional single server name
650
+
651
+ Returns:
652
+ Dict mapping server names to tool lists
653
+ """
654
+ results = {}
655
+ servers = [server_name] if server_name else self._connections.keys()
656
+
657
+ for name in servers:
658
+ try:
659
+ session = await self.get_client_session(name)
660
+ tools = await session.list_tools()
661
+ # Assuming tools is a ListToolsResult or similar Pydantic model
662
+ # We need to serialize it or return it as is.
663
+ # Inspecting mcp-python-sdk, list_tools returns ListToolsResult.
664
+ # Let's return the raw object or access .tools
665
+ if hasattr(tools, "tools"):
666
+ results[name] = [
667
+ {
668
+ "name": t.name,
669
+ "description": getattr(t, "description", "") or "",
670
+ "inputSchema": getattr(t, "inputSchema", {}) or {},
671
+ }
672
+ for t in tools.tools
673
+ ]
674
+ else:
675
+ results[name] = []
676
+
677
+ self.health[name].record_success()
678
+ except Exception as e:
679
+ logger.warning(f"Failed to list tools for {name}: {e}")
680
+ self.health[name].record_failure(str(e))
681
+ results[name] = []
682
+
683
+ return results
684
+
685
+ async def get_tool_input_schema(self, server_name: str, tool_name: str) -> dict[str, Any]:
686
+ """Get full inputSchema for a specific tool."""
687
+
688
+ # This is an optimization. Instead of calling list_tools again,
689
+ # we try to fetch it. But standard MCP list_tools returns everything.
690
+ # So we just filter the output of list_tools.
691
+
692
+ tools = await self.list_tools(server_name)
693
+ server_tools = tools.get(server_name, [])
694
+
695
+ for tool in server_tools:
696
+ # tool might be an object or dict
697
+ t_name = getattr(tool, "name", tool.get("name") if isinstance(tool, dict) else None)
698
+ if t_name == tool_name:
699
+ # Return schema
700
+ if isinstance(tool, dict) and "inputSchema" in tool:
701
+ return cast(dict[str, Any], tool["inputSchema"])
702
+
703
+ raise MCPError(f"Tool {tool_name} not found on server {server_name}")
704
+
705
+ async def _monitor_health(self) -> None:
706
+ """Background task to monitor connection health."""
707
+ while self._running:
708
+ try:
709
+ await asyncio.sleep(self._health_check_interval)
710
+
711
+ tasks = []
712
+ server_names = []
713
+
714
+ for name, connection in self._connections.items():
715
+ if connection.is_connected:
716
+ tasks.append(connection.health_check(timeout=5.0))
717
+ server_names.append(name)
718
+
719
+ if not tasks:
720
+ continue
721
+
722
+ results = await asyncio.gather(*tasks, return_exceptions=True)
723
+
724
+ for name, result in zip(server_names, results, strict=False):
725
+ if isinstance(result, Exception) or result is False:
726
+ # Health check failed
727
+ self.health[name].record_failure("Health check failed")
728
+ logger.warning(f"Health check failed for {name}")
729
+
730
+ # Trigger reconnect if critical
731
+ if self.health[name].health == HealthState.UNHEALTHY:
732
+ logger.info(f"Attempting reconnection for unhealthy server: {name}")
733
+ task = asyncio.create_task(self._reconnect(name))
734
+ self._reconnect_tasks.add(task)
735
+ task.add_done_callback(self._reconnect_tasks.discard)
736
+ else:
737
+ self.health[name].record_success()
738
+
739
+ except asyncio.CancelledError:
740
+ break
741
+ except Exception as e:
742
+ logger.error(f"Error in health monitor: {e}")
743
+
744
+ async def _reconnect(self, server_name: str) -> None:
745
+ """Attempt to reconnect a server."""
746
+ if server_name not in self._configs:
747
+ return
748
+
749
+ config = self._configs[server_name]
750
+ try:
751
+ logger.info(f"Reconnecting {server_name}...")
752
+ await self._connect_server(config)
753
+ logger.info(f"Successfully reconnected {server_name}")
754
+ except Exception as e:
755
+ logger.error(f"Reconnection failed for {server_name}: {e}")
756
+
757
+ def get_server_health(self) -> dict[str, dict[str, Any]]:
758
+ """Get health status for all servers."""
759
+ return {
760
+ name: {
761
+ "state": status.state.value,
762
+ "health": status.health.value,
763
+ "last_check": (
764
+ status.last_health_check.isoformat() if status.last_health_check else None
765
+ ),
766
+ "failures": status.consecutive_failures,
767
+ "response_time_ms": status.response_time_ms,
768
+ }
769
+ for name, status in self.health.items()
770
+ }
771
+
772
+ def add_server_config(self, config: MCPServerConfig) -> None:
773
+ """Register a new server configuration."""
774
+ self._configs[config.name] = config
775
+ if config.name not in self.health:
776
+ self.health[config.name] = MCPConnectionHealth(
777
+ name=config.name, state=ConnectionState.DISCONNECTED
778
+ )
779
+
780
+ def remove_server_config(self, name: str) -> None:
781
+ """Remove a server configuration.
782
+
783
+ Raises RuntimeError if a connection exists for the server,
784
+ forcing callers to disconnect first.
785
+ """
786
+ if name in self._connections:
787
+ # Raise instead of async cleanup to keep this method synchronous.
788
+ # Callers must explicitly disconnect before removing config.
789
+ logger.warning(
790
+ f"Removing config for '{name}' but connection still exists. "
791
+ "You should disconnect the server first."
792
+ )
793
+ raise RuntimeError(
794
+ f"Cannot remove config for connected server '{name}'. Disconnect it first."
795
+ )
796
+
797
+ if name in self._configs:
798
+ del self._configs[name]