Skip to main content

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:

  1. ${VAR} / $VAR placeholder detection. Claude Desktop's env field 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.
  2. --spawn-mode replace PATH-drop detection. Some reported failures are consistent with a host passing an env object that replaces the inherited environment rather than merging into it, which drops PATH and breaks npx resolution for the launched server. --spawn-mode replace constructs 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 a missing/mismatch var, or its command isn't resolvable in the constructed environment, or the probe itself errored.
  • 2 — no config found (no --config given and nothing auto-discovered), or every given --config path 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.json and ./mcp.json in 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 replace tests 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 a replace-mode failure as "worth checking against the real host's behavior," not as a confirmed live bug.
  • With the built-in probe, missing/mismatch var 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. The env_provider parameter on the Python API (capture_actual_env(..., env_provider=...), not exposed on the CLI) exists so missing/mismatch can 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/args themselves (no npx install, no server handshake) — only whether command resolves on PATH in the constructed environment is checked, via shutil.which.

Development

pip install -e ".[test]"
pytest

License

MIT — see LICENSE.

Metadata

Release files for mcp-env-linter 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcp-env-linter 0.1.0
File Size Uploaded
mcp_env_linter-0.1.0.tar.gz 17.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcp-env-linter 0.1.0
File Interpreter ABI Platform
mcp_env_linter-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 32.2 kB

Release files / mcp_env_linter-0.1.0.tar.gz

Download URL mcp_env_linter-0.1.0.tar.gz
Size 17.9 kB
Tags Source
SHA-256 checksum
How to use checksums
3d55ca3a2ae70716e9d97a56ad894ec8769daba472b1762aff2c977372ceb84a
BLAKE2b-256 checksum
How to use checksums
4b9ed8b0ba451a0a6223f4b06782f722de6df2fa88065faff09da17df9669f3c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / mcp_env_linter-0.1.0-py3-none-any.whl

Download URL mcp_env_linter-0.1.0-py3-none-any.whl
Size 14.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f4cf6c5a2162b8536917a2f22d193b259bbc321c603b0cfcbf8d110f6e2eb2b2
BLAKE2b-256 checksum
How to use checksums
2b0c8330bda61cdc92873c2edced44eeb20cc3ea5f42bac0aa7eece8b9c67159
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page