From 123ad26b4521d482f564411bc6b48a66db29a4c1 Mon Sep 17 00:00:00 2001 From: Aashish Ghimire Date: Sat, 25 Apr 2026 22:29:42 -0700 Subject: [PATCH 1/2] feat(cli): support env vars in `mcp dev` (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--env-var/-v` and `--env-file/-f` options to the `mcp dev` command so the inspector subprocess inherits caller-provided environment variables — mirroring what `mcp install` already accepts. Inline `-v` flags override values loaded from `--env-file`. Extract the shared resolution logic into `_resolve_env` and refactor `mcp install` to use it (no behavior change). Document the new flags in README.v2.md and add unit tests for `_resolve_env`. Closes #339 --- README.v2.md | 4 +++ src/mcp/cli/cli.py | 76 ++++++++++++++++++++++++++++++----------- tests/cli/test_utils.py | 37 +++++++++++++++++++- 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/README.v2.md b/README.v2.md index d0851c04e..ee5733b40 100644 --- a/README.v2.md +++ b/README.v2.md @@ -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 diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index 62334a4a2..dfb638540 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -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, @@ -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) @@ -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: @@ -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, diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index 44f4ab4d3..4f11d9337 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, + _get_npx_command, + _parse_file_path, + _resolve_env, +) @pytest.mark.parametrize( @@ -99,3 +104,33 @@ 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"} From 39adf30d6c4e34f6fef89204d87fe1a71d78f72b Mon Sep 17 00:00:00 2001 From: Aashish Ghimire Date: Sat, 25 Apr 2026 22:44:04 -0700 Subject: [PATCH 2/2] test(cli): cover `_resolve_env` error paths Add tests for the dotenv-missing, dotenv-raises, and malformed-`-v` branches so coverage stays at 100% on `src/mcp/cli/cli.py`. Also drop the now-obsolete `# pragma: no cover` from `_parse_env_var` (it's exercised through `_resolve_env`). --- src/mcp/cli/cli.py | 2 +- tests/cli/test_utils.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index dfb638540..4aef39160 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 4f11d9337..45becfa38 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -134,3 +134,36 @@ def test_resolve_env_inline_vars_override_dotenv(tmp_path: Path): 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