hatch-xclam 0.7.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.
- hatch/__init__.py +21 -0
- hatch/cli_hatch.py +2748 -0
- hatch/environment_manager.py +1375 -0
- hatch/installers/__init__.py +25 -0
- hatch/installers/dependency_installation_orchestrator.py +636 -0
- hatch/installers/docker_installer.py +545 -0
- hatch/installers/hatch_installer.py +198 -0
- hatch/installers/installation_context.py +109 -0
- hatch/installers/installer_base.py +195 -0
- hatch/installers/python_installer.py +342 -0
- hatch/installers/registry.py +179 -0
- hatch/installers/system_installer.py +588 -0
- hatch/mcp_host_config/__init__.py +38 -0
- hatch/mcp_host_config/backup.py +458 -0
- hatch/mcp_host_config/host_management.py +572 -0
- hatch/mcp_host_config/models.py +602 -0
- hatch/mcp_host_config/reporting.py +181 -0
- hatch/mcp_host_config/strategies.py +513 -0
- hatch/package_loader.py +263 -0
- hatch/python_environment_manager.py +734 -0
- hatch/registry_explorer.py +171 -0
- hatch/registry_retriever.py +335 -0
- hatch/template_generator.py +179 -0
- hatch_xclam-0.7.0.dist-info/METADATA +150 -0
- hatch_xclam-0.7.0.dist-info/RECORD +93 -0
- hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
- hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
- hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
- hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/run_environment_tests.py +124 -0
- tests/test_cli_version.py +122 -0
- tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
- tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
- tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
- tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
- tests/test_data_utils.py +472 -0
- tests/test_dependency_orchestrator_consent.py +266 -0
- tests/test_docker_installer.py +524 -0
- tests/test_env_manip.py +991 -0
- tests/test_hatch_installer.py +179 -0
- tests/test_installer_base.py +221 -0
- tests/test_mcp_atomic_operations.py +276 -0
- tests/test_mcp_backup_integration.py +308 -0
- tests/test_mcp_cli_all_host_specific_args.py +303 -0
- tests/test_mcp_cli_backup_management.py +295 -0
- tests/test_mcp_cli_direct_management.py +453 -0
- tests/test_mcp_cli_discovery_listing.py +582 -0
- tests/test_mcp_cli_host_config_integration.py +823 -0
- tests/test_mcp_cli_package_management.py +360 -0
- tests/test_mcp_cli_partial_updates.py +859 -0
- tests/test_mcp_environment_integration.py +520 -0
- tests/test_mcp_host_config_backup.py +257 -0
- tests/test_mcp_host_configuration_manager.py +331 -0
- tests/test_mcp_host_registry_decorator.py +348 -0
- tests/test_mcp_pydantic_architecture_v4.py +603 -0
- tests/test_mcp_server_config_models.py +242 -0
- tests/test_mcp_server_config_type_field.py +221 -0
- tests/test_mcp_sync_functionality.py +316 -0
- tests/test_mcp_user_feedback_reporting.py +359 -0
- tests/test_non_tty_integration.py +281 -0
- tests/test_online_package_loader.py +202 -0
- tests/test_python_environment_manager.py +882 -0
- tests/test_python_installer.py +327 -0
- tests/test_registry.py +51 -0
- tests/test_registry_retriever.py +250 -0
- tests/test_system_installer.py +733 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Tests for MCPHostConfigBackupManager.
|
|
2
|
+
|
|
3
|
+
This module contains tests for the MCP host configuration backup functionality,
|
|
4
|
+
including backup creation, restoration, and management with host-agnostic design.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import unittest
|
|
8
|
+
import tempfile
|
|
9
|
+
import shutil
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from unittest.mock import patch, Mock
|
|
14
|
+
|
|
15
|
+
from wobble.decorators import regression_test, integration_test, slow_test
|
|
16
|
+
from test_data_utils import MCPBackupTestDataLoader
|
|
17
|
+
|
|
18
|
+
from hatch.mcp_host_config.backup import (
|
|
19
|
+
MCPHostConfigBackupManager,
|
|
20
|
+
BackupInfo,
|
|
21
|
+
BackupResult,
|
|
22
|
+
BackupError
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestMCPHostConfigBackupManager(unittest.TestCase):
|
|
27
|
+
"""Test MCPHostConfigBackupManager core functionality with host-agnostic design."""
|
|
28
|
+
|
|
29
|
+
def setUp(self):
|
|
30
|
+
"""Set up test environment with host-agnostic configurations."""
|
|
31
|
+
self.temp_dir = Path(tempfile.mkdtemp(prefix="test_mcp_backup_"))
|
|
32
|
+
self.backup_root = self.temp_dir / "backups"
|
|
33
|
+
self.config_dir = self.temp_dir / "configs"
|
|
34
|
+
self.config_dir.mkdir(parents=True)
|
|
35
|
+
|
|
36
|
+
# Initialize test data loader
|
|
37
|
+
self.test_data = MCPBackupTestDataLoader()
|
|
38
|
+
|
|
39
|
+
# Create host-agnostic test configuration files
|
|
40
|
+
self.test_configs = {}
|
|
41
|
+
for hostname in ['claude-desktop', 'vscode', 'cursor', 'lmstudio']:
|
|
42
|
+
config_data = self.test_data.load_host_agnostic_config("simple_server")
|
|
43
|
+
config_file = self.config_dir / f"{hostname}_config.json"
|
|
44
|
+
with open(config_file, 'w') as f:
|
|
45
|
+
json.dump(config_data, f, indent=2)
|
|
46
|
+
self.test_configs[hostname] = config_file
|
|
47
|
+
|
|
48
|
+
self.backup_manager = MCPHostConfigBackupManager(backup_root=self.backup_root)
|
|
49
|
+
|
|
50
|
+
def tearDown(self):
|
|
51
|
+
"""Clean up test environment."""
|
|
52
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
53
|
+
|
|
54
|
+
@regression_test
|
|
55
|
+
def test_backup_directory_creation(self):
|
|
56
|
+
"""Test automatic backup directory creation."""
|
|
57
|
+
self.assertTrue(self.backup_root.exists())
|
|
58
|
+
self.assertTrue(self.backup_root.is_dir())
|
|
59
|
+
|
|
60
|
+
@regression_test
|
|
61
|
+
def test_create_backup_success_all_hosts(self):
|
|
62
|
+
"""Test successful backup creation for all supported host types."""
|
|
63
|
+
for hostname, config_file in self.test_configs.items():
|
|
64
|
+
with self.subTest(hostname=hostname):
|
|
65
|
+
result = self.backup_manager.create_backup(config_file, hostname)
|
|
66
|
+
|
|
67
|
+
# Validate BackupResult Pydantic model
|
|
68
|
+
self.assertIsInstance(result, BackupResult)
|
|
69
|
+
self.assertTrue(result.success)
|
|
70
|
+
self.assertIsNotNone(result.backup_path)
|
|
71
|
+
self.assertTrue(result.backup_path.exists())
|
|
72
|
+
self.assertGreater(result.backup_size, 0)
|
|
73
|
+
self.assertEqual(result.original_size, result.backup_size)
|
|
74
|
+
|
|
75
|
+
# Verify backup filename format (host-agnostic)
|
|
76
|
+
expected_pattern = rf"mcp\.json\.{hostname}\.\d{{8}}_\d{{6}}_\d{{6}}"
|
|
77
|
+
self.assertRegex(result.backup_path.name, expected_pattern)
|
|
78
|
+
|
|
79
|
+
@regression_test
|
|
80
|
+
def test_create_backup_nonexistent_file(self):
|
|
81
|
+
"""Test backup creation with nonexistent source file."""
|
|
82
|
+
nonexistent = self.config_dir / "nonexistent.json"
|
|
83
|
+
result = self.backup_manager.create_backup(nonexistent, "claude-desktop")
|
|
84
|
+
|
|
85
|
+
self.assertFalse(result.success)
|
|
86
|
+
self.assertIsNotNone(result.error_message)
|
|
87
|
+
self.assertIn("not found", result.error_message.lower())
|
|
88
|
+
|
|
89
|
+
@regression_test
|
|
90
|
+
def test_backup_content_integrity_host_agnostic(self):
|
|
91
|
+
"""Test backup content matches original for any host configuration format."""
|
|
92
|
+
hostname = 'claude-desktop'
|
|
93
|
+
config_file = self.test_configs[hostname]
|
|
94
|
+
original_content = config_file.read_text()
|
|
95
|
+
|
|
96
|
+
result = self.backup_manager.create_backup(config_file, hostname)
|
|
97
|
+
|
|
98
|
+
self.assertTrue(result.success)
|
|
99
|
+
backup_content = result.backup_path.read_text()
|
|
100
|
+
self.assertEqual(original_content, backup_content)
|
|
101
|
+
|
|
102
|
+
# Verify JSON structure is preserved (host-agnostic validation)
|
|
103
|
+
original_json = json.loads(original_content)
|
|
104
|
+
backup_json = json.loads(backup_content)
|
|
105
|
+
self.assertEqual(original_json, backup_json)
|
|
106
|
+
|
|
107
|
+
@regression_test
|
|
108
|
+
def test_multiple_backups_same_host(self):
|
|
109
|
+
"""Test creating multiple backups for same host."""
|
|
110
|
+
hostname = 'vscode'
|
|
111
|
+
config_file = self.test_configs[hostname]
|
|
112
|
+
|
|
113
|
+
# Create first backup
|
|
114
|
+
result1 = self.backup_manager.create_backup(config_file, hostname)
|
|
115
|
+
self.assertTrue(result1.success)
|
|
116
|
+
|
|
117
|
+
# Modify config and create second backup
|
|
118
|
+
modified_config = self.test_data.load_host_agnostic_config("complex_server")
|
|
119
|
+
with open(config_file, 'w') as f:
|
|
120
|
+
json.dump(modified_config, f, indent=2)
|
|
121
|
+
|
|
122
|
+
result2 = self.backup_manager.create_backup(config_file, hostname)
|
|
123
|
+
self.assertTrue(result2.success)
|
|
124
|
+
|
|
125
|
+
# Verify both backups exist and are different
|
|
126
|
+
self.assertTrue(result1.backup_path.exists())
|
|
127
|
+
self.assertTrue(result2.backup_path.exists())
|
|
128
|
+
self.assertNotEqual(result1.backup_path, result2.backup_path)
|
|
129
|
+
|
|
130
|
+
@regression_test
|
|
131
|
+
def test_list_backups_empty(self):
|
|
132
|
+
"""Test listing backups when none exist."""
|
|
133
|
+
backups = self.backup_manager.list_backups("claude-desktop")
|
|
134
|
+
self.assertEqual(len(backups), 0)
|
|
135
|
+
|
|
136
|
+
@regression_test
|
|
137
|
+
def test_list_backups_pydantic_validation(self):
|
|
138
|
+
"""Test listing backups returns valid Pydantic models."""
|
|
139
|
+
hostname = 'cursor'
|
|
140
|
+
config_file = self.test_configs[hostname]
|
|
141
|
+
|
|
142
|
+
# Create multiple backups
|
|
143
|
+
self.backup_manager.create_backup(config_file, hostname)
|
|
144
|
+
self.backup_manager.create_backup(config_file, hostname)
|
|
145
|
+
|
|
146
|
+
backups = self.backup_manager.list_backups(hostname)
|
|
147
|
+
self.assertEqual(len(backups), 2)
|
|
148
|
+
|
|
149
|
+
# Verify BackupInfo Pydantic model validation
|
|
150
|
+
for backup in backups:
|
|
151
|
+
self.assertIsInstance(backup, BackupInfo)
|
|
152
|
+
self.assertEqual(backup.hostname, hostname)
|
|
153
|
+
self.assertIsInstance(backup.timestamp, datetime)
|
|
154
|
+
self.assertTrue(backup.file_path.exists())
|
|
155
|
+
self.assertGreater(backup.file_size, 0)
|
|
156
|
+
|
|
157
|
+
# Test Pydantic serialization
|
|
158
|
+
backup_dict = backup.dict()
|
|
159
|
+
self.assertIn('hostname', backup_dict)
|
|
160
|
+
self.assertIn('timestamp', backup_dict)
|
|
161
|
+
|
|
162
|
+
# Test JSON serialization
|
|
163
|
+
backup_json = backup.json()
|
|
164
|
+
self.assertIsInstance(backup_json, str)
|
|
165
|
+
|
|
166
|
+
# Verify sorting (newest first)
|
|
167
|
+
self.assertGreaterEqual(backups[0].timestamp, backups[1].timestamp)
|
|
168
|
+
|
|
169
|
+
@regression_test
|
|
170
|
+
def test_backup_validation_unsupported_hostname(self):
|
|
171
|
+
"""Test Pydantic validation rejects unsupported hostnames."""
|
|
172
|
+
config_file = self.test_configs['claude-desktop']
|
|
173
|
+
|
|
174
|
+
# Test with unsupported hostname
|
|
175
|
+
result = self.backup_manager.create_backup(config_file, 'unsupported-host')
|
|
176
|
+
|
|
177
|
+
self.assertFalse(result.success)
|
|
178
|
+
self.assertIn('unsupported', result.error_message.lower())
|
|
179
|
+
|
|
180
|
+
@regression_test
|
|
181
|
+
def test_multiple_hosts_isolation(self):
|
|
182
|
+
"""Test backup isolation between different host types."""
|
|
183
|
+
# Create backups for multiple hosts
|
|
184
|
+
results = {}
|
|
185
|
+
for hostname, config_file in self.test_configs.items():
|
|
186
|
+
results[hostname] = self.backup_manager.create_backup(config_file, hostname)
|
|
187
|
+
self.assertTrue(results[hostname].success)
|
|
188
|
+
|
|
189
|
+
# Verify separate backup directories
|
|
190
|
+
for hostname in self.test_configs.keys():
|
|
191
|
+
backups = self.backup_manager.list_backups(hostname)
|
|
192
|
+
self.assertEqual(len(backups), 1)
|
|
193
|
+
|
|
194
|
+
# Verify backup isolation (different directories)
|
|
195
|
+
backup_dir = backups[0].file_path.parent
|
|
196
|
+
self.assertEqual(backup_dir.name, hostname)
|
|
197
|
+
|
|
198
|
+
# Verify no cross-contamination
|
|
199
|
+
for other_hostname in self.test_configs.keys():
|
|
200
|
+
if other_hostname != hostname:
|
|
201
|
+
other_backups = self.backup_manager.list_backups(other_hostname)
|
|
202
|
+
self.assertNotEqual(
|
|
203
|
+
backups[0].file_path.parent,
|
|
204
|
+
other_backups[0].file_path.parent
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@regression_test
|
|
208
|
+
def test_clean_backups_older_than_days(self):
|
|
209
|
+
"""Test cleaning backups older than specified days."""
|
|
210
|
+
hostname = 'lmstudio'
|
|
211
|
+
config_file = self.test_configs[hostname]
|
|
212
|
+
|
|
213
|
+
# Create backup
|
|
214
|
+
result = self.backup_manager.create_backup(config_file, hostname)
|
|
215
|
+
self.assertTrue(result.success)
|
|
216
|
+
|
|
217
|
+
# Mock old backup by modifying timestamp
|
|
218
|
+
old_backup_path = result.backup_path.parent / "mcp.json.lmstudio.20200101_120000_000000"
|
|
219
|
+
shutil.copy2(result.backup_path, old_backup_path)
|
|
220
|
+
|
|
221
|
+
# Clean backups older than 1 day (should remove the old one)
|
|
222
|
+
cleaned_count = self.backup_manager.clean_backups(hostname, older_than_days=1)
|
|
223
|
+
|
|
224
|
+
# Verify old backup was cleaned
|
|
225
|
+
self.assertGreater(cleaned_count, 0)
|
|
226
|
+
self.assertFalse(old_backup_path.exists())
|
|
227
|
+
self.assertTrue(result.backup_path.exists()) # Recent backup should remain
|
|
228
|
+
|
|
229
|
+
@regression_test
|
|
230
|
+
def test_clean_backups_keep_count(self):
|
|
231
|
+
"""Test cleaning backups to keep only specified count."""
|
|
232
|
+
hostname = 'claude-desktop'
|
|
233
|
+
config_file = self.test_configs[hostname]
|
|
234
|
+
|
|
235
|
+
# Create multiple backups
|
|
236
|
+
for i in range(5):
|
|
237
|
+
self.backup_manager.create_backup(config_file, hostname)
|
|
238
|
+
|
|
239
|
+
# Verify 5 backups exist
|
|
240
|
+
backups_before = self.backup_manager.list_backups(hostname)
|
|
241
|
+
self.assertEqual(len(backups_before), 5)
|
|
242
|
+
|
|
243
|
+
# Clean to keep only 2 backups
|
|
244
|
+
cleaned_count = self.backup_manager.clean_backups(hostname, keep_count=2)
|
|
245
|
+
|
|
246
|
+
# Verify only 2 backups remain
|
|
247
|
+
backups_after = self.backup_manager.list_backups(hostname)
|
|
248
|
+
self.assertEqual(len(backups_after), 2)
|
|
249
|
+
self.assertEqual(cleaned_count, 3)
|
|
250
|
+
|
|
251
|
+
# Verify newest backups were kept
|
|
252
|
+
for backup in backups_after:
|
|
253
|
+
self.assertIn(backup, backups_before[:2]) # Should be the first 2 (newest)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == '__main__':
|
|
257
|
+
unittest.main()
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test suite for MCP host configuration manager.
|
|
3
|
+
|
|
4
|
+
This module tests the core configuration manager with consolidated models
|
|
5
|
+
and integration with backup system.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
# Add the parent directory to the path to import wobble
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from wobble.decorators import regression_test, integration_test
|
|
20
|
+
except ImportError:
|
|
21
|
+
# Fallback decorators if wobble is not available
|
|
22
|
+
def regression_test(func):
|
|
23
|
+
return func
|
|
24
|
+
|
|
25
|
+
def integration_test(scope="component"):
|
|
26
|
+
def decorator(func):
|
|
27
|
+
return func
|
|
28
|
+
return decorator
|
|
29
|
+
|
|
30
|
+
from test_data_utils import MCPHostConfigTestDataLoader
|
|
31
|
+
from hatch.mcp_host_config.host_management import MCPHostConfigurationManager, MCPHostRegistry, register_host_strategy
|
|
32
|
+
from hatch.mcp_host_config.models import MCPHostType, MCPServerConfig, HostConfiguration, ConfigurationResult, SyncResult
|
|
33
|
+
from hatch.mcp_host_config.strategies import MCPHostStrategy
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestMCPHostConfigurationManager(unittest.TestCase):
|
|
37
|
+
"""Test suite for MCP host configuration manager."""
|
|
38
|
+
|
|
39
|
+
def setUp(self):
|
|
40
|
+
"""Set up test environment."""
|
|
41
|
+
self.test_data_loader = MCPHostConfigTestDataLoader()
|
|
42
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
43
|
+
self.temp_config_path = Path(self.temp_dir) / "test_config.json"
|
|
44
|
+
|
|
45
|
+
# Clear registry before each test
|
|
46
|
+
MCPHostRegistry._strategies.clear()
|
|
47
|
+
MCPHostRegistry._instances.clear()
|
|
48
|
+
|
|
49
|
+
# Store temp_config_path for strategy access
|
|
50
|
+
temp_config_path = self.temp_config_path
|
|
51
|
+
|
|
52
|
+
# Register test strategy
|
|
53
|
+
@register_host_strategy(MCPHostType.CLAUDE_DESKTOP)
|
|
54
|
+
class TestStrategy(MCPHostStrategy):
|
|
55
|
+
def get_config_path(self):
|
|
56
|
+
return temp_config_path
|
|
57
|
+
|
|
58
|
+
def is_host_available(self):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def read_configuration(self):
|
|
62
|
+
if temp_config_path.exists():
|
|
63
|
+
with open(temp_config_path, 'r') as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
|
|
66
|
+
servers = {}
|
|
67
|
+
if "mcpServers" in data:
|
|
68
|
+
for name, config in data["mcpServers"].items():
|
|
69
|
+
servers[name] = MCPServerConfig(**config)
|
|
70
|
+
|
|
71
|
+
return HostConfiguration(servers=servers)
|
|
72
|
+
else:
|
|
73
|
+
return HostConfiguration(servers={})
|
|
74
|
+
|
|
75
|
+
def write_configuration(self, config, no_backup=False):
|
|
76
|
+
try:
|
|
77
|
+
# Convert MCPServerConfig objects to dict
|
|
78
|
+
servers_dict = {}
|
|
79
|
+
for name, server_config in config.servers.items():
|
|
80
|
+
servers_dict[name] = server_config.model_dump(exclude_none=True)
|
|
81
|
+
|
|
82
|
+
# Create configuration data
|
|
83
|
+
config_data = {"mcpServers": servers_dict}
|
|
84
|
+
|
|
85
|
+
# Write to file
|
|
86
|
+
with open(temp_config_path, 'w') as f:
|
|
87
|
+
json.dump(config_data, f, indent=2)
|
|
88
|
+
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def validate_server_config(self, server_config):
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
self.manager = MCPHostConfigurationManager()
|
|
97
|
+
self.temp_config_path = self.temp_config_path
|
|
98
|
+
|
|
99
|
+
def tearDown(self):
|
|
100
|
+
"""Clean up test environment."""
|
|
101
|
+
# Clean up temp files
|
|
102
|
+
if self.temp_config_path.exists():
|
|
103
|
+
self.temp_config_path.unlink()
|
|
104
|
+
os.rmdir(self.temp_dir)
|
|
105
|
+
|
|
106
|
+
# Clear registry after each test
|
|
107
|
+
MCPHostRegistry._strategies.clear()
|
|
108
|
+
MCPHostRegistry._instances.clear()
|
|
109
|
+
|
|
110
|
+
@regression_test
|
|
111
|
+
def test_configure_server_success(self):
|
|
112
|
+
"""Test successful server configuration."""
|
|
113
|
+
server_config_data = self.test_data_loader.load_mcp_server_config("local")
|
|
114
|
+
server_config = MCPServerConfig(**server_config_data)
|
|
115
|
+
# Add name attribute for the manager to use
|
|
116
|
+
server_config.name = "test_server"
|
|
117
|
+
|
|
118
|
+
result = self.manager.configure_server(
|
|
119
|
+
server_config=server_config,
|
|
120
|
+
hostname="claude-desktop"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
self.assertIsInstance(result, ConfigurationResult)
|
|
124
|
+
if not result.success:
|
|
125
|
+
print(f"Configuration failed: {result.error_message}")
|
|
126
|
+
self.assertTrue(result.success)
|
|
127
|
+
self.assertIsNone(result.error_message)
|
|
128
|
+
self.assertEqual(result.hostname, "claude-desktop")
|
|
129
|
+
self.assertEqual(result.server_name, "test_server")
|
|
130
|
+
|
|
131
|
+
# Verify configuration was written
|
|
132
|
+
self.assertTrue(self.temp_config_path.exists())
|
|
133
|
+
|
|
134
|
+
# Verify configuration content
|
|
135
|
+
with open(self.temp_config_path, 'r') as f:
|
|
136
|
+
config_data = json.load(f)
|
|
137
|
+
|
|
138
|
+
self.assertIn("mcpServers", config_data)
|
|
139
|
+
self.assertIn("test_server", config_data["mcpServers"])
|
|
140
|
+
self.assertEqual(config_data["mcpServers"]["test_server"]["command"], "python")
|
|
141
|
+
|
|
142
|
+
@regression_test
|
|
143
|
+
def test_configure_server_unknown_host_type(self):
|
|
144
|
+
"""Test configuration with unknown host type."""
|
|
145
|
+
server_config_data = self.test_data_loader.load_mcp_server_config("local")
|
|
146
|
+
server_config = MCPServerConfig(**server_config_data)
|
|
147
|
+
server_config.name = "test_server"
|
|
148
|
+
|
|
149
|
+
# Clear registry to simulate unknown host type
|
|
150
|
+
MCPHostRegistry._strategies.clear()
|
|
151
|
+
|
|
152
|
+
result = self.manager.configure_server(
|
|
153
|
+
server_config=server_config,
|
|
154
|
+
hostname="claude-desktop"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self.assertIsInstance(result, ConfigurationResult)
|
|
158
|
+
self.assertFalse(result.success)
|
|
159
|
+
self.assertIsNotNone(result.error_message)
|
|
160
|
+
self.assertIn("Unknown host type", result.error_message)
|
|
161
|
+
|
|
162
|
+
@regression_test
|
|
163
|
+
def test_configure_server_validation_failure(self):
|
|
164
|
+
"""Test configuration with validation failure."""
|
|
165
|
+
# Create server config that will fail validation at the strategy level
|
|
166
|
+
server_config_data = self.test_data_loader.load_mcp_server_config("local")
|
|
167
|
+
server_config = MCPServerConfig(**server_config_data)
|
|
168
|
+
server_config.name = "test_server"
|
|
169
|
+
|
|
170
|
+
# Override the test strategy to always fail validation
|
|
171
|
+
@register_host_strategy(MCPHostType.CLAUDE_DESKTOP)
|
|
172
|
+
class FailingValidationStrategy(MCPHostStrategy):
|
|
173
|
+
def get_config_path(self):
|
|
174
|
+
return self.temp_config_path
|
|
175
|
+
|
|
176
|
+
def is_host_available(self):
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def read_configuration(self):
|
|
180
|
+
return HostConfiguration(servers={})
|
|
181
|
+
|
|
182
|
+
def write_configuration(self, config, no_backup=False):
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
def validate_server_config(self, server_config):
|
|
186
|
+
return False # Always fail validation
|
|
187
|
+
|
|
188
|
+
result = self.manager.configure_server(
|
|
189
|
+
server_config=server_config,
|
|
190
|
+
hostname="claude-desktop"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self.assertIsInstance(result, ConfigurationResult)
|
|
194
|
+
self.assertFalse(result.success)
|
|
195
|
+
self.assertIsNotNone(result.error_message)
|
|
196
|
+
self.assertIn("Server configuration invalid", result.error_message)
|
|
197
|
+
|
|
198
|
+
@regression_test
|
|
199
|
+
def test_remove_server_success(self):
|
|
200
|
+
"""Test successful server removal."""
|
|
201
|
+
# First configure a server
|
|
202
|
+
server_config_data = self.test_data_loader.load_mcp_server_config("local")
|
|
203
|
+
server_config = MCPServerConfig(**server_config_data)
|
|
204
|
+
server_config.name = "test_server"
|
|
205
|
+
|
|
206
|
+
self.manager.configure_server(
|
|
207
|
+
server_config=server_config,
|
|
208
|
+
hostname="claude-desktop"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Verify server exists
|
|
212
|
+
with open(self.temp_config_path, 'r') as f:
|
|
213
|
+
config_data = json.load(f)
|
|
214
|
+
self.assertIn("test_server", config_data["mcpServers"])
|
|
215
|
+
|
|
216
|
+
# Remove server
|
|
217
|
+
result = self.manager.remove_server(
|
|
218
|
+
server_name="test_server",
|
|
219
|
+
hostname="claude-desktop"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self.assertIsInstance(result, ConfigurationResult)
|
|
223
|
+
self.assertTrue(result.success)
|
|
224
|
+
self.assertIsNone(result.error_message)
|
|
225
|
+
|
|
226
|
+
# Verify server was removed
|
|
227
|
+
with open(self.temp_config_path, 'r') as f:
|
|
228
|
+
config_data = json.load(f)
|
|
229
|
+
self.assertNotIn("test_server", config_data["mcpServers"])
|
|
230
|
+
|
|
231
|
+
@regression_test
|
|
232
|
+
def test_remove_server_not_found(self):
|
|
233
|
+
"""Test removing non-existent server."""
|
|
234
|
+
result = self.manager.remove_server(
|
|
235
|
+
server_name="nonexistent_server",
|
|
236
|
+
hostname="claude-desktop"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
self.assertIsInstance(result, ConfigurationResult)
|
|
240
|
+
self.assertFalse(result.success)
|
|
241
|
+
self.assertIsNotNone(result.error_message)
|
|
242
|
+
self.assertIn("Server 'nonexistent_server' not found", result.error_message)
|
|
243
|
+
|
|
244
|
+
@regression_test
|
|
245
|
+
def test_sync_environment_to_hosts_success(self):
|
|
246
|
+
"""Test successful environment synchronization."""
|
|
247
|
+
from hatch.mcp_host_config.models import EnvironmentData, EnvironmentPackageEntry, PackageHostConfiguration
|
|
248
|
+
from datetime import datetime
|
|
249
|
+
|
|
250
|
+
# Create test environment data
|
|
251
|
+
server_config_data = self.test_data_loader.load_mcp_server_config("local")
|
|
252
|
+
server_config = MCPServerConfig(**server_config_data)
|
|
253
|
+
|
|
254
|
+
host_config = PackageHostConfiguration(
|
|
255
|
+
config_path="~/test/config.json",
|
|
256
|
+
configured_at=datetime.fromisoformat("2025-09-21T10:00:00.000000"),
|
|
257
|
+
last_synced=datetime.fromisoformat("2025-09-21T10:00:00.000000"),
|
|
258
|
+
server_config=server_config
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
package = EnvironmentPackageEntry(
|
|
262
|
+
name="test-package",
|
|
263
|
+
version="1.0.0",
|
|
264
|
+
type="hatch",
|
|
265
|
+
source="github:user/test-package",
|
|
266
|
+
installed_at=datetime.fromisoformat("2025-09-21T10:00:00.000000"),
|
|
267
|
+
configured_hosts={"claude-desktop": host_config}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
env_data = EnvironmentData(
|
|
271
|
+
name="test_env",
|
|
272
|
+
description="Test environment",
|
|
273
|
+
created_at=datetime.fromisoformat("2025-09-21T10:00:00.000000"),
|
|
274
|
+
packages=[package]
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Sync environment to hosts
|
|
278
|
+
result = self.manager.sync_environment_to_hosts(
|
|
279
|
+
env_data=env_data,
|
|
280
|
+
target_hosts=["claude-desktop"]
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
self.assertIsInstance(result, SyncResult)
|
|
284
|
+
self.assertTrue(result.success)
|
|
285
|
+
self.assertEqual(result.servers_synced, 1)
|
|
286
|
+
self.assertEqual(result.hosts_updated, 1)
|
|
287
|
+
self.assertEqual(len(result.results), 1)
|
|
288
|
+
|
|
289
|
+
# Verify configuration was written
|
|
290
|
+
self.assertTrue(self.temp_config_path.exists())
|
|
291
|
+
|
|
292
|
+
# Verify configuration content
|
|
293
|
+
with open(self.temp_config_path, 'r') as f:
|
|
294
|
+
config_data = json.load(f)
|
|
295
|
+
|
|
296
|
+
self.assertIn("mcpServers", config_data)
|
|
297
|
+
self.assertIn("test-package", config_data["mcpServers"])
|
|
298
|
+
self.assertEqual(config_data["mcpServers"]["test-package"]["command"], "python")
|
|
299
|
+
|
|
300
|
+
@regression_test
|
|
301
|
+
def test_sync_environment_to_hosts_no_servers(self):
|
|
302
|
+
"""Test environment synchronization with no servers."""
|
|
303
|
+
from hatch.mcp_host_config.models import EnvironmentData
|
|
304
|
+
from datetime import datetime
|
|
305
|
+
|
|
306
|
+
# Create empty environment data
|
|
307
|
+
env_data = EnvironmentData(
|
|
308
|
+
name="empty_env",
|
|
309
|
+
description="Empty environment",
|
|
310
|
+
created_at=datetime.fromisoformat("2025-09-21T10:00:00.000000"),
|
|
311
|
+
packages=[]
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Sync environment to hosts
|
|
315
|
+
result = self.manager.sync_environment_to_hosts(
|
|
316
|
+
env_data=env_data,
|
|
317
|
+
target_hosts=["claude-desktop"]
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
self.assertIsInstance(result, SyncResult)
|
|
321
|
+
self.assertTrue(result.success) # Success even with no servers
|
|
322
|
+
self.assertEqual(result.servers_synced, 0)
|
|
323
|
+
self.assertEqual(result.hosts_updated, 1)
|
|
324
|
+
self.assertEqual(len(result.results), 1)
|
|
325
|
+
|
|
326
|
+
# Verify result message
|
|
327
|
+
self.assertEqual(result.results[0].error_message, "No servers to sync")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == '__main__':
|
|
331
|
+
unittest.main()
|