mcplint
English | 简体中文
Turn mcp.json into a readable, testable, PR-blocking permission policy — before you run any MCP server. Local, zero dependencies, no model key, no Docker.
pip install mcp-policy-lint # the PyPI distribution; the command is mcplint
mcplint demo # 30 seconds, no network, no API key, no Docker
# from a checkout instead: pip install -e .
mcplint demo — 2 servers scanned, 6 findings
no network, no API key, no Docker
(vs sample baseline: 3 drift rows — run `mcplint diff` to inspect)
mcplint 0.1.2 — 6 findings
ERROR MCP003 remote /mcpServers/remote/url Plaintext HTTP to a remote endpoint
http://api.example.com/mcp has no TLS, so tool descriptions and responses can be altered in transit
fix: Use https:// and confirm the authentication settings
ERROR MCP001 shell /mcpServers/shell/command stdio command can start a shell
shell runs its arguments through sh, so it can execute arbitrary commands
fix: Call a concrete executable directly instead of assembling a command with -c
ERROR MCP002 shell /mcpServers/shell/env/AWS_SECRET_ACCESS_KEY Likely real credential in config
AWS_SECRET_ACCESS_KEY is not a placeholder; it can leak through logs or travel with the repository
fix: Reference an environment variable or a secret manager; never put the value in mcp.json
ERROR MCP004 shell /mcpServers/shell/roots/0 root outside the repository boundary
root /home/me points at a home directory or escapes the repository
fix: Narrow roots to the current project directory
WARN MCP005 shell /mcpServers/shell/tools/0 Tool annotation contradicts its name or description
delete_everything claims read-only or non-destructive, but its description contains write verbs
fix: Fix the annotation or the name; a client may skip confirmation because of it
WARN MCP006 shell /mcpServers/shell/tools/0 Tool input has no constraints
delete_everything has an empty inputSchema or no properties, so argument boundaries cannot be checked
fix: Complete the JSON Schema: declare required fields and enums
exit code: 1
mcplint 0.1.2 coverage — 1 config(s), 2 server(s)
config server transport modeled unmodeled
--------------- ------ --------- ---------------------------- ---------
unsafe.mcp.json shell stdio command,args,env,roots,tools -
unsafe.mcp.json remote remote url -
unmodeled fields: 0 — rules depending on them report unknown, not pass
unknown findings: 0
gate: --unknown error (default) fails the build on unknown
Three-step workflow
# 1. Record an approved baseline (commit it)
mcplint init examples/safe.mcp.json --out policy.lock.json
# 2. Scan any client config
mcplint scan examples/unsafe.mcp.json --format ndjson
# 3. CI gate: drift or findings -> non-zero exit code
mcplint check examples/unsafe.mcp.json --baseline policy.lock.json --strict
scan renders text / ndjson / json / html; --html-out report.html writes a single self-contained file (no external assets, opens straight from file://). diff prints an add / remove / modify table on its own.
Unknowns cost an exit code
Anything the tool cannot understand is reported as unknown, never quietly counted as a pass. Three cases all pay: an unrecognized file shape (MCP000), a field that is not modeled (MCP009), a transport that cannot be inferred (MCP008).
mcplint check mcp.json # default --unknown error: fail rather than guess
mcplint check mcp.json --unknown warn # downgrade to warn (still gated by --strict)
mcplint check mcp.json --unknown ignore # say explicitly that you do not care
mcplint coverage mcp.json # which fields this run actually consumed
coverage is the one command that volunteers what it failed to understand: per server, the fields it modeled, the fields it did not, and the unknown findings that follow from them.
Verify it yourself, do not take my word
scripts/gate_selftest.py writes down which input must produce which exit code — 10 fixtures plus one drift case, zero dependencies, no install:
python scripts/gate_selftest.py
fixture argv want got
safe.json (default) 0 0 ok
unknown-shape.json (default) 1 1 ok
unmodeled-transport.json (default) 1 1 ok
unmodeled-transport.json --unknown warn 0 0 ok
unmodeled-transport.json --unknown ignore 0 0 ok
windsurf-serverurl-http.json (default) 1 1 ok
cline-autoapprove.json (default) 0 0 ok
cline-autoapprove.json --strict 1 1 ok
cline-autoapprove-secret.json (default) 1 1 ok
localhost-http.json (default) 0 0 ok
cline-autoapprove-plus.json (drift) 2 2 ok
11/11 cases match
That table is the whole claim of this document: a config it cannot read fails (1), a baseline drift is caught (2), and plaintext HTTP on localhost is not a false positive (0). Change the severity of any rule and this table goes red first.
What it is not
| does not | who does |
|---|---|
| run MCP servers, sandbox them | ToolHive |
| scan server source for malware | mcp-scanner |
| proxy traffic, RBAC, JWT | agentgateway |
| use an LLM as judge, judge prompt injection | — |
| edit configs, auto-approve anything | — |
It answers three questions only: what permissions does the config declare, does that violate a least-privilege baseline, and what did this PR change. unknown is not safe, and every finding carries its JSON Pointer.
Exit codes
| code | meaning |
|---|---|
| 0 | pass |
| 1 | at least one error (including an unknown treated as error; warnings too under --strict) |
| 2 | drift from the policy baseline / incompatible schema |
| 3 | usage or config error (missing file, corrupt baseline) |
Priority is 3 > 2 > 1 > 0, designed to be passed straight to sys.exit() in GitHub Actions.
Adding a rule is one function
from mcplint.rules import register
from mcplint.model import Finding
@register("MCP100")
def no_root_ssh(server):
if any(".ssh" in r for r in server.roots):
return [Finding("MCP100", "error", "root points at .ssh",
f"{server.name} exposes the SSH directory", server.name,
server.pointer + "/roots", "narrow roots to the repository")]
return []
The rule registry, policy format, report renderers and config discovery are independent modules — see ARCHITECTURE.md.
Use it as a GitHub Action
- uses: ravenkernel/mcplint@v1
with:
path: . # file or directory
baseline: policy.lock.json # optional; omit to only report findings
unknown: error # error (default) | warn | ignore
strict: false
The action installs mcp-policy-lint, runs mcplint check, and fails the job with mcplint own exit code — the same 0/1/2/3 you see locally. It writes nothing but the baseline you ask for, and it never edits a config file.
| input | default | effect |
|---|---|---|
path |
. |
config file or directory to check |
baseline |
empty | turns on drift detection against policy.lock.json |
unknown |
error |
price of a field mcplint cannot model |
strict |
false |
warnings become failures |
extra-args |
empty | appended to mcplint check |
version |
>=0.1.2,<0.2 |
pip requirement for the package |
Status
0.1.3 ships the GitHub Action above and nothing else — same rules, same exit codes — so the PyPI page documents the way most teams will actually run it. 0.1.2: runs end to end, 9 built-in rules (MCP001-006, MCP008, MCP009, MCP010), covered by tests at both the CLI and library level. Lint messages are English as of 0.1.1 (0.1.0 shipped the same rules with Chinese messages). 0.1.2 is the first PyPI release, and it turns the crashes and false positives I found by hand into tests: a field with the wrong type is MCP009 instead of an exception, a server name containing / produces a valid pointer (a~1b), two servers with the same name are MCP011, git -c user.name=x is not read as a shell, and a mistyped --rule id or flag exits 3 instead of passing silently.
Config compatibility currently covers three top-level shapes (mcpServers / servers / mcp.servers) and the common fields of Claude Desktop, Cursor, VS Code, Cline and Windsurf. What is not covered is never reported as a pass: an unmodeled field always produces MCP009, and --unknown (error by default) decides whether it blocks the merge; mcplint coverage prints the full field-consumption table.
No sandbox, no gateway, no prompt-injection detection — those belong to the runtime layer. No claim of "secure by default".
Metadata
Release files for mcp-policy-lint 0.1.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| mcp_policy_lint-0.1.3.tar.gz | 26.5 kB | Without provenance | Details for mcp_policy_lint-0.1.3.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| mcp_policy_lint-0.1.3-py3-none-any.whl | Python 3 | none | any | Details for mcp_policy_lint-0.1.3-py3-none-any.whl |
Total release size: 53.5 kB
Release files / mcp_policy_lint-0.1.3.tar.gz
| Download URL | mcp_policy_lint-0.1.3.tar.gz |
|---|---|
| Size | 26.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ad410c66a765247658b5aef08aa8596ca444027bdf7751aff117d6b829108094
|
|
BLAKE2b-256 checksum How to use checksums |
eb6d6171ca714afaaa43b4e3e6d1466e3c12d6683078af6a6007d71e2337fddf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.7
|
Release files / mcp_policy_lint-0.1.3-py3-none-any.whl
| Download URL | mcp_policy_lint-0.1.3-py3-none-any.whl |
|---|---|
| Size | 27.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
833740d095dee89539570cdc3404444324838b3f5f86a192ddb198419c55a936
|
|
BLAKE2b-256 checksum How to use checksums |
6d2310cbb30fd25c7b39d35375579424dbc1a3b1d8c49a2fec7d067f00a858ad
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.7
|
Security for mcp-policy-lint 0.1.3
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.