learn-stateless-mcp
Health Uyari
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Gecti
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Gecti
- Permissions — No dangerous permissions requested
Bu listing icin henuz AI raporu yok.
Learn the stateless MCP revision (2026-07-28) by building it twice — once with the Python SDK v2, once with the TypeScript SDK v2. Five lessons that mirror each other file for file: hello world, a server and client with no session, where your tool's JSON Schema really comes from, and multi round-trip requests.
learn-stateless-mcp
A hands-on repo for learning MCP protocol revision 2026-07-28 — the release that
made the Model Context Protocol stateless — with both official SDK v2 lines:
| package | version used here | |
|---|---|---|
| Python | mcp |
2.0.0 |
| TypeScript | @modelcontextprotocol/{core,server,client,hono} + hono |
2.0.0 |
Every example in this repo was run against a live server before being committed.
REFERENCE.md is the one file to keep open. It carries the wire format,
both SDKs' API surfaces, the deployment contracts and a table of failure modes — all captured
from the running code, so you can work through the lessons without leaving the repo. Every
lesson directory also has its own README explaining what to look at and what to notice.
.
├── python/ # MCP Python SDK v2 — one uv project per lesson half
│ ├── 00-helloworld/ server/ client/
│ ├── 01-stateless/ server/ client/
│ ├── 02-pydantic/
│ ├── 03-mrtr/ server/ client/
│ ├── 04-container/ server/ client/
│ ├── 05-vercel/ server/ client/ site/
│ ├── 06-neon/ server/ client/ site/
│ └── 07-rag/ server/ client/ site/
├── typescript/ # MCP TypeScript SDK v2 — one npm package per lesson half
│ ├── 00-helloworld/ server/ client/
│ ├── 01-stateless/ server/ client/
│ ├── 02-zod/
│ ├── 03-mrtr/ server/ client/
│ ├── 04-container/ server/ client/
│ ├── 05-vercel/ server/ client/ site/
│ ├── 06-neon/ server/ client/ site/
│ └── 07-rag/ server/ client/ site/
├── REFERENCE.md # the wire format and both SDK surfaces, self-contained
├── CLAUDE.md # working guide for Claude Code
└── AGENT.md # the same guide for other coding agents
The two stacks are lesson-for-lesson identical — same numbers, same tool names, same wire —
so any file on one side has a counterpart on the other teaching the same thing with the other
SDK. Read one language through, then diff it against the other; the differences that survive
are the interesting part, and each project's README tabulates them.
Prerequisites
Nothing is installed globally except the two toolchains. Each lesson pulls its own SDK copy.
| version | why | check | |
|---|---|---|---|
| Node.js | ≥ 20 | every @modelcontextprotocol/*@2.0.0 package sets engines.node: ">=20" |
node --version |
| npm | ships with Node | installs each TypeScript lesson | npm --version |
| uv | any recent | installs each Python lesson | uv --version |
| Python | ≥ 3.13 | what the lessons pin | uv python list |
| curl | any | only for the *.sh scripts (Windows 10+ has it) |
curl --version |
| PowerShell | 7+, optional | only if you want the *.ps1 scripts instead of the *.sh ones |
pwsh --version |
| Docker | optional | only for lessons 04-container and 05-vercel; Compose ships with Docker Desktop |
docker compose version |
| Neon account | optional, free, no card | lessons 06-neon and 07-rag need a Postgres — how to get the connection string |
— |
| Embeddings key | optional, free, no card | lesson 07-rag only — how to get one |
— |
| Vercel CLI | optional | only to deploy lesson 05-vercel — npm i -g vercel, then vercel login |
vercel whoami |
You do not need to install Python yourself: every Python lesson carries a .python-version
and uv sync downloads a matching interpreter if you have none. uv is the only thing to
install by hand — curl -LsSf https://astral.sh/uv/install.sh | sh, or brew install uv.
Lessons 00–05 need no accounts and no keys at all. Only the last two reach outside your
machine: 06-neon wants a Postgres connection string and 07-rag additionally wants an
embeddings key. Both have free tiers that do not ask for a credit card, and each lesson's
README walks through obtaining them. Every secret goes in a git-ignored server/.env — copy
the .env.example beside it, which explains each value and where it comes from.
Developed and verified against Node 26.7, npm 11.19, uv 0.12.5 and Python 3.13.2. Nothing here
needs Docker, a database, an API key or network access beyond the two package registries — the
servers bind to 127.0.0.1 and the lessons run unauthenticated.
Windows
The lessons themselves run natively — uv, npm, node and python are all cross-platform, every
server binds to 127.0.0.1, and no path in the source is OS-specific:
cd python\00-helloworld\server
uv sync
uv run python main.py
cd typescript\00-helloworld\server
npm install
npm start
The curl walkthroughs are the only part that needs a choice. Each server directory carries
both forms:
| needs | run it with | |
|---|---|---|
hello.sh / call.sh / deploy.sh |
a bash — Git Bash (ships with Git for Windows) or WSL | bash ./hello.sh Zia |
hello.ps1 / call.ps1 / deploy.ps1 |
PowerShell 7+ (pwsh) |
./hello.ps1 Zia |
Windows 10+ already includes curl.exe, so the bash route needs nothing beyond a shell. The
PowerShell twins need nothing at all beyond pwsh — they use Invoke-RestMethod and parse
JSON natively, so unlike the bash MRTR scripts they do not shell out to node or python to read
round one.
Verification status. The
.ps1scripts were written on macOS, where PowerShell is not
installed, so they have not been executed. What was verified is the part most likely to be
wrong: the exact JSON envelope and headers each one builds were transcribed and fired at all
six live servers, and both MRTR rounds completed in both languages. Only PowerShell's own
syntax remains unexercised. If one misbehaves, that is a bug — please report it.
npm run hello / call / deploy invoke the bash form (bash ./hello.sh), so they work from
cmd.exe or PowerShell as long as Git Bash is on PATH; otherwise call the .ps1 directly.
Why "stateless" is the headline
Sessions were the thing that made remote MCP servers hard to scale: initialize minted anMcp-Session-Id, and every later request had to reach the same process that issued it —
sticky routing, or a shared session store, forever.
2026-07-28 deletes that idea:
- No
initialize/notifications/initializedhandshake. Every request carries its own
protocol version, client capabilities and client identity in_meta. - No
Mcp-Session-Idheader. Any replica behind a load balancer can serve any request. server/discoveris the one RPC every server MUST implement — identity, capabilities and
supported protocol versions, fetched up front when a client wants it.- Servers that genuinely need cross-call state mint an explicit handle and pass it as an
ordinary tool argument. State becomes data, not transport magic.
Here is a complete, real tool call against python/01-stateless/server/ — one POST, no
handshake, nothing before it:
$ curl -sS -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: add' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"add","arguments":{"a":2,"b":3},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8.0"},
"io.modelcontextprotocol/clientCapabilities":{}}}}'
{"jsonrpc":"2.0","id":1,"result":{
"content":[{"text":"5.0","type":"text"}],
"isError":false,
"resultType":"complete",
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"calculator","version":"1.0.0"}}}}
Three things to notice: the _meta envelope going up, resultType: "complete" coming back
(now required on every result), and serverInfo in the response _meta.
The TypeScript half of that lesson answers the identical request on :3001. Its add returns"5" where Python returns "5.0" — a language difference in how a number stringifies, not a
protocol one.
Multi Round-Trip Requests (MRTR)
If there are no sessions, how does a server ask the user a question mid-call?
It doesn't hold a connection open. It answers the original request withresultType: "input_required", carrying inputRequests (what it needs) and an opaquerequestState blob. The client gathers the answers and re-sends the original request
with inputResponses plus that same requestState. One logical tool call, two POSTs, and
the second one may land on a different replica.
client server (any replica)
│ tools/call {name:"deploy"} │
│───────────────────────────────────────>│
│ resultType:"input_required" │
│ inputRequests + requestState │
│<───────────────────────────────────────│
│ (ask the human) │
│ tools/call {name:"deploy", │
│ inputResponses, requestState} │
│───────────────────────────────────────>│
│ resultType:"complete" │
│<───────────────────────────────────────│
MRTR replaces the old server-initiated elicitation/create, sampling/createMessage androots/list requests. Write MRTR handlers to be re-entrant: round two is a fresh
invocation, so read the answer first and only ask when it is missing.
Lesson 03-mrtr builds it in both languages, and this is where the two SDKs diverge most
while implementing the identical protocol. TypeScript hands you the round trip: the handler is
re-invoked, checks inputResponses itself and returns inputRequired({...}) when the answer
is missing — so it must be written re-entrant or it asks forever. Python hides it behind
resolver injection: a parameter annotated Annotated[T, Resolve(fn)] is filled by the
framework, so the handler only ever sees the answered case. Same two POSTs on the wire; one
branch fewer to get wrong. Each lesson's deploy.sh walks both rounds by hand in curl.
Containers, and staying off a vendor's hook
Lesson 04-container exists because "it works on my laptop" and "it works behind a load
balancer" are different claims, and only the second one is what this protocol revision
promises. It ships a Dockerfile, a compose.yaml that runs three replicas behind an
nginx round-robin, and two tools built to falsify the promise:
$ ./call.sh whoami # six times, through the proxy
call 1 -> 4128047078a4 (that replica has served 1)
call 2 -> 6600d92f9366 (that replica has served 1)
call 3 -> e6ed2a2e784a (that replica has served 1)
call 4 -> 4128047078a4 (that replica has served 2)
$ ./call.sh deploy staging
Deployed to staging (change ticket CHG-1234). Asked by e6ed2a2e784a, completed by 4128047078a4.
That last line is the whole revision in one sentence: a two-round conversation begun on one
machine and finished on another that had never heard of it. Nothing was shared between them
but the requestState blob the client carried across.
Three deliberate things in the image, none of them vendor-specific:
- Bind
0.0.0.0and read$PORT. A container's loopback is its own, and every scheduler
injects a port. Hard-coding either is the most common reason a working image refuses to serve. - Turn off DNS-rebinding protection. Both SDKs enable Host-header checking for localhost
binds. Behind a proxy theHostis the balancer's, so the check rejects everything —
filtering hosts is the proxy's job in that topology. - A
/healthzendpoint. MCP has no liveness concept; Compose healthchecks and Kubernetes
probes want one anyway.
No vendor lock-in is the point. Neither Dockerfile names a cloud. The image that comes out
runs on Docker, Compose, Kubernetes, Cloud Run, Fly, Render, a bare VM — and, since Vercel
added OCI support, on Vercel Functions too, which is what lesson 05-vercel does. Moving
between them costs a docker push. Netlify still hosts functions rather than images, and both
SDKs are ready for that shape as well: createMcpHandler returns a web-standard fetch
handler and MCPServer.streamable_http_app() returns a Starlette ASGI app. Same server,
several deployment shapes, no rewrite.
The bug the lesson exists to teach
Running one replica hides two failures, and Python has both:
| what you did | what you see |
|---|---|
no shared request_state key across replicas |
round two: Invalid or expired requestState — intermittently, in proportion to your replica count |
| a resolver whose question renders differently per replica | round two silently asks again, forever |
The first is fixed with RequestStateSecurity(keys=[...]) — the default is a per-process
ephemeral key, which is exactly right for one process and quietly fatal for two. The second is
subtler: the SDK digests the rendered question and discards an answer to a question that has
changed, so interpolating a hostname or timestamp into an elicitation message breaks every
cross-replica retry. Both are single-line mistakes that only a fleet reveals, which is why the
lesson runs three replicas rather than describing them.
Deploying it: lesson 05 on Vercel Functions
Vercel runs OCI images now, so lesson 05-vercel takes lesson 04's container and deploys it
without changing the server. What changes is four platform details, and they are worth knowing
before you meet them at 2am:
- The Dockerfile must be called
Dockerfile.vercel. The filename is the trigger; a plainDockerfileis ignored. - The port is 80, not an injected
$PORT. Override it with aPORTenvironment variable
in project settings — but the image setsENV PORT=80so it works untouched. - Instances scale to zero after 5 idle minutes in production, 30 seconds in preview.
- Scale-down is
SIGTERMwith a 30 second grace period. Both servers exit immediately;
there are no sessions to drain.
The lesson uses the multi-service form of vercel.json, so one project serves a static front
page at / and routes /mcp to the container:
{
"services": {
"mcp": { "root": "server/", "entrypoint": "Dockerfile.vercel" },
"site": { "root": "site/", "entrypoint": "Dockerfile.vercel" }
},
"rewrites": [
{ "source": "/mcp", "destination": { "service": "mcp" } },
{ "source": "/(.*)", "destination": { "service": "site" } }
]
}
Deploying it
npm i -g vercel # or pnpm/yarn/bun; brew install vercel-cli works too
vercel login # then `vercel whoami` to confirm
cd typescript/05-vercel # the directory with vercel.json
vercel deploy --yes --scope <your-scope>
--scope is required whenever the CLI cannot prompt and your account has more than one team;
run vercel teams list to see the names, or vercel link once. The first deployment of a
project goes to production, later ones are previews unless you pass --prod, and aBuild output contains no "functions" or "static" directory warning is expected for a
container-only project. The Python lesson additionally needsvercel env add MCP_STATE_KEY production before MRTR survives more than one instance.
Then vercel logs <url> --follow to watch it, vercel curl <path> if Deployment Protection is
on, and vercel remove <project> to clean up. Full walkthrough in each lesson's README.
Why scale-to-zero is the best possible test of this protocol
A compose fleet proves a request can land on any of three replicas. Vercel proves something
stronger: the instance that answered your last request may no longer exist. Round one of
an MRTR call can be served by a container that is deleted before round two arrives, and the
call still completes — because the conversation was never in the server. Verified by
destroying the container mid-conversation:
1. round one on instance A asked; instance A id = 4dba2782a9ed
2. DESTROY instance A (as a scale-to-zero would)
3. start a brand new instance B instance B id = 1a95cb185426
4. round two on instance B -> complete | Deployed to staging. Completed by 1a95cb185426.
That only works because every instance shares one MCP_STATE_KEY. On Python, forget it and
the default per-process key means tomorrow's container cannot unseal today's blob — the
failure lesson 04 teaches, made permanent by a platform that never keeps a container.
Where the state you actually keep goes
Lesson 06-neon is the last one, and it exists because lessons 04 and 05 are only half an
answer. They prove the protocol survives instance churn and scale-to-zero. They say nothing
about the data you wanted to keep.
| Protocol state | Gone. No handshake, no session, no affinity. |
| Application state | Not gone, and never was. It moves into a database you own. |
2026-07-28 did not abolish state; it stopped the transport from being where state hides,
which is what made it invisible and un-scalable. The lesson makes the distinction impossible
to miss with two counters returned side by side:
call 1 -> 725ab973-013 here=2 total=21 instances=4
call 2 -> 9dd22ad8-24e here=1 total=22 instances=5
call 3 -> 725ab973-013 here=3 total=23 instances=5
here is an integer in one process — it restarts at 1 with every container. total isCOUNT(*) in Postgres — it climbs no matter which instance answers, or how many have been
destroyed since.
Neon suits this because it is serverless in the same way the server is: it scales to zero,
branches instantly (so a throwaway database for tests is the natural thing), and its pooled
endpoint is built for exactly the traffic a scale-to-zero platform produces — many short-lived
instances each wanting a connection or two. Nothing in the lesson is Neon-specific though:DATABASE_URL is the only coupling.
The TypeScript version goes one step further and turns requestState into a handle into
Postgres rather than a self-contained blob — smaller on the wire, unforgeable, revocable and
single-use, at the cost of a round trip and an expiry sweep. The Python SDK owns that blob and
its codec seam is synchronous by design, so that half is TypeScript-only; both lesson READMEs
explain why.
AI-searchable context: RAG on Postgres
Lesson 07-rag adds semantic search over a corpus, using pgvector — and makes two arguments
worth disagreeing with.
There is no vector database. The data you want to search is almost always already in
Postgres next to the rows it belongs to. pgvector means the embedding lives in the same table,
is written in the same transaction, and is filtered by the same WHERE clause as everything
else — no second system to sync, nothing extra to back up, no consistency window.
The server retrieves; it does not answer. search_context returns passages with their
source and heading, and never calls a language model. The client already has a model, the
conversation and the user's real intent — none of which the server can see. Retrieval is the
part only the server can do, because only it has the corpus. So the "G" in RAG lives in the
client, and citations come back with every passage.
The corpus is this repository's own documentation, so the server can answer questions about
MCP, and you can judge the retrieval because you know the right answer:
Q: What breaks when I run more than one replica?
0.725 README.md — The bug the lesson exists to teach
0.723 python/04-container/README.md — The bug this lesson exists to teach
0.707 REFERENCE.md — Failure modes and what they look like
The pgvector trap
HNSW cannot index a vector wider than 2000 dimensions — and the good models are wider
(gemini-embedding-001 and text-embedding-3-large are both 3072):
ERROR: column cannot have more than 2000 dimensions for hnsw index
The fix is halfvec, which HNSW indexes to 4000. Index the cast, keep full precision in
storage, and apply the same cast in the query — omit it and the index is silently ignored while
the rows still come back correct, from a sequential scan. Both stacks assert EXPLAIN showsIndex Scan.
Where this pays off: deployment
Statelessness is not an aesthetic choice — it changes what you can deploy an MCP server on.
With initialize and Mcp-Session-Id gone there is no per-connection state to keep alive, so
a server no longer needs sticky routing, a shared session store, stream management or message
replay. Request-scoped infrastructure — serverless functions, edge workers, plain autoscaled
containers — becomes enough. Cloudflare makes the point directly in
their write-up of the revision: MCP no longer requires a
Durable Object to speak the protocol, and their McpAgent primitive is superseded by a plain
handler. Durable Objects still earn their place when your application needs coordinated
state — just not for the protocol itself.
The TypeScript SDK is built for this: createMcpHandler() returns a web-standard{ fetch, close, notify, bus } object, which is exactly the shape Workers, Bun and Deno expect:
const handler = createMcpHandler(createServer);
export default handler; // Workers / Bun / Deno
// Node needs one adapter: createServer(toNodeHandler(handler)).listen(3000)
Backward compatibility is a per-endpoint decision, not a migration event: withlegacy: 'stateless' (the default) the same /mcp URL serves both 2026-07-28 clients and
2025-era Streamable HTTP clients. Use legacy: 'reject' once you no longer want the old era.
What else changed
| Area | Change |
|---|---|
| Transport | Streamable HTTP only; SSE resumability (Last-Event-ID) removed — a broken stream means re-issuing the request with a new id. HTTP+SSE is deprecated. |
| Notifications | GET /mcp and resources/subscribe replaced by subscriptions/listen: one long-lived POST-response stream you opt into per change type. |
| Caching | tools/list, prompts/list, resources/list, resources/read, resources/templates/list now carry ttlMs + cacheScope — cache instead of polling. |
| Headers | Mcp-Method and Mcp-Name are required on Streamable HTTP POSTs so proxies can route without parsing bodies. |
| Removed | ping, logging/setLevel, notifications/roots/list_changed. Log level is per-request via _meta. |
| Tasks | Moved out of core into the io.modelcontextprotocol/tasks extension; polling tasks/get replaces blocking tasks/result. |
| Errors | -32020…-32099 reserved for the spec. Resource-not-found is now -32602, not -32002. |
| Auth | Hardened — see Authorization below. |
| Deprecated | Roots, Sampling and Logging (SEP-2577) — functional for at least twelve months, but don't build on them. |
Authorization
The revision hardens OAuth in a few specific ways:
- Client registration has a priority order. A client that supports all of them SHOULD try:
- pre-registered credentials it already holds for that server;
- Client ID Metadata Documents (CIMD) — an HTTPS URL is the
client_id, resolved by
the authorization server on demand — when the AS advertisesclient_id_metadata_document_supported: true; - Dynamic Client Registration as a fallback (now deprecated, kept for older
authorization servers); - prompting the user for client details.
- CIMD credentials are portable; pre-registered and DCR credentials are not. Clients MUST
key stored credentials by the ASissuer, MUST NOT reuse them against a different
authorization server, and MUST re-register when it changes. - RFC 9207: authorization servers SHOULD return
iss, and clients MUST validate it against
the recorded issuer before redeeming the code — this is what closes mix-up attacks. application_typeis required at DCR time. Omitting it defaults to"web"under OIDC,
which collides withlocalhostredirect URIs; native and CLI clients want"native".
Both SDKs ship the client-side pieces (@modelcontextprotocol/client exportsClientCredentialsProvider, PrivateKeyJwtProvider, discoverOAuthMetadata, withOAuth;
Python has mcp.server.auth). On Cloudflare, @cloudflare/workers-oauth-provider takesclientIdMetadataDocumentEnabled: true and a resourceMetadata block. Authorization is not
implemented in this repo's examples — the lessons run unauthenticated on localhost.
Getting started
Both stacks run the same eight lessons, meant to be read in order:
| # | lesson | teaches |
|---|---|---|
| 00 | 00-helloworld |
one tool end to end: server, client, schema, output schema, test |
| 01 | 01-stateless |
a fuller server — several tools, a resource, cache hints — and a client that reads them |
| 02 | 02-pydantic / 02-zod |
where your tool's JSON Schema comes from, and what it silently drops |
| 03 | 03-mrtr |
Multi Round-Trip Requests: asking the user without a session |
| 04 | 04-container |
The same server in a container; three replicas prove any of them can answer anything |
| 05 | 05-vercel |
That image deployed to Vercel Functions, where instances scale to zero between requests |
| 06 | 06-neon |
Where the state you actually keep goes: Neon serverless Postgres |
| 07 | 07-rag |
AI-searchable context: RAG on Postgres with pgvector |
Every lesson half is its own project, installed and run where it lives. There is no root
install in either language.
Python
cd python/00-helloworld/server && uv sync && uv run python main.py # terminal 1, :8000
cd python/00-helloworld/client && uv sync && uv run python main.py # terminal 2
TypeScript
cd typescript/00-helloworld/server && npm install && npm start # terminal 1, :3000
cd typescript/00-helloworld/client && npm install && npm start # terminal 2
Ports are 8000 + the lesson number in Python and 3000 + the lesson number in
TypeScript, so every lesson in both languages can run at once. Lesson 02 needs no port at
all — it wires a client to a server in one process.
Inside any server project, uv run pytest / npm test exercises the tools with no server,
transport or port, and a curl script (hello.sh / call.sh / deploy.sh, or npm run hello
/ call / deploy) shows the raw wire.
Full details, including the gotchas that cost time, are inpython/README.md, typescript/README.md and
CLAUDE.md.
References
The repo is meant to be self-contained — start with REFERENCE.md, which
carries the protocol, both SDK surfaces and the failure-mode table. These are for going
further, and the full annotated list lives in
REFERENCE.md § Further reading.
Specification
- Specification 2026-07-28 · Changelog vs 2025-11-25
- MRTR pattern
- Release announcement · Beta SDKs for the RC
SDKs and libraries
Deployment
- Vercel: container images · services · container registry
- Cloudflare: MCP v2 and what statelessness buys you
- Docker: multi-stage builds · Compose file reference
Unofficial write-ups — useful for orientation; the changelog above is authoritative.
- Stacktree: every breaking change, with fixes — the most complete migration-shaped write-up, organised by SEP
- Arcade: supporting stateless MCP, a client checklist — the client side, which the spec covers less directly
- 4sysops: stateless, MRTR, routable headers, auth hardening
- TensorFoundry: MCP goes stateless · NiteAgent: inside the spec rewrite
Another language's take on the same protocol
Yorumlar (0)
Yorum birakmak icin giris yap.
Yorum birakSonuc bulunamadi