# One request through an MCP server, end to end

October 24, 2026 · Part 30 of 35

![One request through an MCP server, end to end](https://pournasserian.com/_astro/lead.iO4l0bK6_Z1FzTR1.webp)

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](https://pournasserian.com/writing/mcp-2026-experimental-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):

| 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 `initialize` handshake: the host calls [`server/discover`](https://pournasserian.com/writing/mcp-2026-server-discover), which this server lets it cache for an hour, and lists the tools with the per-request [`_meta` envelope](https://pournasserian.com/writing/mcp-2026-stateless-requests); they come back with their [caching hints](https://pournasserian.com/writing/mcp-2026-caching-and-pagination). 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`](https://pournasserian.com/writing/mcp-2026-subscriptions-listen) stream for tool-list changes. This server requires authorization, so every request over [remote Hypertext Transfer Protocol (HTTP)](https://pournasserian.com/writing/mcp-2026-transports) carries the [bearer token](https://pournasserian.com/writing/mcp-2026-oauth-resource-server).
- **2. The user's turn.** The host builds the model's context: this one puts the [server instructions](https://pournasserian.com/writing/mcp-2026-server-instructions) in the system prompt (a host's choice, not a protocol rule) and the [tool definitions](https://pournasserian.com/writing/mcp-2026-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](https://pournasserian.com/writing/mcp-2026-elicitation) form and a sealed [`requestState`](https://pournasserian.com/writing/mcp-2026-request-state). That exchange is a [multi round-trip request (MRTR)](https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests). 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](https://pournasserian.com/writing/mcp-2026-progress-and-cancellation) flows on the retry's own response stream, and the [trace context](https://pournasserian.com/writing/mcp-2026-opentelemetry) continues through `_meta.traceparent`.

```mermaid
sequenceDiagram
    actor U as User
    participant H as Host +<br/>MCP client
    participant L as LLM
    participant S as MCP server

    Note over H,S: 1. Discovery and setup<br/>(no initialize<br/>handshake)<br/>Remote HTTP: OAuth<br/>bearer token on<br/>every request
    H->>S: server/discover
    S-->>H: supportedVersions,<br/>capabilities,<br/>instructions, ttlMs
    H->>S: tools/list + _meta<br/>(protocolVersion,<br/>clientCapabilities)
    S-->>H: tools incl.<br/>_meta.ui.resourceUri,<br/>ttlMs, cacheScope
    H->>S: resources/read<br/>ui://sales/<br/>dashboard.html
    S-->>H: HTML + _meta.ui.csp<br/>(host caches<br/>and reviews it)
    H->>S: subscriptions/listen<br/>(toolsListChanged)
    S-->>H: notifications/<br/>subscriptions/<br/>acknowledged<br/>(stream stays open)

    Note over U,L: 2. User turn and<br/>tool selection
    U->>H: Show Q3 sales<br/>by region
    H->>L: system prompt +<br/>server instructions +<br/>tool schemas +<br/>message
    L-->>H: tool_use get_sales<br/>(region all)

    Note over H,S: 3. Stateless tool call<br/>with multi round-trip<br/>input (MRTR)
    H->>S: tools/call get_sales<br/>+ _meta (version,<br/>capabilities,<br/>traceparent)
    Note over S: Backend:<br/>validate request,<br/>date range missing
    S-->>H: resultType<br/>input_required +<br/>inputRequests +<br/>requestState
    H->>U: elicitation form<br/>(date range)
    U-->>H: Q3 2026
    H->>S: tools/call retry +<br/>inputResponses +<br/>requestState<br/>(new request ID)
    S-->>H: notifications/progress<br/>on the same<br/>response stream
    Note over S: Backend:<br/>query Q3 2026,<br/>rows
    S-->>H: resultType complete,<br/>content +<br/>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](https://pournasserian.com/writing/mcp-2026-apps) and [part 25](https://pournasserian.com/writing/mcp-2026-apps-building)).
- **5. The model answers.** Under the [MCP Apps convention](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx), the model sees only the text `content`. Hosts differ: as of October 2026, [Claude Code and its Agent SDK](https://code.claude.com/docs/en/agent-sdk/custom-tools) 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](https://pournasserian.com/writing/mcp-2026-tool-definitions) advises.
- **6. Interaction.** The user drills into a region: the app calls an [app-only tool](https://pournasserian.com/writing/mcp-2026-apps-building), `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`](https://pournasserian.com/writing/mcp-2026-subscriptions-listen) on the listen stream, and the host lists the tools again. When the conversation moves on, the host tears the app down gracefully.

```mermaid
sequenceDiagram
    participant A as App view<br/>(iframe)
    participant H as Host +<br/>MCP client
    participant L as LLM
    participant S as MCP server

    Note over A,S: 4. MCP App renders<br/>(only if the host<br/>negotiated<br/>io.modelcontextprotocol/ui)
    H->>A: load cached HTML<br/>in sandboxed iframe<br/>with CSP
    A->>H: ui/initialize
    H-->>A: hostContext (theme,<br/>displayMode,<br/>containerDimensions)
    A->>H: ui/notifications/<br/>initialized
    H->>A: ui/notifications/<br/>tool-input
    H->>A: ui/notifications/<br/>tool-result<br/>(structuredContent)

    Note over H,L: 5. The model answers<br/>from text content,<br/>under the MCP Apps<br/>convention
    H->>L: tool result content
    L-->>H: final answer
    Note over A,H: to the user:<br/>answer + inline app

    Note over A,S: 6. User interacts<br/>with the app
    A->>H: user drills into<br/>a region:<br/>tools/call<br/>get_region_detail<br/>(visibility app only)
    H->>S: tools/call<br/>get_region_detail
    Note over S: Backend:<br/>query, detail rows
    S-->>H: resultType complete
    H-->>A: tool result
    A->>H: ui/update-<br/>model-context<br/>(selected region)

    Note over A,S: 7. Change notification<br/>and teardown
    S-->>H: notifications/tools/<br/>list_changed on the<br/>listen stream
    H->>S: tools/list<br/>(refresh cache)
    S-->>H: updated tools
    H->>A: ui/resource-<br/>teardown
    A-->>H: ok, state saved
```

## Testing the whole sequence

Each phase leaned on a different part of the server:

```mermaid
flowchart LR
    p1["1 Discovery"] --> d1["server/discover,<br/>tools/list,<br/>resources/read,<br/>subscriptions/listen"]
    p2["2 Selection"] --> d2["instructions,<br/>tool descriptions"]
    p3["3 Execution"] --> d3["MRTR + elicitation,<br/>progress, tracing, auth"]
    p4["4 Rendering"] --> d4["ui:// template,<br/>structuredContent"]
    p5["5 Answer"] --> d5["text content<br/>that stands on its own"]
    p6["6 Interaction"] --> d6["app-only tools,<br/>ui/update-model-context"]
    p7["7 Change"] --> d7["list_changed,<br/>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.

```python
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.

```python
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:

```text
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.
