Follow
← all articles

The Stateless MCP · Part 2

code-agents · 17 min read

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.

M

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

  1. 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.
  2. Grep for ctx.elicit, for initialize, 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.
  3. Log the negotiated protocol version on every request. You can't plan a cutover until you know who still speaks the old one.
  4. Set RequestStateSecurity(keys=[...]) before you run a second replica.
  5. 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.