Skip to content

Commit a8fd4d5

Browse files
authored
Merge pull request #285 from alex-feel/alex-feel-dev
Resolve MCP server removal and display format bugs
2 parents 113dd10 + b18cc6a commit a8fd4d5

File tree

2 files changed

+210
-17
lines changed

2 files changed

+210
-17
lines changed

scripts/setup_environment.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4077,8 +4077,9 @@ def configure_mcp_server(server: dict[str, Any]) -> bool:
40774077
# Remove existing MCP server from all scopes to avoid conflicts
40784078
# When servers with the same name exist at multiple scopes, local-scoped servers
40794079
# take precedence, followed by project, then user - so we remove from all scopes
4080-
info(f'Removing existing MCP server {name} from all scopes if present...')
4081-
scopes_removed: list[str] = []
4080+
# Best-effort removal: Claude CLI returns non-zero if server doesn't exist in a scope,
4081+
# which is expected behavior, not an error - we simply attempt removal from all scopes
4082+
info(f'Removing existing MCP server {name} from all scopes (best-effort)...')
40824083

40834084
if system == 'Windows':
40844085
# Windows: Use bash execution for consistency with add operation
@@ -4101,21 +4102,14 @@ def configure_mcp_server(server: dict[str, Any]) -> bool:
41014102
f'export PATH="{unix_explicit_path}:$PATH" && '
41024103
f'"{unix_claude_cmd}" mcp remove --scope {remove_scope} {name}'
41034104
)
4104-
result = run_bash_command(bash_cmd, capture_output=True, login_shell=True)
4105-
if result.returncode == 0:
4106-
scopes_removed.append(remove_scope)
4105+
# Best-effort: ignore exit code, server may not exist in this scope
4106+
run_bash_command(bash_cmd, capture_output=True, login_shell=True)
41074107
else:
41084108
# Unix: Direct subprocess execution
41094109
for remove_scope in ['user', 'local', 'project']:
41104110
remove_cmd = [str(claude_cmd), 'mcp', 'remove', '--scope', remove_scope, name]
4111-
result = run_command(remove_cmd, capture_output=True)
4112-
if result.returncode == 0:
4113-
scopes_removed.append(remove_scope)
4114-
4115-
if scopes_removed:
4116-
info(f"Removed MCP server {name} from scope(s): {', '.join(scopes_removed)}")
4117-
else:
4118-
info(f'MCP server {name} was not found in any scope')
4111+
# Best-effort: ignore exit code, server may not exist in this scope
4112+
run_command(remove_cmd, capture_output=True)
41194113

41204114
# Profile-scoped servers are configured via create_mcp_config_file(), not claude mcp add
41214115
if scope == 'profile':
@@ -5941,12 +5935,18 @@ def main() -> None:
59415935
print(f' * Model: {model}')
59425936
if mcp_stats['combined_count'] > 0:
59435937
# Servers with BOTH global AND profile scope
5944-
print(f" * MCP servers: {mcp_stats['global_count']} global "
5945-
f"({mcp_stats['combined_count']} also in profile)")
5938+
profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count']
5939+
if profile_only > 0:
5940+
print(f" * MCP servers: {mcp_stats['global_count']} global "
5941+
f"({mcp_stats['combined_count']} also in profile), "
5942+
f"{profile_only} profile-only")
5943+
else:
5944+
print(f" * MCP servers: {mcp_stats['global_count']} global "
5945+
f"(all {mcp_stats['combined_count']} also in profile)")
59465946
elif profile_servers:
5947-
# Servers with EITHER global OR profile scope (mutually exclusive)
5947+
# Servers with ONLY profile scope (no global scope)
59485948
print(f" * MCP servers: {mcp_stats['global_count']} global, "
5949-
f"{mcp_stats['profile_count']} profile-scoped (isolated)")
5949+
f"{mcp_stats['profile_count']} profile-only")
59505950
else:
59515951
print(f' * MCP servers: {len(mcp_servers)} configured')
59525952
if permissions:

tests/test_setup_environment_additional.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import tempfile
1313
import urllib.error
1414
from pathlib import Path
15+
from typing import Any
1516
from unittest.mock import MagicMock
1617
from unittest.mock import patch
1718

@@ -3952,3 +3953,195 @@ def test_windows_removal_all_scopes(
39523953

39533954
# All three scopes should be attempted
39543955
assert scopes_removed == {'user', 'local', 'project'}
3956+
3957+
3958+
class TestConfigureMcpServerBestEffortRemoval:
3959+
"""Test configure_mcp_server best-effort removal (ignores exit codes)."""
3960+
3961+
@patch('platform.system', return_value='Windows')
3962+
@patch('setup_environment.find_command_robust', return_value='claude')
3963+
@patch('setup_environment.run_bash_command')
3964+
def test_windows_best_effort_removal_non_existent_server(
3965+
self,
3966+
mock_bash: MagicMock,
3967+
mock_find: MagicMock,
3968+
_mock_system: MagicMock,
3969+
) -> None:
3970+
"""Test Windows removal ignores non-zero exit code for non-existent servers."""
3971+
del mock_find, _mock_system
3972+
3973+
# Removal returns non-zero (server doesn't exist), add succeeds
3974+
def bash_side_effect(cmd: str, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]:
3975+
del args, kwargs
3976+
if 'mcp remove' in cmd:
3977+
return subprocess.CompletedProcess([], 1, '', 'No user-scoped MCP server found')
3978+
return subprocess.CompletedProcess([], 0, '', '')
3979+
3980+
mock_bash.side_effect = bash_side_effect
3981+
3982+
server = {
3983+
'name': 'new-server',
3984+
'command': 'uvx new-server',
3985+
'scope': 'user',
3986+
}
3987+
3988+
result = setup_environment.configure_mcp_server(server)
3989+
3990+
assert result is True # Should succeed despite removal returning non-zero
3991+
# Verify removal was attempted for all scopes
3992+
removal_calls = [c for c in mock_bash.call_args_list if 'mcp remove' in str(c)]
3993+
assert len(removal_calls) == 3 # user, local, project
3994+
3995+
@patch('platform.system', return_value='Linux')
3996+
@patch('setup_environment.find_command_robust', return_value='claude')
3997+
@patch('setup_environment.run_command')
3998+
def test_unix_best_effort_removal_non_existent_server(
3999+
self,
4000+
mock_run: MagicMock,
4001+
mock_find: MagicMock,
4002+
_mock_system: MagicMock,
4003+
) -> None:
4004+
"""Test Unix removal ignores non-zero exit code for non-existent servers."""
4005+
del mock_find, _mock_system
4006+
4007+
# Removal returns non-zero (server doesn't exist), add succeeds
4008+
def run_side_effect(
4009+
cmd: list[str], *args: Any, **kwargs: Any,
4010+
) -> subprocess.CompletedProcess[str]:
4011+
del args, kwargs
4012+
if 'remove' in cmd:
4013+
return subprocess.CompletedProcess([], 1, '', 'No user-scoped MCP server found')
4014+
return subprocess.CompletedProcess([], 0, '', '')
4015+
4016+
mock_run.side_effect = run_side_effect
4017+
4018+
server = {
4019+
'name': 'new-server',
4020+
'command': 'uvx new-server',
4021+
'scope': 'user',
4022+
}
4023+
4024+
result = setup_environment.configure_mcp_server(server)
4025+
4026+
assert result is True # Should succeed despite removal returning non-zero
4027+
4028+
@patch('platform.system', return_value='Linux')
4029+
@patch('setup_environment.find_command_robust', return_value='claude')
4030+
@patch('setup_environment.run_command')
4031+
def test_removal_attempts_all_scopes_regardless_of_exit_codes(
4032+
self,
4033+
mock_run: MagicMock,
4034+
mock_find: MagicMock,
4035+
_mock_system: MagicMock,
4036+
) -> None:
4037+
"""Test that removal is attempted for all scopes even when some fail."""
4038+
del mock_find, _mock_system
4039+
4040+
removal_attempts: list[str] = []
4041+
4042+
def run_side_effect(
4043+
cmd: list[str], *args: Any, **kwargs: Any,
4044+
) -> subprocess.CompletedProcess[str]:
4045+
del args, kwargs
4046+
if 'remove' in cmd:
4047+
scope_idx = cmd.index('--scope') + 1 if '--scope' in cmd else -1
4048+
if scope_idx > 0:
4049+
removal_attempts.append(cmd[scope_idx])
4050+
# First scope fails, others succeed
4051+
if len(removal_attempts) == 1:
4052+
return subprocess.CompletedProcess([], 1, '', 'Not found')
4053+
return subprocess.CompletedProcess([], 0, '', '')
4054+
4055+
mock_run.side_effect = run_side_effect
4056+
4057+
server = {
4058+
'name': 'test-server',
4059+
'command': 'uvx test-server',
4060+
'scope': 'user',
4061+
}
4062+
4063+
result = setup_environment.configure_mcp_server(server)
4064+
4065+
assert result is True
4066+
# All three scopes should have been attempted
4067+
assert set(removal_attempts) == {'user', 'local', 'project'}
4068+
4069+
4070+
class TestMcpServersSummaryDisplay:
4071+
"""Test MCP servers summary display format in main()."""
4072+
4073+
def test_summary_shows_profile_only_when_combined_exists(
4074+
self,
4075+
capsys: pytest.CaptureFixture[str],
4076+
) -> None:
4077+
"""Test summary displays profile-only servers when combined servers exist."""
4078+
# Stats: 5 global, 10 profile, 5 combined -> 5 profile-only
4079+
mcp_stats = {'global_count': 5, 'profile_count': 10, 'combined_count': 5}
4080+
4081+
# Simulate the display logic from main()
4082+
if mcp_stats['combined_count'] > 0:
4083+
profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count']
4084+
if profile_only > 0:
4085+
print(f" * MCP servers: {mcp_stats['global_count']} global "
4086+
f"({mcp_stats['combined_count']} also in profile), "
4087+
f"{profile_only} profile-only")
4088+
else:
4089+
print(f" * MCP servers: {mcp_stats['global_count']} global "
4090+
f"(all {mcp_stats['combined_count']} also in profile)")
4091+
4092+
captured = capsys.readouterr()
4093+
# Verify the output includes profile-only count
4094+
assert '5 global' in captured.out
4095+
assert '5 also in profile' in captured.out
4096+
assert '5 profile-only' in captured.out
4097+
4098+
def test_summary_shows_all_in_profile_when_no_profile_only(
4099+
self,
4100+
capsys: pytest.CaptureFixture[str],
4101+
) -> None:
4102+
"""Test summary shows 'all X also in profile' when profile_only is 0."""
4103+
# Stats: 5 global, 5 profile, 5 combined -> 0 profile-only
4104+
mcp_stats = {'global_count': 5, 'profile_count': 5, 'combined_count': 5}
4105+
4106+
# Simulate the display logic from main()
4107+
if mcp_stats['combined_count'] > 0:
4108+
profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count']
4109+
if profile_only > 0:
4110+
print(f" * MCP servers: {mcp_stats['global_count']} global "
4111+
f"({mcp_stats['combined_count']} also in profile), "
4112+
f"{profile_only} profile-only")
4113+
else:
4114+
print(f" * MCP servers: {mcp_stats['global_count']} global "
4115+
f"(all {mcp_stats['combined_count']} also in profile)")
4116+
4117+
captured = capsys.readouterr()
4118+
assert '5 global' in captured.out
4119+
assert 'all 5 also in profile' in captured.out
4120+
assert 'profile-only' not in captured.out
4121+
4122+
def test_summary_handles_profile_only_without_combined(
4123+
self,
4124+
capsys: pytest.CaptureFixture[str],
4125+
) -> None:
4126+
"""Test summary displays profile-only servers when no combined servers."""
4127+
# Stats: 3 global, 5 profile, 0 combined -> all 5 are profile-only
4128+
mcp_stats = {'global_count': 3, 'profile_count': 5, 'combined_count': 0}
4129+
profile_servers = [{'name': f'server-{i}'} for i in range(5)]
4130+
4131+
# Simulate the display logic from main()
4132+
if mcp_stats['combined_count'] > 0:
4133+
profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count']
4134+
if profile_only > 0:
4135+
print(f" * MCP servers: {mcp_stats['global_count']} global "
4136+
f"({mcp_stats['combined_count']} also in profile), "
4137+
f"{profile_only} profile-only")
4138+
else:
4139+
print(f" * MCP servers: {mcp_stats['global_count']} global "
4140+
f"(all {mcp_stats['combined_count']} also in profile)")
4141+
elif profile_servers:
4142+
print(f" * MCP servers: {mcp_stats['global_count']} global, "
4143+
f"{mcp_stats['profile_count']} profile-only")
4144+
4145+
captured = capsys.readouterr()
4146+
assert '3 global' in captured.out
4147+
assert '5 profile-only' in captured.out

0 commit comments

Comments
 (0)