Skip to main content

toolproof

CI PyPI License: MIT

pytest for MCP servers. Write repeatable tests for any Model Context Protocol server's tools and run them in CI.

  • Static checks on every tool definition: missing descriptions, invalid JSON Schemas, duplicate names, required fields that don't exist
  • YAML test cases: call a tool and assert on errors, text, regex, JSONPath values, latency and the declared outputSchema
  • Crash detection: if the server dies mid-test, toolproof reports it with the server's stderr, restarts the server and keeps going
  • Fuzzing: generates valid and invalid inputs from each tool's schema with Hypothesis, finds crashes, hangs and leaked tracebacks, and shrinks each one to the smallest failing input
  • Benchmarks: p50/p95/p99 latency, throughput and error rate under concurrency, with thresholds that fail the build
  • CI-friendly output: exit code 0/1, JUnit XML and JSON reports
  • pytest plugin: an mcp_server fixture for tests written in Python
  • Works with any server over stdio or streamable HTTP, in any language

Install

pip install mcp-toolproof

Python 3.11+. The package is called mcp-toolproof on PyPI. The command and the import are both toolproof.

30-second quickstart

Look at a server:

toolproof inspect -- python my_server.py
toolproof inspect --url http://localhost:8000/mcp

Write toolproof.yaml:

server:
  command: ["python", "my_server.py"]

tests:
  - name: toronto weather
    tool: get_weather
    args: { city: Toronto }
    expect:
      is_error: false
      contains: "Toronto"
      jsonpath: { "$.temp_c": { type: number } }
      max_latency_ms: 2000

  - name: rejects empty city
    tool: get_weather
    args: { city: "" }
    expect: { is_error: true }

Run it:

toolproof run toolproof.yaml --junit report.xml

Fuzz and benchmark it, no test cases needed:

toolproof fuzz -- python my_server.py
toolproof bench --tool get_weather --args '{"city": "Toronto"}' -- python my_server.py

What a failing run looks like

examples/buggy_server.py has five planted bugs. Here is toolproof run examples/buggy.yaml:

toolproof - buggy-weather 0.0.1

Static checks
┌──────┬───────────────┬─────────────────┬──────────────────────────────────────────────────────────────────┐
│      │ Tool          │ Check           │ Problem                                                          │
├──────┼───────────────┼─────────────────┼──────────────────────────────────────────────────────────────────┤
│ FAIL │ search_cities │ description     │ tool has no description                                          │
│ FAIL │ search_cities │ input-schema    │ input schema is not valid JSON Schema: 'strng' is not valid      │
│      │               │                 │ under any of the given schemas                                   │
│ FAIL │ lookup        │ required-fields │ required fields not listed in properties: city_id                │
└──────┴───────────────┴─────────────────┴──────────────────────────────────────────────────────────────────┘

Tests
┌──────┬────────────────────────────────────┬─────────────────┬────────┬────────────────────────────────────┐
│      │ Test                               │ Tool            │   Time │ Details                            │
├──────┼────────────────────────────────────┼─────────────────┼────────┼────────────────────────────────────┤
│ PASS │ weather works for a normal city    │ get_weather     │    5ms │                                    │
│ FAIL │ empty city should be an error, not │ get_weather     │   23ms │ no result from server: connection  │
│      │ a crash                            │                 │        │ lost, server probably crashed      │
│      │                                    │                 │        │ (Connection closed)                │
│ PASS │ server still works after the crash │ get_weather     │    3ms │                                    │
│ FAIL │ report comes back in time          │ slow_report     │ 1001ms │ no result from server: timed out   │
│      │                                    │                 │        │ after 1.0s                         │
│ FAIL │ temperature matches its output     │ get_temperature │    2ms │ output schema: $.temp_c: '12.5' is │
│      │ schema                             │                 │        │ not of type 'number'               │
└──────┴────────────────────────────────────┴─────────────────┴────────┴────────────────────────────────────┘

FAILED  tests: 2 passed, 3 failed  |  checks: 3 errors, 0 warnings  |  7.21s

The same run as JUnit XML (--junit report.xml), which GitHub, GitLab and Jenkins show as a normal test report:

<testsuite name="tests" tests="5" failures="3" errors="0" time="1.034">
  <testcase classname="toolproof.get_weather" name="weather works for a normal city" time="0.005" />
  <testcase classname="toolproof.get_weather" name="empty city should be an error, not a crash" time="0.023">
    <failure message="no result from server: connection lost, server probably crashed (Connection closed)">...</failure>
  </testcase>
  ...
</testsuite>

YAML reference

server:
  command: ["python", "server.py"]   # start the server over stdio...
  # url: http://localhost:8000/mcp   # ...or connect over streamable HTTP (exactly one)
  env: { API_KEY: test }             # extra environment variables (stdio only)
  cwd: .                             # working directory, relative to this file (default: this file's folder)
  startup_timeout_s: 30

timeout_ms: 10000        # default per-test timeout
retries: 0               # retry failing tests this many times

checks:
  enabled: true
  max_description_length: 1024
  strict: false          # treat warnings as failures

tests:
  - name: a readable name
    tool: tool_name
    args: { any: json }
    timeout_ms: 2000     # optional override
    retries: 1           # optional override
    expect:
      is_error: false
      equals: { result: 42 }         # whole result: structured content, JSON text, or plain text
      contains: "Toronto"            # or a list: ["Toronto", "cloudy"]
      regex: "\\d+ C"
      jsonpath:
        "$.temp_c": { type: number, min: -50, max: 50 }
        "$.city": { equals: Toronto }
        "$.wind": { exists: false }
      max_latency_ms: 2000
      output_schema: true            # validate against the tool's outputSchema (default on)

bench:                   # optional; `toolproof run` includes it when present
  calls: 100             # per target
  concurrency: 10
  warmup: 3
  timeout_ms: 10000
  thresholds:            # all optional
    p50_ms: 50
    p95_ms: 200
    p99_ms: 500
    max_error_rate: 0.01
    min_throughput: 100  # calls per second
  targets:               # default: every test case that expects success
    - tool: get_weather
      args: { city: Toronto }
      thresholds: { p95_ms: 100 }   # per-target override

fuzz:                    # optional; `toolproof run` includes it when present
  max_examples: 50       # per tool, for valid and for invalid inputs
  timeout_ms: 2000
  max_time_s: 60         # per tool
  seed: 1                # fix it for repeatable CI runs
  tools: []              # only these (default: all)
  skip: [send_email]
  include_destructive: false
  valid_must_succeed: false
  invalid_must_fail: true

Notes:

  • jsonpath looks at the tool's structured content if it returned any, otherwise at its text parsed as JSON.
  • type is a JSON Schema type: string, number, integer, boolean, array, object, null.
  • If a tool declares an outputSchema, every successful call is validated against it automatically.
  • Paths in command and cwd are relative to the YAML file, so toolproof run path/to/toolproof.yaml works from anywhere.

Static checks

Check Severity What it catches
name error tool with an empty name
duplicate-name error two tools with the same name
description error tool with no description
description-length warning description longer than max_description_length
input-schema error input schema that isn't valid JSON Schema, or whose root isn't type: object
required-fields error required lists a field that isn't in properties
required-fields warning tool has properties but none are marked required
output-schema error outputSchema that isn't valid JSON Schema

Errors fail the run. Warnings only fail it with --strict.

Fuzzing

toolproof fuzz calls every tool with inputs generated from its input schema, using Hypothesis and hypothesis-jsonschema:

  • valid inputs match the schema, mixed with edge cases that still match it: empty, whitespace, huge (100k characters), emoji, right-to-left text, null bytes, SQL and path-traversal strings
  • invalid inputs take a valid input and break it: drop a required field, set a field to null, or give it the wrong type

Each call is judged like this:

Finding Meaning
crash the server process died or the connection dropped
hang no answer within timeout_ms
internal-error a JSON-RPC error other than "invalid params"
unhandled-exception an error result that leaks a traceback or the SDK's generic crash message
output-schema a successful result that doesn't match the tool's outputSchema
rejected-valid a schema-valid input rejected as invalid params
accepted-invalid an invalid input that returned success (turn off with invalid_must_fail: false)
unexpected-error any error for a valid input (only with valid_must_succeed: true)
bad-schema the input schema is broken, so no inputs can be generated

Hypothesis shrinks every failure, so you get the smallest input that triggers it. Crashed servers are restarted automatically. Every run prints its seed; pass --seed to repeat it exactly.

Running it on examples/buggy_server.py finds all five planted bugs with no hand-written tests:

Fuzz (seed 3)
┌──────┬─────────────────┬───────┬────────────────────────────┬─────────────────┬───────────────────────────────────────────────┐
│      │ Tool            │ Calls │ Finding                    │ Smallest input  │ Details                                       │
├──────┼─────────────────┼───────┼────────────────────────────┼─────────────────┼───────────────────────────────────────────────┤
│ FAIL │ get_weather     │    22 │ crash (valid)              │ {"city": ""}    │ connection lost, server probably crashed      │
│      │                 │       │ accepted-invalid (invalid) │ {"city": []}    │ invalid input returned a success result       │
│      │                 │       │ crash (invalid)            │ {}              │ connection lost, server probably crashed      │
│ FAIL │ search_cities   │     0 │ bad-schema (valid)         │ {}              │ can't generate inputs: 'strng' is not valid   │
│ FAIL │ slow_report     │     1 │ hang (valid)               │ {"city": ""}    │ timed out after 2.0s                          │
│ FAIL │ get_temperature │    24 │ output-schema (valid)      │ {"city": ""}    │ output schema: $.temp_c: '12.5' is not of     │
│      │                 │       │                            │                 │ type 'number'                                 │
│      │                 │       │ accepted-invalid (invalid) │ {}              │ invalid input returned a success result       │
│ FAIL │ lookup          │    15 │ internal-error (valid)     │ {"city_id": {}} │ JSON-RPC error -32603: Internal server error  │
│      │                 │       │ internal-error (invalid)   │ {}              │ JSON-RPC error -32603: Internal server error  │
└──────┴─────────────────┴───────┴────────────────────────────┴─────────────────┴───────────────────────────────────────────────┘
Repeat this run with --seed 3

Be careful with tools that change things. Fuzzing calls every tool many times with odd arguments. Tools annotated with destructiveHint: true are skipped unless you pass --include-destructive. For anything else that sends email, deletes data or spends money, add it to fuzz.skip or point toolproof at a test instance.

Benchmarks

toolproof bench calls each target calls times with concurrency calls in flight over one connection, after a few warm-up calls, and reports p50/p95/p99 latency, mean and max, calls per second and error rate:

Bench
┌──────┬─────────────────────────────────┬───────┬───────┬────────┬────────┬─────────┬────────┬─────────┐
│      │ Target                          │ Calls │   p50 │    p95 │    p99 │ Calls/s │ Errors │ Details │
├──────┼─────────────────────────────────┼───────┼───────┼────────┼────────┼─────────┼────────┼─────────┤
│ PASS │ toronto weather                 │ 50 x5 │ 8.3ms │ 10.8ms │ 11.9ms │   583.0 │   0.0% │         │
│ PASS │ city names are case insensitive │ 50 x5 │ 8.8ms │ 24.5ms │ 24.9ms │   480.9 │   0.0% │         │
│ PASS │ five day forecast               │ 50 x5 │ 9.3ms │ 20.1ms │ 21.3ms │   477.8 │   0.0% │         │
│ PASS │ list cities                     │ 50 x5 │ 8.1ms │ 10.1ms │ 14.3ms │   600.7 │   0.0% │         │
└──────┴─────────────────────────────────┴───────┴───────┴────────┴────────┴─────────┴────────┴─────────┘

Without --tool or bench.targets, every test case that expects success is benchmarked. A threshold that's broken fails the run, so you can catch a slow tool in CI.

CLI reference

toolproof inspect [--url URL | --config FILE | -- COMMAND...] [--json]
toolproof run [CONFIG] [--junit PATH] [--json PATH] [--timeout-ms N] [--retries N] [--strict] [--no-checks]
              [--skip-bench] [--skip-fuzz]
toolproof fuzz [--url URL | --config FILE | -- COMMAND...] [--examples N] [--timeout-ms N] [--max-time S]
               [--seed N] [--tool NAME]... [--include-destructive] [--valid-must-succeed] [--junit PATH] [--json PATH]
toolproof bench [--url URL | --config FILE | -- COMMAND...] [--tool NAME --args JSON] [--calls N] [--concurrency N]
                [--timeout-ms N] [--p50-ms N] [--p95-ms N] [--p99-ms N] [--max-error-rate R] [--min-throughput N]
                [--junit PATH] [--json PATH]

fuzz, bench and inspect read ./toolproof.yaml when no server is given. CLI flags override the YAML.

Exit codes: 0 all passed, 1 a test, check, threshold or fuzz finding failed, 2 bad config or usage.

pytest plugin

Installing toolproof registers a pytest plugin with an mcp_server fixture. Point it at your server in pyproject.toml:

[tool.pytest.ini_options]
toolproof_command = "python my_server.py"
# toolproof_url = "http://localhost:8000/mcp"
# toolproof_config = "toolproof.yaml"
# toolproof_timeout = "30"

Then write normal tests:

def test_weather(mcp_server):
    r = mcp_server.call("get_weather", city="Toronto")
    assert not r.is_error
    assert r.data["temp_c"] > -50


def test_forecast_length(mcp_server):
    r = mcp_server.call("get_forecast", {"city": "Calgary", "days": 5})
    assert len(r.data["days"]) == 5


def test_schema(mcp_server):
    assert mcp_server.tool("get_weather").input_schema["required"] == ["city"]

call() returns a CallResult with is_error, text, data (structured content or parsed JSON), latency_ms and transport_error. The server starts once per test session.

To set the server up in code, override the mcp_server_config fixture in conftest.py:

import pytest
from pathlib import Path
from toolproof import ServerConfig


@pytest.fixture(scope="session")
def mcp_server_config():
    return ServerConfig(command=["python", "my_server.py"], env={"MODE": "test"}), Path.cwd()

Using it in GitHub Actions

- run: pip install mcp-toolproof
- run: toolproof run toolproof.yaml --junit report.xml
- uses: actions/upload-artifact@v5
  if: always()
  with:
    name: toolproof-report
    path: report.xml

How is this different from MCP Inspector?

MCP Inspector is an interactive debugger: you open a UI, click a tool, type arguments and look at the result. It's great while building a server.

toolproof is for what comes after: tests you write once, keep in the repo and run on every pull request. It has no UI. It gives you assertions, an exit code and a JUnit report, so a broken tool fails the build instead of being found by a user.

Try it on the examples

git clone https://github.com/NilkanthSuthar/toolproof
cd toolproof
pip install -e .
toolproof run examples/toolproof.yaml          # passes: tests, bench and fuzz
toolproof run examples/buggy.yaml              # fails, on purpose
toolproof fuzz -- python examples/buggy_server.py   # finds all five planted bugs

Roadmap

  • v0.1: inspect, static checks, YAML tests, console/JUnit/JSON reports, pytest plugin
  • v0.2: toolproof fuzz (schema-based fuzzing with Hypothesis) and toolproof bench (p50/p95/p99 latency, throughput)
  • v0.3: LLM tool-selection evals, a reusable GitHub Action, an HTML report, docs site

Development

pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
mypy

The test suite starts the example servers for real, over stdio and HTTP.

License

MIT

Metadata

Release files for mcp-toolproof 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcp-toolproof 0.2.0
File Size Uploaded
mcp_toolproof-0.2.0.tar.gz 42.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcp-toolproof 0.2.0
File Interpreter ABI Platform
mcp_toolproof-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 78.7 kB

Release files / mcp_toolproof-0.2.0.tar.gz

Download URL mcp_toolproof-0.2.0.tar.gz
Size 42.1 kB
Tags Source
SHA-256 checksum
How to use checksums
6c9abf8f446b549a628151296a1a16be0d9af3ba5932f54fcae6b095150068c3
BLAKE2b-256 checksum
How to use checksums
9b16af6222d3c98f7c0ee1bab745ef2e84cc78f31c04c961942f180878582fed
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / mcp_toolproof-0.2.0-py3-none-any.whl

Download URL mcp_toolproof-0.2.0-py3-none-any.whl
Size 36.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1cd920ef6b6cd0747a20ab6116fb40db930743d2873ccc3f2e8193b8c29c4e56
BLAKE2b-256 checksum
How to use checksums
9043137cddba8ca40947ce819f02475e6b02db049f929e3b64ba78c7d00291d6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.1

2 release files

0.3.0

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page