Thanks for helping harden the MCP supply chain. mcp-warden is a security tool,
so contributions are held to a high bar: deterministic behavior, tests green, and
the design specs in docs/ kept authoritative. This guide gets you set up and
explains the rules that keep the gate trustworthy.
By participating you agree to the Code of Conduct. Never
report security vulnerabilities as public issues or PRs — follow
SECURITY.md.
mcp-warden requires Python ≥ 3.11. Use a project-local virtual environment.
# from a clone of this repo
uv venv .venv
uv pip install --python .venv/bin/python -e ".[dev]"
# the CLI is then available as:
.venv/bin/mcp-warden --helpWhy a project
.venv(and why we run tests through it). Run pytest with.venv/bin/python -m pytest, not a barepytest. Some contributors' system Python is polluted by an unrelated globalconftest.py(e.g. a numcodecs-style autouse fixture) that gets imported into every collection and breaks or skews unrelated test runs. The repo.venvis isolated from that global state, so it is the only supported way to run the suite reproducibly. CI installs into a clean job environment and runspytestdirectly; locally, always go through.venv.
uv is recommended but not required — python -m venv .venv && .venv/bin/pip install -e ".[dev]" works too.
# full suite (the supported path)
.venv/bin/python -m pytest -q
# a single file or test
.venv/bin/python -m pytest tests/test_hashing.py -q
.venv/bin/python -m pytest tests/test_guard_proxy.py::test_default_block -qThe headline tests spawn real MCP stdio fixture servers (pin → mutate →
check, and a live tools/call through the guard proxy), so the suite takes
~1 minute. That round-trip coverage is intentional — keep it.
A change is not done until .venv/bin/python -m pytest -q is fully green.
The guard's live runtime attack surface — the stdio JSON-RPC framer
(framing), the ANSI/control stripper (res_rules/res_catalog), the
exfil-domain matcher (res_net), and the secret redactor (redact) — is
property-fuzzed with hypothesis under
tests/fuzz/. A parser bug here is a silent inspection bypass (framing errors
fail OPEN by design), exactly the class hash-checking misses, so the properties
are construction-based with explicit liveness (a known-malicious input IS
detected/blocked) and soundness (the engine never invents, leaks, or
misclassifies) — a no-op implementation must FAIL them.
The fuzz suite runs in the normal pytest -q path (it lives under tests/).
Two hypothesis profiles are registered in tests/fuzz/conftest.py, selected by
HYPOTHESIS_PROFILE (default ci):
ci—max_examples=1000,derandomize=True,deadline=None, no example DB. Deterministic and source-replayable; this is what CI runs (with a fixed--hypothesis-seed=0). 1000 (not the hypothesis default of 100) because these guard a security boundary, not application behavior — each soundness/ liveness invariant needs a meaningful sample of the constructed-malicious + boundary input space, and 1000 keeps the whole suite under a minute.fuzz—max_examples=20000+ a persistent example DB. The deep local soak for hunting new counterexamples.
# deterministic ci-profile run (what CI does)
make fuzz-ci # == pytest tests/fuzz -p no:randomly --hypothesis-seed=0
# deep local soak (20k examples/property, persistent .hypothesis DB)
make fuzz # == HYPOTHESIS_PROFILE=fuzz pytest tests/fuzz -p no:randomly@example-freezing policy (mandatory). Every counterexample the fuzzer finds
during development MUST be frozen as a hypothesis @example(...) on the relevant
property before the fix lands, so it persists as a permanent regression even
outside the deep fuzz run (the ci profile uses no example DB). Never paper
over a finding with a blind xfail: a hypothesis failure is a real finding —
either the property models the contract wrong (fix the property) or it is a
genuine parser/redactor/framer bug (fix the production code in-scope if small,
else file an issue and freeze the @example with a tracking link).
The files under docs/ are the security contract — they define every
algorithm; the code merely satisfies them. The mapping from spec to module lives
in DOCUMENTATION_INDEX.md. When code and spec disagree,
the spec wins, and your PR must reconcile them.
mcp-warden's core guarantee is reproducibility: canonicalization is RFC 8785
(JCS) + SHA-256 (sha256:<hex>), so pin and check agree byte-for-byte, and
guard (live) and inspect (offline) run the identical rule catalog. A change
that silently shifts a hash, a canonical form, or a rule's verdict can break every
committed warden.lock in the wild and turn a passing gate into a false negative.
If your change touches any of the following, it must preserve byte-compatibility and update the corresponding spec in the same PR:
- Hashing / canonicalization —
src/mcp_warden/hashing.py, the field/entry/ overall digest construction, or null/ordering handling → spec:docs/WARDEN_LOCK_SCHEMA.md. - The
warden.lockschema (including the §11 per-tool inspection policy) → spec:docs/WARDEN_LOCK_SCHEMA.md. - The static-check catalog
WRD-*(capability / secret / supply / robustness), the shared tokenizer, or redaction → specs:docs/CHECKS.md. - The result-inspection catalog
WRD-RES-*(ANSI allowlist, secret-echo, exfil/URL host matching, injection seed phrases, tiers, severities) → spec:docs/RESULT_INSPECTION.md. - Drift semantics (what counts as added/removed/modified/identity drift and at
what severity) → spec:
docs/WARDEN_LOCK_SCHEMA.md. - The
guard/inspectblock posture, flag scheme, or reserved error codes (-32001/-32002) → specs:docs/GUARD_PROXY.md,docs/GUARD_PROXY_V3.md. - The policy model (shapes, fail-closed defaults, SSRF ranges)
→ spec:
docs/POLICY_MODEL.md.
Practical rules:
- Do not change a hash without intent. If a refactor changes a digest of an unchanged surface, that is a bug — fix the refactor, don't re-bless the fixtures.
guardandinspectmust stay in lockstep. Any rule change must produce identical findings on the same frames (tests/test_inspect_parity.pyguards this). Add to the shared catalog, never to one path only.- New behavior gets a new check ID, never a redefinition of an existing one.
ruleIds are emitted verbatim into SARIF that downstream users key on.
New WRD-* (static) or WRD-RES-* (result-inspection) checks are very welcome.
The path that gets a check merged:
- Open an issue first using the feature request template. Describe the threat, the MCP surface or tool-result shape it appears in, and why a deterministic rule can catch it with a ~0 false-positive rate. Fuzzy/heuristic detectors are accepted only in the monitor (log-only) tier, never as a default block.
- Reserve a stable ID in the right family (
WRD-CAP-*,WRD-SEC-*,WRD-SUP-*,WRD-RES-*). IDs are forever — pick a clear, specific name. - Spec it in the matching
docs/file: exact match definition, severity, tier (deterministic-block vs fuzzy-monitor), SARIF mapping, and any redaction. - Implement it in the appropriate module (see
DOCUMENTATION_INDEX.mdfor the module map) soguardandinspectshare the same evaluator. - Test it: a positive fixture, a negative/control fixture (no false positive),
and — for result rules — parity between
guardandinspect.
Detection rules must never weaken on real secrets to reduce noise. Tune precision with allowlists/anchoring, not by dropping coverage.
The composite action installs hash-locked dependencies from two lock files:
action/build-requirements.lock— hatchling + build backend (installed first)action/requirements.lock— full runtime dep closure
Both are generated with uv pip compile and must be regenerated whenever
pyproject.toml dependencies change or a lockfile refresh is needed as part
of a release. The exact command is printed at the top of each lockfile and
reproduced here for convenience:
# From the repo root — requires uv
uv pip compile --universal --generate-hashes pyproject.toml \
-o action/requirements.lock
uv pip compile --universal --generate-hashes action/build-requirements.in \
-o action/build-requirements.lockLockfile regeneration is a release gate — any PR that changes runtime deps
must include updated lockfiles. CI will reject installs whose hashes no longer
match (pip install --require-hashes fails on hash mismatch).
Before you open a PR, confirm:
- Tests pass via the repo venv:
.venv/bin/python -m pytest -qis green. - Specs updated. Any behavior change (hashing, canonicalization, the
WRD-*/WRD-RES-*catalogs, drift, block posture, policy) updates the correspondingdocs/spec in the same PR. Determinism / byte-compatibility is preserved (or the break is intentional, documented, and version-bumped). - No secrets. No real credentials anywhere in the tree. Test fixtures use
obviously-synthetic, clearly-fake placeholders (and the fixture paths are
already allowlisted in
.gitleaks.toml). - Docs in sync. README/CLI reference updated if user-facing behavior or
flags changed. The 3-core docs and
DOCUMENTATION_INDEX.mdstay accurate. - Scoped commits with a clear message describing the why.
Use the pull request template — it restates this checklist. Maintainers may ask for a fixture or spec diff before reviewing logic; that is normal for a security tool.
Thank you for keeping the gate honest.