Testing
The Python SDK ships a Client class with an in-memory transport: pass it your server object and it connects to it directly.
No subprocess. No port. No transport at all. It's the same idea as FastAPI's TestClient.
Basic usage
Let's assume you have a simple server with a single tool:
from mcp.server import MCPServer
mcp = MCPServer("Calculator")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
To run the test below you'll need two extra (development) dependencies:
uv add --dev pytest inline-snapshot
pip install pytest inline-snapshot
Info
These docs assume you already know pytest.
inline-snapshot is what the test below
uses to assert on the whole result object in one line. It records the output of a test as the
snapshot(...) literal you see. If you'd rather not use it, drop the import and assert on the
fields you care about (result.content[0].text == "3") like in any other test.
Now the test:
import pytest
from inline_snapshot import snapshot
from mcp import Client
from mcp_types import CallToolResult, TextContent
from server import mcp
@pytest.fixture
def anyio_backend(): # (1)!
return "asyncio"
@pytest.fixture
async def client(): # (2)!
async with Client(mcp, raise_exceptions=True) as c:
yield c
@pytest.mark.anyio
async def test_call_add_tool(client: Client):
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="3")],
structured_content={"result": 3},
)
)
- If you are using
trio, return"trio"instead. See the anyio documentation for the details. - The fixture yields a connected client. Every test that takes
clientgets a fresh in-memory connection to the same server.
There you go! You can now extend your tests to cover more scenarios.
Why raise_exceptions=True?
Two different things can go wrong, and this flag only touches one of them.
An exception inside one of your tools is not a protocol failure. It becomes a normal result with
is_error=True, and the model reads the message. raise_exceptions doesn't change that: with or
without it, call_tool returns the same is_error=True result. There's a whole chapter on it:
Handling errors.
A failure outside a tool body is different. On the connection Client(mcp) gives you, the
server sanitises it into a generic "Internal server error" before the client sees it. You should
never leak the details of an unexpected crash to a remote caller. In a test that is exactly what
you don't want, and it is what raise_exceptions=True changes: your test sees the real message
instead of the sanitised one.
Leave it on in tests. It has no meaning in production code.
In-process by default
Note
Client(mcp) connects in-process and is era-neutral by default: it probes the server and
picks the appropriate protocol path. Pin mode="legacy" if your test exercises legacy-specific
semantics (sampling or elicitation push, message_handler), and drop raise_exceptions=True
there: a legacy connection never sanitises in the first place, and the flag re-raises the
failure inside the server task instead of in your test.
That one line is also why the rest of this tutorial can promise you that its examples work: every example file is exercised by the SDK's own test suite through exactly this client. You're using the same tool the SDK uses on itself.
The tutorial ends here. Putting your tested server in front of a real client, over a real transport, is Running your server.