mcp-stateless
Demonstrates migrating an MCP server to the stateless protocol, eliminating session management overhead and enabling seamless scaling behind round-robin load balancers.
README
MCP goes stateless — a live before/after demo
A working demonstration of the change introduced in the Model Context Protocol
2026-07-28 release candidate: MCP becomes stateless at the protocol layer. The
initialize handshake and the Mcp-Session-Id header are gone, so a server that used to
need sticky routing + a shared session store to scale can now run behind a plain
round-robin load balancer — any request can land on any instance.
This repo shows that shift two ways: an interactive diagram you can open in a browser, and a live demo that drives real MCP servers on Google Cloud Run and proves the behavior with the platform's own logs.

<Agent> → <Sticky gateway> → <Session store> → <instances> → <Postgres>— everything in amber is the operational tax the old protocol forced on you. The new protocol deletes it.
The thesis
You build an MCP server. It works on your laptop. Then you deploy it for 50,000 users.
Every agent conversation is a session. Under the old protocol the server minted a session
id on initialize, and that session lived in one instance's memory. Put two instances
behind a load balancer and the agent's next tool call lands on the other one — "Session not
found." To cope you bolt on sticky routing and a shared session store, and when a pod
recycles, live agents drop mid-task.
The 2026-07-28 protocol makes MCP stateless: state moves into an explicit token the
client carries as a tool argument (cart_token), so any instance can serve any request. The
migration is two lines — because the app state was already explicit; the protocol was just
forcing a session on top of it.
This demo uses a tiny shopping-cart MCP server (create_cart / add_item / get_cart) —
exactly the shape of a real agentic-commerce server (Shopify, Stripe, and others ship remote
MCP servers today). The tools never change between "before" and "after"; only the protocol flag
and the client mode do.
What's in the box
| Surface | What it is | How to view |
|---|---|---|
| Interactive diagram | Self-contained HTML — Narrative / Before / After / The Change / At Scale / Request Flow tabs, a Technical⇄Plain toggle, light/dark + fullscreen | Open MCP-STATELESS-DEMO.html in any browser (no build, no network) |
| Code map | Self-contained HTML — an architecture diagram of src/mcp_stateless_demo (Modules / Tool call / Log proof tabs) for ramping on the code |
Open mcp-stateless_architecture.html in any browser |
| Live demo | A Gradio app that drives real MCP servers on Cloud Run and shows live results + real Cloud Run logs | Run locally (below) or deploy your own |
The demo, beat by beat
The live demo walks a guided story with the same three tools each time:
- ① Scale it — round-robin over two instances → "Session not found." The break, proven by real
404s across two instance IDs in the logs. - ② Add the tax — turn on sticky routing (+ a session store in the diagram) → it works, but you now own a session-aware gateway and an external store.
- ③ Go stateless — flip the protocol → every request succeeds across instances, nothing extra to own.
- ④ Prove it at scale — blast 60 concurrent agents at a real autoscaling service → Cloud Run fans out to N instances, every request green, proven by the platform's own instance IDs in the logs.
💥 Recycle a pod is the two-sided probe you run after ② or ③: it recycles the pod holding your live agent session, then takes one more turn on that same session. After ② the pinned conversation drops mid-task; after ③ the same recycle is a non-event — a surviving instance carries the session on. Same disruptive action, the protocol decides the outcome.

The change — it's two lines
# server
- streamable_http_app(stateless_http=False)
+ streamable_http_app(stateless_http=True)
# client
- Client(url, mode="legacy")
+ Client(url, mode="auto")
The tool bodies are byte-identical across both modes. The cart always lives in Postgres,
addressed by a signed, opaque cart_token the client passes back — that's why the migration
is so small.
How it works
The demo runs as several small services so a load balancer can genuinely round-robin across distinct process memories:
flowchart LR
A["🧑💻 Agent<br/>(the Gradio UI drives it)"] --> P["Proxy load balancer<br/>round-robin · sticky · kill/revive"]
P --> L["legacy-a / legacy-b<br/>stateless_http = False"]
P --> M["modern-a / modern-b<br/>stateless_http = True"]
A -. "beat ④ blast" .-> S["scale service<br/>autoscaling, concurrency=1"]
L --> DB[("Supabase<br/>Postgres")]
M --> DB
S --> DB
server/— aMCPServerexposing the three cart tools. The entire per-act difference is one flag (stateless_http); everything else is constant.proxy/— a deterministic reverse proxy: plain round-robin, or learned sticky affinity (session-id → instance), plus/kill+/revivefor the recycle beat. This is the "load balancer / session-aware gateway" the audience watches.cart/— the domain: thecart_tokencodec (HMAC-signed handle), the storeProtocol, and the Postgres store.client/— theActRunnerthat drives scripted acts (and the 60-way blast) through the proxy, plus a heldConversationthat keeps one live agent session open across the recycle beat (so recycling its pod really drops it under sticky, or really doesn't under stateless). Both return structured rows.cloud/— reads real Cloud Run stdout logs so the UI can prove an event happened on real infrastructure.ui/— the Gradio app (thin wiring) and the pure HTML panel renderers.
App state lives in Postgres (it survives instance churn); the client carries only a signed
reference to a row. Row Level Security / anon keys aren't involved — the server connects
directly with asyncpg.
Anatomy of a tool call
Trace one request end-to-end and the codebase falls into place. Here's add_item:
sequenceDiagram
participant A as Agent · client/runner.py
participant P as Proxy · proxy/app.py
participant S as MCP server · server/tools.py
participant T as Token · cart/token.py
participant DB as Postgres · cart/store_postgres.py
A->>P: POST /mcp — tools/call add_item {cart_token, name, qty}
P->>S: forward (round-robin / sticky, reads mcp-session-id)
S->>T: codec.decode(cart_token) → verify HMAC → cart_id
S->>DB: update carts set items = items || $2 where id = cart_id
DB-->>S: updated items
S-->>A: {served_by, cart_token, items}
- Agent —
client/runner.pyopensClient(url, mode=…)and callsclient.call_tool("add_item", {…}); the MCP SDK serializes a JSON-RPCtools/calland POSTs it over streamable HTTP. In stateless mode there's noinitialize/session-id handshake. - Proxy —
proxy/app.pyreads one header (mcp-session-id),state.pick()s an instance (round-robin or learned sticky), and forwards the raw bytes. - Server —
server/tools.pyruns the@server.tool()function. Withstateless_http=Falsethe transport first checks the session belongs to this instance (a404 "Session not found"otherwise — that's the "before" break, before your tool even runs). - Token —
cart/token.pyre-signs the id andcompare_digests it, then returns thecart_id(a client can't forge one it wasn't given). - Store —
cart/store_postgres.pyappends the item with a jsonb||and returns the updated cart.
create_cart is the same path minus the decode: it inserts a row (store.create()) and returns codec.encode(cart_id) — the signed handle the client carries into every later call.
Why this shape matters: add_item needs nothing from server memory — the cart_token + Postgres hold all the state, so any instance can serve any call. That's the explicit-handle pattern, and it's why flipping stateless_http is a two-line change rather than a rewrite. (The interactive diagram's Request Flow tab walks this same sequence visually.)
Prerequisites
| To run… | You need |
|---|---|
| The two diagrams | Just a browser — both .html files are self-contained. |
| The live demo locally | Docker and a Postgres database — Supabase's free tier is what this repo assumes. |
| The dev tooling / smoke test | uv and Python 3.11+. |
| The full demo (real autoscale + live logs) | A Google Cloud project with billing and the gcloud CLI — see Deploy to Cloud Run. |
Run it locally
# 1. configure
cp .env.example .env
# fill DATABASE_URL (Supabase session-pooler URI, port 5432) and TOKEN_SECRET:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# 2. create the table
psql "$DATABASE_URL" -f deploy/db/schema.sql # or paste it into the Supabase SQL editor
# 3. run the whole stack (2 legacy + 2 modern servers, the proxy, the UI)
docker compose up --build
Then open http://localhost:7860 and walk the beats.
Want a headless sanity check first? uv run python scripts/smoke.py spins up a
2-instance cluster in-process against your database and asserts the thesis
(BEFORE = all-red, AFTER = all-green) — the fastest way to confirm your DATABASE_URL works.
What runs locally vs. what needs Cloud Run.
docker compose upis a real multi-instance stack, so beats ①–③ and the 💥 Recycle probe — the scale break, the sticky tax, the recycle (drop under sticky and survive under stateless), and going stateless — all work locally against distinct process memories. Beat ④ (the real autoscale blast) and the live Cloud Run log panels need a deployed autoscaling service and log access; locally they degrade to a friendly "no logs" placeholder. To present the full story, including the autoscale proof, deploy to Cloud Run first.
Presenting the demo
Put two things on screen: the live demo (your local or deployed UI) and the
interactive diagram (MCP-STATELESS-DEMO.html) in a second
window. Click a beat in the app, then switch the diagram to the matching tab so the
audience watches the architecture change as the behavior changes.
| Beat (button) | Diagram tab | The line to land |
|---|---|---|
| ① Scale it | Before | Round-robin + the old session protocol = Session not found — proven by real 404s across two instance IDs. |
| ② Add the tax | Before | Sticky routing fixes it, but now you own a session-aware gateway and a shared store. |
| ③ Go stateless | The Change → After | Flip one flag; every request succeeds across instances, nothing extra to own. |
| ④ Prove it at scale | At Scale | 60 concurrent agents at a real autoscaling service — Cloud Run fans out to N instances, all green, proven by the platform's own instance IDs. |
| 💥 Recycle a pod (after ②, then after ③) | Before, then After | Same action, opposite outcome — after ② the pinned session drops (red rows); after ③ the same recycle survives (green, on another instance). The protocol decides. |
Two buttons help you run it live: ↻ Refresh logs re-pulls the Cloud Run logs (they lag
a few seconds behind a burst), and ↺ Reset returns the stack to a clean opening state
between runs. If a technical attendee wants the code-level view, open the diagram's
Request Flow tab or the separate code map
(mcp-stateless_architecture.html).
Development
uv sync --extra dev
uv run pytest # tests
uv run ruff check src tests # lint
uv run mypy --strict src/mcp_stateless_demo/cart src/mcp_stateless_demo/client # strict types on the load-bearing modules
Deploy to Cloud Run
The full demo — the real autoscale blast (beat ④) and the live Cloud Run log panels —
runs on Cloud Run. One Dockerfile builds a single image; each service picks its role
from its command + env vars. Set your project and region once:
export PROJECT=$(gcloud config get-value project)
export REGION=us-central1
export IMAGE=$REGION-docker.pkg.dev/$PROJECT/mcp-stateless/app:latest
- Create the table — run
deploy/db/schema.sqlagainst your Postgres (the Supabase SQL editor works). - Build + push the image to Artifact Registry:
gcloud artifacts repositories create mcp-stateless --repository-format=docker --location=$REGION gcloud builds submit --tag $IMAGE . - Store the secrets and grant the runtime service account
roles/secretmanager.secretAccessoron each:printf '%s' "$DATABASE_URL" | gcloud secrets create DATABASE_URL --data-file=- printf '%s' "$TOKEN_SECRET" | gcloud secrets create TOKEN_SECRET --data-file=- - Deploy the four MCP servers — two legacy (
STATELESS_MODE=0), two modern (STATELESS_MODE=1), each a distinctINSTANCE_ID. They use the image's default command:gcloud run deploy mcp-stateless-legacy-a --image $IMAGE --region $REGION \ --set-secrets=DATABASE_URL=DATABASE_URL:latest,TOKEN_SECRET=TOKEN_SECRET:latest \ --set-env-vars=STATELESS_MODE=0,INSTANCE_ID=legacy-a --allow-unauthenticated # repeat: legacy-b, then modern-a / modern-b with STATELESS_MODE=1 - Deploy the autoscaling
scaleservice — the target beat ④ blasts. One request per instance forces real fan-out:gcloud run deploy mcp-stateless-scale --image $IMAGE --region $REGION \ --set-secrets=DATABASE_URL=DATABASE_URL:latest,TOKEN_SECRET=TOKEN_SECRET:latest \ --set-env-vars=STATELESS_MODE=1,INSTANCE_ID=scale,APPEND_BOOT_ID=1 \ --concurrency=1 --max-instances=8 --allow-unauthenticated - Deploy the proxy and the UI — these override the command. Grab each service's URL
with
gcloud run services describe <name> --region $REGION --format='value(status.url)'. Values that contain commas need gcloud's^@^delimiter:gcloud run deploy mcp-stateless-proxy --image $IMAGE --region $REGION \ --command=python --args=-m,mcp_stateless_demo.proxy \ --set-env-vars="^@^UPSTREAMS=<legacy-a-url>,<legacy-b-url>" --allow-unauthenticated gcloud run deploy mcp-stateless-ui --image $IMAGE --region $REGION --memory=1Gi \ --command=python --args=-m,mcp_stateless_demo.ui \ --set-env-vars="^@^PROXY_BASE=<proxy-url>@LEGACY_UPSTREAMS=<a>,<b>@MODERN_UPSTREAMS=<a>,<b>@SCALE_UPSTREAM=<scale-url>@LEGACY_SERVICES=mcp-stateless-legacy-a,mcp-stateless-legacy-b@MODERN_SERVICES=mcp-stateless-modern-a,mcp-stateless-modern-b@GCP_PROJECT=$PROJECT" \ --allow-unauthenticated - Grant the UI's runtime service account
roles/logging.viewerso the live-log panels can read Cloud Run stdout.
Open the UI's URL and walk the beats. To ship a code change later, the included script does an image-only redeploy (preserving env, secrets, and any auth/network settings you added):
bash deploy/redeploy.sh # rebuild + redeploy the UI
bash deploy/redeploy.sh mcp-stateless-proxy # or a specific service
Hardening (optional). The backends can run internal-only (VPC ingress) with the UI behind IAP. Note the
mcpclient has no header hook, so backend calls are gated at the network layer, not with ID tokens.deploy/redeploy.shis image-only and preserves an IAP/VPC setup across redeploys.
Tech stack
mcp[cli]==2.0.0b2 · Starlette · Uvicorn · Gradio · asyncpg + Supabase Postgres ·
pydantic-settings · Google Cloud Run · Python 3.11 · uv.
<sub>Built for the AAIF Community Series — Agentic AI Night (Agentic AI Foundation, Seattle).</sub>
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
Neon Database
MCP server for interacting with Neon Management API and databases
E2B
Using MCP to run code via e2b.
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.