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,226 @@
1
+ from unittest.mock import ANY, AsyncMock, Mock, patch
2
+
3
+ import pytest
4
+
5
+ from atlas.modules.mcp_tools.client import MCPToolManager
6
+
7
+
8
+ class TestMCPClientAuthentication:
9
+ """Test MCP client initialization with authentication."""
10
+
11
+ @pytest.mark.asyncio
12
+ @patch('atlas.modules.mcp_tools.client.Client')
13
+ async def test_http_client_with_env_var_token(self, mock_client_class, monkeypatch):
14
+ """Should resolve env var and pass token to HTTP client."""
15
+ monkeypatch.setenv("MCP_AUTH_TOKEN", "secret-token-123")
16
+
17
+ server_config = {
18
+ "url": "http://localhost:8000/mcp",
19
+ "transport": "http",
20
+ "auth_token": "${MCP_AUTH_TOKEN}"
21
+ }
22
+
23
+ # Create a dummy MCPToolManager instance to call _initialize_single_client
24
+ # We need to mock the config_manager.mcp_config.servers to return our server_config
25
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
26
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
27
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
28
+
29
+ manager = MCPToolManager()
30
+ # Manually set servers_config for the manager
31
+ manager.servers_config = {"test-server": server_config}
32
+
33
+ await manager._initialize_single_client("test-server", server_config)
34
+
35
+ mock_client_class.assert_called_once_with(
36
+ "http://localhost:8000/mcp",
37
+ auth="secret-token-123",
38
+ log_handler=ANY,
39
+ elicitation_handler=ANY,
40
+ sampling_handler=ANY,
41
+ )
42
+
43
+ @pytest.mark.asyncio
44
+ @patch('atlas.modules.mcp_tools.client.Client')
45
+ async def test_http_client_with_literal_token(self, mock_client_class):
46
+ """Should pass literal token string to HTTP client."""
47
+ mock_client = AsyncMock()
48
+ mock_client_class.return_value = mock_client
49
+
50
+ server_config = {
51
+ "url": "http://localhost:8000/mcp",
52
+ "transport": "http",
53
+ "auth_token": "direct-token-456"
54
+ }
55
+
56
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
57
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
58
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
59
+
60
+ manager = MCPToolManager()
61
+ manager.servers_config = {"test-server": server_config}
62
+
63
+ await manager._initialize_single_client("test-server", server_config)
64
+
65
+ mock_client_class.assert_called_once_with(
66
+ "http://localhost:8000/mcp",
67
+ auth="direct-token-456",
68
+ log_handler=ANY,
69
+ elicitation_handler=ANY,
70
+ sampling_handler=ANY,
71
+ )
72
+
73
+ @pytest.mark.asyncio
74
+ @patch('atlas.modules.mcp_tools.client.Client')
75
+ async def test_http_client_without_token(self, mock_client_class):
76
+ """Should pass None when no auth_token specified."""
77
+ mock_client = AsyncMock()
78
+ mock_client_class.return_value = mock_client
79
+
80
+ server_config = {
81
+ "url": "http://localhost:8000/mcp",
82
+ "transport": "http"
83
+ }
84
+
85
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
86
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
87
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
88
+
89
+ manager = MCPToolManager()
90
+ manager.servers_config = {"test-server": server_config}
91
+
92
+ await manager._initialize_single_client("test-server", server_config)
93
+
94
+ mock_client_class.assert_called_once_with(
95
+ "http://localhost:8000/mcp",
96
+ auth=None,
97
+ log_handler=ANY,
98
+ elicitation_handler=ANY,
99
+ sampling_handler=ANY,
100
+ )
101
+
102
+ @pytest.mark.asyncio
103
+ @patch('atlas.modules.mcp_tools.client.Client')
104
+ async def test_sse_client_with_token(self, mock_client_class):
105
+ """Should pass auth token to SSE client."""
106
+ mock_client = AsyncMock()
107
+ mock_client_class.return_value = mock_client
108
+
109
+ server_config = {
110
+ "url": "http://localhost:8000/sse",
111
+ "transport": "sse",
112
+ "auth_token": "sse-token-789"
113
+ }
114
+
115
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
116
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
117
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
118
+
119
+ manager = MCPToolManager()
120
+ manager.servers_config = {"test-server": server_config}
121
+
122
+ await manager._initialize_single_client("test-server", server_config)
123
+
124
+ mock_client_class.assert_called_once_with(
125
+ "http://localhost:8000/sse",
126
+ auth="sse-token-789",
127
+ log_handler=ANY,
128
+ elicitation_handler=ANY,
129
+ sampling_handler=ANY,
130
+ )
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_missing_env_var_raises_error(self, caplog):
134
+ """Should fail gracefully and log error when env var is missing."""
135
+ server_config = {
136
+ "url": "http://localhost:8000/mcp",
137
+ "transport": "http",
138
+ "auth_token": "${MISSING_TOKEN_VAR}"
139
+ }
140
+
141
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
142
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
143
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
144
+
145
+ manager = MCPToolManager()
146
+ manager.servers_config = {"test-server": server_config}
147
+
148
+ # Client initialization should return None when env var is missing
149
+ result = await manager._initialize_single_client("test-server", server_config)
150
+ assert result is None
151
+ # Should log the error about missing environment variable
152
+ assert "Environment variable 'MISSING_TOKEN_VAR' is not set" in caplog.text
153
+
154
+ @pytest.mark.asyncio
155
+ @patch('atlas.modules.mcp_tools.client.Client')
156
+ @patch('fastmcp.client.transports.StdioTransport')
157
+ async def test_stdio_client_ignores_token(self, mock_transport_class, mock_client_class):
158
+ """stdio clients should ignore auth_token (no auth mechanism)."""
159
+ server_config = {
160
+ "command": ["python", "server.py"],
161
+ "auth_token": "ignored-token"
162
+ }
163
+
164
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
165
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
166
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
167
+
168
+ manager = MCPToolManager()
169
+ manager.servers_config = {"test-server": server_config}
170
+
171
+ await manager._initialize_single_client("test-server", server_config)
172
+
173
+ # For stdio, the Client is called with StdioTransport, not URL and auth
174
+ # The auth_token should be ignored for stdio transports
175
+ assert mock_transport_class.called
176
+ assert mock_client_class.called
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_malformed_env_var_pattern(self, caplog):
180
+ """Should handle malformed env var patterns gracefully."""
181
+ server_config = {
182
+ "url": "http://localhost:8000/mcp",
183
+ "transport": "http",
184
+ "auth_token": "${MISSING_CLOSING_BRACE"
185
+ }
186
+
187
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
188
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
189
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
190
+
191
+ manager = MCPToolManager()
192
+ manager.servers_config = {"test-server": server_config}
193
+
194
+ # Should succeed and pass the malformed pattern as literal string
195
+ result = await manager._initialize_single_client("test-server", server_config)
196
+ assert result is not None # Client should be created with malformed string as auth token
197
+
198
+ @pytest.mark.asyncio
199
+ @patch('atlas.modules.mcp_tools.client.Client')
200
+ async def test_empty_auth_token_string(self, mock_client_class):
201
+ """Should pass empty string as auth token."""
202
+ mock_client = AsyncMock()
203
+ mock_client_class.return_value = mock_client
204
+
205
+ server_config = {
206
+ "url": "http://localhost:8000/mcp",
207
+ "transport": "http",
208
+ "auth_token": ""
209
+ }
210
+
211
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
212
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
213
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
214
+
215
+ manager = MCPToolManager()
216
+ manager.servers_config = {"test-server": server_config}
217
+
218
+ await manager._initialize_single_client("test-server", server_config)
219
+
220
+ mock_client_class.assert_called_once_with(
221
+ "http://localhost:8000/mcp",
222
+ auth="",
223
+ log_handler=ANY,
224
+ elicitation_handler=ANY,
225
+ sampling_handler=ANY,
226
+ )
@@ -0,0 +1,191 @@
1
+
2
+ from unittest.mock import Mock, patch
3
+
4
+ import pytest
5
+
6
+ from atlas.modules.mcp_tools.client import MCPToolManager
7
+
8
+
9
+ class TestMCPClientEnvironmentVariables:
10
+ """Test MCP client initialization with environment variables."""
11
+
12
+ @pytest.mark.asyncio
13
+ @patch('atlas.modules.mcp_tools.client.Client')
14
+ @patch('fastmcp.client.transports.StdioTransport')
15
+ async def test_stdio_client_with_env_vars(self, mock_transport_class, mock_client_class, monkeypatch):
16
+ """Should pass environment variables to StdioTransport."""
17
+ # Set up environment variables for resolution
18
+ monkeypatch.setenv("MY_ENV_VAR", "resolved-value")
19
+
20
+ server_config = {
21
+ "command": ["python", "server.py"],
22
+ "cwd": "backend",
23
+ "env": {
24
+ "VAR1": "literal-value",
25
+ "VAR2": "another-literal"
26
+ }
27
+ }
28
+
29
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
30
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
31
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
32
+
33
+ # Mock os.path.exists to return True for cwd
34
+ with patch('os.path.exists', return_value=True):
35
+ manager = MCPToolManager()
36
+ manager.servers_config = {"test-server": server_config}
37
+
38
+ await manager._initialize_single_client("test-server", server_config)
39
+
40
+ # Verify StdioTransport was called with env dict
41
+ assert mock_transport_class.called
42
+ call_kwargs = mock_transport_class.call_args[1]
43
+ assert "env" in call_kwargs
44
+ assert call_kwargs["env"] == {
45
+ "VAR1": "literal-value",
46
+ "VAR2": "another-literal"
47
+ }
48
+
49
+ @pytest.mark.asyncio
50
+ @patch('atlas.modules.mcp_tools.client.Client')
51
+ @patch('fastmcp.client.transports.StdioTransport')
52
+ async def test_stdio_client_with_env_var_resolution(self, mock_transport_class, mock_client_class, monkeypatch):
53
+ """Should resolve ${ENV_VAR} patterns in env values."""
54
+ # Set up environment variables
55
+ monkeypatch.setenv("CLOUD_PROFILE", "my-profile-9")
56
+ monkeypatch.setenv("CLOUD_REGION", "us-east-7")
57
+
58
+ server_config = {
59
+ "command": ["python", "server.py"],
60
+ "cwd": "backend",
61
+ "env": {
62
+ "PROFILE": "${CLOUD_PROFILE}",
63
+ "REGION": "${CLOUD_REGION}",
64
+ "LITERAL": "not-a-var"
65
+ }
66
+ }
67
+
68
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
69
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
70
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
71
+
72
+ # Mock os.path.exists to return True for cwd
73
+ with patch('os.path.exists', return_value=True):
74
+ manager = MCPToolManager()
75
+ manager.servers_config = {"test-server": server_config}
76
+
77
+ await manager._initialize_single_client("test-server", server_config)
78
+
79
+ # Verify env vars were resolved
80
+ assert mock_transport_class.called
81
+ call_kwargs = mock_transport_class.call_args[1]
82
+ assert call_kwargs["env"] == {
83
+ "PROFILE": "my-profile-9",
84
+ "REGION": "us-east-7",
85
+ "LITERAL": "not-a-var"
86
+ }
87
+
88
+ @pytest.mark.asyncio
89
+ @patch('atlas.modules.mcp_tools.client.Client')
90
+ @patch('fastmcp.client.transports.StdioTransport')
91
+ async def test_stdio_client_without_env(self, mock_transport_class, mock_client_class):
92
+ """Should pass None when no env specified."""
93
+ server_config = {
94
+ "command": ["python", "server.py"],
95
+ "cwd": "backend"
96
+ }
97
+
98
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
99
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
100
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
101
+
102
+ # Mock os.path.exists to return True for cwd
103
+ with patch('os.path.exists', return_value=True):
104
+ manager = MCPToolManager()
105
+ manager.servers_config = {"test-server": server_config}
106
+
107
+ await manager._initialize_single_client("test-server", server_config)
108
+
109
+ # Verify env is None
110
+ assert mock_transport_class.called
111
+ call_kwargs = mock_transport_class.call_args[1]
112
+ assert call_kwargs["env"] is None
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_stdio_client_missing_env_var_fails(self, caplog):
116
+ """Should fail when env var resolution fails."""
117
+ server_config = {
118
+ "command": ["python", "server.py"],
119
+ "cwd": "backend",
120
+ "env": {
121
+ "PROFILE": "${MISSING_VAR}"
122
+ }
123
+ }
124
+
125
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
126
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
127
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
128
+
129
+ # Mock os.path.exists to return True for cwd
130
+ with patch('os.path.exists', return_value=True):
131
+ manager = MCPToolManager()
132
+ manager.servers_config = {"test-server": server_config}
133
+
134
+ result = await manager._initialize_single_client("test-server", server_config)
135
+
136
+ # Should return None and log error
137
+ assert result is None
138
+ assert "Failed to resolve env var" in caplog.text
139
+ assert "MISSING_VAR" in caplog.text
140
+
141
+ @pytest.mark.asyncio
142
+ @patch('atlas.modules.mcp_tools.client.Client')
143
+ @patch('fastmcp.client.transports.StdioTransport')
144
+ async def test_stdio_client_with_env_no_cwd(self, mock_transport_class, mock_client_class, monkeypatch):
145
+ """Should pass env vars even when no cwd specified."""
146
+ monkeypatch.setenv("MY_VAR", "my-value")
147
+
148
+ server_config = {
149
+ "command": ["python", "server.py"],
150
+ "env": {
151
+ "TEST_VAR": "${MY_VAR}"
152
+ }
153
+ }
154
+
155
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
156
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
157
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
158
+
159
+ manager = MCPToolManager()
160
+ manager.servers_config = {"test-server": server_config}
161
+
162
+ await manager._initialize_single_client("test-server", server_config)
163
+
164
+ # Verify env was passed
165
+ assert mock_transport_class.called
166
+ call_kwargs = mock_transport_class.call_args[1]
167
+ assert call_kwargs["env"] == {"TEST_VAR": "my-value"}
168
+
169
+ @pytest.mark.asyncio
170
+ @patch('atlas.modules.mcp_tools.client.Client')
171
+ @patch('fastmcp.client.transports.StdioTransport')
172
+ async def test_stdio_client_empty_env_dict(self, mock_transport_class, mock_client_class):
173
+ """Should handle empty env dict."""
174
+ server_config = {
175
+ "command": ["python", "server.py"],
176
+ "env": {}
177
+ }
178
+
179
+ with patch('atlas.modules.mcp_tools.client.config_manager') as mock_config_manager:
180
+ mock_config_manager.mcp_config.servers = {"test-server": Mock()}
181
+ mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config
182
+
183
+ manager = MCPToolManager()
184
+ manager.servers_config = {"test-server": server_config}
185
+
186
+ await manager._initialize_single_client("test-server", server_config)
187
+
188
+ # Empty dict should become empty dict (not None)
189
+ assert mock_transport_class.called
190
+ call_kwargs = mock_transport_class.call_args[1]
191
+ assert call_kwargs["env"] == {}
@@ -0,0 +1,141 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from main import app
5
+ from starlette.testclient import TestClient
6
+
7
+ from atlas.infrastructure.app_factory import app_factory
8
+ from atlas.modules.config import config_manager
9
+
10
+
11
+ def _configure_test_overrides(tmp_path: Path, monkeypatch):
12
+ # Keep config changes isolated per test
13
+ monkeypatch.setattr(config_manager.app_settings, "app_config_overrides", str(tmp_path))
14
+ monkeypatch.setattr(config_manager.app_settings, "mcp_config_file", "mcp.json")
15
+
16
+ # Avoid any side effects from attempting to reload MCP servers during add/remove.
17
+ monkeypatch.setattr(app_factory, "get_mcp_manager", lambda: None)
18
+
19
+
20
+ def test_admin_mcp_available_servers_returns_inventory(monkeypatch, tmp_path):
21
+ _configure_test_overrides(tmp_path, monkeypatch)
22
+
23
+ client = TestClient(app)
24
+ response = client.get(
25
+ "/admin/mcp/available-servers",
26
+ headers={"X-User-Email": "admin@example.com"},
27
+ )
28
+ assert response.status_code == 200
29
+
30
+ data = response.json()
31
+ assert "available_servers" in data
32
+ assert isinstance(data["available_servers"], dict)
33
+
34
+ # Repo should ship at least one example server.
35
+ assert len(data["available_servers"]) > 0
36
+
37
+ # Spot-check expected shape.
38
+ first_name = next(iter(data["available_servers"]))
39
+ first = data["available_servers"][first_name]
40
+ assert "config" in first
41
+ assert "source_file" in first
42
+
43
+
44
+ def test_admin_mcp_active_servers_empty_when_no_override_file(monkeypatch, tmp_path):
45
+ _configure_test_overrides(tmp_path, monkeypatch)
46
+
47
+ # Ensure there is no mcp.json in overrides
48
+ assert not (tmp_path / "mcp.json").exists()
49
+
50
+ client = TestClient(app)
51
+ response = client.get(
52
+ "/admin/mcp/active-servers",
53
+ headers={"X-User-Email": "admin@example.com"},
54
+ )
55
+ assert response.status_code == 200
56
+ data = response.json()
57
+ assert data == {"active_servers": {}}
58
+
59
+
60
+ def test_admin_mcp_add_server_persists_to_overrides(monkeypatch, tmp_path):
61
+ _configure_test_overrides(tmp_path, monkeypatch)
62
+
63
+ client = TestClient(app)
64
+
65
+ available = client.get(
66
+ "/admin/mcp/available-servers",
67
+ headers={"X-User-Email": "admin@example.com"},
68
+ ).json()["available_servers"]
69
+
70
+ server_name = next(iter(available.keys()))
71
+
72
+ add_response = client.post(
73
+ "/admin/mcp/add-server",
74
+ headers={"X-User-Email": "admin@example.com"},
75
+ json={"server_name": server_name},
76
+ )
77
+ assert add_response.status_code == 200
78
+ add_data = add_response.json()
79
+ assert add_data["server_name"] == server_name
80
+
81
+ # Active endpoint should reflect the new server.
82
+ active = client.get(
83
+ "/admin/mcp/active-servers",
84
+ headers={"X-User-Email": "admin@example.com"},
85
+ ).json()["active_servers"]
86
+ assert server_name in active
87
+
88
+ # And it should be persisted in overrides/mcp.json.
89
+ persisted_path = tmp_path / "mcp.json"
90
+ assert persisted_path.exists()
91
+ persisted = json.loads(persisted_path.read_text(encoding="utf-8"))
92
+ assert server_name in persisted
93
+
94
+ # Re-adding returns the already_active response.
95
+ add_again = client.post(
96
+ "/admin/mcp/add-server",
97
+ headers={"X-User-Email": "admin@example.com"},
98
+ json={"server_name": server_name},
99
+ )
100
+ assert add_again.status_code == 200
101
+ assert add_again.json().get("already_active") is True
102
+
103
+
104
+ def test_admin_mcp_remove_server_updates_overrides(monkeypatch, tmp_path):
105
+ _configure_test_overrides(tmp_path, monkeypatch)
106
+
107
+ client = TestClient(app)
108
+
109
+ available = client.get(
110
+ "/admin/mcp/available-servers",
111
+ headers={"X-User-Email": "admin@example.com"},
112
+ ).json()["available_servers"]
113
+
114
+ server_name = next(iter(available.keys()))
115
+
116
+ # Add then remove.
117
+ add_response = client.post(
118
+ "/admin/mcp/add-server",
119
+ headers={"X-User-Email": "admin@example.com"},
120
+ json={"server_name": server_name},
121
+ )
122
+ assert add_response.status_code == 200
123
+
124
+ remove_response = client.post(
125
+ "/admin/mcp/remove-server",
126
+ headers={"X-User-Email": "admin@example.com"},
127
+ json={"server_name": server_name},
128
+ )
129
+ assert remove_response.status_code == 200
130
+ remove_data = remove_response.json()
131
+ assert remove_data["server_name"] == server_name
132
+ assert "removed_config" in remove_data
133
+
134
+ active = client.get(
135
+ "/admin/mcp/active-servers",
136
+ headers={"X-User-Email": "admin@example.com"},
137
+ ).json()["active_servers"]
138
+ assert server_name not in active
139
+
140
+ persisted = json.loads((tmp_path / "mcp.json").read_text(encoding="utf-8"))
141
+ assert server_name not in persisted