Skip to content

Commit a176ccc

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

2 files changed

Lines changed: 147 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: 142 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,154 @@ 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
133+
134+
135+
def test_dev_adds_server_dependencies(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
136+
"""Should include dependencies declared by the imported server."""
137+
server_path = tmp_path / "server.py"
138+
captured: dict[str, Any] = {}
139+
140+
class Server:
141+
dependencies = ["server-extra"]
142+
143+
def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
144+
captured["cmd"] = cmd
145+
captured["kwargs"] = kwargs
146+
return subprocess.CompletedProcess(cmd, 0)
147+
148+
def fake_parse_file_path(_file_spec: str) -> tuple[Path, str | None]:
149+
return server_path, None
150+
151+
def fake_import_server(_file: Path, _server_object: str | None) -> object:
152+
return Server()
153+
154+
monkeypatch.setattr(cli, "_parse_file_path", fake_parse_file_path)
155+
monkeypatch.setattr(cli, "_import_server", fake_import_server)
156+
monkeypatch.setattr(cli, "_get_npx_command", lambda: "npx")
157+
monkeypatch.setattr(cli.subprocess, "run", fake_run)
158+
159+
with pytest.raises(SystemExit) as exc_info:
160+
cli.dev(str(server_path), with_packages=["direct-extra"])
161+
162+
assert exc_info.value.code == 0
163+
with_values = [captured["cmd"][index + 1] for index, value in enumerate(captured["cmd"]) if value == "--with"]
164+
assert with_values[0] == "mcp"
165+
assert set(with_values[1:]) == {"direct-extra", "server-extra"}
166+
167+
168+
def test_dev_exits_when_npx_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
169+
"""Should exit before spawning the inspector if npx cannot be found."""
170+
server_path = tmp_path / "server.py"
171+
172+
def fake_parse_file_path(_file_spec: str) -> tuple[Path, str | None]:
173+
return server_path, None
174+
175+
def fake_import_server(_file: Path, _server_object: str | None) -> object:
176+
return object()
177+
178+
monkeypatch.setattr(cli, "_parse_file_path", fake_parse_file_path)
179+
monkeypatch.setattr(cli, "_import_server", fake_import_server)
180+
monkeypatch.setattr(cli, "_get_npx_command", lambda: None)
181+
182+
with pytest.raises(SystemExit) as exc_info:
183+
cli.dev(str(server_path))
184+
185+
assert exc_info.value.code == 1
186+
187+
188+
def test_dev_exits_with_process_returncode(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
189+
"""Should propagate inspector process failures."""
190+
server_path = tmp_path / "server.py"
191+
192+
def fake_run(cmd: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
193+
raise subprocess.CalledProcessError(7, cmd)
194+
195+
def fake_parse_file_path(_file_spec: str) -> tuple[Path, str | None]:
196+
return server_path, None
197+
198+
def fake_import_server(_file: Path, _server_object: str | None) -> object:
199+
return object()
200+
201+
monkeypatch.setattr(cli, "_parse_file_path", fake_parse_file_path)
202+
monkeypatch.setattr(cli, "_import_server", fake_import_server)
203+
monkeypatch.setattr(cli, "_get_npx_command", lambda: "npx")
204+
monkeypatch.setattr(cli.subprocess, "run", fake_run)
205+
206+
with pytest.raises(SystemExit) as exc_info:
207+
cli.dev(str(server_path))
208+
209+
assert exc_info.value.code == 7
210+
211+
212+
def test_dev_exits_when_subprocess_cannot_find_npx(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
213+
"""Should handle npx disappearing after discovery."""
214+
server_path = tmp_path / "server.py"
215+
216+
def fake_run(_cmd: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
217+
raise FileNotFoundError()
218+
219+
def fake_parse_file_path(_file_spec: str) -> tuple[Path, str | None]:
220+
return server_path, None
221+
222+
def fake_import_server(_file: Path, _server_object: str | None) -> object:
223+
return object()
224+
225+
monkeypatch.setattr(cli, "_parse_file_path", fake_parse_file_path)
226+
monkeypatch.setattr(cli, "_import_server", fake_import_server)
227+
monkeypatch.setattr(cli, "_get_npx_command", lambda: "npx")
228+
monkeypatch.setattr(cli.subprocess, "run", fake_run)
229+
230+
with pytest.raises(SystemExit) as exc_info:
231+
cli.dev(str(server_path))
232+
233+
assert exc_info.value.code == 1

0 commit comments

Comments
 (0)