MCP 2026-07-28 removes something remote MCP servers have quietly depended on for a long time: transport-level sessions.
The initialize/initialized exchange and Mcp-Session-Id are gone. Each request now carries the protocol information needed to process it independently, while capability discovery is available through the optional server/discover RPC. A request can land on any server instance behind a normal load balancer without first finding the process that handled an earlier request.
If you're already running a remote MCP server, the migration itself is mostly mechanical. AAIF has already covered that part in its guide to migrating sessions to stateless requests with MCP 2026-07-28, including the SDK changes and compatibility options.
The more interesting question starts after the migration: Your application still has state. Where does it go, and what shape should it take?
The answer isn't to make everything stateless. It is to stop treating the transport as the place where application state lives.
Session state is a cache, not a source of truth
A useful rule for deciding where state belongs is this:
If losing it breaks correctness rather than costing you another round trip, it was in the wrong place.
It's also worth being precise about what MCP servers were actually keeping in sessions.
Most servers weren't putting application data directly into Mcp-Session-Id. The common pattern was to keep a StreamableHTTPServerTransport instance in memory, along with protocol bookkeeping, and use the session ID to find that transport when another request arrived. The SDK handled most of this for you.
Application state did sometimes get pinned to that connection. That's more likely with things that genuinely represent a live resource: a browser context, an open transaction, a subscription, or an event store used for resumability.
Those cases need to be handled differently from a value that someone simply hid behind a setter tool.
| What you were holding | What it actually is | Where it goes |
|---|---|---|
| Transport object | Protocol plumbing | Handled by the SDK |
| Value hidden behind a setter tool | A parameter | Request arguments |
| Cart, draft, cursor, transaction | Continuation state | Durable storage + an opaque handle |
| Job that outlives the request | Long-running work | Tasks |
This distinction matters for agents because the model can see a handle and pass it between tools. State hidden inside a transport is invisible to it.
A useful first step when migrating an existing server is to grep for every read of session state. That list is the real scope of the migration.
First, remove the state that isn't really state
Consider a server that lets an agent work with different environments.
A stateful design might expose:
select_environment("env_7f3a9c...")
tail_logs(100)The first call changes invisible context. The second call depends on whatever value happens to be there.
You could make that work with session affinity. You could put the value in Redis. Neither addresses the more basic problem: environmentId was a parameter all along.
A more explicit interface would first expose the available environments:
list_environments()
→ [
{ id: "env_7f3a9c...", name: "Production" },
{ id: "env_19b42d...", name: "Staging" }
]The model can then call:
tail_logs({
environmentId: "env_7f3a9c...",
lines: 100
})The model can't omit the environment, and the server can then validate that ID against the caller's allowed environments. More importantly, the tool call tells you which environment the operation will touch.
An audit log can record the ID and the server can echo the resolved name in its response:
Reading 100 lines from Production (env_7f3a9c...).An approval prompt can show the same information. A human reviewing the call doesn't have to reconstruct the environment from an earlier setter operation.
It also removes an ordering problem. Suppose two tool calls select different environments, or one call is retried after a timeout. With a hidden currentEnvironment, the result depends on which setter happened to win. Session affinity doesn't solve that. The race exists on a single replica.
An explicit parameter makes the dependency visible.
This is also why "delete the setter tools" is too broad a rule. A tool that creates a browser session, starts a deployment, or opens a transaction is doing real work. It should return a handle to that resource.
The better rule is:
A tool that only mutates invisible context isn't a useful interface for the state it is changing.
The state that really needs to persist: use a handle
Now consider something that genuinely needs continuity: a shopping cart.
Web applications learned this lesson years ago. A cart is an order in progress, not a connection detail. Keeping it only in server memory means a restart can lose it, and multiple application servers require affinity or shared session storage.
An MCP server has the same choice.
In practice, most servers kept the transport object in their session map, not their application data, and the SDK put it there for them. But the same pattern can apply to application state that was pinned to the connection, and a cart makes the design easy to see.
The examples below use simplified, v1-style server.tool(...) pseudocode to keep the state-design difference obvious; the current TypeScript SDK uses registerTool() and a different handler context. The important part here is the tool argument and state shape, not the surrounding HTTP registration.
A stateful version might look like this:
const carts = new Map<string, { items: CartItem[] }>();
server.tool(
"add_to_cart",
{ itemId: z.string(), quantity: z.number() },
async ({ itemId, quantity }, extra) => {
const sessionId = extra.sessionId;
if (!sessionId) {
return {
isError: true,
content: [{
type: "text",
text: "No active session."
}]
};
}
const cart = carts.get(sessionId) ?? { items: [] };
cart.items.push({ itemId, quantity });
carts.set(sessionId, cart);
return {
content: [{ type: "text", text: "Added to cart." }]
};
});
server.tool(
"checkout",
{},
async (_, extra) => {
const sessionId = extra.sessionId;
if (!sessionId) {
return {
isError: true,
content: [{
type: "text",
text: "No active session."
}]
};
}
const cart = carts.get(sessionId);
if (!cart) {
return {
isError: true,
content: [{
type: "text",
text: "Cart not found."
}]
};
}
await checkout(cart);
return {
content: [{ type: "text", text: "Order placed." }]
};
}
);This example is intentionally simple, but it illustrates the problem.
The most important issue isn't the Map.
It's the signature of checkout.
It takes no arguments.
Nothing in the tool call says which cart is being checked out. An audit log has to reconstruct that from earlier calls. An approval prompt has to do the same. A human reviewing the call sees an irreversible operation with no indication of what it will act on.
The cart also disappears when the process holding it disappears.
The durable version gives the cart its own identity:
server.tool(
"create_cart",
{},
async (_, { authInfo }) => {
const cartId = await carts.create(authInfo.subject);
return {
content: [{
type: "text",
text: `Cart created: ${cartId}`
}],
structuredContent: {
cartId
}
};
}
);
server.tool(
"add_to_cart",
{
cartId: z.string(),
itemId: z.string(),
quantity: z.number()
},
async ({ cartId, itemId, quantity }, { authInfo }) => {
const cart = await carts.load(cartId, authInfo.subject);
if (!cart) {
return {
isError: true,
content: [{
type: "text",
text: "Cart not found or unavailable."
}]
};
} }
await cart.add(itemId, quantity);
const summary = await cart.summary();
return {
content: [{
type: "text",
text: `Added ${quantity} item(s) to ${summary.name}. Cart now contains ${summary.itemCount} item(s).`
}],
structuredContent: {
cartId,
itemCount: summary.itemCount
}
};
}
);
server.tool(
"checkout",
{
cartId: z.string()
},
async ({ cartId }, { authInfo }) => {
const cart = await carts.load(cartId, authInfo.subject);
if (!cart) {
return {
isError: true,
content: [{
type: "text",
text: "Cart not found or unavailable."
}]
};
}
await checkout(cart);
return {
content: [{ type: "text", text: "Order placed." }],
structuredContent: {
cartId
}
};
}
);The owner is now part of both creation and lookup. The server creates the cart for a specific subject, and every subsequent operation resolves the cart within that subject's scope.
That isn't just an implementation detail. Without it, possession of a cart ID would be enough to operate on someone else's cart.
The other important change is that the cart ID is returned in structuredContent. The model shouldn't have to extract an identifier from a sentence when the identifier is already a distinct piece of state.
The pattern has three parts.
The cart lives in durable storage.
The server creates the identifier and gives it to the model. The model carries that identifier between calls.
And the tool that operates on the cart takes the identifier explicitly while the server resolves it within the current owner scope.
The obvious objection is: couldn't the model just pass a different cart ID?
It could. The session-based design didn't prevent that either. It just hid the state. The model could call a setter twice, two calls could race, and checkout() would still give you no indication of which state it was operating on.
With the handle-based design, the state involved in the operation is visible in the request.
There is also a less malicious version of the same problem. A user might have three open carts, and an agent could select the wrong one even without trying to access anything it shouldn't.
The solution is to make handles useful to the model rather than forcing it to reason over opaque IDs alone. A list_carts operation can return each cart's ID, human-readable label, and item count:
[
{ id: "cart_123", name: "Birthday gifts", itemCount: 3 },
{ id: "cart_456", name: "Office supplies", itemCount: 1 }
]The model can choose between "Birthday gifts" and "Office supplies" instead of two meaningless strings. Each mutation can also echo the cart's label and current item count, so a mistaken selection becomes visible immediately rather than only when the user reaches checkout.
If the cart can't be resolved, return that as a tool execution error with isError: true. Don't turn an application-level missing resource into an HTTP 404 or JSON-RPC error. Those belong to the transport and protocol layers and aren't delivered to the model in the same way.
The tool can also give the model a way to recover:
Cart not found or unavailable. Your open carts arecart_123(Birthday gifts, 3 items) andcart_456(Office supplies, 1 item).
There is another subtlety here. The response shouldn't reveal whether a cart exists but belongs to someone else. From the model's perspective, both cases can simply be "not found or unavailable."
The same pattern works for drafts, browser contexts, deployments, transactions, and other resources that need to survive across requests.
When a handle isn't enough
Explicit handles solve one problem: carrying state from one request to another.
Some interactions need a little more.
User input: MRTR
Consider checkout again. Before completing an irreversible operation, the server may need a user confirmation or approval gate.
In older MCP designs, this kind of server-initiated interaction depended on the live session. MCP 2026-07-28 introduces Multi Round-Trip Requests (MRTR) instead.
The server can return an input_required result describing what it needs. The client collects the input and retries the original request with the answers in inputResponses.
For the cart example, that means the user can confirm the specific line items and total represented by the cart handle. The server doesn't need to hold the original connection open while waiting.
This matters for stateless deployments in practice: elicitation is difficult to use precisely because servers run statelessly; MRTR provides the interaction model without bringing back a transport session.
The continuation is now part of the request flow rather than something hidden in a live connection.
Long-running work: Tasks
The same principle applies when the operation itself outlives the request.
A ten-minute export can be represented as:
start_export → { exportId: "exp_01H..." }
get_export_status(exportId)
→ { state: "running" | "done", url?: string }That shape is now standardized through the io.modelcontextprotocol/tasks extension. Tasks are opt-in: the client advertises the extension in its per-request capabilities, and the server decides per request whether the operation should return a task handle.
When used, the server can return a task handle, and the client can use the task operations to manage its lifecycle.
The important part is that the export has its own identity. The request that starts it doesn't need to remain alive until the work finishes.
Payment capture, deployments, document generation, imports, and other long-running operations can use the same model.
Notifications
Change notifications have also moved toward an explicit subscription model. subscriptions/listen provides a single stream that clients opt into by notification type, replacing the old HTTP GET endpoint.
Roots, Sampling, and Logging are deprecated in the 2026-07-28 release, with a minimum twelve-month transition window. New implementations should use the replacement patterns rather than introducing new dependencies on those deprecated capabilities.
What sticky sessions were hiding
Session affinity was an effective way to make process-local state work across multiple replicas.
It could also hide the underlying dependency for a while.
The first thing that breaks an in-memory session is a restart. The first thing that makes the problem repeatable is a second replica.
Under the old transport model, initialize could land on replica A. Every subsequent request carrying that session ID then had to find the same transport. With two replicas and round-robin routing, each subsequent request had a one-in-two chance of landing on the process that held the state.
The old transport rules also required a client receiving the session-expiry 404 for a request carrying Mcp-Session-Id to start a fresh session. A compliant client could therefore respond to a lost session by re-initializing, potentially losing whatever application state had been attached to the previous transport.
Clients that didn't implement that recovery behavior simply failed. There have been reported issues around this behavior in the Python SDK, rmcp/Codex, and LibreChat. FastMCP provides another useful example: after a restart, its in-memory session map can produce "No valid session ID provided" rather than the session-expiry 404 that would trigger the recovery path.
That makes the failure difficult to reason about. Depending on the client and SDK, the same underlying problem can show up as re-initialization, lost context, or a hard error.
The infrastructure costs are broader:
- Multiple replicas require affinity or a shared session store.
- Deployments and restarts can destroy process-local state.
- Serverless and edge runtimes don't provide a stable process for holding the map.
- Long-lived connections depend on load-balancer idle timeouts and CDN and corporate-proxy behavior.
- Idle sessions consume memory.
- Resumability requires event IDs and somewhere durable to replay missed events.
None of these are impossible to solve. They're simply infrastructure requirements created by keeping state at the transport layer.
A few other benefits follow from the same model
The 2026-07-28 changes also make MCP more amenable to ordinary HTTP infrastructure.
List and resource-read responses can carry ttlMs and cacheScope, allowing clients to cache results from operations such as tools/list, prompts/list, resources/list, and resources/read rather than treating each reconnect as a completely fresh interaction.
The request headers are useful for the same reason. Mcp-Method is required on Streamable HTTP requests, and requests that target a named tool, prompt, or resource also carry Mcp-Name. A gateway can inspect those headers without parsing the JSON-RPC body and make routing or rate-limiting decisions before the request reaches the application.
MCP is becoming a more conventional HTTP workload: routable, cacheable, and independent of the process that handled the previous request.
What happens to existing clients?
This isn't an all-at-once breaking change.
Protocol version selection is negotiated, and the ecosystem has a formal deprecation policy with a minimum twelve-month window. Legacy HTTP+SSE is also deprecated with a transition period.
The current TypeScript SDK's createMcpHandler() provides a legacy option. With legacy: "stateless", it can serve both 2025- and 2026-era requests, but legacy requests are handled through a stateless fallback. It does not recreate the old initialize/session flow. With legacy: "reject", older clients are refused with the unsupported-protocol-version error.
If you genuinely need to continue serving clients that depend on the old stateful behavior, you can run the old stateful handler alongside a modern handler and route between them.
That distinction is worth making because "nothing breaks" is too broad. Existing clients don't have to upgrade immediately, but the compatibility mode you choose determines whether they get the old session semantics or a stateless legacy fallback.
Where the state should live
Stateless MCP doesn't mean the application stops having state. Your database still needs to scale. Rate limits still matter. External APIs still fail. Tool implementations still have their own operational requirements.
What changes is where those concerns live.
- If something was really a parameter, pass it as a parameter.
- If a resource needs to survive across calls, give it a durable identity and let the model carry that handle.
- If work continues after the request finishes, give it a task identity.
- If the client needs to provide information later, use an explicit multi-round exchange rather than relying on a connection remaining alive.
The transport no longer needs to remember which process handled the previous request. That leaves the application responsible for deciding what state needs to survive and how the model gets back to it.
That is the useful design question after the move to stateless MCP: what state needs to survive, who owns it, and what does the model need to carry forward to reach it?
For many tools, the answer is a request parameter. For durable resources, it's an opaque handle. For long-running work, it's a task.
The state is still there. It just isn't hiding in the transport anymore.
Share
Author

Ravi Madabhushi
Ravi Madabhushi is co-founder and CTO of Scalekit, where he works on authentication and authorization infrastructure for agent and B2B SaaS applications. Prior to Scalekit, Ravi spent almost a decade building authorization platforms for B2B applications.



