Skip to content

Commit 2b97959

Browse files
committed
Avoid shell invocation for Windows npx
1 parent 616476f commit 2b97959

2 files changed

Lines changed: 60 additions & 13 deletions

File tree

src/mcp/cli/cli.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import importlib.metadata
44
import importlib.util
55
import os
6+
import shutil
67
import subprocess
78
import sys
89
from pathlib import Path
@@ -39,15 +40,18 @@
3940
)
4041

4142

42-
def _get_npx_command():
43+
def _get_npx_command() -> str | None:
4344
"""Get the correct npx command for the current platform."""
4445
if sys.platform == "win32":
4546
# Try both npx.cmd and npx.exe on Windows
4647
for cmd in ["npx.cmd", "npx.exe", "npx"]:
48+
npx_path = shutil.which(cmd)
49+
if not npx_path:
50+
continue
4751
try:
48-
subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True)
49-
return cmd
50-
except subprocess.CalledProcessError:
52+
subprocess.run([npx_path, "--version"], check=True, capture_output=True)
53+
return npx_path
54+
except (OSError, subprocess.CalledProcessError):
5155
continue
5256
return None
5357
return "npx" # On Unix-like systems, just use npx
@@ -271,12 +275,9 @@ def dev(
271275
)
272276
sys.exit(1)
273277

274-
# Run the MCP Inspector command with shell=True on Windows
275-
shell = sys.platform == "win32"
276278
process = subprocess.run(
277279
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
278280
check=True,
279-
shell=shell,
280281
env=dict(os.environ.items()), # Convert to list of tuples for env update
281282
)
282283
sys.exit(process.returncode)

tests/cli/test_utils.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77

8+
from mcp.cli import cli as cli_module
89
from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage]
910

1011

@@ -78,24 +79,69 @@ def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):
7879
def test_get_npx_windows(monkeypatch: pytest.MonkeyPatch):
7980
"""Should return one of the npx candidates on Windows."""
8081
candidates = ["npx.cmd", "npx.exe", "npx"]
82+
located = {cmd: f"C:\\Node\\{cmd}" for cmd in candidates}
8183

8284
def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]:
83-
if cmd[0] in candidates:
85+
assert kw.get("shell") is not True
86+
assert cmd[0] in located.values()
87+
if Path(cmd[0]).name in candidates:
8488
return subprocess.CompletedProcess(cmd, 0)
8589
else: # pragma: no cover
8690
raise subprocess.CalledProcessError(1, cmd[0])
8791

8892
monkeypatch.setattr(sys, "platform", "win32")
93+
monkeypatch.setattr("shutil.which", located.get)
8994
monkeypatch.setattr(subprocess, "run", fake_run)
90-
assert _get_npx_command() in candidates
95+
assert _get_npx_command() in located.values()
96+
97+
98+
def test_get_npx_windows_skips_failed_candidates(monkeypatch: pytest.MonkeyPatch):
99+
"""Should keep checking candidates if one exists but fails."""
100+
located = {"npx.cmd": "C:\\Node\\npx.cmd", "npx.exe": "C:\\Node\\npx.exe"}
101+
102+
def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]:
103+
assert kw.get("shell") is not True
104+
if cmd[0].endswith("npx.cmd"):
105+
raise subprocess.CalledProcessError(1, cmd)
106+
return subprocess.CompletedProcess(cmd, 0)
107+
108+
monkeypatch.setattr(sys, "platform", "win32")
109+
monkeypatch.setattr("shutil.which", located.get)
110+
monkeypatch.setattr(subprocess, "run", fake_run)
111+
112+
assert _get_npx_command() == located["npx.exe"]
91113

92114

93115
def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
94116
"""Should give None if every candidate fails."""
95117
monkeypatch.setattr(sys, "platform", "win32", raising=False)
118+
monkeypatch.setattr("shutil.which", lambda cmd: None)
119+
assert _get_npx_command() is None
96120

97-
def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
98-
raise subprocess.CalledProcessError(1, args[0])
99121

100-
monkeypatch.setattr(subprocess, "run", always_fail)
101-
assert _get_npx_command() is None
122+
def test_dev_runs_inspector_without_shell(monkeypatch: pytest.MonkeyPatch):
123+
"""mcp dev should not route file paths or args through a platform shell."""
124+
calls: list[tuple[list[str], dict[str, Any]]] = []
125+
126+
class Server:
127+
dependencies = ["server-dep"]
128+
129+
def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[str]:
130+
calls.append((cmd, kw))
131+
return subprocess.CompletedProcess(cmd, 0)
132+
133+
monkeypatch.setattr(cli_module, "_parse_file_path", lambda file_spec: (Path("server&calc.py"), None))
134+
monkeypatch.setattr(cli_module, "_import_server", lambda file, server_object: Server())
135+
monkeypatch.setattr(cli_module, "_build_uv_command", lambda file_spec, with_editable, with_packages: ["uv", "run"])
136+
monkeypatch.setattr(cli_module, "_get_npx_command", lambda: "C:\\Node\\npx.cmd")
137+
monkeypatch.setattr(subprocess, "run", fake_run)
138+
139+
with pytest.raises(SystemExit) as exc_info:
140+
cli_module.dev("server&calc.py", with_packages=["cli-dep"])
141+
142+
assert exc_info.value.code == 0
143+
assert len(calls) == 1
144+
cmd, kwargs = calls[0]
145+
assert cmd == ["C:\\Node\\npx.cmd", "@modelcontextprotocol/inspector", "uv", "run"]
146+
assert kwargs["check"] is True
147+
assert "shell" not in kwargs

0 commit comments

Comments
 (0)