The Stateless MCP · Part 2
The MCP server that asks you a question
Under 2026-07-28 a tool call that needs an answer from a person has no channel to ask on, so it ends early and gets called again. Building that two-leg flow in Python with mcp 2.0.0, sealed requestState included.
Marcin
Java since 2007, .NET since 2010 · Azure · lately deep in LLMs
The booking that can't finish
Someone asks the assistant to book a room for six people on the 4th. Your book_room tool
wakes up, checks the calendar, and finds two rooms that fit: basalt on the second floor and
cinder on the fifth. Then it stops. The people in this meeting are hosting a visitor who is
already up on the fifth floor, so cinder is the room they want, and nothing in the
arguments says so. A person has to choose.
Under the old protocol the tool would have interrupted itself and asked. The server had a
channel back to the client in the middle of a call, the client showed a prompt, and the
answer came back inside the same call. Under 2026-07-28 that channel is gone. There is
nothing to ask on. Part 1 covered the revision that took it away, and the result type that
replaces it.
So the call has to end. It ends early, carrying the question, and it gets called a second time with the answer. Everything below is about building that, and about what it costs you.
The smallest server that works
Create a virtual environment and install the SDK:
python -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]"
Everything in this tutorial was checked against mcp==2.0.0 on Python 3.11.8 on
2026-07-29. pip install mcp now resolves to the 2.x line, which will surprise a build
that isn't expecting it. If you have v1 code that isn't ready to move, pin it today:
mcp>=1.28,<2. Nothing else in this article works on v1, because v1 can't speak this
protocol revision.
One thing about the first person in what follows. I built the room booking server for this article and ran every line of it that day. So when I say something surprised me, it surprised me here, at my desk, and not in a system carrying anyone's real bookings.
The finished server is on GitHub at mapell-dev/mcp-2026-07-28-room-desk, alongside the C# version from Part 3. Read the repository's warnings before you borrow anything from it, because some of what follows is broken on purpose.
Here's a server that does one useful thing:
from mcp.server import MCPServer
mcp = MCPServer(
"Room Desk",
instructions="Check which meeting rooms are free before booking one.",
)
ROOMS = {
"aurora": {"seats": 4, "floor": 2},
"basalt": {"seats": 8, "floor": 2},
"cinder": {"seats": 8, "floor": 5},
}
BOOKED: set[tuple[str, str]] = {("basalt", "2026-08-03")}
def _free_rooms(date: str, attendees: int) -> list[str]:
return sorted(
name
for name, room in ROOMS.items()
if room["seats"] >= attendees and (name, date) not in BOOKED
)
@mcp.tool()
def check_availability(date: str, attendees: int) -> str:
"""List the meeting rooms free on a date that seat this many people."""
free = _free_rooms(date, attendees)
if not free:
return f"No room on {date} seats {attendees}."
return f"Free on {date} for {attendees}: {', '.join(free)}."
@mcp.resource("rooms://all")
def all_rooms() -> str:
"""Every room the desk knows about."""
return "\n".join(f"{n}: {r['seats']} seats, floor {r['floor']}" for n, r in ROOMS.items())
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=3001)
Look at what you didn't write. You wrote no JSON Schema and no code that touches the protocol, and you never parsed an argument. Ask the server for its tool list and the docstring has become the description, word for word, while the parameter names and type hints have become the schema:
check_availability | List the meeting rooms free on a date that seat this many people.
{"type": "object",
"properties": {"date": {"title": "Date", "type": "string"},
"attendees": {"title": "Attendees", "type": "integer"}},
"required": ["date", "attendees"]}
Three changes will trip you up if you're coming from v1. FastMCP is now MCPServer.
Result attributes are snake_case in Python (result.is_error, tool.input_schema) while
the JSON on the wire stays camelCase. Transport options moved from the constructor to
run(), so MCPServer("x", port=9000) raises a TypeError instead of doing what you
meant.
Before you start it, answer this: a client that has never seen your server needs to know which protocol versions you speak and which capabilities you have. How many requests does that take, and what does the reply look like? Hold your answer for a minute.
Look at the wire
Start it with python server.py. It listens on http://127.0.0.1:3001/mcp.
Now poke it with curl, once. I'm not suggesting you build anything this way. Once you've
seen the raw JSON you stop being nervous about the protocol, and five minutes now saves you
an afternoon later:
curl -s http://127.0.0.1:3001/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
The answer, which arrives on one line and is spread out here so you can read it:
{"jsonrpc": "2.0", "id": 1, "result": {
"supportedVersions": ["2026-07-28"],
"capabilities": {
"prompts": {"listChanged": true},
"resources": {"listChanged": true, "subscribe": true},
"tools": {"listChanged": true}
},
"instructions": "Check which meeting rooms are free before booking one.",
"ttlMs": 0,
"cacheScope": "private",
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Room Desk", "version": ""}}
}}
That's the answer to the challenge: one request, and the handshake and the session id that used to come before it are both gone.
Read the two cache fields closely, because they aren't my server's opinion. ttlMs says
how long a client may treat the answer as fresh, the way Cache-Control: max-age does, and
0 means stale on arrival. cacheScope of private stops a shared cache serving my answer
to another user. Both are required on this revision, and the SDK supplies exactly these
values when a handler says nothing, so silence can't turn on shared caching by accident.
Two other fields matter later. Every modern result carries a resultType, and server
identity now arrives inside _meta under a reverse-DNS key instead of from a handshake.
Two mistakes are easy to make by hand. Make them on purpose now, while you're watching, and
they cost you nothing. Before you try them, guess which one the server treats more
strictly: sending
the version under a bare protocolVersion key instead of the namespaced one, or leaving
out the Mcp-Method header when the method is already in the body.
Take the first one. Drop the io.modelcontextprotocol/ prefix and Python tells you exactly
what it wanted:
{"code": -32602,
"message": "params._meta is missing the required envelope key(s): io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities"}
Now remove the Mcp-Method header. I expected a warning here, or nothing at all, since the
method sits right there in the body and the server can simply read it. What comes back is
-32020, the same error you get when the header contradicts the body. Omitting it and
lying about it are treated identically.
{"code": -32020, "message": "mcp-method header does not match the request body's method"}
So neither mistake is more forgiving than the other. That's the answer to the guess: both stop the request dead, and the header case does it while the server is holding everything it needs to carry on. The header isn't advisory routing metadata on a modern HTTP request. It's required, and the people who meet it first are the ones writing a proxy or a test harness, which is exactly the group least likely to have read that part of the spec.
There's a second header with the same rule, and I only found it by getting it wrong. When
you call a tool by hand, Mcp-Method: tools/call isn't enough. The tool name has to be in
a header too:
{"code": -32020, "message": "mcp-name header does not match the request body's 'name' parameter"}
So a hand-built tool call carries both Mcp-Method: tools/call and
Mcp-Name: book_room. An Accept header, despite what every snippet library shows you,
is optional.
That's as far as curl takes you. From here on, use the SDK client, because hand-building
the multi-round-trip you're about to see is a waste of your afternoon.
The tool that has to ask
Back to the booking. The obvious version is short, and it runs:
@mcp.tool()
def book_room(date: str, attendees: int) -> str:
"""Book a meeting room."""
free = _free_rooms(date, attendees)
BOOKED.add((free[0], date))
return f"Booked {free[0]} on {date} for {attendees}."
It passes its tests. It's also wrong, and the bug is not in the code. Before you read on, work out where it is.
Here's the answer. _free_rooms sorts its results and this takes the first one, so the
booking from the hook gets basalt. Basalt is not the room that meeting needs. It's the room
whose name starts with a b. Whenever more than one room fits, alphabetical order makes a
decision that belongs to a person, and nothing in the output tells you it happened.
You can't fix that by choosing better inside the tool, because the tool doesn't have the
information. Neither does the model, and this is the tempting wrong turn: you could add a
room parameter, let the model fill it in from the conversation, and be done. Then the
model guesses on the turns where the user never said, and a wrong guess here books a real
room.
So state the requirement properly. When more than one room fits, the choice belongs to the person booking, and nothing may invent it. Here's the version that does that:
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
class RoomChoice(BaseModel):
room: Literal["aurora", "basalt", "cinder"] = Field(description="Which room to book")
async def pick_room(date: str, attendees: int) -> RoomChoice | Elicit[RoomChoice]:
"""Resolver: decide the room, asking the user only when the choice is real."""
free = _free_rooms(date, attendees)
if not free:
raise ValueError(f"No room on {date} seats {attendees}.")
if len(free) == 1:
return RoomChoice(room=free[0])
return Elicit(f"Rooms free on {date}: {', '.join(free)}. Which one?", RoomChoice)
@mcp.tool()
async def book_room(
date: str,
attendees: int,
choice: Annotated[ElicitationResult[RoomChoice], Resolve(pick_room)],
) -> str:
"""Book a meeting room, asking which one when more than one fits."""
if not isinstance(choice, AcceptedElicitation):
return "Nothing booked."
room = choice.data.room
BOOKED.add((room, date))
return f"Booked {room} on {date} for {attendees}."
The tool body still reads like a function. It takes a room and books it. Everything about questions and second calls lives in the annotation.
Two details in that resolver are doing real work. The choice parameter never appears in
the schema the model sees, because it's resolved rather than supplied, so a client can't
pass it and can't get it wrong. Ask the server what book_room takes and choice isn't in
the answer:
book_room | Book a meeting room, asking which one when more than one fits.
{"type": "object",
"properties": {"date": {"title": "Date", "type": "string"},
"attendees": {"title": "Attendees", "type": "integer"}},
"required": ["date", "attendees"]}
And pick_room asks only when there's a genuine choice. If one room is free it books it
and the second call never happens. That isn't an optimization I bolted on afterwards. It's
the design: the question exists because the information is missing, not because the
parameter exists.
Run it and you'll see the question and then the booking:
check_availability: Free on 2026-08-04 for 6: basalt, cinder.
[asked] Rooms free on 2026-08-04: basalt, cinder. Which one?
book_room: Booked cinder on 2026-08-04 for 6.
One limit to know before you ship this. Declining is an answer, and it's the answer you'll
get most often from a user who has changed their mind. ElicitationResult[T] is what lets
the tool branch on that, which is why book_room checks
isinstance(choice, AcceptedElicitation) and returns "Nothing booked." Annotate the
parameter as the plain RoomChoice instead, and the decline never reaches your code. The
call comes back as an error saying the resolver could not resolve, and the tool body never
runs, so it never gets to clean up.
The two legs, on the wire
The annotation is pleasant to write and opaque when something goes wrong, so look at what it built for you.
One prediction first. The tool couldn't finish, so this can't have returned a booking. What came back instead, and what would a client need in order to finish the job? Two things, at least.
Here it is, captured with curl on a different date from the run above. The requestState
value is cut short:
{"jsonrpc": "2.0", "id": 10, "result": {
"inputRequests": {"__main__:pick_room": {
"method": "elicitation/create",
"params": {
"message": "Rooms free on 2026-08-12: basalt, cinder. Which one?",
"mode": "form",
"requestedSchema": {"type": "object", "required": ["room"], "properties": {
"room": {"type": "string", "title": "Room",
"description": "Which room to book",
"enum": ["aurora", "basalt", "cinder"]}}}}}},
"requestState": "v1.zwWV0vXkfXq-nH58sBsfHCgankq5cAx2ugbkXZluiZ2zsGteWjnUb7er...",
"resultType": "input_required",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Room Desk", "version": ""}}
}}
No content, because there isn't any yet. The result carries the question, an opaque
requestState string, and a resultType of "input_required" instead of "complete".
If you predicted the question and the schema of the answer, you got the visible half. The
requestState is the half people miss, and it comes back in the last section with a bill
attached.
The second call repeats the same tool and the same arguments, hands the requestState
straight back, and adds the answer under a matching key:
"inputResponses": {"__main__:pick_room": {"action": "accept", "content": {"room": "cinder"}}}
That one returns the booking, with resultType back to "complete". Nothing travelled
from server to client except results. That's the whole change:
The server returns its questions instead of asking them.
Two Python-specific details deserve a look. The key __main__:pick_room was derived from
the resolver rather than chosen by you, because you never named the question. And the
requestedSchema carries "enum": ["aurora", "basalt", "cinder"], which Python got from
the Literal on RoomChoice.room. A client with a real UI can render that as three
buttons rather than a free text box, and you got it from a type hint.
One tool body, two protocol eras
New requirement, and it's the one that decides whether you can deploy this at all: a client that predates the new spec has to keep working. Some desktop clients stay unupgraded for years, and you don't control any of them.
Count the branches you think that needs. A tool that can ask a question two different ways
sounds like at least one if in book_room, probably a version check, and a second code
path to test. Write your number down.
You write zero. Here's the client half of the example, and the tool above doesn't change at all:
import asyncio
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult
from server import mcp
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
"""Stand in for a real UI: always pick cinder."""
print(f" [asked] {params.message}")
return ElicitResult(action="accept", content={"room": "cinder"})
async def main() -> None:
async with (
Client(mcp, elicitation_callback=answer) as modern,
Client(mcp, mode="legacy", elicitation_callback=answer) as legacy,
):
for label, client, date in (("modern", modern, "2026-08-04"), ("legacy", legacy, "2026-08-05")):
print(f"--- {label}: {client.protocol_version} ---")
free = await client.call_tool("check_availability", {"date": date, "attendees": 6})
print("check_availability:", free.content[0].text)
booked = await client.call_tool("book_room", {"date": date, "attendees": 6})
print("book_room:", booked.content[0].text)
if __name__ == "__main__":
asyncio.run(main())
Two clients, one server object, one callback, and this comes out:
--- modern: 2026-07-28 ---
check_availability: Free on 2026-08-04 for 6: basalt, cinder.
[asked] Rooms free on 2026-08-04: basalt, cinder. Which one?
book_room: Booked cinder on 2026-08-04 for 6.
--- legacy: 2025-11-25 ---
check_availability: Free on 2026-08-05 for 6: basalt, cinder.
[asked] Rooms free on 2026-08-05: basalt, cinder. Which one?
book_room: Booked cinder on 2026-08-05 for 6.
The two runs used different mechanisms underneath. On the modern connection the question
travelled inside the tools/call result and came back on a second call. On the legacy
connection the server opened a request of its own, elicitation/create, exactly as it
always did. The mode="legacy" client asked for that by doing an initialize handshake,
and negotiation landed on 2025-11-25. The tool body never learned which one it was in.
So the branch you counted does exist. It lives in the SDK instead of in your tool.
Now for the limit. It's the one place a ported v1 server changes behavior. All of this
works because the question goes through Resolve(...). A v1 tool that calls ctx.elicit()
in the middle of its own body is asking for a channel a modern connection doesn't have, and
the SDK says so:
mcp.shared.exceptions.NoBackChannelError: Cannot send 'elicitation/create':
this transport context has no back-channel for server-initiated requests.
That's raised, not returned as a failed tool result, so it comes out of call_tool on the
client. When you migrate, every ctx.elicit() is a rewrite.
Stateless, and what it costs you
The word gets misread constantly, so deal with it before it costs you a design. Your server
has been holding BOOKED in a module-level set this entire time, across both eras, and
it's a conforming stateless server. Stateless describes the protocol interaction: no
request depends on a particular server process having handled an earlier one. Your
application keeps whatever state it likes.
Now the bill, which is the part I find people skip.
Look again at the requestState your server sent. You didn't write that string. The SDK
built it and sealed it, and the sealing matters because it leaves your process and comes
back as input from a client you don't control. The Python SDK binds each token to a time
window (600 seconds by default), to the request that produced it, meaning the method, the
tool name and a digest of the arguments, and to the authenticated principal.
You can watch the binding work. Take a valid requestState, change one argument in the
second call, and the server refuses it:
{"code": -32602, "message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}}
The default key is minted per process, and that's the detail that turns into an incident.
Two replicas behind a load balancer, or one restart between the two legs, and the second
call lands somewhere that can't open the token. It's rejected with that same message: a
tampered token and a wrong key look identical from the error alone. The cure is to pass
your own keys with RequestStateSecurity(keys=[...]), shared across every instance. Do
that before the second replica exists, not after the first incident.
One more cost, and this one has no setting to fix it. This revision drops stream
resumption. A dropped connection doesn't resume where it left off, and nothing gets
replayed to you: the client re-listens and refetches, and the operation is sent again as a
new request. The Last-Event-ID machinery is still in the SDK, but it serves the older
revisions the SDK also speaks, and it will never run on your 2026-07-28 traffic.
Two different things are about to get called a retry, so here are the names I'll use. The second call is the legitimate leg that carries the answer back. A reissue is the duplicate that a dropped connection causes. The protocol needs the second call and offers you nothing against a reissue.
So answer the question for the tool you just built. Is book_room safe to run twice?
I thought I knew this answer. It books twice, obviously, because nothing in the body checks whether this booking already happened. So I sent the request again, new JSON-RPC id and all, the way a dropped connection would. Here's what came back:
{"content": [{"type": "text", "text": "Booked basalt on 2026-08-12 for 6."}],
"resultType": "complete"}
Basalt. The user chose cinder, once, and the reissue handed them the room they had turned down.
Follow the mechanism, because it can happen in any tool whose resolver runs again and reads
state that has changed. The resolver runs on every leg. By the time the reissue arrived, cinder was
already booked by the legitimate second call, so _free_rooms returned basalt alone,
pick_room took its single-candidate branch, and it never looked at the inputResponses
entry naming cinder. The resolver threw away the user's answer and made a fresh one against
state that had changed since they gave it.
The seal doesn't save you here, and it isn't supposed to. That reissue is a legitimate request in every way the seal can check, which is exactly what it's built to accept. The fix is an idempotency key the caller supplies and the tool stores with the booking, so the second attempt recognizes itself and returns the first result. I'm leaving the bug in the example on purpose, because it's the honest shape of the migration: the protocol handed back a responsibility it used to carry, and a tutorial that hides that is lying to you.
What this doesn't give you
Tasks aren't in the Python SDK. The 2.0.0 release removed the experimental Tasks API and
says plainly that it doesn't implement the official extension yet. If your real problem is
work that runs for ten minutes, this SDK isn't where you solve it today, and I'd rather you
learn that here than three days into a design.
The protocol tells a client that input is required. It has nothing to say about what that means in your product. A dialog, a terminal prompt, a Slack message, an approval queue that nobody reads until Monday: all of those are conforming, and choosing between them is product work, not protocol work.
And none of this decides which tools an agent may call without a human watching. That
question was yours before 2026-07-28 and it still is.
What to do on Monday
- Upgrade to 2.x and confirm your existing integrations still pass, before you change a single tool. Do the version bump and the redesign as two separate changes.
- Grep for
ctx.elicit, forinitialize, for session ids, and for anything that expects a stream to resume. That list is your migration, and it's usually shorter than people fear. - Log the negotiated protocol version on every request. You can't plan a cutover until you know who still speaks the old one.
- Set
RequestStateSecurity(keys=[...])before you run a second replica. - For every tool that can ask a question, work out what a reissue does when the answer is already stale. Mine gave away the wrong room.
The same server exists in C#, and it needs one thing this one didn't. There's a single
boolean in the ASP.NET setup that most people read as a scaling preference. Set it wrong
and the server quietly refuses to speak 2026-07-28 at all. That's Part 3.