Skip to content

Commit 4d28f14

Browse files
Theodor N. EngøyTheodor N. Engøy
authored andcommitted
cli: avoid shell=True for npx/browser helpers
1 parent dda845a commit 4d28f14

File tree

3 files changed

+46
-41
lines changed

3 files changed

+46
-41
lines changed

examples/snippets/clients/url_elicitation_client.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@
2424

2525
import asyncio
2626
import json
27-
import subprocess
28-
import sys
2927
import webbrowser
3028
from typing import Any
3129
from urllib.parse import urlparse
@@ -124,12 +122,9 @@ def extract_domain(url: str) -> str:
124122
def open_browser(url: str) -> None:
125123
"""Open URL in the default browser."""
126124
try:
127-
if sys.platform == "darwin":
128-
subprocess.run(["open", url], check=False)
129-
elif sys.platform == "win32":
130-
subprocess.run(["start", url], shell=True, check=False)
131-
else:
132-
webbrowser.open(url)
125+
# Use the stdlib helper which handles macOS/Windows/Linux without
126+
# invoking a shell directly.
127+
webbrowser.open(url, new=2)
133128
except Exception as e:
134129
print(f"Failed to open browser: {e}")
135130
print(f"Please manually open: {url}")

src/mcp/cli/cli.py

Lines changed: 26 additions & 17 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,18 +40,29 @@
3940
)
4041

4142

42-
def _get_npx_command():
43-
"""Get the correct npx command for the current platform."""
44-
if sys.platform == "win32":
45-
# Try both npx.cmd and npx.exe on Windows
46-
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
52-
return None
53-
return "npx" # On Unix-like systems, just use npx
43+
def _get_npx_command() -> list[str] | None:
44+
"""Get the correct npx command for the current platform.
45+
46+
Returns a subprocess-compatible command prefix. This avoids shell=True, and
47+
on Windows it can wrap a .cmd/.bat shim via cmd.exe (COMSPEC) safely.
48+
"""
49+
if sys.platform != "win32":
50+
return ["npx"] # On Unix-like systems, just use npx
51+
52+
# Prefer an executable if present, but fall back to the .cmd shim.
53+
for cmd in ["npx.exe", "npx.cmd", "npx"]:
54+
resolved = shutil.which(cmd)
55+
if not resolved:
56+
continue
57+
58+
# .cmd/.bat shims must be invoked via cmd.exe without using shell=True.
59+
if resolved.lower().endswith((".cmd", ".bat")):
60+
comspec = os.environ.get("COMSPEC") or "cmd.exe"
61+
return [comspec, "/c", resolved]
62+
63+
return [resolved]
64+
65+
return None
5466

5567

5668
def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
@@ -271,13 +283,10 @@ def dev(
271283
)
272284
sys.exit(1)
273285

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

tests/cli/test_utils.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import subprocess
21
import sys
32
from pathlib import Path
4-
from typing import Any
53

64
import pytest
75

@@ -72,30 +70,33 @@ def test_build_uv_command_adds_editable_and_packages():
7270
def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):
7371
"""Should return "npx" on unix-like systems."""
7472
monkeypatch.setattr(sys, "platform", "linux")
75-
assert _get_npx_command() == "npx"
73+
assert _get_npx_command() == ["npx"]
7674

7775

7876
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"]
77+
"""Should return a subprocess-friendly command prefix on Windows."""
78+
import shutil
8179

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])
80+
monkeypatch.setattr(sys, "platform", "win32")
81+
monkeypatch.setattr(shutil, "which", lambda name: "C:\\bin\\npx.exe" if name == "npx.exe" else None)
82+
assert _get_npx_command() == ["C:\\bin\\npx.exe"]
83+
84+
85+
def test_get_npx_windows_cmd_wrapper(monkeypatch: pytest.MonkeyPatch):
86+
"""Should wrap .cmd/.bat shims via COMSPEC on Windows."""
87+
import shutil
8788

8889
monkeypatch.setattr(sys, "platform", "win32")
89-
monkeypatch.setattr(subprocess, "run", fake_run)
90-
assert _get_npx_command() in candidates
90+
monkeypatch.setenv("COMSPEC", "cmd.exe")
91+
monkeypatch.setattr(shutil, "which", lambda name: "C:\\bin\\npx.cmd" if name == "npx.cmd" else None)
92+
93+
assert _get_npx_command() == ["cmd.exe", "/c", "C:\\bin\\npx.cmd"]
9194

9295

9396
def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
9497
"""Should give None if every candidate fails."""
9598
monkeypatch.setattr(sys, "platform", "win32", raising=False)
99+
import shutil
96100

97-
def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
98-
raise subprocess.CalledProcessError(1, args[0])
99-
100-
monkeypatch.setattr(subprocess, "run", always_fail)
101+
monkeypatch.setattr(shutil, "which", lambda name: None)
101102
assert _get_npx_command() is None

0 commit comments

Comments
 (0)