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,512 @@
|
|
|
1
|
+
"""Tests for MCP hot reload and auto-reconnect functionality."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from starlette.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
from atlas.modules.mcp_tools.client import MCPToolManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestMCPAdminEndpoints:
|
|
13
|
+
"""Integration tests for MCP admin endpoints."""
|
|
14
|
+
|
|
15
|
+
def test_mcp_status_endpoint_requires_admin(self):
|
|
16
|
+
"""Test that MCP status endpoint requires admin access."""
|
|
17
|
+
from main import app
|
|
18
|
+
client = TestClient(app)
|
|
19
|
+
|
|
20
|
+
# Non-admin user should be denied
|
|
21
|
+
r = client.get("/admin/mcp/status", headers={"X-User-Email": "user@example.com"})
|
|
22
|
+
assert r.status_code in (302, 403)
|
|
23
|
+
|
|
24
|
+
def test_mcp_status_endpoint_returns_data(self):
|
|
25
|
+
"""Test that MCP status endpoint returns expected data structure."""
|
|
26
|
+
from main import app
|
|
27
|
+
client = TestClient(app)
|
|
28
|
+
|
|
29
|
+
# Admin user should get response
|
|
30
|
+
r = client.get("/admin/mcp/status", headers={"X-User-Email": "admin@example.com"})
|
|
31
|
+
assert r.status_code == 200
|
|
32
|
+
|
|
33
|
+
data = r.json()
|
|
34
|
+
assert "connected_servers" in data
|
|
35
|
+
assert "configured_servers" in data
|
|
36
|
+
assert "failed_servers" in data
|
|
37
|
+
assert "auto_reconnect" in data
|
|
38
|
+
assert "tool_counts" in data
|
|
39
|
+
assert "prompt_counts" in data
|
|
40
|
+
|
|
41
|
+
# Check auto_reconnect structure
|
|
42
|
+
auto_reconnect = data["auto_reconnect"]
|
|
43
|
+
assert "enabled" in auto_reconnect
|
|
44
|
+
assert "base_interval" in auto_reconnect
|
|
45
|
+
assert "max_interval" in auto_reconnect
|
|
46
|
+
assert "backoff_multiplier" in auto_reconnect
|
|
47
|
+
assert "running" in auto_reconnect
|
|
48
|
+
|
|
49
|
+
def test_mcp_status_marks_failed_servers_not_connected(self):
|
|
50
|
+
"""Servers with recorded failures should not appear as connected."""
|
|
51
|
+
from main import app
|
|
52
|
+
client = TestClient(app)
|
|
53
|
+
|
|
54
|
+
# Seed a fake failure in the MCP manager
|
|
55
|
+
from atlas.infrastructure.app_factory import app_factory
|
|
56
|
+
mcp = app_factory.get_mcp_manager()
|
|
57
|
+
mcp._failed_servers["failing-server"] = {
|
|
58
|
+
"last_attempt": time.time(),
|
|
59
|
+
"attempt_count": 1,
|
|
60
|
+
"error": "Simulated failure",
|
|
61
|
+
}
|
|
62
|
+
mcp.clients["failing-server"] = AsyncMock()
|
|
63
|
+
mcp.available_tools["failing-server"] = {"tools": [], "config": {}}
|
|
64
|
+
mcp.available_prompts["failing-server"] = {"prompts": [], "config": {}}
|
|
65
|
+
|
|
66
|
+
r = client.get("/admin/mcp/status", headers={"X-User-Email": "admin@example.com"})
|
|
67
|
+
assert r.status_code == 200
|
|
68
|
+
|
|
69
|
+
data = r.json()
|
|
70
|
+
assert "failing-server" not in data["connected_servers"]
|
|
71
|
+
|
|
72
|
+
def test_mcp_reload_endpoint_requires_admin(self):
|
|
73
|
+
"""Test that MCP reload endpoint requires admin access."""
|
|
74
|
+
from main import app
|
|
75
|
+
client = TestClient(app)
|
|
76
|
+
|
|
77
|
+
# Non-admin user should be denied
|
|
78
|
+
r = client.post("/admin/mcp/reload", headers={"X-User-Email": "user@example.com"})
|
|
79
|
+
assert r.status_code in (302, 403)
|
|
80
|
+
|
|
81
|
+
def test_mcp_reconnect_endpoint_requires_admin(self):
|
|
82
|
+
"""Test that MCP reconnect endpoint requires admin access."""
|
|
83
|
+
from main import app
|
|
84
|
+
client = TestClient(app)
|
|
85
|
+
|
|
86
|
+
# Non-admin user should be denied
|
|
87
|
+
r = client.post("/admin/mcp/reconnect", headers={"X-User-Email": "user@example.com"})
|
|
88
|
+
assert r.status_code in (302, 403)
|
|
89
|
+
|
|
90
|
+
def test_mcp_reconnect_endpoint_returns_data(self):
|
|
91
|
+
"""Test that MCP reconnect endpoint returns expected data structure."""
|
|
92
|
+
from main import app
|
|
93
|
+
client = TestClient(app)
|
|
94
|
+
|
|
95
|
+
# Admin user should get response
|
|
96
|
+
r = client.post("/admin/mcp/reconnect", headers={"X-User-Email": "admin@example.com"})
|
|
97
|
+
assert r.status_code == 200
|
|
98
|
+
|
|
99
|
+
data = r.json()
|
|
100
|
+
assert "message" in data
|
|
101
|
+
assert "result" in data
|
|
102
|
+
assert "current_servers" in data
|
|
103
|
+
assert "failed_servers" in data
|
|
104
|
+
assert "triggered_by" in data
|
|
105
|
+
|
|
106
|
+
def test_admin_dashboard_includes_mcp_endpoints(self):
|
|
107
|
+
"""Test that admin dashboard lists MCP endpoints."""
|
|
108
|
+
from main import app
|
|
109
|
+
client = TestClient(app)
|
|
110
|
+
|
|
111
|
+
r = client.get("/admin/", headers={"X-User-Email": "admin@example.com"})
|
|
112
|
+
assert r.status_code == 200
|
|
113
|
+
|
|
114
|
+
data = r.json()
|
|
115
|
+
endpoints = data.get("available_endpoints", [])
|
|
116
|
+
assert "/admin/mcp/reload" in endpoints
|
|
117
|
+
assert "/admin/mcp/reconnect" in endpoints
|
|
118
|
+
assert "/admin/mcp/status" in endpoints
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestMCPFailedServerTracking:
|
|
122
|
+
"""Tests for tracking failed MCP server connections."""
|
|
123
|
+
|
|
124
|
+
def test_record_server_failure_new_server(self):
|
|
125
|
+
"""Test recording first failure for a server."""
|
|
126
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
127
|
+
manager._failed_servers = {}
|
|
128
|
+
|
|
129
|
+
manager._record_server_failure("test-server", "Connection refused")
|
|
130
|
+
|
|
131
|
+
assert "test-server" in manager._failed_servers
|
|
132
|
+
assert manager._failed_servers["test-server"]["attempt_count"] == 1
|
|
133
|
+
assert manager._failed_servers["test-server"]["error"] == "Connection refused"
|
|
134
|
+
assert "last_attempt" in manager._failed_servers["test-server"]
|
|
135
|
+
|
|
136
|
+
def test_record_server_failure_existing_server(self):
|
|
137
|
+
"""Test recording additional failures for an already-failed server."""
|
|
138
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
139
|
+
initial_time = time.time() - 100
|
|
140
|
+
manager._failed_servers = {
|
|
141
|
+
"test-server": {
|
|
142
|
+
"last_attempt": initial_time,
|
|
143
|
+
"attempt_count": 2,
|
|
144
|
+
"error": "Old error"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
manager._record_server_failure("test-server", "New error")
|
|
149
|
+
|
|
150
|
+
assert manager._failed_servers["test-server"]["attempt_count"] == 3
|
|
151
|
+
assert manager._failed_servers["test-server"]["error"] == "New error"
|
|
152
|
+
assert manager._failed_servers["test-server"]["last_attempt"] > initial_time
|
|
153
|
+
|
|
154
|
+
def test_clear_server_failure(self):
|
|
155
|
+
"""Test clearing failure tracking after successful connection."""
|
|
156
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
157
|
+
manager._failed_servers = {
|
|
158
|
+
"test-server": {
|
|
159
|
+
"last_attempt": time.time(),
|
|
160
|
+
"attempt_count": 3,
|
|
161
|
+
"error": "Some error"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
manager._clear_server_failure("test-server")
|
|
166
|
+
|
|
167
|
+
assert "test-server" not in manager._failed_servers
|
|
168
|
+
|
|
169
|
+
def test_clear_server_failure_nonexistent(self):
|
|
170
|
+
"""Test clearing a server that wasn't tracked (should not error)."""
|
|
171
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
172
|
+
manager._failed_servers = {}
|
|
173
|
+
|
|
174
|
+
# Should not raise any exception
|
|
175
|
+
manager._clear_server_failure("nonexistent-server")
|
|
176
|
+
|
|
177
|
+
assert "nonexistent-server" not in manager._failed_servers
|
|
178
|
+
|
|
179
|
+
def test_get_failed_servers(self):
|
|
180
|
+
"""Test getting failed servers info."""
|
|
181
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
182
|
+
manager._failed_servers = {
|
|
183
|
+
"server1": {"attempt_count": 1, "error": "Error 1"},
|
|
184
|
+
"server2": {"attempt_count": 3, "error": "Error 2"}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
result = manager.get_failed_servers()
|
|
188
|
+
|
|
189
|
+
assert result == manager._failed_servers
|
|
190
|
+
# Verify it returns a copy, not the original dict
|
|
191
|
+
assert result is not manager._failed_servers
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestMCPBackoffCalculation:
|
|
195
|
+
"""Tests for exponential backoff calculation."""
|
|
196
|
+
|
|
197
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
198
|
+
def test_calculate_backoff_first_attempt(self, mock_config_manager):
|
|
199
|
+
"""Test backoff calculation for first retry attempt."""
|
|
200
|
+
mock_settings = MagicMock()
|
|
201
|
+
mock_settings.mcp_reconnect_interval = 60
|
|
202
|
+
mock_settings.mcp_reconnect_max_interval = 300
|
|
203
|
+
mock_settings.mcp_reconnect_backoff_multiplier = 2.0
|
|
204
|
+
mock_config_manager.app_settings = mock_settings
|
|
205
|
+
|
|
206
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
207
|
+
|
|
208
|
+
delay = manager._calculate_backoff_delay(1)
|
|
209
|
+
|
|
210
|
+
assert delay == 60 # Base interval for first attempt
|
|
211
|
+
|
|
212
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
213
|
+
def test_calculate_backoff_exponential(self, mock_config_manager):
|
|
214
|
+
"""Test exponential backoff for subsequent attempts."""
|
|
215
|
+
mock_settings = MagicMock()
|
|
216
|
+
mock_settings.mcp_reconnect_interval = 60
|
|
217
|
+
mock_settings.mcp_reconnect_max_interval = 300
|
|
218
|
+
mock_settings.mcp_reconnect_backoff_multiplier = 2.0
|
|
219
|
+
mock_config_manager.app_settings = mock_settings
|
|
220
|
+
|
|
221
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
222
|
+
|
|
223
|
+
# Second attempt: 60 * 2^1 = 120
|
|
224
|
+
assert manager._calculate_backoff_delay(2) == 120
|
|
225
|
+
# Third attempt: 60 * 2^2 = 240
|
|
226
|
+
assert manager._calculate_backoff_delay(3) == 240
|
|
227
|
+
# Fourth attempt: 60 * 2^3 = 480, but capped at 300
|
|
228
|
+
assert manager._calculate_backoff_delay(4) == 300
|
|
229
|
+
|
|
230
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
231
|
+
def test_calculate_backoff_max_cap(self, mock_config_manager):
|
|
232
|
+
"""Test that backoff is capped at max_interval."""
|
|
233
|
+
mock_settings = MagicMock()
|
|
234
|
+
mock_settings.mcp_reconnect_interval = 60
|
|
235
|
+
mock_settings.mcp_reconnect_max_interval = 300
|
|
236
|
+
mock_settings.mcp_reconnect_backoff_multiplier = 2.0
|
|
237
|
+
mock_config_manager.app_settings = mock_settings
|
|
238
|
+
|
|
239
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
240
|
+
|
|
241
|
+
# Very high attempt count should still be capped
|
|
242
|
+
delay = manager._calculate_backoff_delay(10)
|
|
243
|
+
|
|
244
|
+
assert delay == 300
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TestMCPConfigReload:
|
|
248
|
+
"""Tests for MCP configuration hot-reload."""
|
|
249
|
+
|
|
250
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
251
|
+
def test_reload_config_updates_servers(self, mock_config_manager):
|
|
252
|
+
"""Test that reload_config updates server configuration."""
|
|
253
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
254
|
+
manager.servers_config = {"old-server": {"description": "Old"}}
|
|
255
|
+
manager._failed_servers = {"old-server": {"attempt_count": 1}}
|
|
256
|
+
|
|
257
|
+
# Mock new config
|
|
258
|
+
mock_new_config = MagicMock()
|
|
259
|
+
mock_server = MagicMock()
|
|
260
|
+
mock_server.model_dump.return_value = {"description": "New"}
|
|
261
|
+
mock_new_config.servers = {"new-server": mock_server}
|
|
262
|
+
mock_config_manager.reload_mcp_config.return_value = mock_new_config
|
|
263
|
+
|
|
264
|
+
result = manager.reload_config()
|
|
265
|
+
|
|
266
|
+
assert "old-server" in result["removed"]
|
|
267
|
+
assert "new-server" in result["added"]
|
|
268
|
+
assert manager.servers_config == {"new-server": {"description": "New"}}
|
|
269
|
+
# Old failed server tracking should be cleared
|
|
270
|
+
assert "old-server" not in manager._failed_servers
|
|
271
|
+
|
|
272
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
273
|
+
def test_reload_config_preserves_unchanged(self, mock_config_manager):
|
|
274
|
+
"""Test that reload_config identifies unchanged servers."""
|
|
275
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
276
|
+
manager.servers_config = {"existing-server": {"description": "Existing"}}
|
|
277
|
+
manager._failed_servers = {}
|
|
278
|
+
|
|
279
|
+
# Mock config with same server
|
|
280
|
+
mock_new_config = MagicMock()
|
|
281
|
+
mock_server = MagicMock()
|
|
282
|
+
mock_server.model_dump.return_value = {"description": "Updated"}
|
|
283
|
+
mock_new_config.servers = {"existing-server": mock_server}
|
|
284
|
+
mock_config_manager.reload_mcp_config.return_value = mock_new_config
|
|
285
|
+
|
|
286
|
+
result = manager.reload_config()
|
|
287
|
+
|
|
288
|
+
assert "existing-server" in result["unchanged"]
|
|
289
|
+
assert result["added"] == []
|
|
290
|
+
assert result["removed"] == []
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
class TestMCPDiscoveryResilience:
|
|
295
|
+
"""Tests to ensure tool/prompt discovery tolerates removed servers."""
|
|
296
|
+
|
|
297
|
+
async def test_discover_tools_skips_removed_server(self):
|
|
298
|
+
"""If a client exists without config, discovery should not crash."""
|
|
299
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
300
|
+
# Simulate one valid server and one removed server
|
|
301
|
+
manager.servers_config = {"server-a": {"description": "A"}}
|
|
302
|
+
manager._failed_servers = {}
|
|
303
|
+
manager.clients = {
|
|
304
|
+
"server-a": AsyncMock(),
|
|
305
|
+
"removed-server": AsyncMock(),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async def fake_discover(server_name, client): # noqa: ARG001
|
|
309
|
+
if server_name == "server-a":
|
|
310
|
+
return {"tools": [MagicMock(name="t1")], "config": {"description": "A"}}
|
|
311
|
+
# Simulate that removed-server had a client but config was deleted
|
|
312
|
+
raise RuntimeError("Simulated failure for removed-server")
|
|
313
|
+
|
|
314
|
+
manager._discover_tools_for_server = fake_discover # type: ignore[assignment]
|
|
315
|
+
|
|
316
|
+
# Should complete without raising, and only include server-a in available_tools
|
|
317
|
+
await manager.discover_tools()
|
|
318
|
+
assert "server-a" in manager.available_tools
|
|
319
|
+
assert "removed-server" not in manager.available_tools
|
|
320
|
+
|
|
321
|
+
@pytest.mark.asyncio
|
|
322
|
+
async def test_discover_prompts_skips_removed_server(self):
|
|
323
|
+
"""Prompt discovery should also skip servers missing from config."""
|
|
324
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
325
|
+
manager.servers_config = {"server-a": {"description": "A"}}
|
|
326
|
+
manager._failed_servers = {}
|
|
327
|
+
manager.clients = {
|
|
328
|
+
"server-a": AsyncMock(),
|
|
329
|
+
"removed-server": AsyncMock(),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async def fake_discover_prompts(server_name, client): # noqa: ARG001
|
|
333
|
+
if server_name == "server-a":
|
|
334
|
+
return {"prompts": [MagicMock(name="p1")], "config": {"description": "A"}}
|
|
335
|
+
raise RuntimeError("Simulated failure for removed-server")
|
|
336
|
+
|
|
337
|
+
manager._discover_prompts_for_server = fake_discover_prompts # type: ignore[assignment]
|
|
338
|
+
|
|
339
|
+
await manager.discover_prompts()
|
|
340
|
+
assert "server-a" in manager.available_prompts
|
|
341
|
+
assert "removed-server" not in manager.available_prompts
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.mark.asyncio
|
|
345
|
+
class TestMCPDiscoveryFailureTracking:
|
|
346
|
+
"""Tests for tracking discovery failures so admin status can reflect them."""
|
|
347
|
+
|
|
348
|
+
async def test_tool_discovery_failure_records_failed_server(self):
|
|
349
|
+
"""Tool discovery exception should record server in _failed_servers."""
|
|
350
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
351
|
+
manager.servers_config = {"bad-server": {"description": "Bad"}}
|
|
352
|
+
manager.clients = {"bad-server": AsyncMock()}
|
|
353
|
+
manager._failed_servers = {}
|
|
354
|
+
|
|
355
|
+
async def failing_discover(server_name, client): # noqa: ARG001
|
|
356
|
+
raise RuntimeError("Simulated tool discovery failure")
|
|
357
|
+
|
|
358
|
+
manager._discover_tools_for_server = failing_discover # type: ignore[assignment]
|
|
359
|
+
|
|
360
|
+
await manager.discover_tools()
|
|
361
|
+
|
|
362
|
+
assert "bad-server" in manager._failed_servers
|
|
363
|
+
error = manager._failed_servers["bad-server"]["error"]
|
|
364
|
+
assert "Simulated tool discovery failure" in error
|
|
365
|
+
|
|
366
|
+
async def test_prompt_discovery_failure_records_failed_server(self):
|
|
367
|
+
"""Prompt discovery exception should record server in _failed_servers."""
|
|
368
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
369
|
+
manager.servers_config = {"bad-server": {"description": "Bad"}}
|
|
370
|
+
manager.clients = {"bad-server": AsyncMock()}
|
|
371
|
+
manager._failed_servers = {}
|
|
372
|
+
|
|
373
|
+
async def failing_discover_prompts(server_name, client): # noqa: ARG001
|
|
374
|
+
raise RuntimeError("Simulated prompt discovery failure")
|
|
375
|
+
|
|
376
|
+
manager._discover_prompts_for_server = failing_discover_prompts # type: ignore[assignment]
|
|
377
|
+
|
|
378
|
+
await manager.discover_prompts()
|
|
379
|
+
|
|
380
|
+
assert "bad-server" in manager._failed_servers
|
|
381
|
+
error = manager._failed_servers["bad-server"]["error"]
|
|
382
|
+
assert "Simulated prompt discovery failure" in error
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@pytest.mark.asyncio
|
|
386
|
+
class TestMCPReconnection:
|
|
387
|
+
"""Tests for MCP server reconnection functionality."""
|
|
388
|
+
|
|
389
|
+
async def test_reconnect_skips_when_no_failed_servers(self):
|
|
390
|
+
"""Test that reconnect returns early when no servers have failed."""
|
|
391
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
392
|
+
manager._failed_servers = {}
|
|
393
|
+
|
|
394
|
+
result = await manager.reconnect_failed_servers()
|
|
395
|
+
|
|
396
|
+
assert result["attempted"] == []
|
|
397
|
+
assert result["reconnected"] == []
|
|
398
|
+
assert result["still_failed"] == []
|
|
399
|
+
assert result["skipped_backoff"] == []
|
|
400
|
+
|
|
401
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
402
|
+
async def test_reconnect_respects_backoff(self, mock_config_manager):
|
|
403
|
+
"""When not forced, reconnect should skip servers still in backoff."""
|
|
404
|
+
mock_settings = MagicMock()
|
|
405
|
+
mock_settings.mcp_reconnect_interval = 60
|
|
406
|
+
mock_settings.mcp_reconnect_max_interval = 300
|
|
407
|
+
mock_settings.mcp_reconnect_backoff_multiplier = 2.0
|
|
408
|
+
mock_config_manager.app_settings = mock_settings
|
|
409
|
+
|
|
410
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
411
|
+
manager.servers_config = {"test-server": {"description": "Test"}}
|
|
412
|
+
manager.clients = {}
|
|
413
|
+
# Server failed just now, should be in backoff
|
|
414
|
+
manager._failed_servers = {
|
|
415
|
+
"test-server": {
|
|
416
|
+
"last_attempt": time.time(),
|
|
417
|
+
"attempt_count": 1,
|
|
418
|
+
"error": "Connection refused"
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
result = await manager.reconnect_failed_servers(force=False)
|
|
423
|
+
|
|
424
|
+
assert result["attempted"] == []
|
|
425
|
+
assert result["skipped_backoff"][0]["server"] == "test-server"
|
|
426
|
+
|
|
427
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
428
|
+
async def test_reconnect_attempts_after_backoff(self, mock_config_manager):
|
|
429
|
+
"""When backoff has elapsed, reconnect should attempt server."""
|
|
430
|
+
mock_settings = MagicMock()
|
|
431
|
+
mock_settings.mcp_reconnect_interval = 60
|
|
432
|
+
mock_settings.mcp_reconnect_max_interval = 300
|
|
433
|
+
mock_settings.mcp_reconnect_backoff_multiplier = 2.0
|
|
434
|
+
mock_config_manager.app_settings = mock_settings
|
|
435
|
+
|
|
436
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
437
|
+
manager.servers_config = {"test-server": {"description": "Test"}}
|
|
438
|
+
manager.clients = {}
|
|
439
|
+
# Server failed long ago, backoff period has passed
|
|
440
|
+
manager._failed_servers = {
|
|
441
|
+
"test-server": {
|
|
442
|
+
"last_attempt": time.time() - 120, # 2 minutes ago
|
|
443
|
+
"attempt_count": 1,
|
|
444
|
+
"error": "Connection refused"
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Mock the initialization method to return None (still failing)
|
|
449
|
+
manager._initialize_single_client = AsyncMock(return_value=None)
|
|
450
|
+
|
|
451
|
+
result = await manager.reconnect_failed_servers(force=False)
|
|
452
|
+
|
|
453
|
+
assert "test-server" in result["attempted"]
|
|
454
|
+
assert "test-server" in result["still_failed"]
|
|
455
|
+
manager._initialize_single_client.assert_called_once()
|
|
456
|
+
|
|
457
|
+
@patch('atlas.modules.mcp_tools.client.config_manager')
|
|
458
|
+
async def test_reconnect_force_ignores_backoff(self, mock_config_manager):
|
|
459
|
+
"""Forced reconnect should attempt even inside backoff window."""
|
|
460
|
+
mock_settings = MagicMock()
|
|
461
|
+
mock_settings.mcp_reconnect_interval = 60
|
|
462
|
+
mock_settings.mcp_reconnect_max_interval = 300
|
|
463
|
+
mock_settings.mcp_reconnect_backoff_multiplier = 2.0
|
|
464
|
+
mock_config_manager.app_settings = mock_settings
|
|
465
|
+
|
|
466
|
+
manager = MCPToolManager.__new__(MCPToolManager)
|
|
467
|
+
manager.servers_config = {"test-server": {"description": "Test"}}
|
|
468
|
+
manager.clients = {}
|
|
469
|
+
# Server failed just now, so normally it would be in backoff
|
|
470
|
+
manager._failed_servers = {
|
|
471
|
+
"test-server": {
|
|
472
|
+
"last_attempt": time.time(),
|
|
473
|
+
"attempt_count": 1,
|
|
474
|
+
"error": "Connection refused"
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
# Mock the initialization method to return None (still failing)
|
|
479
|
+
manager._initialize_single_client = AsyncMock(return_value=None)
|
|
480
|
+
|
|
481
|
+
# With force=True, it should attempt despite backoff
|
|
482
|
+
result = await manager.reconnect_failed_servers(force=True)
|
|
483
|
+
|
|
484
|
+
assert "test-server" in result["attempted"]
|
|
485
|
+
manager._initialize_single_client.assert_called_once()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class TestConfigManagerMCPReload:
|
|
489
|
+
"""Tests for ConfigManager MCP reload functionality."""
|
|
490
|
+
|
|
491
|
+
@patch('atlas.modules.config.config_manager.ConfigManager._search_paths')
|
|
492
|
+
@patch('atlas.modules.config.config_manager.ConfigManager._load_file_with_error_handling')
|
|
493
|
+
@patch('atlas.modules.config.config_manager.ConfigManager._validate_mcp_compliance_levels')
|
|
494
|
+
def test_reload_mcp_config_clears_cache(
|
|
495
|
+
self, mock_validate, mock_load, mock_search
|
|
496
|
+
):
|
|
497
|
+
"""Test that reload_mcp_config clears the cached config."""
|
|
498
|
+
from atlas.modules.config.config_manager import ConfigManager
|
|
499
|
+
|
|
500
|
+
manager = ConfigManager()
|
|
501
|
+
# Pre-populate cache
|
|
502
|
+
manager._mcp_config = MagicMock()
|
|
503
|
+
manager._tool_approvals_config = MagicMock()
|
|
504
|
+
|
|
505
|
+
# Mock the config loading
|
|
506
|
+
mock_search.return_value = []
|
|
507
|
+
mock_load.return_value = {"test-server": {"description": "Test"}}
|
|
508
|
+
|
|
509
|
+
manager.reload_mcp_config()
|
|
510
|
+
|
|
511
|
+
# Cache should have been cleared and reloaded
|
|
512
|
+
assert manager._tool_approvals_config is None or manager._tool_approvals_config != MagicMock()
|