Skip to main content

MCP Streamable HTTP in Production: When Stateless Tool Calls Scale Cleanly and When a Deploy Agent Needs Session Affinity

10 min readDora NodaDora Noda
Share
On this page

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:

ToolAny replica OK?Where its state livesIdempotency mechanism
get_status, list_servicesYes — pure readsNone (reads live infra state)None needed
deployYes — if keyedJob record in a shared store, keyed by idempotency keyClient-supplied key; same key returns the same job
rollbackYes — if keyedJob record + a single-flight lease per serviceKey + lease; second caller attaches, never double-rolls
approve_deployYesApproval flag on the job recordOne approval per job ID; replays are no-ops
cancel_jobYesJob record state transitionTerminal 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:

python
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 modeTriggerSymptomReal case
Session lands on the wrong replicaReplicas ≥ 2 behind a non-sticky load balancer404 / "Session terminated" on random callsAltinity's MCP handler switched to stateless after exactly this; same report in af-mcp-platform#128
Session table lost on restartRolling deploy, crash, or platform recycleEvery client holds a dead ID; some clients hang on the 404 instead of re-initializingFastMCP's default in-memory table wiped by an App Service recycle in canvas-mcp#160; dead-ID caching in openclaw#112540
Termination hits a strangerClient DELETEs a session on a replica that never owned it404 on teardown; leaked server-side stateThe 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 needOld container (Mcp-Session-Id)Durable home now
Multi-step workflow context (which step is the rollout on?)Session-scoped server memoryExplicit job record in a shared store, addressed by job ID
Retry context ("resume where I left off")Session + Last-Event-ID replayServer-encoded opaque requestState the client echoes on retry (the spec's multi-round-trip pattern)
Progress streamingStandalone GET SSE stream on the sessionResponse stream scoped to the request, or an opt-in subscription the client owns
Approval pauses ("wait for a human")Held-open sessionJob 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:

python
@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:

  1. Minutes 0–10: Clone the sample repo and run the FastMCP server locally with stateless_http=True. List tools, call one.
  2. Minutes 10–20: Put three replicas behind plain round-robin (three containers, one load balancer, no stickiness). Confirm every call succeeds regardless of target.
  3. 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.
  4. 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); no Mcp-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. Origin validation, 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.

Related articles

Give your agents a chain backend

Autonomous agents hit RPC endpoints very differently than people do. See what bex router handles on their behalf.

Read the agents guide