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.jsonbest-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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mcp_env_linter-0.1.0.tar.gz (17.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mcp_env_linter-0.1.0-py3-none-any.whl (14.3 kB view details)

Uploaded Python 3

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

Hashes for mcp_env_linter-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3d55ca3a2ae70716e9d97a56ad894ec8769daba472b1762aff2c977372ceb84a
MD5 40a4e2d934306ffe82fa86d272dfc2ca
BLAKE2b-256 4b9ed8b0ba451a0a6223f4b06782f722de6df2fa88065faff09da17df9669f3c

See more details on using hashes here.

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

Hashes for mcp_env_linter-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f4cf6c5a2162b8536917a2f22d193b259bbc321c603b0cfcbf8d110f6e2eb2b2
MD5 26be055c7c99f07fcdf0601c517dc4ba
BLAKE2b-256 2b0c8330bda61cdc92873c2edced44eeb20cc3ea5f42bac0aa7eece8b9c67159

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page