Skip to content

Commit 0fcb8fd

Browse files
test(cli): add failing test for host-centric mcp list servers
1 parent 29f86aa commit 0fcb8fd

File tree

1 file changed

+310
-0
lines changed

1 file changed

+310
-0
lines changed

tests/integration/cli/test_cli_reporter_integration.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)