Twenty-nine parts took a Model Context Protocol (MCP) server apart one duty at a time. This one puts them back together, to show how they fit in one request. A user asks an AI agent for Q3 sales by region, the server needs a date range it wasn’t given, it renders an interactive dashboard, and later its tool list changes.
This is part 30 of my series on what an MCP server does under the 2026-07-28 specification, after part 29 on extensions. Each phase links to the part that explains it. The facts are as I read them in October 2026.
In brief #
- One request touches most of a server’s duties, in seven phases, from discovery to the app’s teardown.
- Nothing is remembered between requests. Each carries its own
_metaenvelope. The server asks for the missing date range withinput_required, and the answer arrives on a retry with a new request ID. - Under the MCP Apps convention, the data goes to the app and the text to the model, so write text that stands on its own. Hosts differ, as of October 2026: Claude Code gives the model
structuredContentin place of the text, and ChatGPT lets the model see it. - My advice: check the whole sequence in one test, with a fake host and a recorder that keeps every message. In the Python software development kit (SDK), the recorder fits in the server’s middleware.
Phases 1 to 3: setup, the user’s turn and a question back #
Six parties take part, including the large language model (LLM):
| Participant | Role |
|---|---|
| User | Asks the question and interacts with the dashboard |
| App view | An MCP App running in a sandboxed iframe inside the host |
| Host + MCP client | The AI application (for example Claude or VS Code) and its MCP client |
| LLM | The model reasoning inside the host |
| MCP server | A sales-analytics server speaking 2026-07-28 |
| Backend | The data warehouse behind the server |
- 1. Discovery and setup. There’s no
initializehandshake: the host callsserver/discover, which this server lets it cache for an hour, and lists the tools with the per-request_metaenvelope ; they come back with theircaching hints . It prefetches the user interface (UI) template that_meta.ui.resourceUrinames, to review and cache it before any tool runs, and opens onesubscriptions/listenstream for tool-list changes. This server requires authorization, so every request overremote Hypertext Transfer Protocol (HTTP) carries thebearer token . - 2. The user’s turn. The host builds the model’s context: this one puts theserver instructions in the system prompt (a host’s choice, not a protocol rule) and thetool definitions in up front or through tool search. The model decides to call
get_sales. - 3. A question back. The date range is missing, so instead of holding the connection open the server returns
input_requiredwith anelicitation form and a sealedrequestState. That exchange is amulti round-trip request (MRTR) . The client asks the user and retries with a new request ID, and any server instance can complete the call (in Python, given a shared sealing key).Progress flows on the retry’s own response stream, and thetrace context continues through_meta.traceparent.
Phases 4 to 7: the app, the answer and what follows #
- 4. The app renders. Having negotiated the UI extension, the host loads the cached template in a sandboxed iframe, under a Content Security Policy (CSP) built from
_meta.ui.csp. After theui/initializehandshake it sends the tool’s input, then its result, and the app renders fromstructuredContent(part 24 andpart 25 ). - 5. The model answers. Under theMCP Apps convention , the model sees only the text
content. Hosts differ: as of October 2026,Claude Code and its Agent SDK passstructuredContentto the model in place of the text blocks, and ChatGPT lets the model see it (per OpenAI’s Apps SDK documentation). A text-only host stops here, with a usable answer if the text stands on its own, aspart 8 advises. - 6. Interaction. The user drills into a region: the app calls anapp-only tool ,
visibility: ["app"], through the host, which checks policy before forwarding it. The app then updates the model’s context with the region in view. - 7. Change and teardown. A new tool appears, perhaps because a feature flag flipped: the server signals
list_changedon the listen stream, and the host lists the tools again. When the conversation moves on, the host tears the app down gracefully.
Testing the whole sequence #
Each phase leaned on a different part of the server:
My advice is to test those duties together: simulate the whole sequence in an integration test, with a fake host that records every message. Assert the envelope on each request, the resultType on each result, a fresh request ID on each retry, a requestState that fails verification if tampered with, an app-only tool that never reaches the model’s tool list, and text content that’s useful on its own. That one test catches most real-world interoperability bugs.
Here’s a small version with the official Python SDK, mcp 2.3.0: get_sales asks for a missing date range with Resolve, as in part 13, and the dashboard’s HTML resource and the app-only tool come from mcp.server.apps, as in part 25.
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.apps import Apps
from mcp.server.mcpserver import Elicit, Resolve
from mcp.types import CallToolResult, TextContent
WAREHOUSE = {"Q3 2026": {"north": 1200, "south": 950}} # stands in for the backend
UI = "ui://sales/dashboard.html"
apps = Apps()
apps.add_html_resource(UI, "<!doctype html><html><head><title>Sales</title></head></html>")
class DateRange(BaseModel):
period: str
async def ask_range(period: str | None) -> DateRange | Elicit[DateRange]:
return DateRange(period=period) if period else Elicit("Which date range?", DateRange)
@apps.tool(resource_uri=UI)
async def get_sales(
dates: Annotated[DateRange, Resolve(ask_range)], period: str | None = None
) -> CallToolResult:
"""Sales by region for a date range. Asks for the range if it's missing."""
rows = WAREHOUSE[dates.period]
text = f"Sales for {dates.period}: " + ", ".join(f"{r} {n}" for r, n in rows.items())
data = {"period": dates.period, "regions": rows}
return CallToolResult(content=[TextContent(type="text", text=text)], structured_content=data)
@apps.tool(resource_uri=UI, visibility=["app"])
def get_region_detail(region: str) -> str:
"""One region's figures, for the dashboard's clicks."""
return f"{region}: {WAREHOUSE['Q3 2026'][region]}"
mcp = MCPServer("sales", extensions=[apps])
Without a period from the model, ask_range returns Elicit, and the SDK answers with input_required.
An in-memory connection has no wire to tap, so I record at the server’s door. MCPServer.middleware is the chain around every inbound message, outermost first. The SDK puts its OpenTelemetry layer first and the layer that opens and seals requestState second, so middleware you pass to the constructor sees the state already opened. Inserted at position 0, the recorder sees what the client actually sent, and each result or error code. The SDK calls this interface provisional in 2.3.0.
from mcp import Client, MCPError
wire = [] # every request that reaches the server, with its result or error code
async def record(ctx, call_next):
entry = {"method": ctx.method, "id": ctx.request_id, "params": ctx.params}
wire.append(entry)
try:
entry["result"] = await call_next(ctx)
except MCPError as exc:
entry["result"] = {"error": exc.error.code}
raise
return entry["result"]
mcp.middleware.insert(0, record) # first in the chain, so it sees the wire form
def check_wire() -> None:
for entry in wire:
meta = entry["params"]["_meta"]
assert meta["io.modelcontextprotocol/protocolVersion"] == "2026-07-28"
assert "io.modelcontextprotocol/clientCapabilities" in meta
assert "resultType" in entry["result"] or "error" in entry["result"]
assert len({entry["id"] for entry in wire}) == len(wire) # each retry: a fresh ID
print(f"pass: envelope and resultType on all {len(wire)} requests, no ID reused")
def callers(tool) -> list[str]: # _meta.ui.visibility, ["model", "app"] by default
ui = (tool.meta or {}).get("ui", {})
return ui.get("visibility", ["model", "app"])
async def test_end_to_end(host: Client) -> None:
tools = (await host.list_tools()).tools # the SDK lists the app-only tool too
shown = [tool.name for tool in tools if "model" in callers(tool)]
assert shown == ["get_sales"], shown
result = await host.call_tool("get_sales", {}) # input_required, then a retry
text, data = result.content[0].text, result.structured_content
assert data["period"] in text and all(str(n) in text for n in data["regions"].values())
print(f"pass: the model sees {shown}; its text stands alone: {text!r}")
check_wire()
The host is the SDK’s in-memory Client, advertising Apps as in part 25, with an elicitation callback that answers Q3 2026 for the user. The SDK lists the app-only tool like any other, so the test filters by visibility, as a host does: under the MCP Apps specification, hosts MUST NOT put it in the model’s tool list. My harness runs the test, then calls the app-only tool as the view would. Then it starts a new call, changes one character of the sealed requestState it gets back, retries, as part 14 did, and checks the wire again:
pass: the model sees ['get_sales']; its text stands alone: 'Sales for Q3 2026: north 1200, south 950'
pass: envelope and resultType on all 4 requests, no ID reused
pass: the view's app-only call answered: north: 1200
pass: tampered requestState rejected: -32602 Invalid or expired requestState
pass: envelope and resultType on all 7 requests, no ID reused
With the SDK’s own client, the envelope and ID checks pass by construction; they earn their place once a real host or gateway connects.
Method and caveats #
- Built from my guide’s end-to-end walkthrough, written against the 2026-07-28 specification and the MCP Apps specification of 2026-01-26. Product facts are as of October 2026.
- The Python sample ran against
mcp2.3.0 on 9 October 2026 and printed the output shown. The sales figures are placeholders. The middleware chain, its provisional status and the listed app-only tool come from the package’s source and the same run. - Where the hosts’ documentation, read on 8 October 2026, is more precise than my notes (which half of a tool result the model sees), this article follows it.