Blog Post

Apps on Azure Blog
8 MIN READ

MCP Just Went Stateless — What the 2026 Spec Changes About Scaling on App Service

jordanselig's avatar
jordanselig
Icon for Microsoft rankMicrosoft
Jun 23, 2026

A couple of months ago I wrote about scaling MCP servers behind App Service's built-in load balancer. The trick back then was to lean on stateless HTTP transport so any instance could serve any request — and to make sure you turned off ARR affinity so the load balancer was actually free to spread traffic around.

That post still works. But the MCP spec just caught up to it in a big way.

The 2026-07-28 release candidate is the largest revision of the Model Context Protocol since it launched, and the headline change is exactly the thing we were working around: MCP is now stateless at the protocol layer. The handshake is gone, the session header is gone, and the sticky-routing-and-shared-session-store dance that horizontal deployments used to need is no longer part of the protocol at all.

If you're hosting an MCP server on App Service, this is good news — and it means a few of the steps from my last post are now things the protocol does for you. Here's what changed, and what (if anything) you need to do about it.

Here's the before and after, straight from the spec. In 2025-11-25, the client POSTs an initialize call to /mcp first and gets a session ID back:

{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-11-25","capabilities":{},
 "clientInfo":{"name":"my-app","version":"1.0"}}}

Heads up on timing: 2026-07-28 is a release candidate as I write this; the final spec ships July 28, 2026. It contains breaking changes, so treat this as "get ready" guidance rather than "rip everything out today."

Quick recap: how we scaled MCP before

In the original post, the recipe looked like this:

  1. Run the MCP server in stateless HTTP mode (the 2025-11-25 transport).
  2. Scale App Service out to N instances (the sample used three).
  3. Set clientAffinityEnabled: false so there's no ARR affinity cookie pinning a client to one instance.
  4. If you genuinely needed cross-request state, externalize it — typically into Azure Cache for Redis — so every instance saw the same data.
  5. Watch traffic spread across instances in Application Insights via cloud_RoleInstance.

The catch: even in "stateless HTTP" mode, the 2025-11-25 protocol still started every connection with an initialize handshake and handed back an Mcp-Session-Id that the client had to send on every follow-up request. That session ID pinned a client to whichever instance issued it — so to scale cleanly you either kept affinity on (and gave up even load balancing) or did real work to share session state across instances.

That's the part the 2026 spec deletes.

What the 2026 spec actually changes

The handshake and the session are gone

Two proposals do the heavy lifting:

  • SEP-2575 removes the initialize / initialized handshake. The protocol version, client info, and client capabilities that used to be exchanged once at connect time now ride along in _meta on every request. A new server/discover method lets a client ask for server capabilities when it actually wants them.
  • SEP-2567 removes the Mcp-Session-Id header and the protocol-level session that came with it.

With both gone, any MCP request can land on any instance. The sticky routing and shared session stores that horizontal deployments needed before just aren't required at the protocol layer anymore.

Here's the before and after, straight from the spec. In 2025-11-25, the client POSTs an initialize call to /mcp first and gets a session ID back:

{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-11-25","capabilities":{},
 "clientInfo":{"name":"my-app","version":"1.0"}}}

…then every later call has to carry the Mcp-Session-Id header the server handed back, which pins it to that instance:

{"jsonrpc":"2.0","id":2,"method":"tools/call",
 "params":{"name":"search","arguments":{"q":"otters"}}}

In 2026-07-28, the same tool call is one self-contained request that any instance can answer. The routing info rides in headers — MCP-Protocol-Version, Mcp-Method, and Mcp-Name — and the body carries everything else:

{"jsonrpc":"2.0","id":1,"method":"tools/call",
 "params":{"name":"search","arguments":{"q":"otters"},
 "_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}

No handshake, no session ID, nothing to pin.

Traffic you can route and cache at the edge

A few smaller changes make this traffic much friendlier to the infrastructure App Service already gives you:

  • Routable headers (SEP-2243): Streamable HTTP now requires Mcp-Method and Mcp-Name headers, so load balancers, gateways, and rate-limiters can route or throttle on the operation without cracking open the request body. (Servers reject requests where the headers and body disagree.)
  • Cacheable lists (SEP-2549): tools/list and resource-read results now carry ttlMs and cacheScope, modeled on HTTP Cache-Control. Clients know exactly how long a tool list is fresh and whether it's safe to share across users — no more holding an SSE stream open just to learn the list changed.
  • Traceable calls (SEP-414): W3C Trace Context (traceparent, tracestate, baggage) propagation in _meta is now documented with fixed key names. A trace that starts in the host app can follow a tool call through the client SDK, your MCP server, and whatever it calls downstream — and show up as one span tree in any OpenTelemetry backend, including Application Insights.

That last one pairs really nicely with the App Insights setup from the original sample, which already tags spans with cloud_RoleInstance.

Why this is easier on App Service now

App Service's built-in load balancer has always wanted to round-robin your requests. The thing stopping it from doing that cleanly with MCP was the protocol's own session affinity. Now that the protocol is stateless:

  • No affinity tuning to reason about. You still want clientAffinityEnabled: false, but there's no longer a protocol session fighting it.
  • Any instance serves any request, for real. Scale from 3 to 10 instances and the load balancer just spreads the work — no shared session store required for protocol state.
  • Less Redis glue. In the old model, Redis was often there to share protocol session state. That reason is gone (see the next section for what Redis is still great for).

"Stateless protocol" doesn't mean "stateless app"

This is the part I want to be really clear about, because it's easy to over-read the headline.

Removing the protocol session does not mean your application can't have state. It means the protocol stops carrying state for you. If your server needs to remember something across calls, you do what HTTP APIs have always done: mint an explicit handle and let the model pass it back as an argument.

The spec calls this the explicit-handle pattern. A tool returns a basket_id (or browser_id, or whatever), and later calls include that ID as a normal parameter:

// 1) create returns a handle
{"name": "create_basket", "arguments": {}}
// -> { "basket_id": "b_12345" }

// 2) later calls pass it back as an ordinary argument
{"name": "add_item", "arguments": {"basket_id": "b_12345", "sku": "ABC"}}

The nice side effect: the model can see the handle, compose it across tools, and hand it off between steps — in ways that session state hidden in transport metadata never really allowed.

So where does Redis fit now? Exactly where it always belonged — your application's data, not the protocol's plumbing:

  • Backing store for those explicit handles (what's actually in basket b_12345).
  • Caching expensive lookups or model responses across instances.
  • App-level conversation memory or rate-limit counters.

Stateless protocol, stateful application. You externalize state because your app needs it shared, not because the transport forces you to.

Migrating an existing MCP server on App Service

If you deployed the original sample (or something like it), here's the punch list to get to the 2026 model. The good news: the App Service / infra side barely changes — most of the work is in the protocol layer your SDK handles for you.

App Service config — mostly already done:

  • Keep clientAffinityEnabled: false. (Still the right call.)
  • Keep scaling out to N instances. Nothing here changes.
  • Keep Application Insights + OpenTelemetry — and lean into the new Trace Context key names for cleaner end-to-end traces.

Protocol layer — the real work:

  • Update to an SDK build that speaks 2026-07-28. The handshake and session handling go away; your server reads protocol version and client info from _meta per request instead of from an initialize exchange.
  • Emit ttlMs / cacheScope on tools/list and resource reads so clients (and your gateway) can cache them.
  • Make sure your server honors / validates the Mcp-Method and Mcp-Name headers.
  • If you were storing anything keyed off Mcp-Session-Id, move it to the explicit-handle pattern (handle in, handle out, state in Redis/Cosmos/etc.).
  • Audit for the breaking bits: tasks/list is removed, Roots/Sampling/Logging are deprecated, and the "resource not found" error code moves from -32002 to the standard -32602.

I built a standalone companion sample for exactly this — the 2026-07-28 version of the original, with the handshake gone, everything read from _meta, server/discover implemented, and the explicit-handle pattern shown in a real tool. Link below.

Try it yourself

I built a companion sample for this post: a FastAPI MCP server that speaks 2026-07-28 natively — no handshake, no session — running on three App Service instances behind the built-in load balancer, with a staging slot, App Insights, a spec-compliant client, and a k6 load test:

👉 seligj95/app-service-mcp-stateless-scale-2026-python

azd auth login
azd up

That provisions a Premium v3 plan with capacity: 3, the web app with clientAffinityEnabled: false, a staging slot, and Log Analytics + Application Insights. No initialize, no Mcp-Session-Id anywhere — discovery is a single server/discover call, and every request carries its own protocol version and client info in _meta.

The part I like best is the tally tool. It keeps a running total across calls using an explicit, signed handle instead of a session — so you can watch the total stay correct even as the load balancer routes each call to a different instance:

+10  -> total=10   served_by=2103650c...
+5   -> total=15   served_by=08fc7022...   (different instance, total still right)
+100 -> total=115  served_by=08fc7022...

That's the stateless handle pattern from earlier, made concrete: state travels with the request, not the connection. Then watch the load spread in Application Insights:

requests
| where timestamp > ago(15m)
| where name contains "/mcp"
| summarize count() by cloud_RoleInstance

Want the 2025-11-25 version for comparison? That's the original Part 1 sample: seligj95/app-service-mcp-stateless-scale-python. Diff the two main.py files and you can see the handshake and session handling simply disappear.

The takeaway

When I wrote the first post, "make MCP stateless so App Service can load-balance it" was a pattern you had to apply. With the 2026 spec, it's just how MCP works. The protocol deleted the exact friction we were routing around — which means hosting a horizontally scaled MCP server on App Service is now closer to "deploy a normal web app and scale it out" than ever.

If you're already running MCP on App Service: you did the hard part early. The spec just made it official.


Got an MCP server running on App Service? I'd love to hear how the migration goes — drop a comment.

Updated Jun 23, 2026
Version 2.0
No CommentsBe the first to comment