@@ -416,3 +416,313 @@ def test_backup_clean_handler_uses_result_reporter(self):
416416 # Verify output uses ResultReporter format
417417 assert "[SUCCESS]" in output or "[CLEANED]" in output or "cleaned" in output .lower (), \
418418 "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
449+ mock_env_manager = MagicMock ()
450+ mock_env_manager .list_environments .return_value = [{"name" : "default" }]
451+ mock_env_manager .get_environment_data .return_value = MagicMock (
452+ packages = [
453+ MagicMock (
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+ pattern = None ,
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 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 = MagicMock (packages = [])
520+
521+ args = Namespace (
522+ env_manager = mock_env_manager ,
523+ host = "claude-desktop" ,
524+ pattern = None ,
525+ json = False ,
526+ )
527+
528+ # Host config has a server that's NOT in any Hatch environment
529+ mock_host_config = HostConfiguration (servers = {
530+ "external-tool" : MCPServerConfig (name = "external-tool" , command = "external" , args = []),
531+ })
532+
533+ with patch ('hatch.cli.cli_mcp.MCPHostRegistry' ) as mock_registry :
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 = MagicMock (packages = [])
564+
565+ args = Namespace (
566+ env_manager = mock_env_manager ,
567+ host = None , # No host filter - show ALL hosts
568+ pattern = None ,
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_pattern_filter (self ):
617+ """--pattern flag should filter servers by regex on server name.
618+
619+ Reference: R02 §2.5 - "--pattern filters by server name (regex)"
620+ """
621+ from hatch .cli .cli_mcp import handle_mcp_list_servers
622+ from hatch .mcp_host_config import 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 = MagicMock (packages = [])
628+
629+ args = Namespace (
630+ env_manager = mock_env_manager ,
631+ host = "claude-desktop" ,
632+ pattern = "weather.*" , # Regex pattern
633+ json = False ,
634+ )
635+
636+ mock_host_config = HostConfiguration (servers = {
637+ "weather-server" : MCPServerConfig (name = "weather-server" , command = "python" , args = []),
638+ "weather-api" : MCPServerConfig (name = "weather-api" , command = "python" , args = []),
639+ "fetch-server" : MCPServerConfig (name = "fetch-server" , command = "node" , args = []), # Should NOT match
640+ })
641+
642+ with patch ('hatch.cli.cli_mcp.MCPHostRegistry' ) as mock_registry :
643+ mock_strategy = MagicMock ()
644+ mock_strategy .read_configuration .return_value = mock_host_config
645+ mock_strategy .get_config_path .return_value = MagicMock (exists = lambda : True )
646+ mock_registry .get_strategy .return_value = mock_strategy
647+
648+ with patch ('hatch.mcp_host_config.strategies' ):
649+ captured_output = io .StringIO ()
650+ with patch ('sys.stdout' , captured_output ):
651+ result = handle_mcp_list_servers (args )
652+
653+ output = captured_output .getvalue ()
654+
655+ # Matching servers should appear
656+ assert "weather-server" in output , "weather-server should match pattern"
657+ assert "weather-api" in output , "weather-api should match pattern"
658+
659+ # Non-matching server should NOT appear
660+ assert "fetch-server" not in output , \
661+ "fetch-server should NOT appear (doesn't match pattern)"
662+
663+ def test_list_servers_json_output_host_centric (self ):
664+ """JSON output should include host-centric data structure.
665+
666+ Reference: R02 §8.1 - JSON output format for mcp list servers
667+ """
668+ from hatch .cli .cli_mcp import handle_mcp_list_servers
669+ from hatch .mcp_host_config import MCPServerConfig
670+ from hatch .mcp_host_config .models import HostConfiguration
671+ import json
672+
673+ mock_env_manager = MagicMock ()
674+ mock_env_manager .list_environments .return_value = [{"name" : "default" }]
675+ mock_env_manager .get_environment_data .return_value = MagicMock (
676+ packages = [
677+ MagicMock (
678+ name = "managed-server" ,
679+ version = "1.0.0" ,
680+ configured_hosts = {"claude-desktop" : {}}
681+ )
682+ ]
683+ )
684+
685+ args = Namespace (
686+ env_manager = mock_env_manager ,
687+ host = "claude-desktop" ,
688+ pattern = None ,
689+ json = True , # JSON output
690+ )
691+
692+ mock_host_config = HostConfiguration (servers = {
693+ "managed-server" : MCPServerConfig (name = "managed-server" , command = "python" , args = []),
694+ "unmanaged-server" : MCPServerConfig (name = "unmanaged-server" , command = "node" , args = []),
695+ })
696+
697+ with patch ('hatch.cli.cli_mcp.MCPHostRegistry' ) as mock_registry :
698+ mock_strategy = MagicMock ()
699+ mock_strategy .read_configuration .return_value = mock_host_config
700+ mock_strategy .get_config_path .return_value = MagicMock (exists = lambda : True )
701+ mock_registry .get_strategy .return_value = mock_strategy
702+
703+ with patch ('hatch.mcp_host_config.strategies' ):
704+ captured_output = io .StringIO ()
705+ with patch ('sys.stdout' , captured_output ):
706+ result = handle_mcp_list_servers (args )
707+
708+ output = captured_output .getvalue ()
709+
710+ # Parse JSON output
711+ data = json .loads (output )
712+
713+ # Verify structure
714+ assert "host" in data , "JSON should include host field"
715+ assert "servers" in data , "JSON should include servers array"
716+ assert data ["host" ] == "claude-desktop"
717+
718+ # Verify both servers present with hatch_managed field
719+ server_names = [s ["name" ] for s in data ["servers" ]]
720+ assert "managed-server" in server_names
721+ assert "unmanaged-server" in server_names
722+
723+ # Verify hatch_managed status
724+ for server in data ["servers" ]:
725+ if server ["name" ] == "managed-server" :
726+ assert server ["hatch_managed" ] == True
727+ elif server ["name" ] == "unmanaged-server" :
728+ assert server ["hatch_managed" ] == False
0 commit comments