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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|
+
|