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

# Load environment variables
uv run mcp dev server.py --env-var API_KEY=abc123 --env-file .env

# Mount local code
uv run mcp dev server.py --with-editable .
```
Expand Down
75 changes: 53 additions & 22 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,30 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
return key.strip(), value.strip()


def _collect_env_vars(env_file: Path | None, env_vars: list[str]) -> dict[str, str] | None:
"""Collect environment variables from a .env file and CLI KEY=VALUE pairs."""
if not env_file and not env_vars:
return None

env_dict: dict[str, str] = {}
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)

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,9 +265,30 @@ def dev(
help="Additional packages to install",
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var",
"-v",
help="Environment variables in KEY=VALUE format",
),
] = [],
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)
env_dict = _collect_env_vars(env_file, env_vars)

logger.debug(
"Starting dev server",
Expand All @@ -252,6 +297,8 @@ def dev(
"server_object": server_object,
"with_editable": str(with_editable) if with_editable else None,
"with_packages": with_packages,
"env_file": str(env_file) if env_file else None,
"env_vars": list(env_dict) if env_dict else [],
},
)

Expand All @@ -273,11 +320,14 @@ def dev(

# Run the MCP Inspector command with shell=True on Windows
shell = sys.platform == "win32"
process_env = dict(os.environ.items())
if env_dict:
process_env.update(env_dict)
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=process_env,
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
Expand Down Expand Up @@ -452,26 +502,7 @@ def install(
if server_dependencies:
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 = _collect_env_vars(env_file, env_vars)

if claude.update_claude_config(
file_spec,
Expand Down
76 changes: 75 additions & 1 deletion tests/cli/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any

import pytest

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


@pytest.mark.parametrize(
Expand Down Expand Up @@ -69,6 +76,73 @@ def test_build_uv_command_adds_editable_and_packages():
]


def test_collect_env_vars_returns_none_without_inputs():
"""Should not allocate an env block when no env sources were provided."""
assert _collect_env_vars(None, []) is None


def test_collect_env_vars_from_cli_values():
"""CLI env vars should be parsed as KEY=VALUE pairs."""
assert _collect_env_vars(None, ["API_KEY=abc123", "EMPTY="]) == {"API_KEY": "abc123", "EMPTY": ""}


def test_collect_env_vars_exits_on_invalid_cli_value():
"""CLI env vars must use KEY=VALUE format."""
with pytest.raises(SystemExit) as exc_info:
_collect_env_vars(None, ["API_KEY"])

assert exc_info.value.code == 1


def test_collect_env_vars_file_then_cli_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""CLI env vars should override values loaded from a .env file."""
env_file = tmp_path / ".env"
env_file.write_text("", encoding="utf-8")

def dotenv_values(_: Path) -> dict[str, str]:
return {"API_KEY": "file-value", "KEEP": "from-file"}

monkeypatch.setattr(
cli,
"dotenv",
SimpleNamespace(dotenv_values=dotenv_values),
)

assert _collect_env_vars(env_file, ["API_KEY=cli-value"]) == {"API_KEY": "cli-value", "KEEP": "from-file"}


def test_collect_env_vars_exits_when_dotenv_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""Should fail clearly when loading a .env file without python-dotenv."""
env_file = tmp_path / ".env"
env_file.write_text("", encoding="utf-8")
monkeypatch.setattr(cli, "dotenv", None)

with pytest.raises(SystemExit) as exc_info:
_collect_env_vars(env_file, [])

assert exc_info.value.code == 1


def test_collect_env_vars_exits_when_dotenv_load_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""Should fail clearly when the .env file cannot be parsed or read."""
env_file = tmp_path / ".env"
env_file.write_text("", encoding="utf-8")

def dotenv_values(_: Path) -> dict[str, str]:
raise ValueError("bad env")

monkeypatch.setattr(
cli,
"dotenv",
SimpleNamespace(dotenv_values=dotenv_values),
)

with pytest.raises(SystemExit) as exc_info:
_collect_env_vars(env_file, [])

assert exc_info.value.code == 1


def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):
"""Should return "npx" on unix-like systems."""
monkeypatch.setattr(sys, "platform", "linux")
Expand Down
Loading