learn-stateless-mcp

mcp
Guvenlik Denetimi
Uyari
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.

SUMMARY

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.

README.md

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-vercelnpm 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 0005 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 .ps1 scripts 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 an
Mcp-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/initialized handshake. Every request carries its own
    protocol version, client capabilities and client identity in _meta.
  • No Mcp-Session-Id header. Any replica behind a load balancer can serve any request.
  • server/discover is 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 with
resultType: "input_required", carrying inputRequests (what it needs) and an opaque
requestState 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 and
roots/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.0 and 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 the Host is the balancer's, so the check rejects everything —
    filtering hosts is the proxy's job in that topology.
  • A /healthz endpoint. 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 requestStateintermittently, 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 plain
    Dockerfile is ignored.
  • The port is 80, not an injected $PORT. Override it with a PORT environment variable
    in project settings — but the image sets ENV PORT=80 so it works untouched.
  • Instances scale to zero after 5 idle minutes in production, 30 seconds in preview.
  • Scale-down is SIGTERM with 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 a
Build output contains no "functions" or "static" directory warning is expected for a
container-only project. The Python lesson additionally needs
vercel 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 is
COUNT(*) 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 shows
Index 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: with
legacy: '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:
    1. pre-registered credentials it already holds for that server;
    2. Client ID Metadata Documents (CIMD) — an HTTPS URL is the client_id, resolved by
      the authorization server on demand — when the AS advertises
      client_id_metadata_document_supported: true;
    3. Dynamic Client Registration as a fallback (now deprecated, kept for older
      authorization servers);
    4. prompting the user for client details.
  • CIMD credentials are portable; pre-registered and DCR credentials are not. Clients MUST
    key stored credentials by the AS issuer, 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_type is required at DCR time. Omitting it defaults to "web" under OIDC,
    which collides with localhost redirect URIs; native and CLI clients want "native".

Both SDKs ship the client-side pieces (@modelcontextprotocol/client exports
ClientCredentialsProvider, PrivateKeyJwtProvider, discoverOAuthMetadata, withOAuth;
Python has mcp.server.auth). On Cloudflare, @cloudflare/workers-oauth-provider takes
clientIdMetadataDocumentEnabled: 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 in
python/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

SDKs and libraries

Deployment

Unofficial write-ups — useful for orientation; the changelog above is authoritative.

Another language's take on the same protocol

Yorumlar (0)

Sonuc bulunamadi