mcp-stateless-lab
A comparative lab demonstrating MCP protocol evolution from stateful (2025-11-25) to stateless (2026-07-28) using a procure-to-pay agent, highlighting the implications for load balancing and session management.
README
mcp-stateless-lab
A before/after lab for MCP 2025-11-25 → 2026-07-28, told through a
procure-to-pay story, answering four questions head-on:
what problem showed up · how stateless solves it · why that works · what
discipline it demands in production.
繁體中文版:README.zh-TW.md
Two MCP servers, one business logic. The same story runs against both revisions, so every difference you observe comes from the protocol, not the code. Running it produces a machine-generated report: 29 checks, 29 passed.
git clone https://github.com/133institute/mcp-stateless-lab && cd mcp-stateless-lab
./scripts/setup.sh # Windows: scripts\setup.bat
./scripts/run_experiments.sh
Results land in experiments/report.md — generated from a real run, never hand-written.
The challenge: MCP stopped being a local tool call
The early assumption was simple: one client connects to one server, the connection stays alive, and the server remembers you. The moment agent services went to production, reality stopped cooperating:
- Instances get swapped. A load balancer or serverless platform sends the next request to a different machine. The process that served the last one may no longer exist.
- Humans take their time. Before submitting, someone has to ask a manager, legal, or accounting. The answer might come back in 4 seconds — or 4 hours.
- Work gets handed off. The same job is continued by different people, on different devices, by background schedulers. It does not stay on one connection.
So the challenge was never "have no state." The challenge is that state can no longer be tied to a connection.
The problem: state hidden in the connection makes the system brittle
A purchasing flow shows it plainly. One requisition crosses days, people and
tools on its way to payment — but 2025-11-25 records "who you are, how far you
got" in the connection (the session). Four everyday moments, four ways to break:
| Business moment | Why it breaks | Evidence |
|---|---|---|
| A high-value line needs sign-off before submitting | the server must hold the request open while a person decides: too short a timeout loses the operation, too long parks a connection | exp02(b): an answer 2 s late loses the submission |
| "Add one more line to yesterday's requisition" | yesterday's session is gone; the business document is still very much alive | exp02(c): the row sits in the database, unreachable by anyone |
| The warehouse receives, accounting adjudicates | you cannot make everyone share one connection | exp02(a): hit a different instance, get HTTP 404 |
| A three-way match runs for minutes | progress checking is chained to the channel that started the job | exp01: tasks/result parks a connection for the full duration |
The most telling detail in exp02: the documents sit intact in the shared database for the entire duration of every failure. What broke was not the data but the addressing. What the system needs to catch is "the requisition number, the receipt record, the payment task" — not "whichever connection is still alive."
The stateless answer: self-contained requests, named state
2026-07-28 removes protocol sessions and the initialize handshake. Instead:
| Old: the connection remembers | New: every request carries its own context |
|---|---|
Mcp-Session-Id header |
gone |
after initialize, both sides remember |
_meta states version and capabilities every time |
| drafts, confirmations, tasks hidden in a process/stream | handle: a name for the business object |
| the answer must find "the process that asked" | requestState: continuation data the client carries back |
| progress chained to the starting channel | taskId: a name for the long-running job |
The core difference in one sentence: it no longer matters who receives the request — if the name, the authorization and the state machine all check out, the work continues.
Why that works: state goes back where it belongs
Stateless does not mean throwing state away. It means the transport layer stops secretly holding it for you. Each piece goes to its proper home:
- The request carries its own —
protocolVersion,clientCapabilities,clientInfoin_meta. The server never needs to remember a handshake. - The database keeps the rest — document states, authorization, audit trails, idempotency keys. Restarts, instance swaps and overnight gaps cannot break it.
- The model sees names — document numbers, task ids, result summaries. An agent can quote them, relay them, hand them to the next person.
The payoff: an MCP server scales, restarts and swaps instances like any ordinary HTTP service, while business state stays traceable, controllable and auditable. exp03 proves it the direct way — the same six prompts through the same round-robin cluster, all passing, with the report recording which instance served each step.
How, part 1: business continuity rides on handles
A handle is just the requisition number PR-2026-0001, a receipt record id, a
payment task id — the name of "the thing I want to continue," not a
permission. Anyone may hold the name; whether they can act is decided by three
other things:
- Authorization: may this person see this document? (re-checked on every request)
- The state machine: can it be amended / received / paid right now?
(fully covered in
tests/test_state_machine.py) - Idempotency: does a resend double-order?
Evidence: in exp03, a fresh next-day conversation, the warehouse on a new device, and accounting's separate agent all continue the same flow holding nothing but the number — no hand-over protocol, because the name is the hand-over.
How, part 2: human decisions become two requests (MRTR)
The old way: the server holds the original request open and waits. The new way removes the waiting entirely:
- A tool realizes it needs a human first
- The server answers with
input_required, attaching the question and an opaquerequestState— then hangs up - The client asks the user at leisure — 4 seconds or 4 hours, nothing is waiting
- The client re-sends the original request with the answer and the
untouched
requestState
The catch: requestState passes through the client's hands, so the server may
only verify it, never trust it. This lab implements and tests all four defences
(tests/test_protocol_2026.py):
| Defence | Mechanism | On failure |
|---|---|---|
| Integrity | HMAC signature | one flipped character → -32602, rejected |
| Bound to the user | principal sealed into the signature | presented by someone else → rejected |
| Bound to the request | parameter digest sealed in | replayed onto another document → rejected |
| Short expiry | deadline sealed in | expired → rejected |
And rejection produces zero side effects — every tamper test checks afterwards that the requisition did not move.
Evidence: exp03 replaces every instance in the cluster between the question
and the answer of one confirmation. The submission still completes, because
requestState carries everything needed to resume.
How, part 3: long jobs and multi-agent relay ride on taskId
A minutes-long three-way match must not chain "how is it going?" to the channel that started it:
- Start the job → get a
taskIdback immediately (16 ms measured, versus the old blocking call parking a connection for 2+ seconds) - Poll
tasks/get— any instance can answer, because task state lives in the database, not in memory - When the job needs a ruling it parks at
input_required; answer viatasks/updateand it resumes
Three implementation disciplines, none optional:
- Persist task state — park it in an in-memory future and a
tasks/updatelanding on another instance can never reach it - Per-request capability — whether the server may ask a human is declared by this request, not remembered from a connection
- No
tasks/list— a server that cannot identify the requester would be listing other people's tasks (the spec removed it for exactly this reason)
Evidence: exp03 records one job's creation, polls and mid-flight answer being served across all three instances.
The production baseline: stateless means engineering your state
Moving state from the transport layer back into the application buys the scaling — and costs four disciplines this lab tests one by one:
- A handle is not a permission. Re-authorize on every request; holding a number ≠ being allowed to act on it.
- requestState is not trusted data. Verify everything the client brings back; reject with zero side effects.
- A taskId is not a public query ticket. Bind tasks to user, tenant and originating request.
- Tool-catalogue caching must not leak. Mark a private listing
cacheScope: "public"and you have published it.
The experiments at a glance
exp01 |
2025-11-25, one instance, one conversation | all six prompts pass — proving the old revision's logic is fine |
exp02 |
2025-11-25, 3 instances + round-robin | reproduces the three failures above (a pass = the failure reproduced) |
exp03 |
2026-07-28, identical conditions | all pass, with per-step instance labels |
exp04 |
the two revisions meeting | 12 conformance checks (error codes, headers, capability gating) |
The six driving prompts (P1–P6) live in
src/client_lab/scenarios.py, identical for both
revisions; P2 (continue tomorrow) and P5 (hand over to the warehouse) are the
two boundaries the old revision cannot cross.
Four ways in
- Read: experiments/report.md, the check-by-check record of a real run
- Step through: notebooks/01_walkthrough.ipynb (the whole story on the new revision in twelve steps) and 02_before_after.ipynb (old and new side by side, showing where it breaks) — both committed with real outputs, readable on GitHub as-is
- Run:
./scripts/run_experiments.shstarts and stops every server it needs - Drive it from a real agent: agents/ (Claude Code / Desktop, ChatGPT, OpenAI API). Run the era probe first to learn which revision your host speaks; verification status in agents/STATUS.md
Deeper reading: docs/comparison.md (the full SEP-by-SEP reasoning), docs/migration.md (the migration checklist), docs/diagrams/ (Mermaid diagrams).
Commands
./scripts/setup.sh # idempotent; ends by running a real tool call
./scripts/run_2026.sh --http # :8002/mcp (--stdio also works)
./scripts/run_2025.sh --stdio # :8001/mcp with --http
./scripts/run_cluster_2026.sh # 3 instances :8201-8203 + proxy :8200
./scripts/run_cluster_2025.sh # 3 instances :8101-8103 + proxy :8100
./scripts/run_experiments.sh # exp01..exp04 -> experiments/report.md
./scripts/run_tests.sh # pytest, ruff, mypy, shellcheck
./scripts/stop_all.sh
./scripts/clean.sh # lists what it would delete; --force to do it
Every one has --help, exits non-zero on failure with reproduction steps, and
refuses to silently pick another port when one is busy. Windows equivalents:
scripts\*.bat.
Layout
src/procure_core/ business logic and SQLite — knows nothing about MCP
src/mcp_wire/ JSON-RPC, stdio and Streamable HTTP, round-robin proxy
src/server_2025/ the old server: handshake, sessions, blocking tasks
src/server_2026/ the new server: discover, handles, MRTR, tasks extension
src/client_lab/ protocol-level clients for both revisions + the six prompts
experiments/ exp01..exp04 and the generated report
notebooks/ the same material, step by step
tests/ 97 tests: state machine, thresholds, both protocols
scripts/ one command each (.sh / .bat share scripts/lab.py)
agents/ four agent-host guides + the era probe
docs/ comparison, migration checklist, Mermaid diagrams
Two decisions worth knowing
No official SDK. The two revisions live in mcp 1.x and 2.x — same package
name, cannot coexist — and the experiments need both revisions in one process,
plus the ability to send requests an SDK exists to prevent (wrong headers, a
tampered requestState). Everything is implemented directly on JSON-RPC, so one
virtual environment suffices. requirements-2025.txt / requirements-2026.txt
still pin the real SDKs for separate comparison.
The 50,000 threshold applies to the line total (quantity × unit price), not the unit price. Two laptops at 45,000 form one 90,000 line — which is what makes the walkthrough actually trigger a confirmation and a legal review at all. The boundaries 49,999 / 50,000 / 50,001 are pinned in tests.
Scope and safety
No real ERP, no real payments, no full OAuth chain (the authorization changes are documented, not implemented). Every company, person, document number, amount and supplier is fictional. Nothing in this repository reads, logs or stores any credential, token or personal datum; the one path that exposes anything to the internet (the ChatGPT connector) opens its guide with the safety notes.
Verified on
Python 3.12.7, Windows 11, 2026-07-30: 97 tests pass, ruff and mypy clean, 29 of
29 experiment checks pass. shellcheck was not installed, so it is reported as
not run. The four agent-host integrations have not been run against their
real hosts — agents/STATUS.md records exactly what was and
was not exercised.
Sources
Authoritative:
- https://modelcontextprotocol.io/specification/2026-07-28/changelog
- https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
Also useful: the MRTR pattern, Streamable HTTP, versioning and the tasks extension.
Licence
MIT — see LICENSE.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。