Calling call_tool and printing the result is enough for a demo. An application that lets a model use MCP tools needs more: it must know what the server offers, refuse calls that cannot succeed, tell a tool error from a protocol error or a timeout, and show where the time went when a call is slow. You will build that client with the official MCP Python SDK, mcp 2.3.0, and trace each call from the application into the server with OpenTelemetry.
When you need this. A docs assistant calls a docs server many times in one conversation. When an answer is slow or wrong, you need to know whether the model sent bad arguments, the tool reported an error, or the client stopped waiting. A client that checks each call before it sends it, and traces it across the process boundary, answers that in one look.
The client and server spans of that run joined into 3 traces with 23 spans. The figure follows one call through both processes.
1. Set up the project
Download the complete lab ZIP, which includes the Python files, fixtures, tests and README. Extract it before running the commands below.
unzip build-an-mcp-client.zip
cd mcp-client-tracing
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements-lock.txtThe client talks to this docs server over stdio. search_docs opens its own span, docs.search, and rebuild_index sleeps for the number of seconds it is given, so a short client timeout can fire. The server sets ttlMs to 60,000 for tools/list, which tells clients they can reuse the listing for a minute.
"""The docs server the traced client talks to. Run with `python docs_server.py`.
If MCP_SPANS_FILE is set, the server writes its OpenTelemetry spans there. mcp 2.3.0
creates a server span for every request and continues the trace the client sends in _meta.
"""
import os
import time
from typing import TypedDict
from opentelemetry import trace
if os.environ.get("MCP_SPANS_FILE"):
from span_export import configure
configure(os.environ["MCP_SPANS_FILE"])
from mcp.server import MCPServer
from mcp.server.caching import CacheHint
from mcp.server.mcpserver.exceptions import ToolError
PAGES = {"quickstart": "Start a worker with taskq worker emails.",
"retries": "A failed job is retried 3 times with exponential backoff."}
server = MCPServer("docs", version="1.0.0", instructions="Search the taskq docs.",
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
tracer = trace.get_tracer("docs")
class SearchHit(TypedDict):
slug: str
text: str
@server.tool()
def search_docs(query: str) -> list[SearchHit]:
"""Return the docs pages that contain the query."""
with tracer.start_as_current_span("docs.search") as span:
if not query.strip():
raise ToolError("The query is empty.")
hits = [{"slug": slug, "text": text} for slug, text in PAGES.items() if query.lower() in text.lower()]
span.set_attribute("docs.hits", len(hits))
return hits
@server.tool()
def rebuild_index(seconds: float) -> str:
"""Rebuild the search index; takes `seconds` seconds."""
time.sleep(seconds)
return "index rebuilt"
if __name__ == "__main__":
server.run()2. Act on discovery
Now we connect and read what the server offers. When the SDK client connects, it sends server/discover and keeps the reply: the protocol version, the server’s name and version, and its capabilities. TracedClient.discovery() reads those, and load_tools() sends tools/list only when the server offers the tools capability. MCP server/discover
"""An MCP client that keeps what discovery tells it, checks calls before sending and traces each one.
Usage: python traced_client.py [--write-log execution-log.json] [--write-trace trace-tree.json]
"""
import argparse
import asyncio
import json
import os
import sys
import tempfile
from pathlib import Path
import jsonschema
from opentelemetry import trace
from mcp import Client, MCPError, StdioServerParameters
from mcp.types import REQUEST_TIMEOUT
from span_export import configure, span_tree
HERE = Path(__file__).resolve().parent
tracer = trace.get_tracer("traced-client")
class TracedClient:
"""Wraps an SDK Client: remembers discovery, lists tools only if offered, and classifies every call."""
def __init__(self, client):
self.client, self.tools, self.log = client, {}, []
def discovery(self):
caps, info = self.client.server_capabilities, self.client.server_info
return {"protocol_version": self.client.protocol_version, "server": f"{info.name} {info.version}",
"capabilities": [name for name in ("tools", "resources", "prompts") if getattr(caps, name) is not None]}
async def load_tools(self):
if self.client.server_capabilities.tools is None:
self.log.append({"request": "tools/list", "outcome": "skipped", "detail": "server does not offer tools"})
return
self.tools = {tool.name: tool for tool in (await self.client.list_tools()).tools}
self.log.append({"request": "tools/list", "outcome": "result", "detail": sorted(self.tools)})
async def call(self, name, arguments, timeout=5.0):
with tracer.start_as_current_span(f"app call {name}") as span:
outcome, detail = await self._call(name, arguments, timeout)
span.set_attribute("app.outcome", outcome)
self.log.append({"request": f"tools/call {name}", "arguments": arguments, "outcome": outcome, "detail": detail})
async def _call(self, name, arguments, timeout):
tool = self.tools.get(name)
if tool is None:
return "refused_locally", "tool is not in the server's tools/list"
problem = jsonschema.exceptions.best_match(jsonschema.Draft202012Validator(tool.input_schema).iter_errors(arguments))
if problem:
return "refused_locally", f"arguments do not match inputSchema: {problem.message}"
try:
result = await self.client.call_tool(name, arguments, read_timeout_seconds=timeout)
except MCPError as error:
return ("timeout" if error.error.code == REQUEST_TIMEOUT else "protocol_error"), error.error.message
except RuntimeError as error: # mcp 2.3.0 checks structuredContent against outputSchema and raises this
return "invalid_output", str(getattr(error.__cause__, "message", error))
if result.is_error:
return "tool_error", result.content[0].text
return "result", result.structured_content
def server(script, **env):
return StdioServerParameters(command=sys.executable, args=[str(HERE / script)], env={**os.environ, **env})
async def docs_session(server_spans):
async with Client(server("docs_server.py", MCP_SPANS_FILE=str(server_spans))) as client:
with tracer.start_as_current_span("answer docs question"):
app = TracedClient(client)
discovery = app.discovery()
await app.load_tools()
await app.load_tools() # served from the SDK's cache: the listing has ttlMs 60000
await app.call("search_docs", {"query": "worker"})
await app.call("search_docs", {"query": 42})
await app.call("search_docs", {"query": " "})
await app.call("rebuild_index", {"seconds": 1.0}, timeout=0.2)
await app.call("search_docs", {"query": "retried"})
await app.call("delete_page", {"slug": "retries"})
return {"discovery": discovery, "log": app.log}
async def broken_session():
async with Client(server("broken_server.py")) as client:
app = TracedClient(client)
discovery = app.discovery()
await app.load_tools()
await app.call("search_docs", {"query": "retries"})
return {"discovery": discovery, "log": app.log}
def run(directory):
"""Run both sessions with span files in `directory`; return the execution log and the joined trace."""
client_spans, server_spans = Path(directory) / "client-spans.jsonl", Path(directory) / "server-spans.jsonl"
configure(client_spans)
docs = asyncio.run(docs_session(server_spans))
tree = span_tree(client_spans, server_spans)
return {"docs": docs, "broken": asyncio.run(broken_session())}, tree
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--write-log", type=Path)
parser.add_argument("--write-trace", type=Path)
options = parser.parse_args(argv)
with tempfile.TemporaryDirectory() as directory:
log, tree = run(directory)
for data in log.values():
print(f"== {data['discovery']['server']} offers {', '.join(data['discovery']['capabilities'])}")
for entry in data["log"]:
print(f" {entry['request']:26} {entry['outcome']:16} {json.dumps(entry['detail'])[:64]}")
print(f"trace: {tree['spans']} spans in {tree['traces']} trace(s)")
for path, data in ((options.write_log, log), (options.write_trace, tree)):
if path:
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {path}")
if __name__ == "__main__":
main()docs_session() calls load_tools() twice. The SDK client has a response cache that follows the listing’s ttlMs and cacheScope, so the second call returned the same tools without a request: the trace has one MCP send tools/list span. MCP caching
The recorded discovery shows that the docs server offers tools, resources, prompts, although it has no resources or prompts: MCPServer advertises all three. The broken server offers only tools.
A server without the tools capability. load_tools() records skipped and sends no tools/list. Offer a tools-dependent activity only after checking discovery.
A repeated listing within its ttlMs. The SDK returns the cached listing, producing one request span for the two calls. If a tool changes, the server must choose a freshness interval that limits stale-schema refusals.
3. Check each call before you send it
The tool listing gives each tool’s inputSchema, a JSON Schema for its arguments. _call() refuses a call to a tool absent from the cached listing, and validates the arguments against the schema with jsonschema before anything is sent. The caller gets the reason without a server request; this client records the outcome rather than running a model. In the recorded run, {"query": 42} was refused with arguments do not match inputSchema: 42 is not of type 'string'.
Arguments that break the tool’s inputSchema. _call() records refused_locally without sending a request. Correct the arguments against the cached schema before retrying.
A tool the server never listed. The same check records refused_locally without a request span. Select from tools/list rather than guessing a name.
4. Classify every outcome
The tools specification separates two kinds of failure. A tool error is a normal result with isError: true, written for the model, such as an empty query. A protocol error is a JSON-RPC error, such as an unknown method, which the SDK raises as MCPError. MCP tools: error handling
_call() adds three more outcomes:
- timeout:
read_timeout_secondsis the client’s own limit. When it fires, mcp 2.3.0 raisesMCPErrorwith the SDK constantREQUEST_TIMEOUT, code-32001, and sendsnotifications/cancelled. No reply arrived; the code was produced inside the client. - invalid_output: the specification says clients should validate
structuredContentagainst the tool’soutputSchema. mcp 2.3.0 does this itself and raisesRuntimeError, with thejsonschemaerror as its cause. The broken server returns a hit withouttext, and the client recorded'text' is a required property. - refused_locally: the checks in step 3.
The broken server is a few lines of standard-library Python that answers over stdio like an MCP server, but returns structuredContent without the text field its own outputSchema requires:
Read broken_server.py
"""A deliberately broken MCP server for one client test. Standard library only.
It speaks protocol 2026-07-28 over stdio, offers only tools, and returns
structuredContent that breaks its own outputSchema: "text" is missing.
"""
import json
import sys
META = {"io.modelcontextprotocol/serverInfo": {"name": "broken-docs", "version": "1.0.0"}}
TOOL = {"name": "search_docs", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
"outputSchema": {"type": "object", "properties": {"slug": {"type": "string"}, "text": {"type": "string"}},
"required": ["slug", "text"]}}
RESULTS = {
"server/discover": {"supportedVersions": ["2026-07-28"], "capabilities": {"tools": {}}, "ttlMs": 0, "cacheScope": "public"},
"tools/list": {"tools": [TOOL], "ttlMs": 0, "cacheScope": "public"},
"tools/call": {"content": [{"type": "text", "text": "{\"slug\": \"retries\"}"}], "structuredContent": {"slug": "retries"}},
}
for line in sys.stdin:
message = json.loads(line)
if "id" in message:
reply = {"jsonrpc": "2.0", "id": message["id"]}
if message["method"] in RESULTS:
reply["result"] = {"resultType": "complete", **RESULTS[message["method"]], "_meta": META}
else:
reply["error"] = {"code": -32601, "message": "Method not found"}
print(json.dumps(reply), flush=True)A tool that reports a problem. The client records tool_error and the tool’s text. Pass that text back to the model so it can correct the request; do not treat a normal JSON-RPC result as a transport outage.
A call slower than the client’s timeout. The client records timeout, but the recorded rebuild_index server span finishes with status UNSET: the tool did not observe cancellation. Before retrying a write, check whether it took effect or use an idempotency key. Match the SDK timeout code, not the words ‘timed out’; a server can send similar text itself.
Output that breaks the outputSchema. The client records invalid_output with the schema error. Fix the server’s returned shape; forwarding malformed structured content to the model hides a server bug.
5. Trace each call across the process boundary
OpenTelemetry records work as spans: named intervals with a status and attributes. Spans of one operation share a trace ID, and each span names its parent. MCP reserves the _meta keys traceparent, tracestate and baggage for W3C trace context. With a tracer provider configured, mcp 2.3.0 adds traceparent to every request it sends and starts a server span under it. MCP base protocol: _meta · OpenTelemetry: traces
Each process writes its finished spans to its own JSON Lines file. span_tree() reads both files and nests every span under its parent, without IDs and times, so a fresh run can be compared with the saved one:
"""Write this process's OpenTelemetry spans to a JSON Lines file, and join two files into one trace tree."""
import json
import os
from pathlib import Path
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
class JsonLinesExporter(SpanExporter):
def __init__(self, path):
self.path = path
def export(self, spans):
with open(self.path, "a", encoding="utf-8") as out:
for span in spans:
out.write(json.dumps({
"name": span.name, "kind": span.kind.name, "status": span.status.status_code.name,
"trace_id": format(span.context.trace_id, "032x"), "span_id": format(span.context.span_id, "016x"),
"parent_id": format(span.parent.span_id, "016x") if span.parent else None,
"start": span.start_time, "attributes": dict(span.attributes or {})}) + "\n")
return SpanExportResult.SUCCESS
_exporter = None
def configure(path):
"""Send this process's spans to `path`. A later call only changes the file."""
global _exporter
if _exporter is None:
os.environ["OTEL_SDK_DISABLED"] = "false" # the repository's test runner disables OpenTelemetry; this exporter only writes a file
_exporter = JsonLinesExporter(path)
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(_exporter))
trace.set_tracer_provider(provider)
_exporter.path = path
def span_tree(*paths):
"""Nest the spans from every file by parent, without IDs and times, so two runs compare equal."""
spans = sorted((json.loads(line) for path in paths for line in Path(path).read_text().splitlines()), key=lambda s: s["start"])
ids = {s["span_id"] for s in spans}
def node(span):
attributes = {k: v for k, v in span["attributes"].items() if k != "jsonrpc.request.id"}
children = [node(s) for s in spans if s["parent_id"] == span["span_id"]]
return {"name": span["name"], "kind": span["kind"], "status": span["status"], "attributes": attributes, "children": children}
return {"traces": len({s["trace_id"] for s in spans}), "spans": len(spans),
"roots": [node(s) for s in spans if s["parent_id"] not in ids]}Run the client and save the log and the trace:
.venv/bin/python traced_client.py --write-log execution-log.json --write-trace trace-tree.jsonHere is the recorded run against two servers: a docs server built with the SDK and a deliberately broken server. Each line is one request and how the client classified it:
== docs 1.0.0 offers tools, resources, prompts
tools/list result ["rebuild_index", "search_docs"]
tools/list result ["rebuild_index", "search_docs"]
tools/call search_docs result {"result": [{"slug": "quickstart", "text": "Start a worker with
tools/call search_docs refused_locally "arguments do not match inputSchema: 42 is not of type 'string'"
tools/call search_docs tool_error "Error executing tool search_docs: The query is empty."
tools/call rebuild_index timeout "Request 'tools/call' timed out"
tools/call search_docs result {"result": [{"slug": "retries", "text": "A failed job is retried
tools/call delete_page refused_locally "tool is not in the server's tools/list"
== broken-docs 1.0.0 offers tools
tools/list result ["search_docs"]
tools/call search_docs invalid_output "'text' is a required property"
trace: 23 spans in 3 trace(s)
wrote execution-log.json
wrote trace-tree.jsonThis excerpt of the saved trace shows the app span and everything under it, one span per line with its kind and status:
answer docs question INTERNAL UNSET
MCP send tools/list CLIENT UNSET
tools/list SERVER UNSET
app call search_docs INTERNAL UNSET
MCP send tools/call search_docs CLIENT UNSET
tools/call search_docs SERVER UNSET
docs.search INTERNAL UNSET
app call search_docs INTERNAL UNSET
app call search_docs INTERNAL UNSET
MCP send tools/call search_docs CLIENT UNSET
tools/call search_docs SERVER ERROR
docs.search INTERNAL ERROR
app call rebuild_index INTERNAL UNSET
MCP send tools/call rebuild_index CLIENT ERROR
tools/call rebuild_index SERVER UNSET
app call search_docs INTERNAL UNSET
MCP send tools/call search_docs CLIENT UNSET
tools/call search_docs SERVER UNSET
docs.search INTERNAL UNSET
app call delete_page INTERNAL UNSETThe execution log keeps the discovery result and one entry per request, such as the refused call:
{
"discovery": {
"capabilities": [
"tools",
"resources",
"prompts"
],
"protocol_version": "2026-07-28",
"server": "docs 1.0.0"
},
"log[3]": {
"arguments": {
"query": 42
},
"detail": "arguments do not match inputSchema: 42 is not of type 'string'",
"outcome": "refused_locally",
"request": "tools/call search_docs"
}
}Full output: trace-tree.json and execution-log.json.
Read the tree from the app span answer docs question. Each MCP send client span has exactly one server child, so the server continued the client’s trace, and the tool’s docs.search span sits under its server span. The two refused calls have no children: nothing was sent. The tool error shows as ERROR on the server span while the client span is UNSET, because the reply itself was a normal result. The other two traces are discovery, which ran before the app span began, and notifications/cancelled, which arrived without a traceparent.
The server span names, such as tools/call search_docs, and the attributes mcp.method.name and gen_ai.tool.name follow the OpenTelemetry conventions for MCP, which are still marked as in development. OpenTelemetry GenAI conventions: MCP
6. Test the client
The suite runs both sessions, checks each outcome and the trace shape, and compares the run with the saved files:
.venv/bin/python -m unittest -v test_traced_client.pytest_tools_are_not_listed_when_the_server_does_not_offer_them (test_traced_client.CapabilityGate.test_tools_are_not_listed_when_the_server_does_not_offer_them) ... ok
Tool 'search_docs' failed: 'Error executing tool search_docs: The query is empty.'
test_bad_arguments_and_unknown_tools_are_refused_before_sending (test_traced_client.TracedRun.test_bad_arguments_and_unknown_tools_are_refused_before_sending) ... ok
test_discovery_records_version_server_and_capabilities (test_traced_client.TracedRun.test_discovery_records_version_server_and_capabilities) ... ok
test_output_that_breaks_the_schema_is_rejected (test_traced_client.TracedRun.test_output_that_breaks_the_schema_is_rejected) ... ok
test_saved_fixtures_match_a_fresh_run (test_traced_client.TracedRun.test_saved_fixtures_match_a_fresh_run) ... ok
test_second_listing_is_served_from_cache_without_a_request (test_traced_client.TracedRun.test_second_listing_is_served_from_cache_without_a_request) ... ok
test_server_spans_continue_the_client_trace (test_traced_client.TracedRun.test_server_spans_continue_the_client_trace) ... ok
test_timeout_is_local_and_the_server_still_finishes (test_traced_client.TracedRun.test_timeout_is_local_and_the_server_still_finishes) ... ok
test_tool_error_is_a_result_with_is_error (test_traced_client.TracedRun.test_tool_error_is_a_result_with_is_error) ... ok
----------------------------------------------------------------------
Ran 9 tests in 1.426s
OKAll 9 tests passed. The log line in the middle is the docs server’s own message for the empty query, written to standard error.
Read the test source
"""Tests for the traced MCP client: python -m unittest -v test_traced_client.py"""
import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
import traced_client
HERE = Path(__file__).resolve().parent
def walk(nodes):
for node in nodes:
yield node
yield from walk(node["children"])
class TracedRun(unittest.TestCase):
@classmethod
def setUpClass(cls):
with tempfile.TemporaryDirectory() as directory:
cls.log, cls.tree = traced_client.run(directory)
cls.calls = cls.log["docs"]["log"]
cls.spans = list(walk(cls.tree["roots"]))
cls.app = next(root for root in cls.tree["roots"] if root["name"] == "answer docs question")
def test_discovery_records_version_server_and_capabilities(self):
self.assertEqual(self.log["docs"]["discovery"], {"protocol_version": "2026-07-28", "server": "docs 1.0.0",
"capabilities": ["tools", "resources", "prompts"]})
self.assertEqual(self.log["broken"]["discovery"]["capabilities"], ["tools"])
def test_second_listing_is_served_from_cache_without_a_request(self):
self.assertEqual([s["name"] for s in self.spans].count("MCP send tools/list"), 1)
self.assertEqual([e["outcome"] for e in self.calls[:2]], ["result", "result"])
def test_bad_arguments_and_unknown_tools_are_refused_before_sending(self):
refused = [c for c in self.app["children"] if c["attributes"].get("app.outcome") == "refused_locally"]
self.assertEqual(len(refused), 2)
self.assertTrue(all(c["children"] == [] for c in refused)) # no request span: nothing was sent
self.assertIn("42 is not of type 'string'", self.calls[3]["detail"])
def test_tool_error_is_a_result_with_is_error(self):
self.assertEqual((self.calls[4]["outcome"], self.calls[4]["detail"]), ("tool_error", "Error executing tool search_docs: The query is empty."))
def test_timeout_is_local_and_the_server_still_finishes(self):
self.assertEqual(self.calls[5]["outcome"], "timeout")
client_span = next(c for c in self.app["children"] if c["name"] == "app call rebuild_index")["children"][0]
self.assertEqual((client_span["kind"], client_span["status"]), ("CLIENT", "ERROR"))
self.assertEqual((client_span["children"][0]["kind"], client_span["children"][0]["status"]), ("SERVER", "UNSET"))
self.assertEqual(self.calls[6]["outcome"], "result") # the next call is still answered
self.assertIn("notifications/cancelled", [root["name"] for root in self.tree["roots"]])
def test_server_spans_continue_the_client_trace(self):
for span in self.spans:
if span["kind"] == "CLIENT":
self.assertEqual([child["kind"] for child in span["children"]], ["SERVER"])
self.assertIn("docs.search", [s["name"] for s in self.spans])
def test_output_that_breaks_the_schema_is_rejected(self):
self.assertEqual(self.log["broken"]["log"][1]["outcome"], "invalid_output")
self.assertEqual(self.log["broken"]["log"][1]["detail"], "'text' is a required property")
def test_saved_fixtures_match_a_fresh_run(self):
self.assertEqual(json.loads((HERE / "execution-log.json").read_text()), self.log)
self.assertEqual(json.loads((HERE / "trace-tree.json").read_text()), self.tree)
class CapabilityGate(unittest.IsolatedAsyncioTestCase):
async def test_tools_are_not_listed_when_the_server_does_not_offer_them(self):
class NoTools:
server_capabilities = SimpleNamespace(tools=None)
async def list_tools(self):
raise AssertionError("tools/list must not be sent")
app = traced_client.TracedClient(NoTools())
await app.load_tools()
self.assertEqual(app.log, [{"request": "tools/list", "outcome": "skipped", "detail": "server does not offer tools"}])
if __name__ == "__main__":
unittest.main()Read client and server span outcomes
Every outcome the client records comes from one of three places, and the trace shows which. A local refusal returns from TracedClient._call() before the SDK call, so its app span has no children. A tool error is a normal JSON-RPC result: the client span is UNSET and only the server span is ERROR. A timeout is decided by a timer in the client process. This is the recorded subtree for the timed-out rebuild_index call:
{
"attributes": {
"app.outcome": "timeout"
},
"children": [
{
"attributes": {
"mcp.method.name": "tools/call"
},
"children": [
{
"attributes": {
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": "rebuild_index",
"mcp.method.name": "tools/call",
"mcp.protocol.version": "2026-07-28"
},
"children": [],
"kind": "SERVER",
"name": "tools/call rebuild_index",
"status": "UNSET"
}
],
"kind": "CLIENT",
"name": "MCP send tools/call rebuild_index",
"status": "ERROR"
}
],
"kind": "INTERNAL",
"name": "app call rebuild_index",
"status": "UNSET"
}The client span MCP send tools/call rebuild_index is ERROR because the SDK’s timer fired. Its child, the server span, is UNSET: the server finished the tool and sent a reply that nobody was waiting for. The server span is a child of the client span because the SDK put the client span’s ID into _meta.traceparent on the request, and the server’s SDK started its span from that value. The notifications/cancelled root at the end of the trace is the cancellation the client sent; it carried no traceparent, so it starts its own trace.
The second load_tools() has no span at all. The SDK client keeps the first tools/list result with its ttlMs and cacheScope, and answers the second call from memory.
The design choices
- Validate arguments in the client, or let the server reject them. A local check costs one schema validation and saves a round trip, and the caller gets a precise message, such as the recorded
arguments do not match inputSchema: 42 is not of type 'string'. The risk is a stale schema: if the server changes a tool while the client holds a cached listing, the client refuses calls the server would accept. A shortttlMsbounds that window; the server decides it, not the client. - One timeout for the session, or one per call.
read_timeout_secondson eachcall_toollets a fast search fail in seconds while a slow rebuild gets minutes. A single session timeout is simpler, but it is either too long for the fast calls or too short for the slow ones. - A trace file per process, or a collector. This lesson writes JSON Lines files so the trace can be compared with a saved fixture. In production, both processes export to an OpenTelemetry collector instead, and the trace joins there by the same trace ID.
Check your understanding
Read the explanation
A client timeout is local. The SDK sends notifications/cancelled, but a tool that does not check for cancellation keeps running, as the completed server span shows.
Expose resources and prompts from an MCP server continues with the next part of the MCP application.
References
Checked October 21, 2026, against MCP specification 2026-07-28, mcp 2.3.0 and opentelemetry-sdk 1.45.1.
- MCP specification: base protocol,
_metaand error codes - MCP specification: server/discover
- MCP specification: caching
- MCP specification: tools
- MCP specification: stdio transport and cancellation
- OpenTelemetry: traces
- OpenTelemetry GenAI semantic conventions: MCP
- MCP Python SDK repository and mcp 2.3.0 on PyPI