Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions src/mcp/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import importlib.metadata
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -39,16 +40,14 @@
)


def _get_npx_command():
def _get_npx_command() -> str | None:
"""Get the correct npx command for the current platform."""
if sys.platform == "win32":
# Try both npx.cmd and npx.exe on Windows
# Use shutil.which() to safely locate the executable on Windows
# without invoking a shell, preventing command injection risks.
for cmd in ["npx.cmd", "npx.exe", "npx"]:
try:
subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True)
if shutil.which(cmd) is not None:
return cmd
except subprocess.CalledProcessError:
continue
return None
return "npx" # On Unix-like systems, just use npx

Expand Down Expand Up @@ -271,12 +270,13 @@ def dev(
)
sys.exit(1)

# Run the MCP Inspector command with shell=True on Windows
shell = sys.platform == "win32"
# Run the MCP Inspector command without shell=True to prevent
# command injection via shell metacharacters (see #1257).
# _get_npx_command() already resolves the correct executable
# (e.g. npx.cmd on Windows), so shell dispatch is unnecessary.
process = subprocess.run(
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
check=True,
shell=shell,
env=dict(os.environ.items()), # Convert to list of tuples for env update
)
sys.exit(process.returncode)
Expand Down
22 changes: 9 additions & 13 deletions tests/cli/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shutil
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -76,26 +77,21 @@ def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):


def test_get_npx_windows(monkeypatch: pytest.MonkeyPatch):
"""Should return one of the npx candidates on Windows."""
"""Should return one of the npx candidates on Windows via shutil.which()."""
candidates = ["npx.cmd", "npx.exe", "npx"]

def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]:
if cmd[0] in candidates:
return subprocess.CompletedProcess(cmd, 0)
else: # pragma: no cover
raise subprocess.CalledProcessError(1, cmd[0])
def fake_which(cmd: str) -> str | None:
if cmd in candidates:
return f"/fake/path/{cmd}"
return None # pragma: no cover

monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(shutil, "which", fake_which)
assert _get_npx_command() in candidates


def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
"""Should give None if every candidate fails."""
"""Should give None if every candidate is absent from PATH."""
monkeypatch.setattr(sys, "platform", "win32", raising=False)

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

monkeypatch.setattr(subprocess, "run", always_fail)
monkeypatch.setattr(shutil, "which", lambda cmd: None)
assert _get_npx_command() is None
Loading