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,424 @@
1
+ """Tests for ImageContent handling in MCP tool results.
2
+
3
+ These tests verify that Atlas can extract and process ImageContent items
4
+ from MCP tool responses and convert them to artifacts for display.
5
+ """
6
+
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ import pytest
10
+
11
+ from atlas.domain.messages.models import ToolCall
12
+ from atlas.modules.mcp_tools.client import MCPToolManager
13
+
14
+
15
+ class MockImageContent:
16
+ """Mock for MCP ImageContent item."""
17
+ def __init__(self, data: str, mime_type: str = "image/png"):
18
+ self.type = "image"
19
+ self.data = data
20
+ self.mimeType = mime_type
21
+
22
+
23
+ class MockTextContent:
24
+ """Mock for MCP text content item."""
25
+ def __init__(self, text: str):
26
+ self.type = "text"
27
+ self.text = text
28
+
29
+
30
+ class MockMCPResultWithImage:
31
+ """Mock MCP result that includes ImageContent in content array."""
32
+ def __init__(self, image_data: str, mime_type: str = "image/png"):
33
+ self.content = [MockImageContent(image_data, mime_type)]
34
+ self.structured_content = None
35
+ self.data = None
36
+ self.is_error = False
37
+
38
+
39
+ class MockMCPResultWithMultipleImages:
40
+ """Mock MCP result with multiple ImageContent items."""
41
+ def __init__(self, images: list):
42
+ self.content = [MockImageContent(img["data"], img["mime"]) for img in images]
43
+ self.structured_content = None
44
+ self.data = None
45
+ self.is_error = False
46
+
47
+
48
+ class MockMCPResultWithMixedContent:
49
+ """Mock MCP result with both TextContent and ImageContent."""
50
+ def __init__(self, text: str, image_data: str):
51
+ self.content = [
52
+ MockTextContent(text),
53
+ MockImageContent(image_data)
54
+ ]
55
+ self.structured_content = None
56
+ self.data = None
57
+ self.is_error = False
58
+
59
+
60
+ class TestImageContentHandling:
61
+ """Tests for extracting ImageContent from MCP tool results."""
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_extract_single_image_content(self):
65
+ """Test extraction of a single ImageContent item."""
66
+ manager = MCPToolManager.__new__(MCPToolManager)
67
+
68
+ # Mock tool object
69
+ class MockTool:
70
+ def __init__(self, name):
71
+ self.name = name
72
+
73
+ # Create a tool call
74
+ tool_call = ToolCall(
75
+ id="test-call-1",
76
+ name="generate_image",
77
+ arguments={}
78
+ )
79
+
80
+ # Mock the call_tool to return ImageContent
81
+ image_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
82
+ raw_result = MockMCPResultWithImage(image_b64, "image/png")
83
+
84
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
85
+ mock_call.return_value = raw_result
86
+
87
+ # Mock _tool_index with correct structure
88
+ manager._tool_index = {
89
+ "generate_image": {
90
+ "server": "test-server",
91
+ "tool": MockTool("generate_image")
92
+ }
93
+ }
94
+
95
+ result = await manager.execute_tool(tool_call, context={})
96
+
97
+ # Verify artifacts were created
98
+ assert result.artifacts is not None
99
+ assert len(result.artifacts) == 1
100
+
101
+ artifact = result.artifacts[0]
102
+ assert artifact["name"] == "mcp_image_0.png"
103
+ assert artifact["b64"] == image_b64
104
+ assert artifact["mime"] == "image/png"
105
+ assert artifact["viewer"] == "image"
106
+ assert "generate_image" in artifact["description"]
107
+
108
+ # Verify display config was auto-created
109
+ assert result.display_config is not None
110
+ assert result.display_config["primary_file"] == "mcp_image_0.png"
111
+ assert result.display_config["open_canvas"] is True
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_extract_multiple_image_contents(self):
115
+ """Test extraction of multiple ImageContent items."""
116
+ manager = MCPToolManager.__new__(MCPToolManager)
117
+
118
+ # Mock tool object
119
+ class MockTool:
120
+ def __init__(self, name):
121
+ self.name = name
122
+
123
+ tool_call = ToolCall(
124
+ id="test-call-2",
125
+ name="generate_multiple",
126
+ arguments={}
127
+ )
128
+
129
+ # Use valid base64 encoded strings
130
+ images = [
131
+ {"data": "aW1hZ2UgZGF0YSAxCg==", "mime": "image/png"},
132
+ {"data": "aW1hZ2UgZGF0YSAyCg==", "mime": "image/jpeg"},
133
+ {"data": "aW1hZ2UgZGF0YSAzCg==", "mime": "image/gif"}
134
+ ]
135
+ raw_result = MockMCPResultWithMultipleImages(images)
136
+
137
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
138
+ mock_call.return_value = raw_result
139
+ manager._tool_index = {
140
+ "generate_multiple": {
141
+ "server": "test-server",
142
+ "tool": MockTool("generate_multiple")
143
+ }
144
+ }
145
+
146
+ result = await manager.execute_tool(tool_call, context={})
147
+
148
+ # Verify all images were extracted
149
+ assert len(result.artifacts) == 3
150
+
151
+ # Check each artifact
152
+ for i, (artifact, img) in enumerate(zip(result.artifacts, images)):
153
+ expected_ext = img["mime"].split("/")[-1]
154
+ assert artifact["name"] == f"mcp_image_{i}.{expected_ext}"
155
+ assert artifact["b64"] == img["data"]
156
+ assert artifact["mime"] == img["mime"]
157
+ assert artifact["viewer"] == "image"
158
+
159
+ @pytest.mark.asyncio
160
+ async def test_extract_mixed_content(self):
161
+ """Test extraction when both TextContent and ImageContent are present."""
162
+ manager = MCPToolManager.__new__(MCPToolManager)
163
+
164
+ # Mock tool object
165
+ class MockTool:
166
+ def __init__(self, name):
167
+ self.name = name
168
+
169
+ tool_call = ToolCall(
170
+ id="test-call-3",
171
+ name="mixed_tool",
172
+ arguments={}
173
+ )
174
+
175
+ text = "Here is your visualization"
176
+ # Use valid base64 encoded string
177
+ image_b64 = "aW1hZ2VkYXRhCg=="
178
+ raw_result = MockMCPResultWithMixedContent(text, image_b64)
179
+
180
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
181
+ mock_call.return_value = raw_result
182
+ manager._tool_index = {
183
+ "mixed_tool": {
184
+ "server": "test-server",
185
+ "tool": MockTool("mixed_tool")
186
+ }
187
+ }
188
+
189
+ result = await manager.execute_tool(tool_call, context={})
190
+
191
+ # Verify image was extracted (uses image counter, so first image is image_0)
192
+ assert len(result.artifacts) == 1
193
+ artifact = result.artifacts[0]
194
+ assert artifact["name"] == "mcp_image_0.png"
195
+ assert artifact["b64"] == image_b64
196
+
197
+ # Verify the text content was extracted and included in result.content
198
+ # The content should be JSON containing the text in "results"
199
+ import json
200
+ content_dict = json.loads(result.content)
201
+ assert "results" in content_dict
202
+ assert text in content_dict["results"]
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_no_image_content(self):
206
+ """Test that non-image content doesn't create artifacts."""
207
+ manager = MCPToolManager.__new__(MCPToolManager)
208
+
209
+ # Mock tool object
210
+ class MockTool:
211
+ def __init__(self, name):
212
+ self.name = name
213
+
214
+ tool_call = ToolCall(
215
+ id="test-call-4",
216
+ name="text_only",
217
+ arguments={}
218
+ )
219
+
220
+ # Create a result with only text content
221
+ class MockTextOnlyResult:
222
+ def __init__(self):
223
+ self.content = [MockTextContent("Just text")]
224
+ self.structured_content = None
225
+ self.data = None
226
+ self.is_error = False
227
+
228
+ raw_result = MockTextOnlyResult()
229
+
230
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
231
+ mock_call.return_value = raw_result
232
+ manager._tool_index = {
233
+ "text_only": {
234
+ "server": "test-server",
235
+ "tool": MockTool("text_only")
236
+ }
237
+ }
238
+
239
+ result = await manager.execute_tool(tool_call, context={})
240
+
241
+ # Verify no artifacts were created
242
+ assert len(result.artifacts) == 0
243
+ # Display config should not be auto-created
244
+ assert result.display_config is None
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_image_content_missing_data(self):
248
+ """Test that ImageContent with None/missing data is skipped."""
249
+ manager = MCPToolManager.__new__(MCPToolManager)
250
+
251
+ class MockTool:
252
+ def __init__(self, name):
253
+ self.name = name
254
+
255
+ tool_call = ToolCall(
256
+ id="test-call-5",
257
+ name="missing_data",
258
+ arguments={}
259
+ )
260
+
261
+ # Create ImageContent with missing data
262
+ class MockImageContentNoData:
263
+ def __init__(self):
264
+ self.type = "image"
265
+ self.data = None
266
+ self.mimeType = "image/png"
267
+
268
+ class MockResult:
269
+ def __init__(self):
270
+ self.content = [MockImageContentNoData()]
271
+ self.structured_content = None
272
+ self.data = None
273
+ self.is_error = False
274
+
275
+ raw_result = MockResult()
276
+
277
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
278
+ mock_call.return_value = raw_result
279
+ manager._tool_index = {
280
+ "missing_data": {
281
+ "server": "test-server",
282
+ "tool": MockTool("missing_data")
283
+ }
284
+ }
285
+
286
+ result = await manager.execute_tool(tool_call, context={})
287
+
288
+ # No artifacts should be created when data is missing
289
+ assert len(result.artifacts) == 0
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_image_content_missing_mime_type(self):
293
+ """Test that ImageContent with None/missing mimeType is skipped."""
294
+ manager = MCPToolManager.__new__(MCPToolManager)
295
+
296
+ class MockTool:
297
+ def __init__(self, name):
298
+ self.name = name
299
+
300
+ tool_call = ToolCall(
301
+ id="test-call-6",
302
+ name="missing_mime",
303
+ arguments={}
304
+ )
305
+
306
+ # Create ImageContent with missing mime type
307
+ class MockImageContentNoMime:
308
+ def __init__(self):
309
+ self.type = "image"
310
+ self.data = "SGVsbG8gV29ybGQ=" # Valid base64
311
+ self.mimeType = None
312
+
313
+ class MockResult:
314
+ def __init__(self):
315
+ self.content = [MockImageContentNoMime()]
316
+ self.structured_content = None
317
+ self.data = None
318
+ self.is_error = False
319
+
320
+ raw_result = MockResult()
321
+
322
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
323
+ mock_call.return_value = raw_result
324
+ manager._tool_index = {
325
+ "missing_mime": {
326
+ "server": "test-server",
327
+ "tool": MockTool("missing_mime")
328
+ }
329
+ }
330
+
331
+ result = await manager.execute_tool(tool_call, context={})
332
+
333
+ # No artifacts should be created when mimeType is missing
334
+ assert len(result.artifacts) == 0
335
+
336
+ @pytest.mark.asyncio
337
+ async def test_image_content_invalid_mime_type(self):
338
+ """Test that ImageContent with unsupported mime type is skipped."""
339
+ manager = MCPToolManager.__new__(MCPToolManager)
340
+
341
+ class MockTool:
342
+ def __init__(self, name):
343
+ self.name = name
344
+
345
+ tool_call = ToolCall(
346
+ id="test-call-7",
347
+ name="bad_mime",
348
+ arguments={}
349
+ )
350
+
351
+ # Create ImageContent with unsupported mime type
352
+ class MockImageContentBadMime:
353
+ def __init__(self):
354
+ self.type = "image"
355
+ self.data = "SGVsbG8gV29ybGQ=" # Valid base64
356
+ self.mimeType = "application/octet-stream"
357
+
358
+ class MockResult:
359
+ def __init__(self):
360
+ self.content = [MockImageContentBadMime()]
361
+ self.structured_content = None
362
+ self.data = None
363
+ self.is_error = False
364
+
365
+ raw_result = MockResult()
366
+
367
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
368
+ mock_call.return_value = raw_result
369
+ manager._tool_index = {
370
+ "bad_mime": {
371
+ "server": "test-server",
372
+ "tool": MockTool("bad_mime")
373
+ }
374
+ }
375
+
376
+ result = await manager.execute_tool(tool_call, context={})
377
+
378
+ # No artifacts should be created for unsupported mime type
379
+ assert len(result.artifacts) == 0
380
+
381
+ @pytest.mark.asyncio
382
+ async def test_image_content_invalid_base64(self):
383
+ """Test that ImageContent with invalid base64 data is skipped."""
384
+ manager = MCPToolManager.__new__(MCPToolManager)
385
+
386
+ class MockTool:
387
+ def __init__(self, name):
388
+ self.name = name
389
+
390
+ tool_call = ToolCall(
391
+ id="test-call-8",
392
+ name="bad_base64",
393
+ arguments={}
394
+ )
395
+
396
+ # Create ImageContent with invalid base64
397
+ class MockImageContentBadB64:
398
+ def __init__(self):
399
+ self.type = "image"
400
+ self.data = "not-valid-base64!!!"
401
+ self.mimeType = "image/png"
402
+
403
+ class MockResult:
404
+ def __init__(self):
405
+ self.content = [MockImageContentBadB64()]
406
+ self.structured_content = None
407
+ self.data = None
408
+ self.is_error = False
409
+
410
+ raw_result = MockResult()
411
+
412
+ with patch.object(manager, 'call_tool', new_callable=AsyncMock) as mock_call:
413
+ mock_call.return_value = raw_result
414
+ manager._tool_index = {
415
+ "bad_base64": {
416
+ "server": "test-server",
417
+ "tool": MockTool("bad_base64")
418
+ }
419
+ }
420
+
421
+ result = await manager.execute_tool(tool_call, context={})
422
+
423
+ # No artifacts should be created for invalid base64
424
+ assert len(result.artifacts) == 0
@@ -0,0 +1,172 @@
1
+ """Tests for MCP server logging functionality.
2
+
3
+ These tests verify that:
4
+ 1. Log handlers are properly created and attached to MCP clients
5
+ 2. Log messages are filtered based on configured LOG_LEVEL
6
+ 3. Log messages are forwarded to the UI callback when provided
7
+ 4. Backend logger receives MCP server logs
8
+ """
9
+
10
+ import asyncio
11
+ import logging
12
+ from unittest.mock import AsyncMock, patch
13
+
14
+ import pytest
15
+
16
+ from atlas.modules.mcp_tools.client import MCP_TO_PYTHON_LOG_LEVEL, MCPToolManager
17
+
18
+
19
+ class MockLogMessage:
20
+ """Mock LogMessage from fastmcp.client.logging."""
21
+ def __init__(self, level: str, msg: str, extra: dict = None):
22
+ self.level = level
23
+ self.data = {
24
+ 'msg': msg,
25
+ 'extra': extra or {}
26
+ }
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ class TestMCPLogging:
31
+ """Tests for MCP logging functionality."""
32
+
33
+ async def test_log_level_mapping(self):
34
+ """Test that MCP log levels are mapped correctly to Python logging levels."""
35
+ assert MCP_TO_PYTHON_LOG_LEVEL['debug'] == logging.DEBUG
36
+ assert MCP_TO_PYTHON_LOG_LEVEL['info'] == logging.INFO
37
+ assert MCP_TO_PYTHON_LOG_LEVEL['notice'] == logging.INFO
38
+ assert MCP_TO_PYTHON_LOG_LEVEL['warning'] == logging.WARNING
39
+ assert MCP_TO_PYTHON_LOG_LEVEL['warn'] == logging.WARNING
40
+ assert MCP_TO_PYTHON_LOG_LEVEL['error'] == logging.ERROR
41
+ assert MCP_TO_PYTHON_LOG_LEVEL['alert'] == logging.CRITICAL
42
+ assert MCP_TO_PYTHON_LOG_LEVEL['critical'] == logging.CRITICAL
43
+ assert MCP_TO_PYTHON_LOG_LEVEL['emergency'] == logging.CRITICAL
44
+
45
+ async def test_log_handler_forwards_to_callback(self):
46
+ """Test that log handler forwards messages to UI callback."""
47
+ # Create a mock callback
48
+ mock_callback = AsyncMock()
49
+
50
+ with patch.dict('os.environ', {'LOG_LEVEL': 'DEBUG'}):
51
+ manager = MCPToolManager(log_callback=mock_callback)
52
+ log_handler = manager._create_log_handler("test_server")
53
+
54
+ # Send a log message
55
+ msg = MockLogMessage('info', 'Test message', {'key': 'value'})
56
+ await log_handler(msg)
57
+
58
+ # Callback should be called with correct parameters
59
+ mock_callback.assert_called_once()
60
+ call_args = mock_callback.call_args[0]
61
+ assert call_args[0] == "test_server" # server_name
62
+ assert call_args[1] == "info" # level
63
+ assert call_args[2] == "Test message" # message
64
+ assert call_args[3] == {'key': 'value'} # extra
65
+
66
+ async def test_log_handler_filters_by_level(self):
67
+ """Test that log handler respects min_log_level filtering."""
68
+ mock_callback = AsyncMock()
69
+
70
+ # Set minimum level to WARNING
71
+ with patch.dict('os.environ', {'LOG_LEVEL': 'WARNING'}):
72
+ manager = MCPToolManager(log_callback=mock_callback)
73
+ manager._min_log_level = logging.WARNING # Ensure it's set
74
+ log_handler = manager._create_log_handler("test_server")
75
+
76
+ # Send a DEBUG log (should be filtered out)
77
+ debug_msg = MockLogMessage('debug', 'Debug message')
78
+ await log_handler(debug_msg)
79
+
80
+ # Callback should NOT be called for DEBUG when level is WARNING
81
+ mock_callback.assert_not_called()
82
+
83
+ # Send an INFO log (should also be filtered out)
84
+ info_msg = MockLogMessage('info', 'Info message')
85
+ await log_handler(info_msg)
86
+
87
+ # Still should not be called
88
+ mock_callback.assert_not_called()
89
+
90
+ # Send a WARNING log (should pass through)
91
+ warn_msg = MockLogMessage('warning', 'Warning message')
92
+ await log_handler(warn_msg)
93
+
94
+ # Now callback should be called
95
+ mock_callback.assert_called_once()
96
+
97
+ async def test_set_log_callback(self):
98
+ """Test that log callback can be set after initialization."""
99
+ manager = MCPToolManager()
100
+
101
+ # Initially no callback
102
+ assert manager._default_log_callback is None
103
+
104
+ # Set a callback
105
+ mock_callback = AsyncMock()
106
+ manager.set_log_callback(mock_callback)
107
+
108
+ assert manager._default_log_callback is mock_callback
109
+
110
+ # Test that it's used
111
+ log_handler = manager._create_log_handler("test_server")
112
+
113
+ msg = MockLogMessage('info', 'Test message')
114
+ await log_handler(msg)
115
+
116
+ mock_callback.assert_called_once()
117
+
118
+ async def test_log_handler_handles_callback_errors_gracefully(self):
119
+ """Test that log handler doesn't crash if callback raises an exception."""
120
+ # Create a callback that raises an exception
121
+ mock_callback = AsyncMock(side_effect=Exception("Callback error"))
122
+
123
+ manager = MCPToolManager(log_callback=mock_callback)
124
+ log_handler = manager._create_log_handler("test_server")
125
+
126
+ # Send a log message - should not raise despite callback error
127
+ msg = MockLogMessage('info', 'Test message')
128
+ # This should not raise an exception
129
+ await log_handler(msg)
130
+
131
+ # Verify the callback was attempted
132
+ mock_callback.assert_called_once()
133
+
134
+ async def test_request_scoped_callback_overrides_default(self):
135
+ """Request-scoped callback should override the default callback.
136
+
137
+ This is the core mechanism preventing cross-user log leakage when MCPToolManager
138
+ is shared across multiple websocket connections.
139
+ """
140
+ default_cb = AsyncMock()
141
+ request_cb = AsyncMock()
142
+
143
+ manager = MCPToolManager(log_callback=default_cb)
144
+ log_handler = manager._create_log_handler("test_server")
145
+
146
+ msg = MockLogMessage('info', 'Scoped message')
147
+
148
+ async with manager._use_log_callback(request_cb):
149
+ await log_handler(msg)
150
+
151
+ request_cb.assert_called_once()
152
+ default_cb.assert_not_called()
153
+
154
+ async def test_request_scoped_callbacks_are_isolated_across_tasks(self):
155
+ """Two concurrent tasks should not receive each other's MCP logs."""
156
+ cb_a = AsyncMock()
157
+ cb_b = AsyncMock()
158
+ manager = MCPToolManager()
159
+ log_handler = manager._create_log_handler("test_server")
160
+
161
+ async def run_with(cb, text):
162
+ async with manager._use_log_callback(cb):
163
+ await log_handler(MockLogMessage('info', text))
164
+
165
+ await asyncio.gather(
166
+ run_with(cb_a, "message-a"),
167
+ run_with(cb_b, "message-b"),
168
+ )
169
+
170
+ cb_a.assert_called_once()
171
+ cb_b.assert_called_once()
172
+