Skip to content

Commit de8aa19

Browse files
committed
fix(cli): avoid Windows shell for mcp dev
1 parent 3d7b311 commit de8aa19

2 files changed

Lines changed: 46 additions & 19 deletions

File tree

src/mcp/cli/cli.py

Lines changed: 5 additions & 9 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
@@ -44,11 +45,8 @@ def _get_npx_command():
4445
if sys.platform == "win32":
4546
# Try both npx.cmd and npx.exe on Windows
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
5351
return "npx" # On Unix-like systems, just use npx
5452

@@ -241,7 +239,7 @@ def dev(
241239
help="Additional packages to install",
242240
),
243241
] = [],
244-
) -> None: # pragma: no cover
242+
) -> None:
245243
"""Run an MCP server with the MCP Inspector."""
246244
file, server_object = _parse_file_path(file_spec)
247245

@@ -271,12 +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"
272+
# Run the MCP Inspector command.
276273
process = subprocess.run(
277274
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
278275
check=True,
279-
shell=shell,
280276
env=dict(os.environ.items()), # Convert to list of tuples for env update
281277
)
282278
sys.exit(process.returncode)

tests/cli/test_utils.py

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

66
import pytest
77

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

1011

@@ -79,23 +80,53 @@ 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"]
8182

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])
83+
def fake_which(cmd: str) -> str | None:
84+
return f"C:\\node\\{cmd}" if cmd in candidates else None
8785

8886
monkeypatch.setattr(sys, "platform", "win32")
89-
monkeypatch.setattr(subprocess, "run", fake_run)
90-
assert _get_npx_command() in candidates
87+
monkeypatch.setattr(cli.shutil, "which", fake_which)
88+
assert _get_npx_command() == "C:\\node\\npx.cmd"
9189

9290

9391
def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
9492
"""Should give None if every candidate fails."""
9593
monkeypatch.setattr(sys, "platform", "win32", raising=False)
9694

97-
def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
98-
raise subprocess.CalledProcessError(1, args[0])
95+
def no_npx(_cmd: str) -> str | None:
96+
return None
9997

100-
monkeypatch.setattr(subprocess, "run", always_fail)
98+
monkeypatch.setattr(cli.shutil, "which", no_npx)
10199
assert _get_npx_command() is None
100+
101+
102+
def test_dev_uses_arg_list_without_shell_on_windows(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
103+
"""Should not route user-controlled dev paths through cmd.exe on Windows."""
104+
server_path = tmp_path / "server&calc.py"
105+
captured: dict[str, Any] = {}
106+
107+
def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
108+
captured["cmd"] = cmd
109+
captured["kwargs"] = kwargs
110+
return subprocess.CompletedProcess(cmd, 0)
111+
112+
def fake_parse_file_path(_file_spec: str) -> tuple[Path, str | None]:
113+
return server_path, None
114+
115+
def fake_import_server(_file: Path, _server_object: str | None) -> object:
116+
return object()
117+
118+
def fake_get_npx_command() -> str:
119+
return "C:\\node\\npx.cmd"
120+
121+
monkeypatch.setattr(sys, "platform", "win32")
122+
monkeypatch.setattr(cli, "_parse_file_path", fake_parse_file_path)
123+
monkeypatch.setattr(cli, "_import_server", fake_import_server)
124+
monkeypatch.setattr(cli, "_get_npx_command", fake_get_npx_command)
125+
monkeypatch.setattr(cli.subprocess, "run", fake_run)
126+
127+
with pytest.raises(SystemExit) as exc_info:
128+
cli.dev(str(server_path))
129+
130+
assert exc_info.value.code == 0
131+
assert captured["cmd"][-1] == str(server_path)
132+
assert captured["kwargs"].get("shell") is not True

0 commit comments

Comments
 (0)