mcp-env-linter
Diffs the env vars an MCP host declares for a server (in
claude_desktop_config.json / .mcp.json / mcp.json) against what the
launched subprocess actually receives, and flags two specific,
well-documented propagation failure modes.
Why this exists
The "MCP doctor/linter" space already has several entrants — four or more
mcp-doctor-named repos, plus the official modelcontextprotocol/inspector
and mcp-validator. Checked directly, they cover handshake health,
credential leaks, and tool-description quality. None of them diff declared
env vars against what actually reaches the server subprocess — the specific
bug reported independently against modelcontextprotocol/servers (#1018,
#692), copilot-cli (#744), and claude-code (#10955, #1254), where hosts
silently drop or fail to propagate env vars to npx-launched/subprocess MCP
servers. That gap is what this tool checks. It does not attempt to duplicate
handshake or credential checks that the other tools already do.
Concretely, it runs two diagnostics per configured server:
${VAR}/$VARplaceholder detection. Claude Desktop'senvfield does not expand shell-style variable references — a declared value like"${APPDATA}"is passed to the server literally, not substituted. The linter flags any declared value that looks like an unexpanded placeholder.--spawn-mode replacePATH-drop detection. Some reported failures are consistent with a host passing anenvobject that replaces the inherited environment rather than merging into it, which dropsPATHand breaksnpxresolution for the launched server.--spawn-mode replaceconstructs the environment under that hypothesis and reports whether the declared command still resolves.
Install
Not yet published to PyPI. Install from a local clone:
git clone <this-repo>
cd mcp-env-linter
pip install -e .
Requires Python >= 3.9. No runtime dependencies.
Quick start
Point it at an MCP host config:
mcp-env-lint --config path/to/claude_desktop_config.json
Or run it with no --config and it auto-discovers known config locations
(see "Supported config formats" below).
Given this config:
{
"mcpServers": {
"weather": {
"command": "npx",
"args": ["-y", "@example/weather-mcp-server"],
"env": { "WEATHER_API_KEY": "${WEATHER_API_KEY}", "REGION": "us-east" }
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
$ mcp-env-lint --config claude_desktop_config.json
Server: weather (command: npx)
[OK ] WEATHER_API_KEY
[OK ] REGION
[WARNING ] WEATHER_API_KEY looks like an unexpanded placeholder (e.g. ${VAR}); most hosts pass env values through literally, not expanded
Server: filesystem (command: npx)
(no declared env vars)
The WEATHER_API_KEY value round-trips fine at the OS-propagation level
(hence [OK]), but the placeholder warning tells you Claude Desktop will
hand the server the literal string ${WEATHER_API_KEY}, not an expanded
key — which is a real, separate bug the var-diff alone wouldn't catch.
CLI usage
mcp-env-lint # auto-discover known config paths
mcp-env-lint --config path/to/config.json # lint one config
mcp-env-lint --config a.json --config b.json # lint multiple configs (repeatable flag)
mcp-env-lint --spawn-mode replace --config c.json # test the "host drops PATH" hypothesis
mcp-env-lint --json --config c.json # machine-readable output
mcp-env-lint --timeout 5 --config c.json # per-server probe timeout (default 10s)
--json emits an array of per-server objects:
[
{
"name": "weather",
"command": "npx",
"command_resolvable": true,
"probe_error": null,
"placeholder_warnings": ["WEATHER_API_KEY"],
"vars": [
{"key": "WEATHER_API_KEY", "declared_value": "${WEATHER_API_KEY}", "actual_value": "${WEATHER_API_KEY}", "status": "clean"},
{"key": "REGION", "declared_value": "us-east", "actual_value": "us-east", "status": "clean"}
],
"has_issues": false
}
]
--spawn-mode replace example, where the declared config doesn't include
PATH and the command only resolves via a directory the host would have
added to the inherited environment:
$ mcp-env-lint --config myserver.json # merge (default): inherits PATH
Server: myserver (command: myserver)
[OK ] REGION
$ mcp-env-lint --config myserver.json --spawn-mode replace # replace: PATH dropped
Server: myserver (command: myserver)
WARNING: command not resolvable on PATH in the constructed environment
[OK ] REGION
Exit codes (for CI)
0— no config errors, no servers with issues.1— at least one server has amissing/mismatchvar, or its command isn't resolvable in the constructed environment, or the probe itself errored.2— no config found (no--configgiven and nothing auto-discovered), or every given--configpath failed to parse.
Note: placeholder warnings (diagnostic 1) are reported in both the table and
--json output but do not by themselves change the exit code or
has_issues — only var status (missing/mismatch), an unresolvable
command, or a probe error do. If you want CI to fail on placeholder
warnings specifically, check placeholder_warnings in the --json output
rather than relying on the exit code alone.
Supported config formats
Any file with a top-level {"mcpServers": {name: {command, args, env}}}
object — the shape used by Claude Desktop's claude_desktop_config.json,
Claude Code's project-level .mcp.json, and generic mcp.json files that
reuse the same schema. Server entries declared with a url (remote/SSE
servers, no local subprocess) are skipped — there's no subprocess env to
inspect for those.
With no --config given, known default locations are auto-discovered:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json— best-effort guess. There is no official Linux build of Claude Desktop; this is the community/XDG-convention path, not a documented one. ./.mcp.jsonand./mcp.jsonin the current directory (Claude Code project config / generic).
Known limitations
The env-propagation probe constructs the environment a host would hand
the subprocess (per the declared config and the chosen --spawn-mode) and
launches a small Python probe script into that constructed environment to
see what actually lands there. It does not hook into a real running
host process (Claude Desktop, Claude Code, etc.) — doing so would mean
starting a live MCP server (network installs via npx, a stdio handshake
to wait on) and leaving a process running behind it, which is out of scope
for an env-propagation check.
Practically, that means:
- It validates propagation at the OS/subprocess-library level — the
execve-adjacent mechanism every host's spawn call ultimately bottoms out in (merge= declared vars layered on the inherited environment,replace= declared vars only). It cannot reproduce a bug specific to a particular host's own config-loading or spawn code (e.g. a field-mapping bug in that host's JSON parsing) — only the propagation mechanism itself. --spawn-mode replacetests a hypothesis (that a host behaves like "replace" rather than "merge") against your config; it does not confirm that any specific host actually does this. Treat areplace-mode failure as "worth checking against the real host's behavior," not as a confirmed live bug.- With the built-in probe,
missing/mismatchvar statuses will rarely trigger in practice, because the constructed environment always contains the declared values verbatim before probing — there's no host in the loop that could drop or rewrite them. Theenv_providerparameter on the Python API (capture_actual_env(..., env_provider=...), not exposed on the CLI) exists somissing/mismatchcan be exercised against a real, host-specific capture mechanism if you build one; the shipped CLI only ever produces the constructed-environment result described above. - The probe never executes the declared
command/argsthemselves (nonpxinstall, no server handshake) — only whethercommandresolves onPATHin the constructed environment is checked, viashutil.which.
Development
pip install -e ".[test]"
pytest
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mcp_env_linter-0.1.0.tar.gz.
File metadata
- Download URL: mcp_env_linter-0.1.0.tar.gz
- Upload date:
- Size: 17.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3d55ca3a2ae70716e9d97a56ad894ec8769daba472b1762aff2c977372ceb84a
|
|
| MD5 |
40a4e2d934306ffe82fa86d272dfc2ca
|
|
| BLAKE2b-256 |
4b9ed8b0ba451a0a6223f4b06782f722de6df2fa88065faff09da17df9669f3c
|
File details
Details for the file mcp_env_linter-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mcp_env_linter-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f4cf6c5a2162b8536917a2f22d193b259bbc321c603b0cfcbf8d110f6e2eb2b2
|
|
| MD5 |
26be055c7c99f07fcdf0601c517dc4ba
|
|
| BLAKE2b-256 |
2b0c8330bda61cdc92873c2edced44eeb20cc3ea5f42bac0aa7eece8b9c67159
|