Most MCP servers get a handful of assert result == ... tests bolted on and
nothing more. That misses the failures that actually bite: a tool quietly renamed,
a parameter that changed type, a result that grew a field, an error that stopped
being an error. A server is a contract a model depends on, and the contract needs
its own tests.
This tutorial builds a small, deterministic FastMCP server and then a layered test suite around it with four kinds of tests, each catching a different class of regression:
- Contract tests pin the public interface — the exact tool set and each tool’s input schema.
- Conformance tests assert invariants that must hold for every tool, and
validate real results against each tool’s advertised
outputSchema. - Snapshot tests freeze rich structured results in golden files so an unnoticed change fails loudly.
- Fixtures (an in-memory client, a snapshot helper) keep all of the above short and fast.
The stack is Mac-native: uv, make, and pytest.
The idea: test the contract, not just the math
Two questions have different answers and deserve different tests:
- Does this tool compute the right value? Assert it directly, or snapshot it.
- Is this tool still the same tool the client discovered yesterday? That is the contract: names, schemas, annotations, error behavior. It breaks silently during refactors and is what a conformance test defends.
The trick that keeps conformance tests useful as a server grows is to write them against the tool list at runtime, not a hardcoded name. A rule like “every tool has a description” then protects tools you have not written yet.
What you will build
server.py: the server under test — two tools with structured outputs, a resource, and a prompt, all pure and deterministic.tests/conftest.py: aclientfixture and asnapshotfixture.tests/test_contract.py: contract and conformance tests.tests/test_snapshots.py: golden-file snapshots plus explicit edge cases.- A
makewrapper whosesnapshotstarget regenerates the golden files.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with FastMCP tools and the in-memory
Client(see Build an MCP Server with FastMCP). Structured output is covered in Return Structured Output from a FastMCP Server.
Step 1: Add project hygiene
Create the file
mkdir -p testing-strategy-mcp-macos
cd testing-strategy-mcp-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
- Standard Python ignores plus
.DS_Store, created first so the virtualenv and caches never get committed. Note thattests/snapshots/is not ignored — the golden files are committed artifacts the suite compares against.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio jsonschema
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A testing strategy for MCP servers with FastMCP and pytest"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[dependency-groups]
dev = [
"jsonschema>=4.26.0",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
jsonschemais a dev dependency: it validates tool results against theoutputSchemathe server advertises, and only the tests need it. FastMCP already validates outputs internally, but checking again from the outside proves the advertised contract is what a real client would enforce.
Step 3: The server under test
Keep the server pure so results are a function of inputs alone. No clock, no randomness, no I/O means no flaky tests and no need to mock anything.
Create the file
touch server.py
Add the code: server.py
"""The server under test.
A small, deterministic FastMCP server used to demonstrate a testing method: two
tools with structured (Pydantic) outputs, one resource, and one prompt. It is
intentionally pure — no clock, no randomness, no I/O — so every result is a
function of its inputs and the tests can assert exact values.
"""
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
mcp = FastMCP(name="text-tools")
READ_ONLY = ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)
class WordStats(BaseModel):
"""Structured result for analyze_text."""
words: int = Field(description="Total whitespace-separated tokens")
characters: int = Field(description="Character count including spaces")
unique_words: int = Field(description="Distinct tokens, case-insensitive")
@mcp.tool(annotations=READ_ONLY)
def analyze_text(text: str) -> WordStats:
"""Count words, characters, and distinct words in a string."""
tokens = text.split()
return WordStats(
words=len(tokens),
characters=len(text),
unique_words=len({t.lower() for t in tokens}),
)
@mcp.tool(annotations=READ_ONLY)
def to_celsius(fahrenheit: float) -> float:
"""Convert a Fahrenheit temperature to Celsius, rounded to 2 decimals."""
if fahrenheit < -459.67:
raise ToolError("temperature below absolute zero")
return round((fahrenheit - 32) * 5 / 9, 2)
@mcp.resource("catalog://info")
def catalog_info() -> str:
"""A short description of what this server offers."""
return "text-tools: analyze_text, to_celsius"
@mcp.prompt
def summarize(text: str) -> str:
"""Build a prompt that asks a model to summarize the given text."""
return f"Summarize the following in one sentence:\n\n{text}"
if __name__ == "__main__":
mcp.run()
Detailed breakdown
analyze_textreturns aWordStatsmodel, so FastMCP advertises anoutputSchemaand returnsstructuredContent. That structured result is exactly what the conformance and snapshot tests inspect.to_celsiusreturns a barefloat, which FastMCP wraps under aresultkey (withx-fastmcp-wrap-result: truein the schema). Its guard raises aToolErrorbelow absolute zero — an error path the suite tests explicitly.- The
ToolAnnotationsare part of the contract too; a conformance test checks they are present. The resource and prompt round out a realistic surface.
Step 4: Fixtures — a client and a snapshot helper
Every test needs a connected client, and the snapshot tests need a golden-file
comparator. Both live in conftest.py so pytest injects them by name.
Create the file
mkdir -p tests/snapshots
touch tests/__init__.py
touch tests/conftest.py
Add the code: tests/conftest.py
"""Shared fixtures for the MCP test suite.
Two fixtures do most of the work:
- `client` opens the server over the in-memory transport once per test, so no test
spins up its own connection or reaches the network.
- `snapshot` compares a value against a committed golden JSON file, and rewrites it
when `SNAPSHOT_UPDATE=1` — the pattern behind tool-result snapshot tests.
"""
import json
import os
from pathlib import Path
import pytest
import pytest_asyncio
from fastmcp import Client
from server import mcp
SNAPSHOT_DIR = Path(__file__).parent / "snapshots"
@pytest_asyncio.fixture
async def client():
"""An in-memory client connected to the server under test."""
async with Client(mcp) as c:
yield c
@pytest.fixture
def snapshot():
"""Assert a JSON-serializable value equals its committed snapshot.
Set `SNAPSHOT_UPDATE=1` to (re)write snapshots after an intentional change,
then review the diff before committing.
"""
update = os.environ.get("SNAPSHOT_UPDATE") == "1"
def assert_match(name: str, value) -> None:
path = SNAPSHOT_DIR / f"{name}.json"
serialized = json.dumps(value, indent=2, sort_keys=True)
if update or not path.exists():
path.write_text(serialized + "\n")
return
expected = path.read_text().rstrip("\n")
assert serialized == expected, (
f"snapshot '{name}' changed.\n"
f"--- expected ---\n{expected}\n--- actual ---\n{serialized}\n"
f"If intended, re-run with SNAPSHOT_UPDATE=1."
)
return assert_match
Detailed breakdown
clientis an async fixture (pytest_asyncio.fixture) that opens one in-memoryClientper test and tears it down after. Tests just declare aclientparameter; there is no connection boilerplate anywhere else.snapshotreturns a comparator.json.dumps(..., sort_keys=True)makes the serialization stable regardless of key order, so a snapshot only changes when the data changes. Missing file orSNAPSHOT_UPDATE=1writes the golden file; otherwise it compares and fails with a readable diff.- The
snapshots/directory is committed, so a fresh clone has the golden files to compare against. The first run generates them; every run after that guards them.
Step 5: Contract and conformance tests
Create the file
touch tests/test_contract.py
Add the code: tests/test_contract.py
"""Contract and spec-conformance tests.
These do not check *what a tool computes* — the snapshot tests do that. They check
that the server's advertised interface is stable and well-formed:
- **Contract**: the exact set of tools, and each tool's input schema, so an
accidental rename or a changed parameter type fails loudly.
- **Conformance**: generic invariants that must hold for *every* tool (a
description, an input schema, annotations) plus validation of real results
against each tool's own advertised `outputSchema`.
The conformance tests are written against the tool list, not hardcoded names, so
they keep protecting new tools as the server grows.
"""
import jsonschema
import pytest
# --- Contract: pin the public interface ---------------------------------------
async def test_tool_set_is_stable(client):
names = {t.name for t in await client.list_tools()}
assert names == {"analyze_text", "to_celsius"}
async def test_analyze_text_input_contract(client):
tools = {t.name: t for t in await client.list_tools()}
schema = tools["analyze_text"].inputSchema
assert schema["required"] == ["text"]
assert schema["properties"]["text"]["type"] == "string"
async def test_to_celsius_input_contract(client):
tools = {t.name: t for t in await client.list_tools()}
schema = tools["to_celsius"].inputSchema
assert schema["required"] == ["fahrenheit"]
assert schema["properties"]["fahrenheit"]["type"] == "number"
# --- Conformance: invariants that hold for every tool -------------------------
async def test_every_tool_has_a_description_and_schema(client):
for tool in await client.list_tools():
assert tool.description, f"{tool.name} has no description"
assert tool.inputSchema.get("type") == "object", f"{tool.name} input not object"
async def test_every_tool_names_snake_case(client):
for tool in await client.list_tools():
assert tool.name.islower() and " " not in tool.name
assert tool.name.replace("_", "").isalnum()
@pytest.mark.parametrize(
"name,args",
[
("analyze_text", {"text": "hello world"}),
("to_celsius", {"fahrenheit": 98.6}),
],
)
async def test_results_conform_to_output_schema(client, name, args):
"""Every tool result must validate against the schema the tool advertises."""
tools = {t.name: t for t in await client.list_tools()}
out_schema = tools[name].outputSchema
result = await client.call_tool(name, args)
jsonschema.validate(instance=result.structured_content, schema=out_schema)
Detailed breakdown
test_tool_set_is_stableis the single most valuable test here: a rename, an accidental deletion, or an extra tool leaking in all fail it immediately.- The two input-contract tests pin the required parameters and their types, so
a change from
floattostr, or a newly required argument, is caught. test_every_tool_has_a_description_and_schemaandtest_every_tool_names_snake_caseiterate the live tool list. Add a tool with no docstring or aCamelCasename and these fail without being edited — that is the point of writing them against the runtime list.test_results_conform_to_output_schemacalls each tool and validates the realstructured_contentagainst theoutputSchemathe server published, withjsonschema. It is parametrized, so one function covers every tool.
Step 6: Snapshot tests and explicit edge cases
Create the file
touch tests/test_snapshots.py
Add the code: tests/test_snapshots.py
"""Tool-result snapshot tests.
Snapshots capture the exact shape and values a tool returns and compare them to a
committed golden file. They catch regressions you did not think to assert by hand:
a reordered field, a changed rounding rule, a newly added key. When a change is
intentional, regenerate with `SNAPSHOT_UPDATE=1` and review the diff.
Prefer snapshots for rich, structured results; prefer explicit assertions (below)
for a single scalar or a specific edge case you want to document.
"""
import pytest
# --- Snapshots for structured results -----------------------------------------
async def test_analyze_text_snapshot(client, snapshot):
result = await client.call_tool(
"analyze_text", {"text": "the cat sat on the mat"}
)
snapshot("analyze_text_basic", result.structured_content)
async def test_analyze_text_unicode_snapshot(client, snapshot):
result = await client.call_tool("analyze_text", {"text": "café Café CAFÉ"})
snapshot("analyze_text_unicode", result.structured_content)
# --- Explicit assertions for scalars and edge cases ---------------------------
@pytest.mark.parametrize(
"fahrenheit,expected",
[
(32.0, 0.0),
(212.0, 100.0),
(98.6, 37.0),
(-40.0, -40.0),
],
)
async def test_to_celsius_values(client, fahrenheit, expected):
result = await client.call_tool("to_celsius", {"fahrenheit": fahrenheit})
assert result.data == expected
async def test_to_celsius_rejects_below_absolute_zero(client):
from fastmcp.exceptions import ToolError
with pytest.raises(ToolError, match="absolute zero"):
await client.call_tool("to_celsius", {"fahrenheit": -500})
async def test_empty_text_is_all_zeros(client):
result = await client.call_tool("analyze_text", {"text": ""})
assert result.structured_content == {
"words": 0,
"characters": 0,
"unique_words": 0,
}
Detailed breakdown
- The two snapshot tests capture the full
structured_contentfor a normal and a Unicode input. The Unicode case (café Café CAFÉ) documents real behavior: three words, one unique word (case-insensitive), and 14 characters because the precomposedécounts as one code point. test_to_celsius_valuesusesresult.data, the typed return value FastMCP parses from the wrapped result, so the assertion is against100.0, not{"result": 100.0}. Parametrizing documents four reference conversions in one function.test_to_celsius_rejects_below_absolute_zeropins the error path: aToolErrorwhose message matchesabsolute zero. Error contracts regress as easily as success ones.test_empty_text_is_all_zerosis the boundary case an LLM will eventually send; asserting it explicitly keeps the edge documented next to the snapshots.
Step 7: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install demo serve test test-v snapshots clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
demo: ## Smoke-run the server under test
uv run python client.py
serve: ## Run the server over stdio (for a real MCP client)
uv run python server.py
test: ## Run the full test suite
uv run pytest -q
test-v: ## Run the suite verbosely
uv run pytest -v
snapshots: ## Regenerate committed snapshots (review the diff before committing)
SNAPSHOT_UPDATE=1 uv run pytest -q tests/test_snapshots.py
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.make snapshotsis the deliberate escape hatch: after an intentional change to a tool’s output, regenerate the golden files, then read the git diff before committing so a real regression can never be rubber-stamped.
Step 8: The smoke demo (optional but useful)
A tiny client.py gives a human-readable look at the server the tests exercise.
Create the file
touch client.py
Add the code: client.py
"""Drive the server under test in-memory.
A quick smoke run of the same server the test suite exercises: list the tools,
call each one, and read the resource. The real coverage lives in `tests/`; this is
just a human-readable look at what the tools return.
"""
import asyncio
from fastmcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print("tools: ", [t.name for t in await client.list_tools()])
print("resources:", [str(r.uri) for r in await client.list_resources()])
print("prompts: ", [p.name for p in await client.list_prompts()])
r = await client.call_tool("analyze_text", {"text": "the cat sat on the mat"})
print("\nanalyze_text('the cat sat on the mat'):")
print(" ", r.structured_content)
r = await client.call_tool("to_celsius", {"fahrenheit": 98.6})
print("to_celsius(98.6):")
print(" ", r.data)
print("read catalog://info:")
print(" ", (await client.read_resource("catalog://info"))[0].text)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- This is not a test; it is a quick way to eyeball the surface. The suite is the source of truth, but a smoke script is handy when adding a tool.
Run it:
uv run python client.py
Expected output:
tools: ['analyze_text', 'to_celsius']
resources: ['catalog://info']
prompts: ['summarize']
analyze_text('the cat sat on the mat'):
{'words': 6, 'characters': 22, 'unique_words': 5}
to_celsius(98.6):
37.0
read catalog://info:
text-tools: analyze_text, to_celsius
Step 9: Run the suite
make # prints the help screen
make demo # smoke-runs the server (output in Step 8)
make test # runs the full suite
The suite passes:
............... [100%]
15 passed in 0.04s
Run it verbosely to see the layers:
make test-v
tests/test_contract.py::test_tool_set_is_stable PASSED
tests/test_contract.py::test_analyze_text_input_contract PASSED
tests/test_contract.py::test_to_celsius_input_contract PASSED
tests/test_contract.py::test_every_tool_has_a_description_and_schema PASSED
tests/test_contract.py::test_every_tool_names_snake_case PASSED
tests/test_contract.py::test_results_conform_to_output_schema[analyze_text-args0] PASSED
tests/test_contract.py::test_results_conform_to_output_schema[to_celsius-args1] PASSED
tests/test_snapshots.py::test_analyze_text_snapshot PASSED
tests/test_snapshots.py::test_analyze_text_unicode_snapshot PASSED
tests/test_snapshots.py::test_to_celsius_values[32.0-0.0] PASSED
tests/test_snapshots.py::test_to_celsius_values[212.0-100.0] PASSED
tests/test_snapshots.py::test_to_celsius_values[98.6-37.0] PASSED
tests/test_snapshots.py::test_to_celsius_values[-40.0--40.0] PASSED
tests/test_snapshots.py::test_to_celsius_rejects_below_absolute_zero PASSED
tests/test_snapshots.py::test_empty_text_is_all_zeros PASSED
Applying this to your own server
- Start with
test_tool_set_is_stable. One test that pins the tool names pays for itself the first time a refactor drops or renames a tool. - Write conformance tests against the live list. “Every tool has a description”,
“every result validates against its
outputSchema” — these cost nothing per new tool and never go stale. - Snapshot structured results, assert scalars. A rich dict is a snapshot; a
single number or a specific edge case is a plain
assertthat documents intent. - Keep the server pure, or make time and randomness injectable. If a tool reads the clock or a database, pass those in so tests can pin them; otherwise snapshots flake.
- Test error paths as contracts.
pytest.raises(ToolError, match=...)guards the message and behavior a client depends on when input is bad. - Regenerate snapshots deliberately.
SNAPSHOT_UPDATE=1then a diff review is the workflow; never regenerate blind.
Troubleshooting
ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.- Snapshots always “pass” and never compare. The golden file was missing, so
the fixture wrote it. Commit
tests/snapshots/*.jsonand rerun; the second run compares. - A snapshot fails after an intentional change. Run
make snapshots, review the diff, and commit the updated golden file. outputSchemaisNonefor a tool. The tool returns an unannotated value. Give it a return type (a Pydantic model or a typed scalar) so FastMCP publishes a schema for the conformance test to check.- Async tests are skipped or error.
asyncio_mode = automust be set inpytest.iniandpytest-asyncioinstalled.
Recap
The suite has four layers, each guarding a different failure: contract tests pin
the tool set and input schemas, conformance tests enforce per-tool invariants and
validate results against the advertised outputSchema, snapshot tests freeze
structured results in reviewed golden files, and shared fixtures keep every test to
a few lines. Together they treat the server as the contract a model depends on, not
just a bag of functions.
Next improvements:
- Add a JSON-Schema-conformance check that also validates each tool’s input schema is a valid draft, catching malformed hand-written schemas.
- Snapshot the full
tools/listpayload to detect any interface drift in one test. - Wire
make testinto CI so the contract is checked on every push.