An MCP server (FastMCP, stdio) that makes mutation testing with mutmut a first-class workflow. Exposes structured tools for running mutations, reading results, computing scores, and tracking score history.
- Python 3.11+
- uv (for isolated server venv)
mutmut >= 3.5.0installed in the target project (uv add mutmut --dev)pytestin the target project
- This repository supports
uvonly. pipworkflows are not supported for development, CI, or release tasks.
pymutant is a controller around mutmut, not a mutation engine.
- Runtime parity: execute in the target project's environment (same Python/deps/shims used by CI).
- Deterministic outputs: all MCP tools return structured JSON envelopes and schema-versioned artifacts.
- Failure separation: mutation-quality outcomes (
killed/survived/no_tests) are distinct from execution instability (timeout/segfault/interruption/tooling errors). - Policy-first gating: enforce absolute floors and baseline-drop policies via profiles and policy checks.
claude mcp add pymutant -- uvx pymutantOr add to your project's .mcp.json:
{
"mcpServers": {
"pymutant": {
"command": "uvx",
"args": ["pymutant"]
}
}
}Add to claude_desktop_config.json:
{
"mcpServers": {
"pymutant": {
"command": "uvx",
"args": ["pymutant"]
}
}
}uv run pymutant --project-root .Commands live in .claude/commands/ and are discoverable by Claude Code.
Full end-to-end mutation testing workflow:
- Run
mutmut runon your project (skip with--no-rerunto use existing results) - Compute and display mutation score
- Show all surviving mutants with diffs
- Write pytest functions to kill each survivor
- Save score snapshot to
mutation-score.json
Analyze existing results without re-running:
- Load results from last run
- Show score and survivor breakdown
- Suggest (but don't write) killing tests
- Display score trend if history exists
The pymutant server exposes these tools to Claude:
| Tool | Purpose |
|---|---|
pymutant_run |
Shell out to mutmut run (supports changed_only + base_ref) |
pymutant_results |
Read mutant status from mutants/*.meta |
pymutant_show_diff |
Return unified diff for one mutant |
pymutant_compute_score |
Compute killed/(killed+survived+timeout+segfault) (crash kept as alias) |
pymutant_surviving_mutants |
All survivors with diffs, grouped by file |
pymutant_update_score_history |
Append score to mutation-score.json |
pymutant_score_history |
Load full score history |
pymutant_ledger_status |
Show ledger + strict-campaign progress state |
pymutant_reset_campaign |
Reset strict-campaign state (optionally clear ledger) |
pymutant_rank_survivors |
Rank survivors by impact/frequency/churn priority |
pymutant_explain_failure |
Classify failure source and suggest remediation |
pymutant_policy_check |
Evaluate policy gates (baseline-drop + absolute floor) |
pymutant_trend_report |
Mutation drift/regression summary from score history |
pymutant_suggest_pytest_patch |
Generate pytest patch suggestion (optional apply=true) |
pymutant_render_report |
Generate HTML report bundle under dist/ |
pymutant_set_project_root |
Set process-local project root at runtime for this MCP process |
pymutant_baseline_status |
Show execution-baseline validity, drift reasons, and fingerprint |
pymutant_baseline_refresh |
Reset runtime mutation state and write fresh execution baseline |
All tools return the same response envelope:
{
"ok": true,
"data": {},
"error": null,
"schema_version": "1.0",
"generated_at": "2026-03-09T14:00:00+00:00"
}Mutation run/status/score payloads also include a baseline block:
validreasonsfingerprint_idauto_reset_applied
After each run, scores are appended to mutation-score.json in the project root:
{
"history": [
{
"timestamp": "2026-03-07T14:51:00",
"score": 0.78,
"killed": 45,
"survived": 13,
"no_tests": 2,
"timeout": 0,
"segfault": 0,
"total": 60,
"label": "after adding auth tests"
}
]
}flowchart LR
U["User"] --> C["Claude/Codex"]
C --> M["pymutant MCP Server"]
M --> R["runner/api.py + runner/helpers.py (mutmut run)"]
M --> RS["results.py (mutants/*.meta + ledger)"]
M --> SC["score.py (mutation-score.json)"]
flowchart TD
A["pymutant_run"] --> B{"strict_campaign?"}
B -- "yes" --> C["snapshot pending mutants"]
C --> D["run next batch"]
B -- "no" --> E["run selected batch or full run"]
D --> F["record outcomes to ledger"]
E --> F
F --> G["pymutant_results"]
G --> H["pymutant_compute_score"]
H --> I["pymutant_update_score_history"]
Project root resolution is runtime-only and non-sticky:
PYMUTANT_PROJECT_ROOT(preferred, explicit)- process
cwd(fallback)
You can also set root dynamically at launch:
pymutant --project-root /abs/path/to/repopymutant --project-root .(resolved relative to launch cwd)
docs/tool-contracts.md: MCP tool names, contract, and error payload shape.docs/reporting-artifacts.md: CI artifacts and the files they contain.docs/architecture.md: architecture decisions and mutation run flow.
Add to the project's pyproject.toml:
[tool.mutmut]
paths_to_mutate = ["src/mypackage/"]
tests_dir = ["tests/"]uv sync
uv run verify # governance + quality gate (ruff, max-loc, SPDX, mypy, bandit, docs, schemas, pytest 100%)
uv run python scripts/validate_repo_schemas.py
uv run mutation-sweep --max-rounds 4 --json-out dist/mutation-gate.json
uv run benchmark throughput # deterministic runtime/no-op regression benchmark
# uv run benchmark quality # mutation quality gate (long-running)
uv run mcp-smoke --project-root . --base-ref HEAD
uv run pre-commit install
uv run pre-commit run --all-files
uv run pymutant --project-root . # starts pymutant server on stdioPre-commit CQ stack includes:
detect-secrets(baseline-backed secret scanning)- Ruff lint/format
- max LOC guard (
scripts/check_max_loc.py) - SPDX header compliance check (
scripts/check_spdx_headers.py) - REUSE license compliance + SPDX checks
- codespell
- mypy + ty
- bandit + pip-audit
- xenon complexity + vulture dead-code checks
verifyaggregate gate- manual hooks:
mutation-gate,performance-smokemutation-sweepmanual hook enforces zero survivors for local campaign runs
Hypothesis property tests are part of the default suite.
- Local default:
HYPOTHESIS_PROFILE=dev(max_examples=200) - CI default:
HYPOTHESIS_PROFILE=ci(max_examples=80)
Override explicitly when needed:
HYPOTHESIS_PROFILE=ci uv run pytest -q
HYPOTHESIS_PROFILE=dev uv run pytest -qpymutant_run now batches by default when prior results exist:
- If
mutants/*.metacontainsnot_checkedmutants, the tool runs only the next batch. - Default batch size is
10mutants per call. - Default batch parallelism is
--max-children 2(unless you passmax_children). - Override via
PYMUTANT_BATCH_SIZE(for exampleexport PYMUTANT_BATCH_SIZE=20). - When passing explicit
paths/selectors topymutant_run, use mutant names (for examplepymutant.score.x_compute_score__mutmut_10), not source file paths.
Calibrated on this repo:
batch_size=10,max_children=2is the best balance of throughput and stability.- Larger batches and higher concurrency were more likely to trigger flaky/segfault runs.
- mutmut pytest runs disable cacheprovider (
-p no:cacheprovider) to avoid cross-platformWindowsPathcache crashes.
Use pymutant_run(changed_only=true) to target only changed Python files from git.
- Default diff target is
HEAD(includes current local changes). - Optional
base_ref(for exampleorigin/main) usesbase_ref...HEAD. - Untracked Python files are included when they are under configured
paths_to_mutate. - If no changed Python files match mutation roots, the tool returns a no-op success response.
- If changed selectors do not map to active mutants, the tool returns a no-op success response (
no matching mutants for changed selectors) rather than a tooling error.
Use pymutant_run(strict_campaign=true) when mutmut metadata churn causes re-queued mutants.
- On first call, pymutant snapshots pending mutant IDs to
.pymutant-strict-campaign.json. - Each call processes only the next batch from that fixed snapshot.
- Progress is deterministic via
campaign_attemptedandremaining_not_checked. - Stale selectors are quarantined in
campaign_stale, excluded fromremaining_not_checked, and no longer re-queued.
pymutant tracks runtime execution baseline state in .pymutant-state/baseline.json.
- Baseline fingerprint captures git head, Python/mutmut versions, resolved mutation/test roots, profile hash, and command mode.
- On
pymutant_run, drift is auto-detected and runtime mutation state is reset before continuing. - Use
pymutant_baseline_statusto inspect validity and drift reasons. - Use
pymutant_baseline_refreshto force reset + re-baseline.
pymutant now writes an append-only mutation ledger at .pymutant-ledger.json.
- One event is appended per processed batch/selector run.
- Per-mutant outcomes are captured from mutmut stdout result lines (with meta fallback).
pymutant_resultsandpymutant_compute_scoreuse ledger-resolved statuses when available, so prior terminal outcomes remain stable even if mutmut rewrites.metaentries later.
GitHub Actions runs .github/workflows/ci.yml with these benchmark-gated jobs:
verify: quality + tests + coverage gate- emits
bandit-reportartifact (dist/bandit-report.json) for audit traceability - runs
uv run mcp-smoke --project-root . --base-ref HEADto validate MCP root/setup/run path
- emits
mutation_benchmark_throughput(push/PR/schedule/manual):- deterministic strict-campaign stale-selector pass
- asserts follow-up no-op call behavior (
strict campaign complete; nothing to run) - enforces runtime budgets from
.ci/benchmark-baseline.json - validates
dist/benchmark-throughput.jsonagainstschemas/benchmark-throughput.schema.json - uploads
benchmark-throughputartifact (dist/benchmark-throughput.json)
mutation_zero_survivors(PR + push):- runs changed-only mutation gate:
- PR:
--base-ref origin/<base_branch> - push:
--base-ref <before_sha>(or full gate on first push with no before SHA)
- PR:
- fails CI when survivors remain after configured rounds
- uploads
mutation-gateartifact (dist/mutation-gate.json)
- runs changed-only mutation gate:
mutation_benchmark_quality(schedule/manual):- strict-campaign-first mutation pass with interruption recovery (
kill_stuck_mutmut) - classifies execution collapse as
tooling_error(separate from test quality score) - enforces score floor and failure budgets (
timeout,segfault, duration, iteration cap, minimum checked mutants) - accepts interrupted runs only when mutation progress is recorded and budgets are still satisfied
- validates
dist/benchmark-quality.jsonagainstschemas/benchmark-quality.schema.json - uploads
benchmark-quality/release-benchmark-qualityartifact (dist/benchmark-quality.json)
- strict-campaign-first mutation pass with interruption recovery (
build: build distribution, runtwine check, generateSHA256SUMS, and verify checksums
Optional in CI: if GPG_PRIVATE_KEY and GPG_PASSPHRASE secrets are set, the workflow signs dist/SHA256SUMS to produce dist/SHA256SUMS.asc.
Benchmark thresholds are versioned in .ci/benchmark-baseline.json and treated as gates:
quality.min_score:0.385quality.min_checked_mutants:10quality.max_timeout:3quality.max_segfault:500quality.max_duration_seconds:7200throughput.max_first_call_seconds:160throughput.max_noop_call_seconds:4throughput.max_total_seconds:165
For stricter enforcement, lower failure budgets and raise min_score incrementally as the codebase improves.
Tag pushes (v*) run .github/workflows/release-readiness.yml, which requires:
uv run verifyto pass.uv run benchmark qualityto pass against.ci/benchmark-baseline.json.- Build + twine + checksum validation.
Run the same workflow locally with act:
export DOCKER_HOST=unix://${HOME}/.colima/default/docker.sock
act push -W .github/workflows/ci.yml --container-architecture linux/amd64 --container-daemon-socket ---container-daemon-socket - avoids bind-mounting the Docker socket path inside the container when using Colima.
When running under act (ACT=true), this workflow executes the verify job and skips mutation benchmark/build jobs to avoid local QEMU timing variance and PR base-ref fetch issues.
Operator procedures for mutation campaigns, stuck-process recovery, and strict campaign progress are documented in docs/operator-runbook.md.