hatch-xclam 0.7.1.dev3__py3-none-any.whl → 0.8.0.dev1__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 +1 -1
- hatch/cli/__init__.py +71 -0
- hatch/cli/__main__.py +1035 -0
- hatch/cli/cli_env.py +865 -0
- hatch/cli/cli_mcp.py +1965 -0
- hatch/cli/cli_package.py +566 -0
- hatch/cli/cli_system.py +136 -0
- hatch/cli/cli_utils.py +1289 -0
- hatch/cli_hatch.py +160 -2838
- hatch/mcp_host_config/__init__.py +10 -10
- hatch/mcp_host_config/adapters/__init__.py +34 -0
- hatch/mcp_host_config/adapters/base.py +170 -0
- hatch/mcp_host_config/adapters/claude.py +105 -0
- hatch/mcp_host_config/adapters/codex.py +104 -0
- hatch/mcp_host_config/adapters/cursor.py +83 -0
- hatch/mcp_host_config/adapters/gemini.py +75 -0
- hatch/mcp_host_config/adapters/kiro.py +78 -0
- hatch/mcp_host_config/adapters/lmstudio.py +79 -0
- hatch/mcp_host_config/adapters/registry.py +149 -0
- hatch/mcp_host_config/adapters/vscode.py +83 -0
- hatch/mcp_host_config/backup.py +5 -3
- hatch/mcp_host_config/fields.py +126 -0
- hatch/mcp_host_config/models.py +161 -456
- hatch/mcp_host_config/reporting.py +57 -16
- hatch/mcp_host_config/strategies.py +155 -87
- hatch/template_generator.py +1 -1
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/METADATA +3 -2
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/RECORD +52 -43
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/WHEEL +1 -1
- hatch_xclam-0.8.0.dev1.dist-info/entry_points.txt +2 -0
- tests/cli_test_utils.py +280 -0
- tests/integration/cli/__init__.py +14 -0
- tests/integration/cli/test_cli_reporter_integration.py +2439 -0
- tests/integration/mcp/__init__.py +0 -0
- tests/integration/mcp/test_adapter_serialization.py +173 -0
- tests/regression/cli/__init__.py +16 -0
- tests/regression/cli/test_color_logic.py +268 -0
- tests/regression/cli/test_consequence_type.py +298 -0
- tests/regression/cli/test_error_formatting.py +328 -0
- tests/regression/cli/test_result_reporter.py +586 -0
- tests/regression/cli/test_table_formatter.py +211 -0
- tests/regression/mcp/__init__.py +0 -0
- tests/regression/mcp/test_field_filtering.py +162 -0
- tests/test_cli_version.py +7 -5
- tests/test_data/fixtures/cli_reporter_fixtures.py +184 -0
- tests/unit/__init__.py +0 -0
- tests/unit/mcp/__init__.py +0 -0
- tests/unit/mcp/test_adapter_protocol.py +138 -0
- tests/unit/mcp/test_adapter_registry.py +158 -0
- tests/unit/mcp/test_config_model.py +146 -0
- hatch_xclam-0.7.1.dev3.dist-info/entry_points.txt +0 -2
- tests/integration/test_mcp_kiro_integration.py +0 -153
- tests/regression/test_mcp_codex_backup_integration.py +0 -162
- tests/regression/test_mcp_codex_host_strategy.py +0 -163
- tests/regression/test_mcp_codex_model_validation.py +0 -117
- tests/regression/test_mcp_kiro_backup_integration.py +0 -241
- tests/regression/test_mcp_kiro_cli_integration.py +0 -141
- tests/regression/test_mcp_kiro_decorator_registration.py +0 -71
- tests/regression/test_mcp_kiro_host_strategy.py +0 -214
- tests/regression/test_mcp_kiro_model_validation.py +0 -116
- tests/regression/test_mcp_kiro_omni_conversion.py +0 -104
- tests/test_mcp_atomic_operations.py +0 -276
- tests/test_mcp_backup_integration.py +0 -308
- tests/test_mcp_cli_all_host_specific_args.py +0 -496
- tests/test_mcp_cli_backup_management.py +0 -295
- tests/test_mcp_cli_direct_management.py +0 -456
- tests/test_mcp_cli_discovery_listing.py +0 -582
- tests/test_mcp_cli_host_config_integration.py +0 -823
- tests/test_mcp_cli_package_management.py +0 -360
- tests/test_mcp_cli_partial_updates.py +0 -859
- tests/test_mcp_environment_integration.py +0 -520
- tests/test_mcp_host_config_backup.py +0 -257
- tests/test_mcp_host_configuration_manager.py +0 -331
- tests/test_mcp_host_registry_decorator.py +0 -348
- tests/test_mcp_pydantic_architecture_v4.py +0 -603
- tests/test_mcp_server_config_models.py +0 -242
- tests/test_mcp_server_config_type_field.py +0 -221
- tests/test_mcp_sync_functionality.py +0 -316
- tests/test_mcp_user_feedback_reporting.py +0 -359
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/licenses/LICENSE +0 -0
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,2439 @@
|
|
|
1
|
+
"""Integration tests for CLI handler → ResultReporter flow.
|
|
2
|
+
|
|
3
|
+
These tests verify that CLI handlers correctly integrate with ResultReporter
|
|
4
|
+
for unified output rendering. Focus is on component communication, not output format.
|
|
5
|
+
|
|
6
|
+
Reference: R05 §3.7 (05-test_definition_v0.md) — CLI Handler Integration test group
|
|
7
|
+
|
|
8
|
+
Test Strategy:
|
|
9
|
+
- Tests verify that handlers USE ResultReporter (import and instantiate)
|
|
10
|
+
- Tests fail if handlers don't import ResultReporter from cli_utils
|
|
11
|
+
- Once handlers are updated, tests will pass
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from argparse import Namespace
|
|
16
|
+
from unittest.mock import MagicMock, patch, PropertyMock
|
|
17
|
+
import io
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from hatch.cli.cli_utils import ResultReporter, ConsequenceType
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _handler_uses_result_reporter(handler_module_source: str) -> bool:
|
|
24
|
+
"""Check if handler module imports and uses ResultReporter.
|
|
25
|
+
|
|
26
|
+
This is a simple source code check to verify the handler has been updated.
|
|
27
|
+
"""
|
|
28
|
+
return "ResultReporter" in handler_module_source
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestMCPConfigureHandlerIntegration:
|
|
32
|
+
"""Integration tests for handle_mcp_configure → ResultReporter flow."""
|
|
33
|
+
|
|
34
|
+
def test_handler_imports_result_reporter(self):
|
|
35
|
+
"""Handler module should import ResultReporter from cli_utils.
|
|
36
|
+
|
|
37
|
+
This test verifies that the handler has been updated to use the new
|
|
38
|
+
ResultReporter infrastructure instead of display_report.
|
|
39
|
+
|
|
40
|
+
Risk: R3 (ConversionReport mapping loses field data)
|
|
41
|
+
"""
|
|
42
|
+
import inspect
|
|
43
|
+
from hatch.cli import cli_mcp
|
|
44
|
+
|
|
45
|
+
# Get the source code of the module
|
|
46
|
+
source = inspect.getsource(cli_mcp)
|
|
47
|
+
|
|
48
|
+
# Verify ResultReporter is imported
|
|
49
|
+
assert "from hatch.cli.cli_utils import" in source and "ResultReporter" in source, \
|
|
50
|
+
"handle_mcp_configure should import ResultReporter from cli_utils"
|
|
51
|
+
|
|
52
|
+
def test_handler_uses_result_reporter_for_output(self):
|
|
53
|
+
"""Handler should use ResultReporter instead of display_report.
|
|
54
|
+
|
|
55
|
+
Verifies that handle_mcp_configure creates a ResultReporter and uses
|
|
56
|
+
add_from_conversion_report() for ConversionReport integration.
|
|
57
|
+
|
|
58
|
+
Risk: R3 (ConversionReport mapping loses field data)
|
|
59
|
+
"""
|
|
60
|
+
from hatch.cli.cli_mcp import handle_mcp_configure
|
|
61
|
+
from hatch.mcp_host_config import MCPHostType
|
|
62
|
+
|
|
63
|
+
# Create mock args for a simple configure operation
|
|
64
|
+
args = Namespace(
|
|
65
|
+
host="claude-desktop",
|
|
66
|
+
server_name="test-server",
|
|
67
|
+
server_command="python",
|
|
68
|
+
args=["server.py"],
|
|
69
|
+
env_var=None,
|
|
70
|
+
url=None,
|
|
71
|
+
header=None,
|
|
72
|
+
timeout=None,
|
|
73
|
+
trust=False,
|
|
74
|
+
cwd=None,
|
|
75
|
+
env_file=None,
|
|
76
|
+
http_url=None,
|
|
77
|
+
include_tools=None,
|
|
78
|
+
exclude_tools=None,
|
|
79
|
+
input=None,
|
|
80
|
+
disabled=None,
|
|
81
|
+
auto_approve_tools=None,
|
|
82
|
+
disable_tools=None,
|
|
83
|
+
env_vars=None,
|
|
84
|
+
startup_timeout=None,
|
|
85
|
+
tool_timeout=None,
|
|
86
|
+
enabled=None,
|
|
87
|
+
bearer_token_env_var=None,
|
|
88
|
+
env_header=None,
|
|
89
|
+
no_backup=True,
|
|
90
|
+
dry_run=False,
|
|
91
|
+
auto_approve=True, # Skip confirmation
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Mock the MCPHostConfigurationManager
|
|
95
|
+
with patch('hatch.cli.cli_mcp.MCPHostConfigurationManager') as mock_manager_class:
|
|
96
|
+
mock_manager = MagicMock()
|
|
97
|
+
mock_manager.get_server_config.return_value = None # New server
|
|
98
|
+
mock_result = MagicMock()
|
|
99
|
+
mock_result.success = True
|
|
100
|
+
mock_result.backup_path = None
|
|
101
|
+
mock_manager.configure_server.return_value = mock_result
|
|
102
|
+
mock_manager_class.return_value = mock_manager
|
|
103
|
+
|
|
104
|
+
# Capture stdout to verify ResultReporter output format
|
|
105
|
+
captured_output = io.StringIO()
|
|
106
|
+
with patch('sys.stdout', captured_output):
|
|
107
|
+
# Run the handler
|
|
108
|
+
result = handle_mcp_configure(args)
|
|
109
|
+
|
|
110
|
+
output = captured_output.getvalue()
|
|
111
|
+
|
|
112
|
+
# Verify output uses new format (ResultReporter style)
|
|
113
|
+
# The new format should have [SUCCESS] and [CONFIGURED] patterns
|
|
114
|
+
assert "[SUCCESS]" in output or result == 0, \
|
|
115
|
+
"Handler should produce success output"
|
|
116
|
+
|
|
117
|
+
def test_handler_dry_run_shows_preview(self):
|
|
118
|
+
"""Dry-run flag should show preview without executing.
|
|
119
|
+
|
|
120
|
+
Risk: R5 (Dry-run mode not propagated correctly)
|
|
121
|
+
"""
|
|
122
|
+
from hatch.cli.cli_mcp import handle_mcp_configure
|
|
123
|
+
|
|
124
|
+
args = Namespace(
|
|
125
|
+
host="claude-desktop",
|
|
126
|
+
server_name="test-server",
|
|
127
|
+
server_command="python",
|
|
128
|
+
args=["server.py"],
|
|
129
|
+
env_var=None,
|
|
130
|
+
url=None,
|
|
131
|
+
header=None,
|
|
132
|
+
timeout=None,
|
|
133
|
+
trust=False,
|
|
134
|
+
cwd=None,
|
|
135
|
+
env_file=None,
|
|
136
|
+
http_url=None,
|
|
137
|
+
include_tools=None,
|
|
138
|
+
exclude_tools=None,
|
|
139
|
+
input=None,
|
|
140
|
+
disabled=None,
|
|
141
|
+
auto_approve_tools=None,
|
|
142
|
+
disable_tools=None,
|
|
143
|
+
env_vars=None,
|
|
144
|
+
startup_timeout=None,
|
|
145
|
+
tool_timeout=None,
|
|
146
|
+
enabled=None,
|
|
147
|
+
bearer_token_env_var=None,
|
|
148
|
+
env_header=None,
|
|
149
|
+
no_backup=True,
|
|
150
|
+
dry_run=True, # Dry-run enabled
|
|
151
|
+
auto_approve=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
with patch('hatch.cli.cli_mcp.MCPHostConfigurationManager') as mock_manager_class:
|
|
155
|
+
mock_manager = MagicMock()
|
|
156
|
+
mock_manager.get_server_config.return_value = None
|
|
157
|
+
mock_manager_class.return_value = mock_manager
|
|
158
|
+
|
|
159
|
+
# Capture stdout
|
|
160
|
+
captured_output = io.StringIO()
|
|
161
|
+
with patch('sys.stdout', captured_output):
|
|
162
|
+
result = handle_mcp_configure(args)
|
|
163
|
+
|
|
164
|
+
output = captured_output.getvalue()
|
|
165
|
+
|
|
166
|
+
# Verify dry-run output format
|
|
167
|
+
assert "[DRY RUN]" in output, \
|
|
168
|
+
"Dry-run should show [DRY RUN] prefix in output"
|
|
169
|
+
|
|
170
|
+
# Verify configure_server was NOT called (dry-run doesn't execute)
|
|
171
|
+
mock_manager.configure_server.assert_not_called()
|
|
172
|
+
|
|
173
|
+
def test_handler_shows_prompt_before_confirmation(self):
|
|
174
|
+
"""Handler should show consequence preview before requesting confirmation.
|
|
175
|
+
|
|
176
|
+
Risk: R1 (Consequence data lost/corrupted during tracking)
|
|
177
|
+
"""
|
|
178
|
+
from hatch.cli.cli_mcp import handle_mcp_configure
|
|
179
|
+
|
|
180
|
+
args = Namespace(
|
|
181
|
+
host="claude-desktop",
|
|
182
|
+
server_name="test-server",
|
|
183
|
+
server_command="python",
|
|
184
|
+
args=["server.py"],
|
|
185
|
+
env_var=None,
|
|
186
|
+
url=None,
|
|
187
|
+
header=None,
|
|
188
|
+
timeout=None,
|
|
189
|
+
trust=False,
|
|
190
|
+
cwd=None,
|
|
191
|
+
env_file=None,
|
|
192
|
+
http_url=None,
|
|
193
|
+
include_tools=None,
|
|
194
|
+
exclude_tools=None,
|
|
195
|
+
input=None,
|
|
196
|
+
disabled=None,
|
|
197
|
+
auto_approve_tools=None,
|
|
198
|
+
disable_tools=None,
|
|
199
|
+
env_vars=None,
|
|
200
|
+
startup_timeout=None,
|
|
201
|
+
tool_timeout=None,
|
|
202
|
+
enabled=None,
|
|
203
|
+
bearer_token_env_var=None,
|
|
204
|
+
env_header=None,
|
|
205
|
+
no_backup=True,
|
|
206
|
+
dry_run=False,
|
|
207
|
+
auto_approve=False, # Will prompt for confirmation
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
with patch('hatch.cli.cli_mcp.MCPHostConfigurationManager') as mock_manager_class:
|
|
211
|
+
mock_manager = MagicMock()
|
|
212
|
+
mock_manager.get_server_config.return_value = None
|
|
213
|
+
mock_manager_class.return_value = mock_manager
|
|
214
|
+
|
|
215
|
+
# Capture stdout and mock confirmation to decline
|
|
216
|
+
captured_output = io.StringIO()
|
|
217
|
+
with patch('sys.stdout', captured_output):
|
|
218
|
+
with patch('hatch.cli.cli_utils.request_confirmation', return_value=False):
|
|
219
|
+
result = handle_mcp_configure(args)
|
|
220
|
+
|
|
221
|
+
output = captured_output.getvalue()
|
|
222
|
+
|
|
223
|
+
# Verify prompt was shown (should contain command name and CONFIGURE verb)
|
|
224
|
+
assert "hatch mcp configure" in output or "[CONFIGURE]" in output, \
|
|
225
|
+
"Handler should show consequence preview before confirmation"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestMCPSyncHandlerIntegration:
|
|
229
|
+
"""Integration tests for handle_mcp_sync → ResultReporter flow."""
|
|
230
|
+
|
|
231
|
+
def test_sync_handler_imports_result_reporter(self):
|
|
232
|
+
"""Sync handler module should import ResultReporter.
|
|
233
|
+
|
|
234
|
+
Risk: R1 (Consequence data lost/corrupted)
|
|
235
|
+
"""
|
|
236
|
+
import inspect
|
|
237
|
+
from hatch.cli import cli_mcp
|
|
238
|
+
|
|
239
|
+
source = inspect.getsource(cli_mcp)
|
|
240
|
+
|
|
241
|
+
# Verify ResultReporter is imported and used in sync handler
|
|
242
|
+
assert "ResultReporter" in source, \
|
|
243
|
+
"cli_mcp module should import ResultReporter"
|
|
244
|
+
|
|
245
|
+
def test_sync_handler_uses_result_reporter(self):
|
|
246
|
+
"""Sync handler should use ResultReporter for output.
|
|
247
|
+
|
|
248
|
+
Risk: R1 (Consequence data lost/corrupted)
|
|
249
|
+
"""
|
|
250
|
+
from hatch.cli.cli_mcp import handle_mcp_sync
|
|
251
|
+
|
|
252
|
+
args = Namespace(
|
|
253
|
+
from_env=None,
|
|
254
|
+
from_host="claude-desktop",
|
|
255
|
+
to_host="cursor",
|
|
256
|
+
servers=None,
|
|
257
|
+
|
|
258
|
+
dry_run=False,
|
|
259
|
+
auto_approve=True,
|
|
260
|
+
no_backup=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
with patch('hatch.cli.cli_mcp.MCPHostConfigurationManager') as mock_manager_class:
|
|
264
|
+
mock_manager = MagicMock()
|
|
265
|
+
mock_result = MagicMock()
|
|
266
|
+
mock_result.success = True
|
|
267
|
+
mock_result.servers_synced = 1
|
|
268
|
+
mock_result.hosts_updated = 1
|
|
269
|
+
mock_result.results = []
|
|
270
|
+
mock_manager.sync_configurations.return_value = mock_result
|
|
271
|
+
mock_manager_class.return_value = mock_manager
|
|
272
|
+
|
|
273
|
+
# Capture stdout
|
|
274
|
+
captured_output = io.StringIO()
|
|
275
|
+
with patch('sys.stdout', captured_output):
|
|
276
|
+
result = handle_mcp_sync(args)
|
|
277
|
+
|
|
278
|
+
output = captured_output.getvalue()
|
|
279
|
+
|
|
280
|
+
# Verify output uses ResultReporter format
|
|
281
|
+
# ResultReporter uses [SYNC] for prompt and [SYNCED] for result, or [SUCCESS] header
|
|
282
|
+
assert "[SUCCESS]" in output or "[SYNCED]" in output or "[SYNC]" in output, \
|
|
283
|
+
f"Sync handler should use ResultReporter output format. Got: {output}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestMCPRemoveHandlerIntegration:
|
|
287
|
+
"""Integration tests for handle_mcp_remove → ResultReporter flow."""
|
|
288
|
+
|
|
289
|
+
def test_remove_handler_uses_result_reporter(self):
|
|
290
|
+
"""Remove handler should use ResultReporter for output.
|
|
291
|
+
|
|
292
|
+
Risk: R1 (Consequence data lost/corrupted)
|
|
293
|
+
"""
|
|
294
|
+
from hatch.cli.cli_mcp import handle_mcp_remove
|
|
295
|
+
|
|
296
|
+
args = Namespace(
|
|
297
|
+
host="claude-desktop",
|
|
298
|
+
server_name="test-server",
|
|
299
|
+
no_backup=True,
|
|
300
|
+
dry_run=False,
|
|
301
|
+
auto_approve=True,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
with patch('hatch.cli.cli_mcp.MCPHostConfigurationManager') as mock_manager_class:
|
|
305
|
+
mock_manager = MagicMock()
|
|
306
|
+
mock_result = MagicMock()
|
|
307
|
+
mock_result.success = True
|
|
308
|
+
mock_result.backup_path = None
|
|
309
|
+
mock_manager.remove_server.return_value = mock_result
|
|
310
|
+
mock_manager_class.return_value = mock_manager
|
|
311
|
+
|
|
312
|
+
# Capture stdout
|
|
313
|
+
captured_output = io.StringIO()
|
|
314
|
+
with patch('sys.stdout', captured_output):
|
|
315
|
+
result = handle_mcp_remove(args)
|
|
316
|
+
|
|
317
|
+
output = captured_output.getvalue()
|
|
318
|
+
|
|
319
|
+
# Verify output uses ResultReporter format
|
|
320
|
+
assert "[SUCCESS]" in output or "[REMOVED]" in output, \
|
|
321
|
+
"Remove handler should use ResultReporter output format"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class TestMCPBackupHandlerIntegration:
|
|
325
|
+
"""Integration tests for MCP backup handlers → ResultReporter flow."""
|
|
326
|
+
|
|
327
|
+
def test_backup_restore_handler_uses_result_reporter(self):
|
|
328
|
+
"""Backup restore handler should use ResultReporter for output.
|
|
329
|
+
|
|
330
|
+
Risk: R1 (Consequence data lost/corrupted)
|
|
331
|
+
"""
|
|
332
|
+
from hatch.cli.cli_mcp import handle_mcp_backup_restore
|
|
333
|
+
from hatch.mcp_host_config.backup import MCPHostConfigBackupManager
|
|
334
|
+
from pathlib import Path
|
|
335
|
+
import tempfile
|
|
336
|
+
|
|
337
|
+
# Create mock env_manager
|
|
338
|
+
mock_env_manager = MagicMock()
|
|
339
|
+
mock_env_manager.apply_restored_host_configuration_to_environments.return_value = 0
|
|
340
|
+
|
|
341
|
+
args = Namespace(
|
|
342
|
+
env_manager=mock_env_manager,
|
|
343
|
+
host="claude-desktop",
|
|
344
|
+
backup_file=None,
|
|
345
|
+
dry_run=False,
|
|
346
|
+
auto_approve=True,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Create a temporary backup file for the test
|
|
350
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
351
|
+
backup_dir = Path(tmpdir) / "claude-desktop"
|
|
352
|
+
backup_dir.mkdir(parents=True)
|
|
353
|
+
backup_file = backup_dir / "mcp.json.claude-desktop.20260130_120000_000000"
|
|
354
|
+
backup_file.write_text('{"mcpServers": {}}')
|
|
355
|
+
|
|
356
|
+
# Mock the backup manager to use our temp directory
|
|
357
|
+
original_init = MCPHostConfigBackupManager.__init__
|
|
358
|
+
def mock_init(self, backup_root=None):
|
|
359
|
+
self.backup_root = Path(tmpdir)
|
|
360
|
+
self.backup_root.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
from hatch.mcp_host_config.backup import AtomicFileOperations
|
|
362
|
+
self.atomic_ops = AtomicFileOperations()
|
|
363
|
+
|
|
364
|
+
with patch.object(MCPHostConfigBackupManager, '__init__', mock_init):
|
|
365
|
+
with patch.object(MCPHostConfigBackupManager, 'restore_backup', return_value=True):
|
|
366
|
+
# Mock the strategy for post-restore sync
|
|
367
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
368
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
369
|
+
mock_strategy = MagicMock()
|
|
370
|
+
mock_strategy.read_configuration.return_value = MagicMock(servers={})
|
|
371
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
372
|
+
|
|
373
|
+
# Capture stdout
|
|
374
|
+
captured_output = io.StringIO()
|
|
375
|
+
with patch('sys.stdout', captured_output):
|
|
376
|
+
result = handle_mcp_backup_restore(args)
|
|
377
|
+
|
|
378
|
+
output = captured_output.getvalue()
|
|
379
|
+
|
|
380
|
+
# Verify output uses ResultReporter format
|
|
381
|
+
assert "[SUCCESS]" in output or "[RESTORED]" in output, \
|
|
382
|
+
f"Backup restore handler should use ResultReporter output format. Got: {output}"
|
|
383
|
+
|
|
384
|
+
def test_backup_clean_handler_uses_result_reporter(self):
|
|
385
|
+
"""Backup clean handler should use ResultReporter for output.
|
|
386
|
+
|
|
387
|
+
Risk: R1 (Consequence data lost/corrupted)
|
|
388
|
+
"""
|
|
389
|
+
from hatch.cli.cli_mcp import handle_mcp_backup_clean
|
|
390
|
+
from hatch.mcp_host_config.backup import MCPHostConfigBackupManager
|
|
391
|
+
|
|
392
|
+
args = Namespace(
|
|
393
|
+
host="claude-desktop",
|
|
394
|
+
older_than_days=30,
|
|
395
|
+
keep_count=None,
|
|
396
|
+
dry_run=False,
|
|
397
|
+
auto_approve=True,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
with patch.object(MCPHostConfigBackupManager, '__init__', return_value=None):
|
|
401
|
+
with patch.object(MCPHostConfigBackupManager, 'list_backups') as mock_list:
|
|
402
|
+
mock_backup_info = MagicMock()
|
|
403
|
+
mock_backup_info.age_days = 45
|
|
404
|
+
mock_backup_info.file_path = MagicMock()
|
|
405
|
+
mock_backup_info.file_path.name = "old_backup.json"
|
|
406
|
+
mock_list.return_value = [mock_backup_info]
|
|
407
|
+
|
|
408
|
+
with patch.object(MCPHostConfigBackupManager, 'clean_backups', return_value=1):
|
|
409
|
+
# Capture stdout
|
|
410
|
+
captured_output = io.StringIO()
|
|
411
|
+
with patch('sys.stdout', captured_output):
|
|
412
|
+
result = handle_mcp_backup_clean(args)
|
|
413
|
+
|
|
414
|
+
output = captured_output.getvalue()
|
|
415
|
+
|
|
416
|
+
# Verify output uses ResultReporter format
|
|
417
|
+
assert "[SUCCESS]" in output or "[CLEANED]" in output or "cleaned" in output.lower(), \
|
|
418
|
+
"Backup clean handler should use ResultReporter output format"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class TestMCPListServersHostCentric:
|
|
422
|
+
"""Integration tests for host-centric mcp list servers command.
|
|
423
|
+
|
|
424
|
+
Reference: R02 §2.5 (02-list_output_format_specification_v2.md)
|
|
425
|
+
Reference: R09 §1 (09-implementation_gap_analysis_v0.md) - Critical deviation analysis
|
|
426
|
+
|
|
427
|
+
These tests verify that handle_mcp_list_servers:
|
|
428
|
+
1. Reads from actual host config files (not environment data)
|
|
429
|
+
2. Shows ALL servers (Hatch-managed ✅ and 3rd party ❌)
|
|
430
|
+
3. Cross-references with environments for Hatch status
|
|
431
|
+
4. Supports --host flag to filter to specific host
|
|
432
|
+
5. Supports --pattern flag for regex filtering
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
def test_list_servers_reads_from_host_config(self):
|
|
436
|
+
"""Command should read servers from host config files, not environment data.
|
|
437
|
+
|
|
438
|
+
This is the CRITICAL test for host-centric design.
|
|
439
|
+
The command must read from actual host config files (e.g., ~/.claude/config.json)
|
|
440
|
+
and show ALL servers, not just Hatch-managed packages.
|
|
441
|
+
|
|
442
|
+
Risk: Architectural deviation - package-centric vs host-centric
|
|
443
|
+
"""
|
|
444
|
+
from hatch.cli.cli_mcp import handle_mcp_list_servers
|
|
445
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
446
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
447
|
+
|
|
448
|
+
# Create mock env_manager with dict-based return values (matching real implementation)
|
|
449
|
+
mock_env_manager = MagicMock()
|
|
450
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
451
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
452
|
+
"packages": [
|
|
453
|
+
{
|
|
454
|
+
"name": "weather-server",
|
|
455
|
+
"version": "1.0.0",
|
|
456
|
+
"configured_hosts": {"claude-desktop": {"configured_at": "2026-01-30"}}
|
|
457
|
+
}
|
|
458
|
+
]
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
args = Namespace(
|
|
462
|
+
env_manager=mock_env_manager,
|
|
463
|
+
host="claude-desktop",
|
|
464
|
+
|
|
465
|
+
json=False,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Mock the host strategy to return servers from config file
|
|
469
|
+
# This simulates reading from ~/.claude/config.json
|
|
470
|
+
mock_host_config = HostConfiguration(servers={
|
|
471
|
+
"weather-server": MCPServerConfig(name="weather-server", command="python", args=["weather.py"]),
|
|
472
|
+
"custom-tool": MCPServerConfig(name="custom-tool", command="node", args=["custom.js"]), # 3rd party!
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
476
|
+
mock_strategy = MagicMock()
|
|
477
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
478
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
479
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
480
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
481
|
+
|
|
482
|
+
# Import strategies to trigger registration
|
|
483
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
484
|
+
# Capture stdout
|
|
485
|
+
captured_output = io.StringIO()
|
|
486
|
+
with patch('sys.stdout', captured_output):
|
|
487
|
+
result = handle_mcp_list_servers(args)
|
|
488
|
+
|
|
489
|
+
output = captured_output.getvalue()
|
|
490
|
+
|
|
491
|
+
# CRITICAL: Verify the command reads from host config (strategy.read_configuration called)
|
|
492
|
+
mock_strategy.read_configuration.assert_called_once()
|
|
493
|
+
|
|
494
|
+
# Verify BOTH servers appear in output (Hatch-managed AND 3rd party)
|
|
495
|
+
assert "weather-server" in output, \
|
|
496
|
+
"Hatch-managed server should appear in output"
|
|
497
|
+
assert "custom-tool" in output, \
|
|
498
|
+
"3rd party server should appear in output (host-centric design)"
|
|
499
|
+
|
|
500
|
+
# Verify Hatch status indicators
|
|
501
|
+
assert "✅" in output, "Hatch-managed server should show ✅"
|
|
502
|
+
assert "❌" in output, "3rd party server should show ❌"
|
|
503
|
+
|
|
504
|
+
def test_list_servers_shows_third_party_servers(self):
|
|
505
|
+
"""Command should show 3rd party servers with ❌ status.
|
|
506
|
+
|
|
507
|
+
A 3rd party server is one configured directly on the host
|
|
508
|
+
that is NOT tracked in any Hatch environment.
|
|
509
|
+
|
|
510
|
+
Risk: Missing 3rd party servers in output
|
|
511
|
+
"""
|
|
512
|
+
from hatch.cli.cli_mcp import handle_mcp_list_servers
|
|
513
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
514
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
515
|
+
|
|
516
|
+
# Create mock env_manager with NO packages (empty environment)
|
|
517
|
+
mock_env_manager = MagicMock()
|
|
518
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
519
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
520
|
+
|
|
521
|
+
args = Namespace(
|
|
522
|
+
env_manager=mock_env_manager,
|
|
523
|
+
host=None, # No filter - show all hosts
|
|
524
|
+
json=False,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Host config has a server that's NOT in any Hatch environment
|
|
528
|
+
mock_host_config = HostConfiguration(servers={
|
|
529
|
+
"external-tool": MCPServerConfig(name="external-tool", command="external", args=[]),
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
533
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
534
|
+
mock_strategy = MagicMock()
|
|
535
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
536
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
537
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
538
|
+
|
|
539
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
540
|
+
captured_output = io.StringIO()
|
|
541
|
+
with patch('sys.stdout', captured_output):
|
|
542
|
+
result = handle_mcp_list_servers(args)
|
|
543
|
+
|
|
544
|
+
output = captured_output.getvalue()
|
|
545
|
+
|
|
546
|
+
# 3rd party server should appear with ❌ status
|
|
547
|
+
assert "external-tool" in output, \
|
|
548
|
+
"3rd party server should appear in output"
|
|
549
|
+
assert "❌" in output, \
|
|
550
|
+
"3rd party server should show ❌ (not Hatch-managed)"
|
|
551
|
+
|
|
552
|
+
def test_list_servers_without_host_shows_all_hosts(self):
|
|
553
|
+
"""Without --host flag, command should show servers from ALL available hosts.
|
|
554
|
+
|
|
555
|
+
Reference: R02 §2.5 - "Without --host: shows all servers across all hosts"
|
|
556
|
+
"""
|
|
557
|
+
from hatch.cli.cli_mcp import handle_mcp_list_servers
|
|
558
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
559
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
560
|
+
|
|
561
|
+
mock_env_manager = MagicMock()
|
|
562
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
563
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
564
|
+
|
|
565
|
+
args = Namespace(
|
|
566
|
+
env_manager=mock_env_manager,
|
|
567
|
+
host=None, # No host filter - show ALL hosts
|
|
568
|
+
|
|
569
|
+
json=False,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Create configs for multiple hosts
|
|
573
|
+
claude_config = HostConfiguration(servers={
|
|
574
|
+
"server-a": MCPServerConfig(name="server-a", command="python", args=[]),
|
|
575
|
+
})
|
|
576
|
+
cursor_config = HostConfiguration(servers={
|
|
577
|
+
"server-b": MCPServerConfig(name="server-b", command="node", args=[]),
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
581
|
+
# Mock detect_available_hosts to return multiple hosts
|
|
582
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
583
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
584
|
+
MCPHostType.CURSOR,
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
# Mock get_strategy to return different configs per host
|
|
588
|
+
def get_strategy_side_effect(host_type):
|
|
589
|
+
mock_strategy = MagicMock()
|
|
590
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
591
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
592
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
593
|
+
elif host_type == MCPHostType.CURSOR:
|
|
594
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
595
|
+
else:
|
|
596
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
597
|
+
return mock_strategy
|
|
598
|
+
|
|
599
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
600
|
+
|
|
601
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
602
|
+
captured_output = io.StringIO()
|
|
603
|
+
with patch('sys.stdout', captured_output):
|
|
604
|
+
result = handle_mcp_list_servers(args)
|
|
605
|
+
|
|
606
|
+
output = captured_output.getvalue()
|
|
607
|
+
|
|
608
|
+
# Both servers from different hosts should appear
|
|
609
|
+
assert "server-a" in output, "Server from claude-desktop should appear"
|
|
610
|
+
assert "server-b" in output, "Server from cursor should appear"
|
|
611
|
+
|
|
612
|
+
# Host column should be present (since no --host filter)
|
|
613
|
+
assert "claude-desktop" in output or "Host" in output, \
|
|
614
|
+
"Host column should be present when showing all hosts"
|
|
615
|
+
|
|
616
|
+
def test_list_servers_host_filter_pattern(self):
|
|
617
|
+
"""--host flag should filter by host name using regex pattern.
|
|
618
|
+
|
|
619
|
+
Reference: R10 §3.2 - "--host accepts regex patterns"
|
|
620
|
+
"""
|
|
621
|
+
from hatch.cli.cli_mcp import handle_mcp_list_servers
|
|
622
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
623
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
624
|
+
|
|
625
|
+
mock_env_manager = MagicMock()
|
|
626
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
627
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
628
|
+
|
|
629
|
+
args = Namespace(
|
|
630
|
+
env_manager=mock_env_manager,
|
|
631
|
+
host="claude.*", # Regex pattern
|
|
632
|
+
json=False,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
claude_config = HostConfiguration(servers={
|
|
636
|
+
"weather-server": MCPServerConfig(name="weather-server", command="python", args=[]),
|
|
637
|
+
})
|
|
638
|
+
cursor_config = HostConfiguration(servers={
|
|
639
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="node", args=[]),
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
643
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
644
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
645
|
+
MCPHostType.CURSOR,
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
def get_strategy_side_effect(host_type):
|
|
649
|
+
mock_strategy = MagicMock()
|
|
650
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
651
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
652
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
653
|
+
elif host_type == MCPHostType.CURSOR:
|
|
654
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
655
|
+
else:
|
|
656
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
657
|
+
return mock_strategy
|
|
658
|
+
|
|
659
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
660
|
+
|
|
661
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
662
|
+
captured_output = io.StringIO()
|
|
663
|
+
with patch('sys.stdout', captured_output):
|
|
664
|
+
result = handle_mcp_list_servers(args)
|
|
665
|
+
|
|
666
|
+
output = captured_output.getvalue()
|
|
667
|
+
|
|
668
|
+
# Server from claude-desktop should appear (matches pattern)
|
|
669
|
+
assert "weather-server" in output, "weather-server should appear (host matches pattern)"
|
|
670
|
+
|
|
671
|
+
# Server from cursor should NOT appear (doesn't match pattern)
|
|
672
|
+
assert "fetch-server" not in output, \
|
|
673
|
+
"fetch-server should NOT appear (cursor doesn't match 'claude.*')"
|
|
674
|
+
|
|
675
|
+
def test_list_servers_json_output_host_centric(self):
|
|
676
|
+
"""JSON output should include host-centric data structure.
|
|
677
|
+
|
|
678
|
+
Reference: R10 §3.2 - JSON output format for mcp list servers
|
|
679
|
+
"""
|
|
680
|
+
from hatch.cli.cli_mcp import handle_mcp_list_servers
|
|
681
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
682
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
683
|
+
import json
|
|
684
|
+
|
|
685
|
+
mock_env_manager = MagicMock()
|
|
686
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
687
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
688
|
+
"packages": [
|
|
689
|
+
{
|
|
690
|
+
"name": "managed-server",
|
|
691
|
+
"version": "1.0.0",
|
|
692
|
+
"configured_hosts": {"claude-desktop": {}}
|
|
693
|
+
}
|
|
694
|
+
]
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
args = Namespace(
|
|
698
|
+
env_manager=mock_env_manager,
|
|
699
|
+
host=None, # No filter - show all hosts
|
|
700
|
+
json=True, # JSON output
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
mock_host_config = HostConfiguration(servers={
|
|
704
|
+
"managed-server": MCPServerConfig(name="managed-server", command="python", args=[]),
|
|
705
|
+
"unmanaged-server": MCPServerConfig(name="unmanaged-server", command="node", args=[]),
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
709
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
710
|
+
mock_strategy = MagicMock()
|
|
711
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
712
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
713
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
714
|
+
|
|
715
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
716
|
+
captured_output = io.StringIO()
|
|
717
|
+
with patch('sys.stdout', captured_output):
|
|
718
|
+
result = handle_mcp_list_servers(args)
|
|
719
|
+
|
|
720
|
+
output = captured_output.getvalue()
|
|
721
|
+
|
|
722
|
+
# Parse JSON output
|
|
723
|
+
data = json.loads(output)
|
|
724
|
+
|
|
725
|
+
# Verify structure per R10 §8
|
|
726
|
+
assert "rows" in data, "JSON should include rows array"
|
|
727
|
+
|
|
728
|
+
# Verify both servers present with correct fields
|
|
729
|
+
server_names = [s["server"] for s in data["rows"]]
|
|
730
|
+
assert "managed-server" in server_names
|
|
731
|
+
assert "unmanaged-server" in server_names
|
|
732
|
+
|
|
733
|
+
# Verify hatch_managed status and host field
|
|
734
|
+
for row in data["rows"]:
|
|
735
|
+
assert "host" in row, "Each row should have host field"
|
|
736
|
+
assert "hatch_managed" in row, "Each row should have hatch_managed field"
|
|
737
|
+
if row["server"] == "managed-server":
|
|
738
|
+
assert row["hatch_managed"] == True
|
|
739
|
+
assert row["environment"] == "default"
|
|
740
|
+
elif row["server"] == "unmanaged-server":
|
|
741
|
+
assert row["hatch_managed"] == False
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
class TestMCPListHostsHostCentric:
|
|
745
|
+
"""Integration tests for host-centric mcp list hosts command.
|
|
746
|
+
|
|
747
|
+
Reference: R10 §3.1 (10-namespace_consistency_specification_v2.md)
|
|
748
|
+
|
|
749
|
+
These tests verify that handle_mcp_list_hosts:
|
|
750
|
+
1. Reads from actual host config files (not environment data)
|
|
751
|
+
2. Shows host/server pairs with columns: Host → Server → Hatch → Environment
|
|
752
|
+
3. Supports --server flag to filter by server name regex
|
|
753
|
+
4. First column (Host) sorted alphabetically
|
|
754
|
+
"""
|
|
755
|
+
|
|
756
|
+
def test_mcp_list_hosts_uniform_output(self):
|
|
757
|
+
"""Command should produce uniform table output with Host → Server → Hatch → Environment columns.
|
|
758
|
+
|
|
759
|
+
Reference: R10 §3.1 - Column order matches command structure
|
|
760
|
+
"""
|
|
761
|
+
from hatch.cli.cli_mcp import handle_mcp_list_hosts
|
|
762
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
763
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
764
|
+
|
|
765
|
+
mock_env_manager = MagicMock()
|
|
766
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
767
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
768
|
+
"packages": [
|
|
769
|
+
{
|
|
770
|
+
"name": "weather-server",
|
|
771
|
+
"version": "1.0.0",
|
|
772
|
+
"configured_hosts": {"claude-desktop": {"configured_at": "2026-01-30"}}
|
|
773
|
+
}
|
|
774
|
+
]
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
args = Namespace(
|
|
778
|
+
env_manager=mock_env_manager,
|
|
779
|
+
server=None, # No filter
|
|
780
|
+
json=False,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Host config has both Hatch-managed and 3rd party servers
|
|
784
|
+
mock_host_config = HostConfiguration(servers={
|
|
785
|
+
"weather-server": MCPServerConfig(name="weather-server", command="python", args=["weather.py"]),
|
|
786
|
+
"custom-tool": MCPServerConfig(name="custom-tool", command="node", args=["custom.js"]),
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
790
|
+
mock_strategy = MagicMock()
|
|
791
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
792
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
793
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
794
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
795
|
+
|
|
796
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
797
|
+
captured_output = io.StringIO()
|
|
798
|
+
with patch('sys.stdout', captured_output):
|
|
799
|
+
result = handle_mcp_list_hosts(args)
|
|
800
|
+
|
|
801
|
+
output = captured_output.getvalue()
|
|
802
|
+
|
|
803
|
+
# Verify column headers present
|
|
804
|
+
assert "Host" in output, "Host column should be present"
|
|
805
|
+
assert "Server" in output, "Server column should be present"
|
|
806
|
+
assert "Hatch" in output, "Hatch column should be present"
|
|
807
|
+
assert "Environment" in output, "Environment column should be present"
|
|
808
|
+
|
|
809
|
+
# Verify both servers appear
|
|
810
|
+
assert "weather-server" in output, "Hatch-managed server should appear"
|
|
811
|
+
assert "custom-tool" in output, "3rd party server should appear"
|
|
812
|
+
|
|
813
|
+
# Verify Hatch status indicators
|
|
814
|
+
assert "✅" in output, "Hatch-managed server should show ✅"
|
|
815
|
+
assert "❌" in output, "3rd party server should show ❌"
|
|
816
|
+
|
|
817
|
+
def test_mcp_list_hosts_server_filter_exact(self):
|
|
818
|
+
"""--server flag with exact name should filter to matching servers only.
|
|
819
|
+
|
|
820
|
+
Reference: R10 §3.1 - --server <pattern> filter
|
|
821
|
+
"""
|
|
822
|
+
from hatch.cli.cli_mcp import handle_mcp_list_hosts
|
|
823
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
824
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
825
|
+
|
|
826
|
+
mock_env_manager = MagicMock()
|
|
827
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
828
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
829
|
+
|
|
830
|
+
args = Namespace(
|
|
831
|
+
env_manager=mock_env_manager,
|
|
832
|
+
server="weather-server", # Exact match filter
|
|
833
|
+
json=False,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
mock_host_config = HostConfiguration(servers={
|
|
837
|
+
"weather-server": MCPServerConfig(name="weather-server", command="python", args=[]),
|
|
838
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="node", args=[]),
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
842
|
+
mock_strategy = MagicMock()
|
|
843
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
844
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
845
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
846
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
847
|
+
|
|
848
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
849
|
+
captured_output = io.StringIO()
|
|
850
|
+
with patch('sys.stdout', captured_output):
|
|
851
|
+
result = handle_mcp_list_hosts(args)
|
|
852
|
+
|
|
853
|
+
output = captured_output.getvalue()
|
|
854
|
+
|
|
855
|
+
# Matching server should appear
|
|
856
|
+
assert "weather-server" in output, "weather-server should match filter"
|
|
857
|
+
|
|
858
|
+
# Non-matching server should NOT appear
|
|
859
|
+
assert "fetch-server" not in output, "fetch-server should NOT appear"
|
|
860
|
+
|
|
861
|
+
def test_mcp_list_hosts_server_filter_pattern(self):
|
|
862
|
+
"""--server flag with regex pattern should filter matching servers.
|
|
863
|
+
|
|
864
|
+
Reference: R10 §3.1 - --server accepts regex patterns
|
|
865
|
+
"""
|
|
866
|
+
from hatch.cli.cli_mcp import handle_mcp_list_hosts
|
|
867
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
868
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
869
|
+
|
|
870
|
+
mock_env_manager = MagicMock()
|
|
871
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
872
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
873
|
+
|
|
874
|
+
args = Namespace(
|
|
875
|
+
env_manager=mock_env_manager,
|
|
876
|
+
server=".*-server", # Regex pattern
|
|
877
|
+
json=False,
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
mock_host_config = HostConfiguration(servers={
|
|
881
|
+
"weather-server": MCPServerConfig(name="weather-server", command="python", args=[]),
|
|
882
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="node", args=[]),
|
|
883
|
+
"custom-tool": MCPServerConfig(name="custom-tool", command="node", args=[]), # Should NOT match
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
887
|
+
mock_strategy = MagicMock()
|
|
888
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
889
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
890
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
891
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
892
|
+
|
|
893
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
894
|
+
captured_output = io.StringIO()
|
|
895
|
+
with patch('sys.stdout', captured_output):
|
|
896
|
+
result = handle_mcp_list_hosts(args)
|
|
897
|
+
|
|
898
|
+
output = captured_output.getvalue()
|
|
899
|
+
|
|
900
|
+
# Matching servers should appear
|
|
901
|
+
assert "weather-server" in output, "weather-server should match pattern"
|
|
902
|
+
assert "fetch-server" in output, "fetch-server should match pattern"
|
|
903
|
+
|
|
904
|
+
# Non-matching server should NOT appear
|
|
905
|
+
assert "custom-tool" not in output, "custom-tool should NOT match pattern"
|
|
906
|
+
|
|
907
|
+
def test_mcp_list_hosts_alphabetical_ordering(self):
|
|
908
|
+
"""First column (Host) should be sorted alphabetically.
|
|
909
|
+
|
|
910
|
+
Reference: R10 §1.3 - Alphabetical ordering
|
|
911
|
+
"""
|
|
912
|
+
from hatch.cli.cli_mcp import handle_mcp_list_hosts
|
|
913
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
914
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
915
|
+
|
|
916
|
+
mock_env_manager = MagicMock()
|
|
917
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
918
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
919
|
+
|
|
920
|
+
args = Namespace(
|
|
921
|
+
env_manager=mock_env_manager,
|
|
922
|
+
server=None,
|
|
923
|
+
json=False,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# Create configs for multiple hosts
|
|
927
|
+
claude_config = HostConfiguration(servers={
|
|
928
|
+
"server-a": MCPServerConfig(name="server-a", command="python", args=[]),
|
|
929
|
+
})
|
|
930
|
+
cursor_config = HostConfiguration(servers={
|
|
931
|
+
"server-b": MCPServerConfig(name="server-b", command="node", args=[]),
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
935
|
+
# Return hosts in non-alphabetical order to test sorting
|
|
936
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
937
|
+
MCPHostType.CURSOR, # Should come second alphabetically
|
|
938
|
+
MCPHostType.CLAUDE_DESKTOP, # Should come first alphabetically
|
|
939
|
+
]
|
|
940
|
+
|
|
941
|
+
def get_strategy_side_effect(host_type):
|
|
942
|
+
mock_strategy = MagicMock()
|
|
943
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
944
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
945
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
946
|
+
elif host_type == MCPHostType.CURSOR:
|
|
947
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
948
|
+
else:
|
|
949
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
950
|
+
return mock_strategy
|
|
951
|
+
|
|
952
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
953
|
+
|
|
954
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
955
|
+
captured_output = io.StringIO()
|
|
956
|
+
with patch('sys.stdout', captured_output):
|
|
957
|
+
result = handle_mcp_list_hosts(args)
|
|
958
|
+
|
|
959
|
+
output = captured_output.getvalue()
|
|
960
|
+
|
|
961
|
+
# Find positions of hosts in output
|
|
962
|
+
claude_pos = output.find("claude-desktop")
|
|
963
|
+
cursor_pos = output.find("cursor")
|
|
964
|
+
|
|
965
|
+
# claude-desktop should appear before cursor (alphabetically)
|
|
966
|
+
assert claude_pos < cursor_pos, \
|
|
967
|
+
"Hosts should be sorted alphabetically (claude-desktop before cursor)"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
class TestEnvListHostsCommand:
|
|
971
|
+
"""Integration tests for env list hosts command.
|
|
972
|
+
|
|
973
|
+
Reference: R10 §3.3 (10-namespace_consistency_specification_v2.md)
|
|
974
|
+
|
|
975
|
+
These tests verify that handle_env_list_hosts:
|
|
976
|
+
1. Reads from environment data (Hatch-managed packages only)
|
|
977
|
+
2. Shows environment/host/server deployments with columns: Environment → Host → Server → Version
|
|
978
|
+
3. Supports --env and --server filters (regex patterns)
|
|
979
|
+
4. First column (Environment) sorted alphabetically
|
|
980
|
+
"""
|
|
981
|
+
|
|
982
|
+
def test_env_list_hosts_uniform_output(self):
|
|
983
|
+
"""Command should produce uniform table output with Environment → Host → Server → Version columns.
|
|
984
|
+
|
|
985
|
+
Reference: R10 §3.3 - Column order matches command structure
|
|
986
|
+
"""
|
|
987
|
+
from hatch.cli.cli_env import handle_env_list_hosts
|
|
988
|
+
|
|
989
|
+
mock_env_manager = MagicMock()
|
|
990
|
+
mock_env_manager.list_environments.return_value = [
|
|
991
|
+
{"name": "default", "is_current": True},
|
|
992
|
+
{"name": "dev", "is_current": False},
|
|
993
|
+
]
|
|
994
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
995
|
+
"default": {
|
|
996
|
+
"packages": [
|
|
997
|
+
{
|
|
998
|
+
"name": "weather-server",
|
|
999
|
+
"version": "1.0.0",
|
|
1000
|
+
"configured_hosts": {
|
|
1001
|
+
"claude-desktop": {"configured_at": "2026-01-30"},
|
|
1002
|
+
"cursor": {"configured_at": "2026-01-30"},
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
]
|
|
1006
|
+
},
|
|
1007
|
+
"dev": {
|
|
1008
|
+
"packages": [
|
|
1009
|
+
{
|
|
1010
|
+
"name": "test-server",
|
|
1011
|
+
"version": "0.1.0",
|
|
1012
|
+
"configured_hosts": {
|
|
1013
|
+
"claude-desktop": {"configured_at": "2026-01-30"},
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
]
|
|
1017
|
+
},
|
|
1018
|
+
}.get(env_name, {"packages": []})
|
|
1019
|
+
|
|
1020
|
+
args = Namespace(
|
|
1021
|
+
env_manager=mock_env_manager,
|
|
1022
|
+
env=None,
|
|
1023
|
+
server=None,
|
|
1024
|
+
json=False,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
captured_output = io.StringIO()
|
|
1028
|
+
with patch('sys.stdout', captured_output):
|
|
1029
|
+
result = handle_env_list_hosts(args)
|
|
1030
|
+
|
|
1031
|
+
output = captured_output.getvalue()
|
|
1032
|
+
|
|
1033
|
+
# Verify column headers present
|
|
1034
|
+
assert "Environment" in output, "Environment column should be present"
|
|
1035
|
+
assert "Host" in output, "Host column should be present"
|
|
1036
|
+
assert "Server" in output, "Server column should be present"
|
|
1037
|
+
assert "Version" in output, "Version column should be present"
|
|
1038
|
+
|
|
1039
|
+
# Verify data appears
|
|
1040
|
+
assert "default" in output, "default environment should appear"
|
|
1041
|
+
assert "dev" in output, "dev environment should appear"
|
|
1042
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
1043
|
+
assert "test-server" in output, "test-server should appear"
|
|
1044
|
+
|
|
1045
|
+
def test_env_list_hosts_env_filter_exact(self):
|
|
1046
|
+
"""--env flag with exact name should filter to matching environment only.
|
|
1047
|
+
|
|
1048
|
+
Reference: R10 §3.3 - --env <pattern> filter
|
|
1049
|
+
"""
|
|
1050
|
+
from hatch.cli.cli_env import handle_env_list_hosts
|
|
1051
|
+
|
|
1052
|
+
mock_env_manager = MagicMock()
|
|
1053
|
+
mock_env_manager.list_environments.return_value = [
|
|
1054
|
+
{"name": "default"},
|
|
1055
|
+
{"name": "dev"},
|
|
1056
|
+
]
|
|
1057
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1058
|
+
"default": {
|
|
1059
|
+
"packages": [
|
|
1060
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1061
|
+
]
|
|
1062
|
+
},
|
|
1063
|
+
"dev": {
|
|
1064
|
+
"packages": [
|
|
1065
|
+
{"name": "server-b", "version": "0.1.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1066
|
+
]
|
|
1067
|
+
},
|
|
1068
|
+
}.get(env_name, {"packages": []})
|
|
1069
|
+
|
|
1070
|
+
args = Namespace(
|
|
1071
|
+
env_manager=mock_env_manager,
|
|
1072
|
+
env="default", # Exact match filter
|
|
1073
|
+
server=None,
|
|
1074
|
+
json=False,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
captured_output = io.StringIO()
|
|
1078
|
+
with patch('sys.stdout', captured_output):
|
|
1079
|
+
result = handle_env_list_hosts(args)
|
|
1080
|
+
|
|
1081
|
+
output = captured_output.getvalue()
|
|
1082
|
+
|
|
1083
|
+
# Matching environment should appear
|
|
1084
|
+
assert "server-a" in output, "server-a from default should appear"
|
|
1085
|
+
|
|
1086
|
+
# Non-matching environment should NOT appear
|
|
1087
|
+
assert "server-b" not in output, "server-b from dev should NOT appear"
|
|
1088
|
+
|
|
1089
|
+
def test_env_list_hosts_env_filter_pattern(self):
|
|
1090
|
+
"""--env flag with regex pattern should filter matching environments.
|
|
1091
|
+
|
|
1092
|
+
Reference: R10 §3.3 - --env accepts regex patterns
|
|
1093
|
+
"""
|
|
1094
|
+
from hatch.cli.cli_env import handle_env_list_hosts
|
|
1095
|
+
|
|
1096
|
+
mock_env_manager = MagicMock()
|
|
1097
|
+
mock_env_manager.list_environments.return_value = [
|
|
1098
|
+
{"name": "default"},
|
|
1099
|
+
{"name": "dev"},
|
|
1100
|
+
{"name": "dev-staging"},
|
|
1101
|
+
]
|
|
1102
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1103
|
+
"default": {
|
|
1104
|
+
"packages": [
|
|
1105
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1106
|
+
]
|
|
1107
|
+
},
|
|
1108
|
+
"dev": {
|
|
1109
|
+
"packages": [
|
|
1110
|
+
{"name": "server-b", "version": "0.1.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1111
|
+
]
|
|
1112
|
+
},
|
|
1113
|
+
"dev-staging": {
|
|
1114
|
+
"packages": [
|
|
1115
|
+
{"name": "server-c", "version": "0.2.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1116
|
+
]
|
|
1117
|
+
},
|
|
1118
|
+
}.get(env_name, {"packages": []})
|
|
1119
|
+
|
|
1120
|
+
args = Namespace(
|
|
1121
|
+
env_manager=mock_env_manager,
|
|
1122
|
+
env="dev.*", # Regex pattern
|
|
1123
|
+
server=None,
|
|
1124
|
+
json=False,
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
captured_output = io.StringIO()
|
|
1128
|
+
with patch('sys.stdout', captured_output):
|
|
1129
|
+
result = handle_env_list_hosts(args)
|
|
1130
|
+
|
|
1131
|
+
output = captured_output.getvalue()
|
|
1132
|
+
|
|
1133
|
+
# Matching environments should appear
|
|
1134
|
+
assert "server-b" in output, "server-b from dev should appear"
|
|
1135
|
+
assert "server-c" in output, "server-c from dev-staging should appear"
|
|
1136
|
+
|
|
1137
|
+
# Non-matching environment should NOT appear
|
|
1138
|
+
assert "server-a" not in output, "server-a from default should NOT appear"
|
|
1139
|
+
|
|
1140
|
+
def test_env_list_hosts_server_filter(self):
|
|
1141
|
+
"""--server flag should filter by server name regex.
|
|
1142
|
+
|
|
1143
|
+
Reference: R10 §3.3 - --server <pattern> filter
|
|
1144
|
+
"""
|
|
1145
|
+
from hatch.cli.cli_env import handle_env_list_hosts
|
|
1146
|
+
|
|
1147
|
+
mock_env_manager = MagicMock()
|
|
1148
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1149
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
1150
|
+
"packages": [
|
|
1151
|
+
{"name": "weather-server", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1152
|
+
{"name": "fetch-server", "version": "2.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1153
|
+
{"name": "custom-tool", "version": "0.5.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1154
|
+
]
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
args = Namespace(
|
|
1158
|
+
env_manager=mock_env_manager,
|
|
1159
|
+
env=None,
|
|
1160
|
+
server=".*-server", # Regex pattern
|
|
1161
|
+
json=False,
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
captured_output = io.StringIO()
|
|
1165
|
+
with patch('sys.stdout', captured_output):
|
|
1166
|
+
result = handle_env_list_hosts(args)
|
|
1167
|
+
|
|
1168
|
+
output = captured_output.getvalue()
|
|
1169
|
+
|
|
1170
|
+
# Matching servers should appear
|
|
1171
|
+
assert "weather-server" in output, "weather-server should match pattern"
|
|
1172
|
+
assert "fetch-server" in output, "fetch-server should match pattern"
|
|
1173
|
+
|
|
1174
|
+
# Non-matching server should NOT appear
|
|
1175
|
+
assert "custom-tool" not in output, "custom-tool should NOT match pattern"
|
|
1176
|
+
|
|
1177
|
+
def test_env_list_hosts_combined_filters(self):
|
|
1178
|
+
"""Combined --env and --server filters should work with AND logic.
|
|
1179
|
+
|
|
1180
|
+
Reference: R10 §1.5 - Combined filters
|
|
1181
|
+
"""
|
|
1182
|
+
from hatch.cli.cli_env import handle_env_list_hosts
|
|
1183
|
+
|
|
1184
|
+
mock_env_manager = MagicMock()
|
|
1185
|
+
mock_env_manager.list_environments.return_value = [
|
|
1186
|
+
{"name": "default"},
|
|
1187
|
+
{"name": "dev"},
|
|
1188
|
+
]
|
|
1189
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1190
|
+
"default": {
|
|
1191
|
+
"packages": [
|
|
1192
|
+
{"name": "weather-server", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1193
|
+
{"name": "fetch-server", "version": "2.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1194
|
+
]
|
|
1195
|
+
},
|
|
1196
|
+
"dev": {
|
|
1197
|
+
"packages": [
|
|
1198
|
+
{"name": "weather-server", "version": "0.9.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1199
|
+
]
|
|
1200
|
+
},
|
|
1201
|
+
}.get(env_name, {"packages": []})
|
|
1202
|
+
|
|
1203
|
+
args = Namespace(
|
|
1204
|
+
env_manager=mock_env_manager,
|
|
1205
|
+
env="default",
|
|
1206
|
+
server="weather.*",
|
|
1207
|
+
json=False,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
captured_output = io.StringIO()
|
|
1211
|
+
with patch('sys.stdout', captured_output):
|
|
1212
|
+
result = handle_env_list_hosts(args)
|
|
1213
|
+
|
|
1214
|
+
output = captured_output.getvalue()
|
|
1215
|
+
|
|
1216
|
+
# Only weather-server from default should appear
|
|
1217
|
+
assert "weather-server" in output, "weather-server from default should appear"
|
|
1218
|
+
assert "1.0.0" in output, "Version 1.0.0 should appear"
|
|
1219
|
+
|
|
1220
|
+
# fetch-server should NOT appear (doesn't match server filter)
|
|
1221
|
+
assert "fetch-server" not in output, "fetch-server should NOT appear"
|
|
1222
|
+
|
|
1223
|
+
# dev environment should NOT appear (doesn't match env filter)
|
|
1224
|
+
assert "0.9.0" not in output, "Version 0.9.0 from dev should NOT appear"
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
class TestEnvListServersCommand:
|
|
1228
|
+
"""Integration tests for env list servers command.
|
|
1229
|
+
|
|
1230
|
+
Reference: R10 §3.4 (10-namespace_consistency_specification_v2.md)
|
|
1231
|
+
|
|
1232
|
+
These tests verify that handle_env_list_servers:
|
|
1233
|
+
1. Reads from environment data (Hatch-managed packages only)
|
|
1234
|
+
2. Shows environment/server/host deployments with columns: Environment → Server → Host → Version
|
|
1235
|
+
3. Shows '-' for undeployed packages
|
|
1236
|
+
4. Supports --env and --host filters (regex patterns)
|
|
1237
|
+
5. Supports --host - to show only undeployed packages
|
|
1238
|
+
6. First column (Environment) sorted alphabetically
|
|
1239
|
+
"""
|
|
1240
|
+
|
|
1241
|
+
def test_env_list_servers_uniform_output(self):
|
|
1242
|
+
"""Command should produce uniform table output with Environment → Server → Host → Version columns.
|
|
1243
|
+
|
|
1244
|
+
Reference: R10 §3.4 - Column order matches command structure
|
|
1245
|
+
"""
|
|
1246
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1247
|
+
|
|
1248
|
+
mock_env_manager = MagicMock()
|
|
1249
|
+
mock_env_manager.list_environments.return_value = [
|
|
1250
|
+
{"name": "default"},
|
|
1251
|
+
{"name": "dev"},
|
|
1252
|
+
]
|
|
1253
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1254
|
+
"default": {
|
|
1255
|
+
"packages": [
|
|
1256
|
+
{
|
|
1257
|
+
"name": "weather-server",
|
|
1258
|
+
"version": "1.0.0",
|
|
1259
|
+
"configured_hosts": {"claude-desktop": {}}
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
"name": "util-lib",
|
|
1263
|
+
"version": "0.5.0",
|
|
1264
|
+
"configured_hosts": {} # Undeployed
|
|
1265
|
+
}
|
|
1266
|
+
]
|
|
1267
|
+
},
|
|
1268
|
+
"dev": {
|
|
1269
|
+
"packages": [
|
|
1270
|
+
{
|
|
1271
|
+
"name": "test-server",
|
|
1272
|
+
"version": "0.1.0",
|
|
1273
|
+
"configured_hosts": {"cursor": {}}
|
|
1274
|
+
}
|
|
1275
|
+
]
|
|
1276
|
+
},
|
|
1277
|
+
}.get(env_name, {"packages": []})
|
|
1278
|
+
|
|
1279
|
+
args = Namespace(
|
|
1280
|
+
env_manager=mock_env_manager,
|
|
1281
|
+
env=None,
|
|
1282
|
+
host=None,
|
|
1283
|
+
json=False,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
captured_output = io.StringIO()
|
|
1287
|
+
with patch('sys.stdout', captured_output):
|
|
1288
|
+
result = handle_env_list_servers(args)
|
|
1289
|
+
|
|
1290
|
+
output = captured_output.getvalue()
|
|
1291
|
+
|
|
1292
|
+
# Verify column headers present
|
|
1293
|
+
assert "Environment" in output, "Environment column should be present"
|
|
1294
|
+
assert "Server" in output, "Server column should be present"
|
|
1295
|
+
assert "Host" in output, "Host column should be present"
|
|
1296
|
+
assert "Version" in output, "Version column should be present"
|
|
1297
|
+
|
|
1298
|
+
# Verify data appears
|
|
1299
|
+
assert "default" in output, "default environment should appear"
|
|
1300
|
+
assert "dev" in output, "dev environment should appear"
|
|
1301
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
1302
|
+
assert "util-lib" in output, "util-lib should appear"
|
|
1303
|
+
assert "test-server" in output, "test-server should appear"
|
|
1304
|
+
|
|
1305
|
+
def test_env_list_servers_env_filter_exact(self):
|
|
1306
|
+
"""--env flag with exact name should filter to matching environment only.
|
|
1307
|
+
|
|
1308
|
+
Reference: R10 §3.4 - --env <pattern> filter
|
|
1309
|
+
"""
|
|
1310
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1311
|
+
|
|
1312
|
+
mock_env_manager = MagicMock()
|
|
1313
|
+
mock_env_manager.list_environments.return_value = [
|
|
1314
|
+
{"name": "default"},
|
|
1315
|
+
{"name": "dev"},
|
|
1316
|
+
]
|
|
1317
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1318
|
+
"default": {
|
|
1319
|
+
"packages": [
|
|
1320
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1321
|
+
]
|
|
1322
|
+
},
|
|
1323
|
+
"dev": {
|
|
1324
|
+
"packages": [
|
|
1325
|
+
{"name": "server-b", "version": "0.1.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1326
|
+
]
|
|
1327
|
+
},
|
|
1328
|
+
}.get(env_name, {"packages": []})
|
|
1329
|
+
|
|
1330
|
+
args = Namespace(
|
|
1331
|
+
env_manager=mock_env_manager,
|
|
1332
|
+
env="default",
|
|
1333
|
+
host=None,
|
|
1334
|
+
json=False,
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
captured_output = io.StringIO()
|
|
1338
|
+
with patch('sys.stdout', captured_output):
|
|
1339
|
+
result = handle_env_list_servers(args)
|
|
1340
|
+
|
|
1341
|
+
output = captured_output.getvalue()
|
|
1342
|
+
|
|
1343
|
+
# Matching environment should appear
|
|
1344
|
+
assert "server-a" in output, "server-a from default should appear"
|
|
1345
|
+
|
|
1346
|
+
# Non-matching environment should NOT appear
|
|
1347
|
+
assert "server-b" not in output, "server-b from dev should NOT appear"
|
|
1348
|
+
|
|
1349
|
+
def test_env_list_servers_env_filter_pattern(self):
|
|
1350
|
+
"""--env flag with regex pattern should filter matching environments.
|
|
1351
|
+
|
|
1352
|
+
Reference: R10 §3.4 - --env accepts regex patterns
|
|
1353
|
+
"""
|
|
1354
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1355
|
+
|
|
1356
|
+
mock_env_manager = MagicMock()
|
|
1357
|
+
mock_env_manager.list_environments.return_value = [
|
|
1358
|
+
{"name": "default"},
|
|
1359
|
+
{"name": "dev"},
|
|
1360
|
+
{"name": "dev-staging"},
|
|
1361
|
+
]
|
|
1362
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1363
|
+
"default": {
|
|
1364
|
+
"packages": [
|
|
1365
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1366
|
+
]
|
|
1367
|
+
},
|
|
1368
|
+
"dev": {
|
|
1369
|
+
"packages": [
|
|
1370
|
+
{"name": "server-b", "version": "0.1.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1371
|
+
]
|
|
1372
|
+
},
|
|
1373
|
+
"dev-staging": {
|
|
1374
|
+
"packages": [
|
|
1375
|
+
{"name": "server-c", "version": "0.2.0", "configured_hosts": {"claude-desktop": {}}}
|
|
1376
|
+
]
|
|
1377
|
+
},
|
|
1378
|
+
}.get(env_name, {"packages": []})
|
|
1379
|
+
|
|
1380
|
+
args = Namespace(
|
|
1381
|
+
env_manager=mock_env_manager,
|
|
1382
|
+
env="dev.*",
|
|
1383
|
+
host=None,
|
|
1384
|
+
json=False,
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
captured_output = io.StringIO()
|
|
1388
|
+
with patch('sys.stdout', captured_output):
|
|
1389
|
+
result = handle_env_list_servers(args)
|
|
1390
|
+
|
|
1391
|
+
output = captured_output.getvalue()
|
|
1392
|
+
|
|
1393
|
+
# Matching environments should appear
|
|
1394
|
+
assert "server-b" in output, "server-b from dev should appear"
|
|
1395
|
+
assert "server-c" in output, "server-c from dev-staging should appear"
|
|
1396
|
+
|
|
1397
|
+
# Non-matching environment should NOT appear
|
|
1398
|
+
assert "server-a" not in output, "server-a from default should NOT appear"
|
|
1399
|
+
|
|
1400
|
+
def test_env_list_servers_host_filter_exact(self):
|
|
1401
|
+
"""--host flag with exact name should filter to matching host only.
|
|
1402
|
+
|
|
1403
|
+
Reference: R10 §3.4 - --host <pattern> filter
|
|
1404
|
+
"""
|
|
1405
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1406
|
+
|
|
1407
|
+
mock_env_manager = MagicMock()
|
|
1408
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1409
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
1410
|
+
"packages": [
|
|
1411
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1412
|
+
{"name": "server-b", "version": "2.0.0", "configured_hosts": {"cursor": {}}},
|
|
1413
|
+
]
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
args = Namespace(
|
|
1417
|
+
env_manager=mock_env_manager,
|
|
1418
|
+
env=None,
|
|
1419
|
+
host="claude-desktop",
|
|
1420
|
+
json=False,
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
captured_output = io.StringIO()
|
|
1424
|
+
with patch('sys.stdout', captured_output):
|
|
1425
|
+
result = handle_env_list_servers(args)
|
|
1426
|
+
|
|
1427
|
+
output = captured_output.getvalue()
|
|
1428
|
+
|
|
1429
|
+
# Matching host should appear
|
|
1430
|
+
assert "server-a" in output, "server-a on claude-desktop should appear"
|
|
1431
|
+
|
|
1432
|
+
# Non-matching host should NOT appear
|
|
1433
|
+
assert "server-b" not in output, "server-b on cursor should NOT appear"
|
|
1434
|
+
|
|
1435
|
+
def test_env_list_servers_host_filter_pattern(self):
|
|
1436
|
+
"""--host flag with regex pattern should filter matching hosts.
|
|
1437
|
+
|
|
1438
|
+
Reference: R10 §3.4 - --host accepts regex patterns
|
|
1439
|
+
"""
|
|
1440
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1441
|
+
|
|
1442
|
+
mock_env_manager = MagicMock()
|
|
1443
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1444
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
1445
|
+
"packages": [
|
|
1446
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1447
|
+
{"name": "server-b", "version": "2.0.0", "configured_hosts": {"cursor": {}}},
|
|
1448
|
+
{"name": "server-c", "version": "3.0.0", "configured_hosts": {"claude-code": {}}},
|
|
1449
|
+
]
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
args = Namespace(
|
|
1453
|
+
env_manager=mock_env_manager,
|
|
1454
|
+
env=None,
|
|
1455
|
+
host="claude.*", # Regex pattern
|
|
1456
|
+
json=False,
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
captured_output = io.StringIO()
|
|
1460
|
+
with patch('sys.stdout', captured_output):
|
|
1461
|
+
result = handle_env_list_servers(args)
|
|
1462
|
+
|
|
1463
|
+
output = captured_output.getvalue()
|
|
1464
|
+
|
|
1465
|
+
# Matching hosts should appear
|
|
1466
|
+
assert "server-a" in output, "server-a on claude-desktop should appear"
|
|
1467
|
+
assert "server-c" in output, "server-c on claude-code should appear"
|
|
1468
|
+
|
|
1469
|
+
# Non-matching host should NOT appear
|
|
1470
|
+
assert "server-b" not in output, "server-b on cursor should NOT appear"
|
|
1471
|
+
|
|
1472
|
+
def test_env_list_servers_host_filter_undeployed(self):
|
|
1473
|
+
"""--host - should show only undeployed packages.
|
|
1474
|
+
|
|
1475
|
+
Reference: R10 §3.4 - Special filter for undeployed packages
|
|
1476
|
+
"""
|
|
1477
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1478
|
+
|
|
1479
|
+
mock_env_manager = MagicMock()
|
|
1480
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1481
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
1482
|
+
"packages": [
|
|
1483
|
+
{"name": "deployed-server", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1484
|
+
{"name": "util-lib", "version": "0.5.0", "configured_hosts": {}}, # Undeployed
|
|
1485
|
+
{"name": "debug-lib", "version": "0.3.0", "configured_hosts": {}}, # Undeployed
|
|
1486
|
+
]
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
args = Namespace(
|
|
1490
|
+
env_manager=mock_env_manager,
|
|
1491
|
+
env=None,
|
|
1492
|
+
host="-", # Special filter for undeployed
|
|
1493
|
+
json=False,
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
captured_output = io.StringIO()
|
|
1497
|
+
with patch('sys.stdout', captured_output):
|
|
1498
|
+
result = handle_env_list_servers(args)
|
|
1499
|
+
|
|
1500
|
+
output = captured_output.getvalue()
|
|
1501
|
+
|
|
1502
|
+
# Undeployed packages should appear
|
|
1503
|
+
assert "util-lib" in output, "util-lib (undeployed) should appear"
|
|
1504
|
+
assert "debug-lib" in output, "debug-lib (undeployed) should appear"
|
|
1505
|
+
|
|
1506
|
+
# Deployed package should NOT appear
|
|
1507
|
+
assert "deployed-server" not in output, "deployed-server should NOT appear"
|
|
1508
|
+
|
|
1509
|
+
def test_env_list_servers_combined_filters(self):
|
|
1510
|
+
"""Combined --env and --host filters should work with AND logic.
|
|
1511
|
+
|
|
1512
|
+
Reference: R10 §1.5 - Combined filters
|
|
1513
|
+
"""
|
|
1514
|
+
from hatch.cli.cli_env import handle_env_list_servers
|
|
1515
|
+
|
|
1516
|
+
mock_env_manager = MagicMock()
|
|
1517
|
+
mock_env_manager.list_environments.return_value = [
|
|
1518
|
+
{"name": "default"},
|
|
1519
|
+
{"name": "dev"},
|
|
1520
|
+
]
|
|
1521
|
+
mock_env_manager.get_environment_data.side_effect = lambda env_name: {
|
|
1522
|
+
"default": {
|
|
1523
|
+
"packages": [
|
|
1524
|
+
{"name": "server-a", "version": "1.0.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1525
|
+
{"name": "server-b", "version": "2.0.0", "configured_hosts": {"cursor": {}}},
|
|
1526
|
+
]
|
|
1527
|
+
},
|
|
1528
|
+
"dev": {
|
|
1529
|
+
"packages": [
|
|
1530
|
+
{"name": "server-c", "version": "0.1.0", "configured_hosts": {"claude-desktop": {}}},
|
|
1531
|
+
]
|
|
1532
|
+
},
|
|
1533
|
+
}.get(env_name, {"packages": []})
|
|
1534
|
+
|
|
1535
|
+
args = Namespace(
|
|
1536
|
+
env_manager=mock_env_manager,
|
|
1537
|
+
env="default",
|
|
1538
|
+
host="claude-desktop",
|
|
1539
|
+
json=False,
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
captured_output = io.StringIO()
|
|
1543
|
+
with patch('sys.stdout', captured_output):
|
|
1544
|
+
result = handle_env_list_servers(args)
|
|
1545
|
+
|
|
1546
|
+
output = captured_output.getvalue()
|
|
1547
|
+
|
|
1548
|
+
# Only server-a from default on claude-desktop should appear
|
|
1549
|
+
assert "server-a" in output, "server-a should appear"
|
|
1550
|
+
|
|
1551
|
+
# server-b should NOT appear (wrong host)
|
|
1552
|
+
assert "server-b" not in output, "server-b should NOT appear"
|
|
1553
|
+
|
|
1554
|
+
# server-c should NOT appear (wrong env)
|
|
1555
|
+
assert "server-c" not in output, "server-c should NOT appear"
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
class TestMCPShowHostsCommand:
|
|
1559
|
+
"""Integration tests for hatch mcp show hosts command.
|
|
1560
|
+
|
|
1561
|
+
Reference: R11 §2.1 (11-enhancing_show_command_v0.md) - Show hosts specification
|
|
1562
|
+
|
|
1563
|
+
These tests verify that handle_mcp_show_hosts:
|
|
1564
|
+
1. Shows detailed host configurations with hierarchical output
|
|
1565
|
+
2. Supports --server filter for regex pattern matching
|
|
1566
|
+
3. Omits hosts with no matching servers when filter applied
|
|
1567
|
+
4. Shows horizontal separators between host sections
|
|
1568
|
+
5. Highlights entity names with amber + bold
|
|
1569
|
+
6. Supports --json output format
|
|
1570
|
+
"""
|
|
1571
|
+
|
|
1572
|
+
def test_mcp_show_hosts_no_filter(self):
|
|
1573
|
+
"""Command should show all hosts with detailed configuration.
|
|
1574
|
+
|
|
1575
|
+
Reference: R11 §2.1 - Output format without filter
|
|
1576
|
+
"""
|
|
1577
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1578
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1579
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1580
|
+
|
|
1581
|
+
mock_env_manager = MagicMock()
|
|
1582
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1583
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
1584
|
+
"packages": [
|
|
1585
|
+
{
|
|
1586
|
+
"name": "weather-server",
|
|
1587
|
+
"version": "1.0.0",
|
|
1588
|
+
"configured_hosts": {"claude-desktop": {"configured_at": "2026-01-30"}}
|
|
1589
|
+
}
|
|
1590
|
+
]
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
args = Namespace(
|
|
1594
|
+
env_manager=mock_env_manager,
|
|
1595
|
+
server=None, # No filter
|
|
1596
|
+
json=False,
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
mock_host_config = HostConfiguration(servers={
|
|
1600
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=["weather-mcp"]),
|
|
1601
|
+
"custom-tool": MCPServerConfig(name="custom-tool", command="node", args=["custom.js"]),
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1605
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
1606
|
+
mock_strategy = MagicMock()
|
|
1607
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
1608
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1609
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
1610
|
+
|
|
1611
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1612
|
+
captured_output = io.StringIO()
|
|
1613
|
+
with patch('sys.stdout', captured_output):
|
|
1614
|
+
result = handle_mcp_show_hosts(args)
|
|
1615
|
+
|
|
1616
|
+
output = captured_output.getvalue()
|
|
1617
|
+
|
|
1618
|
+
# Should show host header
|
|
1619
|
+
assert "claude-desktop" in output, "Host name should appear"
|
|
1620
|
+
|
|
1621
|
+
# Should show both servers
|
|
1622
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
1623
|
+
assert "custom-tool" in output, "custom-tool should appear"
|
|
1624
|
+
|
|
1625
|
+
# Should show server details
|
|
1626
|
+
assert "Command:" in output or "uvx" in output, "Server command should appear"
|
|
1627
|
+
|
|
1628
|
+
def test_mcp_show_hosts_server_filter_exact(self):
|
|
1629
|
+
"""--server filter should match exact server name.
|
|
1630
|
+
|
|
1631
|
+
Reference: R11 §2.1 - Server filter with exact match
|
|
1632
|
+
"""
|
|
1633
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1634
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1635
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1636
|
+
|
|
1637
|
+
mock_env_manager = MagicMock()
|
|
1638
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1639
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
1640
|
+
|
|
1641
|
+
args = Namespace(
|
|
1642
|
+
env_manager=mock_env_manager,
|
|
1643
|
+
server="weather-server", # Exact match
|
|
1644
|
+
json=False,
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
mock_host_config = HostConfiguration(servers={
|
|
1648
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=["weather-mcp"]),
|
|
1649
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=["fetch.py"]),
|
|
1650
|
+
})
|
|
1651
|
+
|
|
1652
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1653
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
1654
|
+
mock_strategy = MagicMock()
|
|
1655
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
1656
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1657
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
1658
|
+
|
|
1659
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1660
|
+
captured_output = io.StringIO()
|
|
1661
|
+
with patch('sys.stdout', captured_output):
|
|
1662
|
+
result = handle_mcp_show_hosts(args)
|
|
1663
|
+
|
|
1664
|
+
output = captured_output.getvalue()
|
|
1665
|
+
|
|
1666
|
+
# Should show matching server
|
|
1667
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
1668
|
+
|
|
1669
|
+
# Should NOT show non-matching server
|
|
1670
|
+
assert "fetch-server" not in output, "fetch-server should NOT appear"
|
|
1671
|
+
|
|
1672
|
+
def test_mcp_show_hosts_server_filter_pattern(self):
|
|
1673
|
+
"""--server filter should support regex patterns.
|
|
1674
|
+
|
|
1675
|
+
Reference: R11 §2.1 - Server filter with regex pattern
|
|
1676
|
+
"""
|
|
1677
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1678
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1679
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1680
|
+
|
|
1681
|
+
mock_env_manager = MagicMock()
|
|
1682
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1683
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
1684
|
+
|
|
1685
|
+
args = Namespace(
|
|
1686
|
+
env_manager=mock_env_manager,
|
|
1687
|
+
server=".*-server", # Regex pattern
|
|
1688
|
+
json=False,
|
|
1689
|
+
)
|
|
1690
|
+
|
|
1691
|
+
mock_host_config = HostConfiguration(servers={
|
|
1692
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=[]),
|
|
1693
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=[]),
|
|
1694
|
+
"custom-tool": MCPServerConfig(name="custom-tool", command="node", args=[]),
|
|
1695
|
+
})
|
|
1696
|
+
|
|
1697
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1698
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
1699
|
+
mock_strategy = MagicMock()
|
|
1700
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
1701
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1702
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
1703
|
+
|
|
1704
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1705
|
+
captured_output = io.StringIO()
|
|
1706
|
+
with patch('sys.stdout', captured_output):
|
|
1707
|
+
result = handle_mcp_show_hosts(args)
|
|
1708
|
+
|
|
1709
|
+
output = captured_output.getvalue()
|
|
1710
|
+
|
|
1711
|
+
# Should show matching servers
|
|
1712
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
1713
|
+
assert "fetch-server" in output, "fetch-server should appear"
|
|
1714
|
+
|
|
1715
|
+
# Should NOT show non-matching server
|
|
1716
|
+
assert "custom-tool" not in output, "custom-tool should NOT appear"
|
|
1717
|
+
|
|
1718
|
+
def test_mcp_show_hosts_omits_empty_hosts(self):
|
|
1719
|
+
"""Hosts with no matching servers should be omitted.
|
|
1720
|
+
|
|
1721
|
+
Reference: R11 §2.1 - Empty host omission
|
|
1722
|
+
"""
|
|
1723
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1724
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1725
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1726
|
+
|
|
1727
|
+
mock_env_manager = MagicMock()
|
|
1728
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1729
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
1730
|
+
|
|
1731
|
+
args = Namespace(
|
|
1732
|
+
env_manager=mock_env_manager,
|
|
1733
|
+
server="weather-server", # Only matches on claude-desktop
|
|
1734
|
+
json=False,
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
claude_config = HostConfiguration(servers={
|
|
1738
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=[]),
|
|
1739
|
+
})
|
|
1740
|
+
cursor_config = HostConfiguration(servers={
|
|
1741
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=[]),
|
|
1742
|
+
})
|
|
1743
|
+
|
|
1744
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1745
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
1746
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
1747
|
+
MCPHostType.CURSOR,
|
|
1748
|
+
]
|
|
1749
|
+
|
|
1750
|
+
def get_strategy_side_effect(host_type):
|
|
1751
|
+
mock_strategy = MagicMock()
|
|
1752
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1753
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
1754
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
1755
|
+
elif host_type == MCPHostType.CURSOR:
|
|
1756
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
1757
|
+
else:
|
|
1758
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
1759
|
+
return mock_strategy
|
|
1760
|
+
|
|
1761
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
1762
|
+
|
|
1763
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1764
|
+
captured_output = io.StringIO()
|
|
1765
|
+
with patch('sys.stdout', captured_output):
|
|
1766
|
+
result = handle_mcp_show_hosts(args)
|
|
1767
|
+
|
|
1768
|
+
output = captured_output.getvalue()
|
|
1769
|
+
|
|
1770
|
+
# claude-desktop should appear (has matching server)
|
|
1771
|
+
assert "claude-desktop" in output, "claude-desktop should appear"
|
|
1772
|
+
|
|
1773
|
+
# cursor should NOT appear (no matching servers)
|
|
1774
|
+
assert "cursor" not in output, "cursor should NOT appear (no matching servers)"
|
|
1775
|
+
|
|
1776
|
+
def test_mcp_show_hosts_alphabetical_ordering(self):
|
|
1777
|
+
"""Hosts should be sorted alphabetically.
|
|
1778
|
+
|
|
1779
|
+
Reference: R11 §1.4 - Alphabetical ordering
|
|
1780
|
+
"""
|
|
1781
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1782
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1783
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1784
|
+
|
|
1785
|
+
mock_env_manager = MagicMock()
|
|
1786
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1787
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
1788
|
+
|
|
1789
|
+
args = Namespace(
|
|
1790
|
+
env_manager=mock_env_manager,
|
|
1791
|
+
server=None,
|
|
1792
|
+
json=False,
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
mock_config = HostConfiguration(servers={
|
|
1796
|
+
"server-a": MCPServerConfig(name="server-a", command="python", args=[]),
|
|
1797
|
+
})
|
|
1798
|
+
|
|
1799
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1800
|
+
# Return hosts in non-alphabetical order
|
|
1801
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
1802
|
+
MCPHostType.CURSOR,
|
|
1803
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
1804
|
+
]
|
|
1805
|
+
|
|
1806
|
+
mock_strategy = MagicMock()
|
|
1807
|
+
mock_strategy.read_configuration.return_value = mock_config
|
|
1808
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1809
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
1810
|
+
|
|
1811
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1812
|
+
captured_output = io.StringIO()
|
|
1813
|
+
with patch('sys.stdout', captured_output):
|
|
1814
|
+
result = handle_mcp_show_hosts(args)
|
|
1815
|
+
|
|
1816
|
+
output = captured_output.getvalue()
|
|
1817
|
+
|
|
1818
|
+
# Find positions of host names
|
|
1819
|
+
claude_pos = output.find("claude-desktop")
|
|
1820
|
+
cursor_pos = output.find("cursor")
|
|
1821
|
+
|
|
1822
|
+
# claude-desktop should appear before cursor (alphabetically)
|
|
1823
|
+
assert claude_pos < cursor_pos, \
|
|
1824
|
+
"Hosts should be sorted alphabetically (claude-desktop before cursor)"
|
|
1825
|
+
|
|
1826
|
+
def test_mcp_show_hosts_horizontal_separators(self):
|
|
1827
|
+
"""Output should have horizontal separators between host sections.
|
|
1828
|
+
|
|
1829
|
+
Reference: R11 §3.1 - Horizontal separators
|
|
1830
|
+
"""
|
|
1831
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1832
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1833
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1834
|
+
|
|
1835
|
+
mock_env_manager = MagicMock()
|
|
1836
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1837
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
1838
|
+
|
|
1839
|
+
args = Namespace(
|
|
1840
|
+
env_manager=mock_env_manager,
|
|
1841
|
+
server=None,
|
|
1842
|
+
json=False,
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
mock_config = HostConfiguration(servers={
|
|
1846
|
+
"server-a": MCPServerConfig(name="server-a", command="python", args=[]),
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1850
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
1851
|
+
mock_strategy = MagicMock()
|
|
1852
|
+
mock_strategy.read_configuration.return_value = mock_config
|
|
1853
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1854
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
1855
|
+
|
|
1856
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1857
|
+
captured_output = io.StringIO()
|
|
1858
|
+
with patch('sys.stdout', captured_output):
|
|
1859
|
+
result = handle_mcp_show_hosts(args)
|
|
1860
|
+
|
|
1861
|
+
output = captured_output.getvalue()
|
|
1862
|
+
|
|
1863
|
+
# Should have horizontal separator (═ character)
|
|
1864
|
+
assert "═" in output, "Output should have horizontal separators"
|
|
1865
|
+
|
|
1866
|
+
def test_mcp_show_hosts_json_output(self):
|
|
1867
|
+
"""--json flag should output JSON format.
|
|
1868
|
+
|
|
1869
|
+
Reference: R11 §6.1 - JSON output format
|
|
1870
|
+
"""
|
|
1871
|
+
from hatch.cli.cli_mcp import handle_mcp_show_hosts
|
|
1872
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1873
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1874
|
+
import json
|
|
1875
|
+
|
|
1876
|
+
mock_env_manager = MagicMock()
|
|
1877
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1878
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
1879
|
+
|
|
1880
|
+
args = Namespace(
|
|
1881
|
+
env_manager=mock_env_manager,
|
|
1882
|
+
server=None,
|
|
1883
|
+
json=True, # JSON output
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
mock_host_config = HostConfiguration(servers={
|
|
1887
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=["weather-mcp"]),
|
|
1888
|
+
})
|
|
1889
|
+
|
|
1890
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1891
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
1892
|
+
mock_strategy = MagicMock()
|
|
1893
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
1894
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1895
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
1896
|
+
|
|
1897
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1898
|
+
captured_output = io.StringIO()
|
|
1899
|
+
with patch('sys.stdout', captured_output):
|
|
1900
|
+
result = handle_mcp_show_hosts(args)
|
|
1901
|
+
|
|
1902
|
+
output = captured_output.getvalue()
|
|
1903
|
+
|
|
1904
|
+
# Should be valid JSON
|
|
1905
|
+
try:
|
|
1906
|
+
data = json.loads(output)
|
|
1907
|
+
except json.JSONDecodeError:
|
|
1908
|
+
pytest.fail(f"Output should be valid JSON: {output}")
|
|
1909
|
+
|
|
1910
|
+
# Should have hosts array
|
|
1911
|
+
assert "hosts" in data, "JSON should have 'hosts' key"
|
|
1912
|
+
assert len(data["hosts"]) > 0, "Should have at least one host"
|
|
1913
|
+
|
|
1914
|
+
# Host should have expected structure
|
|
1915
|
+
host = data["hosts"][0]
|
|
1916
|
+
assert "host" in host, "Host should have 'host' key"
|
|
1917
|
+
assert "servers" in host, "Host should have 'servers' key"
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
class TestMCPShowServersCommand:
|
|
1921
|
+
"""Integration tests for hatch mcp show servers command.
|
|
1922
|
+
|
|
1923
|
+
Reference: R11 §2.2 (11-enhancing_show_command_v0.md) - Show servers specification
|
|
1924
|
+
|
|
1925
|
+
These tests verify that handle_mcp_show_servers:
|
|
1926
|
+
1. Shows detailed server configurations across hosts
|
|
1927
|
+
2. Supports --host filter for regex pattern matching
|
|
1928
|
+
3. Omits servers with no matching hosts when filter applied
|
|
1929
|
+
4. Shows horizontal separators between server sections
|
|
1930
|
+
5. Highlights entity names with amber + bold
|
|
1931
|
+
6. Supports --json output format
|
|
1932
|
+
"""
|
|
1933
|
+
|
|
1934
|
+
def test_mcp_show_servers_no_filter(self):
|
|
1935
|
+
"""Command should show all servers with host configurations.
|
|
1936
|
+
|
|
1937
|
+
Reference: R11 §2.2 - Output format without filter
|
|
1938
|
+
"""
|
|
1939
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
1940
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
1941
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
1942
|
+
|
|
1943
|
+
mock_env_manager = MagicMock()
|
|
1944
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
1945
|
+
mock_env_manager.get_environment_data.return_value = {
|
|
1946
|
+
"packages": [
|
|
1947
|
+
{
|
|
1948
|
+
"name": "weather-server",
|
|
1949
|
+
"version": "1.0.0",
|
|
1950
|
+
"configured_hosts": {"claude-desktop": {"configured_at": "2026-01-30"}}
|
|
1951
|
+
}
|
|
1952
|
+
]
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
args = Namespace(
|
|
1956
|
+
env_manager=mock_env_manager,
|
|
1957
|
+
host=None, # No filter
|
|
1958
|
+
json=False,
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
claude_config = HostConfiguration(servers={
|
|
1962
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=["weather-mcp"]),
|
|
1963
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=["fetch.py"]),
|
|
1964
|
+
})
|
|
1965
|
+
cursor_config = HostConfiguration(servers={
|
|
1966
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=["weather-mcp"]),
|
|
1967
|
+
})
|
|
1968
|
+
|
|
1969
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
1970
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
1971
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
1972
|
+
MCPHostType.CURSOR,
|
|
1973
|
+
]
|
|
1974
|
+
|
|
1975
|
+
def get_strategy_side_effect(host_type):
|
|
1976
|
+
mock_strategy = MagicMock()
|
|
1977
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
1978
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
1979
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
1980
|
+
elif host_type == MCPHostType.CURSOR:
|
|
1981
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
1982
|
+
else:
|
|
1983
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
1984
|
+
return mock_strategy
|
|
1985
|
+
|
|
1986
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
1987
|
+
|
|
1988
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
1989
|
+
captured_output = io.StringIO()
|
|
1990
|
+
with patch('sys.stdout', captured_output):
|
|
1991
|
+
result = handle_mcp_show_servers(args)
|
|
1992
|
+
|
|
1993
|
+
output = captured_output.getvalue()
|
|
1994
|
+
|
|
1995
|
+
# Should show both servers
|
|
1996
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
1997
|
+
assert "fetch-server" in output, "fetch-server should appear"
|
|
1998
|
+
|
|
1999
|
+
# Should show host configurations
|
|
2000
|
+
assert "claude-desktop" in output, "claude-desktop should appear"
|
|
2001
|
+
assert "cursor" in output, "cursor should appear"
|
|
2002
|
+
|
|
2003
|
+
def test_mcp_show_servers_host_filter_exact(self):
|
|
2004
|
+
"""--host filter should match exact host name.
|
|
2005
|
+
|
|
2006
|
+
Reference: R11 §2.2 - Host filter with exact match
|
|
2007
|
+
"""
|
|
2008
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2009
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2010
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2011
|
+
|
|
2012
|
+
mock_env_manager = MagicMock()
|
|
2013
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2014
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2015
|
+
|
|
2016
|
+
args = Namespace(
|
|
2017
|
+
env_manager=mock_env_manager,
|
|
2018
|
+
host="claude-desktop", # Exact match
|
|
2019
|
+
json=False,
|
|
2020
|
+
)
|
|
2021
|
+
|
|
2022
|
+
claude_config = HostConfiguration(servers={
|
|
2023
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=[]),
|
|
2024
|
+
})
|
|
2025
|
+
cursor_config = HostConfiguration(servers={
|
|
2026
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=[]),
|
|
2027
|
+
})
|
|
2028
|
+
|
|
2029
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2030
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
2031
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
2032
|
+
MCPHostType.CURSOR,
|
|
2033
|
+
]
|
|
2034
|
+
|
|
2035
|
+
def get_strategy_side_effect(host_type):
|
|
2036
|
+
mock_strategy = MagicMock()
|
|
2037
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2038
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
2039
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
2040
|
+
elif host_type == MCPHostType.CURSOR:
|
|
2041
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
2042
|
+
else:
|
|
2043
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
2044
|
+
return mock_strategy
|
|
2045
|
+
|
|
2046
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
2047
|
+
|
|
2048
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2049
|
+
captured_output = io.StringIO()
|
|
2050
|
+
with patch('sys.stdout', captured_output):
|
|
2051
|
+
result = handle_mcp_show_servers(args)
|
|
2052
|
+
|
|
2053
|
+
output = captured_output.getvalue()
|
|
2054
|
+
|
|
2055
|
+
# Should show server from matching host
|
|
2056
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
2057
|
+
|
|
2058
|
+
# Should NOT show server only on non-matching host
|
|
2059
|
+
assert "fetch-server" not in output, "fetch-server should NOT appear"
|
|
2060
|
+
|
|
2061
|
+
def test_mcp_show_servers_host_filter_pattern(self):
|
|
2062
|
+
"""--host filter should support regex patterns.
|
|
2063
|
+
|
|
2064
|
+
Reference: R11 §2.2 - Host filter with regex pattern
|
|
2065
|
+
"""
|
|
2066
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2067
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2068
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2069
|
+
|
|
2070
|
+
mock_env_manager = MagicMock()
|
|
2071
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2072
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2073
|
+
|
|
2074
|
+
args = Namespace(
|
|
2075
|
+
env_manager=mock_env_manager,
|
|
2076
|
+
host="claude.*", # Regex pattern
|
|
2077
|
+
json=False,
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
claude_config = HostConfiguration(servers={
|
|
2081
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=[]),
|
|
2082
|
+
})
|
|
2083
|
+
cursor_config = HostConfiguration(servers={
|
|
2084
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=[]),
|
|
2085
|
+
})
|
|
2086
|
+
|
|
2087
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2088
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
2089
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
2090
|
+
MCPHostType.CURSOR,
|
|
2091
|
+
]
|
|
2092
|
+
|
|
2093
|
+
def get_strategy_side_effect(host_type):
|
|
2094
|
+
mock_strategy = MagicMock()
|
|
2095
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2096
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
2097
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
2098
|
+
elif host_type == MCPHostType.CURSOR:
|
|
2099
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
2100
|
+
else:
|
|
2101
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
2102
|
+
return mock_strategy
|
|
2103
|
+
|
|
2104
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
2105
|
+
|
|
2106
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2107
|
+
captured_output = io.StringIO()
|
|
2108
|
+
with patch('sys.stdout', captured_output):
|
|
2109
|
+
result = handle_mcp_show_servers(args)
|
|
2110
|
+
|
|
2111
|
+
output = captured_output.getvalue()
|
|
2112
|
+
|
|
2113
|
+
# Should show server from matching host
|
|
2114
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
2115
|
+
|
|
2116
|
+
# Should NOT show server only on non-matching host
|
|
2117
|
+
assert "fetch-server" not in output, "fetch-server should NOT appear"
|
|
2118
|
+
|
|
2119
|
+
def test_mcp_show_servers_host_filter_multi_pattern(self):
|
|
2120
|
+
"""--host filter should support multi-pattern regex.
|
|
2121
|
+
|
|
2122
|
+
Reference: R11 §2.2 - Host filter with multi-pattern
|
|
2123
|
+
"""
|
|
2124
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2125
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2126
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2127
|
+
|
|
2128
|
+
mock_env_manager = MagicMock()
|
|
2129
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2130
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2131
|
+
|
|
2132
|
+
args = Namespace(
|
|
2133
|
+
env_manager=mock_env_manager,
|
|
2134
|
+
host="claude-desktop|cursor", # Multi-pattern
|
|
2135
|
+
json=False,
|
|
2136
|
+
)
|
|
2137
|
+
|
|
2138
|
+
claude_config = HostConfiguration(servers={
|
|
2139
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=[]),
|
|
2140
|
+
})
|
|
2141
|
+
cursor_config = HostConfiguration(servers={
|
|
2142
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=[]),
|
|
2143
|
+
})
|
|
2144
|
+
kiro_config = HostConfiguration(servers={
|
|
2145
|
+
"debug-server": MCPServerConfig(name="debug-server", command="node", args=[]),
|
|
2146
|
+
})
|
|
2147
|
+
|
|
2148
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2149
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
2150
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
2151
|
+
MCPHostType.CURSOR,
|
|
2152
|
+
MCPHostType.KIRO,
|
|
2153
|
+
]
|
|
2154
|
+
|
|
2155
|
+
def get_strategy_side_effect(host_type):
|
|
2156
|
+
mock_strategy = MagicMock()
|
|
2157
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2158
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
2159
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
2160
|
+
elif host_type == MCPHostType.CURSOR:
|
|
2161
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
2162
|
+
elif host_type == MCPHostType.KIRO:
|
|
2163
|
+
mock_strategy.read_configuration.return_value = kiro_config
|
|
2164
|
+
else:
|
|
2165
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
2166
|
+
return mock_strategy
|
|
2167
|
+
|
|
2168
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
2169
|
+
|
|
2170
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2171
|
+
captured_output = io.StringIO()
|
|
2172
|
+
with patch('sys.stdout', captured_output):
|
|
2173
|
+
result = handle_mcp_show_servers(args)
|
|
2174
|
+
|
|
2175
|
+
output = captured_output.getvalue()
|
|
2176
|
+
|
|
2177
|
+
# Should show servers from matching hosts
|
|
2178
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
2179
|
+
assert "fetch-server" in output, "fetch-server should appear"
|
|
2180
|
+
|
|
2181
|
+
# Should NOT show server only on non-matching host
|
|
2182
|
+
assert "debug-server" not in output, "debug-server should NOT appear"
|
|
2183
|
+
|
|
2184
|
+
def test_mcp_show_servers_omits_empty_servers(self):
|
|
2185
|
+
"""Servers with no matching hosts should be omitted.
|
|
2186
|
+
|
|
2187
|
+
Reference: R11 §2.2 - Empty server omission
|
|
2188
|
+
"""
|
|
2189
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2190
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2191
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2192
|
+
|
|
2193
|
+
mock_env_manager = MagicMock()
|
|
2194
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2195
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2196
|
+
|
|
2197
|
+
args = Namespace(
|
|
2198
|
+
env_manager=mock_env_manager,
|
|
2199
|
+
host="claude-desktop", # Only matches claude-desktop
|
|
2200
|
+
json=False,
|
|
2201
|
+
)
|
|
2202
|
+
|
|
2203
|
+
claude_config = HostConfiguration(servers={
|
|
2204
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=[]),
|
|
2205
|
+
})
|
|
2206
|
+
cursor_config = HostConfiguration(servers={
|
|
2207
|
+
"fetch-server": MCPServerConfig(name="fetch-server", command="python", args=[]),
|
|
2208
|
+
})
|
|
2209
|
+
|
|
2210
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2211
|
+
mock_registry.detect_available_hosts.return_value = [
|
|
2212
|
+
MCPHostType.CLAUDE_DESKTOP,
|
|
2213
|
+
MCPHostType.CURSOR,
|
|
2214
|
+
]
|
|
2215
|
+
|
|
2216
|
+
def get_strategy_side_effect(host_type):
|
|
2217
|
+
mock_strategy = MagicMock()
|
|
2218
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2219
|
+
if host_type == MCPHostType.CLAUDE_DESKTOP:
|
|
2220
|
+
mock_strategy.read_configuration.return_value = claude_config
|
|
2221
|
+
elif host_type == MCPHostType.CURSOR:
|
|
2222
|
+
mock_strategy.read_configuration.return_value = cursor_config
|
|
2223
|
+
else:
|
|
2224
|
+
mock_strategy.read_configuration.return_value = HostConfiguration(servers={})
|
|
2225
|
+
return mock_strategy
|
|
2226
|
+
|
|
2227
|
+
mock_registry.get_strategy.side_effect = get_strategy_side_effect
|
|
2228
|
+
|
|
2229
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2230
|
+
captured_output = io.StringIO()
|
|
2231
|
+
with patch('sys.stdout', captured_output):
|
|
2232
|
+
result = handle_mcp_show_servers(args)
|
|
2233
|
+
|
|
2234
|
+
output = captured_output.getvalue()
|
|
2235
|
+
|
|
2236
|
+
# weather-server should appear (has matching host)
|
|
2237
|
+
assert "weather-server" in output, "weather-server should appear"
|
|
2238
|
+
|
|
2239
|
+
# fetch-server should NOT appear (no matching hosts)
|
|
2240
|
+
assert "fetch-server" not in output, "fetch-server should NOT appear"
|
|
2241
|
+
|
|
2242
|
+
def test_mcp_show_servers_alphabetical_ordering(self):
|
|
2243
|
+
"""Servers should be sorted alphabetically.
|
|
2244
|
+
|
|
2245
|
+
Reference: R11 §1.4 - Alphabetical ordering
|
|
2246
|
+
"""
|
|
2247
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2248
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2249
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2250
|
+
|
|
2251
|
+
mock_env_manager = MagicMock()
|
|
2252
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2253
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2254
|
+
|
|
2255
|
+
args = Namespace(
|
|
2256
|
+
env_manager=mock_env_manager,
|
|
2257
|
+
host=None,
|
|
2258
|
+
json=False,
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
# Servers in non-alphabetical order
|
|
2262
|
+
mock_config = HostConfiguration(servers={
|
|
2263
|
+
"zebra-server": MCPServerConfig(name="zebra-server", command="python", args=[]),
|
|
2264
|
+
"alpha-server": MCPServerConfig(name="alpha-server", command="python", args=[]),
|
|
2265
|
+
})
|
|
2266
|
+
|
|
2267
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2268
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
2269
|
+
mock_strategy = MagicMock()
|
|
2270
|
+
mock_strategy.read_configuration.return_value = mock_config
|
|
2271
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2272
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
2273
|
+
|
|
2274
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2275
|
+
captured_output = io.StringIO()
|
|
2276
|
+
with patch('sys.stdout', captured_output):
|
|
2277
|
+
result = handle_mcp_show_servers(args)
|
|
2278
|
+
|
|
2279
|
+
output = captured_output.getvalue()
|
|
2280
|
+
|
|
2281
|
+
# Find positions of server names
|
|
2282
|
+
alpha_pos = output.find("alpha-server")
|
|
2283
|
+
zebra_pos = output.find("zebra-server")
|
|
2284
|
+
|
|
2285
|
+
# alpha-server should appear before zebra-server (alphabetically)
|
|
2286
|
+
assert alpha_pos < zebra_pos, \
|
|
2287
|
+
"Servers should be sorted alphabetically (alpha-server before zebra-server)"
|
|
2288
|
+
|
|
2289
|
+
def test_mcp_show_servers_horizontal_separators(self):
|
|
2290
|
+
"""Output should have horizontal separators between server sections.
|
|
2291
|
+
|
|
2292
|
+
Reference: R11 §3.1 - Horizontal separators
|
|
2293
|
+
"""
|
|
2294
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2295
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2296
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2297
|
+
|
|
2298
|
+
mock_env_manager = MagicMock()
|
|
2299
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2300
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2301
|
+
|
|
2302
|
+
args = Namespace(
|
|
2303
|
+
env_manager=mock_env_manager,
|
|
2304
|
+
host=None,
|
|
2305
|
+
json=False,
|
|
2306
|
+
)
|
|
2307
|
+
|
|
2308
|
+
mock_config = HostConfiguration(servers={
|
|
2309
|
+
"server-a": MCPServerConfig(name="server-a", command="python", args=[]),
|
|
2310
|
+
})
|
|
2311
|
+
|
|
2312
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2313
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
2314
|
+
mock_strategy = MagicMock()
|
|
2315
|
+
mock_strategy.read_configuration.return_value = mock_config
|
|
2316
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2317
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
2318
|
+
|
|
2319
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2320
|
+
captured_output = io.StringIO()
|
|
2321
|
+
with patch('sys.stdout', captured_output):
|
|
2322
|
+
result = handle_mcp_show_servers(args)
|
|
2323
|
+
|
|
2324
|
+
output = captured_output.getvalue()
|
|
2325
|
+
|
|
2326
|
+
# Should have horizontal separator (═ character)
|
|
2327
|
+
assert "═" in output, "Output should have horizontal separators"
|
|
2328
|
+
|
|
2329
|
+
def test_mcp_show_servers_json_output(self):
|
|
2330
|
+
"""--json flag should output JSON format.
|
|
2331
|
+
|
|
2332
|
+
Reference: R11 §6.2 - JSON output format
|
|
2333
|
+
"""
|
|
2334
|
+
from hatch.cli.cli_mcp import handle_mcp_show_servers
|
|
2335
|
+
from hatch.mcp_host_config import MCPHostType, MCPServerConfig
|
|
2336
|
+
from hatch.mcp_host_config.models import HostConfiguration
|
|
2337
|
+
import json
|
|
2338
|
+
|
|
2339
|
+
mock_env_manager = MagicMock()
|
|
2340
|
+
mock_env_manager.list_environments.return_value = [{"name": "default"}]
|
|
2341
|
+
mock_env_manager.get_environment_data.return_value = {"packages": []}
|
|
2342
|
+
|
|
2343
|
+
args = Namespace(
|
|
2344
|
+
env_manager=mock_env_manager,
|
|
2345
|
+
host=None,
|
|
2346
|
+
json=True, # JSON output
|
|
2347
|
+
)
|
|
2348
|
+
|
|
2349
|
+
mock_host_config = HostConfiguration(servers={
|
|
2350
|
+
"weather-server": MCPServerConfig(name="weather-server", command="uvx", args=["weather-mcp"]),
|
|
2351
|
+
})
|
|
2352
|
+
|
|
2353
|
+
with patch('hatch.cli.cli_mcp.MCPHostRegistry') as mock_registry:
|
|
2354
|
+
mock_registry.detect_available_hosts.return_value = [MCPHostType.CLAUDE_DESKTOP]
|
|
2355
|
+
mock_strategy = MagicMock()
|
|
2356
|
+
mock_strategy.read_configuration.return_value = mock_host_config
|
|
2357
|
+
mock_strategy.get_config_path.return_value = MagicMock(exists=lambda: True)
|
|
2358
|
+
mock_registry.get_strategy.return_value = mock_strategy
|
|
2359
|
+
|
|
2360
|
+
with patch('hatch.mcp_host_config.strategies'):
|
|
2361
|
+
captured_output = io.StringIO()
|
|
2362
|
+
with patch('sys.stdout', captured_output):
|
|
2363
|
+
result = handle_mcp_show_servers(args)
|
|
2364
|
+
|
|
2365
|
+
output = captured_output.getvalue()
|
|
2366
|
+
|
|
2367
|
+
# Should be valid JSON
|
|
2368
|
+
try:
|
|
2369
|
+
data = json.loads(output)
|
|
2370
|
+
except json.JSONDecodeError:
|
|
2371
|
+
pytest.fail(f"Output should be valid JSON: {output}")
|
|
2372
|
+
|
|
2373
|
+
# Should have servers array
|
|
2374
|
+
assert "servers" in data, "JSON should have 'servers' key"
|
|
2375
|
+
assert len(data["servers"]) > 0, "Should have at least one server"
|
|
2376
|
+
|
|
2377
|
+
# Server should have expected structure
|
|
2378
|
+
server = data["servers"][0]
|
|
2379
|
+
assert "name" in server, "Server should have 'name' key"
|
|
2380
|
+
assert "hosts" in server, "Server should have 'hosts' key"
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
class TestMCPShowCommandRemoval:
|
|
2384
|
+
"""Tests for mcp show command behavior after removal of legacy syntax.
|
|
2385
|
+
|
|
2386
|
+
Reference: R11 §5 (11-enhancing_show_command_v0.md) - Migration Path
|
|
2387
|
+
|
|
2388
|
+
These tests verify that:
|
|
2389
|
+
1. 'hatch mcp show' without subcommand shows help/error
|
|
2390
|
+
2. Invalid subcommands show appropriate error
|
|
2391
|
+
"""
|
|
2392
|
+
|
|
2393
|
+
def test_mcp_show_without_subcommand_shows_help(self):
|
|
2394
|
+
"""'hatch mcp show' without subcommand should show help message.
|
|
2395
|
+
|
|
2396
|
+
Reference: R11 §5.3 - Clean removal
|
|
2397
|
+
"""
|
|
2398
|
+
from hatch.cli.__main__ import _route_mcp_command
|
|
2399
|
+
|
|
2400
|
+
# Create args with no show_command
|
|
2401
|
+
args = Namespace(
|
|
2402
|
+
mcp_command="show",
|
|
2403
|
+
show_command=None,
|
|
2404
|
+
)
|
|
2405
|
+
|
|
2406
|
+
captured_output = io.StringIO()
|
|
2407
|
+
with patch('sys.stdout', captured_output):
|
|
2408
|
+
result = _route_mcp_command(args)
|
|
2409
|
+
|
|
2410
|
+
output = captured_output.getvalue()
|
|
2411
|
+
|
|
2412
|
+
# Should return error code
|
|
2413
|
+
assert result == 1, "Should return error code when no subcommand"
|
|
2414
|
+
|
|
2415
|
+
# Should show helpful message
|
|
2416
|
+
assert "hosts" in output or "servers" in output, \
|
|
2417
|
+
"Error message should mention available subcommands"
|
|
2418
|
+
|
|
2419
|
+
def test_mcp_show_invalid_subcommand_error(self):
|
|
2420
|
+
"""Invalid subcommand should show error message.
|
|
2421
|
+
|
|
2422
|
+
Reference: R11 §5.3 - Clean removal
|
|
2423
|
+
"""
|
|
2424
|
+
from hatch.cli.__main__ import _route_mcp_command
|
|
2425
|
+
|
|
2426
|
+
# Create args with invalid show_command
|
|
2427
|
+
args = Namespace(
|
|
2428
|
+
mcp_command="show",
|
|
2429
|
+
show_command="invalid",
|
|
2430
|
+
)
|
|
2431
|
+
|
|
2432
|
+
captured_output = io.StringIO()
|
|
2433
|
+
with patch('sys.stdout', captured_output):
|
|
2434
|
+
result = _route_mcp_command(args)
|
|
2435
|
+
|
|
2436
|
+
output = captured_output.getvalue()
|
|
2437
|
+
|
|
2438
|
+
# Should return error code
|
|
2439
|
+
assert result == 1, "Should return error code for invalid subcommand"
|