Skip to content

Commit 123ad26

Browse files
committed
feat(cli): support env vars in mcp dev (#339)
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
1 parent 3d7b311 commit 123ad26

3 files changed

Lines changed: 96 additions & 21 deletions

File tree

README.v2.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,10 @@ uv run mcp dev server.py --with pandas --with numpy
11671167

11681168
# Mount local code
11691169
uv run mcp dev server.py --with-editable .
1170+
1171+
# Pass environment variables to the server
1172+
uv run mcp dev server.py -v API_KEY=abc123 -v DB_URL=postgres://...
1173+
uv run mcp dev server.py -f .env
11701174
```
11711175

11721176
### Claude Desktop Integration

src/mcp/cli/cli.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,33 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
6262
return key.strip(), value.strip()
6363

6464

65+
def _resolve_env(env_file: Path | None, env_vars: list[str]) -> dict[str, str] | None:
66+
"""Resolve env vars from an optional .env file plus repeated KEY=VALUE flags.
67+
68+
Command-line ``env_vars`` override values from ``env_file``. Returns ``None``
69+
when neither source is provided.
70+
"""
71+
if not env_file and not env_vars:
72+
return None
73+
74+
env_dict: dict[str, str] = {}
75+
if env_file:
76+
if dotenv is None:
77+
logger.error("python-dotenv is not installed. Cannot load .env file.")
78+
sys.exit(1)
79+
try:
80+
env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None}
81+
except (OSError, ValueError):
82+
logger.exception("Failed to load .env file")
83+
sys.exit(1)
84+
85+
for env_var in env_vars:
86+
key, value = _parse_env_var(env_var)
87+
env_dict[key] = value
88+
89+
return env_dict
90+
91+
6592
def _build_uv_command(
6693
file_spec: str,
6794
with_editable: Path | None = None,
@@ -241,6 +268,26 @@ def dev(
241268
help="Additional packages to install",
242269
),
243270
] = [],
271+
env_vars: Annotated[
272+
list[str],
273+
typer.Option(
274+
"--env-var",
275+
"-v",
276+
help="Environment variables in KEY=VALUE format (repeatable)",
277+
),
278+
] = [],
279+
env_file: Annotated[
280+
Path | None,
281+
typer.Option(
282+
"--env-file",
283+
"-f",
284+
help="Load environment variables from a .env file",
285+
exists=True,
286+
file_okay=True,
287+
dir_okay=False,
288+
resolve_path=True,
289+
),
290+
] = None,
244291
) -> None: # pragma: no cover
245292
"""Run an MCP server with the MCP Inspector."""
246293
file, server_object = _parse_file_path(file_spec)
@@ -271,13 +318,20 @@ def dev(
271318
)
272319
sys.exit(1)
273320

321+
# Build the environment for the inspector subprocess. Caller-supplied
322+
# vars take precedence over the inherited environment.
323+
env = dict(os.environ)
324+
extra_env = _resolve_env(env_file, env_vars)
325+
if extra_env:
326+
env.update(extra_env)
327+
274328
# Run the MCP Inspector command with shell=True on Windows
275329
shell = sys.platform == "win32"
276330
process = subprocess.run(
277331
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
278332
check=True,
279333
shell=shell,
280-
env=dict(os.environ.items()), # Convert to list of tuples for env update
334+
env=env,
281335
)
282336
sys.exit(process.returncode)
283337
except subprocess.CalledProcessError as e:
@@ -453,25 +507,7 @@ def install(
453507
with_packages = list(set(with_packages + server_dependencies))
454508

455509
# Process environment variables if provided
456-
env_dict: dict[str, str] | None = None
457-
if env_file or env_vars:
458-
env_dict = {}
459-
# Load from .env file if specified
460-
if env_file:
461-
if dotenv:
462-
try:
463-
env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None}
464-
except (OSError, ValueError):
465-
logger.exception("Failed to load .env file")
466-
sys.exit(1)
467-
else:
468-
logger.error("python-dotenv is not installed. Cannot load .env file.")
469-
sys.exit(1)
470-
471-
# Add command line environment variables
472-
for env_var in env_vars:
473-
key, value = _parse_env_var(env_var)
474-
env_dict[key] = value
510+
env_dict = _resolve_env(env_file, env_vars)
475511

476512
if claude.update_claude_config(
477513
file_spec,

tests/cli/test_utils.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
import pytest
77

8-
from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage]
8+
from mcp.cli.cli import ( # type: ignore[reportPrivateUsage]
9+
_build_uv_command,
10+
_get_npx_command,
11+
_parse_file_path,
12+
_resolve_env,
13+
)
914

1015

1116
@pytest.mark.parametrize(
@@ -99,3 +104,33 @@ def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]
99104

100105
monkeypatch.setattr(subprocess, "run", always_fail)
101106
assert _get_npx_command() is None
107+
108+
109+
def test_resolve_env_returns_none_when_nothing_provided():
110+
"""No env file and no env vars should yield None."""
111+
assert _resolve_env(None, []) is None
112+
113+
114+
def test_resolve_env_parses_inline_vars():
115+
"""Repeated KEY=VALUE flags should be parsed into a dict."""
116+
assert _resolve_env(None, ["FOO=bar", "BAZ=qux"]) == {"FOO": "bar", "BAZ": "qux"}
117+
118+
119+
def test_resolve_env_handles_value_with_equals():
120+
"""Values containing '=' should be preserved (only the first '=' splits)."""
121+
assert _resolve_env(None, ["DB_URL=postgres://u:p@host/db?x=1"]) == {"DB_URL": "postgres://u:p@host/db?x=1"}
122+
123+
124+
def test_resolve_env_loads_dotenv_file(tmp_path: Path):
125+
"""Values from a .env file should be loaded."""
126+
env_file = tmp_path / ".env"
127+
env_file.write_text("FOO=from_file\nBAR=also_from_file\n")
128+
assert _resolve_env(env_file, []) == {"FOO": "from_file", "BAR": "also_from_file"}
129+
130+
131+
def test_resolve_env_inline_vars_override_dotenv(tmp_path: Path):
132+
"""Inline -v flags should override values from --env-file."""
133+
env_file = tmp_path / ".env"
134+
env_file.write_text("FOO=from_file\nBAR=keep_me\n")
135+
result = _resolve_env(env_file, ["FOO=from_cli"])
136+
assert result == {"FOO": "from_cli", "BAR": "keep_me"}

0 commit comments

Comments
 (0)