@@ -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+
38633977def 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