pytest-evidence-mcp
MCP server that gives an AI agent access to structured evidence about pytest test failures.
The server delivers deterministic data — it does not diagnose. Diagnosis is the agent's job. It exposes four tools (list_failed_tests, get_test_failure, inspect_fixture, parse_pytest_output), one documentation resource per tool, and one prompt template per tool.
Prerequisites
- Python 3.10+.
- pytest installed in the target project's own environment (the project being investigated), not in this server's environment. This server does not depend on pytest as a package — it locates and calls the pytest already installed where the code under investigation lives (its
.venv, by default). If pytest isn't installed there,list_failed_tests/get_test_failureraisePytestNotFoundErrorwith a clean message instead of crashing. - An MCP client that supports stdio transport (Claude Code, VS Code with the Copilot Chat MCP integration, or any other MCP-compatible client).
uvinstalled, if you're running the server viauvx(recommended, see below) rather than from a local clone. Any reasonably recent version works (tested with 0.12.x).
Quick start: running from PyPI (recommended)
No clone, no virtualenv to manage — uvx downloads the package and runs it in an isolated, disposable environment on demand:
uvx pytest-evidence-mcp
It should log Starting MCP Server. to stderr and wait on stdin (this is a stdio server — running it standalone in a terminal is only useful to sanity-check that it starts; stop it with Ctrl+C). This is also the command your MCP client will run under the hood (see "Registering with an MCP client" below).
Updating to a new version: uvx resolves the latest version compatible with your Python at each run, but may reuse a cached resolution. To force it to pick up a version you just published:
uvx --refresh-package pytest-evidence-mcp pytest-evidence-mcp
Alternative: running from a local clone (for development)
Only needed if you're modifying this server itself, not to investigate a target project's tests.
git clone https://github.com/wilcsonaraujo/pytest-evidence-mcp
cd pytest-evidence-mcp
python -m venv .venv
.venv/bin/pip install -e ".[dev]" # Linux/macOS
.venv\Scripts\pip install -e ".[dev]" # Windows
To confirm it starts correctly:
.venv/bin/python -m pytest_evidence_mcp
Registering with an MCP client
Claude Code, from PyPI:
claude mcp add --transport stdio --scope project pytest-evidence-mcp -- uvx pytest-evidence-mcp
Claude Code only loads MCP servers when a session starts — if you register the server while a session is already open, restart the session (or run /mcp) before using the tools.
VS Code (Copilot Chat), from PyPI, .vscode/mcp.json in the workspace you'll investigate:
{
"servers": {
"pytest-evidence-mcp": {
"type": "stdio",
"command": "uvx",
"args": ["pytest-evidence-mcp"]
}
}
}
From a local clone instead (development), point command/the executable at the .venv created above rather than at uvx:
claude mcp add --transport stdio --scope project pytest-evidence-mcp -- "/absolute/path/to/pytest-evidence-mcp/.venv/bin/python" -m pytest_evidence_mcp
{
"servers": {
"pytest-evidence-mcp": {
"type": "stdio",
"command": "/absolute/path/to/pytest-evidence-mcp/.venv/bin/python",
"args": ["-m", "pytest_evidence_mcp"]
}
}
}
(On Windows, use .venv\Scripts\python.exe.)
Any other MCP client that supports a generic stdio server definition (command + args) can be registered the same way, from either source.
How it decides where the data comes from
list_failed_tests and get_test_failure resolve the most recent test run for a project through a priority chain, tried in order:
.report.json(pytest-json-report) — read if it exists, at the path declared in the project's own pytest config (addoptsinpytest.ini,pyproject.toml,tox.iniorsetup.cfg, in that precedence order) or at the default.report.json.junit.xml— same lookup, falling back to the defaultjunit.xml.- Run pytest now, via subprocess, if neither report exists. This is a real, fresh execution every time — the temporary report it generates is deleted right after parsing, so there is no caching in this branch, and a repeated call without a persisted report re-runs the whole suite from scratch.
The source field in list_failed_tests' output tells you which branch was used, and age_seconds tells you how old that data is.
The four tools
list_failed_tests(path: str)
Summarizes the most recent pytest run for a project. Always the starting point of an investigation.
Input:
{ "path": "/home/dev/my-project" }
Output:
{
"source": "json_report",
"generated_at": "2026-08-28T14:35:30.180000",
"age_seconds": 12.4,
"total": 42,
"passed": 40,
"failed": 2,
"skipped": 0,
"failed_tests": [
{
"nodeid": "tests/test_checkout.py::test_apply_discount",
"name": "test_apply_discount",
"error_type": "AssertionError"
},
{
"nodeid": "tests/test_checkout.py::test_apply_discount_negative",
"name": "test_apply_discount_negative",
"error_type": "AssertionError"
}
]
}
A project where nothing fails returns failed_tests: [], not an error.
get_test_failure(test_name: str, path: str, max_output_chars: int = 10000)
Returns the full evidence pytest already collected for one failing test: error, traceback, captured output. No diagnosis.
Input:
{ "test_name": "test_apply_discount", "path": "/home/dev/my-project" }
Output:
{
"error_type": "AssertionError",
"message": "assert 90 == 9.0",
"traceback": "def test_apply_discount():\n> assert apply_discount(100, 10) == 9.0\nE AssertionError: assert 90 == 9.0\n\ntests/test_checkout.py:14: AssertionError",
"actual": "90",
"expected": "9.0",
"stdout": null,
"stderr": null,
"log": null,
"duration_ms": 3
}
test_name accepts either the short name (test_apply_discount) or a full nodeid (tests/test_checkout.py::test_apply_discount). If the short name matches more than one test, the call raises AmbiguousTestNameError listing every matching nodeid — retry with the full nodeid from that list.
Large fields (traceback, actual, expected, stdout, stderr, log) are truncated to max_output_chars, keeping the tail (where the relevant part usually is) and prefixing a ...[N chars omitted]... note.
inspect_fixture(path: str)
Inspects a JSON or YAML fixture file used by a test, to check whether a failure comes from the input data itself rather than the code. path points to the fixture file, not to the project root.
Input:
{ "path": "/home/dev/my-project/fixtures/orders.json" }
Output:
{
"valid": true,
"field_count": 6,
"null_fields": [],
"types": {
"orders[].id": "integer",
"orders[].customer": "string",
"orders[].total": "float",
"orders[23].id": "integer",
"orders[23].customer": "string",
"orders[23].total": "string"
},
"collapsed_lists": {
"orders": 49
}
}
Lists longer than 10 items get their majority shape collapsed into a single path[] entry (collapsed_lists records how many items share that shape), so the output stays bounded on large fixtures. Any item whose shape doesn't match the majority — like orders[23] above, where total is a string instead of a float — is still reported individually, in full, by its real index. That is usually the actual cause of the failure.
An invalid or unparsable file returns {"valid": false, "message": "..."} instead of raising.
parse_pytest_output(raw_text: str, max_output_chars: int = 10000)
Last-resort fallback: extracts failure evidence directly from raw pytest terminal output, for when pytest can't be run again and no .report.json/junit.xml is available (e.g. output pasted from a CI log).
Input:
{ "raw_text": "=================== FAILURES ===================\n_____ test_apply_discount _____\n\n def test_apply_discount():\n> assert apply_discount(100, 10) == 9.0\nE AssertionError: assert 90 == 9.0\n\ntests/test_checkout.py:14: AssertionError\n=============== 1 failed, 41 passed in 0.42s ===============" }
Output:
{
"confidence": "low",
"failures": [
{
"test_name": "test_apply_discount",
"outcome": "failed",
"error_type": "AssertionError",
"message": "assert 90 == 9.0",
"traceback": "...",
"actual": "90",
"expected": "9.0",
"captured_stdout": null,
"captured_stderr": null,
"captured_log": null,
"duration_ms": null
}
]
}
Known limitation: this parser recognizes pytest's own default output format (the === FAILURES === / ==== ERRORS ==== sections and their ----- Captured stdout call ----- subsections). It does not understand output reformatted by a different plugin — most commonly pytest-sugar, which changes the layout enough that these sections stop being recognizable. If the target project has pytest-sugar (or a similar output plugin) installed and active, either disable it for the run you're capturing (pytest -p no:sugar) or use one of the structured sources (branches 1/2 above) instead. confidence: "low" on every response from this tool is a permanent reminder that, unlike the other three tools, this one is reconstructing evidence from free text rather than reading a structured report — treat it as a fallback, not a primary source.
Error handling
Every domain error (PytestNotFoundError, TestNotFoundError, AmbiguousTestNameError, IncompleteEvidenceError, and others in core/errors.py) reaches the calling agent as a clean MCP tool error with a descriptive message, not as a crash or a silently swallowed exception. If a tool call fails, the message itself usually says exactly what to do next (e.g. "install pytest", "use one of these full nodeids").
Resources and prompts
Each tool also has a matching documentation resource (docs://tools/<tool_name>) with its machine-readable contract, and a prompt template (<tool_name>_prompt) that fills in a ready-to-send instruction for that tool given the same arguments. These exist so a client can introspect a tool's real input/output shape, or start an investigation, without leaving the chat.
Development
After the local clone install above (pip install -e ".[dev]"), you can launch the MCP Inspector against the server directly:
mcp dev src/pytest_evidence_mcp/server.py
Run the test suite and checks:
pytest
mypy src/
ruff check .
Build distributable artifacts (wheel + sdist):
uv build
Release files for pytest-evidence-mcp 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pytest_evidence_mcp-0.1.0.tar.gz | 139.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pytest_evidence_mcp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 175.2 kB
Release files / pytest_evidence_mcp-0.1.0.tar.gz
| Download URL | pytest_evidence_mcp-0.1.0.tar.gz |
|---|---|
| Size | 139.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
658aed7f8de1c9ac1b75c6f1598b055094438dcd16f1edeaaee70be328273e03
|
|
BLAKE2b-256 checksum How to use checksums |
a6ff892f24f7ed783cd4b440d5c9ce7ad481eb069d41cddab155be4d2edd08d8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / pytest_evidence_mcp-0.1.0-py3-none-any.whl
| Download URL | pytest_evidence_mcp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 36.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
857b428c4c2ef9c4c7e9f17437cc514c1fcd7885556f54b275696050b35c53a4
|
|
BLAKE2b-256 checksum How to use checksums |
b61b1382e9c6fb46a18f3770c399ed78566b895a713010da50e6be75a94008e3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|