atlas-chat 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.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,512 @@
1
+ """Tests for MCP hot reload and auto-reconnect functionality."""
2
+
3
+ import time
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+ from starlette.testclient import TestClient
8
+
9
+ from atlas.modules.mcp_tools.client import MCPToolManager
10
+
11
+
12
+ class TestMCPAdminEndpoints:
13
+ """Integration tests for MCP admin endpoints."""
14
+
15
+ def test_mcp_status_endpoint_requires_admin(self):
16
+ """Test that MCP status endpoint requires admin access."""
17
+ from main import app
18
+ client = TestClient(app)
19
+
20
+ # Non-admin user should be denied
21
+ r = client.get("/admin/mcp/status", headers={"X-User-Email": "user@example.com"})
22
+ assert r.status_code in (302, 403)
23
+
24
+ def test_mcp_status_endpoint_returns_data(self):
25
+ """Test that MCP status endpoint returns expected data structure."""
26
+ from main import app
27
+ client = TestClient(app)
28
+
29
+ # Admin user should get response
30
+ r = client.get("/admin/mcp/status", headers={"X-User-Email": "admin@example.com"})
31
+ assert r.status_code == 200
32
+
33
+ data = r.json()
34
+ assert "connected_servers" in data
35
+ assert "configured_servers" in data
36
+ assert "failed_servers" in data
37
+ assert "auto_reconnect" in data
38
+ assert "tool_counts" in data
39
+ assert "prompt_counts" in data
40
+
41
+ # Check auto_reconnect structure
42
+ auto_reconnect = data["auto_reconnect"]
43
+ assert "enabled" in auto_reconnect
44
+ assert "base_interval" in auto_reconnect
45
+ assert "max_interval" in auto_reconnect
46
+ assert "backoff_multiplier" in auto_reconnect
47
+ assert "running" in auto_reconnect
48
+
49
+ def test_mcp_status_marks_failed_servers_not_connected(self):
50
+ """Servers with recorded failures should not appear as connected."""
51
+ from main import app
52
+ client = TestClient(app)
53
+
54
+ # Seed a fake failure in the MCP manager
55
+ from atlas.infrastructure.app_factory import app_factory
56
+ mcp = app_factory.get_mcp_manager()
57
+ mcp._failed_servers["failing-server"] = {
58
+ "last_attempt": time.time(),
59
+ "attempt_count": 1,
60
+ "error": "Simulated failure",
61
+ }
62
+ mcp.clients["failing-server"] = AsyncMock()
63
+ mcp.available_tools["failing-server"] = {"tools": [], "config": {}}
64
+ mcp.available_prompts["failing-server"] = {"prompts": [], "config": {}}
65
+
66
+ r = client.get("/admin/mcp/status", headers={"X-User-Email": "admin@example.com"})
67
+ assert r.status_code == 200
68
+
69
+ data = r.json()
70
+ assert "failing-server" not in data["connected_servers"]
71
+
72
+ def test_mcp_reload_endpoint_requires_admin(self):
73
+ """Test that MCP reload endpoint requires admin access."""
74
+ from main import app
75
+ client = TestClient(app)
76
+
77
+ # Non-admin user should be denied
78
+ r = client.post("/admin/mcp/reload", headers={"X-User-Email": "user@example.com"})
79
+ assert r.status_code in (302, 403)
80
+
81
+ def test_mcp_reconnect_endpoint_requires_admin(self):
82
+ """Test that MCP reconnect endpoint requires admin access."""
83
+ from main import app
84
+ client = TestClient(app)
85
+
86
+ # Non-admin user should be denied
87
+ r = client.post("/admin/mcp/reconnect", headers={"X-User-Email": "user@example.com"})
88
+ assert r.status_code in (302, 403)
89
+
90
+ def test_mcp_reconnect_endpoint_returns_data(self):
91
+ """Test that MCP reconnect endpoint returns expected data structure."""
92
+ from main import app
93
+ client = TestClient(app)
94
+
95
+ # Admin user should get response
96
+ r = client.post("/admin/mcp/reconnect", headers={"X-User-Email": "admin@example.com"})
97
+ assert r.status_code == 200
98
+
99
+ data = r.json()
100
+ assert "message" in data
101
+ assert "result" in data
102
+ assert "current_servers" in data
103
+ assert "failed_servers" in data
104
+ assert "triggered_by" in data
105
+
106
+ def test_admin_dashboard_includes_mcp_endpoints(self):
107
+ """Test that admin dashboard lists MCP endpoints."""
108
+ from main import app
109
+ client = TestClient(app)
110
+
111
+ r = client.get("/admin/", headers={"X-User-Email": "admin@example.com"})
112
+ assert r.status_code == 200
113
+
114
+ data = r.json()
115
+ endpoints = data.get("available_endpoints", [])
116
+ assert "/admin/mcp/reload" in endpoints
117
+ assert "/admin/mcp/reconnect" in endpoints
118
+ assert "/admin/mcp/status" in endpoints
119
+
120
+
121
+ class TestMCPFailedServerTracking:
122
+ """Tests for tracking failed MCP server connections."""
123
+
124
+ def test_record_server_failure_new_server(self):
125
+ """Test recording first failure for a server."""
126
+ manager = MCPToolManager.__new__(MCPToolManager)
127
+ manager._failed_servers = {}
128
+
129
+ manager._record_server_failure("test-server", "Connection refused")
130
+
131
+ assert "test-server" in manager._failed_servers
132
+ assert manager._failed_servers["test-server"]["attempt_count"] == 1
133
+ assert manager._failed_servers["test-server"]["error"] == "Connection refused"
134
+ assert "last_attempt" in manager._failed_servers["test-server"]
135
+
136
+ def test_record_server_failure_existing_server(self):
137
+ """Test recording additional failures for an already-failed server."""
138
+ manager = MCPToolManager.__new__(MCPToolManager)
139
+ initial_time = time.time() - 100
140
+ manager._failed_servers = {
141
+ "test-server": {
142
+ "last_attempt": initial_time,
143
+ "attempt_count": 2,
144
+ "error": "Old error"
145
+ }
146
+ }
147
+
148
+ manager._record_server_failure("test-server", "New error")
149
+
150
+ assert manager._failed_servers["test-server"]["attempt_count"] == 3
151
+ assert manager._failed_servers["test-server"]["error"] == "New error"
152
+ assert manager._failed_servers["test-server"]["last_attempt"] > initial_time
153
+
154
+ def test_clear_server_failure(self):
155
+ """Test clearing failure tracking after successful connection."""
156
+ manager = MCPToolManager.__new__(MCPToolManager)
157
+ manager._failed_servers = {
158
+ "test-server": {
159
+ "last_attempt": time.time(),
160
+ "attempt_count": 3,
161
+ "error": "Some error"
162
+ }
163
+ }
164
+
165
+ manager._clear_server_failure("test-server")
166
+
167
+ assert "test-server" not in manager._failed_servers
168
+
169
+ def test_clear_server_failure_nonexistent(self):
170
+ """Test clearing a server that wasn't tracked (should not error)."""
171
+ manager = MCPToolManager.__new__(MCPToolManager)
172
+ manager._failed_servers = {}
173
+
174
+ # Should not raise any exception
175
+ manager._clear_server_failure("nonexistent-server")
176
+
177
+ assert "nonexistent-server" not in manager._failed_servers
178
+
179
+ def test_get_failed_servers(self):
180
+ """Test getting failed servers info."""
181
+ manager = MCPToolManager.__new__(MCPToolManager)
182
+ manager._failed_servers = {
183
+ "server1": {"attempt_count": 1, "error": "Error 1"},
184
+ "server2": {"attempt_count": 3, "error": "Error 2"}
185
+ }
186
+
187
+ result = manager.get_failed_servers()
188
+
189
+ assert result == manager._failed_servers
190
+ # Verify it returns a copy, not the original dict
191
+ assert result is not manager._failed_servers
192
+
193
+
194
+ class TestMCPBackoffCalculation:
195
+ """Tests for exponential backoff calculation."""
196
+
197
+ @patch('atlas.modules.mcp_tools.client.config_manager')
198
+ def test_calculate_backoff_first_attempt(self, mock_config_manager):
199
+ """Test backoff calculation for first retry attempt."""
200
+ mock_settings = MagicMock()
201
+ mock_settings.mcp_reconnect_interval = 60
202
+ mock_settings.mcp_reconnect_max_interval = 300
203
+ mock_settings.mcp_reconnect_backoff_multiplier = 2.0
204
+ mock_config_manager.app_settings = mock_settings
205
+
206
+ manager = MCPToolManager.__new__(MCPToolManager)
207
+
208
+ delay = manager._calculate_backoff_delay(1)
209
+
210
+ assert delay == 60 # Base interval for first attempt
211
+
212
+ @patch('atlas.modules.mcp_tools.client.config_manager')
213
+ def test_calculate_backoff_exponential(self, mock_config_manager):
214
+ """Test exponential backoff for subsequent attempts."""
215
+ mock_settings = MagicMock()
216
+ mock_settings.mcp_reconnect_interval = 60
217
+ mock_settings.mcp_reconnect_max_interval = 300
218
+ mock_settings.mcp_reconnect_backoff_multiplier = 2.0
219
+ mock_config_manager.app_settings = mock_settings
220
+
221
+ manager = MCPToolManager.__new__(MCPToolManager)
222
+
223
+ # Second attempt: 60 * 2^1 = 120
224
+ assert manager._calculate_backoff_delay(2) == 120
225
+ # Third attempt: 60 * 2^2 = 240
226
+ assert manager._calculate_backoff_delay(3) == 240
227
+ # Fourth attempt: 60 * 2^3 = 480, but capped at 300
228
+ assert manager._calculate_backoff_delay(4) == 300
229
+
230
+ @patch('atlas.modules.mcp_tools.client.config_manager')
231
+ def test_calculate_backoff_max_cap(self, mock_config_manager):
232
+ """Test that backoff is capped at max_interval."""
233
+ mock_settings = MagicMock()
234
+ mock_settings.mcp_reconnect_interval = 60
235
+ mock_settings.mcp_reconnect_max_interval = 300
236
+ mock_settings.mcp_reconnect_backoff_multiplier = 2.0
237
+ mock_config_manager.app_settings = mock_settings
238
+
239
+ manager = MCPToolManager.__new__(MCPToolManager)
240
+
241
+ # Very high attempt count should still be capped
242
+ delay = manager._calculate_backoff_delay(10)
243
+
244
+ assert delay == 300
245
+
246
+
247
+ class TestMCPConfigReload:
248
+ """Tests for MCP configuration hot-reload."""
249
+
250
+ @patch('atlas.modules.mcp_tools.client.config_manager')
251
+ def test_reload_config_updates_servers(self, mock_config_manager):
252
+ """Test that reload_config updates server configuration."""
253
+ manager = MCPToolManager.__new__(MCPToolManager)
254
+ manager.servers_config = {"old-server": {"description": "Old"}}
255
+ manager._failed_servers = {"old-server": {"attempt_count": 1}}
256
+
257
+ # Mock new config
258
+ mock_new_config = MagicMock()
259
+ mock_server = MagicMock()
260
+ mock_server.model_dump.return_value = {"description": "New"}
261
+ mock_new_config.servers = {"new-server": mock_server}
262
+ mock_config_manager.reload_mcp_config.return_value = mock_new_config
263
+
264
+ result = manager.reload_config()
265
+
266
+ assert "old-server" in result["removed"]
267
+ assert "new-server" in result["added"]
268
+ assert manager.servers_config == {"new-server": {"description": "New"}}
269
+ # Old failed server tracking should be cleared
270
+ assert "old-server" not in manager._failed_servers
271
+
272
+ @patch('atlas.modules.mcp_tools.client.config_manager')
273
+ def test_reload_config_preserves_unchanged(self, mock_config_manager):
274
+ """Test that reload_config identifies unchanged servers."""
275
+ manager = MCPToolManager.__new__(MCPToolManager)
276
+ manager.servers_config = {"existing-server": {"description": "Existing"}}
277
+ manager._failed_servers = {}
278
+
279
+ # Mock config with same server
280
+ mock_new_config = MagicMock()
281
+ mock_server = MagicMock()
282
+ mock_server.model_dump.return_value = {"description": "Updated"}
283
+ mock_new_config.servers = {"existing-server": mock_server}
284
+ mock_config_manager.reload_mcp_config.return_value = mock_new_config
285
+
286
+ result = manager.reload_config()
287
+
288
+ assert "existing-server" in result["unchanged"]
289
+ assert result["added"] == []
290
+ assert result["removed"] == []
291
+
292
+
293
+ @pytest.mark.asyncio
294
+ class TestMCPDiscoveryResilience:
295
+ """Tests to ensure tool/prompt discovery tolerates removed servers."""
296
+
297
+ async def test_discover_tools_skips_removed_server(self):
298
+ """If a client exists without config, discovery should not crash."""
299
+ manager = MCPToolManager.__new__(MCPToolManager)
300
+ # Simulate one valid server and one removed server
301
+ manager.servers_config = {"server-a": {"description": "A"}}
302
+ manager._failed_servers = {}
303
+ manager.clients = {
304
+ "server-a": AsyncMock(),
305
+ "removed-server": AsyncMock(),
306
+ }
307
+
308
+ async def fake_discover(server_name, client): # noqa: ARG001
309
+ if server_name == "server-a":
310
+ return {"tools": [MagicMock(name="t1")], "config": {"description": "A"}}
311
+ # Simulate that removed-server had a client but config was deleted
312
+ raise RuntimeError("Simulated failure for removed-server")
313
+
314
+ manager._discover_tools_for_server = fake_discover # type: ignore[assignment]
315
+
316
+ # Should complete without raising, and only include server-a in available_tools
317
+ await manager.discover_tools()
318
+ assert "server-a" in manager.available_tools
319
+ assert "removed-server" not in manager.available_tools
320
+
321
+ @pytest.mark.asyncio
322
+ async def test_discover_prompts_skips_removed_server(self):
323
+ """Prompt discovery should also skip servers missing from config."""
324
+ manager = MCPToolManager.__new__(MCPToolManager)
325
+ manager.servers_config = {"server-a": {"description": "A"}}
326
+ manager._failed_servers = {}
327
+ manager.clients = {
328
+ "server-a": AsyncMock(),
329
+ "removed-server": AsyncMock(),
330
+ }
331
+
332
+ async def fake_discover_prompts(server_name, client): # noqa: ARG001
333
+ if server_name == "server-a":
334
+ return {"prompts": [MagicMock(name="p1")], "config": {"description": "A"}}
335
+ raise RuntimeError("Simulated failure for removed-server")
336
+
337
+ manager._discover_prompts_for_server = fake_discover_prompts # type: ignore[assignment]
338
+
339
+ await manager.discover_prompts()
340
+ assert "server-a" in manager.available_prompts
341
+ assert "removed-server" not in manager.available_prompts
342
+
343
+
344
+ @pytest.mark.asyncio
345
+ class TestMCPDiscoveryFailureTracking:
346
+ """Tests for tracking discovery failures so admin status can reflect them."""
347
+
348
+ async def test_tool_discovery_failure_records_failed_server(self):
349
+ """Tool discovery exception should record server in _failed_servers."""
350
+ manager = MCPToolManager.__new__(MCPToolManager)
351
+ manager.servers_config = {"bad-server": {"description": "Bad"}}
352
+ manager.clients = {"bad-server": AsyncMock()}
353
+ manager._failed_servers = {}
354
+
355
+ async def failing_discover(server_name, client): # noqa: ARG001
356
+ raise RuntimeError("Simulated tool discovery failure")
357
+
358
+ manager._discover_tools_for_server = failing_discover # type: ignore[assignment]
359
+
360
+ await manager.discover_tools()
361
+
362
+ assert "bad-server" in manager._failed_servers
363
+ error = manager._failed_servers["bad-server"]["error"]
364
+ assert "Simulated tool discovery failure" in error
365
+
366
+ async def test_prompt_discovery_failure_records_failed_server(self):
367
+ """Prompt discovery exception should record server in _failed_servers."""
368
+ manager = MCPToolManager.__new__(MCPToolManager)
369
+ manager.servers_config = {"bad-server": {"description": "Bad"}}
370
+ manager.clients = {"bad-server": AsyncMock()}
371
+ manager._failed_servers = {}
372
+
373
+ async def failing_discover_prompts(server_name, client): # noqa: ARG001
374
+ raise RuntimeError("Simulated prompt discovery failure")
375
+
376
+ manager._discover_prompts_for_server = failing_discover_prompts # type: ignore[assignment]
377
+
378
+ await manager.discover_prompts()
379
+
380
+ assert "bad-server" in manager._failed_servers
381
+ error = manager._failed_servers["bad-server"]["error"]
382
+ assert "Simulated prompt discovery failure" in error
383
+
384
+
385
+ @pytest.mark.asyncio
386
+ class TestMCPReconnection:
387
+ """Tests for MCP server reconnection functionality."""
388
+
389
+ async def test_reconnect_skips_when_no_failed_servers(self):
390
+ """Test that reconnect returns early when no servers have failed."""
391
+ manager = MCPToolManager.__new__(MCPToolManager)
392
+ manager._failed_servers = {}
393
+
394
+ result = await manager.reconnect_failed_servers()
395
+
396
+ assert result["attempted"] == []
397
+ assert result["reconnected"] == []
398
+ assert result["still_failed"] == []
399
+ assert result["skipped_backoff"] == []
400
+
401
+ @patch('atlas.modules.mcp_tools.client.config_manager')
402
+ async def test_reconnect_respects_backoff(self, mock_config_manager):
403
+ """When not forced, reconnect should skip servers still in backoff."""
404
+ mock_settings = MagicMock()
405
+ mock_settings.mcp_reconnect_interval = 60
406
+ mock_settings.mcp_reconnect_max_interval = 300
407
+ mock_settings.mcp_reconnect_backoff_multiplier = 2.0
408
+ mock_config_manager.app_settings = mock_settings
409
+
410
+ manager = MCPToolManager.__new__(MCPToolManager)
411
+ manager.servers_config = {"test-server": {"description": "Test"}}
412
+ manager.clients = {}
413
+ # Server failed just now, should be in backoff
414
+ manager._failed_servers = {
415
+ "test-server": {
416
+ "last_attempt": time.time(),
417
+ "attempt_count": 1,
418
+ "error": "Connection refused"
419
+ }
420
+ }
421
+
422
+ result = await manager.reconnect_failed_servers(force=False)
423
+
424
+ assert result["attempted"] == []
425
+ assert result["skipped_backoff"][0]["server"] == "test-server"
426
+
427
+ @patch('atlas.modules.mcp_tools.client.config_manager')
428
+ async def test_reconnect_attempts_after_backoff(self, mock_config_manager):
429
+ """When backoff has elapsed, reconnect should attempt server."""
430
+ mock_settings = MagicMock()
431
+ mock_settings.mcp_reconnect_interval = 60
432
+ mock_settings.mcp_reconnect_max_interval = 300
433
+ mock_settings.mcp_reconnect_backoff_multiplier = 2.0
434
+ mock_config_manager.app_settings = mock_settings
435
+
436
+ manager = MCPToolManager.__new__(MCPToolManager)
437
+ manager.servers_config = {"test-server": {"description": "Test"}}
438
+ manager.clients = {}
439
+ # Server failed long ago, backoff period has passed
440
+ manager._failed_servers = {
441
+ "test-server": {
442
+ "last_attempt": time.time() - 120, # 2 minutes ago
443
+ "attempt_count": 1,
444
+ "error": "Connection refused"
445
+ }
446
+ }
447
+
448
+ # Mock the initialization method to return None (still failing)
449
+ manager._initialize_single_client = AsyncMock(return_value=None)
450
+
451
+ result = await manager.reconnect_failed_servers(force=False)
452
+
453
+ assert "test-server" in result["attempted"]
454
+ assert "test-server" in result["still_failed"]
455
+ manager._initialize_single_client.assert_called_once()
456
+
457
+ @patch('atlas.modules.mcp_tools.client.config_manager')
458
+ async def test_reconnect_force_ignores_backoff(self, mock_config_manager):
459
+ """Forced reconnect should attempt even inside backoff window."""
460
+ mock_settings = MagicMock()
461
+ mock_settings.mcp_reconnect_interval = 60
462
+ mock_settings.mcp_reconnect_max_interval = 300
463
+ mock_settings.mcp_reconnect_backoff_multiplier = 2.0
464
+ mock_config_manager.app_settings = mock_settings
465
+
466
+ manager = MCPToolManager.__new__(MCPToolManager)
467
+ manager.servers_config = {"test-server": {"description": "Test"}}
468
+ manager.clients = {}
469
+ # Server failed just now, so normally it would be in backoff
470
+ manager._failed_servers = {
471
+ "test-server": {
472
+ "last_attempt": time.time(),
473
+ "attempt_count": 1,
474
+ "error": "Connection refused"
475
+ }
476
+ }
477
+
478
+ # Mock the initialization method to return None (still failing)
479
+ manager._initialize_single_client = AsyncMock(return_value=None)
480
+
481
+ # With force=True, it should attempt despite backoff
482
+ result = await manager.reconnect_failed_servers(force=True)
483
+
484
+ assert "test-server" in result["attempted"]
485
+ manager._initialize_single_client.assert_called_once()
486
+
487
+
488
+ class TestConfigManagerMCPReload:
489
+ """Tests for ConfigManager MCP reload functionality."""
490
+
491
+ @patch('atlas.modules.config.config_manager.ConfigManager._search_paths')
492
+ @patch('atlas.modules.config.config_manager.ConfigManager._load_file_with_error_handling')
493
+ @patch('atlas.modules.config.config_manager.ConfigManager._validate_mcp_compliance_levels')
494
+ def test_reload_mcp_config_clears_cache(
495
+ self, mock_validate, mock_load, mock_search
496
+ ):
497
+ """Test that reload_mcp_config clears the cached config."""
498
+ from atlas.modules.config.config_manager import ConfigManager
499
+
500
+ manager = ConfigManager()
501
+ # Pre-populate cache
502
+ manager._mcp_config = MagicMock()
503
+ manager._tool_approvals_config = MagicMock()
504
+
505
+ # Mock the config loading
506
+ mock_search.return_value = []
507
+ mock_load.return_value = {"test-server": {"description": "Test"}}
508
+
509
+ manager.reload_mcp_config()
510
+
511
+ # Cache should have been cleared and reloaded
512
+ assert manager._tool_approvals_config is None or manager._tool_approvals_config != MagicMock()