Test a server and watch it run
About 28 minutes to read, with 6 examples in 8 sections.
Written with AI. Verified by people. How we make books
Anyone can check this book or correct it. Report an error · How to fix one
Labs
Learn this chapter by doing it
Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.
Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.
Checking your hours…
Checking for your Lab…
Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.
Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.
Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.
By the end of this chapter you can test a Model Context Protocol server's tools in memory and over HTTP, catch a schema change that would break clients before it ships, replay a recorded host run as a regression test, trace tool calls with OpenTelemetry, measure how often a model chooses the right tool, and retire a tool without breaking the clients that use it. Everything holds for the specification at revision 2026-07-28 and the Python SDK, mcp 2.3.0.
This book follows one case study throughout: a small online shop for kitchen and garden goods, which we call the shop, and whose support team answers customer tickets through a host. Meet the shop and its API introduces it. Build a host that works through tickets ended with the support host, lib.ticket_host, working all 48 of the shop's tickets against two servers, the shop's and the partner's parcel-tracking server, with the model's replies taken from the recording ticket-runs. A recording is a saved model output under examples/data/recorded/, so the run replays the same way on every machine with no model call.
That run left two problems. The host chose the right first tool for 36 of 48 tickets, and nothing said which ones it got wrong or why. And on the refund tickets the model wrote to customers as if their refunds were done, while of the tickets asking for a refund, 0 of 7 ever reached issue_refund. The shop's server and host also keep changing: a description is reworded, a field is renamed, a tool is replaced. Any of those can break a client without failing a single call on the server's side.
The Model Context Protocol (MCP) gives a team a few things to build checks on: typed schemas for every tool, trace context in every request's _meta, and change notifications for the tool list. This chapter builds the shop's checks on them. Three new modules hold the setup. lib.testing runs a pytest module and prints one stable line per test. lib.contract compares two releases of a tool's schemas. lib.queue_replay replays chapter 10's queue run and checks what each reply promises against the calls the host made. Two of this chapter's examples replay the recording ticket-runs, the others use no model, and the chapter makes no model calls of its own.
Test tools in memory
A test of a server's tools should run with no port and no subprocess, so that it can run on every change. The Python SDK's Client gives you that: passed a server object instead of a URL or launch parameters, it connects to the server in the same process. The SDK's testing guide presents it as the way to test a server: "No subprocess. No port. Nothing on a wire" (testing). In the default mode="auto", that connection is a pair of DirectDispatchers that hand each request to the server's handler as a Python object, with "no streams, no JSON-RPC framing, no initialize handshake" (mcp/client/client.py, _connect_inproc; mcp/shared/direct_dispatcher.py).
Two details matter in a test. raise_exceptions=True makes a failure outside a tool's body, such as a bug in a handler, come back with its real message instead of a sanitised "Internal server error". It doesn't change what a tool's own exception does: that is still a result with is_error=True, as it is for any client (testing). And the SDK stamps the server's identity into each result's _meta, so a test that compares whole results has to clear result.meta first (same page). Asserting on the fields a test is about avoids both the stamp and the SDK guide's snapshot library, inline-snapshot, which the book doesn't pin. Async tests need no extra plugin: anyio, which the SDK depends on, ships a pytest plugin that runs any test marked pytest.mark.anyio (AnyIO testing).
What the in-memory path skips matters as much as what it covers. With no HTTP layer, an in-memory test never meets the checks a Streamable HTTP server makes on headers, Origin and Host, never sees a server-sent event, and never carries trace context, as Trace calls with OpenTelemetry shows. The SDK's documentation adds that the in-process path doesn't deliver standalone notifications, such as a change to the tool list (client caching). A suite that is all in memory can pass while every HTTP client fails.
The shop's tests run against chapter 3's server, lib.shop_tools, from Design tools a model can use, with the shop's API on loopback for each test, served by the fixture shop_api_env from lib.testing. Three tests run in memory: an order's status comes back as structured fields, an unknown order comes back as a tool error that names find_orders, and an order ID in the wrong form is refused before the tool runs. The fourth serves the same tools over Streamable HTTP on loopback and sends a tools/call whose Mcp-Name header names a different tool from its body: a request the SDK's client never sends, and one an in-memory client can't express. Run as a script, the file runs its own tests with pytest 9.2.0.
1"""pytest tests for the shop's tools: three in memory, through the SDK's in-process client, and one over Streamable2HTTP on loopback, for a check an in-memory test never reaches. Run as a script, the file runs its own tests."""3import pytest4from mcp import Client56from lib import http_mcp, loopback, shop_tools7from lib.testing import anyio_backend, run, shop_api_env89pytestmark = pytest.mark.anyio101112async def test_order_status_fields(shop_api_env):13 async with Client(shop_tools.create_server(), raise_exceptions=True) as client:14 result = await client.call_tool("get_order_status", {"order_id": "O-1873"})15 assert not result.is_error16 assert result.structured_content["order_id"] == "O-1873"17 assert result.structured_content["status"] == "delivered"181920async def test_unknown_order_is_a_tool_error(shop_api_env):21 async with Client(shop_tools.create_server(), raise_exceptions=True) as client:22 result = await client.call_tool("quote_refund", {"order_id": "O-7144"})23 assert result.is_error # raise_exceptions doesn't make it raise24 assert "find_orders" in result.content[0].text # the model is told what to do instead252627async def test_malformed_order_id_is_refused(shop_api_env):28 async with Client(shop_tools.create_server(), raise_exceptions=True) as client:29 result = await client.call_tool("get_order_status", {"order_id": "1873"})30 assert result.is_error31 text = result.content[0].text # only argument validation writes this error:32 assert "order_id" in text and "string_pattern_mismatch" in text # the field and pydantic's pattern check333435def test_http_refuses_a_mismatched_header(shop_api_env):36 with loopback.serve(shop_tools.create_server().streamable_http_app()) as base_url:37 reply = http_mcp.post(base_url + "/mcp", "tools/call", {"name": "get_order_status",38 "arguments": {"order_id": "O-1873"}}, headers={"Mcp-Name": "check_stock"})39 assert reply.status_code == 40040 assert reply.json()["error"]["code"] == -32020414243if __name__ == "__main__":44 run(__file__)
python test-tools-in-memory.pytest_order_status_fields: passed
test_unknown_order_is_a_tool_error: passed
test_malformed_order_id_is_refused: passed
test_http_refuses_a_mismatched_header: passed
tests passed: 4 of 4
The run reports 4 of 4. The unknown order's test passed with raise_exceptions=True on: the tool raised ToolError, and the client got an ordinary result with the error flag set and the server's advice in its text, which is what a model needs to correct itself (Report failures as tool results). The malformed ID never reached the tool: the test asserts that the error names order_id and pydantic's string_pattern_mismatch, which only argument validation writes. The SDK validated the arguments against the tool's parameter model, which its input schema is generated from, before calling the tool (mcp/server/mcpserver/server.py, _handle_call_tool). The HTTP test covers the layer the others skip: the server refused the mismatched header with status 400 and the JSON-RPC error -32020, as Read one HTTP exchange describes, before any tool code ran.
Working rule: Test each tool in memory with Client(server, raise_exceptions=True), and assert on structured_content fields and is_error, not on whole results. Keep at least one test per transport you ship that goes over it on loopback, for the header, origin and streaming checks an in-memory test can't reach, and put the tests of trace propagation and list changes there too.
Check schemas as a contract
The in-memory tests from the previous section check that a tool does what its own release says. They don't check that the release still fits the clients using the last one. A client depends on a tool through its schemas: it sends arguments that its copy of the inputSchema accepts, and it reads the fields that its copy of the outputSchema promises. When a tool declares an output schema, the server MUST return structured content that conforms to it, and clients SHOULD validate it (tools, "Structured Content").
The Python SDK's client does validate, and it validates against its own copy. It keeps one compiled validator per tool, built from the output schema in the last tools/list it absorbed, and replaces it only when a later listing brings a different schema (mcp/client/session.py, _output_schema_validator and _absorb_tool_listing). A non-error result that doesn't match raises RuntimeError in the caller (mcp/client/session.py, validate_tool_result). So a server that renames an output field breaks every connected client that listed its tools before the release, at the moment each one next calls the tool, and the server sees nothing wrong.
In the shop's case, release 2 of the server renames the per-warehouse on_hand field of check_stock to count. The example connects a client to release 1 in memory and lists the tools. Then it swaps check_stock for release 2's version while the client stays connected, as a rolling deploy would, calls the tool, lists again, and calls once more. Last, it runs the two checks a contract test makes before a release ships: lib.contract.breaking_changes, a diff of the two releases' schemas, and a validation of release 2's real result against release 1's published output schema with jsonschema, the library the client uses.
1"""Release 2 of the shop's server renames check_stock's on_hand to count: what a connected client and a contract check see."""2import asyncio34from mcp import Client5from pydantic import BaseModel67from lib import confirmations, contract, shop_tools8from lib.testing import shop_api_here91011class Warehouse(BaseModel):12 warehouse: str13 count: int # release 1 called it on_hand14 last_counted: str151617class StockReport(BaseModel):18 sku: str19 available: int20 warehouses: list[Warehouse]212223def check_stock(sku: shop_tools.Sku) -> StockReport:24 report = confirmations.chapter3_tools()["check_stock"].fn(sku) # release 1's tool does the reading25 return StockReport(sku=sku, available=report.available, warehouses=[Warehouse(26 warehouse=w.warehouse, count=w.on_hand, last_counted=str(w.last_counted)) for w in report.warehouses])272829async def main() -> None:30 server = shop_tools.create_server()31 with shop_api_here(): # the server's tools run in this process32 async with Client(server) as client:33 listing = await client.list_tools()34 print("release 1's tools/list cache hint:", f"ttlMs {listing.ttl_ms}")35 server.remove_tool("check_stock") # the release, while connected36 server.add_tool(check_stock, description=shop_tools.DESCRIPTIONS["check_stock"])37 try:38 await client.call_tool("check_stock", {"sku": "KG-1031"})39 except RuntimeError as error:40 print("client that listed before the release:", error.args[0].splitlines()[0])41 new = next(tool for tool in (await client.list_tools()).tools if tool.name == "check_stock")42 result = await client.call_tool("check_stock", {"sku": "KG-1031"})43 print("client after listing again:", "result" if not result.is_error else "tool error")44 old = next(tool for tool in listing.tools if tool.name == "check_stock")45 print("contract check:", "; ".join(contract.breaking_changes(old, new)) or "no breaking change")46 validator = contract.validator(old.output_schema)(old.output_schema) # by the dialect the schema declares47 print("validator for release 1's schema:", type(validator).__name__ + ", as it declares",48 old.output_schema.get("$schema", "no $schema"))49 print("release 2's result against release 1's schema:", next(validator.iter_errors(result.structured_content)).message)505152asyncio.run(main())
python schema-change-breaks-a-client.pyrelease 1's tools/list cache hint: ttlMs 0
client that listed before the release: Invalid structured content returned by tool check_stock: 'on_hand' is a required property
client after listing again: result
contract check: output warehouses[].on_hand: removed
validator for release 1's schema: Draft202012Validator, as it declares no $schema
release 2's result against release 1's schema: 'on_hand' is a required property
Release 1's tool list carried the SDK's default cache hint, ttlMs 0, so the list was stale the moment it arrived, and the client still validated against it. The call after the release failed inside the client: "Invalid structured content returned by tool check_stock: 'on_hand' is a required property". The server had returned a correct release 2 result. After the client listed the tools again, the same call came back as a result. A zero TTL doesn't protect a connected client, because the validator follows the last listing, not the cache: a client that lists once at startup holds that schema for as long as it runs.
Both contract checks caught the change without a client. The schema diff reported output warehouses[].on_hand: removed, and validating release 2's result against release 1's schema failed with the client's own message: "'on_hand' is a required property". The diff is a reviewer's aid: it compares field paths, types and required inputs, and its docstring lists what it leaves out, such as patterns and enums. The validation is the test to keep, because it checks the server's real output against the exact schema that clients hold.
Breaking changes aren't symmetric. On the output side, removing, renaming or retyping a field breaks clients, and adding an optional field doesn't. On the input side, adding a required argument breaks old callers, and removing one breaks them quietly, because the SDK's server drops arguments it doesn't know without an error, as Treat ticket text as untrusted data shows. The pinned revision allows any JSON Schema 2020-12 keyword in both schemas, and reads a schema with no $schema as 2020-12 (tools). A schema may name another dialect in $schema, and clients and servers MUST validate by the declared dialect, or by 2020-12 when none is declared (base protocol, "JSON Schema Usage"). So choose the validator from the schema, as the SDK's client does with jsonschema's validator_for (mcp/client/session.py, _output_schema_validator). The example does the same through lib.contract.validator, with 2020-12 as the default. pydantic writes no $schema, so release 1's schema got Draft202012Validator, as it declares no $schema.
Working rule: Keep the last release's tools/list in the repository, and in CI validate every structured result your tests produce against it as well as against the new schema, each by the dialect it declares and by 2020-12 when it declares none. Treat a removed, renamed or retyped output field and a new required input as a breaking change: add the new field beside the old one, or add a new tool, and retire the old shape through a window (Change tools without breaking clients). Don't rely on a short TTL to protect clients that are already connected.
Replay recorded host runs
Tool tests and contract tests cover the server. The support host from Build a host that works through tickets has logic of its own between the model and the servers: it routes names, asks for consent, sends results back and caps turns, and a change there can make every ticket go wrong without any tool failing. A replay covers that layer. It runs the real host against the real servers, with the model's replies taken from a recording, so a test can assert on what the host did for each ticket.
A replay tests the host and the servers against fixed model outputs. It doesn't test the model: a different model, or the same one with other sampling, needs a new recording. And it holds only while the messages stay exactly as recorded. ReplayModel from lib.host looks each reply up by the SHA-256 of the exact messages, which include every tool definition and every tool result so far, and raises RecordingMismatch when they differ (Run the host loop). So a replay doubles as a tripwire: any change to what the model would see stops it, and a team then decides whether to record again.
The replay also gives the queue's results a second reading. Work through the ticket queue found that the model told customers their refunds were on the way for orders it never refunded. A reply is the model's claim, and the host's call log is the record of what happened, so the host can compare them before a reply reaches a customer. lib.queue_replay holds a small version of that check, built on the phrase check chapter 10 counted with, ticket_host.promises_refund, which looks for phrases such as "will be refunded" or "we'll refund". Reusing it keeps this chapter's counts and chapter 10's the same measurement. Its unbacked_refund is true when a run's last reply makes that promise and no issue_refund call in the run returned a result. The phrase list is a rule of thumb written against this recording's replies, not a classifier, and the module's docstring says what it misses.
The example replays the whole queue with queue_replay.replay_queue, which sets up the support host exactly as chapter 10's work-the-ticket-queue does. It prints one ticket's tool sequence, counts the refunds the host issued and the replies that promise one, and then replays the first ticket again after one sentence has been added to quote_refund's description.
1"""Replay chapter 10's queue run (the recording ticket-runs) through the support host, check each reply's promises2against the host's call log, and show what a one-sentence change to a tool description does to the replay."""3import asyncio45from lib import queue_replay, shop_tools, ticket_host6from lib.host import RecordingMismatch7from lib.shop_data import load_shop8910def reword_quote_refund(host) -> None: # the server's next release adds a sentence to one description11 changed = {**shop_tools.DESCRIPTIONS}12 changed["quote_refund"] += " Quotes are in the order's currency."13 host.listed["shop"] = shop_tools.with_descriptions(host.listed["shop"], changed)141516async def main() -> None:17 tickets = {ticket.ticket_id: ticket for ticket in load_shop().tickets}18 runs = await queue_replay.replay_queue()19 print("tickets replayed:", len(runs))20 print("T-0009 tools:", " then ".join(turn.tool for turn in runs["T-0009"].calls))21 print("refunds issued:", sum(queue_replay.refunded(run) for run in runs.values()))22 promised = [ticket_id for ticket_id, run in runs.items() if ticket_host.promises_refund(run.answer)]23 unbacked = [ticket_id for ticket_id in promised if queue_replay.unbacked_refund(runs[ticket_id])]24 print("replies that promise a refund:", len(promised))25 print("of which no call made:", len(unbacked))26 for tag in ("issue_refund", "quote_refund"):27 group = [t for t in tickets.values() if t.expected_tool == tag]28 print(f"{tag} tickets flagged:", f"{sum(t.ticket_id in unbacked for t in group)} of {len(group)}")29 print("flagged:", ", ".join(unbacked))30 try:31 await queue_replay.replay_queue(change=reword_quote_refund, tickets=[tickets["T-0001"]])32 except RecordingMismatch:33 print("after rewording quote_refund, T-0001 replays:", "no, RecordingMismatch")343536asyncio.run(main())
python replay-ticket-runs.pytickets replayed: 48
T-0009 tools: shop.quote_refund then shop.find_orders
refunds issued: 0
replies that promise a refund: 11
of which no call made: 11
issue_refund tickets flagged: 6 of 7
quote_refund tickets flagged: 5 of 9
flagged: T-0007, T-0020, T-0029, T-0034, T-0036, T-0037, T-0040, T-0042, T-0045, T-0046, T-0048
after rewording quote_refund, T-0001 replays: no, RecordingMismatch
All 48 tickets replayed, and ticket T-0009 took the path it took in chapter 10: shop.quote_refund then shop.find_orders. An assertion on that sequence is a regression test for the host. If a change to routing or error handling sent the first tool error back differently, the model's second turn would no longer match the recording, and the replay would stop.
The host issued 0 refunds, yet 11 replies promise one, and 11 of them have no refund behind them. They are 6 of 7 of the tickets asking for a refund, and 5 of 9 of the tickets asking only what a refund would be, where the customer was told the money was on its way. The one refund request the check let through, T-0024, ended with the model asking which product the customer meant, which promises nothing. These are the tickets a host should send to a person instead of to the customer: T-0007, T-0020, T-0029, T-0034, T-0036, T-0037, T-0040, T-0042, T-0045, T-0046, T-0048.
The last line shows the tripwire. After one sentence was added to one description, the first ticket's replay gave no, RecordingMismatch. Every ticket's prompt holds every tool's description, so changing any description changes every prompt. That is the cost of replays, and also their use: a reworded description is exactly the change that can alter what the model chooses, and the replay won't let it through unmeasured.
Working rule: Replay a recorded run of the whole queue in CI, assert on each ticket's tool sequence, and run a check that compares each reply's promises with the calls that returned a result. Keep generated IDs and clocks fixed in the servers a replay runs against, or it stops for reasons that aren't changes. Then treat RecordingMismatch as a signal, not as a flaky test: when you change a description, a prompt or a tool's output, record again with the recording script and compare the new run's scores with the old run's before you ship.
Trace calls with OpenTelemetry
Tests run before a release; traces show what a server does after it. OpenTelemetry is the common way to get them: an application creates spans, each a named, timed operation with attributes and a parent, and an exporter sends them to a tracing backend. MCP adds one thing of its own. At the pinned revision, the _meta keys traceparent, tracestate and baggage are reserved for trace context, and their values MUST follow the W3C Trace Context and Baggage formats (base protocol). That is how a client's trace and a server's trace join into one, whatever the transport.
The Python SDK traces with no setup in your server's code. Every server installs OpenTelemetryMiddleware, which wraps each inbound message in a SERVER span and does nothing until an OpenTelemetry SDK and exporter are installed (mcp/server/lowlevel/server.py; OpenTelemetry). The source marks the middleware provisional, so its details may change in a 2.x minor release. On the client side, the JSON-RPC paths, which are URLs, stdio and the legacy in-memory mode, open a CLIENT span named MCP send {method} {target} and inject traceparent into the request's _meta, and the server extracts it as the parent of its span (mcp/shared/jsonrpc_dispatcher.py, mcp/shared/_otel.py). The in-process path from Test tools in memory sends no JSON-RPC, so it creates no client span and injects nothing.
The shop wants a trace for each tool call that it can find by tool name and that shows whether the call failed. The example installs OpenTelemetry's SDK, opentelemetry-sdk 1.45.2, with an InMemorySpanExporter, which keeps finished spans in a list that a test can read (opentelemetry/sdk/trace/export/in_memory_span_exporter.py). It serves chapter 3's tools from Design tools a model can use over Streamable HTTP on loopback in the same process, so the client's and the server's spans land in the same list, and calls find_orders and then quote_refund for an order that doesn't exist. Then it makes the same find_orders call in memory twice, the second time inside a span of the test's own.
1"""The OpenTelemetry spans the SDK emits for the shop's tool calls, over Streamable HTTP on loopback and in memory."""2import asyncio34from mcp import Client5from opentelemetry import trace6from opentelemetry.sdk.trace import TracerProvider7from opentelemetry.sdk.trace.export import SimpleSpanProcessor8from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter9from opentelemetry.semconv._incubating.attributes.mcp_attributes import McpMethodNameValues1011from lib import loopback, shop_tools12from lib.testing import shop_api_here1314spans = InMemorySpanExporter()15trace.set_tracer_provider(provider := TracerProvider())16provider.add_span_processor(SimpleSpanProcessor(spans)) # each span, as it ends, into memory171819def finished(name: str) -> list:20 return [span for span in spans.get_finished_spans() if span.name == name]212223def parent(span) -> str:24 return next((s.name for s in spans.get_finished_spans() if span.parent and s.context.span_id ==25 span.parent.span_id), "none")262728async def main() -> None:29 server = shop_tools.create_server()30 with shop_api_here(), loopback.serve(server.streamable_http_app()) as base_url:31 async with Client(base_url + "/mcp") as client:32 await client.call_tool("find_orders", {"customer_id": "C-0127"})33 await client.call_tool("quote_refund", {"order_id": "O-7144"}) # a tool error34 print("HTTP spans:", ", ".join(sorted(span.name for span in spans.get_finished_spans())))35 call, error = finished("tools/call find_orders")[0], finished("tools/call quote_refund")[0]36 print("HTTP server span:", call.name, f"({call.kind.name}), parent: {parent(call)}")37 for key in sorted(set(call.attributes) - {"jsonrpc.request.id"}):38 print(f"attribute {key}:", call.attributes[key])39 print("tool error span status:", error.status.status_code.name)40 print("tool error span error.type:", error.attributes["error.type"])41 spans.clear()42 async with Client(server) as client:43 await client.call_tool("find_orders", {"customer_id": "C-0127"})44 with trace.get_tracer("shop-tests").start_as_current_span("test find_orders"):45 await client.call_tool("find_orders", {"customer_id": "C-0127"})46 print("in-memory spans:", ", ".join(sorted(span.name for span in spans.get_finished_spans())))47 print("in-memory server span parents:", ", ".join(parent(s) for s in finished("tools/call find_orders")))48 print("pinned semconv lists server/discover:", "server/discover" in {m.value for m in McpMethodNameValues})495051asyncio.run(main())
python trace-a-tool-call.pyHTTP spans: MCP send server/discover, MCP send tools/call find_orders, MCP send tools/call quote_refund, MCP send tools/list, server/discover, tools/call find_orders, tools/call quote_refund, tools/list
HTTP server span: tools/call find_orders (SERVER), parent: MCP send tools/call find_orders
attribute gen_ai.operation.name: execute_tool
attribute gen_ai.tool.name: find_orders
attribute mcp.method.name: tools/call
attribute mcp.protocol.version: 2026-07-28
tool error span status: ERROR
tool error span error.type: tool_error
in-memory spans: server/discover, test find_orders, tools/call find_orders, tools/call find_orders, tools/list
in-memory server span parents: none, test find_orders
pinned semconv lists server/discover: False
Over HTTP the spans came in pairs, four client spans and their four server spans, printed in sorted order: MCP send server/discover, MCP send tools/call find_orders, MCP send tools/call quote_refund, MCP send tools/list, server/discover, tools/call find_orders, tools/call quote_refund, tools/list. The client listed the tools before its first call, because it needs the output schema to validate the result. The server's span for the call printed as tools/call find_orders (SERVER), parent: MCP send tools/call find_orders: the trace context went across in _meta. Its attributes name the method, tools/call, the operation, execute_tool, the tool, find_orders, and the revision (mcp/server/_otel.py). The failed quote's span has the status ERROR and the error.type tool_error, although the call returned a result and not a JSON-RPC error, so a tracing backend can count tool errors without reading result bodies.
In memory there were no client spans, only the server's and the test's own test find_orders: server/discover, test find_orders, tools/call find_orders, tools/call find_orders, tools/list. The two server spans' parents were none, test find_orders, in that order. With nothing to extract, the server falls back to whatever span is current, and only the test's own span gave it one (mcp/shared/_otel.py, extract_trace_context). A test of trace propagation needs HTTP or stdio.
The names a server emits are the part to treat with care. OpenTelemetry's semantic conventions for MCP have the status Development; they recommend span names of the form {mcp.method.name} {target} and define metrics such as mcp.server.operation.duration (MCP semantic conventions, read 2026-10-31). They have also moved: the page the MCP specification links to shows a notice that the GenAI conventions moved to a repository of their own, which has no tagged release (moved notice). The SDK's server spans follow the recommended naming. Its client span name, MCP send …, doesn't, and the SDK emits no metrics: the source of mcp 2.3.0 never creates a meter. The pinned conventions package, opentelemetry-semantic-conventions 0.66b2, predates the pinned revision. Its list of method names has initialize and logging/setLevel, it still defines mcp.session.id, and it has no server/discover, as the example's last line shows: False (opentelemetry/semconv/_incubating/attributes/mcp_attributes.py).
For logs, the pinned revision deprecates MCP's own Logging messages, notifications/message, and points servers to standard error on stdio and to OpenTelemetry otherwise (changelog). Replace sampling, roots and logging covers the move.
Working rule: Install an OpenTelemetry SDK and exporter in every server and host you run, and let the SDK's middleware make the server spans. Build dashboards on the attributes the SDK emits, mcp.method.name, gen_ai.tool.name and error.type, and label them as following a Development-status convention at a named version, because the names may change. Don't validate method names against the pinned conventions package. Test propagation over a real transport, and wrap in-memory calls in a span of your own when a test needs a parent.
Measure how often the right tool is chosen
The replay in Replay recorded host runs asks whether the host still does what it did. A second question is how often the model chooses well with the tools and descriptions the shop gives it. The usual first measure is tool-selection accuracy: the share of cases in which the model's first call uses the tool a correct agent would use, often called top-1 accuracy. Benchmarks also score the arguments. The Berkeley Function Calling Leaderboard, for example, checks each call's name and arguments together against the expected ones by matching their abstract syntax trees (BFCL).
A number from a fixed recording measures something narrow. It measures the shop's tool names and descriptions against one model, Qwen3-4B-Instruct-2507, quantized as Q4_K_M and sampled at temperature 0.7 (Work through the ticket queue), on the book's one prompt and one seed, over the 48 tickets the book wrote. It is one draw from that setup, and the groups are small: the smallest has 8 tickets, so one ticket is a large share of its score. It says nothing about models in general, and a change to any description needs a new recording before the number means anything, which the replay's RecordingMismatch enforces. Each of the shop's tickets carries the tool a correct agent calls first, and the example scores with ticket_host.expected_first_tool, as chapter 10 does: a refund request counts as right when it starts with quote_refund, because issue_refund needs a quote first.
The example replays the queue, takes each ticket's first tool call, and prints the accuracy overall, the accuracy per expected tool, and each confusion. For the calls that chose the right tool and name an order, it also scores the order_id argument two ways: against the ID as the ticket wrote it, and against the order the customer meant. The two differ for the 5 tickets where a customer mistyped the number.
1"""Score the first tool call of every ticket in chapter 10's queue run against the tool each ticket is tagged with:2accuracy overall and per tag, the confusions, and how often the right tool got the order ID right."""3import asyncio4from collections import Counter56from lib import queue_replay, ticket_host7from lib.shop_data import load_shop8910async def main() -> None:11 tickets = load_shop().tickets12 runs = await queue_replay.replay_queue()13 scored = [] # (ticket, expected tool, first tool called)14 for ticket in tickets:15 calls = runs[ticket.ticket_id].calls16 scored.append((ticket, ticket_host.expected_first_tool(ticket), calls[0] if calls else None))17 right = [(t, call) for t, expected, call in scored if call and call.tool == expected]18 print("first tool right:", f"{len(right)} of {len(scored)}")19 print("top-1 accuracy:", f"{len(right) / len(scored):.2f}")20 for expected, count in sorted(Counter(expected for _, expected, _ in scored).items()):21 hits = sum(1 for t, e, call in scored if e == expected and call and call.tool == expected)22 print(f"expected {expected}:", f"{hits} of {count}")23 confusions = Counter((expected, call.tool if call else "no tool") for _, expected, call in scored24 if not call or call.tool != expected)25 for (expected, chosen), count in confusions.most_common():26 print(f"expected {expected}, called {chosen}:", count)27 with_order = [(t, call) for t, call in right if t.order_id and "order_id" in call.arguments]28 as_written = sum(call.arguments["order_id"] == t.cited_order_id for t, call in with_order)29 meant = sum(call.arguments["order_id"] == t.order_id for t, call in with_order)30 print("right tool, order ID as the ticket wrote it:", f"{as_written} of {len(with_order)}")31 print("right tool, order the customer meant:", f"{meant} of {len(with_order)}")32 typos = [(t, e, call) for t, e, call in scored if t.cited_order_id and t.cited_order_id != t.order_id]33 print("tickets with a mistyped order ID:", len(typos))34 print("of which the right tool was called:", sum(1 for _, e, call in typos if call and call.tool == e))35 for t, _, call in [(t, e, call) for t, e, call in typos if not call or call.tool != e]:36 print(f"mistyped {t.ticket_id}, first tool:", call.tool if call else "no tool")37 print("smallest expected-tool group, tickets:", min(Counter(e for _, e, _ in scored).values()))38 manifest = queue_replay.manifest()39 print("recorded model:", manifest["model"].split("/")[-1])40 print("recorded quantization:", manifest["quantization"])414243asyncio.run(main())
python tool-choice-accuracy.pyfirst tool right: 36 of 48
top-1 accuracy: 0.75
expected shop.check_stock: 10 of 10
expected shop.find_orders: 8 of 8
expected shop.get_order_status: 12 of 14
expected shop.quote_refund: 6 of 16
expected shop.quote_refund, called shop.get_order_status: 8
expected shop.get_order_status, called partner.track_parcel: 2
expected shop.quote_refund, called shop.find_orders: 2
right tool, order ID as the ticket wrote it: 18 of 18
right tool, order the customer meant: 14 of 18
tickets with a mistyped order ID: 5
of which the right tool was called: 4
mistyped T-0042, first tool: shop.get_order_status
smallest expected-tool group, tickets: 8
recorded model: Qwen3-4B-Instruct-2507
recorded quantization: Q4_K_M
The first tool was right for 36 of 48 tickets, a top-1 accuracy of 0.75, and the same 36 of 48 that chapter 10 counted. The overall figure hides where the errors are. Stock questions scored 10 of 10 and order searches 8 of 8, while refund tickets scored 6 of 16. Most of the misses, 8, went to get_order_status, although its description says to use quote_refund for a refund and quote_refund's says to call it first. That pair of descriptions is the one to work on next, and the queue is the ticket set to record again once they change. The two status questions that went to partner.track_parcel are chapter 10's tracking-number case (Work through the ticket queue).
The arguments show why the reference matters. With the right tool, the model used the order ID as the ticket wrote it in 18 of 18 calls, and the order the customer meant in 14 of 18. The misses are customers' typos: 4 of the mistyped tickets reached the right tool and are scored here, and the other, T-0042, went first to shop.get_order_status, so it counts among the wrong tools instead. No model can see through a typo without looking the customer's orders up. Scored against what the customer meant, the model looks careless; scored against what it was given, it is faithful. The gap belongs to the host's flow, which should check an order against the ticket's customer before acting on it.
Published benchmarks point the same way, each with its own setup. One preprint, RAG-MCP, reports that retrieving a shortlist of tools before the model chooses raised selection accuracy from 13.62% to 43.13% on its benchmark tasks, scored by a model acting as judge (RAG-MCP). MCP-Bench, over 28 live servers and 250 tools, reports persistent challenges on tasks whose instructions don't name the tool (MCP-Bench). Each is a single preprint on its own tasks, and neither says anything about the shop's numbers.
Working rule: Score every recorded run on the first tool per tag, not only overall, and score arguments separately, against what the ticket gave the model and against the truth. Work on the descriptions behind the largest confusion first, record again, and compare per-tag scores between the two recordings. State the model, the prompt and the ticket set beside every score, and never compare scores across different setups.
Change tools without breaking clients
The contract test in Check schemas as a contract says when a change breaks clients; it doesn't say how to make the change. The specification doesn't either. A tool definition has no deprecation field, and nothing in the pinned revision marks a tool as on its way out (tools). What follows is practice: the book's recommendation, built from parts the specification does define, not a protocol feature.
There are three such parts. A client asks for tool list changes with subscriptions/listen and hears about each one on that stream (subscriptions), as Tell clients when a resource changes shows for resources. A tools/list result carries a cache hint, ttlMs, and a change notification invalidates a cached list that is still fresh; clients MAY re-fetch early when a call fails as if the tool changed, for example with an unknown method or invalid parameters (caching). And a tool execution error is what clients SHOULD give the model so it can correct itself (tools, "Error Handling"). From those parts, the book recommends this procedure: add the new tool beside the old one; mark the old one "Deprecated: use …" in its description; announce the change; shorten the TTL while the change is under way; keep the old tool working through a window; and after the window, answer it with an execution error that names the replacement.
The shop is replacing find_orders with find_orders_v2, which can also filter by SKU. The example serves chapter 3's tools from Design tools a model can use over Streamable HTTP on loopback, with a TTL of one minute on the tool list, and connects two clients: one that listens for list changes, and one that only caches. Release 2 adds find_orders_v2 and rewords find_orders, and the server publishes the change. Release 3 replaces find_orders with a function that raises ToolError. An in-memory client wouldn't do here, because the in-process path doesn't deliver list changes (Test tools in memory).
1"""Retire find_orders for find_orders_v2 over three releases, watched by a listening client and a caching client."""2import asyncio34from mcp import Client5from mcp.server.caching import CacheHint6from mcp.server.mcpserver import MCPServer7from mcp.server.mcpserver.exceptions import ToolError8from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged910from lib import confirmations, loopback, shop_tools11from lib.testing import shop_api_here1213bus, tools = InMemorySubscriptionBus(), confirmations.chapter3_tools()14server = MCPServer("shop-support", version="0.11.0", tools=list(tools.values()), subscriptions=bus,15 cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}) # short, for the change16old = tools["find_orders"]171819def find_orders_v2(customer_id: shop_tools.CustomerId, sku: shop_tools.Sku | None = None) -> shop_tools.OrderList:20 """Search one customer's orders, newest first, optionally only those with one product's SKU."""21 found = old.fn(customer_id, limit=50)22 return found.model_copy(update={"orders": [o for o in found.orders if sku is None or sku in o.skus]})232425def retired(customer_id: shop_tools.CustomerId) -> shop_tools.OrderList:26 raise ToolError("find_orders was retired. Call find_orders_v2 with the same customer_id.")272829async def replace_find_orders(fn, description: str) -> None:30 server.remove_tool("find_orders")31 server.add_tool(fn, name="find_orders", title=old.title, description=description, annotations=old.annotations)32 await bus.publish(ToolsListChanged()) # to every client listening for list changes333435async def main() -> None:36 with shop_api_here(), loopback.serve(server.streamable_http_app()) as base_url:37 async with Client(base_url + "/mcp") as listening, Client(base_url + "/mcp") as cached:38 listing, _ = await cached.list_tools(), await listening.list_tools() # both cache release 1's list39 print("release 1:", f"{len(listing.tools)} tools, ttlMs {listing.ttl_ms}")40 async with listening.listen(tools_list_changed=True) as changes:41 server.add_tool(find_orders_v2) # release 242 await replace_find_orders(old.fn, "Deprecated: use find_orders_v2. " + old.description)43 print("event:", type(await anext(changes)).__name__)44 print("listening client sees:", ", ".join(t.name for t in (await listening.list_tools()).tools))45 print("cached client sees:", ", ".join(t.name for t in (await cached.list_tools()).tools))46 during = await cached.call_tool("find_orders", {"customer_id": "C-0127"})47 print("find_orders during the window:", "tool error" if during.is_error else "result")48 await replace_find_orders(retired, "Retired: use find_orders_v2.") # release 349 after = await cached.call_tool("find_orders", {"customer_id": "C-0127"})50 print("find_orders after the window:", after.content[0].text)515253asyncio.run(main())
python deprecate-a-tool.pyrelease 1: 5 tools, ttlMs 60000
event: ToolsListChanged
listening client sees: get_order_status, quote_refund, issue_refund, check_stock, find_orders_v2, find_orders
cached client sees: find_orders, get_order_status, quote_refund, issue_refund, check_stock
find_orders during the window: result
find_orders after the window: Error executing tool find_orders: find_orders was retired. Call find_orders_v2 with the same customer_id.
Release 1 listed 5 tools, ttlMs 60000. When release 2 shipped, the listening client received ToolsListChanged. The SDK drops a cached list before it hands the client such an event (mcp/client/client.py), so the listening client's next listing showed both tools: get_order_status, quote_refund, issue_refund, check_stock, find_orders_v2, find_orders. The caching client heard nothing and served release 1's list from its cache: find_orders, get_order_status, quote_refund, issue_refund, check_stock. A model behind that client doesn't learn about find_orders_v2 until the TTL runs out or it re-fetches, which is why the TTL should be short while a change is under way, and why the old tool has to keep working. During the window it did, with a result.
After release 3, the caching client's call to find_orders came back as an execution error the model can act on: "Error executing tool find_orders: find_orders was retired. Call find_orders_v2 with the same customer_id.". A removed tool would have produced the SDK's unknown-tool error instead, which tells the model the name was wrong but not what to call (Report failures as tool results). The SDK returns that error as a tool error, although the specification makes an unknown tool a protocol error (mcp/server/mcpserver/tools/tool_manager.py; tools, "Error Handling").
The listening client's list also shows a side effect: find_orders moved to the end. MCPServer lists tools in the order they were added, so the example's live swap, which removes the tool and adds it back, moves it (Cache list results). A changed order changes the start of every prompt that renders the list, which is what a model provider's prompt cache matches on. A server that restarts with the new code lists tools in the order its code registers them, so add new tools at the end and leave the existing ones where they are.
Working rule: Retire a tool in three releases: add the replacement and mark the old tool deprecated in its description, keep both working through a window you announce, then answer the old name with an execution error that names the replacement. Shorten ttlMs on tools/list while the change is under way, publish a list change at each release, and open a subscriptions/listen stream in every host you control. Say in your changelog that the window is your policy, since the protocol defines none.
What goes wrong in production
The checks in this chapter run against one process on one machine. In production the same signals arrive from many replicas and many clients, and each can mislead you in its own way.
Spans multiply past what a backend can index. The SDK names a server span after the method and the tool, which keeps the number of distinct names as small as the tool list. An order ID, a resource URI or a customer ID in a span name makes one name per value; the MCP conventions keep the resource URI in an attribute for that reason (MCP semantic conventions). The sign is a tracing bill that grows with traffic rather than with features.
Traces carry the shop's data. The SDK records no tool arguments or results in its spans (mcp/server/_otel.py), and the conventions mark gen_ai.tool.call.arguments as opt-in (same source). The shop's arguments hold customer and order IDs, and an argument says only what the caller sent. To record who acted, take the verified token's subject, which at the support desk is the member of staff, with the customer from the ticket record the server checked (Keep the server from acting as a confused deputy). Add arguments to spans only behind a setting, and keep them out of any backend that the shop's data-handling policy doesn't cover.
The conventions move under your dashboards. The MCP conventions have Development status and have changed repository once (moved notice), and the SDK's middleware is marked provisional (mcp/server/lowlevel/server.py). An SDK release that renames an attribute empties a dashboard without an error. Keep the attribute names in one place in your configuration, and check them at each SDK upgrade, as you would any update trigger.
Replicas disagree during a deploy. In a rolling deploy, one replica serves release 1's schema while the next serves release 2's, and a client that listed from one and calls the other gets the RuntimeError from Check schemas as a contract. The sign is client-side validation errors that rise during a deploy and fall when it ends. Ship additive changes first, and remove the old shape only once every replica serves the new one and the window has passed.
The recording ages. A replay keeps passing while the model behind the production host changes, so scores from the recording drift away from what customers see. Record again at each model change, as Work through the ticket queue recommends, and run the same unbacked-promise check on a sample of production runs, since a replay can't find a failure the recorded model never made.
Working rule: Before you rely on a trace or a score, check what it covers: which transport, which model, and which release of each schema. Keep span names bounded and arguments out of spans by default, pin the attribute names your dashboards use to a named convention version, and run the contract test and the replay against every release before it reaches a replica.
Decisions this chapter made
- Tools are tested in memory with
Client(server, raise_exceptions=True), asserting on fields, with one loopback test per transport for what the in-process path skips. - pytest runs with anyio's own plugin, and without
inline-snapshot, which the book doesn't pin.lib.testingprints one line per test so the output is stable. - The last release's schemas are the contract. CI validates new results against them with the validator for each schema's declared dialect, 2020-12 by default, and
lib.contractdiffs the schemas for review. - Chapter 10's recording
ticket-runsis replayed, not recorded again. ARecordingMismatchafter a description change means record again. - The host checks replies against its call log. A reply that promises a refund no call made goes to a person; the keyword check in
lib.queue_replayis a rule of thumb. - Traces come from the SDK's middleware, with the attributes it emits labelled as a Development-status convention, and propagation is tested over HTTP.
- Tool choice is scored per tag, and arguments separately, with
expected_first_tool, as chapter 10 scores. Order IDs are scored both against the ticket's text and against the order the customer meant. - Tools are retired in three releases, with a short TTL and a list change at each, and an execution error that names the replacement after the window. The procedure is the book's practice, not the specification's.
This chapter, in this edition
Edition 2026.11.5Examples passed Nov 4, 20266 of 6 examples in this chapter pass
Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.