Skip to content

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:

server.py
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:

test_server.py
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},
        )
    )
  1. If you are using trio, return "trio" instead. See the anyio documentation for the details.
  2. The fixture yields a connected client. Every test that takes client gets 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.