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
4 changes: 4 additions & 0 deletions README.v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,10 @@ uv run mcp dev server.py --with pandas --with numpy

# Mount local code
uv run mcp dev server.py --with-editable .

# Pass environment variables to the server
uv run mcp dev server.py -v API_KEY=abc123 -v DB_URL=postgres://...
uv run mcp dev server.py -f .env
```

### Claude Desktop Integration
Expand Down
78 changes: 57 additions & 21 deletions src/mcp/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def _get_npx_command():
return "npx" # On Unix-like systems, just use npx


def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
def _parse_env_var(env_var: str) -> tuple[str, str]:
"""Parse environment variable string in format KEY=VALUE."""
if "=" not in env_var:
logger.error(f"Invalid environment variable format: {env_var}. Must be KEY=VALUE")
Expand All @@ -62,6 +62,33 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
return key.strip(), value.strip()


def _resolve_env(env_file: Path | None, env_vars: list[str]) -> dict[str, str] | None:
"""Resolve env vars from an optional .env file plus repeated KEY=VALUE flags.

Command-line ``env_vars`` override values from ``env_file``. Returns ``None``
when neither source is provided.
"""
if not env_file and not env_vars:
return None

env_dict: dict[str, str] = {}
if env_file:
if dotenv is None:
logger.error("python-dotenv is not installed. Cannot load .env file.")
sys.exit(1)
try:
env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None}
except (OSError, ValueError):
logger.exception("Failed to load .env file")
sys.exit(1)

for env_var in env_vars:
key, value = _parse_env_var(env_var)
env_dict[key] = value

return env_dict


def _build_uv_command(
file_spec: str,
with_editable: Path | None = None,
Expand Down Expand Up @@ -241,6 +268,26 @@ def dev(
help="Additional packages to install",
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var",
"-v",
help="Environment variables in KEY=VALUE format (repeatable)",
),
] = [],
env_file: Annotated[
Path | None,
typer.Option(
"--env-file",
"-f",
help="Load environment variables from a .env file",
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
),
] = None,
) -> None: # pragma: no cover
"""Run an MCP server with the MCP Inspector."""
file, server_object = _parse_file_path(file_spec)
Expand Down Expand Up @@ -271,13 +318,20 @@ def dev(
)
sys.exit(1)

# Build the environment for the inspector subprocess. Caller-supplied
# vars take precedence over the inherited environment.
env = dict(os.environ)
extra_env = _resolve_env(env_file, env_vars)
if extra_env:
env.update(extra_env)

# Run the MCP Inspector command with shell=True on Windows
shell = sys.platform == "win32"
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
env=env,
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
Expand Down Expand Up @@ -453,25 +507,7 @@ def install(
with_packages = list(set(with_packages + server_dependencies))

# Process environment variables if provided
env_dict: dict[str, str] | None = None
if env_file or env_vars:
env_dict = {}
# Load from .env file if specified
if env_file:
if dotenv:
try:
env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None}
except (OSError, ValueError):
logger.exception("Failed to load .env file")
sys.exit(1)
else:
logger.error("python-dotenv is not installed. Cannot load .env file.")
sys.exit(1)

# Add command line environment variables
for env_var in env_vars:
key, value = _parse_env_var(env_var)
env_dict[key] = value
env_dict = _resolve_env(env_file, env_vars)

if claude.update_claude_config(
file_spec,
Expand Down
70 changes: 69 additions & 1 deletion tests/cli/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

import pytest

from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage]
from mcp.cli.cli import ( # type: ignore[reportPrivateUsage]
_build_uv_command,
_get_npx_command,
_parse_file_path,
_resolve_env,
)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -99,3 +104,66 @@ def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]

monkeypatch.setattr(subprocess, "run", always_fail)
assert _get_npx_command() is None


def test_resolve_env_returns_none_when_nothing_provided():
"""No env file and no env vars should yield None."""
assert _resolve_env(None, []) is None


def test_resolve_env_parses_inline_vars():
"""Repeated KEY=VALUE flags should be parsed into a dict."""
assert _resolve_env(None, ["FOO=bar", "BAZ=qux"]) == {"FOO": "bar", "BAZ": "qux"}


def test_resolve_env_handles_value_with_equals():
"""Values containing '=' should be preserved (only the first '=' splits)."""
assert _resolve_env(None, ["DB_URL=postgres://u:p@host/db?x=1"]) == {"DB_URL": "postgres://u:p@host/db?x=1"}


def test_resolve_env_loads_dotenv_file(tmp_path: Path):
"""Values from a .env file should be loaded."""
env_file = tmp_path / ".env"
env_file.write_text("FOO=from_file\nBAR=also_from_file\n")
assert _resolve_env(env_file, []) == {"FOO": "from_file", "BAR": "also_from_file"}


def test_resolve_env_inline_vars_override_dotenv(tmp_path: Path):
"""Inline -v flags should override values from --env-file."""
env_file = tmp_path / ".env"
env_file.write_text("FOO=from_file\nBAR=keep_me\n")
result = _resolve_env(env_file, ["FOO=from_cli"])
assert result == {"FOO": "from_cli", "BAR": "keep_me"}


def test_resolve_env_exits_when_dotenv_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""If python-dotenv isn't installed, asking to load a .env file should exit."""
env_file = tmp_path / ".env"
env_file.write_text("FOO=bar\n")
monkeypatch.setattr("mcp.cli.cli.dotenv", None)
with pytest.raises(SystemExit) as exc:
_resolve_env(env_file, [])
assert exc.value.code == 1


def test_resolve_env_exits_when_dotenv_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""OSError/ValueError from dotenv_values should be turned into a clean exit."""
env_file = tmp_path / ".env"
env_file.write_text("FOO=bar\n")

class _FakeDotenv:
@staticmethod
def dotenv_values(_path: Path) -> dict[str, str]:
raise OSError("simulated read failure")

monkeypatch.setattr("mcp.cli.cli.dotenv", _FakeDotenv)
with pytest.raises(SystemExit) as exc:
_resolve_env(env_file, [])
assert exc.value.code == 1


def test_resolve_env_exits_on_malformed_inline_var():
"""A -v flag without '=' should exit cleanly instead of raising ValueError."""
with pytest.raises(SystemExit) as exc:
_resolve_env(None, ["NO_EQUALS_SIGN"])
assert exc.value.code == 1
Loading