Chaos-MCP
<div align="center">
# Chaos-MCP
**Break your code on purpose, and find out what your tests never noticed.**
[](https://github.com/AraneaDev/Chaos-MCP/releases)
[](https://aranea-development.nl/en/tools/chaos-mcp)
[](https://github.com/AraneaDev/Chaos-MCP/actions/workflows/ci.yml)
[](https://github.com/AraneaDev/Chaos-MCP/actions/workflows/ci.yml)
[](./LICENSE)
[](https://github.com/AraneaDev/Chaos-MCP)
[](https://github.com/AraneaDev/Chaos-MCP/commits/main)
[](https://www.conventionalcommits.org/)
[](https://mcpobservatory.com/servers/github:AraneaDev/Chaos-MCP/security)
[](#quick-start)
</div>
> **Chaos** (Χάος) is the first thing that existed in Greek cosmogony, the yawning void Hesiod
> puts before everything else in the _Theogony_. Order came out of it, not the other way round.
> The name means "gap" or "chasm", which is also what this tool is looking for.
**TL;DR:** Chaos-MCP measures whether your tests catch realistic code changes. Its MCP tools
create isolated sandboxes, inject logical mutations such as changing `>` to `>=`, run the test
suite, and report the mutants that survive.
It exposes `audit_code_resilience` for one file, `triage_test_coverage` for a whole tree, and
`estimate_audit` for a quick mutant count and timing estimate.
> **Status:** pre-release. Chaos-MCP is **not yet published to npm**. The source is public on
> [GitHub](https://github.com/AraneaDev/Chaos-MCP), so install from source (see
> [Installation](#installation)). Any `npm install -g` or `npx` command in this README
> describes the planned published experience and does not work yet.
---
## Features
- **4 Languages Supported**: TypeScript/JavaScript (StrykerJS), Python (cosmic-ray), Rust (cargo-mutants), PHP (Infection)
- **Sandbox Isolation**: all mutation runs execute in temporary directories, and the target's real path is verified to live inside the sandbox before any engine runs, so a symlinked source file (or one under a symlinked directory) is refused rather than mutated in place through the link. Dependency directories are shared cheaply by default and entry-linked, so a write to a new path stays sandboxed while a write through an existing package entry still reaches the host. The exception is PHP's `vendor/`, which is always copied because Composer's autoloader resolves `__DIR__` through symlinks back to the real workspace; see `sandbox.dependencies` in `chaos://config-schema` for the `copy`/`share` alternatives
- **Pinned Container Runners**: release-matched OCI images provide all four mutation engines without installing them on the host
- **Auto-Detection**: automatically detects project type, test runner, and workspace root
- **Async Subprocesses**: all mutation-tool execution uses async `execFile`/`exec`, and the sandbox's workspace copy uses async `fs.cp`, so neither blocks the event loop; the entry-linking pass that follows runs synchronously afterward and scales with the number of installed packages, not workspace size
- **Rich Tool Schema**: supports line scoping, mutator denylists, concurrency control, dry-run mode, incremental runs, and output format selection
- **Pre-flight Estimation**: `estimate_audit` gives a fast mutant count and an optional timing estimate before you commit to a full run. For Rust it is an exact count of the mutants `cargo-mutants --list` **generates**; the audit itself scores fewer, because mutants that fail to compile leave its denominator and are reported as `incompetent`. The other three languages use a source heuristic
- **Gate Mode**: pass `minScore` to `audit_code_resilience` or `triage_test_coverage` to get a machine-readable pass/fail field for CI pipelines
- **Dead-Harness Detection**: a run that generates mutants but kills none of them carries a `fidelityNote` naming both causes that produce that shape, a suite that asserts nothing or a mutation harness that never applied the mutants, plus the one-minute check that tells them apart. A test runner your mutation tool cannot drive otherwise reports a silent `0.00%` that looks exactly like a real score regression
- **Sound result reuse**: Python reuses a fingerprinted cosmic-ray session, PHP reuses project-wide Infection coverage, and TypeScript verify runs reuse Stryker's incremental file. Reuse is accepted only when the covered source, tests, configuration, tool version, and relevant diff scope match exactly. Missing, corrupt, changed, rejected, or non-git inputs fall back to a fresh run, and the scope note names anything reused. Rust has no result reuse because cargo-mutants' documented iterate mode is heuristic.
- **Cross-Platform**: works on macOS, Linux, and Windows (with junction fallback for symlinks)
## Installation
Chaos-MCP tracks the active Node LTS line and newer. You need **Node 24.11.0 or later**; CI runs 24.x and 26.x.
While in development, the only supported install path is **from source**: clone the repo, build, and register the built entrypoint with your MCP client.
```bash
git clone https://github.com/AraneaDev/Chaos-MCP.git
cd Chaos-MCP
npm install
npm run build # compiles to build/index.js
```
Register it with an MCP client. The command must point at the built entrypoint
with an absolute path.
#### Codex
```bash
codex mcp add chaos-mcp -- node /absolute/path/to/Chaos-MCP/build/index.js
```
#### Claude Code
```bash
claude mcp add chaos-mcp -- node /absolute/path/to/Chaos-MCP/build/index.js
```
> **Planned (not available yet):** once published, install will be `npm install -g chaos-mcp` or run on demand via `npx chaos-mcp`. These do not work until the package ships to npm.
### Installation pitfalls
- This is currently a source install: the npm package is not published, so
`npm install -g chaos-mcp` and `npx chaos-mcp` do not work yet.
- The build requires Node 24.11.0 or newer. In a restricted sandbox,
`npm run build` can fail only at its final child-process `chmod`; rerun
`node_modules/.bin/tsc` and then `chmod +x build/index.js` if that happens.
- Native mutation engines are installed in the target project, not by Chaos-MCP.
Install only the engine for the language being audited, or configure the
pinned container runner.
- MCP clients launch the server with a fixed working directory. To audit
projects outside it, set `CHAOS_ALLOWED_ROOTS` in the registration; use
absolute paths in both the server command and that variable.
### Prerequisites: language mutation tools
Native mode (the default) shells out to mutation engines installed on the host.
Install only the engine(s) for the languages you audit. Alternatively, enable
[container execution](#container-execution) to use the release-matched,
pinned engines without installing them on the host. Missing native tools return
a clear error naming the exact install command.
| Language | Engine | Install |
| ----------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TypeScript / JavaScript | [StrykerJS](https://stryker-mutator.io/) | `npm install --save-dev @stryker-mutator/core` (in the target project). For a vitest project, add `@stryker-mutator/vitest-runner` too, and Chaos-MCP will use it: vitest 2, 3 and 4 all run on the native runner, which enables per-mutant coverage. Anything else, vitest 5 included, falls back to Stryker's built-in command runner. |
| Python | [cosmic-ray](https://github.com/sixty-north/cosmic-ray) | `pipx install cosmic-ray`, or `pip install cosmic-ray` inside a virtualenv |
| Rust | [cargo-mutants](https://github.com/sourcefrog/cargo-mutants) | `cargo install cargo-mutants` |
| PHP | [Infection](https://infection.github.io/) | `composer require --dev infection/infection`, and enable a coverage driver (Xdebug or PCOV) |
Notes:
- In native mode, the tool must be on `PATH` (or, for StrykerJS, resolvable from the target project's `node_modules`), and its language toolchain must be installed.
- **PHP / Infection:** set `failOnWarning="true"` in your `phpunit.xml`. Infection writes a PHPUnit config per mutant with `stopOnDefect="true"`, so the suite stops as soon as a mutant looks killed. But a PHP warning is a _defect_ without being a _failure_, so under `failOnWarning="false"` a mutant that makes an earlier test warn stops the run with exit 0 and is reported as **survived** before the asserting test runs. Scores are only ever depressed by this, never inflated. Chaos-MCP reads your PHPUnit config and attaches a `fidelityNote` to any PHP result that reports survivors while the setting is off.
- **PHP / Infection (run time):** Infection's own default re-runs every covering test _file_ for every mutant, so a class whose covering set is large pays for all of it once per mutant, and wall-clock stops tracking the mutant count. Chaos-MCP therefore passes `--only-covering-test-cases`, which narrows that to the test _cases_ covering the mutated line. Measured on a 192-mutant file with cheap covering tests: 172.5s → 135.3s. The flag works through PHPUnit's `--filter`, so if a score moves in a way the code does not explain, restore Infection's default with `{ "infection": { "onlyCoveringTestCases": false } }`.
- **PHP / Infection (initial coverage selection):** to avoid running a large suite before a PHP audit, set `infection.coverageTestFrameworkOptions` to an explicit PHPUnit narrowing selection, for example `{ "infection": { "coverageTestFrameworkOptions": "--testsuite=unit" } }`. Accepted controls are `--testsuite`, `--filter`, `--group`, and `--exclude-group`, each in either separate-value or `--name=value` form. This setting applies only to the initial coverage pass; `infection.testFrameworkOptions` remains the mutation-phase PHPUnit option. Selected coverage is strict: execution failures and zero collected tests fail the audit and never retry with the full suite. Results expose `coverageScope: "selected"` and `coverageNote`; selected scores do not represent tests outside the chosen PHPUnit selection. Without this setting, PHP coverage remains project-wide (`coverageScope: "project"`).
- **Python / cosmic-ray (native mode):** on modern distros a bare `pip install cosmic-ray` is blocked by [PEP 668](https://peps.python.org/pep-0668/) ("externally-managed-environment"); use `pipx install cosmic-ray` or an activated virtualenv. Chaos-MCP generates cosmic-ray's config and runs `baseline → init → exec → dump` in the sandbox. Use `testSelection` and `excludeOperators` to keep large audits tractable.
- These engines run **inside the sandbox** against a copy of your workspace; Chaos-MCP never installs or modifies anything in your real project.
For container mode, install Docker or Podman. Both runtimes normally pull a
missing image while creating the first audit container, but pre-pulling avoids
making a large download compete with the container startup timeout:
```bash
CHAOS_MCP_TAG="v$(node -p "require('./package.json').version")"
docker pull "ghcr.io/araneadev/chaos-mcp-typescript:${CHAOS_MCP_TAG}"
docker pull "ghcr.io/araneadev/chaos-mcp-python:${CHAOS_MCP_TAG}"
docker pull "ghcr.io/araneadev/chaos-mcp-rust:${CHAOS_MCP_TAG}"
docker pull "ghcr.io/araneadev/chaos-mcp-php:${CHAOS_MCP_TAG}"
# From a source checkout:
node build/index.js --container-doctor
```
Use `podman pull` instead when `"runtime": "podman"` is configured. Each
Chaos-MCP release selects its matching `vX.Y.Z` image tags automatically.
## Quick start
### 1. Start the Server
Normally your MCP client launches the server for you (see [Installation](#installation)). To run it directly from a source checkout:
```bash
# From the repo root, after `npm run build`
npm start # → node build/index.js
node build/index.js --verbose # diagnostic logging to stderr
node build/index.js --config ./chaos-mcp.config.json
```
### 2. Call the Tool from Your MCP Client
The primary tool is `audit_code_resilience` (the batch tool `triage_test_coverage` is documented [below](#batch-triage--triage_test_coverage); the lightweight pre-flight tool `estimate_audit` is documented [below](#pre-flight-estimate--estimate_audit)).
**Minimal example:**
```json
{
"filePath": "src/utils/math.ts"
}
```
**Full example with all options:**
```json
{
"filePath": "src/utils/math.ts",
"timeoutMs": 120000,
"lineScope": { "start": 10, "end": 80 },
"mutatorDenylist": ["StringLiteral"],
"concurrency": 4,
"incremental": true,
"ignorePatterns": ["fixtures/", "snapshots/"],
"outputFormat": "text",
"enrich": false,
"maxSurvivors": 20,
"severityFloor": "medium"
}
```
**Get enriched, severity-ranked guidance on survivors (on by default):**
Enrichment is enabled by default. Each surviving / no-coverage line is augmented with four fields: a `severity` rating (`high`, `medium`, or `low`) based on the mutator's semantics (e.g. boundary operators and logical operators rank high), a `why` explanation of why the gap is dangerous, a `hint` describing the kind of test that would kill it, and a `context` snippet of the surrounding source lines. Survivors are re-ranked severity-first so the most critical gaps appear first. To disable enrichment and return the plain unranked output, pass `"enrich": false`.
TypeScript targets produce the richest output because StrykerJS exposes per-mutant operator detail; Python (cosmic-ray) targets also produce severity-ranked output, mapping the tool's authoritative operator name to a canonical category; targets whose tool can't expose a per-mutant operator fall back to `severity: "unknown"` with a generic why/hint.
**Cap and filter the survivor list:**
```json
{
"filePath": "src/utils/math.ts",
"maxSurvivors": 5,
"severityFloor": "high"
}
```
`maxSurvivors` caps how many survivor (and no-coverage) line groups are returned after severity ranking (default: 10; configurable via `defaultMaxSurvivors`). Hidden groups are counted in `survivorsTruncated` / `noCoverageTruncated` in the output. `severityFloor` drops groups below the given severity level (requires enrichment, which is on by default); dropped groups are counted in `survivorsFiltered` / `noCoverageFiltered`.
**Scope to just your uncommitted changes:**
```json
{
"filePath": "src/utils/math.ts",
"diffBase": "HEAD"
}
```
Mutation-tests only the lines you've changed since the last commit.
**Verify your new tests killed the previous survivors:**
```json
{
"filePath": "src/utils/math.ts",
"baseline": { "survivors": [{ "line": 42, "mutators": { "ConditionalExpression": 1 } }] }
}
```
Re-runs only the baseline lines and reports which previously-uncaught mutants are now killed:
```json
{
"mode": "verify",
"baselineTotal": 1,
"killedCount": 1,
"nowKilled": [{ "line": 42, "mutator": "ConditionalExpression" }],
"stillSurviving": [],
"newSurvivors": []
}
```
### 3. Interpret the Results
The output is **bundled and deduplicated** to stay token-efficient: mutants are grouped by line (with a per-line count of each mutator type), `survivors` (tests ran but didn't catch) and `noCoverage` (no test reached the mutant) are reported separately at line+mutator granularity, and the explanatory note appears once instead of being repeated for every mutant. Because the split is per-mutator, the same line can appear in both lists (e.g. a live expression that survived next to an unreachable fallback that no test reached). Survivors and no-coverage entries also include a `changes` sample, a capped and deduped list of per-mutant edits, for all four languages (best-effort). TypeScript (StrykerJS) and Python (cosmic-ray, read from each mutant's diff) report the full `original → mutated` form; Rust (cargo-mutants) and PHP (Infection) expose only the mutated side, so their entries carry just that. When `diffBase` is used, the output may include a `scopeNote` (a top-level JSON field / a `Scope:` text line) reporting scoping decisions, for example a skipped run when nothing changed, or a whole-file fallback for an untracked target.
**JSON output (default, emitted as a single compact line):**
```json
{
"target": "src/utils/math.ts",
"mutationScore": "91.67%",
"summary": { "total": 12, "killed": 11, "survived": 1, "worstSeverity": "high" },
"survivors": [
{
"line": 42,
"mutators": { "ConditionalExpression": 1 },
"changes": ["a > b → a >= b"],
"severity": "high",
"why": "a branch condition was forced to a constant; a test passed without exercising both arms.",
"hint": "add tests that take BOTH the true and the false branch.",
"context": ["41: if (a > b) {", "42: return a;", "43: }"]
}
],
"noCoverage": [],
"suggestedTestFile": { "path": "src/utils/__tests__/math.test.ts", "exists": false },
"note": "survivors: mutants your tests ran but did not kill. noCoverage: mutants no test reached (per line+mutator, so a line may appear here and in survivors). mutators = type→count. Add or strengthen tests targeting these. changes = sampled original→mutated edits for that line (capped)."
}
```
The tool response also carries a `structuredContent` field (in addition to the standard text content block) so MCP clients that support it can consume the data directly without parsing JSON from text. The text block is retained for compatibility with clients that read `content[0].text`.
`suggestedTestFile` is included when there are survivors or no-coverage entries (i.e. when the mutation score is below 100%), pointing to the conventional test file path for the audited source file (e.g. `src/utils/__tests__/math.test.ts` for `src/utils/math.ts`). The `exists` flag indicates whether the file already exists on disk.
**Text output** (`"outputFormat": "text"`):
```
Chaos-MCP Audit Report: src/utils/math.ts
Mutation score: 91.67% (11/12 killed, 1 survived)
Survivors (line: mutators):
42: ConditionalExpression (a > b → a >= b)
Add or strengthen tests targeting these lines to kill the survivors.
```
#### When the score is not measuring your tests
A run that generates mutants but kills none of them carries a `fidelityNote`, because that
shape has two very different causes and the number alone cannot separate them. Either the
suite genuinely asserts nothing about the file, or the mutation harness never applied the
mutants at all.
The second case is the dangerous one, because nothing else about the run looks wrong. A
test-runner version your mutation tool cannot drive makes every mutant report as survived,
the score reads `0.00%`, the suite still passes normally, and the tool exits on its own
break threshold. That is indistinguishable from an ordinary score regression, and it invites
the worst possible repair: writing tests to raise a number that is not measuring tests.
The advisory fires when at least 10 **covered** mutants survived and not one was killed.
Counting covered survivors only is what keeps it quiet for a file no test imports, where
every mutant is reported as no-coverage and a zero score is correct. A single kill anywhere
in the file proves the harness is alive and suppresses the warning, so an ordinary weak spot
is still reported as the plain finding it is.
To tell the two causes apart, edit the file by hand to break a branch, inverting an `if` for
example, and run the suite. If it goes red your tests are fine and the score is not measuring
them, so check whether your mutation tool supports the installed test-runner version. If it
stays green, the survivors are real.
This applies to every language and to both execution modes. The container images pin the
mutation engines but deliberately not your project's test runner, which comes from your own
`node_modules`, so a version mismatch reaches the containerised path too.
## Tool parameters
| Parameter | Type | Required | Description |
| ------------------ | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filePath` | `string` | Yes | Workspace-relative path to the file (`.ts`, `.js`, `.tsx`, `.jsx`, `.py`, `.rs`, `.php`) |
| `timeoutMs` | `number` | No | Max run time in ms (default: 300000 / 5 min) |
| `lineScope` | `{ start, end }` | No | 1-based line range (StrykerJS only) |
| `diffBase` | `string` | No | Auto-scope mutation to git-changed lines. `"HEAD"` (uncommitted), `"staged"`, or a git ref (e.g. `"main"`, via merge-base). Mutually exclusive with `lineScope`. Supported on all four languages: each restricts its run to the changed lines (TypeScript via `--mutate file:A-B`, Python via `cr-filter-lines`, Rust via `--in-diff`, PHP via `--git-diff-lines`). An untracked file, or a base git cannot resolve, falls back to whole-file with a note. No changes vs base → run skipped. |
| `baseline` | `object` | No | Verify mode. Pass back a prior run's `{ survivors, noCoverage }` to re-test only those mutants and get a delta (`nowKilled` / `stillSurviving` / `newSurvivors`). The re-run is always whole-file, on every language, and the delta is computed by filtering to the baseline's mutant keys afterward, because a mutant's span can exceed the lines it was reported on, so narrowing the re-run itself could miss it and misreport it as fixed. Mutually exclusive with `diffBase`/`lineScope`. Verify mode keys on line numbers, so run it after **adding tests**, not after editing the source under test, since edits shift line numbers and would misreport which mutants were killed. |
| `mutatorAllowlist` | `string[]` | No | Not supported by StrykerJS, rejected with an error (use `mutatorDenylist`) |
| `mutatorDenylist` | `string[]` | No | Stryker mutator names to exclude |
| `concurrency` | `number` | No | Parallel mutation workers (StrykerJS only) |
| `dryRun` | `boolean` | No | Validate test suite only, no mutations (StrykerJS only) |
| `outputFormat` | `"json"` \| `"text"` | No | Output format (default: `"json"`) |
| `incremental` | `boolean` | No | Reuse previous run results (StrykerJS only). State is cached per (workspace, file) OUTSIDE the sandbox, because the sandbox is deleted after each run and without that the option would have nothing to reuse |
| `ignorePatterns` | `string[]` | No | Path segments to exclude from the sandbox, in addition to the built-in exclusions. A path is skipped when any of its segments **equals** the pattern exactly, not a substring match, so `"dist"` excludes `dist/` but not `src/dist-utils.js`. One trailing path separator is stripped (`"fixtures/"` works; on Windows that is a trailing `\`); empty patterns are ignored. Naming a dependency directory (`node_modules`, `.venv`/`venv`, `vendor`) also suppresses the dependency link, so the sandbox is left without it and the run will usually fail. Chaos-MCP warns when an exclusion does that to a directory that exists, except under `sandbox.dependencies: "copy"`, which links nothing to warn about and drops the directory silently. |
| `enrich` | `boolean` | No | Annotate each survivor with severity, why-it-matters, a test hint, and source context, and ranks severity-first. **Default: `true`** (pass `false` to disable and return plain unranked output). Richest for TypeScript; Python degrades to `severity: "unknown"`. |
| `maxSurvivors` | `integer ≥ 1` | No | Cap on how many survivor (and no-coverage) line groups are returned after severity ranking. Hidden groups counted in `survivorsTruncated`/`noCoverageTruncated`. Precedence: arg > `defaultMaxSurvivors` config > 10. |
| `severityFloor` | `"high"` \| `"medium"` \| `"low"` | No | Drop survivor groups below this severity (requires enrichment, on by default). Dropped groups counted in `survivorsFiltered`/`noCoverageFiltered`. `"unknown"`-severity groups are below `"low"` and are dropped by any floor. |
| `runId` | `string` | No | Verify mode by cached id: re-run against the survivor baseline saved from a prior audit (the `runId` it returned). Mutually exclusive with `baseline`, `diffBase`, and `lineScope`. Unknown or expired ids (cache TTL: ~24 h) return an error. |
| `suppress` | `object[]` | No | Mark mutants as equivalent (unkillable). Each entry: `{ "line": N, "mutator": "MutatorName" }`, plus an optional `reason` explaining why the mutant is equivalent and an optional `change` (the `"original → mutated"` string) naming **which** mutant when one line carries several of that mutator. Omit `change` and it is resolved from this run's survivors; if several match, the entry is refused and the candidates are listed. Persisted to `.chaos-mcp/suppressions.json`; suppressed mutants are auto-excluded from the score denominator and from future `audit` and `triage` output. An entry is identified by its mutator and change rather than its line, so it follows the code when an edit moves it, and the stored line is rewritten when it does. The output fields `suppressedCount`, `relocatedSuppressions`, `driftedSuppressions`, `unverifiedSuppressions`, `orphanedSuppressions` and `rejectedSuppressions` report how many were excluded, moved, rejected, unverifiable, inert, and refused at write time. |
| `unsuppress` | `object[]` | No | Remove previously-suppressed mutants for this file. Each entry: `{ "line": N, "mutator": "MutatorName" }`, plus an optional `change` to remove one specific entry; omit it to remove every entry for that mutator. The `line` is ignored when matching, so an entry that has relocated is still removable. |
| `minScore` | `number 0–100` | No | Gate threshold. When the mutation score is below this value, the output includes `gate: { minScore, passed: false }`. Never an error. Uses the suppression-adjusted score. |
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup and the full parameter semantics.
## State & the verify loop
### Verify loop via `runId`
Every successful, non-verify `audit_code_resilience` call returns a `runId` (an 8-character id) in its JSON output. Use it to re-verify without copying the full `baseline` object:
1. **Audit:** `{ "filePath": "src/utils/math.ts" }` → response includes `"runId": "a1b2c3d4"`.
2. **Fix or add tests.**
3. **Verify:** `{ "filePath": "src/utils/math.ts", "runId": "a1b2c3d4" }` → reports which previously-surviving mutants are now killed.
`runId` is mutually exclusive with `baseline`, `diffBase`, and `lineScope`. The baseline cache lives in `os.tmpdir()/chaos-mcp-runs/<hash-of-server-cwd>/`, partitioned per server working directory so two checkouts served by two servers never read each other's entries, and is ephemeral (default TTL: 24 h; default max: 200 entries). Passing an unknown or expired `runId` returns an error.
`triage_test_coverage` also mints and returns a `runId` per ranking row, so you can drill into a weak file and immediately verify after fixing its tests.
### Suppressing equivalent mutants
Some mutants are _equivalent_, logically identical to the original under all possible inputs, and cannot be killed by any test. Suppress them so they stop appearing in the output and stop dragging down the score:
```json
{
"filePath": "src/utils/math.ts",
"suppress": [
{ "line": 99, "mutator": "StringLiteral", "reason": "guard always true for this type" }
]
}
```
Suppressed mutants are:
- **Persisted** to `<workspaceRoot>/.chaos-mcp/suppressions.json` (keyed by workspace-relative file path).
- **Identified by content**: an entry names the mutator _and the change the mutant makes_, not the line it sits on, so it follows the code when an edit moves it.
- **Auto-excluded** from every future `audit` and `triage` call for that file, with no flag needed.
- **Removed from the score denominator**: `mutationScore` rises and the output field `suppressedCount` tells you how many were excluded.
- **Excluded from verify mode**: suppressed mutants won't appear as "still surviving".
To undo a wrong suppression:
```json
{
"filePath": "src/utils/math.ts",
"unsuppress": [{ "line": 99, "mutator": "StringLiteral" }]
}
```
**`.gitignore` or commit?** Add `.chaos-mcp/` to `.gitignore` if the suppression list is personal, or commit it to share the equivalent-mutant list with the team. Suppression keys are workspace-relative, so the file is portable across machines.
#### Naming which mutant: the `change` field
One line can carry several mutants of the same mutator. This single line emits **four** `ConditionalExpression` mutants and **two** `LogicalOperator` mutants:
```ts
if (typeof g !== 'object' || g === null || Array.isArray(g)) return false;
```
A `{ line, mutator }` pair cannot say which of them you mean, so an entry identifies its mutant by the **change** it makes: the `"original → mutated"` string, the same form the report's `changes` field uses:
```json
{
"suppress": [
{
"line": 331,
"mutator": "ConditionalExpression",
"change": "g === null → false",
"reason": "the null case is rejected by the group.line check below"
}
]
}
```
`change` is **optional**. Omit it and Chaos-MCP resolves it from that run's survivors, so the ordinary two-field call still works. When several distinct mutants match, the entry is **refused** rather than suppressing all of them, and the response lists the candidate `change` values to choose from. The report's own `changes` field is capped at three per line and aggregated across mutators, so on exactly these lines it cannot show them all.
Replacement text alone is not enough to identify a mutant: several `ConditionalExpression` mutants on one line can all replace their span with `true`, and only the _original_ span tells them apart. That is why the change carries both halves.
#### Staleness is detected, and usually repaired
Each entry also stores a `fingerprint`: a digest of the normalized source line (trimmed, internal whitespace collapsed) it was recorded against. Every run resolves each stored entry through a ladder, and **ambiguity at any step is a refusal, nothing is ever guessed**:
| Outcome | Meaning | Applied? |
| ---------- | ---------------------------------------------------------------------------------- | -------------------------------------------- |
| applied | the stored line still matches the stored fingerprint | yes, counted in `suppressedCount` |
| relocated | the line moved, or was edited and the mutant found by its change; entry re-pointed | yes, also counted in `relocatedSuppressions` |
| drifted | the mutant could not be placed, or more than one candidate matched | **no**, counted in `driftedSuppressions` |
| unverified | the entry has no fingerprint (written before v2) | **no**, counted in `unverifiedSuppressions` |
| orphaned | the entry was placed but matched no surviving mutant, inert | n/a, counted in `orphanedSuppressions` |
A **relocation is written back** to `suppressions.json`, so the repair happens once rather than on every run. Expect the file to show up in `git status` after an edit that moved a suppressed line.
An entry relocates in one of two ways. If the **line's content is unchanged** and simply moved, the match is exact and the move is reported as a bare count. If the **line itself was edited**, reflowed or a variable renamed or a comment appended, the mutant is found by its change instead, and that one can be wrong: if the original site was _deleted_ and an unrelated site happens to produce the same change, the entry lands on code its `reason` was never written about. Those moves are reported individually, with the reason quoted, so you can confirm or drop them.
The bias throughout is deliberate: a suppression that is not applied lowers your score _visibly_, while one applied to the wrong code hides a real coverage gap _invisibly_. To restore a drifted or unverified entry, re-issue the same `suppress` argument, which re-stamps the fingerprint and keeps the existing `reason` and `addedAt` (a new `reason`, if you pass one, replaces the old).
An `orphaned` entry means one of three things and Chaos-MCP cannot tell which: the mutant is now killed, its identity no longer exists, or a `mutatorDenylist` entry stopped it being generated. It is inert either way, so drop it with `unsuppress` unless you know the mutant still exists.
**Migrating an older file.** `version: 1` and `version: 2` files load unchanged; every entry keeps its `line`, `mutator`, `reason` and `addedAt`, and nothing is deleted or back-filled.
- **v1** entries have no fingerprint and report as `unverified` until re-confirmed.
- **v2** entries have no `change`, so they fall back to mutator-only identity, _broader_ than the mutant they were filed against, since one entry then covers every mutant of that mutator on its line, including ones added later.
To migrate a v2 corpus, run `node scripts/migrate-suppressions-v3.mjs --write`. It re-points entries whose line moved but whose content is unchanged, refuses to guess at anything ambiguous, and writes a replay payload; feeding those entries back through `audit_code_resilience` resolves each `change` from that run's survivors.
### Config keys for state
| Key | Default | Description |
| ------------------ | ------------------------------ | ------------------------------------------------------------- |
| `suppressionsPath` | `.chaos-mcp/suppressions.json` | Path to the suppression file (workspace-relative or absolute) |
| `runCacheTtlMs` | `86400000` (24 h) | Run-cache entry TTL in milliseconds |
| `runCacheMax` | `200` | Max cached run entries; oldest are evicted when exceeded |
## Batch triage: `triage_test_coverage`
A second tool ranks where your test suite is weakest across many files in one call.
```json
{ "paths": ["src/utils", "src/index.ts"], "maxFiles": 25 }
```
Directories are recursively expanded to supported source files (test files skipped), audited in **bounded parallel** (default `max(1, min(4, cpus-1))` files at a time; capped at `maxFiles`; precedence `maxFiles` arg → `defaultMaxFiles` config → 25), and ranked weakest-first by mutation score:
```json
{
"mode": "triage",
"summary": { "filesDiscovered": 30, "filesAudited": 25, "filesSkipped": 5, "filesErrored": 0 },
"ranking": [
{
"file": "src/a.ts",
"mutationScore": "62.50%",
"total": 16,
"killed": 10,
"survived": 5,
"noCoverage": 1
}
],
"errors": [],
"note": "Ranked weakest-first by mutation score. Drill into a file with audit_code_resilience for survivor detail."
}
```
The tool response carries a `structuredContent` field (in addition to the text block) so MCP clients can consume the ranked payload directly without parsing JSON. The `outputSchema` on the tool definition describes the payload shape.
Drill into a weak file with `audit_code_resilience` for per-mutant survivor detail.
**PR-diff scan (`diffBase`):**
Pass `diffBase` to limit the triage to files changed in a PR or branch. `paths` becomes optional in this mode:
```json
{ "diffBase": "main" }
```
`diffBase` alone audits every changed supported source file in the workspace (relative to `main` via merge-base). Passing both limits the scan to changed files under those paths:
```json
{ "diffBase": "main", "paths": ["src/utils"] }
```
All four languages are mutated only on the changed lines (a per-file `scopeNote` is included in the ranking row); an untracked file, or one a diff scope could not be built for, falls back to whole-file with its own note.
**Inline survivor detail (`survivorsPerFile`):**
```json
{ "paths": ["src"], "survivorsPerFile": 3 }
```
`survivorsPerFile` (default `0`, scores-only) inlines the top-N severity-ranked, enriched survivor groups into each ranking row so you can triage and inspect in one call. Set it to `0` for the compact leaderboard; raise it when you want to see the worst gaps immediately.
**Parallel file auditing (`fileConcurrency`):**
```json
{ "paths": ["src"], "fileConcurrency": 8 }
```
`fileConcurrency` controls how many files are audited in parallel (default `max(1, min(4, cpus-1))`; range 1–64). When `fileConcurrency > 1` and the file is TypeScript, each StrykerJS run's worker count is automatically capped (`floor((cpus-1) / fileConcurrency)`) so total CPU use stays near the core count rather than oversubscribing. Other languages run their mutation tool without a worker-count override (they ignore the concurrency cap).
**Parameters:**
| Parameter | Type | Description |
| ------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paths` | `string[]` | Workspace-relative files/dirs to triage. Optional when `diffBase` is provided. |
| `maxFiles` | `integer ≥ 1` | Cap on files audited (precedence: arg → `defaultMaxFiles` config → 25). |
| `timeoutMs` | `number > 0` | Per-file mutation-run timeout in ms (default: 300000). Also clamped by whatever remains of `totalTimeoutMs`. |
| `totalTimeoutMs` | `number > 0` | Wall-clock budget for the whole sweep (default: 900000 = 15 min). Files not started before it runs out are returned in `unaudited` with `stoppedReason: "time_budget_exhausted"`, so a large sweep still returns its ranking. |
| `mutatorDenylist` | `string[]` | Stryker mutator names to exclude, applied to every TypeScript/JS file. |
| `outputFormat` | `"json"` \| `"text"` | Output format (default: `"json"`). |
| `diffBase` | `string` | Auto-scope to git-changed files. `"HEAD"`, `"staged"`, or any git ref/SHA. Makes `paths` optional; with `paths`, intersects changed files under those paths. Restricts each audited file to its changed lines on all four languages. |
| `survivorsPerFile` | `integer ≥ 0` | Inline top-N enriched survivors per ranked file (default `0` = scores-only). |
| `fileConcurrency` | `integer 1–64` | Files audited in parallel (default `max(1, min(4, cpus-1))`). Per-file StrykerJS worker count is automatically capped (TypeScript/StrykerJS only; other engines ignore the worker-count cap). |
| `minScore` | `number 0–100` | Gate threshold. Per-row `passed` field + top-level `gate: { minScore, passed, failingFiles }` in output. Never an error. A row whose audit was cut short (`complete: false`) fails the gate regardless of its score. |
## Pre-flight estimate: `estimate_audit`
Before committing to a full mutation run, use `estimate_audit` to check how many mutants a file will produce and (optionally) how long the run will take. It never runs the mutation test cycle by default.
```json
{ "filePath": "src/utils/math.ts" }
```
**Output:**
```json
{
"target": "src/utils/math.ts",
"language": "typescript",
"mutants": 47,
"fidelity": "approx",
"basis": "source heuristic: 23 constructs",
"note": "Approximate mutant count from a source-parse heuristic; the real audit may differ. Run audit_code_resilience for exact results."
}
```
**With timing** (`withTiming: true`): runs the test suite once to measure a baseline, then estimates total wall-clock time as `mutants × baseline / concurrency`. This provisions a sandbox and counts against your machine's resources, so use it when you want a time budget before a large audit.
```json
{ "filePath": "src/utils/math.ts", "withTiming": true }
```
Additional output fields when `withTiming: true`:
```json
{
"baselineMs": 4200,
"estimatedMs": 197400,
"concurrency": 1
}
```
### Fidelity
| Language | Fidelity | Basis |
| ----------------------- | -------- | -------------------------------------------------------- |
| Rust | `exact` | `cargo-mutants --list` (generated mutants; no tests run) |
| TypeScript / JavaScript | `approx` | source-parse heuristic |
| Python | `approx` | source-parse heuristic |
For Rust, the estimate is exact for the mutants `cargo mutants --list` _generates_ without running tests. The audit itself scores fewer, since mutants that fail to compile are excluded from its denominator and reported as `incompetent`. For all other languages the count is approximate, a lightweight heuristic over the source AST, and the actual audit may differ. Run `audit_code_resilience` for exact results.
If `cargo-mutants` is not installed, the Rust path falls back to the heuristic and reports `fidelity: "approx"` with a note.
### Parameters
| Parameter | Type | Required | Description |
| ------------ | --------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `filePath` | `string` | Yes | Workspace-relative path to the file to estimate. |
| `withTiming` | `boolean` | No | When `true`, runs the test suite once to measure `baselineMs` and computes `estimatedMs`. Default: `false`. |
### Use case
Call `estimate_audit` first when you are unsure whether a file is too large to audit interactively:
1. `estimate_audit { "filePath": "src/big.ts" }` → 300 mutants, approx.
2. Consider scoping with `lineScope` or `diffBase`, or scheduling the full run with a longer `timeoutMs`.
3. `audit_code_resilience { "filePath": "src/big.ts", "diffBase": "HEAD" }` → audits only your changed lines.
## Gate mode: `minScore`
Both `audit_code_resilience` and `triage_test_coverage` accept a `minScore` parameter (0–100). When the mutation score falls below the threshold, the result reports the gate as failed. **A failing gate is never an error**. It is a data field for an agent or CI pipeline to read and act on.
### Gate on a single file
```json
{ "filePath": "src/utils/math.ts", "minScore": 80 }
```
If the mutation score is below 80, the output includes:
```json
{ "gate": { "minScore": 80, "passed": false } }
```
If the score meets or exceeds the threshold, `gate.passed` is `true`. The field is absent when `minScore` is not provided.
The gate uses the suppression-adjusted mutation score (i.e. equivalent mutants excluded via `suppress` are not counted against the denominator).
### Gate on a triage run
```json
{ "paths": ["src"], "minScore": 75 }
```
Each ranking row gains a `passed` field. The top-level output includes:
```json
{
"gate": {
"minScore": 75,
"passed": false,
"failingFiles": ["src/utils/math.ts", "src/parser.ts"],
"notGraded": { "errored": 0, "unaudited": 0 },
"reason": "below_threshold"
}
}
```
**The triage gate fails closed.** `gate.passed` is `false` if any file's score is below `minScore` **or if any requested file was never measured**: one that errored during the sweep (also listed in `errors[]`) or that the `totalTimeoutMs` budget never reached (also listed in `unaudited[]`). Grading a sweep on whichever subset happened to finish would let a CI step keyed on `gate.passed` go green over ungraded code, so an incomplete sweep never passes. A file audited only partially (its `complete` is `false`) fails on the same basis.
The gate object always carries `minScore`, `passed`, `failingFiles`, and `notGraded` whenever `minScore` was supplied:
- `failingFiles`: workspace-relative paths that were measured and scored below `minScore`.
- `notGraded`: `{ "errored": <count>, "unaudited": <count> }`, the files that produced no score at all.
- `reason`: present only on a failure, and the only machine-readable way to tell the two causes apart:
- `"below_threshold"`: at least one file was measured and scored too low (`failingFiles` is non-empty).
- `"files_not_graded"`: every measured file passed, but something was never measured (`failingFiles` is empty and `notGraded` is non-zero).
A `passed: false` with an empty `failingFiles` is therefore expected, not a bug: check `reason` and `notGraded` before assuming a score problem.
### CI use case
```bash
# Fail CI if any audited file scores below 80%
mcp call triage_test_coverage '{"paths":["src"],"minScore":80}' \
| jq -e '.gate.passed'
```
An agent or CI script reads `gate.passed` and decides whether to block the build, open an issue, or continue. The tool call itself always succeeds (never `isError`) regardless of the gate outcome.
## Configuration
Create a `chaos-mcp.config.json` in your workspace root for default settings:
```json
{
"defaultTimeoutMs": 300000,
"mutatorDenylist": ["StringLiteral"],
"concurrency": 4,
"defaultMaxFiles": 25,
"defaultMaxSurvivors": 10,
"defaultSeverityFloor": "medium",
"defaultFileConcurrency": 4,
"container": {
"mode": "auto",
"runtime": "docker",
"cpus": 2,
"memoryMb": 4096
}
}
```
Tool call arguments override config defaults.
| Config key | Type | Default | Description |
| ------------------------ | --------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `defaultTimeoutMs` | `number` | `300000` | Per-file timeout in ms |
| `mutatorDenylist` | `string[]` | `[]` | Mutator names to exclude globally |
| `concurrency` | `number` | `4` | Parallel mutation workers |
| `defaultMaxFiles` | `number` | `25` | Default triage file cap (integer ≥ 1); overridden by the `maxFiles` argument |
| `defaultMaxSurvivors` | `number` | `10` | Default cap on survivor/no-coverage groups returned by `audit_code_resilience` (integer ≥ 1); overridden by the `maxSurvivors` argument |
| `defaultSeverityFloor` | `"high"` \| `"medium"` \| `"low"` | – | Default severity floor for survivor reporting; overridden by the `severityFloor` argument |
| `defaultFileConcurrency` | `number` | `max(1, min(4, cpus-1))` | Default parallel file count for `triage_test_coverage` (integer 1–64); overridden by the `fileConcurrency` argument |
| `infection` | `object` | – | PHP/Infection overrides. `testFrameworkOptions` controls mutation-phase PHPUnit runs; `coverageTestFrameworkOptions` narrows only the initial coverage pass with `--testsuite`, `--filter`, `--group`, or `--exclude-group` |
| `container` | `object` | `{ "mode": "native" }` | Optional shared OCI execution backend for TypeScript, Python, Rust, and PHP |
| `sandbox` | `object` | `{ "dependencies": "link-entries" }` | Sandbox provisioning. `dependencies` chooses how `node_modules`, `.venv`/`venv` and `vendor` are materialised , see below |
| `resources` | `object` | computed from the machine | Memory governance. `watchdog` (default `true`) stops the newest run before a sweep can take the machine down; set `false` to disable the sampler while sizing and admission still apply. `admissionFloorBytes` (default `max(1 GiB, 15% of the effective limit)`) is the memory that must stay free before another file starts. `criticalFloorBytes` (default `max(512 MiB, 7% of the effective limit)`) is the level below which the newest run is stopped |
| `stryker` | `object` | – | StrykerJS settings for TypeScript/JavaScript targets. `testRunner` overrides runner detection, see below |
### Overriding the StrykerJS test runner
Chaos-MCP detects the target's test runner from its config files, dependencies
and `test` script, then maps it to something StrykerJS can drive. vitest 2, 3
and 4 use StrykerJS's native `@stryker-mutator/vitest-runner`, which reports
per-mutant coverage so only the tests that actually cover a mutant are run.
Runners with no Stryker plugin (bun, `node:test`), and vitest outside the
verified 2 to 4 window, fall back to Stryker's built-in command runner: it drives
any framework as a black box, at the cost of re-running the whole related-test
set for every mutant.
The upper end of that window is measured, not read off a peer range. The runner
declares an open `vitest: >=2.0.0`, but on vitest 5 it reports **every mutant as
Survived**: no error, and the dry run still succeeds. Since a survivor is how
this tool reports a coverage hole, that turns a healthy suite into a wall of
false findings. vitest 5 projects therefore get the command runner, which was
checked against the same fixture and returns the correct split. The window moves
up again when a newer vitest is measured, not when a peer range permits it.
To override the detection, name the runner yourself:
```json
{ "stryker": { "testRunner": "vitest" } }
```
Your config is trusted, so this wins over anything detected in the workspace.
Use it when detection guesses wrong, or to force the command runner (`"command"`)
if a native runner misbehaves on your project.
### Sandbox dependencies
```json
{ "sandbox": { "dependencies": "link-entries" } }
```
| `dependencies` | What the sandbox gets | A write under it |
| -------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `link-entries` | **Default.** A real directory holding one symlink per installed package | To a **new** path (`.vite-temp`, a lockfile, a cache) stays in the sandbox; **through** an existing package still reaches your tree |
| `copy` | A full copy of the tree | Always stays in the sandbox. The only mode that fully contains a suite which writes through its own dependencies , and the slowest |
| `share` | One symlink for the whole directory (the pre-1.8 behaviour) | Always reaches your real workspace. Opt in knowingly |
PHP's `vendor/` is always copied regardless of this setting, because Composer's
autoloader resolves `__DIR__` through symlinks back to the real workspace.
An unrecognised value is dropped and the default applies.
### Container execution
Container mode removes the need to install StrykerJS, Cosmic Ray,
cargo-mutants, or Infection on the host. Chaos-MCP starts one hardened,
short-lived container per audit, mounts the temporary sandbox at `/workspace`,
and runs both prebuild and mutation commands in that session. The real
workspace is never mounted. Recognized dependency trees linked into the
sandbox may be mounted separately and read-only, as described below.
```json
{
"container": {
"mode": "container",
"runtime": "docker",
"network": "bridge",
"cpus": 2,
"memoryMb": 4096,
"pidsLimit": 512,
"startupTimeoutMs": 60000,
"tmpfsSizeMb": 2048
}
}
```
> **On Windows, the container process runs as root.** On Linux and macOS
> Chaos-MCP passes `--user <your uid>:<your gid>`, so the audited suite runs as
> you. Windows has no POSIX uid/gid to pass, and hardcoding one risks making the
> `/workspace` bind mount unwritable under Docker Desktop, so no user is set and
> the container falls back to its image default. The container is still
> short-lived, resource-capped and network-scoped, and the audited code was
> already going to execute either way, but it is worth knowing that on Windows
> it executes as uid 0 inside that container.
Modes:
- `native` (default) preserves the existing host-subprocess behavior.
- `container` requires Docker or Podman and fails clearly when unavailable.
- `auto` uses containers when the configured runtime is reachable and otherwise
falls back to native. Image or project failures do not silently fall back.
Each image carries exactly one language runtime, so a suite that spawns another
language's toolchain cannot run inside it. Override the mode for just that
language rather than giving up containers everywhere:
```json
{
"container": {
"mode": "container",
"modes": { "php": "native" }
}
}
```
Container settings:
| Key | Type | Default | Description |
| ------------------ | ----------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `native \| container \| auto` | `native` | Select the execution backend or runtime-only fallback behavior |
| `modes` | per-language mode map | none | Override `mode` for `typescript`, `python`, `rust`, or `php` individually; wins over `mode` for that language |
| `runtime` | `docker \| podman` | `docker` | OCI-compatible command used to create and manage audit containers |
| `network` | `string` | `bridge` | Container network mode or name; use `none` when project tests need no network |
| `cpus` | positive number | `2` | CPU limit for each audit container |
| `memoryMb` | positive integer | `4096` | Memory limit in MiB for each audit container |
| `pidsLimit` | positive integer | `512` | Maximum number of processes in each audit container |
| `startupTimeoutMs` | positive integer | 60 s startup; 10 s probe | Override the timeout for runtime probing, container creation, and startup |
| `tmpfsSizeMb` | positive integer | `2048` | Size of the writable `/tmp`. The container root filesystem is read-only and host dependency directories are mounted read-only, so `/tmp` holds every toolchain cache (Cargo registry, npm cache, per-mutant working files) |
| `images` | per-language image map | release-matched GHCR tags | Override the `typescript`, `python`, `rust`, or `php` image |
The images pin the language runtime and mutation engine, while the project
still supplies its own test dependencies. Which dependency trees get mounted
follows `sandbox.dependencies`: under `link-entries` (the default) and `share`
the **host** trees (`node_modules`, `.venv`/`venv`, and `vendor`) are
bind-mounted read-only at their own absolute paths, which is what makes the
sandbox's symlinks into them resolve inside the container, with one exception:
`node_modules/.vite-temp` gets a small writable tmpfs, because Vite writes a
bundled copy of the config it is loading there and a read-only tree would fail
the config load of every vitest project. The scratch is discarded with the
container and project test code still cannot write to the real dependency tree.
Under `copy` nothing extra is mounted and no tmpfs is needed, because the copies
already live inside the sandbox, which is itself mounted writable at
`/workspace`.
Dependencies containing native
extensions must be compatible with the selected Linux image; use an image
override when the project requires another runtime or platform build.
Chaos-MCP selects release-matched GHCR images for all four languages by
default; the optional `images` map accepts per-language tags or digest-pinned
references for private mirrors and custom runtimes.
The official images are published for Linux AMD64 and ARM64:
| Language | Default image |
| ----------------------- | ----------------------------------------------- |
| TypeScript / JavaScript | `ghcr.io/araneadev/chaos-mcp-typescript:vX.Y.Z` |
| Python | `ghcr.io/araneadev/chaos-mcp-python:vX.Y.Z` |
| Rust | `ghcr.io/araneadev/chaos-mcp-rust:vX.Y.Z` |
| PHP | `ghcr.io/araneadev/chaos-mcp-php:vX.Y.Z` |
The server never installs target-project dependencies. It reuses the recognized
dependency directories the project already has (`node_modules`, `.venv`/`venv`,
and `vendor`), mounting the host trees read-only under `link-entries` and
`share` and using the sandbox's own copies under `copy`. Install the target
project's dependencies before auditing it.
Containers run with a read-only root filesystem, all Linux capabilities
dropped, `no-new-privileges`, a private temporary filesystem, and configurable
CPU, memory, and PID limits. Resource usage defaults to a conservative two CPUs
and 4096 MiB of memory. The entire container is forcibly removed on timeout,
cancellation, normal completion, or engine failure.
Network mode is part of the audit's isolation boundary. The `bridge` default
allows outbound access for tests or dependency resolution; use
`"network": "none"` for untrusted code or offline audits that do not require
network access. Avoid host networking unless the target project explicitly
requires it and you accept the reduced isolation.
Check runtime connectivity and whether all four configured images are already
present without pulling anything:
```sh
node build/index.js --container-doctor
```
The doctor exits non-zero when the runtime is unavailable or any configured
image is missing. Pull the reported image, or set a matching entry in
`container.images`, and run it again. `startupTimeoutMs` only governs runtime
probing and container startup; `defaultTimeoutMs` and per-tool `timeoutMs`
govern the mutation audit itself.
### Enabling `prebuildCommand`
The `prebuildCommand` tool argument runs an arbitrary shell command inside the sandbox, which can reach outside it. It is **disabled by default**. Enable it explicitly with `"allowPrebuild": true` in `chaos-mcp.config.json`, or by setting the `CHAOS_MCP_ALLOW_PREBUILD=1` environment variable. The auto-detected prebuild for Rust (`cargo check`) runs without this flag.
### Python test commands declared by the audited project
Mutation testing runs the audited project's test suite. That is the job, but
the Python engine resolves its test command partly from the **audited
project's own** `pyproject.toml`, via the `[tool.mutmut] runner` key, and
cosmic-ray executes that string through a shell once per mutant. Accepting an
arbitrary command line from repository content is the same hazard
`prebuildCommand` is gated for, so it is bounded the same way:
- A **bare executable name** (`nose2`, `ward`, `green`) is accepted. It can name
a program to run and nothing else: no arguments, no `;`, `|`, `&&`, `$(...)`,
or redirects.
- Anything else is **refused with an explanation** rather than silently replaced
with pytest, which would quietly change which tests can kill a mutant.
To run such a command deliberately, either set it in **your** config (which is
trusted, being your file):
```json
{ "cosmicray": { "testRunner": "python -m unittest discover" } }
```
or set `CHAOS_MCP_ALLOW_REPO_TEST_COMMAND=1` to trust project-declared commands
in this workspace.
### Auditing workspaces outside the working directory
By default Chaos-MCP only audits files beneath the directory the process was
launched in: the workspace-root walk stops at `process.cwd()`, and the sandbox
refuses to copy anything that escapes it. For an MCP server, which is launched
once with a fixed cwd, that means a server started in project A cannot audit
project B at all.
Set `CHAOS_ALLOWED_ROOTS` to name additional roots, separated by the platform
path delimiter (`:` on POSIX, `;` on Windows):
```json
{
"mcpServers": {
"chaos-mcp": {
"command": "node",
"args": ["/path/to/Chaos-MCP/build/index.js"],
"env": { "CHAOS_ALLOWED_ROOTS": "/srv/project-b:/srv/project-c" }
}
}
}
```
A workspace is accepted when it is inside the working directory **or** inside
one of these roots; everything else is still refused. Descendants of a listed
root are included, siblings and parents are not. When roots nest (a monorepo and
one of its packages), the innermost match bounds the root walk. Leaving the
variable unset keeps the cwd-only behaviour exactly as before.
## Supported test runners (auto-detected)
| Language | Mutation Tool | Detected Runners |
| ------------- | ------------- | -------------------------------------------- |
| TypeScript/JS | StrykerJS | vitest, jest, mocha, jasmine, bun, node:test |
| Python | cosmic-ray | pytest, unittest |
| Rust | cargo-mutants | cargo test, cargo-nextest |
| PHP | Infection | phpunit |
## CLI flags
```
chaos-mcp [flags]
--version Print version and exit
--help Show help text and exit
--config Path to a JSON config file
--container-doctor
Check runtime connectivity and whether all four configured
container images are present without pulling them
--verbose Enable diagnostic logging to stderr
```
## Protocol features
### Progress notifications
When an MCP client includes a `progressToken` in a tool call's `_meta` field, Chaos-MCP emits `notifications/progress` events during the run. Clients that omit `progressToken` receive no notifications , there is zero overhead for clients that do not opt in.
**Triage** emits one notification per file as it completes:
| Field | Value |
| ---------- | ---------------------- |
| `progress` | files completed so far |
| `total` | total files to audit |
| `message` | `"audited X/N"` |
**Audit** emits four coarse milestones:
| `progress` | `total` | `message` |
| ---------- | ------- | --------------------------- |
| 1 | 4 | `"validating"` |
| 2 | 4 | `"provisioning sandbox"` |
| 3 | 4 | `"running mutation engine"` |
| 4 | 4 | `"complete"` |
**Estimate** does not emit progress notifications.
### Cancellation
Cancelling an in-flight MCP request aborts the run cleanly:
- The abort signal propagates through the tool handler into `RunOptions.signal` and from there into the mutation engine subprocess, terminating it.
- The sandbox is always cleaned up even if cancellation occurs mid-run.
- The cancelled call returns `"Operation cancelled."` as a tool error rather than throwing.
All three tools (`audit_code_resilience`, `triage_test_coverage`, `estimate_audit`) respect cancellation.
### Resources
The server exposes three static resources, discoverable via `resources/list` and readable via `resources/read`:
| URI | MIME type | Contents |
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chaos://languages` | `application/json` | Per-language entry: engine name, `supportsLineScope`, estimate fidelity (`"exact"` or `"approx"`), config key, and whether an auto-prebuild runs. |
| `chaos://config-schema` | `application/json` | Every `chaos-mcp.config.json` key with its type and a short description. |
| `chaos://capabilities` | `text/markdown` | All three tools (args summary) and the triage → audit → verify workflow loop. |
### Prompts
The server exposes two prompts, discoverable via `prompts/list` and retrieved via `prompts/get`:
| Prompt | Required argument | Purpose |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harden_file` | `filePath` | Returns a `user`-role message walking an agent through: optional estimate → audit → write tests for survivors → verify by `runId` → repeat until clean. |
| `triage_changes` | `diffBase` | Returns a `user`-role message walking an agent through: triage changed files weakest-first → harden the weakest → move down the ranking until the score bar is met. |
## Development
```bash
npm run check # Full CI pipeline: build + lint + format + test
npm run test:watch # Watch mode for iterative development
npm run test:coverage # Tests with coverage report
```
The suite runs on every push/PR to `main` via [CI](./.github/workflows/ci.yml) (Node 24/26). v8 line/statement coverage of `src/` sits at **~99%**, and the source is additionally hardened by running Chaos-MCP against its own code, so the suite is graded by mutation score rather than by line coverage alone.
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for detailed development setup and contribution guidelines.
## Further reading
- [AI writes the tests. Mutation testing checks if they work.](https://tim-schipper.nl/en/blog/ai-generated-tests-mutation-testing)
## License
MIT. See [LICENSE](LICENSE) for details.
## Links
- [MCP Documentation](https://modelcontextprotocol.io/)
- [StrykerJS](https://stryker-mutator.io/)
- [cosmic-ray](https://github.com/sixty-north/cosmic-ray)
- [cargo-mutants](https://github.com/sourcefrog/cargo-mutants)
- [Infection](https://infection.github.io/)
- [Changelog](CHANGELOG.md)
---
Built by [Tim Schipper](https://tim-schipper.nl/en) and released as open source under
[Aranea Development](https://aranea-development.nl).
TDQS
Scored across 3 tools
Each tool occupies a distinct stage of the workflow: estimate_audit is pre-flight sizing, audit_code_resilience is a deep single-file mutation audit, and triage_test_coverage is a batch leaderboard. There is no meaningful overlap in purpose, and the descriptions make the differences explicit.
All three tool names follow a consistent verb_noun snake_case pattern: estimate_audit, audit_code_resilience, triage_test_coverage. The verbs clearly indicate the operation and the objects describe the domain artifact being acted on.
Three tools is minimal but well-scoped for a focused mutation-testing server: estimate, single-file audit, and batch triage. Each tool earns its place and there is no filler or redundant utility.
The tool set covers the full workflow: triage to find weak files, estimate to decide whether to audit, and audit to get detailed per-mutant survivor information. No obvious gaps exist for the stated purpose of mutation-testing coverage analysis.