What MCP adds to an API

About 26 minutes to read, with 6 examples in 11 sections.

Edition 2026.11.5 Examples passed Nov 4, 2026 70 of 70 examples pass What changed

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.

Open in Lab, in a new tab

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 say what the host, the client and the server each do, put each of the shop's capabilities in the right Model Context Protocol primitive, read the version and capabilities that every request carries, and decide when the shop should keep a plain HTTP API instead. Every example runs against the specification at revision 2026-07-28 and the Python SDK, mcp 2.3.0.

This book follows one case study throughout: Fernhill Supply, a fictional online shop for kitchen and garden goods, which we call the shop. The shop's support team answers customer tickets, and MCP end to end put one lookup from the shop's existing HTTP API, the order status, behind a Model Context Protocol (MCP) server, so that a model in the support team's host could call it. That server had one tool. The support team wants more: the refund policy, which the host should have at hand whenever a ticket mentions money, and a standard way to review a refund request. Meanwhile the warehouse team, which reads stock levels from the same API every night, asks whether it should move to MCP too.

Both questions turn on what MCP adds to an API that already works. An HTTP client is written against endpoints its developer read about. An MCP client learns at run time what a server offers, in a form a model can read, and the protocol says who decides when each capability is used. This chapter takes those additions one at a time, measures what they cost on the wire, and ends with where the shop shouldn't pay for them.

Separate the host, the client and the server

The server from Wrap one endpoint as a tool ran behind a client that the example code drove. Before the shop adds more to it, it helps to be exact about which part does what, because MCP's guarantees attach to the roles, not to the processes.

The specification names three roles (architecture). The host is the application the user works in. It creates clients, holds the conversation with the model, and enforces security policies and consent. A client is the host's connection to one server: "Each client is created by the host and communicates with exactly one server". A server exposes capabilities, such as tools, and knows nothing else about the host. The same page sets a design principle that follows from that split: servers "should not be able to read the whole conversation, nor 'see into' other servers". The host decides what each server sees, and anything that crosses servers happens in the host.

For the shop, the host is the support desk, which runs the book's host loop. Each server it uses gets its own client: in this chapter one, shop-support, and in Catch poisoned tool descriptions a second server from a partner, which must never learn what the shop's server returned. shop-support is this chapter's server. It keeps chapter 1's get_order_status tool and adds a resource, the refund policy, and a prompt, review_refund, so that each kind of capability appears once. Like every server example in this book, the file defines the server and, run with no arguments, starts a client that launches the same file with --serve.

The first thing a client does is ask the server what it offers. A capability is something a client or server declares it supports, such as a server that offers tools or a client that can answer a request for input. At the pinned revision that takes one request, server/discover, whose answer holds the versions the server supports, its capabilities and its name, so a client "can present the capabilities a server supports from a single server/discover response" (discovery). The client then lists each kind of capability with tools/list, resources/list and prompts/list. In its default auto mode, mcp.Client sends server/discover when it opens, and keeps the answer as client.session.discover_result (mcp/client/session.py).

list-what-the-server-offers.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""shop-support, the shop's chapter 2 server: a tool, a resource and a prompt; run bare, a client lists them."""import asyncioimport osimport sysimport httpx2from mcp import Client, StdioServerParametersfrom mcp.server.caching import CacheHintfrom mcp.server.mcpserver import MCPServerfrom mcp.server.mcpserver.exceptions import ToolErrorfrom lib.policies import policyserver = MCPServer("shop-support", version="0.2.0",                   cache_hints={"tools/list": CacheHint(ttl_ms=300_000, scope="public")})@server.tool()def get_order_status(order_id: str) -> dict:    """Look up one of the shop's orders by its ID, such as O-1873: its status, dates, lines and total."""    response = httpx2.get(f"{os.environ['SHOP_API_URL']}/orders/{order_id}",                          headers={"X-API-Key": os.environ["SHOP_API_KEY"]}, timeout=5.0)    if response.status_code == 404:        raise ToolError(f"The shop has no order {order_id}. Order IDs look like O-1873.")    response.raise_for_status()    return response.json()@server.resource("shop://policies/refund", name="refund-policy", mime_type="text/markdown")def refund_policy() -> str:    return policy("refund").path.read_text(encoding="utf-8")       # examples/data/shop/policies/refund.md@server.prompt()def review_refund(order_id: str) -> str:    """Review a customer's refund request for one order against the shop's refund policy."""    return (f"Review the refund request for order {order_id}. Look up the order with get_order_status, read the "            "refund policy at shop://policies/refund, and say whether the refund needs a person to confirm it.")async def main() -> None:    async with Client(StdioServerParameters(command=sys.executable, args=[__file__, "--serve"], env={})) as client:        print("server:", client.server_info.name, client.server_info.version)        print("protocol revision:", client.protocol_version)        print("supported versions:", ", ".join(client.session.discover_result.supported_versions))        print("capabilities:", ", ".join(sorted(client.server_capabilities.model_dump(exclude_none=True))))        print("tools:", ", ".join(t.name for t in (await client.list_tools()).tools))        print("resources:", ", ".join(str(r.uri) for r in (await client.list_resources()).resources))        print("prompts:", ", ".join(p.name for p in (await client.list_prompts()).prompts))if __name__ == "__main__":    server.run() if "--serve" in sys.argv else asyncio.run(main())
Outputpython list-what-the-server-offers.py
server: shop-support 0.2.0
protocol revision: 2026-07-28
supported versions: 2026-07-28
capabilities: prompts, resources, tools
tools: get_order_status
resources: shop://policies/refund
prompts: review_refund

The server introduces itself as shop-support 0.2.0, supports revision 2026-07-28, and declares the capabilities prompts, resources, tools. Its tool is get_order_status, its resource shop://policies/refund and its prompt review_refund. The client's code names none of them. That is the first thing MCP adds to the shop's API: a client that knows nothing about the shop can find out what the shop offers, with a description of each item and, for tools and prompts, the arguments they take, and a host can put those in front of a model. The server also sets a cache hint on its tool list, which Cache list results explains.

Working rule: Give each server its own client in the host, and keep the conversation and anything that crosses servers in the host's code, never in a server. Start every client by reading what the server offers rather than assuming it, so that a server that changes its tools doesn't break the host. When you write a server, assume it sees only the requests sent to it, because that is all the specification lets it see.

Put each capability in the right primitive

The shop-support server from the previous section offers one tool, one resource and one prompt. These are a server's three primitives, and they differ less in what they return than in who decides when they're used. The specification's server overview puts it in a table: prompts are user-controlled, resources are application-controlled, and tools are model-controlled (server features). So the question to ask of each of the shop's capabilities is not "is it read-only?" but "who should decide when it's used: the model, the host's own code, or the person at the desk?"

The protocol mandates no particular interface for any of them (tools, resources, prompts). A host can show tools, resources and prompts however it likes, but a host that respects the control model gives each one to the party the table names. The example uses each of shop-support's three once, the way a host would.

three-primitives-side-by-side.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Use each of shop-support's three primitives once: call the tool, read the resource, get the prompt."""import asyncioimport jsonimport sysfrom mcp import Client, StdioServerParametersfrom lib import loopback, shop_apiasync def main() -> None:    with loopback.serve(shop_api.create_app()) as base_url:        params = StdioServerParameters(command=sys.executable, args=["list-what-the-server-offers.py", "--serve"],                                       env=shop_api.server_env(base_url))        async with Client(params) as client:            # A tool: the host lists it for the model, and calls it when the model asks.            result = await client.call_tool("get_order_status", {"order_id": "O-2476"})            print("tool call is error:", result.is_error)            print("tool says order status:", json.loads(result.content[0].text)["status"])            # A resource: the host reads it when its own code decides the model needs it.            policy = (await client.read_resource("shop://policies/refund")).contents[0]            print("resource type:", policy.mime_type)            print("resource first line:", policy.text.splitlines()[0])            # A prompt: the host shows it to the user, who picks it and fills in its arguments.            listed = (await client.list_prompts()).prompts[0]            print("prompt arguments:", "; ".join(f"{a.name}, {'required' if a.required else 'optional'}"                                                 for a in listed.arguments))            prompt = await client.get_prompt("review_refund", {"order_id": "O-2476"})            print("prompt messages:", len(prompt.messages))            print("prompt role:", prompt.messages[0].role)            print("prompt starts:", prompt.messages[0].content.text[:44] + "…")asyncio.run(main())
Outputpython three-primitives-side-by-side.py
tool call is error: False
tool says order status: shipped
resource type: text/markdown
resource first line: # Refund policy
prompt arguments: order_id, required
prompt messages: 1
prompt role: user
prompt starts: Review the refund request for order O-2476. …

Tools are actions the model chooses

A tool is a named action with a description and an input schema, and the model decides whether to call it and with what (tools). The host lists the tools for the model and runs the call the model asks for. In the example, get_order_status for order O-2476 came back with its error flag False and the status shipped.

A lookup belongs here because the ticket decides whether it's needed. One customer quotes an order ID and asks where the parcel is; another asks whether a frying pan comes in 28 cm. Only something that reads the ticket can choose, and in the shop's host that is the model. Let a model choose the tool showed the model making that choice, from a recording.

Working rule: Make a capability a tool when the model should decide, from the conversation, whether to use it. That covers lookups that depend on what the customer wrote, and every action that changes something, such as a refund, for which the host then asks for consent before the call.

Resources are data the application chooses

A resource is data a server exposes under a URI, and the application decides when to read it and what to do with it (resources). The host might attach a resource to the model's context, show it to the user, or index it, by rules in its own code. shop-support serves the shop's refund policy, one of the short policy documents written for the book, under shop://policies/refund. The example read it and got back text/markdown text that starts with # Refund policy.

The refund policy is a resource because the shop knows in advance when it matters: on every ticket about money. The host's code can attach it to the model's context for those tickets without asking the model. As a tool, it would depend on the model remembering to fetch it, and cost a turn each time. A resource is not the same as a read-only tool: both return data, but a resource's reader is the host's code, and its result can be cached under the rules in Cache list results. Expose the policies as resources serves the rest of the shop's policies the same way.

Working rule: Make data a resource when the host's own code can tell when it's needed, such as a policy that applies to a known kind of ticket, or a record the user has opened. Keep it a tool when only the model, reading the conversation, can tell whether it's needed.

Prompts are templates the user chooses

A prompt is a template of messages, with arguments, that the server defines and the user picks (prompts). The specification is precise about the word: "user-controlled" refers to "who decides when the prompt is used, not who authors its content". The server writes the content, and a host typically offers prompts in a menu or as commands. In the example, review_refund lists one argument, order_id, required. Given order O-2476, it returned 1 message with the role user, which starts "Review the refund request for order O-2476. …".

review_refund is a prompt because a person at the support desk decides to start a refund review, usually after reading the ticket, and the shop wants every review to follow the same steps. The prompt's text names the tool and the resource the review needs, so the model starts from the policy and the order instead of guessing. A prompt isn't a system prompt the user writes: the shop writes it once, on the server, and every host that connects gets the same text. Package a workflow as a prompt grows review_refund into the full review, with the refund amount as a second argument.

Working rule: Make a workflow a prompt when a person should start it and every run should follow the same steps, and keep the prompt's text on the server so that a change reaches every host at once. If no person chooses when it runs, it isn't a prompt: make the steps a tool, or put them in the host's own instructions.

Carry the version and capabilities on every request

The client in Put each capability in the right primitive made three calls, and the server answered each one without remembering the others. That is by design. At the pinned revision there's no handshake: every request carries, in its _meta field, the protocol version and the client's capabilities, and those fields "MUST be included on every request" (base protocol). Each request should also carry the client's name and version, and each result the server's.

Three rules on the same page shape how the shop's server uses these fields:

  • A request without a required field is rejected with JSON-RPC error -32602 (invalid params), and over HTTP with status 400.
  • A server must not rely on a capability the client didn't declare on that request. If it needs one, it answers with error -32021, whose data lists the missing capabilities.
  • clientInfo and serverInfo are self-reported. Implementations "SHOULD NOT rely on them for security decisions", so the shop's server never treats the client's name as an identity.

_meta also carries keys of your own, under a reverse-DNS prefix that ends in /. A prefix whose second label is modelcontextprotocol or mcp is reserved for the protocol, and so are traceparent, tracestate and baggage, which belong to OpenTelemetry (base protocol). The shop's domain is fernhill.example, so its keys start with example.fernhill/.

The example calls get_order_status through lib.wire, the book's trace printer, which logs every line between the client and the server. It passes one key of the shop's own, a request ID that support staff can quote. It then sends a tools/list request without the client's capabilities, through a second small module, lib.raw_stdio, which starts a stdio server and writes JSON-RPC lines by hand. The example needs it because mcp.Client never sends a malformed request.

request-meta-on-the-wire.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Show the _meta every request and result carries, add one key of the shop's own, and send a request without one."""import asyncioimport jsonimport sysfrom pathlib import Pathfrom mcp import Client, StdioServerParametersfrom mcp.types import Implementationfrom lib import byte_meter, loopback, raw_stdio, shop_api, wireCOMMAND = [sys.executable, "list-what-the-server-offers.py", "--serve"]async def main() -> None:    Path("meta.jsonl").unlink(missing_ok=True)       # the relay appends to its log    with loopback.serve(shop_api.create_app()) as base_url:        server = StdioServerParameters(command=COMMAND[0], args=COMMAND[1:], env=shop_api.server_env(base_url))        async with Client(wire.relay(server, log="meta.jsonl"),                          client_info=Implementation(name="shop-host", version="0.1.0")) as client:            await client.call_tool("get_order_status", {"order_id": "O-2476"},                                   meta={"example.fernhill/requestId": "req-0001"})    trace = [message for _, message in wire.read_trace("meta.jsonl")]    call = next(m for m in trace if m.get("method") == "tools/call")    result = next(m for m in trace if m.get("id") == call["id"] and "result" in m)    for key, value in call["params"]["_meta"].items():        print(f"request {key}:", json.dumps(value))    for key, value in result["result"]["_meta"].items():        print(f"result {key}:", json.dumps(value))    lines = [json.loads(entry)["line"] for entry in Path("meta.jsonl").read_text(encoding="utf-8").splitlines()]    print("request _meta bytes:", byte_meter.meta_bytes(lines, call["params"]["_meta"]))    print("result _meta bytes:", byte_meter.meta_bytes(lines, result["result"]["_meta"]))    with raw_stdio.launch(COMMAND, env={}) as raw:          # the same request, without clientCapabilities        reply = raw.request("tools/list", meta=raw_stdio.envelope("2026-07-28", capabilities=None))        print("without capabilities, error code:", reply["error"]["code"])        print("without capabilities, message:", reply["error"]["message"])asyncio.run(main())
Outputpython request-meta-on-the-wire.py
request example.fernhill/requestId: "req-0001"
request io.modelcontextprotocol/protocolVersion: "2026-07-28"
request io.modelcontextprotocol/clientInfo: {"name": "shop-host", "version": "0.1.0"}
request io.modelcontextprotocol/clientCapabilities: {}
result io.modelcontextprotocol/serverInfo: {"name": "shop-support", "version": "0.2.0"}
request _meta bytes: 228
result _meta bytes: 88
without capabilities, error code: -32602
without capabilities, message: params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities

The tools/call request's _meta held four keys. The shop's own came first, example.fernhill/requestId set to "req-0001", and the SDK added the protocol's three after it: the version "2026-07-28", the client's name and version, {"name": "shop-host", "version": "0.1.0"}, and its capabilities, {}, empty because this client declares none. The result's _meta carried the server's name and version, {"name": "shop-support", "version": "0.2.0"}.

The request without capabilities failed with code -32602, and the SDK's message names the missing key: "params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities". A hand-written client, or a gateway that rewrites bodies and drops _meta, fails this way on its first request.

What this costs the shop is bytes on every message. Measured on the lines as sent, the request's _meta, with the shop's key, took 228 bytes, and the result's 88. They are part of the per-call overhead that Keep an HTTP API where MCP adds nothing measures. What it buys is a server that needs no memory of earlier requests: any copy of shop-support can answer any request, which is what lets Run replicas without sticky sessions put two replicas behind one address.

Working rule: Read the version and the client's capabilities from each request, never from an earlier one, and answer -32021 when a call needs a capability the request didn't declare. Put the shop's own request metadata under its own reverse-DNS prefix, never under io.modelcontextprotocol/ or mcp. Log clientInfo and serverInfo for debugging, and decide nothing on them.

Handle a version the server doesn't support

Every request in Carry the version and capabilities on every request named its protocol version, so a server can meet, on any request, a version it doesn't implement. There's no negotiation step to settle it in advance. A server that doesn't implement the requested version "MUST respond with an UnsupportedProtocolVersionError listing the versions it does support", error code -32022, with the supported versions and the requested one in its data. The client "SHOULD select a mutually supported version from the supported list and retry", or show the user an error (versioning). Over Streamable HTTP the version also travels in the MCP-Protocol-Version header, which must match the body, and an unsupported version gets status 400 (Streamable HTTP).

For the shop this matters on the day a host built for a later revision connects to shop-support. To show the error, the example has to send a version the server doesn't know, and the obvious way fails first: mcp.Client accepts as its mode only "auto", "legacy" or one of the SDK's per-request revisions, such as 2026-07-28, and raises before it sends anything (mcp/client/client.py). So the example tries that, then uses lib.raw_stdio from the previous section to write the request by hand. It asks for 2099-01-01, a made-up revision that stands for any the server lacks, then retries with a version from the server's list.

unsupported-version-error.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Ask shop-support for a protocol revision it doesn't support, read the error, and retry with one it does."""import sysfrom mcp import Client, StdioServerParametersfrom mcp_types.version import MODERN_PROTOCOL_VERSIONSfrom lib import raw_stdioCOMMAND = [sys.executable, "list-what-the-server-offers.py", "--serve"]UNKNOWN = "2099-01-01"                       # stands for any revision this server doesn't implementtry:                                         # the SDK's client won't even send a version it doesn't know    Client(StdioServerParameters(command=COMMAND[0], args=COMMAND[1:]), mode=UNKNOWN)except ValueError as refused:    print("client refuses:", type(refused).__name__)with raw_stdio.launch(COMMAND, env={}) as server:    error = server.request("tools/list", meta=raw_stdio.envelope(UNKNOWN, capabilities={}))["error"]    print("error code:", error["code"])    print("error message:", error["message"])    print("server supports:", ", ".join(error["data"]["supported"]))    print("client asked for:", error["data"]["requested"])    mutual = [version for version in MODERN_PROTOCOL_VERSIONS if version in error["data"]["supported"]]    if not mutual:        sys.exit("no version in common: show the user an error instead of retrying")    print("retry with:", mutual[-1])    retry = server.request("tools/list", meta=raw_stdio.envelope(mutual[-1], capabilities={}))    print("retry tools:", ", ".join(tool["name"] for tool in retry["result"]["tools"]))
Outputpython unsupported-version-error.py
client refuses: ValueError
error code: -32022
error message: Unsupported protocol version
server supports: 2026-07-28
client asked for: 2099-01-01
retry with: 2026-07-28
retry tools: get_order_status

The SDK's client refused the unknown mode with a ValueError. The server, given the raw request, answered with code -32022 and the message "Unsupported protocol version". Its data said that it supports 2026-07-28 and that the client asked for 2099-01-01. The example picked 2026-07-28, the newest version both sides know, and the retry listed get_order_status. The retry went to the same process: at the pinned revision a connection carries no agreed version, so the next request may name another one.

Two details of the SDK at 2.3.0 matter when you read this error from someone else's server. First, an SDK stdio server decides from the client's first request which era of the protocol to speak. A request whose _meta has a protocol version opens a connection at the pinned revision, even when the version is unknown, which is why this request got -32022 instead of a demand for the 2025 initialize handshake (mcp/server/runner.py). Second, the server's supported list holds only the versions that use per-request _meta, although the same server still accepts 2025 clients through initialize (mcp/shared/inbound.py). The specification's own example of the error lists a 2025 revision as well; the SDK's list doesn't. The SDK's client, in its default auto mode, reads a -32022 whose list shares nothing with its own as a real incompatibility, and any other error on its first request as a 2025 server that needs the old handshake (mcp/client/_probe.py). Serve old and new clients through the window covers that second path.

Working rule: When a server answers -32022, pick the newest version in both your list and its supported list, retry once, and show the user an error naming both lists if they share none. Don't retry in a loop, and don't guess a version the server didn't list. On your own server, keep the supported list exact, because clients choose from it.

Cache list results

The server/discover answer and the tool list in Separate the host, the client and the server change only when the shop deploys, but a stateless client could ask for them before every model turn. At the pinned revision each result of server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read must carry two caching hints (caching):

  • ttlMs, how many milliseconds the result stays fresh, like HTTP's max-age. Zero means stale at once.
  • cacheScope, either "public", which a cache may share across callers, or "private", which it "MUST NOT" share across authorization contexts.

The same page limits what the hints mean. A TTL is a freshness hint, not a polling interval: a client checks freshness when it needs the data, and any polling must use jitter and backoff. A list_changed notification invalidates a cached result that is still fresh. And cacheScope is no access control: a server "MUST NOT rely on cacheScope alone to prevent unauthorized access".

Caching a tool list works only if the list is stable. A server's tools/list "MUST NOT vary per-connection", although it may vary with the authorization on the request, and servers should return tools in a deterministic order, which "improves LLM prompt cache hit rates" (tools). The SDK's MCPServer lists tools in the order you register them, which meets that rule (mcp/server/mcpserver/tools/tool_manager.py).

In the SDK, a server's hints default to ttl_ms=0 and "private", so nothing is reused unless the server opts in. shop-support opts in for its tool list with cache_hints={"tools/list": CacheHint(ttl_ms=300_000, scope="public")}, five minutes and shareable (mcp/server/caching.py). On the other side, mcp.Client caches by default, in memory, by the server's hints, and each list call takes a cache_mode of "use", "refresh" or "bypass". A call that passes its own meta always reaches the server, and the client caps any TTL at 24 hours (mcp/client/caching.py). The example lists the tools twice from chapter 1's server, which sets no hint, then twice from shop-support, and counts on the wire, with lib.wire, the book's trace printer, how many tools/list requests reached each server.

cache-the-tool-list.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Call list_tools() twice on two servers, one with the SDK's default cache hint and one with a 5-minute public hint,and count the tools/list requests that reach each server."""import asyncioimport sysfrom pathlib import Pathfrom mcp import Client, StdioServerParametersfrom lib import wireasync def list_twice(script: str, second_call: str = "use") -> tuple[str, str, int]:    log = Path(script).with_suffix(".jsonl")    log.unlink(missing_ok=True)                          # the relay appends to its log    server = StdioServerParameters(command=sys.executable, args=[script, "--serve"], env={})    async with Client(wire.relay(server, log=str(log))) as client:        first = await client.list_tools()                # cache_mode="use" is the default        await client.list_tools(cache_mode=second_call)        name = client.server_info.name    sent = sum(1 for _, message in wire.read_trace(str(log)) if message.get("method") == "tools/list")    return name, f"ttlMs {first.ttl_ms}, cacheScope {first.cache_scope}", sentasync def main() -> None:    name, hint, sent = await list_twice("order-status-server.py")         # chapter 1's server sets no hint    print(f"{name} hint:", hint)    print(f"{name} tools/list sent for two calls:", sent)    name, hint, sent = await list_twice("list-what-the-server-offers.py")    print(f"{name} hint:", hint)    print(f"{name} tools/list sent for two calls:", sent)    name, hint, sent = await list_twice("list-what-the-server-offers.py", second_call="bypass")    print(f"{name} tools/list sent when the second call bypasses:", sent)asyncio.run(main())
Outputpython cache-the-tool-list.py
shop-orders hint: ttlMs 0, cacheScope private
shop-orders tools/list sent for two calls: 2
shop-support hint: ttlMs 300000, cacheScope public
shop-support tools/list sent for two calls: 1
shop-support tools/list sent when the second call bypasses: 2

Chapter 1's shop-orders returned ttlMs 0, cacheScope private, so both calls went to the server: 2 requests. shop-support returned ttlMs 300000, cacheScope public, and the client's cache served the second call: 1 request. With cache_mode="bypass" on the second call, both reached the server again: 2. A host that renders the tool list into every model turn saves a request a turn. And because the list comes back in the same order each time, the model's prompt starts with the same bytes, which is what a model provider's prompt cache needs.

The shop's tool list can be public because every caller sees the same tools. The shop's per-customer data can't. When Address records with resource templates serves one resource per order, those reads stay "private", so a shared cache never hands one customer's order to another customer's session. If the tool list ever depends on the caller's token, as it may once Scope tools by what the token allows applies, it turns private too.

Working rule: Set a TTL and "public" scope on lists that are the same for every caller, such as the shop's tool list, and choose a TTL no longer than you'd accept a stale list after a deploy: minutes, not hours. Keep "private", the SDK's default, on anything that depends on who is asking, and never let cacheScope stand in for an authorization check. On the client, keep the default cache, which drops an entry when a change notification arrives on a client.listen(tools_list_changed=True) stream (mcp/client/client.py), the kind of stream that Tell clients when a resource changes opens. Without that stream no notification reaches the client, so open one, and use cache_mode="refresh" when you have another reason to doubt an entry, rather than polling on the TTL.

Keep an HTTP API where MCP adds nothing

The sections so far each showed something MCP adds to the shop's API: run-time discovery in Separate the host, the client and the server, a control model per primitive in Put each capability in the right primitive, and versioned, self-describing requests in Carry the version and capabilities on every request. The host's consent step, in Ask for consent before side effects, is another. Each of those helps when a model, or a user's host, chooses among capabilities at run time. None of them helps a program whose developer already knows which endpoint it will call.

The specification doesn't say when not to use MCP, so what follows is the book's judgement, not a rule of the protocol. We recommend a plain HTTP API for three kinds of work:

  • Service-to-service calls with no model choosing, such as the warehouse's nightly read of stock levels. Its code calls fixed endpoints, so nobody reads the discovery answers or the descriptions.
  • Fixed workflows, where the same steps run in the same order every time. Write them as code over the API, and if a model needs to start one, expose the whole workflow as one tool.
  • High-volume batch work, where every byte and round trip is multiplied.

We recommend MCP where a model or a user's host chooses among capabilities at run time, as the shop's support desk does. The two don't compete. Meet the shop and its API kept the shop's API as the system of record, and shop-support is a thin adapter over it, so the warehouse keeps its API calls while the support desk uses MCP.

The example measures the price of the adapter, so that the trade-off rests on numbers. Its setup, which the example also prints: everything runs in one process on 127.0.0.1, over HTTP/1.1 without TLS. lib.byte_meter, a small module for this measurement, puts a TCP relay in front of each server and counts every byte both ways, headers included, and counts the HTTP requests each server handles. The example fetches order O-2476 once from the shop's API, with GET /orders/O-2476, and once through shop-support's get_order_status tool over Streamable HTTP, the transport Move the server from stdio to HTTP teaches. On the MCP side it counts the setup a host does each time it opens a client, which includes listing the tools, separately from the tool call itself, once in the default auto mode, which first probes the server with server/discover, and once with the version pinned, and it makes the tool call itself with the version pinned. It measures no times, because times on one machine's loopback say little about a network.

api-call-versus-tool-call.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Fetch order O-2476 from the shop's API, then through shop-support's tool over Streamable HTTP, and count the cost."""import asyncioimport osimport runpyimport httpx2from mcp import Clientfrom lib import byte_meter, loopback, shop_apiasync def session(url, traffic, requests, mode: str, calls: int) -> tuple[list[str], int]:    """Open a client, list the tools as a host does, make `calls` tool calls, close it: the requests and bytes."""    traffic.reset()    requests.reset()    async with Client(f"{url}/mcp", mode=mode) as client:        await client.list_tools()        for _ in range(calls):            await client.call_tool("get_order_status", {"order_id": "O-2476"})    return list(requests.names), traffic.sent + traffic.receivedasync def main() -> None:    print("setup: one process, 127.0.0.1, HTTP/1.1, no TLS, bytes counted at a TCP relay, headers included")    api_app, api_requests = byte_meter.count_requests(shop_api.create_app())    with loopback.serve(api_app) as api_url, byte_meter.relay(api_url) as (url, traffic):        httpx2.get(f"{url}/orders/O-2476", headers={"X-API-Key": shop_api.DEMO_API_KEY}).raise_for_status()        api_bytes = traffic.sent + traffic.received        print("api call round trips:", api_requests.count)        print("api call bytes:", api_bytes)        os.environ.update(shop_api.server_env(api_url))      # the tool calls the API directly, not through a relay        shop_support = runpy.run_path("list-what-the-server-offers.py")["server"]    # defined there, not run        mcp_app, mcp_requests = byte_meter.count_requests(shop_support.streamable_http_app())        with loopback.serve(mcp_app) as mcp_url, byte_meter.relay(mcp_url) as (url, traffic):            for mode, label in (("auto", "auto"), ("2026-07-28", "pinned")):                names, setup_bytes = await session(url, traffic, mcp_requests, mode, calls=0)                print(f"mcp {label} setup request count:", len(names))                print(f"mcp {label} setup requests:", ", ".join(names))                print(f"mcp {label} setup bytes:", setup_bytes)            names, with_call = await session(url, traffic, mcp_requests, "2026-07-28", calls=1)            call_bytes = with_call - setup_bytes          # a pinned session with one call, less the pinned setup            print("mcp tool call round trips:", names.count("tools/call"))            print("mcp tool call bytes:", call_bytes)            print("tool call bytes per api call byte:", round(call_bytes / api_bytes, 1))asyncio.run(main())
Outputpython api-call-versus-tool-call.py
setup: one process, 127.0.0.1, HTTP/1.1, no TLS, bytes counted at a TCP relay, headers included
api call round trips: 1
api call bytes: 591
mcp auto setup request count: 2
mcp auto setup requests: server/discover, tools/list
mcp auto setup bytes: 2157
mcp pinned setup request count: 1
mcp pinned setup requests: tools/list
mcp pinned setup bytes: 1147
mcp tool call round trips: 1
mcp tool call bytes: 1357
tool call bytes per api call byte: 2.3

The API call took 1 round trip and 591 bytes. The tool call also took 1 round trip, and 1357 bytes, 2.3 times as many. The difference is the JSON-RPC envelope, the _meta on the request and on the result, the routing headers that Streamable HTTP adds, and the order itself, which the SDK returns as indented JSON inside a text block, with its quotes escaped, as Read the messages on the wire showed. Setup costs more. In auto mode, opening the client sent 2 requests, server/discover, tools/list, 2157 bytes in all, before its first call. With the version pinned it skipped the server/discover probe and sent 1, tools/list, 1147 bytes. Behind the server, each tool call also made its own request to the shop's API, which the counts leave out because the caller never sees it.

Some of those bytes buy something even without a model. The headers Mcp-Method, on every request, and Mcp-Name, on tools/call, resources/read and prompts/get, let a gateway route by method and by tool without parsing bodies (Streamable HTTP). For a support desk that makes a few calls per ticket, 1357 bytes a call is small beside the model's own tokens. For the warehouse's nightly read of 64 products, every call pays for an envelope that its code never reads, and the adapter adds a process to the path of a job that ran without it.

Working rule: Keep a plain HTTP API for callers whose code knows which endpoint to call, such as other services, fixed workflows and batch jobs, and put an MCP server in front of the same API for hosts where a model or a user chooses at run time. Measure the overhead on your own transport before you route high-volume traffic through MCP, and pin the protocol version in clients you control, so they skip the discovery probe. Keep auto mode for clients that may meet servers you don't control, which may speak only the 2025 protocol.

What goes wrong in production

The parts this chapter added fail in production in a few recognisable ways, and each has a sign you can watch for.

A capability lands in the wrong primitive. A policy served as a tool gets skipped when the model doesn't think to fetch it, and a refund served as a resource leaves the host no point at which to ask for consent. The sign is behaviour that varies from ticket to ticket for no reason in the ticket: answers about money given without the policy, or side effects with no consent step in the logs. Ask of each capability who should decide when it runs, as Put each capability in the right primitive does.

A gateway drops or rewrites _meta. Proxies and API gateways built for plain JSON APIs sometimes rewrite request bodies. A request that loses its version or capabilities fails with -32602, as Carry the version and capabilities on every request showed. On Streamable HTTP, a body that no longer matches its headers fails with -32020, because the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers must agree with the body (Streamable HTTP). Pass MCP traffic through unchanged, and route on the headers rather than editing bodies.

clientInfo is used as an identity. The client's name in _meta is whatever the client chose to send, and the specification says not to rely on it for security (base protocol). A server that grants more to a client named shop-host grants it to anyone who sends that name. Authorization comes from tokens, which Find the authorization server from the resource starts on.

A public cache entry crosses customers. A result marked "public" may be shared across authorization contexts, even when it came from an authenticated endpoint (caching). A server that marks per-customer data public lets a shared cache serve one customer's order to another. Keep the SDK's "private" default for anything that depends on the caller, and treat every "public" hint in a code review as a claim to check.

A long TTL hides a deploy. With a tool list fresh for hours, a client keeps offering a tool the server has removed, and calls to it fail. The sign is unknown-tool errors that stop when clients restart. Keep tool-list TTLs to minutes, and let clients refresh on a change notification, which reaches a client only on a subscriptions/listen stream it opened, as Tell clients when a resource changes sets up.

A client calls a tool it never listed. At 2.3.0, mcp.Client checks a tool's result against the tool's output schema, and if it hasn't listed the tools yet, it sends tools/list first (mcp/client/session.py). A client that calls tools without listing them pays an extra request on its first call. List the tools when the client opens, as a host does anyway.

Old error codes from old material. The pinned revision reserves -32020 to -32099 for the specification, treats -32000 to -32019 as a legacy range that new code shouldn't use, and asks for application codes outside the range JSON-RPC reserves (base protocol). The revision renumbered its error codes on the way to release (changelog), so material written against a draft can show other numbers for the same errors. Match error codes against the specification, not against a blog post.

The specification moves on. MCP is governed by the Agentic AI Foundation, a directed fund under the Linux Foundation, which took MCP on as a founding project in December 2025 (announcement). Revisions are dated, and a deprecated feature stays for at least twelve months (feature lifecycle). The window is shorter only for an active security risk, and then at least ninety days, or where the deprecated features registry says so. Read the registry when you plan a server's next release, and watch your logs for -32022 from clients built for a later revision.

Working rule: Before a server goes to production, check four things: each capability's primitive matches who decides when it runs, nothing between client and server edits MCP bodies, nothing is decided on clientInfo, and every "public" cache hint covers data that is the same for every caller. Log the protocol version and error code of every failed request, because version and envelope errors are the first sign that a client or a gateway has changed.

Decisions this chapter made

  • One client per server, in the host. The conversation and anything that crosses servers stay in the host's code; a server sees only the requests sent to it.
  • The shop's server is shop-support, with chapter 1's get_order_status tool, the refund policy as a resource at shop://policies/refund, and review_refund as a prompt.
  • Each capability goes to the primitive whose controller should decide when it runs: the model for tools, the host's code for resources, the person at the desk for prompts.
  • Version and capabilities are read from each request, never from an earlier one, and a missing capability gets -32021.
  • The shop's own _meta keys use the example.fernhill/ prefix. Nothing is decided on clientInfo or serverInfo.
  • On -32022, the client retries once with the newest version both sides list, and shows an error otherwise.
  • The tool list is public with a five-minute TTL. Per-customer data stays private, and the tool list turns private if it comes to depend on the token.
  • Hand-written JSON-RPC, through lib.raw_stdio, is only for showing errors that the SDK's client would never cause.
  • We recommend a plain HTTP API for service-to-service calls, fixed workflows and batch work, and MCP where a model or a user's host chooses at run time. This is the book's judgement, backed by the byte and round-trip counts in api-call-versus-tool-call.

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.

Cite this section

Format

Every format names this edition. Its link opens the current edition, where a section’s heading says when its text last changed. Accessed and retrieved dates are the day you open this.

On this page

Chapter 2 of 12: What MCP adds to an API

  1. Separate the host, the client and the server
  2. Put each capability in the right primitive
  3. Tools are actions the model chooses
  4. Resources are data the application chooses
  5. Prompts are templates the user chooses
  6. Carry the version and capabilities on every request
  7. Handle a version the server doesn't support
  8. Cache list results
  9. Keep an HTTP API where MCP adds nothing
  10. What goes wrong in production
  11. Decisions this chapter made

Back to the topAll chapters