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.
Files changed (81) hide show
  1. hatch/__init__.py +1 -1
  2. hatch/cli/__init__.py +71 -0
  3. hatch/cli/__main__.py +1035 -0
  4. hatch/cli/cli_env.py +865 -0
  5. hatch/cli/cli_mcp.py +1965 -0
  6. hatch/cli/cli_package.py +566 -0
  7. hatch/cli/cli_system.py +136 -0
  8. hatch/cli/cli_utils.py +1289 -0
  9. hatch/cli_hatch.py +160 -2838
  10. hatch/mcp_host_config/__init__.py +10 -10
  11. hatch/mcp_host_config/adapters/__init__.py +34 -0
  12. hatch/mcp_host_config/adapters/base.py +170 -0
  13. hatch/mcp_host_config/adapters/claude.py +105 -0
  14. hatch/mcp_host_config/adapters/codex.py +104 -0
  15. hatch/mcp_host_config/adapters/cursor.py +83 -0
  16. hatch/mcp_host_config/adapters/gemini.py +75 -0
  17. hatch/mcp_host_config/adapters/kiro.py +78 -0
  18. hatch/mcp_host_config/adapters/lmstudio.py +79 -0
  19. hatch/mcp_host_config/adapters/registry.py +149 -0
  20. hatch/mcp_host_config/adapters/vscode.py +83 -0
  21. hatch/mcp_host_config/backup.py +5 -3
  22. hatch/mcp_host_config/fields.py +126 -0
  23. hatch/mcp_host_config/models.py +161 -456
  24. hatch/mcp_host_config/reporting.py +57 -16
  25. hatch/mcp_host_config/strategies.py +155 -87
  26. hatch/template_generator.py +1 -1
  27. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/METADATA +3 -2
  28. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/RECORD +52 -43
  29. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/WHEEL +1 -1
  30. hatch_xclam-0.8.0.dev1.dist-info/entry_points.txt +2 -0
  31. tests/cli_test_utils.py +280 -0
  32. tests/integration/cli/__init__.py +14 -0
  33. tests/integration/cli/test_cli_reporter_integration.py +2439 -0
  34. tests/integration/mcp/__init__.py +0 -0
  35. tests/integration/mcp/test_adapter_serialization.py +173 -0
  36. tests/regression/cli/__init__.py +16 -0
  37. tests/regression/cli/test_color_logic.py +268 -0
  38. tests/regression/cli/test_consequence_type.py +298 -0
  39. tests/regression/cli/test_error_formatting.py +328 -0
  40. tests/regression/cli/test_result_reporter.py +586 -0
  41. tests/regression/cli/test_table_formatter.py +211 -0
  42. tests/regression/mcp/__init__.py +0 -0
  43. tests/regression/mcp/test_field_filtering.py +162 -0
  44. tests/test_cli_version.py +7 -5
  45. tests/test_data/fixtures/cli_reporter_fixtures.py +184 -0
  46. tests/unit/__init__.py +0 -0
  47. tests/unit/mcp/__init__.py +0 -0
  48. tests/unit/mcp/test_adapter_protocol.py +138 -0
  49. tests/unit/mcp/test_adapter_registry.py +158 -0
  50. tests/unit/mcp/test_config_model.py +146 -0
  51. hatch_xclam-0.7.1.dev3.dist-info/entry_points.txt +0 -2
  52. tests/integration/test_mcp_kiro_integration.py +0 -153
  53. tests/regression/test_mcp_codex_backup_integration.py +0 -162
  54. tests/regression/test_mcp_codex_host_strategy.py +0 -163
  55. tests/regression/test_mcp_codex_model_validation.py +0 -117
  56. tests/regression/test_mcp_kiro_backup_integration.py +0 -241
  57. tests/regression/test_mcp_kiro_cli_integration.py +0 -141
  58. tests/regression/test_mcp_kiro_decorator_registration.py +0 -71
  59. tests/regression/test_mcp_kiro_host_strategy.py +0 -214
  60. tests/regression/test_mcp_kiro_model_validation.py +0 -116
  61. tests/regression/test_mcp_kiro_omni_conversion.py +0 -104
  62. tests/test_mcp_atomic_operations.py +0 -276
  63. tests/test_mcp_backup_integration.py +0 -308
  64. tests/test_mcp_cli_all_host_specific_args.py +0 -496
  65. tests/test_mcp_cli_backup_management.py +0 -295
  66. tests/test_mcp_cli_direct_management.py +0 -456
  67. tests/test_mcp_cli_discovery_listing.py +0 -582
  68. tests/test_mcp_cli_host_config_integration.py +0 -823
  69. tests/test_mcp_cli_package_management.py +0 -360
  70. tests/test_mcp_cli_partial_updates.py +0 -859
  71. tests/test_mcp_environment_integration.py +0 -520
  72. tests/test_mcp_host_config_backup.py +0 -257
  73. tests/test_mcp_host_configuration_manager.py +0 -331
  74. tests/test_mcp_host_registry_decorator.py +0 -348
  75. tests/test_mcp_pydantic_architecture_v4.py +0 -603
  76. tests/test_mcp_server_config_models.py +0 -242
  77. tests/test_mcp_server_config_type_field.py +0 -221
  78. tests/test_mcp_sync_functionality.py +0 -316
  79. tests/test_mcp_user_feedback_reporting.py +0 -359
  80. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/licenses/LICENSE +0 -0
  81. {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"