Skip to content

Commit b51cd4c

Browse files
committed
fix: parse MCP profile config commands correctly
Fixes two bugs in MCP profile server configuration: - Tilde paths (~/.claude/...) not expanded to absolute paths - Commands stored as raw strings instead of command+args structure Added parse_mcp_command() function that: - Expands tilde paths using existing expand_tildes_in_command() - Converts backslashes to forward slashes before shlex parsing - Splits commands into executable and args using shlex - Wraps npx/npm with cmd /c on Windows for proper execution Updated create_mcp_config_file() to use structured format with separate command, args, and env fields per MCP JSON schema.
1 parent 9c01263 commit b51cd4c

File tree

2 files changed

+238
-5
lines changed

2 files changed

+238
-5
lines changed

scripts/setup_environment.py

Lines changed: 59 additions & 3 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
@@ -4310,13 +4362,17 @@ def create_mcp_config_file(
43104362
key, _, value = header.partition(':')
43114363
server_config['headers'] = {key.strip(): value.strip()}
43124364

4313-
# Stdio transport (command)
4365+
# Stdio transport - with proper command + args format
43144366
command = server.get('command')
43154367
if command:
43164368
server_config['type'] = 'stdio'
4317-
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
43184374

4319-
# Environment variables
4375+
# Environment variables (override default empty env)
43204376
env_config = server.get('env')
43214377
if env_config:
43224378
env_dict: dict[str, str] = {}

tests/test_setup_environment_additional.py

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88
import os
9+
import platform
910
import subprocess
1011
import sys
1112
import tempfile
@@ -2523,8 +2524,17 @@ def test_create_mcp_config_file_single_server(self) -> None:
25232524
config = json.loads(config_path.read_text())
25242525
assert 'mcpServers' in config
25252526
assert 'test-server' in config['mcpServers']
2526-
assert config['mcpServers']['test-server']['type'] == 'stdio'
2527-
assert config['mcpServers']['test-server']['command'] == 'npx test-server'
2527+
server_config = config['mcpServers']['test-server']
2528+
assert server_config['type'] == 'stdio'
2529+
# Verify command + args format (Windows wraps npx with cmd /c)
2530+
if platform.system() == 'Windows':
2531+
assert server_config['command'] == 'cmd'
2532+
assert server_config['args'] == ['/c', 'npx', 'test-server']
2533+
else:
2534+
assert server_config['command'] == 'npx'
2535+
assert server_config['args'] == ['test-server']
2536+
# Verify env object present for format consistency
2537+
assert server_config['env'] == {}
25282538

25292539
def test_create_mcp_config_file_multiple_servers(self) -> None:
25302540
"""Test creating MCP config file with multiple servers."""
@@ -2551,6 +2561,10 @@ def test_create_mcp_config_file_multiple_servers(self) -> None:
25512561
assert 'server1' in config['mcpServers']
25522562
assert 'server2' in config['mcpServers']
25532563
assert config['mcpServers']['server1']['type'] == 'stdio'
2564+
# Verify command + args format for stdio server
2565+
assert config['mcpServers']['server1']['command'] == 'uvx'
2566+
assert config['mcpServers']['server1']['args'] == ['server1']
2567+
assert config['mcpServers']['server1']['env'] == {}
25542568
assert config['mcpServers']['server2']['type'] == 'http'
25552569
assert config['mcpServers']['server2']['url'] == 'http://localhost:8080'
25562570

@@ -3502,3 +3516,166 @@ def test_stale_profile_config_removed_when_combined_scope_removed(self) -> None:
35023516
assert success_flag is True
35033517
assert len(profile_servers) == 0
35043518
assert not config_path.exists()
3519+
3520+
3521+
class TestParseMcpCommand:
3522+
"""Test parse_mcp_command() function for MCP config formatting."""
3523+
3524+
# === Basic Parsing Tests ===
3525+
3526+
def test_simple_command_no_args(self) -> None:
3527+
"""Test simple executable without arguments."""
3528+
result = setup_environment.parse_mcp_command('bash')
3529+
assert result['command'] == 'bash'
3530+
assert result['args'] == []
3531+
3532+
def test_command_with_single_arg(self) -> None:
3533+
"""Test command with single argument."""
3534+
result = setup_environment.parse_mcp_command('uvx server')
3535+
assert result['command'] == 'uvx'
3536+
assert result['args'] == ['server']
3537+
3538+
def test_command_with_multiple_args(self) -> None:
3539+
"""Test command with multiple arguments."""
3540+
result = setup_environment.parse_mcp_command('npx -y @package/name')
3541+
# Result depends on platform (Windows wraps with cmd /c)
3542+
if platform.system() == 'Windows':
3543+
assert result['command'] == 'cmd'
3544+
assert result['args'] == ['/c', 'npx', '-y', '@package/name']
3545+
else:
3546+
assert result['command'] == 'npx'
3547+
assert result['args'] == ['-y', '@package/name']
3548+
3549+
# === Tilde Expansion Tests ===
3550+
3551+
def test_tilde_expansion_in_path_arg(self) -> None:
3552+
"""Test tilde path expansion in argument."""
3553+
home = str(Path.home())
3554+
result = setup_environment.parse_mcp_command('bash ~/.claude/script.sh')
3555+
assert result['command'] == 'bash'
3556+
# Path should be expanded and POSIX-formatted
3557+
assert len(result['args']) == 1
3558+
assert '~' not in result['args'][0]
3559+
assert home.replace('\\', '/') in result['args'][0] or home in result['args'][0]
3560+
3561+
def test_tilde_expansion_in_executable(self) -> None:
3562+
"""Test tilde path expansion when executable has tilde."""
3563+
home = str(Path.home())
3564+
result = setup_environment.parse_mcp_command('~/scripts/server.sh --port 8080')
3565+
assert '~' not in result['command']
3566+
assert home.replace('\\', '/') in result['command'] or home in result['command']
3567+
3568+
def test_multiple_tilde_paths(self) -> None:
3569+
"""Test multiple tilde paths in single command."""
3570+
result = setup_environment.parse_mcp_command('bash ~/.claude/script.sh ~/.config/settings.json')
3571+
assert result['command'] == 'bash'
3572+
assert len(result['args']) == 2
3573+
for arg in result['args']:
3574+
assert '~' not in arg
3575+
3576+
# === Windows npx/npm Handling Tests ===
3577+
3578+
@pytest.mark.skipif(platform.system() != 'Windows', reason='Windows-specific test')
3579+
def test_windows_npx_wrapped_with_cmd(self) -> None:
3580+
"""Test npx command is wrapped with cmd /c on Windows."""
3581+
result = setup_environment.parse_mcp_command('npx -y @mobilenext/mobile-mcp')
3582+
assert result['command'] == 'cmd'
3583+
assert result['args'][0] == '/c'
3584+
assert result['args'][1] == 'npx'
3585+
assert '-y' in result['args']
3586+
assert '@mobilenext/mobile-mcp' in result['args']
3587+
3588+
@pytest.mark.skipif(platform.system() != 'Windows', reason='Windows-specific test')
3589+
def test_windows_npm_wrapped_with_cmd(self) -> None:
3590+
"""Test npm command is wrapped with cmd /c on Windows."""
3591+
result = setup_environment.parse_mcp_command('npm exec server')
3592+
assert result['command'] == 'cmd'
3593+
assert result['args'][0] == '/c'
3594+
assert result['args'][1] == 'npm'
3595+
3596+
@pytest.mark.skipif(platform.system() != 'Windows', reason='Windows-specific test')
3597+
def test_windows_non_npm_command_not_wrapped(self) -> None:
3598+
"""Test non-npm commands are NOT wrapped on Windows."""
3599+
result = setup_environment.parse_mcp_command('uvx my-server')
3600+
assert result['command'] == 'uvx'
3601+
assert result['args'] == ['my-server']
3602+
3603+
@pytest.mark.skipif(platform.system() == 'Windows', reason='Non-Windows test')
3604+
def test_unix_npx_not_wrapped(self) -> None:
3605+
"""Test npx is NOT wrapped on Unix systems."""
3606+
result = setup_environment.parse_mcp_command('npx -y @mobilenext/mobile-mcp')
3607+
assert result['command'] == 'npx'
3608+
assert '-y' in result['args']
3609+
3610+
# === POSIX Path Conversion Tests ===
3611+
3612+
@pytest.mark.skipif(platform.system() != 'Windows', reason='Windows-specific test')
3613+
def test_windows_path_converted_to_posix(self) -> None:
3614+
"""Test Windows backslash paths converted to POSIX forward slashes."""
3615+
# Use a path with backslashes that expand_tildes_in_command would return
3616+
with patch('setup_environment.expand_tildes_in_command', return_value=r'bash C:\Users\test\script.sh'):
3617+
result = setup_environment.parse_mcp_command(r'bash C:\Users\test\script.sh')
3618+
assert result['command'] == 'bash'
3619+
assert '\\' not in result['args'][0]
3620+
assert '/' in result['args'][0]
3621+
3622+
def test_posix_path_unchanged(self) -> None:
3623+
"""Test POSIX paths remain unchanged."""
3624+
result = setup_environment.parse_mcp_command('bash /usr/local/bin/script.sh')
3625+
assert result['command'] == 'bash'
3626+
assert result['args'] == ['/usr/local/bin/script.sh']
3627+
3628+
# === Quoted Arguments Tests ===
3629+
3630+
def test_quoted_arg_with_spaces(self) -> None:
3631+
"""Test quoted argument with spaces is preserved."""
3632+
result = setup_environment.parse_mcp_command('bash -c "echo hello world"')
3633+
assert result['command'] == 'bash'
3634+
assert '-c' in result['args']
3635+
assert 'echo hello world' in result['args']
3636+
3637+
def test_single_quoted_arg(self) -> None:
3638+
"""Test single-quoted argument is preserved."""
3639+
result = setup_environment.parse_mcp_command("bash -c 'echo test'")
3640+
assert result['command'] == 'bash'
3641+
assert '-c' in result['args']
3642+
assert 'echo test' in result['args']
3643+
3644+
# === Edge Cases ===
3645+
3646+
def test_empty_command(self) -> None:
3647+
"""Test empty command string."""
3648+
result = setup_environment.parse_mcp_command('')
3649+
assert result['command'] == ''
3650+
assert result['args'] == []
3651+
3652+
def test_whitespace_only_command(self) -> None:
3653+
"""Test whitespace-only command string."""
3654+
result = setup_environment.parse_mcp_command(' ')
3655+
# shlex.split of whitespace returns []
3656+
assert result['command'] == ' ' # Falls back to original
3657+
assert result['args'] == []
3658+
3659+
def test_malformed_quotes_fallback(self) -> None:
3660+
"""Test malformed quotes fall back to split()."""
3661+
result = setup_environment.parse_mcp_command('bash "unclosed quote')
3662+
# Should not crash, falls back to simple split
3663+
assert result['command'] == 'bash'
3664+
assert '"unclosed' in result['args'] or 'quote' in result['args']
3665+
3666+
def test_special_characters_in_args(self) -> None:
3667+
"""Test special characters in arguments."""
3668+
result = setup_environment.parse_mcp_command('npx @scope/package-name:1.0.0')
3669+
if platform.system() == 'Windows':
3670+
assert '@scope/package-name:1.0.0' in result['args']
3671+
else:
3672+
assert result['args'] == ['@scope/package-name:1.0.0']
3673+
3674+
def test_non_path_arg_unchanged(self) -> None:
3675+
"""Test non-path arguments are not modified."""
3676+
result = setup_environment.parse_mcp_command('server --port 8080 --host localhost')
3677+
assert result['command'] == 'server'
3678+
assert '--port' in result['args']
3679+
assert '8080' in result['args']
3680+
assert '--host' in result['args']
3681+
assert 'localhost' in result['args']

0 commit comments

Comments
 (0)