Skip to content

Commit 8de3c6e

Browse files
authored
Merge pull request #279 from alex-feel/alex-feel-dev
Add combined scope support for MCP servers and fix parsing MCP profile config commands
2 parents 0a339e1 + b51cd4c commit 8de3c6e

File tree

2 files changed

+960
-16
lines changed

2 files changed

+960
-16
lines changed

scripts/setup_environment.py

Lines changed: 200 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
870922
def 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+
38634029
def 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

Comments
 (0)