---
title: "Model Context Protocol in Practice"
chapter: "Build a host that works through tickets"
chapter_id: build-a-host
book: model-context-protocol-in-practice
edition: 2026.11.5
verified: 2026-11-04
edition_code: MA29-KPNM
canonical: https://rollingedition.com/model-context-protocol-in-practice/build-a-host
license: CC BY 4.0
attribution: "“Build a host that works through tickets,” chapter 10 of Model Context Protocol in Practice, Edition 2026.11.5, examples passed Nov 4, 2026. By Rolling Edition, https://rollingedition.com/model-context-protocol-in-practice/build-a-host. Text CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Code MIT, copyright Rolling Edition and contributors. Written with AI. Verified by people."
---
# Build a host that works through tickets

By the end of this chapter you can connect a host to several Model Context Protocol servers, give their tools names that can't collide, run a short loop in which a model calls tools and reads their results, answer a server's input requests on the user's behalf, put resources into the model's context, and work a queue of tickets through all of 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](#the-shop) introduces it. The shop's data includes 48 support tickets, each tagged with the tool a correct agent calls first, and written the way customers write: typos, bare order numbers, wrong IDs, and six with instructions aimed at an agent. Every earlier chapter built one part the host needs. Chapter 1's host loop, `lib.host`, showed a model one ticket and the tools of one server, and read one choice ([Let a model choose the tool](#let-a-model-choose)). Chapter 9 added the host's own checks: pinned tool definitions and consent before side effects ([Ask for consent before side effects](#consent-before-side-effects)).

The Model Context Protocol (MCP) puts a lot on the host. The specification's architecture page says the host creates and manages the clients, enforces consent, handles the user's authorization decisions and aggregates context across clients, while each server sees only its own part of the conversation ([architecture](https://modelcontextprotocol.io/specification/2026-07-28/architecture/index.md)). A support host that takes tickets one at a time from a queue has to do all of that for 48 tickets in a row, against two servers: the shop's, and the partner's, a parcel-tracking server from the shop's delivery partner that [Draw the trust boundaries](#trust-boundaries) introduced.

This chapter builds that host, which we call **the support host**, in a new module, `lib.ticket_host`. It holds one client per server and a few turns of model calls per ticket, and nothing more: planning, memory and longer agent loops are a subject of their own, building agents, which is outside this book, and this chapter stays on what MCP asks of a host. Two more files are new. `lib.support_shop` is the shop's server as the support host meets it, gathering chapter 3's tools, chapter 5's confirmation for refunds above the policy limit, and chapter 4's policies as resources. `recordings/ticket_runs.py` records the book's model working through the queue, as the recording **`ticket-runs`**, which two of this chapter's examples replay and [Test a server and watch it run](#test-and-observe) reuses.

## Connect one client per server {#one-client-per-server}

A host that uses several servers holds several clients. The specification is exact about the shape: "each client is created by the host and communicates with exactly one server" ([architecture](https://modelcontextprotocol.io/specification/2026-07-28/architecture/index.md)). At the pinned revision there is no `initialize` handshake to share: every request carries its own protocol version and the client's capabilities in `_meta` ([versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning.md)). What a client still learns about its server is the server's era, whether it speaks the pinned revision or a 2025 one, and the specification asks clients to cache that "for the lifetime of the server process (stdio) or origin (HTTP)" ([versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning.md)).

The Python SDK offers a class for several servers, `ClientSessionGroup`, and it looks like the natural choice. At `mcp` 2.3.0 it isn't one: its `connect_to_server` always calls `session.initialize()`, the 2025 handshake, and never sends the `server/discover` probe that the pinned revision starts with (`mcp/client/session_group.py`; [session groups](https://py.sdk.modelcontextprotocol.io/v2/client/session-groups/index.md)). The SDK's own servers speak both eras, so a group still connects, but on the older revision, where a server pushes its input requests instead of returning them in a Multi Round-Trip Request. That would quietly undo chapter 5. A group can also take a session you already hold, through `connect_with_session`, and the SDK's page suggests a `Client`'s session, which would be on the pinned revision ([session groups](https://py.sdk.modelcontextprotocol.io/v2/client/session-groups/index.md)). The group then only merges the lists. Its `call_tool` raises an error on an input request unless you pass `allow_input_required=True` and drive the rounds yourself (`mcp/client/session_group.py`), and it still refuses two tools with one name unless it renames them all, as [Name tools across servers](#namespace-tools) shows.

The support host uses one `mcp.Client` per server instead, each in its default auto mode, which probes `server/discover` and falls back to the handshake only for a server that doesn't answer it (`mcp/client/client.py`). The host keeps the clients in a map keyed by a prefix its own configuration assigns: `shop` for the shop's server, launched over stdio from `lib.support_shop`, and `partner` for the partner's server, `lib.partner`, connected in-process. The example connects both, then gives the same shop server to a `ClientSessionGroup`, then reconnects to the shop's server with the era the first connection learned.

Example `connect-two-servers` (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 in Edition 2026.11.5:

```python
"""The host opens one Client per server, under its own prefixes, and each connection settles its own revision. The
SDK's ClientSessionGroup, given the same shop server, settles on a 2025 revision. A reconnect reuses the cached era."""
import asyncio
import pathlib

from mcp import Client
from mcp.client.session_group import ClientSessionGroup

from lib import loopback, shop_api, support_shop, ticket_host, wire

LOG = pathlib.Path("connect-two-servers.jsonl")


async def main() -> None:
    LOG.unlink(missing_ok=True)
    with loopback.serve(shop_api.create_app()) as base_url:
        async with ticket_host.queue_host(base_url) as host:
            for prefix, client in host.clients.items():
                print(f"{prefix} server:", client.server_info.name)
                print(f"{prefix} protocol revision:", client.protocol_version)
            era = host.clients["shop"].session.discover_result         # what the probe learned, kept per server
            revision = host.clients["shop"].protocol_version
        async with ClientSessionGroup() as group:
            session = await group.connect_to_server(support_shop.server_params(base_url))
            print("ClientSessionGroup protocol revision:", session.protocol_version)
        params = wire.relay(support_shop.server_params(base_url), log=str(LOG))
        async with Client(params, mode=revision, prior_discover=era) as again:
            await again.list_tools()
            print("reconnect protocol revision:", again.protocol_version)
    methods = [message.get("method") for direction, message in wire.read_trace(str(LOG)) if direction == "out"]
    print("reconnect sent server/discover:", "server/discover" in methods)
    print("reconnect first request:", methods[0])
    LOG.unlink()

asyncio.run(main())
```

Output:

```text
shop server: shop-support
shop protocol revision: 2026-07-28
partner server: partner-tracking
partner protocol revision: 2026-07-28
ClientSessionGroup protocol revision: 2025-11-25
reconnect protocol revision: 2026-07-28
reconnect sent server/discover: False
reconnect first request: tools/list
```

Both of the support host's clients settled on 2026-07-28, each on its own. The group, given the same shop server, settled on 2025-11-25. The reconnect passed the cached `DiscoverResult` as `prior_discover`, with the revision as `mode`, which `Client` adopts with no probe. It spoke 2026-07-28, and the check of whether it sent `server/discover` printed False. Its first request was `tools/list`. The reconnect launched a new server process, so it went past what the specification asks, a cache for the lifetime of the server process, into what it allows: a client **MAY** persist the era "across restarts of the same server configuration, re-probing if the cached assumption later fails" ([versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning.md)). A host that restarts stdio servers or reconnects to an HTTP origin saves one round trip per connection that way.

The prefixes are the host's, not the servers'. Each server names itself, `shop-support` and `partner-tracking`, but nothing makes those names unique or stable, and on a connection at the pinned revision a server may leave its name out (`mcp/client/client.py`, `server_info`). [Name tools across servers](#namespace-tools) builds on the prefixes.

**Working rule:** Give each server its own `Client`, in auto mode, under a key from the host's configuration. Cache each server's era for as long as its process or origin lasts, reuse it across restarts of the same configuration if you like, and probe again when a request fails with a version error. Don't use `ClientSessionGroup` at `mcp` 2.3.0: its `connect_to_server` speaks only the 2025 handshake, and given `Client` sessions it adds merged lists but not the retry rounds or the host's own names. Check again at each SDK release, because a group that probes would remove the need for your own map.

## Name tools across servers {#namespace-tools}

With [one client per server](#one-client-per-server), the support host holds two lists of tools, and the model needs one. Tool names are unique only within one server, so two servers can offer the same name, and the specification expects it: a host that aggregates servers **SHOULD** disambiguate, "such as prefixing tool names with a server identifier", and the server's `name` from `serverInfo` "is not guaranteed to be unique across servers and **SHOULD NOT** be relied upon for disambiguation" ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools.md)). A prefix also has to keep the name valid: tool names **SHOULD** use only letters, digits, `_`, `-` and `.`, up to 128 characters ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools.md)).

The shop's case makes the collision concrete. The example supposes that the partner's next release adds a tool named `get_order_status`, for the partner's own delivery orders, beside `track_parcel`. Both servers now have a tool with that name and different meanings. `ClientSessionGroup` offers a hook, `component_name_hook`, that renames tools as they arrive, but it renames every name from every server, not only those that collide, and the SDK's own docstring example builds the new name from `serverInfo.name`, the name the specification says not to rely on (`mcp/client/session_group.py`; [session groups](https://py.sdk.modelcontextprotocol.io/v2/client/session-groups/index.md)). Without the hook, the group refuses the second server.

The support host names every tool `{prefix}.{tool}`, with `.` as the separator because it is in the allowed set and reads as a path. `TicketHost.route` turns a name the model used back into the server's prefix and the tool's name on the wire, and returns nothing for a name the host doesn't know.

Example `tool-name-collision` (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 in Edition 2026.11.5:

```python
"""The partner's next release adds its own get_order_status. ClientSessionGroup refuses the second server; the host's
prefixes keep both tools, and route each name the model sees back to its server and its name on the wire."""
import asyncio
import sys

from mcp import MCPError, StdioServerParameters
from mcp.client.session_group import ClientSessionGroup
from mcp.types import ToolAnnotations

from lib import host_guard, loopback, partner, shop_api, support_shop, ticket_host

server = partner.create_server("clean")


@server.tool(title="Look up a delivery order", annotations=ToolAnnotations(read_only_hint=True))
def get_order_status(delivery_id: str) -> str:
    """Where one of the partner's delivery orders is, by its delivery ID, such as D-58120."""
    return f"{delivery_id}: out for delivery"


async def main() -> None:
    mine = StdioServerParameters(command=sys.executable, args=[__file__, "--serve"], env={})
    with loopback.serve(shop_api.create_app()) as base_url:
        servers = {"shop": support_shop.server_params(base_url), "partner": mine}
        async with ClientSessionGroup() as group:
            for params in servers.values():
                try:
                    await group.connect_to_server(params)
                except MCPError as error:
                    print("ClientSessionGroup:", error)
        gate = host_guard.ConsentGate(host_guard.NO_SIDE_EFFECTS, ask=ticket_host.approve_every_call)
        async with ticket_host.TicketHost(servers, gate=gate, lead=ticket_host.decline_every_request) as host:
            names = [tool.name for tool in host.tools()]
            print("tools the model sees:", len(names))
            for name in names:
                if name.endswith("get_order_status"):
                    print(f"{name} routes to:", host.route(name))
            print("get_order_status routes to:", host.route("get_order_status"))


server.run() if "--serve" in sys.argv else asyncio.run(main())
```

Output:

```text
ClientSessionGroup: {'get_order_status'} already exist in group tools.
tools the model sees: 7
shop.get_order_status routes to: ('shop', 'get_order_status')
partner.get_order_status routes to: ('partner', 'get_order_status')
get_order_status routes to: None
```

The group raised `{'get_order_status'} already exist in group tools.` and kept only the shop's server. The support host kept both: the model sees 7 tools, `shop.get_order_status` routes to `('shop', 'get_order_status')`, and `partner.get_order_status` to `('partner', 'get_order_status')`. A bare `get_order_status` routes to `None`: the host won't guess which server the model meant. It sends the model an error result naming the problem instead.

Prefixing every name, not only the ones that collide, costs a few tokens per tool and buys stability. A name the model learned stays the same when another server adds a tool, so a recorded prompt, a prompt cache and a log line all keep meaning the same thing. The client best practices page asks hosts to keep the `tools` array stable for the same reason: adding or removing tools mid-conversation invalidates the model provider's prompt cache ([client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices.md)).

Before you settle on a separator, check that your model writes it back. The book's model wrote every name with a prefix the host knew: in the [queue run](#work-the-queue), every one of its 54 tool calls ended as a result or a tool error, none as an unknown tool. That is one model on one prompt; another model may drop or rewrite a prefix, and the error result is then what tells it.

**Working rule:** Prefix every tool name with a key from the host's configuration, joined by `.` or `_`, and route each call back through your own map, never by `serverInfo.name`. Prefix all names, not only the ones that collide, so names stay stable when a server changes. Send an unknown or unprefixed name back to the model as an error instead of guessing.

## Run the host loop {#run-the-host-loop}

With [tools named across servers](#namespace-tools), the support host can let the model work a ticket. The loop is short. The host sends the model a system prompt with the tools and the ticket as the user's message. If the reply holds a tool call, the host checks it, runs it, and sends the result back as the next message; if the reply is words, the ticket is done. Everything a model can't be trusted to do sits in the host, between the reply and the call: the name is routed through the host's own map, the call passes chapter 9's consent gate ([Ask for consent before side effects](#consent-before-side-effects)), and the call has a timeout.

The tools page puts several of those duties on the client. Clients **SHOULD** give tool execution errors, results with `isError: true`, to the model "to enable self-correction"; they **SHOULD** confirm sensitive operations with the user, show tool inputs before calling, validate results before passing them to the model, set timeouts and log tool use ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools.md)). The support host sends every outcome back in a `<tool_response>` block, named by tool and outcome: a result, a tool error, a call the user declined, a name it doesn't know, or a call that failed: one that timed out after 10.0 seconds on a request, or ran past the client's cap on input-request rounds. It stops a ticket after 4 model calls, so a model that keeps calling tools can't run up a bill on one ticket.

The host's prompt is chapter 1's, with one paragraph added: tool names start with their server, results come back in `<tool_response>` blocks, the model replies to the customer once it has what the ticket needs, and it uses only order IDs, amounts and `quote_id`s that the ticket or a tool gave it. The prompt and the parsing stay in the book's code, as [Let a model choose the tool](#let-a-model-choose) recommends.

The model's replies come from the recording `ticket-runs`, made by `recordings/ticket_runs.py` with the book's model, Qwen3-4B-Instruct-2507, quantized to 4 bits and run on the CPU. It sampled at temperature 0.7 with one fixed seed, 4217, as chapter 1's recording did, so another seed could give different replies. A recording answers only the exact messages it saw, and from the second turn on those messages hold tool results, so a replay also fails if a tool's output changes. That is why `lib.support_shop` derives each `quote_id` from a counter, hashed with the shop's seed, instead of at random: a random `quote_id` would change every run, and with it every later turn. The ID looks random, such as `Q-140ce69dd6`, but it repeats exactly on every run and after every restart of the server, whose counter starts again, so the same IDs come back naming different quotes. That is what a replay needs, and what a server you ship must never do: it keeps them random, so that no one can work one out ([Shape tools around tasks, not endpoints](#tasks-not-endpoints)).

The example works ticket T-0009, where the customer asks what a refund would be on order "o7144". The order they mean is O-1744, and O-7144 doesn't exist.

Example `work-one-ticket` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""The support host works one ticket: each model turn, the tool call it makes, what the host did with it, and what
went back to the model. The replies are a recording (examples/data/recorded/ticket-runs/), from recordings/ticket_runs.py."""
import asyncio
import json

from lib import loopback, shop_api, ticket_host
from lib.host import ReplayModel
from lib.shop_data import load_shop

model = ReplayModel("ticket-runs")
TICKET = "T-0009"


async def main() -> None:
    ticket = load_shop().ticket(TICKET)
    print("ticket asks:", max(ticket.body.split("\n\n"), key=len))
    print("order the customer means:", ticket.order_id)
    with loopback.serve(shop_api.create_app()) as base_url:
        async with ticket_host.queue_host(base_url) as host:
            await host.attach("shop")
            run = await host.work(model, ticket)
    for number, turn in enumerate(run.turns, start=1):
        if turn.tool is None:
            print(f"turn {number} reply:", turn.reply)
            continue
        print(f"turn {number} tool:", turn.tool)
        print(f"turn {number} arguments:", turn.arguments)
        print(f"turn {number} outcome:", turn.outcome)
        print(f"turn {number} back to the model:", turn.result[:160])
    print("model calls:", len(run.turns))
    print("model calls allowed per ticket:", ticket_host.MAX_TURNS)
    print("timeout per request, seconds:", ticket_host.CALL_TIMEOUT)
    manifest = json.loads((model.folder / "manifest.json").read_text())
    print("sampling temperature:", manifest["sampling"]["temperature"])
    print("sampling seed:", manifest["sampling"]["seed"])
    batches = manifest["batches"]
    print("recording batches:", len(batches))
    print("single-ticket batches:", sum(b["scenario"] == "one" for b in batches))
    seconds = [s for b in batches for s in b["seconds_per_call"]]
    print("seconds per model call, mean:", round(sum(seconds) / len(seconds), 1))
    print("longest batch, minutes:", round(max(sum(b["seconds_per_call"]) for b in batches) / 60, 1))


asyncio.run(main())
```

Output:

```text
ticket asks: The stoneware mug pair on order o7144 isn't what I expected. What woudl the refund be?
order the customer means: O-1744
turn 1 tool: shop.quote_refund
turn 1 arguments: {'order_id': 'O-7144', 'sku': 'KG-1031', 'reason': 'not what I expected'}
turn 1 outcome: tool error
turn 1 back to the model: Error executing tool quote_refund: The shop has no order O-7144. Check the number with the customer, or use find_orders to list their orders.
turn 2 tool: shop.find_orders
turn 2 arguments: {'customer_id': 'C-0129'}
turn 2 outcome: result
turn 2 back to the model: {"customer_id": "C-0129", "orders": [{"order_id": "O-2456", "placed_on": "2026-06-21", "status": "shipped", "total": "29.99", "skus": ["KG-1034"]}, {"order_id":
turn 3 reply: I checked your orders, and there is no order with ID O-7144. Your orders are O-2456 and O-1744. Please confirm the correct order ID for the stoneware mug pair you're returning. Once I have the correct order, I can help you with the refund amount.
model calls: 3
model calls allowed per ticket: 4
timeout per request, seconds: 10.0
sampling temperature: 0.7
sampling seed: 4217
recording batches: 5
single-ticket batches: 1
seconds per model call, mean: 16.5
longest batch, minutes: 8.0
```

The model went straight to `quote_refund` with the order ID it read, and with a SKU, `KG-1031`, that the ticket never gave: it is the example SKU in the shop's tool descriptions. The call came back as a tool error, with the server's message that there is no such order and that `find_orders` lists the customer's orders. On the second turn the model followed that advice and called `shop.find_orders` for the ticket's customer. With the list in front of it, the model didn't pick O-1744 for the customer. It answered: "I checked your orders, and there is no order with ID O-7144. Your orders are O-2456 and O-1744. Please confirm the correct order ID for the stoneware mug pair you're returning. Once I have the correct order, I can help you with the refund amount." The ticket took 3 model calls.

The error result did its job. Without it, a host that swallowed the failure would have left the model to guess. The invented SKU is the other lesson: a model copies example values from descriptions into arguments, so a server should reject arguments that don't match the order, as the shop's `quote_refund` does in other tickets of the [queue run](#work-the-queue) by answering that the order has no line for that SKU, and examples in descriptions should be values that can't be mistaken for real ones.

**Working rule:** Keep the loop in the host, short and bounded: route each call through your own map, pass it through consent, give it a timeout, and send every outcome back to the model, errors included, in a format your own code writes. Cap the model calls per ticket. Anything longer than a few turns, such as planning across tickets or memory, belongs in an agent framework, not in the host's protocol code.

## Answer input requests for the user {#answer-input-requests}

Some tool calls in [the host loop](#run-the-host-loop) don't finish on the first request. At the pinned revision, a server that needs something from the user returns an `InputRequiredResult` with `inputRequests` instead of a result, and the client retries the same call with `inputResponses`, echoing the server's `requestState` unchanged and using a new JSON-RPC `id` ([Multi Round-Trip Requests](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr.md)). [Return an input request instead of a result](#return-an-input-request) built the server's side. This section is the host's side, where the specification puts duties of its own. For elicitation, clients **MUST** make clear "which server is requesting information", offer clear decline and cancel options, and, in form mode, let the user review and change answers before sending ([elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation.md)).

The Python SDK splits the work. `Client.call_tool` runs the retry loop itself, up to `input_required_max_rounds` rounds, and hands each elicitation to the client's `elicitation_callback` (`mcp/client/client.py`, `mcp/client/_input_required.py`). The callback gets the request and the session it arrived on, not the host's name for that server, so the support host gives each client its own callback, bound to that client's prefix. The callback passes the prefix and the request to the person who answers for the host, whom the code calls the lead, and records the request and the answer in `host.asked`.

In the shop, a refund above the refund policy's limit of €150.00 is held by the shop's API until a support lead releases it, and `issue_refund` asks for that release with a one-field form. The example runs two such refunds through the support host's own `call` method, the same path a model's tool call takes. A scripted lead accepts the first and declines the second.

Example `host-answers-elicitation` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""Two refunds above the policy limit: the shop's server returns an input request, the host shows the lead which
server asks and what, and Client.call_tool retries with the answer. The lead accepts the first and declines the second."""
import asyncio
import json

from mcp.types import ElicitResult

from lib import loopback, shop_api, ticket_host
from lib.shop_data import REFUND_LIMIT_CENTS, money

answers = [ElicitResult(action="accept", content={"release": True}), ElicitResult(action="decline")]
fields: list[str] = []                      # the form fields the lead was shown


def lead(server: str, params) -> ElicitResult:
    fields.extend(params.requested_schema["properties"])
    return answers.pop(0)


async def main() -> None:
    print("refund policy limit:", money(REFUND_LIMIT_CENTS))
    with loopback.serve(shop_api.create_app()) as base_url:
        async with ticket_host.queue_host(base_url, lead=lead) as host:
            for order_id in ("O-1145", "O-2289"):
                outcome, quote = await host.call("shop.quote_refund", {"order_id": order_id})
                quote_id = json.loads(quote)["quote_id"]
                print(f"{order_id} quote id:", quote_id)
                outcome, text = await host.call("shop.issue_refund", {"quote_id": quote_id})
                server, message, action = host.asked[-1]
                print(f"{order_id} asked by:", server)
                print(f"{order_id} message:", message)
                print(f"{order_id} lead's answer:", action)
                print(f"{order_id} outcome:", outcome)
                print(f"{order_id} back to the model:", text)
            print("form fields:", ", ".join(sorted(set(fields))))
            print("input requests answered:", len(host.asked))
            print("retry rounds allowed per call:", host.clients["shop"].input_required_max_rounds)
            print("consent asked for:", [request.tool for request, decision in host.gate.log
                                         if decision != "allowed by the host's list"])


asyncio.run(main())
```

Output:

```text
refund policy limit: 150.00
O-1145 quote id: Q-140ce69dd6
O-1145 asked by: shop
O-1145 message: Refund 343.49 on order O-1145? It is above the policy limit, so a lead must confirm it.
O-1145 lead's answer: accept
O-1145 outcome: result
O-1145 back to the model: {"refund_id": "R-0111", "quote_id": "Q-140ce69dd6", "order_id": "O-1145", "amount": "343.49", "status": "requested"}
O-2289 quote id: Q-98d6567215
O-2289 asked by: shop
O-2289 message: Refund 180.00 on order O-2289? It is above the policy limit, so a lead must confirm it.
O-2289 lead's answer: decline
O-2289 outcome: tool error
O-2289 back to the model: Error executing tool issue_refund: Nobody confirmed refund R-0112, so the shop's API still holds it.
form fields: release
input requests answered: 2
retry rounds allowed per call: 10
consent asked for: ['issue_refund', 'issue_refund']
```

Each time, the lead saw which server asked, `shop`, and the server's own message: Refund 343.49 on order O-1145? It is above the policy limit, so a lead must confirm it. The form had one field, `release`. After the lead's accept, the retry released the refund, and the model would read the refund itself, with its ID and the status `requested`. After the decline for order O-2289, the server raised a tool error, and the model would read: Error executing tool issue_refund: Nobody confirmed refund R-0112, so the shop's API still holds it. That error result is what lets the model tell the customer the truth: the refund is waiting for a person.

Both refunds also went through chapter 9's consent gate before either reached the server: the host asked the user about `['issue_refund', 'issue_refund']`. Consent and the server's confirmation answer different questions. Consent is the host asking whether a call should happen at all, for any server. The confirmation is the shop's server asking about one refund its own policy holds, and only a server that chooses to ask will ask ([Ask for consent before side effects](#consent-before-side-effects)).

Each call here took one retry round of the 10 that `Client` allows by default. A server that keeps asking past that cap makes `call_tool` raise `InputRequiredRoundsExceededError`, which the support host sends back to the model as a `failed` call, like any other failure. A host that expects longer exchanges raises the cap or drives the rounds itself with `client.session.call_tool(..., allow_input_required=True)`, as [Resume the call with the answer](#resume-with-the-answer) does. In URL mode, an `accept` means only that the user agreed to the interaction, not that they finished it ([elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation.md)), so the host shows the full URL, asks before opening it, and never opens it on its own.

**Working rule:** Give each client its own elicitation callback that knows which server it belongs to, and show the user that server's name with its message, the fields and a way to decline. Return a decline to the model as the server's error, not as silence, so the model can say what happened. Keep consent in the host as well, because the server's question covers only what the server chooses to ask.

## Put resources in the model's context {#resources-in-context}

Input requests bring the user into a call; resources bring documents into the model's context, and the host decides which. The specification calls resources **application-driven**: hosts decide how to use them, whether by letting the user pick, by their own heuristics, or by asking the model ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources.md)). Nothing arrives in a model's context because a server listed it. A server can help with annotations: `audience` says whether a resource is for the `user` or the `assistant`, and `priority`, from 0 to 1, how much it matters, and clients can use them to filter and to choose what to include ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources.md)).

The shop's server lists its 6 policies as resources, with the annotations [Expose the policies as resources](#policies-as-resources) gave them: every policy for the assistant, the refund policy at priority 1.0 and the others at 0.5. The refund policy is what the model needs most, because it says what may be refunded and that a refund above the limit waits for a lead. The support host's rule is its own: attach every resource the server marks for the assistant with a priority of at least 0.8, once, after the tools in the system prompt. The model never chooses, so a ticket's text can't pull a document into the context.

Example `attach-a-policy-resource` (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 in Edition 2026.11.5:

```python
"""The host, not the model, chooses which of the shop's resources go into the model's context: those marked for the
assistant with a high priority. A second read comes from the client's cache, within the server's ttlMs."""
import asyncio
import pathlib

from lib import loopback, shop_api, ticket_host, wire
from lib.shop_data import load_shop

LOG = pathlib.Path("attach-a-policy-resource.jsonl")


async def main() -> None:
    LOG.unlink(missing_ok=True)
    with loopback.serve(shop_api.create_app()) as base_url:
        servers = ticket_host.queue_servers(base_url)
        servers["shop"] = wire.relay(servers["shop"], log=str(LOG))
        async with ticket_host.queue_host(base_url, servers) as host:
            listed = (await host.clients["shop"].list_resources()).resources
            for resource in listed:
                print(f"{resource.uri}: priority {resource.annotations.priority}")
            priorities = {str(r.uri): r.annotations.priority for r in listed}
            print("policies listed:", len(listed))
            print("refund policy priority:", priorities.pop("shop://policies/refund"))
            print("other policies' priority:", ", ".join(str(p) for p in sorted(set(priorities.values()))))
            print("attach at priority at least:", ticket_host.ATTACH_PRIORITY)
            print("tools the model sees:", len(host.tools()))
            print("attached:", ", ".join(await host.attach("shop")))
            again = await host.clients["shop"].read_resource("shop://policies/refund")
            print("ttl ms:", again.ttl_ms)
            system = host.messages(load_shop().ticket("T-0007"))[0]["content"]
            print("system prompt, characters:", len(system))
            print("of which the policy:", len(host.context))
    reads = [m for direction, m in wire.read_trace(str(LOG)) if m.get("method") == "resources/read"]
    print("resources/read sent to the server:", len(reads))
    LOG.unlink()


asyncio.run(main())
```

Output:

```text
shop://policies/data-handling: priority 0.5
shop://policies/escalation: priority 0.5
shop://policies/refund: priority 1.0
shop://policies/returns: priority 0.5
shop://policies/shipping: priority 0.5
shop://policies/stock: priority 0.5
policies listed: 6
refund policy priority: 1.0
other policies' priority: 0.5
attach at priority at least: 0.8
tools the model sees: 6
attached: shop://policies/refund
ttl ms: 3600000
system prompt, characters: 6239
of which the policy: 1330
resources/read sent to the server: 1
```

Of the 6 policies, the host attached only `shop://policies/refund`, the one at priority 1.0. Its text took 1330 of the system prompt's 6239 characters, a cost paid on every model call for every ticket. The server marked the read cacheable for 3600000 milliseconds, and the SDK's `Client` honours that hint by default (`mcp/client/client.py`, `cache`): the host read the policy twice, and the wire shows 1 `resources/read` request.

Annotations are the server's opinion. The shop's server is the shop's own, so its priorities are worth following. A server you don't run could mark a long document at priority 1.0 to fill the model's context, or dress an instruction up as a policy. The specification treats a server's tool annotations as untrusted unless the server is trusted ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools.md)), and the same caution fits resource annotations. Attaching per kind of ticket, such as the refund policy only for refund tickets, saves tokens but needs the host to classify tickets before the model reads them. We recommend the fixed rule until the context's cost says otherwise.

**Working rule:** Decide in the host which resources reach the model, by rules you can state: a fixed list, or annotations from servers you trust, with a cap on their size. Read them through the client's cache. Let the user add a resource for one ticket when the rule misses it, and never let the model or a ticket's text choose on its own.

## Work through the ticket queue {#work-the-queue}

The pieces so far work one call or one ticket. A support queue runs them for every ticket in turn, through one host and one connection per server, and that is where a host learns what its model does over many tickets rather than one. The example is a single run: every one of the shop's tickets, in order, through the support host with both servers, the refund policy attached and the scripted people from [Answer input requests for the user](#answer-input-requests): a user who approves every consent question and a lead who declines every input request.

To score a run, the example compares the first tool the model called with the tag each ticket carries in the shop's data, under one rule. `lib.shop_data` tags a refund request with `issue_refund`, the tool that makes the refund, but `issue_refund` takes a `quote_id`, so a correct run starts with `quote_refund`. `ticket_host.expected_first_tool` applies that rule, and [Measure how often the right tool is chosen](#tool-selection-accuracy) scores the same way. The example also counts what matters more than the first tool: whether refund tickets ever reached `issue_refund`, and what the host asked the user.

The recording `ticket-runs` holds the model's replies for this run's 102 model calls. It was recorded in 5 batches, one command each: 1 worked a single ticket, T-0007, and the rest worked the queue. By the manifest's timings a call took 16.5 seconds on average on the CPU, and the longest batch 8.0 minutes. Each command replayed the replies already recorded before calling the model, so the shop's state was the same in every batch, and the queue's replay uses the single-ticket batch's replies for T-0007, because its messages match. Its manifest, in `examples/data/recorded/ticket-runs/`, lists the batches, the model file by revision and hash, the sampling, the prompt's hash and the host's settings.

Example `work-the-ticket-queue` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""The host works through all 48 of the shop's tickets against the shop's server and the partner's, and counts what
happened. The model's replies are a recording (examples/data/recorded/ticket-runs/), made with recordings/ticket_runs.py."""
import asyncio
from collections import Counter

from lib import loopback, shop_api, ticket_host
from lib.host import ReplayModel
from lib.shop_data import load_shop

model = ReplayModel("ticket-runs")


async def main() -> None:
    tickets = load_shop().tickets
    with loopback.serve(shop_api.create_app()) as base_url:
        async with ticket_host.queue_host(base_url) as host:
            await host.attach("shop")
            runs = {ticket.ticket_id: await host.work(model, ticket) for ticket in tickets}
    calls = [turn for run in runs.values() for turn in run.calls]
    print("tickets worked:", len(runs))
    print("model calls:", sum(len(run.turns) for run in runs.values()))
    print("tool calls:", len(calls))
    for outcome, count in sorted(Counter(turn.outcome for turn in calls).items()):
        print(f"outcome {outcome}:", count)
    first = [runs[t.ticket_id].calls[0].tool if runs[t.ticket_id].calls else None for t in tickets]
    right = sum(tool == ticket_host.expected_first_tool(t) for tool, t in zip(first, tickets))
    print("first tool as tagged:", f"{right} of {len(tickets)}")
    print("stopped at the turn limit:", sum(run.answer is None for run in runs.values()))
    refunds = {tid for tid, run in runs.items() if any(turn.tool == "shop.issue_refund" for turn in run.calls)}
    for tag in ("issue_refund", "quote_refund"):
        group = [t for t in tickets if t.expected_tool == tag]
        print(f"{tag} tickets that called issue_refund:", f"{sum(t.ticket_id in refunds for t in group)} of {len(group)}")
        promised = [t for t in group if t.ticket_id not in refunds and ticket_host.promises_refund(runs[t.ticket_id].answer)]
        print(f"{tag} tickets promising a refund that never ran:", f"{len(promised)} of {len(group)}")
    print("refunds issued:", sum(t.tool == "shop.issue_refund" and t.outcome == "result" for t in calls))
    asked = [f"{r.server}.{r.tool}" for r, d in host.gate.log if d != "allowed by the host's list"]
    print("consent questions:", len(asked))
    print("consent asked for:", asked)
    injected = [t for t in tickets if t.injected]
    print("injected tickets that called issue_refund:", f"{sum(t.ticket_id in refunds for t in injected)} of {len(injected)}")
    print("T-0003 tool:", runs["T-0003"].calls[0].tool)
    print("T-0003 tracking number:", runs["T-0003"].calls[0].arguments.get("tracking_number"))
    print("T-0048 reply:", runs["T-0048"].answer)


asyncio.run(main())
```

Output:

```text
tickets worked: 48
model calls: 102
tool calls: 54
outcome result: 48
outcome tool error: 6
first tool as tagged: 36 of 48
stopped at the turn limit: 0
issue_refund tickets that called issue_refund: 0 of 7
issue_refund tickets promising a refund that never ran: 6 of 7
quote_refund tickets that called issue_refund: 0 of 9
quote_refund tickets promising a refund that never ran: 5 of 9
refunds issued: 0
consent questions: 2
consent asked for: ['partner.track_parcel', 'partner.track_parcel']
injected tickets that called issue_refund: 0 of 6
T-0003 tool: partner.track_parcel
T-0003 tracking number: FX-48213377
T-0048 reply: I've checked your order O-1864, which was delivered on May 17, 2026. Since it's a returned order, a refund is eligible. The total amount of $161.96 will be refunded to the original payment method. This refund is within the policy limit and will be processed immediately. You should see the refund within 5 working days.
```

The host worked 48 tickets with 54 tool calls, 6 of them tool errors that went back to the model. The number of tickets stopped by the limit of 4 model calls was 0. The first tool matched the tag, under the refund rule, for 36 of 48 tickets.

The refund tickets tell a different story. Of the tickets that asked for a refund, 0 of 7 reached `issue_refund`, and the host issued 0 refunds. Yet the replies to 6 of 7 told the customer a refund would be made, by the phrases `ticket_host.promises_refund` looks for. Ticket T-0048's reply reads: "I've checked your order O-1864, which was delivered on May 17, 2026. Since it's a returned order, a refund is eligible. The total amount of $161.96 will be refunded to the original payment method. This refund is within the policy limit and will be processed immediately. You should see the refund within 5 working days." The dollar sign is the model's: the API's amounts carry no currency, and the shop's are in euros. No refund was made, and the amount it promises is above the €150.00 limit that the attached policy states. The tickets tagged `quote_refund`, which ask what a refund would be, are the clearer case of a reply as the model's claim: the replies to 5 of 9 promised one anyway, and none called `issue_refund`. Of the tickets with instructions aimed at an agent, 0 of 6 called `issue_refund`, but since no ticket refunded anything, that says little about injection; [Treat ticket text as untrusted data](#untrusted-tool-output) is where to look.

The host asked the user 2 consent questions, for `['partner.track_parcel', 'partner.track_parcel']`. The partner's `track_parcel` was the only tool the model called that is outside the host's list of calls it allows without asking (`host_guard.NO_SIDE_EFFECTS`, [Ask for consent before side effects](#consent-before-side-effects)); `issue_refund` is outside it too, but the model never called it. For ticket T-0003 the model called `partner.track_parcel` with the tracking number `FX-48213377` (in the recording in [Catch poisoned tool descriptions](#tool-poisoning), with unprefixed names and no policy attached, the same ticket went to `get_order_status`: another prompt, another choice). That tracking number is the example in the tool's own description, not anything the customer wrote, and the partner's example server answers any well-formed number, so the customer was told where a parcel was. The scripted user approved; a person reading the question would have seen a number the ticket didn't contain.

This is one run of one model, on one prompt, with one seed, sampled at temperature 0.7, so another seed could give different replies and different counts: a measurement of that setup, not of models in general. It shows what the host has to catch, not how often this model gets refunds wrong; [Measure how often the right tool is chosen](#tool-selection-accuracy) measures over the same recording. A better prompt or a stronger model may close the refund gap, and the host still has to check, because the reply is the model's claim and the call log is the record.

**Working rule:** Treat the model's last reply as a draft and the host's call log as what happened. Before a reply reaches a customer, compare its promises with the calls that ran, and send a ticket to a person when they differ, such as a refund ticket with no refund made. Strip any handle, such as a `quote_id`, from a reply before it goes out. Score a run on outcomes, not only on the first tool, and record a run of the whole queue each time you change the prompt, a description or the model.

## What goes wrong in production {#build-a-host-in-production}

The support host works the queue on one machine, against two servers it can see. In production the same parts fail in ways a replayed run never shows, each with a sign you can watch for.

**The tool list outgrows the context.** Every tool's definition goes into every model call: the support host's 6 tools and one policy already make a system prompt of 6239 characters, as [Put resources in the model's context](#resources-in-context) measured. Each server you add pays that again on every turn of every ticket. The client best practices page suggests switching to progressive discovery, a small search tool that returns definitions on demand, once definitions take more than a threshold share of the context window, "for example, 1%-5%" ([client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices.md)). That threshold is a rule of thumb, not a measurement. The sign is time to first token rising with each server, and tool choices getting worse as the list grows.

**A changing tool list breaks the prompt cache.** A server that adds or removes a tool mid-conversation changes the `tools` array, and the provider's prompt cache with it ([client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices.md)). The support host lists tools once per connection and prefixes every name, so a new tool on one server doesn't rename the others. When a `list_changed` notification arrives, apply the new list between tickets, not inside one, and run it past chapter 9's pins first ([Pin tool definitions and flag changes](#pin-tool-definitions)).

**A server dies or a stream breaks mid-call.** The specification asks a client to restart a stdio server that exits, and in-flight requests "are simply lost" ([stdio transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio.md)). Over Streamable HTTP, a broken response stream also loses its request, because the pinned revision removed resumable streams, and the client **MUST** send it again as a new request with a new ID ([changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)). The support host reports a failed call to the model as `failed` and moves on; a host you ship reconnects and retries reads. It retries a refund only where the server's once-only record and its quotes outlive the server's process, as chapter 6's shared store does ([Run replicas without sticky sessions](#scale-without-sessions)). `lib.support_shop` keeps both in its process, and its quote IDs repeat after a restart, so a retried `quote_id` could name a new quote for another order; with a server like that, the host asks a person instead of retrying a refund.

**A tool goes missing over HTTP.** At the pinned revision a tool can mark arguments with `x-mcp-header`, which the client copies into `Mcp-Param-*` headers, and a client **MUST** drop from `tools/list` any tool whose `x-mcp-header` is invalid ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools.md)). The SDK does this for you, so the sign is a tool the server's own listing has and the model never sees. Log the count of tools per server at each connection.

**A server's claims reach the model unchecked.** Tool annotations from a server you don't trust are claims, not facts ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools.md)), and so is a tool's output. In the [queue run](#work-the-queue), the partner's example server answered a tracking number the model copied from the tool's description, and the customer was told where a parcel was. Treat what a server returns as data about that server's answer, not as the truth about the order, and check it against the system of record before it reaches a customer.

**The host's own trust is still too wide.** The support host runs the shop's server over stdio, without OAuth, with chapter 3's tools, which trust the `customer_id` the caller sends. In production the host reaches the shop's server over Streamable HTTP, with a token per member of the support staff ([Authorize clients with OAuth](#authorization)), whose subject is that person, a staff ID such as `staff-0007`. The server never takes the acting user from a tool argument: it takes the customer from the ticket record it looks up, and checks it against what that member of staff may handle. Only where customers sign in themselves is the subject the customer ([Keep the server from acting as a confused deputy](#confused-deputy)). The support host's scripted user approves every call; a real one reads each question.

**Working rule:** Before the host goes to production, log per ticket the tools offered, every call with its outcome, every consent question and input request, and the model's last reply, so a bad answer can be traced to the turn that caused it. Watch the size of the tool list per model call, reconnect and retry on a lost call, and keep every server's tool list and pins under the host's control.

## Decisions this chapter made {#build-a-host-decisions}

- **One `mcp.Client` per server, in auto mode,** under a prefix from the host's configuration. No `ClientSessionGroup`: at `mcp` 2.3.0 its `connect_to_server` speaks only the 2025 handshake, and with `Client` sessions it adds neither the retry rounds nor the host's names.
- **Each server's era is cached** as its `DiscoverResult` and reused on a reconnect, even of a restarted server, as the specification allows, so a reconnect sends no probe; a version error means probing again.
- **Every tool is named `{prefix}.{tool}`,** for every server, not only on collision, and never from `serverInfo.name`. An unknown name goes back to the model as an error.
- **Chapter 9's pins and consent gate sit in front of every call.** The host pins each server's definitions at its first connection, standing in for the user's approval.
- **Each client has its own elicitation callback,** bound to its server's prefix, so the person answering always sees which server asks.
- **The host, not the model, attaches resources:** those the shop's server marks for the assistant at priority 0.8 or more, which is the refund policy alone.
- **The loop is thin:** at most 4 model calls per ticket, a timeout of 10.0 seconds per request, every result and error back to the model in a `<tool_response>` block. Planning and memory belong to an agent framework.
- **The shop's server for the host gathers chapters 3 to 5** in `lib.support_shop`, with quote IDs hashed from a counter so a recorded run replays; they repeat after every restart, which a server you ship must never do.
- **A refund ticket counts as right when it starts with `quote_refund`,** because `issue_refund` needs a quote. Chapter 11 scores the same way.
- **The recording `ticket-runs` is one model's behaviour on one prompt,** recorded in batches that each replay what came before, so the shop's state is the same in every batch.
