MCP 2026-07-28: The Protocol Grew Up
The Model Context Protocol just shipped its biggest update since remote MCP launched over a year ago. The 2026-07-28 spec transforms MCP from a stateful, session-based protocol into a stateless request/response protocol — the kind you can put behind a plain load balancer without any sticky sessions or shared state.
This post explains what changed, why it matters, and shows you what the migration looks like in practice. If you've ever built or used an MCP server, this is the upgrade path you've been waiting for.
Why the Change?#
The old MCP (spec 2025-11-25) worked like a phone call: you dial in (initialize), the server remembers who you are (session ID), and you stay on the line until one side hangs up. This design had real problems at scale:
Pain points of the old stateful MCP
| Problem | Why It Hurts |
|---|---|
| Session affinity | Every request had to hit the same server instance. No simple round-robin load balancing. |
| Connection drops | If the WebSocket or SSE stream died, you lost your session and had to re-initialize. |
| Scaling complexity | Shared session stores (Redis, DB) were needed to run multiple server replicas. |
| Bidirectional coupling | Servers could push requests to clients at any time, requiring an always-open stream. |
The new spec solves all of these by making every request self-contained. Think of it as going from WebSocket-style to REST-style — each request carries everything the server needs to handle it.
The Pain We Actually Felt#
The table above sounds abstract. Here's what building on MCP v1 actually looked like when we ran hundreds of AI agents calling tools concurrently through a shared gateway.
Closing a connection shouldn't require 70 lines of workarounds. MCP v1 used Server-Sent Events (SSE) — a long-lived HTTP stream. Closing that stream cleanly in async Python was nearly impossible. The SDK's background reader task would throw errors like "generator didn't stop after athrow()" and "Attempted to exit cancel scope in a different task". We wrote a two-phase cleanup wrapper (force-kill the HTTP client first, then close the generator) just to disconnect without crashing.
A dropped connection meant starting over. In a REST API, if a request fails, you retry it. In MCP v1, a dropped stream meant the entire session was lost. We had to build exponential backoff, reachability pre-checks, and health monitoring — all because "retry the request" wasn't an option without rebuilding the session from scratch.
Error classification became its own subsystem. MCP v1 surfaced async cleanup noise and real failures the same way. We wrote functions that pattern-match against dozens of error strings ("cancel scope", "broken resource", "TaskGroup") just to tell them apart. Code that shouldn't need to exist.
Load balancing required sticky sessions. We wanted simple round-robin routing, but MCP v1 sessions are bound to whichever server handled initialize. Our gateway needed connection tracking, pools, and semaphores — all because the protocol said "this client belongs to this server."
None of these are "bad code" problems. They trace back to one decision: a stateful, bidirectional protocol in an environment where connections drop and concurrency is high. The 2026-07-28 spec eliminates most of them at the protocol level.
The Big Picture: Old vs New#
What Changed: The Highlights#
1. No Handshake, No Sessions#
The initialize / initialized exchange is gone. The Mcp-Session-Id header is gone. Each request now carries its protocol version, client identity, and capabilities inside _meta:
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"},
"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}
Any request can land on any server instance behind a plain round-robin load balancer. No sticky sessions, no shared session store.
But what if my server needs state? Mint a handle from a tool and have the model pass it back as an argument. This is better than hidden session state — the model can see the handle and thread it between tool calls.
2. Multi-Round-Trip Requests (MRTR)#
Real-world tools often can't complete in a single call. Think about a deployment tool that needs the user to confirm "Deploy to production? (yes/no)", or a database tool that asks "Which table do you mean — users or user_profiles?" In the old spec, the server handled this by pushing a request back to the client over a held-open bidirectional stream (e.g., sampling/createMessage or elicitation/create).
That design was fundamentally at odds with the stateless goal. If the server can push requests to the client at any time, both sides must maintain a persistent connection — which means session affinity, connection state, and all the scaling problems we just eliminated.
MRTR flips the direction. Instead of the server reaching back to the client, it simply returns a response that says "I need more information before I can finish." The client collects the answer from the user, then retries the same tool call with the answers attached. From the server's perspective, the retry is just another stateless request — it can land on any instance.
This matters because it means interactive tools (confirmations, disambiguation, multi-step wizards) work without any persistent connection. The server stays stateless, the client drives the conversation, and standard HTTP infrastructure handles the requests like any other API call.
3. Header-Based Routing#
In the old spec, the only way to know what a request was doing was to parse the JSON body and inspect method and params.name. That's fine for application code, but it's a problem for everything that sits in front of your application: load balancers, API gateways, rate limiters, WAFs, and observability proxies. These tools are designed to route and filter on HTTP headers — not to deserialize JSON-RPC payloads on every request.
The new spec requires every Streamable HTTP request to include Mcp-Method and Mcp-Name headers:
Mcp-Method: tools/call
Mcp-Name: search_books
Now your infrastructure can:
- Route specific tools to dedicated backend pools (e.g., send
code_executeto a sandboxed cluster) - Rate limit per tool name without touching the body (e.g., expensive tools get stricter limits)
- Authorize at the gateway layer (e.g., block
admin_deleteunless the caller has an admin scope) - Observe method and tool distributions in access logs, dashboards, and traces
This means MCP behaves like any well-designed HTTP API from the infrastructure perspective — no custom middleware needed to peek inside request bodies.
4. Cacheable List Results#
In the old spec, since there were no sessions anymore (and no guarantee you'd talk to the same server twice), clients had to call tools/list before every interaction to know what tools were available. For most servers, the tool catalog doesn't change between requests — it's the same 5 or 20 tools every time. That's a lot of redundant round-trips.
Worse, LLM providers use prompt caching to avoid re-processing identical prefixes. Every time a client re-fetches the tool list and injects it into the system prompt, even a tiny ordering difference invalidates the cache and costs extra tokens and latency.
The new spec adds ttlMs (how long the result is valid) and cacheScope (whether it's per-client or shared across all clients) to responses from tools/list, prompts/list, resources/list, and resources/read:
{
"tools": [...],
"_meta": {
"ttlMs": 300000,
"cacheScope": "global"
}
}
Now clients can:
- Skip redundant fetches — if the TTL hasn't expired, use the cached tool list
- Keep prompt caches stable — same tool list in the same order means LLM prompt caches stay warm across reconnects
- Reduce server load — servers that host thousands of clients aren't hammered with identical list requests
The spec also mandates deterministic ordering in list results, so even after a cache expires and the client re-fetches, the response is identical as long as nothing actually changed — preserving prompt cache hits.
5. Authorization Hardening#
Authorization has been the biggest pain point for MCP implementers. The spec uses OAuth 2.0, but the previous version left gaps that made real-world deployments fragile or insecure:
- Authorization server mix-up attacks: A malicious server could trick a client into sending its auth code to the wrong endpoint. Without validating who issued the code, the client couldn't detect this.
- Desktop/CLI apps rejected: OAuth servers often reject
localhostredirect URIs because the OAuth spec requires clients to declare theirapplication_type— but MCP didn't send it, so auth servers had to guess. - Credential reuse across servers: Nothing stopped a client from reusing credentials minted by one authorization server against a completely different one — a subtle but dangerous trust boundary violation.
- Dynamic Client Registration (DCR) complexity: DCR requires every auth server to accept registration from any client at runtime. This is hard to secure and even harder to audit in enterprise environments.
The new spec closes each of these:
OAuth and authorization changes
| Change | What It Means |
|---|---|
| RFC 9207 issuer validation | Clients must verify the iss parameter before redeeming an auth code, closing the mix-up attack vector. |
application_type in registration | Auth servers stop rejecting localhost redirects for desktop/CLI apps — the client correctly identifies itself. |
| Credentials bound to issuer | Credentials minted by server A cannot be replayed against server B. Enforced at the protocol level. |
| DCR deprecated → CIMD | Dynamic Client Registration is being replaced by Client ID Metadata Documents — a static, auditable alternative that doesn't require open registration endpoints. |
The net effect: if you're building an MCP server that connects to enterprise systems (databases, APIs, internal tools), the auth story is now closer to what security teams expect from production OAuth deployments. Fewer footguns, more alignment with the RFCs that auth libraries already implement.
6. Deprecations (with a 12-month runway)#
The spec now has a formal deprecation policy. These features are deprecated but still work for at least 12 months:
| Deprecated | Replacement |
|---|---|
| Roots | Pass as tool arguments or use extensions |
| Sampling (server-initiated) | Multi-Round-Trip Requests (MRTR) |
| Protocol-level Logging | Use your own logging; OpenTelemetry for tracing |
| Legacy HTTP+SSE transport | Streamable HTTP |
The Python SDK: v1 to v2#
The Python SDK was rebuilt alongside the protocol update. The biggest change you'll notice immediately: FastMCP is now MCPServer.
Before (v1) vs After (v2): A Complete Server#
Here's the same MCP server in both versions. This is the most common pattern — a decorator-based server with tools:
v1 (old):
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
v2 (new):
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
For simple decorator-based servers, the migration is mostly a rename. The decorators (@mcp.tool(), @mcp.resource(), @mcp.prompt()) work the same way.
The Client Got Simple Too#
The old client required three nested layers. The new one is a single object:
v1 (old):
from mcp import ClientSession
from mcp.client.stdio import stdio_client
# Three layers: transport → streams → session → manual initialize
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("search_books", {"query": "Dune"})
v2 (new):
from mcp import Client
# One object, auto-negotiates protocol version
async with Client(server_url) as client:
result = await client.call_tool("search_books", {"query": "Dune"})
print(client.server_capabilities)
print(client.protocol_version)
Client takes a server object (for testing), a URL (for remote), or any transport context manager. Protocol negotiation happens automatically.
Resolve: Asking Users for Input (Both Eras)#
Here's the problem: you have a tool that needs information from the user, not the model. The model can provide arguments like title="Dune", but something like "how many copies do you want?" is a question only the human can answer. In v1, you'd call ctx.elicit() to push a question to the client. But that only works over a live bidirectional stream — it fails on 2026-07-28 connections where there's no back-channel.
You could manually return an InputRequiredResult and handle the MRTR loop yourself, but then your tool only works with modern clients.
Resolve solves the compatibility problem. You annotate a parameter with Resolve(fn), where fn is a function that returns the question to ask. The SDK figures out the right mechanism based on the client's protocol version:
- Legacy client (2025-11-25): sends a live elicitation request over the open stream
- Modern client (2026-07-28): returns
inputRequiredand handles the multi-round-trip automatically
One tool body, both eras, zero branching logic:
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, ElicitationResult, Resolve, AcceptedElicitation
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: defines WHAT to ask (not how to deliver it)."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(
title: str,
quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]
) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
The key pieces:
Quantity— a Pydantic model defining the shape of the answer you expectask_quantity()— the resolver function; it returns what to ask, not how to deliver itResolve(ask_quantity)— tells the SDK this parameter comes from the user, not the modelElicitationResult[Quantity]— the result type, which is either anAcceptedElicitation(user answered) or a declined/cancelled state
The model never sees the quantity parameter — it's invisible in the tool's input schema. From the model's perspective, reserve only takes title. The SDK intercepts the call, asks the user, and injects the answer before your function runs.
This is the recommended pattern going forward. The older ctx.elicit() still works for legacy-only servers, but Resolve is the only approach that serves both protocol versions from a single tool definition.
Key Renames at a Glance#
Python SDK: v1 → v2 rename table
| v1 | v2 | Notes |
|---|---|---|
FastMCP | MCPServer | Class and module renamed |
mcp.server.fastmcp.* | mcp.server.mcpserver.* | Import path changed |
ctx.fastmcp | ctx.mcp_server | Context attribute renamed |
get_context() | Declare ctx: Context param | No more ambient lookup |
FastMCPError | MCPServerError | Exception base renamed |
McpError | MCPError(code, message, data) | New constructor signature |
| camelCase fields | snake_case fields | inputSchema → input_schema, isError → is_error |
httpx | httpx2 | HTTP client library swapped |
Backward Compatibility: Both Eras at Once#
The best part: you don't have to choose. A v2 server serves both protocol versions simultaneously — no flag, no separate deployment. A 2025-era client sends initialize and gets a session. A 2026-era client sends requests directly. Same server, same code.
Summary: What You Need to Do#
Migration checklist
| If you... | Then... |
|---|---|
Use @mcp.tool() decorators (most people) | Rename FastMCP → MCPServer, update imports, done |
Use the low-level Server API | Rewrite handlers to the new (ctx, params) -> result shape |
| Depend on session state | Mint explicit handles from tools instead |
Use ctx.elicit() or sampling | Switch to Resolve() for cross-era compatibility |
| Run behind a load balancer | Remove session affinity — round-robin works now |
Publish a library depending on mcp | Pin mcp>=1.28,<2 until you're ready to migrate |
Looking Forward#
MCP just crossed 1 billion total downloads across both the TypeScript and Python SDKs. The move to stateless makes it viable as real production infrastructure — you deploy it the same way you deploy any HTTP API. No special session stores, no sticky routing, no held-open streams.
The formal deprecation policy (12-month minimum window) means you can plan upgrades instead of reacting to surprise breakages. And with all four Tier 1 SDKs (TypeScript, Python, Go, C#) updated on the same day, the ecosystem is moving together.
If you're starting a new MCP server today, use the 2026-07-28 spec. If you have an existing one, you have at least 12 months before anything breaks. The SDKs handle both eras, so your server serves everyone regardless of which client version they're on.
Resources: