Skip to content

Commit 9c01263

Browse files
committed
feat: add combined scope support for MCP servers
Enable MCP servers to be configured with combined scopes (e.g., scope: [user, profile]) allowing servers to be available both in profile isolation and globally. - Add validate_scope_combination() to validate scope combinations - Add normalize_scope() to normalize scope values (string/list/comma-separated) - Modify configure_all_mcp_servers() to handle multiple scopes per server - Add 47 comprehensive tests in TestCombinedScopeSupport class - Support backward compatibility with existing single-scope configurations
1 parent 0a339e1 commit 9c01263

File tree

2 files changed

+722
-11
lines changed

2 files changed

+722
-11
lines changed

scripts/setup_environment.py

Lines changed: 141 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3860,6 +3860,120 @@ def verify_nodejs_available() -> bool:
38603860
return False
38613861

38623862

3863+
def validate_scope_combination(scopes: list[str]) -> tuple[bool, str | None]:
3864+
"""Validate scope combination for MCP server configuration.
3865+
3866+
Validates that the provided scope combination is valid according to these rules:
3867+
- Single scope values are always valid (user, local, project, profile)
3868+
- Combined scopes MUST include 'profile' for meaningful combination
3869+
- Pure non-profile combinations are INVALID (they overlap at runtime)
3870+
- Profile + multiple non-profile scopes trigger a WARNING (valid but unusual)
3871+
3872+
Args:
3873+
scopes: List of normalized scope values (lowercase)
3874+
3875+
Returns:
3876+
Tuple of (is_valid, message_or_none)
3877+
- If is_valid is False, message contains the ERROR description
3878+
- If is_valid is True and message is not None, it is a WARNING
3879+
- If is_valid is True and message is None, combination is fully valid
3880+
"""
3881+
valid_scopes = {'user', 'local', 'project', 'profile'}
3882+
non_profile_scopes = {'user', 'local', 'project'}
3883+
3884+
# Check for invalid scope values
3885+
invalid = set(scopes) - valid_scopes
3886+
if invalid:
3887+
return False, f'Invalid scope values: {invalid}. Valid scopes: {valid_scopes}'
3888+
3889+
# Check for duplicate values
3890+
if len(scopes) != len(set(scopes)):
3891+
return False, 'Duplicate scope values not allowed'
3892+
3893+
# Single scope is always valid
3894+
if len(scopes) == 1:
3895+
return True, None
3896+
3897+
has_profile = 'profile' in scopes
3898+
non_profile = [s for s in scopes if s in non_profile_scopes]
3899+
3900+
# Multiple non-profile scopes WITHOUT profile -> ERROR
3901+
# These scopes overlap at runtime (all config files are read and merged)
3902+
if not has_profile and len(non_profile) > 1:
3903+
return False, (
3904+
f"Cannot combine {non_profile} - these scopes overlap at runtime "
3905+
"(all config files are read and merged). Use ONE of user/local/project, "
3906+
"or combine with 'profile' for isolated profile sessions."
3907+
)
3908+
3909+
# Profile + multiple non-profile -> WARNING (valid but unusual)
3910+
if has_profile and len(non_profile) > 1:
3911+
return True, (
3912+
f'In profile mode, only profile config is used. In normal mode, '
3913+
f'servers from {non_profile} will all be loaded. Ensure server names '
3914+
'do not conflict across these locations.'
3915+
)
3916+
3917+
# Profile + one other scope (or just profile) -> VALID
3918+
return True, None
3919+
3920+
3921+
def normalize_scope(scope_value: str | list[str] | None) -> list[str]:
3922+
"""Normalize scope to list format with case normalization.
3923+
3924+
Supports multiple input formats for flexibility:
3925+
- None -> ['user'] (default behavior, backward compatible)
3926+
- 'user' -> ['user'] (single string)
3927+
- 'User' -> ['user'] (case normalization)
3928+
- 'user, profile' -> ['user', 'profile'] (comma-separated string)
3929+
- ['user', 'profile'] -> ['user', 'profile'] (list passthrough)
3930+
- ['User', 'PROFILE'] -> ['user', 'profile'] (list with case normalization)
3931+
3932+
Args:
3933+
scope_value: Raw scope value from YAML config (string, list, or None)
3934+
3935+
Returns:
3936+
List of normalized scope strings (lowercase, deduplicated)
3937+
3938+
Raises:
3939+
ValueError: If scope combination is invalid per validate_scope_combination()
3940+
"""
3941+
if scope_value is None:
3942+
return ['user']
3943+
3944+
if isinstance(scope_value, str):
3945+
scopes = (
3946+
[s.strip().lower() for s in scope_value.split(',')]
3947+
if ',' in scope_value
3948+
else [scope_value.strip().lower()]
3949+
)
3950+
else:
3951+
# scope_value is list[str] at this point per type hint
3952+
scopes = [str(s).strip().lower() for s in scope_value]
3953+
3954+
# Remove empty strings and duplicates while preserving order
3955+
seen: set[str] = set()
3956+
result: list[str] = []
3957+
for s in scopes:
3958+
if s and s not in seen:
3959+
seen.add(s)
3960+
result.append(s)
3961+
3962+
if not result:
3963+
return ['user']
3964+
3965+
# Validate combination
3966+
is_valid, message = validate_scope_combination(result)
3967+
if not is_valid:
3968+
raise ValueError(f'Invalid scope configuration: {message}')
3969+
3970+
# Log warning if applicable
3971+
if message:
3972+
warning(f'Combined scope warning: {message}')
3973+
3974+
return result
3975+
3976+
38633977
def configure_mcp_server(server: dict[str, Any]) -> bool:
38643978
"""Configure a single MCP server."""
38653979
name = server.get('name')
@@ -4094,33 +4208,49 @@ def configure_all_mcp_servers(
40944208
) -> tuple[bool, list[dict[str, Any]]]:
40954209
"""Configure all MCP servers from configuration.
40964210
4211+
Handles combined scope configurations where servers can be added to multiple
4212+
locations simultaneously. For example, `scope: [user, profile]` adds the server
4213+
to both ~/.claude.json (for global access) and the profile MCP config file
4214+
(for isolated profile sessions).
4215+
40974216
Args:
4098-
servers: List of MCP server configurations
4217+
servers: List of MCP server configurations from YAML
40994218
profile_mcp_config_path: Path for profile-scoped servers JSON file
41004219
41014220
Returns:
4102-
Tuple of (success: bool, profile_servers: list)
4221+
Tuple of (success: bool, profile_servers: list of servers with profile scope)
41034222
"""
41044223
if not servers:
41054224
info('No MCP servers to configure')
41064225
return True, []
41074226

41084227
info('Configuring MCP servers...')
41094228

4110-
# Separate profile-scoped servers from regular servers
4229+
# Collect servers for profile config
41114230
profile_servers: list[dict[str, Any]] = []
4112-
regular_servers: list[dict[str, Any]] = []
41134231

41144232
for server in servers:
4115-
scope = server.get('scope', 'user')
4116-
if scope == 'profile':
4233+
server_name = server.get('name', 'unnamed')
4234+
scope_value = server.get('scope', 'user')
4235+
4236+
try:
4237+
scopes = normalize_scope(scope_value)
4238+
except ValueError as e:
4239+
error(f'Server {server_name}: {e}')
4240+
continue # Skip invalid server configuration
4241+
4242+
has_profile = 'profile' in scopes
4243+
non_profile_scopes = [s for s in scopes if s != 'profile']
4244+
4245+
# Add to profile config if profile scope present
4246+
if has_profile:
41174247
profile_servers.append(server)
4118-
else:
4119-
regular_servers.append(server)
41204248

4121-
# Configure regular servers via claude mcp add
4122-
for server in regular_servers:
4123-
configure_mcp_server(server)
4249+
# Configure for each non-profile scope via claude mcp add
4250+
for scope in non_profile_scopes:
4251+
server_copy = server.copy()
4252+
server_copy['scope'] = scope
4253+
configure_mcp_server(server_copy)
41244254

41254255
# Create profile MCP config file if there are profile-scoped servers
41264256
if profile_servers and profile_mcp_config_path:

0 commit comments

Comments
 (0)