Vocabulary for this course

The terms the chapters assume, in plain words. You can open this from any chapter.

capability
A named feature one side declares it supports during the handshake, such as tools or resources.
client
The connector a host creates to talk to one server, sending requests and handling replies.
handshake
The opening exchange where both sides agree on a version of the rules and declare what each supports.
host
The application the user runs, which creates a client and decides what a model may reach.
JSON-RPC
A message format of requests, responses and notifications, where each request carries an id so its reply can be matched.
notification
A message that expects no reply, used to report an event or a change.
prompt
A reusable message template a server offers, which a user can choose and fill in.
resource
Read-only data a server exposes, addressed by a name or address and returned as content.
schema
A description of the arguments a tool accepts, used to check a request before it runs.
server
The program that offers tools, resources or prompts and answers requests over a transport.
stdio
A transport that carries messages over the standard input and output of a program, usually on one machine.
tool
A function a server exposes that a model may ask to run, with named arguments and a result.
transport
The channel that carries messages between client and server, such as stdio or HTTP.

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.

Part 9 of 10

Advanced16 min readBack to Courses

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.

Output
Click "Run" to execute your code

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.

Output
Click "Run" to execute your code

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?