From 58b66872b30d3616797db0e56240945835d508c7 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Fri, 1 May 2026 21:01:49 +0800 Subject: [PATCH 1/4] feat: support env vars in dev command --- README.md | 3 ++ src/mcp/cli/cli.py | 73 +++++++++++++++++++++++++++++------------ tests/cli/test_utils.py | 25 +++++++++++++- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 487d48bee..7fa6c9153 100644 --- a/README.md +++ b/README.md @@ -1174,6 +1174,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 . ``` diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index 62334a4a2..41054456c 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -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, @@ -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", @@ -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 [], }, ) @@ -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: @@ -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, diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index 44f4ab4d3..936f7697a 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -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, + _collect_env_vars, + _get_npx_command, + _parse_file_path, +) @pytest.mark.parametrize( @@ -69,6 +74,24 @@ 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_file_then_cli_override(tmp_path: Path): + """CLI env vars should override values loaded from a .env file.""" + env_file = tmp_path / ".env" + env_file.write_text("API_KEY=file-value\nKEEP=from-file\n", encoding="utf-8") + + assert _collect_env_vars(env_file, ["API_KEY=cli-value"]) == {"API_KEY": "cli-value", "KEEP": "from-file"} + + def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch): """Should return "npx" on unix-like systems.""" monkeypatch.setattr(sys, "platform", "linux") From 232ea33817a644b3e06b6c79ec3481703bbee342 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Fri, 1 May 2026 21:07:53 +0800 Subject: [PATCH 2/4] test: avoid optional dotenv dependency --- tests/cli/test_utils.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index 936f7697a..b136c76e6 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -1,10 +1,12 @@ import subprocess import sys from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest +import mcp.cli.cli as cli from mcp.cli.cli import ( # type: ignore[reportPrivateUsage] _build_uv_command, _collect_env_vars, @@ -84,10 +86,19 @@ def test_collect_env_vars_from_cli_values(): assert _collect_env_vars(None, ["API_KEY=abc123", "EMPTY="]) == {"API_KEY": "abc123", "EMPTY": ""} -def test_collect_env_vars_file_then_cli_override(tmp_path: Path): +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("API_KEY=file-value\nKEEP=from-file\n", encoding="utf-8") + 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"} From 6ab70ea0268db3c04cad88f5d639b0e9365a54ff Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Fri, 1 May 2026 21:13:43 +0800 Subject: [PATCH 3/4] test: fix dev env var ci coverage --- README.md | 3 --- README.v2.md | 3 +++ tests/cli/test_utils.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7fa6c9153..487d48bee 100644 --- a/README.md +++ b/README.md @@ -1174,9 +1174,6 @@ 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 . ``` diff --git a/README.v2.md b/README.v2.md index d0851c04e..da701765b 100644 --- a/README.v2.md +++ b/README.v2.md @@ -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 . ``` diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index b136c76e6..fe0137ad0 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -103,6 +103,38 @@ def dotenv_values(_: Path) -> dict[str, str]: 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") From 53442d701dd5ba3576ac9c01668294fe6115ed90 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Fri, 1 May 2026 21:16:59 +0800 Subject: [PATCH 4/4] test: cover invalid cli env vars --- src/mcp/cli/cli.py | 2 +- tests/cli/test_utils.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index 41054456c..9672c346d 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -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") diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index fe0137ad0..79aec8a39 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -86,6 +86,14 @@ def test_collect_env_vars_from_cli_values(): 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"