Skip to content

Commit ccc2897

Browse files
committed
Avoid shell=True for npx on Windows
1 parent fb2276b commit ccc2897

File tree

2 files changed

+71
-24
lines changed

2 files changed

+71
-24
lines changed

src/mcp/cli/cli.py

Lines changed: 6 additions & 11 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
@@ -42,15 +43,12 @@
4243
def _get_npx_command():
4344
"""Get the correct npx command for the current platform."""
4445
if sys.platform == "win32":
45-
# Try both npx.cmd and npx.exe on Windows
46+
# Resolve a concrete executable path so Windows never has to route through cmd.exe.
4647
for cmd in ["npx.cmd", "npx.exe", "npx"]:
47-
try:
48-
subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True)
49-
return cmd
50-
except subprocess.CalledProcessError:
51-
continue
48+
if resolved := shutil.which(cmd):
49+
return resolved
5250
return None
53-
return "npx" # On Unix-like systems, just use npx
51+
return "npx"
5452

5553

5654
def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
@@ -271,13 +269,10 @@ def dev(
271269
)
272270
sys.exit(1)
273271

274-
# Run the MCP Inspector command with shell=True on Windows
275-
shell = sys.platform == "win32"
276272
process = subprocess.run(
277273
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
278274
check=True,
279-
shell=shell,
280-
env=dict(os.environ.items()), # Convert to list of tuples for env update
275+
env=os.environ.copy(),
281276
)
282277
sys.exit(process.returncode)
283278
except subprocess.CalledProcessError as e:

tests/cli/test_utils.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import os
12
import subprocess
23
import sys
34
from pathlib import Path
5+
from types import SimpleNamespace
46
from typing import Any
57

68
import pytest
79

10+
from mcp.cli import cli as cli_module
811
from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage]
912

1013

@@ -76,26 +79,75 @@ def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):
7679

7780

7881
def test_get_npx_windows(monkeypatch: pytest.MonkeyPatch):
79-
"""Should return one of the npx candidates on Windows."""
80-
candidates = ["npx.cmd", "npx.exe", "npx"]
82+
"""Should return the first Windows npx executable found on PATH."""
83+
resolved_commands = {
84+
"npx.cmd": r"C:\Program Files\nodejs\npx.cmd",
85+
"npx.exe": None,
86+
"npx": None,
87+
}
8188

82-
def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]:
83-
if cmd[0] in candidates:
84-
return subprocess.CompletedProcess(cmd, 0)
85-
else: # pragma: no cover
86-
raise subprocess.CalledProcessError(1, cmd[0])
89+
def fake_which(cmd: str) -> str | None:
90+
return resolved_commands.get(cmd)
8791

8892
monkeypatch.setattr(sys, "platform", "win32")
89-
monkeypatch.setattr(subprocess, "run", fake_run)
90-
assert _get_npx_command() in candidates
93+
monkeypatch.setattr("shutil.which", fake_which)
94+
assert _get_npx_command() == r"C:\Program Files\nodejs\npx.cmd"
9195

9296

9397
def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
9498
"""Should give None if every candidate fails."""
9599
monkeypatch.setattr(sys, "platform", "win32", raising=False)
100+
monkeypatch.setattr("shutil.which", lambda cmd: None)
101+
assert _get_npx_command() is None
96102

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

100-
monkeypatch.setattr(subprocess, "run", always_fail)
101-
assert _get_npx_command() is None
104+
def test_dev_runs_inspector_without_shell_on_windows(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
105+
"""Should invoke the inspector with a resolved executable and shell=False on Windows."""
106+
server_file = tmp_path / "server.py"
107+
server_file.write_text("x = 1")
108+
109+
monkeypatch.setattr(sys, "platform", "win32")
110+
monkeypatch.setattr(cli_module, "_parse_file_path", lambda file_spec: (server_file, None))
111+
monkeypatch.setattr(cli_module, "_import_server", lambda file, server_object: SimpleNamespace(dependencies=[]))
112+
monkeypatch.setattr(
113+
cli_module,
114+
"_build_uv_command",
115+
lambda file_spec, with_editable=None, with_packages=None: [
116+
"uv",
117+
"run",
118+
"--with",
119+
"mcp",
120+
"mcp",
121+
"run",
122+
file_spec,
123+
],
124+
)
125+
monkeypatch.setattr(cli_module, "_get_npx_command", lambda: r"C:\Program Files\nodejs\npx.cmd")
126+
127+
recorded: dict[str, Any] = {}
128+
129+
def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
130+
recorded["cmd"] = cmd
131+
recorded["kwargs"] = kwargs
132+
return subprocess.CompletedProcess(cmd, 0)
133+
134+
monkeypatch.setattr(subprocess, "run", fake_run)
135+
136+
with pytest.raises(SystemExit) as excinfo:
137+
cli_module.dev(str(server_file))
138+
139+
assert excinfo.value.code == 0
140+
assert recorded["cmd"] == [
141+
r"C:\Program Files\nodejs\npx.cmd",
142+
"@modelcontextprotocol/inspector",
143+
"uv",
144+
"run",
145+
"--with",
146+
"mcp",
147+
"mcp",
148+
"run",
149+
str(server_file),
150+
]
151+
assert recorded["kwargs"]["check"] is True
152+
assert recorded["kwargs"]["env"] == dict(os.environ.items())
153+
assert recorded["kwargs"].get("shell", False) is False

0 commit comments

Comments
 (0)