CLI linter for MCP tool cards: schema quality, description clarity, side effects, and tool-poisoning risks.
Project description
MCP Tool Card Linter
MCP Tool Card Linter is a CLI quality gate for MCP servers. It discovers exposed tools, checks tool names, descriptions, inputSchema, outputSchema, icons, annotations, potential side effects, and tool-poisoning risks, then emits versioned machine reports and CI-friendly exit codes.
In this project, a "Tool Card" is an engineering concept: a reviewable bundle of an MCP tool's name, description, input/output schemas, behavior annotations, risk level, and recommendations. It is not a standalone object in the MCP specification.
Features
- Input sources: static tools JSON,
mcp.json, stdio MCP servers, and Streamable HTTP MCP endpoints. - Checks: complete JSON Schema 2020-12 metaschema validation plus bounded security/quality rules, icon validation, full model-visible metadata scanning, tool poisoning, hidden Unicode, embedded credentials, cross-server tool shadowing, rug-pull fingerprints, dangerous parameters, side-effect labeling, and annotation conflicts.
- Reports: report schema 1.1.0, deterministic JSON, Markdown, SARIF 2.1.0, JUnit XML, JSON Lines, GitHub annotations, stable rule metadata, RFC 8785 card fingerprints, field-level hash diffs, and approval/block suggestions.
- Policy: production/security/spec/strict/compatibility profiles, select/ignore, severity overrides, and suppressions that require a reason, owner, and expiry date.
- Defensive discovery: MCP 2025-11-25, 2025-06-18, and legacy 2025-03-26 negotiation, capability gating, strict stdio, bounded resumable Streamable HTTP/SSE, DNS rebinding detection, optional one-shot
tools/list_changedrefresh, bounded I/O/concurrency/retries, SSRF-aware URL policy, disabled redirects, minimal child environments, managed sandbox processes, and atomic private report files. - Safe execution and trust: default-deny local commands; Docker, Bubblewrap, Windows Job Object, and explicit unsandboxed host backends; Ed25519-signed baselines bound to publisher/server/source identity; and signed hash-chained append-only approval logs.
- Remote credentials: pre-issued Bearer tokens from environment/private files, custom CA bundles, explicit proxies, and mTLS; or MCP OAuth protected-resource/authorization-server discovery with Authorization Code, S256 PKCE, Resource Indicators, private single-use state, and private token output for a pre-registered public client.
- v1 production contracts: machine-readable report/rule/exit-code/protocol commitments, 1.0.0/1.1.0 report readers, a public labelled accuracy corpus, deterministic fuzz/input-mutation/stdio-soak gates, bit-for-bit wheel/sdist rebuild checks, signed release attestations, and minimal hash-chained operational audit events.
Quick Start
Install the CLI in an isolated environment:
pipx install mcp-tool-card-linter
mcp-toolsmith lint --tools-file path/to/tools.json
Alternatively, install it into an existing Python environment:
python3 -m pip install mcp-tool-card-linter
Run directly from a source checkout without installing the package:
PYTHONPATH=src python3 -m mcp_tool_card_linter lint \
--tools-file examples/bad-tools.json \
--json-report docs/sample-bad-report.json \
--markdown-report docs/sample-bad-report.md \
--fail-on error
Example stdio MCP server:
PYTHONPATH=src python3 -m mcp_tool_card_linter lint \
--stdio "python3 tests/fixtures/mock_mcp_stdio_server.py" \
--executor host \
--server mock \
--fail-on never
Example mcp.json:
PYTHONPATH=src python3 -m mcp_tool_card_linter lint \
--config examples/mcp.json \
--allow-config-execution \
--executor host \
--server mock \
--fail-on never
Streamable HTTP:
mcp-toolsmith lint --server-url https://example.com/mcp --server example
To wait for a declared notifications/tools/list_changed event and refresh the snapshot once:
mcp-toolsmith lint --server-url https://example.com/mcp --server example \
--refresh-on-list-changed 5
Authenticated Streamable HTTP without putting token text in process arguments:
export MCP_AUDIT_TOKEN='short-lived-resource-scoped-token'
mcp-toolsmith lint --server-url https://example.com/mcp \
--bearer-token-env MCP_AUDIT_TOKEN --server example
OAuth Authorization Code + PKCE for a pre-registered public client is a two-process-safe flow. The callback URL is deliberately read from an environment variable or private file so the authorization code is not placed in process arguments:
mcp-toolsmith authorize start \
--server-url https://example.com/mcp \
--client-id registered-public-client \
--redirect-uri https://client.example/callback \
--state-file ~/.config/mcp-toolsmith/oauth-state.json \
--scope tools.read
export MCP_CALLBACK_URL='https://client.example/callback?code=...&state=...'
mcp-toolsmith authorize complete \
--state-file ~/.config/mcp-toolsmith/oauth-state.json \
--callback-url-env MCP_CALLBACK_URL \
--token-file ~/.config/mcp-toolsmith/access-token
mcp-toolsmith lint --server-url https://example.com/mcp \
--bearer-token-file ~/.config/mcp-toolsmith/access-token
authorize start requires S256 support, uses the challenge scope before metadata fallback, includes
the canonical MCP resource in both requests, and refuses non-HTTPS authorization endpoints by
default. Dynamic Client Registration and refresh-token management are intentionally outside v1.0;
register the public client and exact redirect URI with the authorization server first.
Secure Discovery Defaults
- Local command execution defaults to
--executor none. Config commands additionally require--allow-config-execution. Use--executor docker --executor-image IMAGE, Linux--executor bubblewrap, or Windows--executor windows-job;--executor hostis an explicit compatibility choice without filesystem or network isolation. - stdio servers receive a small allowlist of ordinary environment variables by default.
--inherit-envis an explicit opt-in because a full parent environment often contains credentials. - stdio stdout is protocol-strict by default.
--compat-stdio-noiseis an explicit compatibility exception for reviewed legacy servers. - remote endpoints require HTTPS except for an explicitly supplied loopback
--server-url. Private/reserved destinations and loopback URLs loaded indirectly from config require--allow-private-network; non-loopback plain HTTP additionally requires--allow-insecure-http. Resolved IPv4/IPv6 sets are pinned and changes across requests/retries are refused. - HTTP redirects are refused. Response bodies, stdio messages, stderr history, pagination, retries, server count, worker count, schemas, descriptions, and tool counts all have hard limits.
- reports are written through a same-directory temporary file and atomic replacement. New report files default to owner-only permissions on POSIX.
- programmatic stdio discovery accepts an argument sequence, which avoids shell quoting ambiguity. The
--stdiostring uses POSIX shell tokenization on Unix and the native Windows command-line parser on Windows. - OAuth state and token files use mode
0600on POSIX. Windows uses the containing directory's inherited DACL, so keep those files in a directory restricted to the current user and administrators.
Docker uses no network, a read-only root, dropped capabilities, no-new-privileges, a bounded tmpfs, and CPU/memory/process limits. Bubblewrap uses an empty mount namespace, read-only runtime/workspace bindings, no network namespace, and prlimit where available. Windows Job Object applies process-tree lifetime, CPU, memory, and process-count limits, but does not provide filesystem or network isolation. Treat host as trusted-code-only.
Policy and rule catalog
Use list-rules --format json and explain RULE_ID to inspect stable machine-readable rule metadata. A policy can be a dedicated TOML file or the corresponding section of pyproject.toml:
[tool.mcp-tool-card-linter]
profile = "production"
select = ["INVALID_*", "TOOL_POISONING_*"]
ignore = ["DESCRIPTION_TOO_SHORT"]
fail-on = "error"
[tool.mcp-tool-card-linter.rules.URL_PARAMETER_ALLOWLIST_MISSING]
severity = "error"
[[tool.mcp-tool-card-linter.suppressions]]
server = "internal-search"
tool = "search_records"
rule = "CROSS_SERVER_TOOL_SHADOWING"
reason = "Gateway namespaces the exported name"
owner = "platform-security"
expires = 2026-12-31
Apply it with --policy pyproject.toml. Expired suppressions do not hide findings and are recorded in the report.
See examples/policy.toml for a standalone policy.
Machine report contracts
JSON reports declare report_schema_version: 1.1.0, a scan_id, tool version, policy, requested/negotiated protocol metadata, per-field hashes, and bounded field diffs. The bundled Draft 2020-12 schema is at src/mcp_tool_card_linter/schemas/report.schema.json.
Inspect the frozen v1 machine contract or validate a current/legacy report:
mcp-tool-card-linter contract
mcp-tool-card-linter validate-report --input report.json
v1 produces schema 1.1.0 and reads 1.0.0/1.1.0. Rule IDs are not repurposed, and CLI exit meanings are stable. See the complete stability policy.
mcp-toolsmith lint --tools-file tools.json --deterministic \
--json-report report.json --sarif-report report.sarif \
--junit-report report.xml --jsonl-report report.jsonl --format github
--deterministic fixes the timestamp and derives scan_id from content for reproducible output. Normal scans use a UTC timestamp and UUID. SARIF results include rule metadata, logical tool locations, JSON Pointer paths, and partial fingerprints.
Input Format
Static JSON supports the common tools shape:
{
"tools": [
{
"name": "search_customer_orders",
"description": "Search customer orders by customer email. Use for read-only lookup only.",
"inputSchema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Customer email address"
}
},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"orders": { "type": "array", "items": { "type": "object" } }
}
}
}
]
}
It also supports the MCP tools/list response shape:
{
"result": {
"tools": []
}
}
CI Exit Codes
0: no source error occurred and no finding reached the threshold (or findings were disabled with--fail-on never).1: at least one lint finding reached the configured severity threshold.2: input, discovery, network, stdio, or report-writing error.130: interrupted by the user.
The default threshold is --fail-on error. --fail-on never disables finding-based failure only; discovery/source errors still return 2. For production releases, use at least:
mcp-toolsmith lint --config ./mcp.json --allow-config-execution \
--executor docker --executor-image registry.example/reviewed-mcp-server@sha256:DIGEST \
--json-report mcp-tool-report.json --fail-on error --format none
Optimization Output
optimize generates tool-exposure policy suggestions from a JSON lint report:
PYTHONPATH=src python3 -m mcp_tool_card_linter optimize \
--input-report docs/sample-bad-report.json \
--output docs/sample-optimized.json
Each output decision is one of:
include_by_defaultrequire_approvalblock_until_review
Signed Rug-pull Baselines
Every JSON report contains an RFC 8785 + SHA-256 fingerprint of each complete tool definition and SHA-256 fingerprints for bounded JSON Pointer leaves. Create an Ed25519 trust root, approve a report, and append an independently reviewable hash-chain record:
mcp-toolsmith baseline keygen \
--private-key baseline.key --public-key baseline.pub
mcp-toolsmith baseline approve \
--report approved-report.json --output approved-baseline.json \
--private-key baseline.key --publisher example-org \
--server-identity example-org/production-mcp@1 \
--approved-by security-reviewer --approval-log approvals.jsonl
mcp-toolsmith baseline verify \
--baseline approved-baseline.json --public-key baseline.pub \
--approval-log approvals.jsonl
Compare fresh discovery with that signed baseline:
mcp-toolsmith lint \
--tools-file current-tools.json \
--server production \
--baseline-report approved-baseline.json \
--baseline-public-key baseline.pub \
--require-signed-baseline \
--expected-publisher example-org \
--expected-server-identity example-org/production-mcp@1 \
--json-report current-report.json \
--fail-on error \
--format none
Changed cards emit TOOL_CARD_CHANGED, report added/removed/changed JSON Pointer paths without disclosing raw values, and are blocked pending review. Identity, endpoint/capability/server-info, or publisher drift has distinct identity_changed/publisher_changed states. Unsigned legacy reports remain readable for migration, but an otherwise unchanged match is marked baseline_untrusted; use --require-signed-baseline in production.
Operational audit log
Lint and OAuth operations can append allowlisted, credential-free fields to a private hash-chained JSONL log. Both options are required together; an audit write failure is an operational failure:
mcp-tool-card-linter lint --tools-file tools.json --fail-on error --format none \
--audit-log audit.jsonl --audit-actor ci:production
mcp-tool-card-linter audit verify --log audit.jsonl
The log uses an exclusive cooperating-writer lock, O_APPEND, fsync and mode 0600 on POSIX. A local
hash chain detects modification but is not an authenticity or availability boundary against an
administrator who can rewrite, recompute or delete the file; forward it to WORM/transparent storage.
Accuracy and adversarial testing
Run the public explicitly-labelled corpus and its production threshold:
mcp-tool-card-linter evaluate \
--corpus evaluation/rule_accuracy_v1.jsonl \
--min-precision 0.95 --min-recall 0.95
The v1 corpus has 12 cases and 21 labelled rule/case pairs. The current scoped precision, recall and F1 are 1.0, with no missed or unexpected labelled result. This is a synthetic regression score, not a claim about all rules or production prevalence; see the accuracy report.
CI separately executes deterministic parser/tool fuzzing, security input mutations, a 40-cycle stdio process-lifecycle soak, the uninstrumented performance budget and two byte-compared package builds.
Release verification
Release wheel/sdist artifacts have SHA-256 checksums, reproducible CycloneDX SBOMs, signed GitHub/Sigstore provenance and PyPI Trusted Publisher attestations. Verification and local reproduction commands are in RELEASE_VERIFICATION.md. A valid signature establishes digest, publisher/build identity and provenance—not vulnerability freedom.
Notable Security Rules
- Tool metadata: injection/coercion text across descriptions, titles, schemas, examples/defaults/enums and keys; hidden/bidirectional Unicode; opaque encoded blobs; credential-like literals.
- Multi-server integrity: duplicate names within a server, cross-server same-name shadowing, deterministic card fingerprints, and baseline change detection.
- Input schemas: external
$ref, malformed types/composition/bounds/annotations, unrestricted extra properties, unbounded arrays/strings, permissive or nested-quantifier regexes, and unconstrained command/URL/path/secret fields. - Behavior claims: write/destructive/financial/network/filesystem/code-execution risk, missing approval/side-effect boundaries, and contradictory or invalid MCP annotation hints.
References
- MCP tools specification
- MCP transports specification
- MCP authorization specification
- JSON Schema Draft 2020-12
- GitHub SARIF support
- MCP security best practices
- RFC 8785 JSON Canonicalization Scheme
- RFC 8032 Ed25519
- Docker run security/resource flags
- Windows Job Objects
- OWASP MCP Security Cheat Sheet
- OpenAI MCP and connectors guide
- MCP tool description smells paper
- MCP tool poisoning threat modeling paper
Project Documents
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_tool_card_linter-1.0.0.tar.gz.
File metadata
- Download URL: mcp_tool_card_linter-1.0.0.tar.gz
- Upload date:
- Size: 104.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0f43c155f2ce6474cc04474cd430c0bf275d87e0f35df74037f8fededceb6599
|
|
| MD5 |
b0aaf04ffbdcad3edec7d0edf0e8c5bf
|
|
| BLAKE2b-256 |
3f451a0108827e0d23fd1a7a5d8d47b1362ee7ece17be2faad51c51c2fc00fa3
|
Provenance
The following attestation bundles were made for mcp_tool_card_linter-1.0.0.tar.gz:
Publisher:
release.yml on inostarlin-passion/MCP-Tool-Card-Linter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcp_tool_card_linter-1.0.0.tar.gz -
Subject digest:
0f43c155f2ce6474cc04474cd430c0bf275d87e0f35df74037f8fededceb6599 - Sigstore transparency entry: 2171548505
- Sigstore integration time:
-
Permalink:
inostarlin-passion/MCP-Tool-Card-Linter@75de0634d6e281bd36c3f083286fd59fbbdca7a7 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/inostarlin-passion
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@75de0634d6e281bd36c3f083286fd59fbbdca7a7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file mcp_tool_card_linter-1.0.0-py3-none-any.whl.
File metadata
- Download URL: mcp_tool_card_linter-1.0.0-py3-none-any.whl
- Upload date:
- Size: 106.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
70b0e368331e1557fbf9f21ab71548ae7dcf9c92807029dae57f7edc0c32e1f7
|
|
| MD5 |
b48e85e147b0ab176845cb8569fcfd6d
|
|
| BLAKE2b-256 |
dbf39cfcbcd7f6c70478522dcc2752a7db26d598f43318a69a409882aeef0c18
|
Provenance
The following attestation bundles were made for mcp_tool_card_linter-1.0.0-py3-none-any.whl:
Publisher:
release.yml on inostarlin-passion/MCP-Tool-Card-Linter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcp_tool_card_linter-1.0.0-py3-none-any.whl -
Subject digest:
70b0e368331e1557fbf9f21ab71548ae7dcf9c92807029dae57f7edc0c32e1f7 - Sigstore transparency entry: 2171548524
- Sigstore integration time:
-
Permalink:
inostarlin-passion/MCP-Tool-Card-Linter@75de0634d6e281bd36c3f083286fd59fbbdca7a7 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/inostarlin-passion
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@75de0634d6e281bd36c3f083286fd59fbbdca7a7 -
Trigger Event:
push
-
Statement type: