On April 14, 2026, AWS published a reference for running MCP servers on ECS: a Gradio UI, a Strands agent on Bedrock's Nova 2 Lite, and a FastMCP server behind ECS Service Connect, deployable from the sample repo in about 30–40 minutes. One sentence in that post carries more production wisdom than the whole architecture diagram: the MCP server runs Streamable HTTP "in stateless mode. Each tool call is a self-contained HTTP request with no server-side session state," so it "scales horizontally across multiple replicas without session affinity."
Three months later, the protocol agreed with them permanently. The MCP spec revision 2026-07-28 removed protocol-level sessions entirely — no Mcp-Session-Id, no standalone GET stream, no Last-Event-ID resumability. Stateless stopped being a deployment option and became the wire shape.
So here is the tradeoff, settled up front, mapped onto a deploy/rollback agent — the kind of privileged infrastructure agent a self-hosted PaaS actually runs:
| Tool | Any replica OK? | Where its state lives | Idempotency mechanism |
|---|---|---|---|
get_status, list_services | Yes — pure reads | None (reads live infra state) | None needed |
deploy | Yes — if keyed | Job record in a shared store, keyed by idempotency key | Client-supplied key; same key returns the same job |
rollback | Yes — if keyed | Job record + a single-flight lease per service | Key + lease; second caller attaches, never double-rolls |
approve_deploy | Yes | Approval flag on the job record | One approval per job ID; replays are no-ops |
cancel_job | Yes | Job record state transition | Terminal states are sticky; cancel-after-finish is a no-op |
And the worked retry that makes the table real: the agent POSTs deploy with key dep-9f31, the response is lost to a timeout, and the agent retries with the same key. Any replica can answer, because the key — not a session — identifies the work. The replica finds job dep-9f31 already running and returns its handle instead of starting a second rollout. No sticky routing, no shared session table, no duplicate deploy.
The rest of this post earns each row.
What stateless Streamable HTTP actually means
Streamable HTTP has been MCP's remote transport since spec revision 2025-03-26, when it replaced the two-endpoint HTTP+SSE design with a single /mcp endpoint. From the start, sessions were optional: a server MAY assign an Mcp-Session-Id at initialization, and a server that never mints one is fully spec-compliant. Every client message is its own POST; the response is one JSON object or an SSE stream scoped to that request.
In FastMCP, the Python framework behind the AWS reference, stateless is a constructor flag:
from fastmcp import FastMCP
mcp = FastMCP("deploy-agent", stateless_http=True)With stateless_http=True, each request gets a fresh transport context: no session table is consulted, no Mcp-Session-Id is issued, and nothing about request N is assumed when request N+1 arrives. The deployment docs recommend exactly this for horizontally scaled setups. The operational payoff is that the load balancer needs no opinions at all — plain round-robin across replicas works, because every request is self-contained. That is the property the AWS reference leans on: scale the FastMCP task count up and down, and no tool call ever cares which replica answers.
This is also why the 2026-07-28 revision could delete sessions without breaking the model. Under the new shape, a server receiving an Mcp-Session-Id header simply ignores it, and GET or DELETE to /mcp get 405 Method Not Allowed. If you deploy stateless today, you are already speaking the current spec.
What breaks when you go stateful
Stateful mode — minting an Mcp-Session-Id at initialize and validating it on every later call — looks harmless with one replica. It fails in three documented ways the moment production conditions apply:
| Failure mode | Trigger | Symptom | Real case |
|---|---|---|---|
| Session lands on the wrong replica | Replicas ≥ 2 behind a non-sticky load balancer | 404 / "Session terminated" on random calls | Altinity's MCP handler switched to stateless after exactly this; same report in af-mcp-platform#128 |
| Session table lost on restart | Rolling deploy, crash, or platform recycle | Every client holds a dead ID; some clients hang on the 404 instead of re-initializing | FastMCP's default in-memory table wiped by an App Service recycle in canvas-mcp#160; dead-ID caching in openclaw#112540 |
| Termination hits a stranger | Client DELETEs a session on a replica that never owned it | 404 on teardown; leaked server-side state | The trailing Session termination failed: 404 in af-mcp-platform#128 |
Note the shape of these failures: none of them is a bug in any one component. The spec behavior is even correct — unknown session, return 404, client re-initializes. The problem is architectural. A session ID is routing state that must rendezvous with the exact process that minted it, and everything production does to processes — scaling, rescheduling, rolling, recycling — breaks the rendezvous.
Fixing it means sticky sessions (which defeat the load balancer you scaled for) or a shared session store (which reintroduces the single stateful dependency you were avoiding). Stateless deletes the dilemma instead of solving it.
There is a mirror-image failure worth naming too: clients that re-initialize before every tool call, minting a fresh session per request (seen in the wild). Session-scoped state never persists, and the server accumulates orphaned sessions. Stateful transport punishes both the clients that remember too much and the ones that remember too little.
The cases that used to need Mcp-Session-Id — and where that state lives now
This is the second half of the title, answered honestly. Under the 2025-03-26 through 2025-11-25 spec shape, stateful mode existed for real needs: multi-step workflows with server-side context, long-lived streaming, server-initiated follow-ups. A deploy agent genuinely has all three — a rollout is build, push, deploy, verify, with progress worth streaming and approvals worth pausing for. The question was never whether the work has state. It is where that state should live.
A transport session was always the wrong container: it ties durable workflow state to an ephemeral process-to-process rendezvous, which is precisely what the failure table above punishes.
Each need has a durable home that survives any replica answering:
| The stateful need | Old container (Mcp-Session-Id) | Durable home now |
|---|---|---|
| Multi-step workflow context (which step is the rollout on?) | Session-scoped server memory | Explicit job record in a shared store, addressed by job ID |
| Retry context ("resume where I left off") | Session + Last-Event-ID replay | Server-encoded opaque requestState the client echoes on retry (the spec's multi-round-trip pattern) |
| Progress streaming | Standalone GET SSE stream on the session | Response stream scoped to the request, or an opt-in subscription the client owns |
| Approval pauses ("wait for a human") | Held-open session | Job record in awaiting_approval; approve_deploy(job_id) resumes from any replica |
One timeline note, because it reconciles the AWS reference with the current spec: AWS's April post predates the July 2026-07-28 revision, so it still describes stateful mode as the path for "multi-step workflows with server-side context." The spec's removal of sessions three months later did not contradict that post — it ratified its main decision. The workflows still have server-side context; the context just lives behind explicit handles now, not behind a transport session.
Worked reference: the deploy/rollback toolset
Here is the pattern as tools. Three privileged operations, all safe on any replica, all safe to retry:
@mcp.tool
def deploy(service: str, image: str, idempotency_key: str) -> dict:
"""Start a rollout. Same key twice returns the same job, never two rollouts."""
job = store.find_or_create(
key=idempotency_key,
factory=lambda: Job(service=service, image=image, state="queued"),
)
worker.enqueue(job.id) # enqueue is itself idempotent on job.id
return {"job_id": job.id, "state": job.state}
@mcp.tool
def rollback(service: str, idempotency_key: str) -> dict:
"""Roll back to the last healthy release. Single-flight per service."""
lease = store.acquire_lease(f"rollout:{service}", ttl_seconds=600)
if lease is None:
return {"state": "already_running", "job_id": store.active_job(service)}
job = store.find_or_create(key=idempotency_key,
factory=lambda: Job(service=service, state="queued"))
worker.enqueue(job.id)
return {"job_id": job.id, "state": job.state}
@mcp.tool
def job_status(job_id: str) -> dict:
"""Pure read. Any replica, no key, no lease."""
return store.get(job_id).to_dict()Three design decisions do all the work. First, the idempotency key comes from the client — the agent mints one key per intended real-world effect and reuses it across retries, so "retry" and "duplicate" are distinguishable by construction. Second, the single-flight lease makes rollback safe against two agents (or one agent plus one panicking human) racing: the loser attaches to the running job instead of starting a competing rollout. Third, the job record is the only shared state, and it lives in a store every replica already reaches — Postgres, Redis, etcd — not in any replica's memory.
To feel the difference hands-on, mirror the AWS walkthrough's 30–40 minute shape with a chaos step it never needed:
- Minutes 0–10: Clone the sample repo and run the FastMCP server locally with
stateless_http=True. List tools, call one. - Minutes 10–20: Put three replicas behind plain round-robin (three containers, one load balancer, no stickiness). Confirm every call succeeds regardless of target.
- Minutes 20–30: Add a
deploy-shaped tool backed by a shared store with an idempotency key, as above. POST it, kill the replica mid-call, retry with the same key against a survivor. One job, not two. - Minutes 30–40: Flip the server to stateful sessions, repeat the kill, and watch the 404s. Then flip it back — you have now reproduced both the AWS lesson and the spec's reason for deleting sessions.
Step 4 is the only step that takes convincing, because it fails theatrically. That is the point: the failure is load-bearing evidence, not a configuration mistake.
Security: sessions were routing state wearing an authentication costume
The spec was always careful here — session IDs had to be globally unique, cryptographically secure, and visible-ASCII — but randomness is not authorization. A session ID is a bearer token for a conversation: whoever presents it inherits the session's context, with no per-request proof of who they are. Stateful servers therefore inherit a small catalog of session liabilities: fixation (an attacker plants a known ID), hijacking (an ID leaks through logs or a proxy and becomes reusable), and cross-principal confusion when one ID's context bleeds into another caller's request. Binding IDs to principals and scopes helps, but it is application code bolted onto transport state.
Stateless inverts the default. With no session to inherit, every request must carry its own credentials — a signed token, validated per call — so there is nothing to fixate, hijack, or confuse. The OWASP guidance for MCP transport says the quiet part plainly: sessions are routing state, not authentication. Deleting the routing state deletes the temptation to treat it as the other thing.
Two baseline controls stay mandatory either way, and the spec marks them MUST: validate the Origin header on Streamable HTTP endpoints (DNS rebinding lets a malicious web page reach a local server otherwise — answer mismatches with 403), and authenticate remote connections rather than trusting network locality. Stateless removes the session layer; it does not remove the perimeter.
Production checklist
If you run MCP servers that agents depend on — especially agents allowed to touch infrastructure — the settled advice fits on an index card:
- Run stateless.
stateless_http=True(or your framework's equivalent); noMcp-Session-Id, no affinity, no shared session store. - Put workflow state behind explicit handles. Job records in a store every replica reaches; never in replica memory.
- Make every privileged tool idempotent. Client-supplied keys, find-or-create semantics, sticky terminal states.
- Single-flight the dangerous ones. A lease per service for rollouts and rollbacks; losers attach, never duplicate.
- Authenticate per request. Signed tokens validated on every call; sessions are routing state, not identity.
- Keep the perimeter controls.
Originvalidation, TLS, authenticated remote access — the transport change exempts none of it.
The arc of this story is short: AWS demonstrated in April that stateless MCP servers scale cleanly behind ordinary load balancing, production incidents spent the spring proving that stateful sessions break exactly where scaling matters, and the July spec revision deleted the stateful option from the wire. The deploy agent never needed session affinity. It needed durable handles and idempotent tools — and those were always application state, not transport state.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a Render-compatible API your deploy agents can drive as ordinary stateless tools. Star the repo on GitHub or self-host your first MCP-backed deploy agent today.



