Skip to main content
Glama
133institute

mcp-stateless-lab

by 133institute

mcp-stateless-lab

A before/after lab for MCP 2025-11-252026-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.

Related MCP server: agent-audit-trail

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 ownprotocolVersion, 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, 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.sh starts 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:

Also useful: the MRTR pattern, Streamable HTTP, versioning and the tasks extension.

Licence

MIT — see LICENSE.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/133institute/mcp-stateless-lab'

If you have feedback or need assistance with the MCP directory API, please join our Discord server