@@ -867,6 +867,58 @@ def expand_match(match: re.Match[str]) -> str:
867867 return re .sub (tilde_pattern , expand_match , command )
868868
869869
870+ def parse_mcp_command (command_str : str ) -> dict [str , Any ]:
871+ """Parse MCP command string into official MCP JSON schema format.
872+
873+ Converts a shell command string into the structured format expected by
874+ Claude Code's MCP configuration. Handles:
875+ - Tilde path expansion to absolute paths
876+ - Shell-aware splitting with shlex
877+ - Windows npx/npm wrapper with cmd /c
878+ - POSIX path format for arguments (cross-platform compatibility)
879+
880+ Args:
881+ command_str: Full command string from YAML config
882+
883+ Returns:
884+ Dict with 'command' (executable) and 'args' (argument array) keys
885+ """
886+ # Step 1: Expand tilde paths using existing function (DRY principle)
887+ expanded = expand_tildes_in_command (command_str )
888+
889+ # Step 2: Convert backslashes to forward slashes BEFORE shlex.split
890+ # This prevents shlex from interpreting backslashes as escape characters
891+ # and ensures consistent POSIX path format in the output
892+ expanded = expanded .replace ('\\ ' , '/' )
893+
894+ # Step 3: Parse command into parts using shlex
895+ try :
896+ parts = shlex .split (expanded )
897+ except ValueError :
898+ # Fallback for malformed commands
899+ parts = expanded .split ()
900+
901+ if not parts :
902+ return {'command' : expanded , 'args' : []}
903+
904+ executable = parts [0 ]
905+ args = parts [1 :] if len (parts ) > 1 else []
906+
907+ # Step 4: Windows-specific handling for npx/npm commands
908+ if platform .system () == 'Windows' and any (
909+ npm_cmd in executable .lower () for npm_cmd in ['npx' , 'npm' ]
910+ ):
911+ return {
912+ 'command' : 'cmd' ,
913+ 'args' : ['/c' , executable ] + args ,
914+ }
915+
916+ return {
917+ 'command' : executable ,
918+ 'args' : args ,
919+ }
920+
921+
870922def add_directory_to_windows_path (directory : str ) -> tuple [bool , str ]:
871923 """Add a directory to the Windows user PATH environment variable.
872924
@@ -3860,6 +3912,120 @@ def verify_nodejs_available() -> bool:
38603912 return False
38613913
38623914
3915+ def validate_scope_combination (scopes : list [str ]) -> tuple [bool , str | None ]:
3916+ """Validate scope combination for MCP server configuration.
3917+
3918+ Validates that the provided scope combination is valid according to these rules:
3919+ - Single scope values are always valid (user, local, project, profile)
3920+ - Combined scopes MUST include 'profile' for meaningful combination
3921+ - Pure non-profile combinations are INVALID (they overlap at runtime)
3922+ - Profile + multiple non-profile scopes trigger a WARNING (valid but unusual)
3923+
3924+ Args:
3925+ scopes: List of normalized scope values (lowercase)
3926+
3927+ Returns:
3928+ Tuple of (is_valid, message_or_none)
3929+ - If is_valid is False, message contains the ERROR description
3930+ - If is_valid is True and message is not None, it is a WARNING
3931+ - If is_valid is True and message is None, combination is fully valid
3932+ """
3933+ valid_scopes = {'user' , 'local' , 'project' , 'profile' }
3934+ non_profile_scopes = {'user' , 'local' , 'project' }
3935+
3936+ # Check for invalid scope values
3937+ invalid = set (scopes ) - valid_scopes
3938+ if invalid :
3939+ return False , f'Invalid scope values: { invalid } . Valid scopes: { valid_scopes } '
3940+
3941+ # Check for duplicate values
3942+ if len (scopes ) != len (set (scopes )):
3943+ return False , 'Duplicate scope values not allowed'
3944+
3945+ # Single scope is always valid
3946+ if len (scopes ) == 1 :
3947+ return True , None
3948+
3949+ has_profile = 'profile' in scopes
3950+ non_profile = [s for s in scopes if s in non_profile_scopes ]
3951+
3952+ # Multiple non-profile scopes WITHOUT profile -> ERROR
3953+ # These scopes overlap at runtime (all config files are read and merged)
3954+ if not has_profile and len (non_profile ) > 1 :
3955+ return False , (
3956+ f"Cannot combine { non_profile } - these scopes overlap at runtime "
3957+ "(all config files are read and merged). Use ONE of user/local/project, "
3958+ "or combine with 'profile' for isolated profile sessions."
3959+ )
3960+
3961+ # Profile + multiple non-profile -> WARNING (valid but unusual)
3962+ if has_profile and len (non_profile ) > 1 :
3963+ return True , (
3964+ f'In profile mode, only profile config is used. In normal mode, '
3965+ f'servers from { non_profile } will all be loaded. Ensure server names '
3966+ 'do not conflict across these locations.'
3967+ )
3968+
3969+ # Profile + one other scope (or just profile) -> VALID
3970+ return True , None
3971+
3972+
3973+ def normalize_scope (scope_value : str | list [str ] | None ) -> list [str ]:
3974+ """Normalize scope to list format with case normalization.
3975+
3976+ Supports multiple input formats for flexibility:
3977+ - None -> ['user'] (default behavior, backward compatible)
3978+ - 'user' -> ['user'] (single string)
3979+ - 'User' -> ['user'] (case normalization)
3980+ - 'user, profile' -> ['user', 'profile'] (comma-separated string)
3981+ - ['user', 'profile'] -> ['user', 'profile'] (list passthrough)
3982+ - ['User', 'PROFILE'] -> ['user', 'profile'] (list with case normalization)
3983+
3984+ Args:
3985+ scope_value: Raw scope value from YAML config (string, list, or None)
3986+
3987+ Returns:
3988+ List of normalized scope strings (lowercase, deduplicated)
3989+
3990+ Raises:
3991+ ValueError: If scope combination is invalid per validate_scope_combination()
3992+ """
3993+ if scope_value is None :
3994+ return ['user' ]
3995+
3996+ if isinstance (scope_value , str ):
3997+ scopes = (
3998+ [s .strip ().lower () for s in scope_value .split (',' )]
3999+ if ',' in scope_value
4000+ else [scope_value .strip ().lower ()]
4001+ )
4002+ else :
4003+ # scope_value is list[str] at this point per type hint
4004+ scopes = [str (s ).strip ().lower () for s in scope_value ]
4005+
4006+ # Remove empty strings and duplicates while preserving order
4007+ seen : set [str ] = set ()
4008+ result : list [str ] = []
4009+ for s in scopes :
4010+ if s and s not in seen :
4011+ seen .add (s )
4012+ result .append (s )
4013+
4014+ if not result :
4015+ return ['user' ]
4016+
4017+ # Validate combination
4018+ is_valid , message = validate_scope_combination (result )
4019+ if not is_valid :
4020+ raise ValueError (f'Invalid scope configuration: { message } ' )
4021+
4022+ # Log warning if applicable
4023+ if message :
4024+ warning (f'Combined scope warning: { message } ' )
4025+
4026+ return result
4027+
4028+
38634029def configure_mcp_server (server : dict [str , Any ]) -> bool :
38644030 """Configure a single MCP server."""
38654031 name = server .get ('name' )
@@ -4094,33 +4260,49 @@ def configure_all_mcp_servers(
40944260) -> tuple [bool , list [dict [str , Any ]]]:
40954261 """Configure all MCP servers from configuration.
40964262
4263+ Handles combined scope configurations where servers can be added to multiple
4264+ locations simultaneously. For example, `scope: [user, profile]` adds the server
4265+ to both ~/.claude.json (for global access) and the profile MCP config file
4266+ (for isolated profile sessions).
4267+
40974268 Args:
4098- servers: List of MCP server configurations
4269+ servers: List of MCP server configurations from YAML
40994270 profile_mcp_config_path: Path for profile-scoped servers JSON file
41004271
41014272 Returns:
4102- Tuple of (success: bool, profile_servers: list)
4273+ Tuple of (success: bool, profile_servers: list of servers with profile scope )
41034274 """
41044275 if not servers :
41054276 info ('No MCP servers to configure' )
41064277 return True , []
41074278
41084279 info ('Configuring MCP servers...' )
41094280
4110- # Separate profile-scoped servers from regular servers
4281+ # Collect servers for profile config
41114282 profile_servers : list [dict [str , Any ]] = []
4112- regular_servers : list [dict [str , Any ]] = []
41134283
41144284 for server in servers :
4115- scope = server .get ('scope' , 'user' )
4116- if scope == 'profile' :
4285+ server_name = server .get ('name' , 'unnamed' )
4286+ scope_value = server .get ('scope' , 'user' )
4287+
4288+ try :
4289+ scopes = normalize_scope (scope_value )
4290+ except ValueError as e :
4291+ error (f'Server { server_name } : { e } ' )
4292+ continue # Skip invalid server configuration
4293+
4294+ has_profile = 'profile' in scopes
4295+ non_profile_scopes = [s for s in scopes if s != 'profile' ]
4296+
4297+ # Add to profile config if profile scope present
4298+ if has_profile :
41174299 profile_servers .append (server )
4118- else :
4119- regular_servers .append (server )
41204300
4121- # Configure regular servers via claude mcp add
4122- for server in regular_servers :
4123- configure_mcp_server (server )
4301+ # Configure for each non-profile scope via claude mcp add
4302+ for scope in non_profile_scopes :
4303+ server_copy = server .copy ()
4304+ server_copy ['scope' ] = scope
4305+ configure_mcp_server (server_copy )
41244306
41254307 # Create profile MCP config file if there are profile-scoped servers
41264308 if profile_servers and profile_mcp_config_path :
@@ -4180,13 +4362,17 @@ def create_mcp_config_file(
41804362 key , _ , value = header .partition (':' )
41814363 server_config ['headers' ] = {key .strip (): value .strip ()}
41824364
4183- # Stdio transport ( command)
4365+ # Stdio transport - with proper command + args format
41844366 command = server .get ('command' )
41854367 if command :
41864368 server_config ['type' ] = 'stdio'
4187- server_config ['command' ] = command
4369+ parsed = parse_mcp_command (command )
4370+ server_config ['command' ] = parsed ['command' ]
4371+ if parsed ['args' ]:
4372+ server_config ['args' ] = parsed ['args' ]
4373+ server_config ['env' ] = {} # Format consistency with claude mcp add
41884374
4189- # Environment variables
4375+ # Environment variables (override default empty env)
41904376 env_config = server .get ('env' )
41914377 if env_config :
41924378 env_dict : dict [str , str ] = {}
0 commit comments