Skip to content

Commit 37f1d7d

Browse files
authored
Merge pull request #287 from alex-feel/alex-feel-dev
Ensure profile-only MCP servers trigger removal from all scopes
2 parents efd3571 + 2b80428 commit 37f1d7d

File tree

2 files changed

+261
-0
lines changed

2 files changed

+261
-0
lines changed

scripts/setup_environment.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4343,6 +4343,13 @@ def configure_all_mcp_servers(
43434343
# Add to profile config if profile scope present
43444344
if has_profile:
43454345
profile_servers.append(server)
4346+
# For profile-only servers, call configure_mcp_server to trigger removal
4347+
# from all scopes (user, local, project). The function will early-return
4348+
# after removal since scope == 'profile', skipping the claude mcp add.
4349+
if not has_global:
4350+
server_copy = server.copy()
4351+
server_copy['scope'] = 'profile'
4352+
configure_mcp_server(server_copy)
43464353

43474354
# Configure for each non-profile scope via claude mcp add
43484355
for scope in non_profile_scopes:

tests/test_setup_environment_additional.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2757,6 +2757,260 @@ def test_configure_all_mcp_servers_separates_profile(
27572757
config = json.loads(profile_config.read_text())
27582758
assert 'profile-server' in config['mcpServers']
27592759

2760+
@patch('platform.system', return_value='Linux')
2761+
@patch('setup_environment.find_command_robust', return_value='claude')
2762+
@patch('setup_environment.run_command')
2763+
def test_configure_all_mcp_servers_profile_only_triggers_removal(
2764+
self,
2765+
mock_run: MagicMock,
2766+
mock_find: MagicMock,
2767+
_mock_system: MagicMock,
2768+
) -> None:
2769+
"""Test that profile-only servers trigger removal from all scopes via configure_all_mcp_servers."""
2770+
del mock_find, _mock_system
2771+
mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
2772+
2773+
servers = [
2774+
{'name': 'profile-only-server', 'command': 'uvx profile-only', 'scope': 'profile'},
2775+
]
2776+
2777+
with tempfile.TemporaryDirectory() as tmpdir:
2778+
profile_config = Path(tmpdir) / 'test-mcp.json'
2779+
2780+
success, profile_servers, stats = setup_environment.configure_all_mcp_servers(
2781+
servers,
2782+
profile_mcp_config_path=profile_config,
2783+
)
2784+
2785+
assert success is True
2786+
assert len(profile_servers) == 1
2787+
assert profile_servers[0]['name'] == 'profile-only-server'
2788+
2789+
# Verify removal was attempted from all 3 scopes
2790+
assert mock_run.call_count == 3, (
2791+
f'Expected 3 removal calls (user, local, project), got {mock_run.call_count}'
2792+
)
2793+
2794+
# Verify each call was a removal command
2795+
calls = mock_run.call_args_list
2796+
scopes_removed: set[str] = set()
2797+
for call in calls:
2798+
args = call[0][0]
2799+
assert 'mcp' in args
2800+
assert 'remove' in args
2801+
assert 'profile-only-server' in args
2802+
scope_idx = args.index('--scope') + 1
2803+
scopes_removed.add(args[scope_idx])
2804+
2805+
assert scopes_removed == {'user', 'local', 'project'}, (
2806+
f'Expected removal from all scopes, got: {scopes_removed}'
2807+
)
2808+
2809+
# Verify NO add command was executed (profile servers skip claude mcp add)
2810+
for call in calls:
2811+
args = call[0][0]
2812+
assert 'add' not in args, 'Profile-only server should not call claude mcp add'
2813+
2814+
# Verify stats are correct
2815+
assert stats['profile_count'] == 1
2816+
assert stats['global_count'] == 0
2817+
assert stats['combined_count'] == 0
2818+
2819+
@patch('platform.system', return_value='Linux')
2820+
@patch('setup_environment.find_command_robust', return_value='claude')
2821+
@patch('setup_environment.run_command')
2822+
def test_configure_all_mcp_servers_multi_scope_triggers_removal_and_add(
2823+
self,
2824+
mock_run: MagicMock,
2825+
mock_find: MagicMock,
2826+
_mock_system: MagicMock,
2827+
) -> None:
2828+
"""Test that [user, profile] scope triggers removal from all scopes AND adds to user scope."""
2829+
del mock_find, _mock_system
2830+
mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
2831+
2832+
servers = [
2833+
{
2834+
'name': 'multi-scope-server',
2835+
'command': 'uvx multi-scope',
2836+
'scope': ['user', 'profile'],
2837+
},
2838+
]
2839+
2840+
with tempfile.TemporaryDirectory() as tmpdir:
2841+
profile_config = Path(tmpdir) / 'test-mcp.json'
2842+
2843+
success, profile_servers, stats = setup_environment.configure_all_mcp_servers(
2844+
servers,
2845+
profile_mcp_config_path=profile_config,
2846+
)
2847+
2848+
assert success is True
2849+
assert len(profile_servers) == 1
2850+
assert profile_servers[0]['name'] == 'multi-scope-server'
2851+
2852+
# Verify removal was attempted from all 3 scopes (3 calls)
2853+
# PLUS the add command for user scope (1 call via run_command)
2854+
# Total should be at least 3 removal calls + potential add call
2855+
removal_calls = [
2856+
call for call in mock_run.call_args_list
2857+
if 'remove' in call[0][0]
2858+
]
2859+
assert len(removal_calls) == 3, f'Expected 3 removal calls, got {len(removal_calls)}'
2860+
2861+
# Verify profile config created
2862+
assert profile_config.exists()
2863+
config = json.loads(profile_config.read_text())
2864+
assert 'multi-scope-server' in config['mcpServers']
2865+
2866+
# Verify stats
2867+
assert stats['profile_count'] == 1
2868+
assert stats['global_count'] == 1
2869+
assert stats['combined_count'] == 1
2870+
2871+
@patch('platform.system', return_value='Linux')
2872+
@patch('setup_environment.find_command_robust', return_value='claude')
2873+
@patch('setup_environment.run_command')
2874+
def test_configure_all_mcp_servers_user_scope_unchanged(
2875+
self,
2876+
mock_run: MagicMock,
2877+
mock_find: MagicMock,
2878+
_mock_system: MagicMock,
2879+
) -> None:
2880+
"""Test that user-scoped servers work exactly as before (regression test)."""
2881+
del mock_find, _mock_system
2882+
mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
2883+
2884+
servers = [
2885+
{'name': 'user-server', 'command': 'uvx user-server', 'scope': 'user'},
2886+
]
2887+
2888+
with tempfile.TemporaryDirectory() as tmpdir:
2889+
profile_config = Path(tmpdir) / 'test-mcp.json'
2890+
2891+
success, profile_servers, stats = setup_environment.configure_all_mcp_servers(
2892+
servers,
2893+
profile_mcp_config_path=profile_config,
2894+
)
2895+
2896+
assert success is True
2897+
# User-scope servers should NOT be in profile_servers
2898+
assert len(profile_servers) == 0
2899+
2900+
# Should call removal (3 times) + add (1 time) = 4 calls
2901+
assert mock_run.call_count == 4
2902+
2903+
# Verify stats
2904+
assert stats['profile_count'] == 0
2905+
assert stats['global_count'] == 1
2906+
assert stats['combined_count'] == 0
2907+
2908+
# Profile config should NOT be created
2909+
assert not profile_config.exists()
2910+
2911+
@patch('platform.system', return_value='Linux')
2912+
@patch('setup_environment.find_command_robust', return_value='claude')
2913+
@patch('setup_environment.run_command')
2914+
def test_configure_all_mcp_servers_multiple_profile_only_servers(
2915+
self,
2916+
mock_run: MagicMock,
2917+
mock_find: MagicMock,
2918+
_mock_system: MagicMock,
2919+
) -> None:
2920+
"""Test that multiple profile-only servers each trigger removal from all scopes."""
2921+
del mock_find, _mock_system
2922+
mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
2923+
2924+
servers = [
2925+
{'name': 'profile-server-1', 'command': 'uvx profile1', 'scope': 'profile'},
2926+
{'name': 'profile-server-2', 'command': 'uvx profile2', 'scope': 'profile'},
2927+
{'name': 'profile-server-3', 'transport': 'http', 'url': 'http://localhost', 'scope': 'profile'},
2928+
]
2929+
2930+
with tempfile.TemporaryDirectory() as tmpdir:
2931+
profile_config = Path(tmpdir) / 'test-mcp.json'
2932+
2933+
success, profile_servers, stats = setup_environment.configure_all_mcp_servers(
2934+
servers,
2935+
profile_mcp_config_path=profile_config,
2936+
)
2937+
2938+
assert success is True
2939+
assert len(profile_servers) == 3
2940+
2941+
# 3 servers * 3 removal scopes = 9 removal calls
2942+
assert mock_run.call_count == 9
2943+
2944+
# Verify all servers attempted removal from all scopes
2945+
server_scopes_removed: dict[str, set[str]] = {
2946+
'profile-server-1': set(),
2947+
'profile-server-2': set(),
2948+
'profile-server-3': set(),
2949+
}
2950+
for call in mock_run.call_args_list:
2951+
args = call[0][0]
2952+
for server_name in server_scopes_removed:
2953+
if server_name in args:
2954+
scope_idx = args.index('--scope') + 1
2955+
server_scopes_removed[server_name].add(args[scope_idx])
2956+
2957+
for server_name, scopes in server_scopes_removed.items():
2958+
assert scopes == {'user', 'local', 'project'}, (
2959+
f'{server_name} missing removal scopes: {scopes}'
2960+
)
2961+
2962+
# Verify stats
2963+
assert stats['profile_count'] == 3
2964+
assert stats['global_count'] == 0
2965+
assert stats['combined_count'] == 0
2966+
2967+
@patch('platform.system', return_value='Linux')
2968+
@patch('setup_environment.find_command_robust', return_value='claude')
2969+
@patch('setup_environment.run_command')
2970+
def test_configure_all_mcp_servers_comprehensive_scope_mix(
2971+
self,
2972+
mock_run: MagicMock,
2973+
mock_find: MagicMock,
2974+
_mock_system: MagicMock,
2975+
) -> None:
2976+
"""Test comprehensive mix: user-only, profile-only, and multi-scope servers."""
2977+
del mock_find, _mock_system
2978+
mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
2979+
2980+
servers = [
2981+
{'name': 'user-only', 'command': 'uvx user-only', 'scope': 'user'},
2982+
{'name': 'profile-only', 'command': 'uvx profile-only', 'scope': 'profile'},
2983+
{'name': 'user-profile', 'command': 'uvx user-profile', 'scope': ['user', 'profile']},
2984+
{'name': 'local-profile', 'command': 'uvx local-profile', 'scope': ['local', 'profile']},
2985+
]
2986+
2987+
with tempfile.TemporaryDirectory() as tmpdir:
2988+
profile_config = Path(tmpdir) / 'test-mcp.json'
2989+
2990+
success, profile_servers, stats = setup_environment.configure_all_mcp_servers(
2991+
servers,
2992+
profile_mcp_config_path=profile_config,
2993+
)
2994+
2995+
assert success is True
2996+
2997+
# 3 servers have profile scope: profile-only, user-profile, local-profile
2998+
assert len(profile_servers) == 3
2999+
profile_names = {s['name'] for s in profile_servers}
3000+
assert profile_names == {'profile-only', 'user-profile', 'local-profile'}
3001+
3002+
# Verify stats
3003+
assert stats['profile_count'] == 3
3004+
assert stats['global_count'] == 3 # user-only, user-profile (user), local-profile (local)
3005+
assert stats['combined_count'] == 2 # user-profile, local-profile
3006+
3007+
# Verify profile config contains correct servers
3008+
config = json.loads(profile_config.read_text())
3009+
assert 'profile-only' in config['mcpServers']
3010+
assert 'user-profile' in config['mcpServers']
3011+
assert 'local-profile' in config['mcpServers']
3012+
assert 'user-only' not in config['mcpServers']
3013+
27603014
@patch('platform.system', return_value='Linux')
27613015
def test_launcher_script_includes_mcp_flags_when_profile_exists(self, _mock_system: MagicMock) -> None:
27623016
"""Test launcher script includes --strict-mcp-config when profile servers exist."""

0 commit comments

Comments
 (0)