Static linter for MCP (Model Context Protocol) server configs. Every rule is pinned to a real MCP CVE/GHSA with a biting fixture.
Project description
mcp-lint
Published on PyPI/CLI as mcp-cve-lint — the name mcp-lint was already
taken by an unrelated package. The GitHub repo keeps its original name. (No
PyPI badge yet — see RELEASING.md, publish is still pending.)
A static linter for MCP (Model Context Protocol) server configs and source
where every rule is pinned to a real, public MCP CVE or GHSA. Each finding
message carries the CVE id and an advisory URL so a reviewer can verify the
provenance in one click; each rule ships with a fixture that proves it actually
bites the documented vulnerable shape (build-honesty: no silent no-ops), and a
corpus gate pins finding counts over real MCP packages so a checker change that
drifts them fails CI before release. The pattern is the one proven out by
wildlint -- provenance-pinned lint,
fail-loud exit codes, corpus-gated -- transposed to MCP security, where the
surface is moving fast and the configs that wire agents to servers are the new
privilege boundary.
This is v0.2: two rules across two surfaces (JSON config + Python source), CVE-pinned, corpus-gated. It is not a complete MCP security scanner; the JS/TS source surface and the control-flow-sensitive rules are the next bricks. See Honesty note below.
Install
pip install mcp-cve-lint # or, from a clone: uv tool install . / pip install -e .
mcp-cve-lint --version
Use
mcp-cve-lint .mcp.json # lint one JSON config (MC001)
mcp-cve-lint server.py # lint one Python source file (MC002)
mcp-cve-lint . # walk a tree for *.json + *.py
mcp-cve-lint --format json . # machine-readable
mcp-cve-lint --select MC002 src/ # run one rule
Exit codes are ruff-compatible: 0 clean, 1 findings present, 2 errors only (e.g. an unparsable JSON file) with no findings.
Rules
MC001 -- unauth-network-server -- default tier
An mcpServers entry whose url is on a non-loopback host and whose entry
carries no authentication header. Maps to the documented root cause of:
CVE-2025-49596 -- MCP Inspector unauthenticated remote code execution (fixed in 0.14.1). Versions of MCP Inspector below 0.14.1 are vulnerable to remote code execution due to lack of authentication between the Inspector client and proxy.
The Inspector proxy exposed an HTTP endpoint with no auth between client and proxy; any network-reachable caller could drive it to spawn MCP servers and execute commands over stdio. MC001 catches the user-controllable half of that shape at config level.
Bites (red fixture: tests/fixtures/mc001_vulnerable.json):
{
"mcpServers": {
"inspector": { "url": "http://0.0.0.0:6277/sse" }
}
}
$ mcp-cve-lint tests/fixtures/mc001_vulnerable.json
tests/fixtures/mc001_vulnerable.json:4:6: MC001 MCP server 'inspector' exposes a
network endpoint (http://0.0.0.0:6277/sse) on a non-loopback host (0.0.0.0) with
no authentication header; this is the documented shape of CVE-2025-49596 ...
Stays silent (green fixtures): loopback binds (127.0.0.1 / ::1 /
localhost), entries with headers.Authorization / X-API-Key / X-Auth-Token
/ Cookie, or stdio entries (no url). See tests/fixtures/mc001_safe.json
and tests/fixtures/mc001_with_auth.json.
MC002 -- unsanitized-stdio-command -- default tier (Python source)
A StdioServerParameters(command=<expr>) call whose command is not a string
literal and whose enclosing scope has no allowlist guard. Maps to the
documented root cause of:
CVE-2026-30623 -- LiteLLM authenticated remote code execution via MCP stdio transport (fixed in v1.83.6-nightly / v1.83.7-stable, commit
7b7f304). The application let users add MCP servers via JSON config with arbitrarycommand/args; LiteLLM executed these on the host without validation.
- Tenable: https://www.tenable.com/cve/CVE-2026-30623
- LiteLLM advisory: https://docs.litellm.ai/blog/mcp-stdio-command-injection-april-2026
- OX Security cross-ecosystem advisory (10+ CVE family, same root cause): https://www.ox.security/blog/mcp-supply-chain-advisory-rce-vulnerabilities-across-the-ai-ecosystem/
The Anthropic MCP SDK's StdioServerParameters runs whatever command it is
handed. LiteLLM (and LangFlow, GPT Researcher, Agent Zero, LangBot, Bisheng,
Jaaz, Langchain-Chatchat, Fay -- the whole OX Security CVE family) passed
user-supplied JSON command/args straight through. The LiteLLM fix added
MCP_STDIO_ALLOWED_COMMANDS = frozenset({"npx","uvx","python","python3","node", "docker","deno"}) and validates os.path.basename(command) in ALLOWED before
construction. MC002 catches that shape in Python source: the sink with no guard.
Bites (red fixture: tests/fixtures/mc002_vulnerable.py):
from mcp.client.stdio import StdioServerParameters
def build_server(user_config):
command = user_config["command"] # attacker-controlled
return StdioServerParameters(command=command, args=[]) # MC002
$ mcp-cve-lint tests/fixtures/mc002_vulnerable.py
tests/fixtures/mc002_vulnerable.py:6:48: MC002 StdioServerParameters constructed
with a non-literal `command` and no allowlist guard in scope; this is the
documented shape of CVE-2026-30623 ...
Stays silent (green fixtures):
- Module-level frozenset allowlist (the LiteLLM 7b7f304 fix shape) --
tests/fixtures/mc002_fixed_allowlist.py - Inline string-set allowlist (
if cmd not in {"npx", ...}) --tests/fixtures/mc002_fixed_inline.py - Literal command (
command="npx", positional or keyword; f-strings with no interpolation) --tests/fixtures/mc002_safe_literal.py validate/check-named guard function in scope
Recognised guards: an in/not in test against a string set/frozenset, a
module-level constant whose assignment is a string set, a name matching
ALLOW|COMMAND|STDIO|MCP|TOOL|PERMIT|WHITELIST, or a call to a
validate/check/sanitize-named function. Honesty caveat: the guard scan is
scope-wide, not path-sensitive (a guard on any branch silences the rule) --
this trades a few false negatives for near-zero false positives on the corpus;
a PEDANTIC tier (v0.3) can tighten to "the guard dominates the call's branch."
Corpus gate
scripts/corpus_diff.py pins finding counts over a real-world MCP-package
corpus and fails on drift. The v0.2 corpus:
| package (pinned) | version | MC001 | MC002 |
|---|---|---|---|
mcp (Anthropic SDK -- defines the sink) |
1.28.1 | 0 | 0 |
fastmcp (framework) |
3.4.2 | 0 | 1 |
langchain-mcp-adapters (adapter) |
0.3.0 | 0 | 1 |
The two MC002 findings in fastmcp and langchain-mcp-adapters are
true-positives-at-the-sink: each constructs
StdioServerParameters(command=<param>) with no allowlist, exactly the
CVE-2026-30623 shape. Both are transport/framework layers (OX Security lists
them under "won't patch -- caller's responsibility"), so the finding is real
but the responsibility is delegated; the count is stable and the gate's job is
to catch a checker change that moves it, not to judge the framework's design.
uv run python scripts/corpus_diff.py # compare to baseline; exit 1 on drift
uv run python scripts/corpus_diff.py --update # rewrite baseline (intended drift only)
The gate runs in CI (.github/workflows/ci.yml, the corpus job).
Provenance tiers
Inherited from wildlint. Every rule declares a tier:
default-- low false-positive; on unless deselected. MC001 is default.pedantic-- higher false-positive; opt-in via--pedantic. Reserved for rules whose class is real but whose cost on a real corpus is not yet measured.
Bug classes pinned to a real CVE but not yet shipped are documented in
DEFERRED in src/mcp_lint/checkers.py -- the CVE provenance is kept so a
future rule can pick it up once its detector design and false-positive
measurement land. v0.1 ships exactly one rule, on one surface (JSON config).
Honesty note
- Two rules, two surfaces. v0.2 lints JSON MCP config (MC001) and Python source (MC002). The TypeScript/JavaScript source surface (where the mcp-remote client lives) is the next brick; the wildlint ast-grep multi-language pack is the template for the JS/TS arm.
- MC001 flags the operator-controllable half of CVE-2025-49596. The 0.14.1
fix ships authentication inside the Inspector binary, so even a loopback
bind was vulnerable pre-fix (DNS-rebinding / browser-origin confusion). MC001
does not clear an entry as safe; it flags the missing credential on a
non-loopback bind. The loopback-no-auth arm is held -- see the
DEFERREDentry insrc/mcp_lint/checkers.pyfor the full FP reasoning (a config-level rule cannot distinguish "Inspector <0.14.1 on loopback" from "any innocent local dev server on loopback", and the latter is the canonical quickstart shape; the corpus cannot size it either because MCP libraries ship no consumer.mcp.json). - MC002 flags the sink, not the responsibility. The rule fires wherever
StdioServerParameters(command=<expr>)has no guard in scope, including in framework/transport code that has explicitly declined to add an allowlist (OX Security's "won't patch" list). The finding is real at the sink; whether the framework should guard is a design question the rule does not answer. - CVEs are not invented; signatures are verified against the patch. v0.2
corrected three DEFERRED entries whose original CVE summaries did not match
the actual patched code. CVE-2026-30623 was described as "eval/exec on model
metadata" but is actually MCP stdio command injection (shipped as MC002
against the verified LiteLLM 7b7f304 signature). CVE-2026-27124 (FastMCP)
was described as "tool-name relay" but is actually missing OAuth-proxy
consent verification. When a CVE's summary and its patch diverge, the patch
is the source of truth. See
RELEASING.mdfor the full honesty protocol.
Roadmap
- MC001 loopback arm -- held; the
DEFERREDentry documents why a config-level PEDANTIC rule would fire on every innocent local-dev quickstart with no way to tell the dangerous case apart at this layer. - CVE-2025-6514 --
mcp-remoteOS command injection (JS/TS surface). The fix usesexecFile/argument arrays instead of a shell string; deferred until the ast-grep multi-language surface lands. - CVE-2026-27124 / GHSA-rww4-4w9c-7733 -- FastMCP OAuth-proxy missing consent verification. A control-flow omission rather than a syntactic sink; deferred until a control-flow-sensitive detector design lands.
- MC002 path-sensitivity -- a PEDANTIC tier that requires the guard to dominate the call's branch (not just be present in the scope).
CI
.github/workflows/ci.yml runs four jobs on every push and PR:
- test -- pytest matrix on Python 3.9 and 3.13
- lint --
ruff check+ruff format --check(ruff pinned to 0.6.9) - type --
mypy src/mcp_lint(strict) - corpus --
scripts/corpus_diff.py(drift vs baseline)
License
MIT.
Project details
Release history Release notifications | RSS feed
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_cve_lint-0.2.0.tar.gz.
File metadata
- Download URL: mcp_cve_lint-0.2.0.tar.gz
- Upload date:
- Size: 23.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6966b53f0e9a75c3bda5a472376dcfdf5c1a91994cd69af0bfbac84bb37aae1c
|
|
| MD5 |
3189ec1b6bf91aecdd3bf57bdebb515b
|
|
| BLAKE2b-256 |
f0edb27c3b776d769f9cc24bc9fc635cc607fc686318a18591aacc9c21554eac
|
File details
Details for the file mcp_cve_lint-0.2.0-py3-none-any.whl.
File metadata
- Download URL: mcp_cve_lint-0.2.0-py3-none-any.whl
- Upload date:
- Size: 20.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a64dfe9fa6ba4b163e0a7ecc0885e256a5f83767a79e13df2cb9c5a52aa6e4ed
|
|
| MD5 |
738b59f30713d54811410a1fa7ecef85
|
|
| BLAKE2b-256 |
d111d4c7c6a224e14138a141475bd3065b3771b8cb45ce4f1d6ea021c550b196
|