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,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
|