One request through an MCP server, end to end

One request through an MCP server, end to end

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

  1. One request touches most of a server’s duties, in seven phases, from discovery to the app’s teardown.
  2. Nothing is remembered between requests. Each carries its own _meta envelope. The server asks for the missing date range with input_required, and the answer arrives on a retry with a new request ID.
  3. 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 structuredContent in place of the text, and ChatGPT lets the model see it.
  4. 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):

ParticipantRole
UserAsks the question and interacts with the dashboard
App viewAn MCP App running in a sandboxed iframe inside the host
Host + MCP clientThe AI application (for example Claude or VS Code) and its MCP client
LLMThe model reasoning inside the host
MCP serverA sales-analytics server speaking 2026-07-28
BackendThe data warehouse behind the server
  • 1. Discovery and setup. There’s no initialize handshake: the host calls server/discover, which this server lets it cache for an hour, and lists the tools with the per-request _meta envelope; they come back with their caching hints. It prefetches the user interface (UI) template that _meta.ui.resourceUri names, to review and cache it before any tool runs, and opens one subscriptions/listen stream for tool-list changes. This server requires authorization, so every request over remote Hypertext Transfer Protocol (HTTP) carries the bearer token.
  • 2. The user’s turn. The host builds the model’s context: this one puts the server instructions in the system prompt (a host’s choice, not a protocol rule) and the tool 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_required with an elicitation form and a sealed requestState. That exchange is a multi 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 the trace context continues through _meta.traceparent.
Note (Host + MCP client, MCP server): 1. Discovery and setup (no initialize handshake) Remote HTTP: OAuth bearer token on every request; Host + MCP client → MCP server: server/discover; MCP server → Host + MCP client: supportedVersions, capabilities, instructions, ttlMs; Host + MCP client → MCP server: tools/list + _meta (protocolVersion, clientCapabilities); MCP server → Host + MCP client: tools incl. _meta.ui.resourceUri, ttlMs, cacheScope; Host + MCP client → MCP server: resources/read ui://sales/ dashboard.html; MCP server → Host + MCP client: HTML + _meta.ui.csp (host caches and reviews it); Host + MCP client → MCP server: subscriptions/listen (toolsListChanged); MCP server → Host + MCP client: notifications/ subscriptions/ acknowledged (stream stays open); Note (User, LLM): 2. User turn and tool selection; User → Host + MCP client: Show Q3 sales by region; Host + MCP client → LLM: system prompt + server instructions + tool schemas + message; LLM → Host + MCP client: tool_use get_sales (region all); Note (Host + MCP client, MCP server): 3. Stateless tool call with multi round-trip input (MRTR); Host + MCP client → MCP server: tools/call get_sales + _meta (version, capabilities, traceparent); Note (MCP server): Backend: validate request, date range missing; MCP server → Host + MCP client: resultType input_required + inputRequests + requestState; Host + MCP client → User: elicitation form (date range); User → Host + MCP client: Q3 2026; Host + MCP client → MCP server: tools/call retry + inputResponses + requestState (new request ID); MCP server → Host + MCP client: notifications/progress on the same response stream; Note (MCP server): Backend: query Q3 2026, rows; MCP server → Host + MCP client: resultType complete, content + structuredContentMCP serverLLMHost +MCP client1. Discovery and setup(no initializehandshake)Remote HTTP: OAuthbearer token onevery request2. User turn andtool selection3. Stateless tool callwith multi round-tripinput (MRTR)Backend:validate request,date range missingBackend:query Q3 2026,rowsUserserver/discoversupportedVersions,capabilities,instructions, ttlMstools/list + _meta(protocolVersion,clientCapabilities)tools incl._meta.ui.resourceUri,ttlMs, cacheScoperesources/readui://sales/dashboard.htmlHTML + _meta.ui.csp(host cachesand reviews it)subscriptions/listen(toolsListChanged)notifications/subscriptions/acknowledged(stream stays open)Show Q3 salesby regionsystem prompt +server instructions +tool schemas +messagetool_use get_sales(region all)tools/call get_sales+ _meta (version,capabilities,traceparent)resultTypeinput_required +inputRequests +requestStateelicitation form(date range)Q3 2026tools/call retry +inputResponses +requestState(new request ID)notifications/progresson the sameresponse streamresultType complete,content +structuredContent

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 the ui/initialize handshake it sends the tool’s input, then its result, and the app renders from structuredContent (part 24 and part 25).
  • 5. The model answers. Under the MCP Apps convention, the model sees only the text content. Hosts differ: as of October 2026, Claude Code and its Agent SDK pass structuredContent to 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, as part 8 advises.
  • 6. Interaction. The user drills into a region: the app calls an app-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_changed on the listen stream, and the host lists the tools again. When the conversation moves on, the host tears the app down gracefully.
Note (App view (iframe), MCP server): 4. MCP App renders (only if the host negotiated io.modelcontextprotocol/ui); Host + MCP client → App view (iframe): load cached HTML in sandboxed iframe with CSP; App view (iframe) → Host + MCP client: ui/initialize; Host + MCP client → App view (iframe): hostContext (theme, displayMode, containerDimensions); App view (iframe) → Host + MCP client: ui/notifications/ initialized; Host + MCP client → App view (iframe): ui/notifications/ tool-input; Host + MCP client → App view (iframe): ui/notifications/ tool-result (structuredContent); Note (Host + MCP client, LLM): 5. The model answers from text content, under the MCP Apps convention; Host + MCP client → LLM: tool result content; LLM → Host + MCP client: final answer; Note (App view (iframe), Host + MCP client): to the user: answer + inline app; Note (App view (iframe), MCP server): 6. User interacts with the app; App view (iframe) → Host + MCP client: user drills into a region: tools/call get_region_detail (visibility app only); Host + MCP client → MCP server: tools/call get_region_detail; Note (MCP server): Backend: query, detail rows; MCP server → Host + MCP client: resultType complete; Host + MCP client → App view (iframe): tool result; App view (iframe) → Host + MCP client: ui/update- model-context (selected region); Note (App view (iframe), MCP server): 7. Change notification and teardown; MCP server → Host + MCP client: notifications/tools/ list_changed on the listen stream; Host + MCP client → MCP server: tools/list (refresh cache); MCP server → Host + MCP client: updated tools; Host + MCP client → App view (iframe): ui/resource- teardown; App view (iframe) → Host + MCP client: ok, state savedMCP serverLLMHost +MCP clientApp view(iframe)4. MCP App renders(only if the hostnegotiatedio.modelcontextprotocol/ui)5. The model answersfrom text content,under the MCP Appsconventionto the user:answer + inline app6. User interactswith the appBackend:query, detail rows7. Change notificationand teardownload cached HTMLin sandboxed iframewith CSPui/initializehostContext (theme,displayMode,containerDimensions)ui/notifications/initializedui/notifications/tool-inputui/notifications/tool-result(structuredContent)tool result contentfinal answeruser drills intoa region:tools/callget_region_detail(visibility app only)tools/callget_region_detailresultType completetool resultui/update-model-context(selected region)notifications/tools/list_changed on thelisten streamtools/list(refresh cache)updated toolsui/resource-teardownok, state saved

Testing the whole sequence

Each phase leaned on a different part of the server:

1 Discovery → server/discover, tools/list, resources/read, subscriptions/listen; 2 Selection → instructions, tool descriptions; 3 Execution → MRTR + elicitation, progress, tracing, auth; 4 Rendering → ui:// template, structuredContent; 5 Answer → text content that stands on its own; 6 Interaction → app-only tools, ui/update-model-context; 7 Change → list_changed, ttlMs, teardown1 Discoveryserver/discover,tools/list,resources/read,subscriptions/listen2 Selectioninstructions,tool descriptions3 ExecutionMRTR + elicitation,progress, tracing, auth4 Renderingui:// template,structuredContent5 Answertext contentthat stands on its own6 Interactionapp-only tools,ui/update-model-context7 Changelist_changed,ttlMs, teardown

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 mcp 2.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.