{"slug": "one-request-through-an-mcp-server-end-to-end", "title": "One request through an MCP server, end to end", "summary": "A walkthrough published as part 30 of a series on the Model Context Protocol (MCP) 2026-07-28 specification traces a single user request through an MCP server in seven phases, from discovery to app teardown, showing that one request touches most of a server's duties. The author reports that nothing is remembered between requests — each carries its own `_meta` envelope — and that the server asks for a missing date range via `input_required`, with the answer arriving on a retry under a new request ID. The piece also notes that hosts differ as of October 2026: Claude Code gives the model `structuredContent` in place of text, while ChatGPT lets the model see it, and advises checking the whole sequence in one test using a fake host and a recorder kept in the Python SDK server's middleware.", "body_md": "# One request through an MCP server, end to end\n\nTwenty-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.\n\nThis 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.\n\n## In brief\n\n1. **One request touches most of a server’s duties, in seven phases,** from discovery to the app’s teardown.\n2. **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.\n3. **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.\n4. **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.\n\n## Phases 1 to 3: setup, the user’s turn and a question back\n\nSix parties take part, including the large language model (LLM):\n\n| Participant | Role | \n|---|---|\n| User | Asks the question and interacts with the dashboard | \n| App view | An MCP App running in a sandboxed iframe inside the host | \n| Host + MCP client | The AI application (for example Claude or VS Code) and its MCP client | \n| LLM | The model reasoning inside the host | \n| MCP server | A sales-analytics server speaking 2026-07-28 | \n| Backend | The data warehouse behind the server | \n\n- **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) .\n- **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` .\n- **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` .\n\n## Phases 4 to 7: the app, the answer and what follows\n\n- **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) ).\n- **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.\n- **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.\n- **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.\n\n## Testing the whole sequence\n\nEach phase leaned on a different part of the server:\n\nMy 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.\n\nHere’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.\n\n``` python\nfrom typing import Annotated\n\nfrom pydantic import BaseModel\n\nfrom mcp.server import MCPServer\nfrom mcp.server.apps import Apps\nfrom mcp.server.mcpserver import Elicit, Resolve\nfrom mcp.types import CallToolResult, TextContent\n\nWAREHOUSE = {\"Q3 2026\": {\"north\": 1200, \"south\": 950}}  # stands in for the backend\nUI = \"ui://sales/dashboard.html\"\napps = Apps()\napps.add_html_resource(UI, \"<!doctype html><html><head><title>Sales</title></head></html>\")\n\nclass DateRange(BaseModel):\n    period: str\n\nasync def ask_range(period: str | None) -> DateRange | Elicit[DateRange]:\n    return DateRange(period=period) if period else Elicit(\"Which date range?\", DateRange)\n\n@apps.tool(resource_uri=UI)\nasync def get_sales(\n    dates: Annotated[DateRange, Resolve(ask_range)], period: str | None = None\n) -> CallToolResult:\n    \"\"\"Sales by region for a date range. Asks for the range if it's missing.\"\"\"\n    rows = WAREHOUSE[dates.period]\n    text = f\"Sales for {dates.period}: \" + \", \".join(f\"{r} {n}\" for r, n in rows.items())\n    data = {\"period\": dates.period, \"regions\": rows}\n    return CallToolResult(content=[TextContent(type=\"text\", text=text)], structured_content=data)\n\n@apps.tool(resource_uri=UI, visibility=[\"app\"])\ndef get_region_detail(region: str) -> str:\n    \"\"\"One region's figures, for the dashboard's clicks.\"\"\"\n    return f\"{region}: {WAREHOUSE['Q3 2026'][region]}\"\n\nmcp = MCPServer(\"sales\", extensions=[apps])\n```\n\nWithout a `period` from the model, `ask_range` returns `Elicit`, and the SDK answers with `input_required`.\n\nAn 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.\n\n``` python\nfrom mcp import Client, MCPError\n\nwire = []  # every request that reaches the server, with its result or error code\n\nasync def record(ctx, call_next):\n    entry = {\"method\": ctx.method, \"id\": ctx.request_id, \"params\": ctx.params}\n    wire.append(entry)\n    try:\n        entry[\"result\"] = await call_next(ctx)\n    except MCPError as exc:\n        entry[\"result\"] = {\"error\": exc.error.code}\n        raise\n    return entry[\"result\"]\n\nmcp.middleware.insert(0, record)  # first in the chain, so it sees the wire form\n\ndef check_wire() -> None:\n    for entry in wire:\n        meta = entry[\"params\"][\"_meta\"]\n        assert meta[\"io.modelcontextprotocol/protocolVersion\"] == \"2026-07-28\"\n        assert \"io.modelcontextprotocol/clientCapabilities\" in meta\n        assert \"resultType\" in entry[\"result\"] or \"error\" in entry[\"result\"]\n    assert len({entry[\"id\"] for entry in wire}) == len(wire)  # each retry: a fresh ID\n    print(f\"pass: envelope and resultType on all {len(wire)} requests, no ID reused\")\n\ndef callers(tool) -> list[str]:  # _meta.ui.visibility, [\"model\", \"app\"] by default\n    ui = (tool.meta or {}).get(\"ui\", {})\n    return ui.get(\"visibility\", [\"model\", \"app\"])\n\nasync def test_end_to_end(host: Client) -> None:\n    tools = (await host.list_tools()).tools  # the SDK lists the app-only tool too\n    shown = [tool.name for tool in tools if \"model\" in callers(tool)]\n    assert shown == [\"get_sales\"], shown\n    result = await host.call_tool(\"get_sales\", {})  # input_required, then a retry\n    text, data = result.content[0].text, result.structured_content\n    assert data[\"period\"] in text and all(str(n) in text for n in data[\"regions\"].values())\n    print(f\"pass: the model sees {shown}; its text stands alone: {text!r}\")\n    check_wire()\n```\n\nThe 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:\n\n```\npass: the model sees ['get_sales']; its text stands alone: 'Sales for Q3 2026: north 1200, south 950'\npass: envelope and resultType on all 4 requests, no ID reused\npass: the view's app-only call answered: north: 1200\npass: tampered requestState rejected: -32602 Invalid or expired requestState\npass: envelope and resultType on all 7 requests, no ID reused\n```\n\nWith the SDK’s own client, the envelope and ID checks pass by construction; they earn their place once a real host or gateway connects.\n\n## Method and caveats\n\n- 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.\n- 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.\n- 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.", "url": "https://wpnews.pro/news/one-request-through-an-mcp-server-end-to-end", "canonical_source": "https://pournasserian.com/writing/mcp-2026-end-to-end", "published_at": "2026-10-24 00:00:00+00:00", "updated_at": "2026-10-24 12:43:35.008529+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "Claude Code", "ChatGPT", "Python SDK", "VS Code"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/one-request-through-an-mcp-server-end-to-end", "markdown": "https://wpnews.pro/news/one-request-through-an-mcp-server-end-to-end.md", "text": "https://wpnews.pro/news/one-request-through-an-mcp-server-end-to-end.txt", "jsonld": "https://wpnews.pro/news/one-request-through-an-mcp-server-end-to-end.jsonld"}}