Metadata-Version: 2.4
Name: mcp-env-linter
Version: 0.1.0
Summary: Diffs declared MCP server env vars (per host config) against what actually reaches the launched subprocess.
License: MIT
Project-URL: Homepage, https://github.com/mrlarrylv/mcp-env-linter
Project-URL: Repository, https://github.com/mrlarrylv/mcp-env-linter
Project-URL: Issues, https://github.com/mrlarrylv/mcp-env-linter/issues
Keywords: mcp,model-context-protocol,env,linter,diagnostics,claude-desktop
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# 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:

```bash
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:

```bash
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:

```json
{
  "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

```bash
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:

```json
[
  {
    "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

```bash
pip install -e ".[test]"
pytest
```

## License

MIT — see [LICENSE](LICENSE).
