mcp-stateless-lab
by 133institute
README.md
# 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](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**.
```bash
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](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`,
`clientInfo` in `_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:
1. A tool realizes it needs a human first
2. The server **answers** with `input_required`, attaching the question and an
opaque `requestState` — then hangs up
3. The client asks the user at leisure — 4 seconds or 4 hours, nothing is waiting
4. 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:
1. Start the job → get a `taskId` back **immediately** (16 ms measured, versus
the old blocking call parking a connection for 2+ seconds)
2. Poll `tasks/get` — any instance can answer, because task state lives in the
database, not in memory
3. When the job needs a ruling it parks at `input_required`; answer via
`tasks/update` and it resumes
Three implementation disciplines, none optional:
- **Persist task state** — park it in an in-memory future and a `tasks/update`
landing 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:
1. **A handle is not a permission.** Re-authorize on every request; holding a
number ≠ being allowed to act on it.
2. **requestState is not trusted data.** Verify everything the client brings
back; reject with zero side effects.
3. **A taskId is not a public query ticket.** Bind tasks to user, tenant and
originating request.
4. **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`](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](experiments/report.md), the check-by-check
record of a real run
- **Step through**: [notebooks/01_walkthrough.ipynb](notebooks/01_walkthrough.ipynb)
(the whole story on the new revision in twelve steps) and
[02_before_after.ipynb](notebooks/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.sh` starts and stops every server it needs
- **Drive it from a real agent**: [agents/](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](agents/STATUS.md)
Deeper reading: [docs/comparison.md](docs/comparison.md) (the full SEP-by-SEP
reasoning), [docs/migration.md](docs/migration.md) (the migration checklist),
[docs/diagrams/](docs/diagrams/) (Mermaid diagrams).
## Commands
```bash
./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](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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr),
[Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http),
[versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) and the
[tasks extension](https://modelcontextprotocol.io/docs/extensions/tasks).
## Licence
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing