Model Context Protocol from the ground up
Testing an MCP server
A handler test does not prove the server speaks MCP. Test the frames, keep a readable transcript as the fixture, and spend most of the suite on the error paths a client will actually meet.
Test the wire, not the framework
A handler test proves a function works; it does not prove the server speaks MCP. The contract is the frame: the id echoed, the result shape, the error code, the notification with no id. A server can pass every unit test and still fail every real client.
Test at the lowest layer that can fail. Pure validation and formatting functions get ordinary unit tests; the dispatcher gets frame-level tests; the transport gets one integration test per transport; the client gets a fake server that misbehaves on purpose. A test that needs a socket to fail is a test that will be skipped on a busy afternoon.
The in-memory transport is the workhorse: it connects a real server to a real client without a socket, so the handshake, the capability check and the tool call all run in milliseconds. Keep one stdio or HTTP test as well, because framing bugs live in the transport, not in the dispatcher.
The transcript is the fixture
A golden transcript is a list of frames and the frames that answered them. Record initialize, tools/list, a successful call, an invalid call and an unknown method; normalise the ids, the timestamps and any ordering that the protocol does not promise; compare the rest exactly.
The fixture is only useful if it is readable. One JSON object per line, a comment above each exchange, and a diff that a reviewer can follow. When the protocol revision changes, update the fixture in its own commit so the behaviour change is visible rather than buried in a refactor.
Cover the handshake in both directions: a client that asks for an old revision, a server that answers with the newest it supports, and a capability that is absent. Those are the paths where a client that works against one server fails against the next.
A real client against a real server, in memory
import pytest
from mcp.shared.memory import create_connected_server_and_client_session
@pytest.mark.anyio
async def test_lists_one_tool(server):
async with create_connected_server_and_client_session(server) as session:
result = await session.list_tools()
assert [tool.name for tool in result.tools] == ["read_note"]Compare a transcript, not a log
Run the five frames and read the replies. Then add a notification with no id and confirm the handler returns nothing for it instead of a response.
Failure paths are the product
Most of the product is the failure path. Unknown method, invalid params, a tool that raises, a resource that does not exist, a request that is cancelled: each has a defined answer, and each is a test. A server that returns a stack trace has leaked its internals and told the client nothing it can act on. A tool error should be a result with isError, not a transport failure, so the model can read it and try another path.
Fuzz the parser as well as the dispatcher. Random bytes, a missing jsonrpc field, an id that is an object, params that are a list where a map is expected: none of them may crash the process, and all of them must produce a JSON-RPC error with the id when one can be recovered.
Concurrency is part of the contract. Two requests with different ids may complete out of order, a notification must not receive a response, and a slow tool must not block a list call. Test the interleaving with a fake clock or an event, not with a sleep that makes the suite flaky.
Assert on the error code and the id, not on the message text. Messages are for people; codes and ids are the contract a client can branch on.
Test the client and the contract
The client deserves the same tests. An allow-list that refuses an unlisted tool, a timeout that cancels the request, a capability check that skips an undeclared method, and a policy that asks for approval before a destructive call: each is a behaviour with a fake server behind it. The fake server should be able to answer slowly, refuse a method and return a malformed frame on demand.
Run the contract suite against every protocol revision you claim to support. The same frames, the same assertions, a matrix in CI. A revision is a promise, and a promise tested on one version is a guess about the others.
Keep the suite offline and deterministic. A test that calls a real provider is an integration test with a bill and a flaky day; record the frames once and replay them. The unit suite should run on every commit, and the recorded suite should fail when a fixture drifts, not when the network does.
Fuzz the parser without crashing
Run the fixed frames and the random loop. Then add a case where params is null and assert that the error code is -32602.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Why is a handler test not enough for an MCP server?
