PromptisePromptise
Docs
GitHubGet started
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Tools and the Model Context Protocol (MCP)

MCP 2026-07-28 Migration: Make Your MCP Server Stateless

Migrate an MCP server to the stateless 2026-07-28 spec: what changed, how to replace sessions and elicitation, and a runnable before and after in Python.

Level
Advanced
Reading time
22 min
Published
Oct 22, 2026
Last updated
Oct 22, 2026
Tested with
Promptise 1.6.0
By
Promptise Team
  • Model Context Protocol (MCP)
  • Deployment
  • Human-in-the-Loop
Contents · 18 sections↓
  1. 01How do you migrate an MCP server to the 2026-07-28 spec?
  2. 02What changed in MCP 2026-07-28?
  3. 03How does a stateless MCP request work?
  4. 04When do you need to migrate, and when not?
  5. 05What you need
  6. 06Step 1: Find the session state in your server
  7. 07Step 2: Upgrade the SDK and check that both eras answer
  8. 08Step 3: Replace session state with server-minted handles
  9. 09Step 4: Move user questions out of server callbacks
  10. 10Step 5: Replace sampling, roots and logging
  11. 11Step 6: Serve it stateless behind a load balancer
  12. 12Step 7: Test both protocol eras
  13. 13The complete example
  14. 14Running a stateless MCP server in production
  15. 15Common problems and fixes
  16. 16Limitations
  17. 17FAQ
  18. 18Next steps

The MCP 2026-07-28 spec makes the protocol stateless: no initialize handshake, no Mcp-Session-Id, and no requests from the server back to the client. This guide is for developers who run an MCP server and need to migrate it. You will learn what changed, which parts of a typical server break, and how to fix each one: session state, elicitation, sampling and load balancing. Every step has a runnable before and after server in Python with real output.

[01]

How do you migrate an MCP server to the 2026-07-28 spec?

Upgrade to an SDK that speaks 2026-07-28 (the MCP Python SDK 2.x does, and serves older clients from the same endpoint). Then remove everything that relies on a session: keep cross-call state in a shared store behind a handle the server returns as a tool result, and stop calling the client mid-request, because elicitation, sampling and roots now travel as multi round-trip requests. Finally, serve the app without sticky sessions and test it with both a 2026-07-28 client and a 2025-11-25 client.

  • The protocol is stateless, your app may not be. Each request carries its protocol version, client info and capabilities in _meta, so any replica can answer it. Data your tools need across calls still has to live somewhere every replica can reach.

  • Sessions become handles. A tool returns an ID such as cart_id, and the model passes it to the next tool as an ordinary argument.

  • The server asks by returning. A tool that needs user input returns an input_required result, and the client retries the call with the answers.

  • Sampling, roots and logging are deprecated, not removed. They keep working for at least twelve months under the new deprecation policy.

  • Old clients keep working if your SDK serves both eras. The MCP Python SDK 2.x does, and so does a Promptise Foundry 1.6.0 server installed with it.


[02]

What changed in MCP 2026-07-28?

The 2026-07-28 changelog lists nine major changes. These are the ones that affect a server, with what to do about each:

Change

What breaks

What to do

initialize handshake removed; version and capabilities sent in every request's _meta

Code that reads client capabilities once per session

Read them per request (the SDK does this)

Mcp-Session-Id and protocol sessions removed

Anything keyed by session: carts, logins, scratch data

Server-minted handles in a shared store

server/discover added, and servers must implement it

Nothing, if your SDK provides it

Upgrade the SDK

Server-to-client requests replaced by multi round-trip requests

elicitation/create, sampling/createMessage and roots/list sent mid-call

Return the question as input_required

HTTP GET stream and resources/subscribe replaced by subscriptions/listen

List-changed and resource-updated notifications

Send them on the listen stream (the SDK does this)

ping and logging/setLevel removed

Log level set once per session

The level comes per request in _meta

SSE resumability (Last-Event-ID) removed

Clients that resumed broken streams

A broken stream loses the request; the client sends it again

Every result has a resultType

Hand-built JSON results

"complete" or "input_required"

Tasks moved to the io.modelcontextprotocol/tasks extension

Experimental task support

Use the extension where your SDK has it

The spec also deprecates Roots, Sampling, Logging, the old HTTP+SSE transport and OAuth Dynamic Client Registration. Deprecated features stay in the spec for at least twelve months under the new feature lifecycle policy. The Streamable HTTP transport now requires MCP-Protocol-Version, Mcp-Method and, for tool calls, Mcp-Name headers on every POST, so gateways can route requests without parsing the body.


[03]

How does a stateless MCP request work?

Under 2025-11-25, a client opened a session with initialize, the server returned an Mcp-Session-Id, and every later call had to reach the process that held that session. With several replicas that meant sticky routing, and a restart cost every session. Under 2026-07-28, each POST is complete on its own:

Two panels. Left, 2025-11-25: a client with an Mcp-Session-Id goes through a sticky load balancer to Replica A, which holds the session in memory; Replica B answers 404 for it, and the server calls the client back with elicitation/create. Right, 2026-07-28: each request carries its version in _meta, any replica can answer, and state lives in a shared store behind a cart_id handle.
FigureMCP before and after 2026-07-28: from pinned sessions to self-contained requests

This is the whole client side of a 2026-07-28 tool call. There is no handshake before it and nothing to clean up after it:

Pythonmodern_call.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
"""Send 2026-07-28 requests by hand: no initialize, no session, one POST each."""
import itertools
import json
import sys

import httpx

VERSION = "2026-07-28"
_ids = itertools.count(1)


def rpc(url: str, method: str, params: dict | None = None) -> dict:
    params = dict(params or {})
    params["_meta"] = {
        "io.modelcontextprotocol/protocolVersion": VERSION,
        "io.modelcontextprotocol/clientInfo": {"name": "probe", "version": "1.0"},
        "io.modelcontextprotocol/clientCapabilities": {},
    }
    headers = {
        "Accept": "application/json, text/event-stream",
        "MCP-Protocol-Version": VERSION,
        "Mcp-Method": method,
    }
    if "name" in params:
        headers["Mcp-Name"] = params["name"]
    body = {"jsonrpc": "2.0", "id": next(_ids), "method": method, "params": params}
    resp = httpx.post(url, json=body, headers=headers, timeout=10)
    if resp.headers.get("content-type", "").startswith("text/event-stream"):
        data = [l[5:] for l in resp.text.splitlines() if l.startswith("data:")]
        return json.loads(data[-1])
    return {"http": resp.status_code, "session": resp.headers.get("mcp-session-id"), **resp.json()}


def call(url: str, tool: str, **arguments) -> str:
    reply = rpc(url, "tools/call", {"name": tool, "arguments": arguments})
    if "error" in reply:
        return f"error {reply['error']['code']}: {reply['error']['message']}"
    return reply["result"]["content"][0]["text"]


if __name__ == "__main__":
    url = sys.argv[1]
    print(json.dumps(rpc(url, "server/discover"), indent=2)[:900])

Run it against a server and it calls server/discover, the new method every server must implement. It returns the versions, capabilities and identity that initialize used to return. Note ttlMs and cacheScope: list results are now cacheable, and the server says for how long.

Output
{
  "http": 200,
  "session": null,
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "cacheScope": "private",
    "capabilities": {
      "prompts": {
        "listChanged": false
      },
      "resources": {
        "listChanged": false,
        "subscribe": false
      },
      "tools": {
        "listChanged": false
      }
    },
    "resultType": "complete",
    "supportedVersions": [
      "2026-07-28"
    ],
    "ttlMs": 0,
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "cart",
        "version": "0.1.0"
      }
    }
  }
}

"session": null is the Mcp-Session-Id response header. There isn't one.


[04]

When do you need to migrate, and when not?

Clients move to 2026-07-28 on their own schedule. The MCP Python SDK 2.x Client uses mode="auto" by default: it tries 2026-07-28 first and falls back to initialize if the server answers with an old-style error. So the question isn't whether your server will see 2026-07-28 traffic, but when.

Migrate now if:

  • you run more than one replica and pay for sticky sessions;

  • you deploy on serverless platforms, where a process rarely sees two requests from the same client;

  • your tools ask the user questions or call sampling mid-request, because those calls fail on 2026-07-28 connections;

  • your server is public and you can't control which client versions connect.

You can wait if your server runs locally over stdio for one client, uses no session state, and never calls the client back. Upgrading the SDK alone then covers you. Keep the legacy path either way: some clients will speak 2025-11-25 for months.


[05]

What you need

  • An MCP server you can change, and Python 3.10 or later for the examples.

  • The MCP Python SDK 2.x. Version 2.0.0 shipped on 2026-07-28 with support for both protocol eras; the examples use 2.3.0.

  • For the Foundry examples, Promptise Foundry 1.6.0, which accepts mcp>=1.9,<3.

  • An OpenAI API key, only for the agent at the end.

>_Terminal
uv venv && uv pip install "promptise==1.6.0" "mcp==2.3.0" uvicorn httpx python-dotenv

If your lock file pins mcp<2, your server can't answer 2026-07-28 requests. On mcp 1.30.0 the same server answered a 2026-07-28 request with 400 Bad Request: Unsupported protocol version: 2026-07-28, which a 2026 client in auto mode reads as "fall back to initialize". That works, but the server is not migrated.


[06]

Step 1: Find the session state in your server

Most servers hold more session state than their authors think. Search your code for these before you change anything:

  • Per-session storage. Foundry's SessionState, dictionaries keyed by session ID, objects created when a session opens.

  • Things learned at `initialize`. Client capabilities, client name or a protocol version cached for the session.

  • Calls back to the client. ctx.elicit(), Foundry's Elicitor.ask(), create_message (sampling), list_roots.

  • Server-pushed notifications. tools/list_changed, resource subscriptions, log messages sent outside a request.

  • Long-running work tied to a stream, such as a job whose progress the client resumes with Last-Event-ID.

Here is a typical 2025-era server. It keeps the cart in the session and confirms checkout with elicitation:

Pythonserver_before.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
"""Before: a 2025-era MCP server that keeps state in the session."""
from promptise.mcp.server import Depends, Elicitor, MCPServer, SessionState

server = MCPServer(name="cart")


@server.tool()
async def add_item(sku: str, session: SessionState = Depends(SessionState)) -> dict:
    """Add a product to the cart."""
    items = session.get("items", [])
    items.append(sku)
    session.set("items", items)
    return {"items": items}


@server.tool()
async def checkout(
    session: SessionState = Depends(SessionState),
    elicit: Elicitor = Depends(Elicitor),
) -> str:
    """Place the order for everything in the cart."""
    items = session.get("items", [])
    answer = await elicit.ask(
        f"Place an order for {len(items)} item(s)?",
        {"type": "object", "properties": {"confirm": {"type": "boolean"}}},
    )
    if not answer or not answer.get("confirm"):
        return "Cancelled: not confirmed."
    session.set("items", [])
    return f"Ordered {items}."


app = server.asgi_app()  # uvicorn server_before:app --port 8001

[07]

Step 2: Upgrade the SDK and check that both eras answer

With mcp 2.x installed, the SDK routes each request by its protocol version before your code sees it. A 2026-07-28 request takes the new single-exchange path, and an initialize takes the legacy path. You don't configure this. Check it with the official 2.x client, which picks the version itself:

Pythonsdk_client.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
"""The official MCP Python SDK 2.x client picks the protocol version itself."""
import asyncio

from mcp import Client


async def main() -> None:
    for name, port in (("before", 8001), ("after", 8002)):
        async with Client(f"http://127.0.0.1:{port}/mcp") as client:
            tools = await client.list_tools()
            print(f"{name}: protocol {client.protocol_version}, tools {[t.name for t in tools.tools]}")


asyncio.run(main())
Output
before: protocol 2026-07-28, tools ['add_item', 'checkout']
after: protocol 2026-07-28, tools ['create_cart', 'add_item', 'checkout']

Both servers negotiated 2026-07-28, including the unchanged before server. That is the trap: the protocol upgrade succeeds, and the server's behavior breaks quietly. Call the before server the 2026-07-28 way, one request at a time, and look at the first block of output:

Pythonprobe.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""Call both servers the 2026-07-28 way: every request on its own."""
import json

from modern_call import call, rpc

BEFORE = "http://127.0.0.1:8001/mcp"
AFTER = "http://127.0.0.1:8002/mcp"

print("== before: session state and elicitation")
print("add_item ->", call(BEFORE, "add_item", sku="mug"))
print("add_item ->", call(BEFORE, "add_item", sku="tea"))
print("checkout ->", call(BEFORE, "checkout"))

print("\n== after: a handle the server minted")
cart = json.loads(call(AFTER, "create_cart"))["cart_id"]
print("create_cart ->", cart)
print("add_item ->", call(AFTER, "add_item", cart_id=cart, sku="mug"))
print("add_item ->", call(AFTER, "add_item", cart_id=cart, sku="tea"))
print("checkout ->", call(AFTER, "checkout", cart_id=cart))
print("checkout ->", call(AFTER, "checkout", cart_id=cart, confirmed=True))
Output
== before: session state and elicitation
add_item -> {"items": ["mug"]}
add_item -> {"items": ["tea"]}
checkout -> Cancelled: not confirmed.

== after: a handle the server minted
create_cart -> cart_a6770a05
add_item -> {"cart_id": "cart_a6770a05", "items": ["mug"]}
add_item -> {"cart_id": "cart_a6770a05", "items": ["mug", "tea"]}
checkout -> Not placed. Ask the user to confirm 2 item(s): ['mug', 'tea'].
checkout -> Ordered ['mug', 'tea'].

In the before block, the second add_item returns only ["tea"], because each request got a fresh, empty session. And checkout cancels without asking anyone: Foundry logged a warning that the elicitation request failed with NoBackChannelError and treated it as a decline. No error reached the client. The after block is what the next steps build.


[08]

Step 3: Replace session state with server-minted handles

The spec's answer to session state is the handle: an ID the server creates, returns as a tool result and receives back as an ordinary tool argument. The model carries it from one call to the next, the way it carries an order number. The state itself goes into a store every replica can reach. The example uses SQLite so it runs anywhere; in production that is Postgres, Redis or your own API.

Pythonserver_after.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
"""After: a stateless MCP server. State lives in a shared store, behind a handle."""
import json
import sqlite3
import uuid

from promptise.mcp.server import MCPServer, ToolError

server = MCPServer(name="cart")
DB = "carts.db"  # any store every replica can reach: Postgres, Redis, ...


def store() -> sqlite3.Connection:
    conn = sqlite3.connect(DB, timeout=5)
    conn.execute("CREATE TABLE IF NOT EXISTS carts (id TEXT PRIMARY KEY, items TEXT)")
    return conn


def load(cart_id: str) -> list[str]:
    with store() as conn:
        row = conn.execute("SELECT items FROM carts WHERE id = ?", (cart_id,)).fetchone()
    if row is None:
        raise ToolError(f"Unknown cart_id {cart_id!r}. Call create_cart first.")
    return json.loads(row[0])


@server.tool()
async def create_cart() -> dict:
    """Start a new cart. Pass the returned cart_id to the other cart tools."""
    cart_id = f"cart_{uuid.uuid4().hex[:8]}"
    with store() as conn:
        conn.execute("INSERT INTO carts VALUES (?, '[]')", (cart_id,))
    return {"cart_id": cart_id}


@server.tool()
async def add_item(cart_id: str, sku: str) -> dict:
    """Add a product to a cart."""
    items = load(cart_id) + [sku]
    with store() as conn:
        conn.execute("UPDATE carts SET items = ? WHERE id = ?", (json.dumps(items), cart_id))
    return {"cart_id": cart_id, "items": items}


@server.tool(destructive_hint=True)
async def checkout(cart_id: str, confirmed: bool = False) -> str:
    """Place the order for a cart. Ask the user first, then call with confirmed=true."""
    items = load(cart_id)
    if not confirmed:
        return f"Not placed. Ask the user to confirm {len(items)} item(s): {items}."
    with store() as conn:
        conn.execute("DELETE FROM carts WHERE id = ?", (cart_id,))
    return f"Ordered {items}."


app = server.asgi_app(stateless=True)  # uvicorn server_after:app --port 8002 --workers 4

Three details matter:

  • The tool description tells the model what to do with the handle: "Pass the returned cart_id to the other cart tools." In the complete example below, the model passed it along with no other instruction.

  • An unknown handle is a tool error the model can act on, not a crash. "Call create_cart first" tells it how to recover.

  • Handles are not credentials. Anyone who learns a cart ID can pass it. When a handle points at private data, check on every call that it belongs to the authenticated caller, as you would for an order ID in a REST API.


[09]

Step 4: Move user questions out of server callbacks

Under 2025-11-25, a tool could pause and send elicitation/create to the client over the open stream. Under 2026-07-28 there is no stream to send it on. Instead the server returns its question, and the client retries the original call with the answer. The spec calls this pattern multi round-trip requests (MRTR):

A sequence diagram. The client sends tools/call checkout with id 1. The server answers input_required with an elicitation in inputRequests. The client asks the user, then sends tools/call checkout again with id 2, adding inputResponses and requestState. The server answers with a complete result.
FigureMulti round-trip requests: at 2026-07-28 the server returns its question instead of sending one

The retry is a new request with a new JSON-RPC ID, so any replica can handle it. Anything the server needs to remember between the two goes into requestState, an opaque string the client must echo back unchanged. The spec says to treat it as attacker-controlled input: if it affects authorization or business logic, protect it with an HMAC or AEAD and include the caller, a short expiry and the original request in it.

You have four ways to port a confirmation step:

  1. The SDK's resolver. In the MCP Python SDK 2.x MCPServer, a parameter annotated with Resolve(fn) whose function returns Elicit(...) elicits directly on a legacy connection and runs the multi round-trip on 2026-07-28, from one tool body. The SDK migration guide shows the pattern.

  2. A two-step tool. checkout(cart_id, confirmed=False) returns "Ask the user to confirm" first, and does the work only with confirmed=true. It works on every protocol version and every client. The tradeoff: the model relays the question, so the server trusts the model to have asked.

  3. Approval in the agent. If your own agent calls the server, enforce confirmation there. The complete example below uses Promptise's ApprovalPolicy.

  4. A server-side approval queue. Foundry's PendingApprover holds a gated call until a person with the approver role decides through the approvals_decide tool. Its store is process-local, so with several replicas the reviewer's call must reach the replica that holds the pending request.

What Foundry 1.6.0 does on a 2026-07-28 connection, from its source: Elicitor.ask() returns None (treated as a decline), Sampler.create_message() returns None, and an ElicitationApprover gate denies the call fail-closed. Foundry doesn't implement multi round-trip requests yet. If a tool must ask the user mid-call on 2026-07-28 connections, write that tool with the SDK's resolver, or use options 2 to 4.


[10]

Step 5: Replace sampling, roots and logging

These three features are deprecated, not removed, and keep working for at least twelve months. New code shouldn't use them, and on 2026-07-28 connections sampling and roots only work through multi round-trip requests. The spec suggests these replacements:

  • Sampling: call an LLM provider's API from the server with your own key, and put the cost in your budget rather than the client's.

  • Roots: pass directories or files as tool parameters, resource URIs or server configuration.

  • Logging: write to stderr on stdio, or use OpenTelemetry. On 2026-07-28 the client opts into log messages per request with io.modelcontextprotocol/logLevel in _meta, and the server must not send notifications/message for requests without it.


[11]

Step 6: Serve it stateless behind a load balancer

For 2026-07-28 clients, the server is already sessionless. Older clients still open sessions on the legacy path, and those sessions live in one process. Foundry's asgi_app(stateless=True) turns that off for the legacy path too: no Mcp-Session-Id is issued, and every request is served on its own. The option exists on asgi_app() only, not on run(), and applies to Streamable HTTP only.

>_Terminal
uvicorn server_after:app --host 0.0.0.0 --port 8002 --workers 4

The probe output in Step 2 came from exactly this: four uvicorn workers with no sticky routing. Each of the five calls on one cart could land on any of the four workers, and the cart survived because it lives in the database. With the old in-memory SessionState, the same setup would lose items on almost every call.

The price of stateless=True is the legacy back-channel. A legacy client on a stateless server can't receive elicitation or sampling requests either, which is the same rule 2026-07-28 applies to everyone. The MCP Python SDK docs describe its own stateless_http flag the same way.


[12]

Step 7: Test both protocol eras

Test what each kind of client actually does. First the legacy handshake, raw and through Promptise's own client:

Pythonprobe_legacy.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
"""The same servers from a 2025-era client: the initialize handshake."""
import asyncio

import httpx

from promptise.mcp.client import MCPClient

INIT = {
    "jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {"protocolVersion": "2025-11-25", "capabilities": {},
               "clientInfo": {"name": "probe", "version": "1.0"}},
}
HEADERS = {"Accept": "application/json, text/event-stream"}


async def main() -> None:
    for name, port in (("before", 8001), ("after", 8002)):
        url = f"http://127.0.0.1:{port}/mcp"
        resp = httpx.post(url, json=INIT, headers=HEADERS)
        print(f"{name}: initialize -> {resp.status_code}, mcp-session-id: {resp.headers.get('mcp-session-id')}")

    # Promptise's MCPClient still opens a 2025-11-25 session, on mcp 1.x and 2.x.
    async with MCPClient(url="http://127.0.0.1:8002/mcp") as client:
        tools = await client.list_tools()
        print("MCPClient tools:", [t.name for t in tools])
        cart = await client.call_tool("create_cart", {})
        print("MCPClient create_cart ->", cart.content[0].text)


asyncio.run(main())
Output
before: initialize -> 200, mcp-session-id: 29889817288948c0a1ad2457cf706244
after: initialize -> 200, mcp-session-id: None
MCPClient tools: ['create_cart', 'add_item', 'checkout']
MCPClient create_cart -> {"cart_id": "cart_02427674"}

The before server issued a session ID and the stateless after server did not, yet both answered initialize. Promptise's MCPClient in 1.6.0 still opens a 2025-11-25 session even on mcp 2.x, and it works against the stateless server unchanged.

Then a version the server doesn't speak. A modern server must answer with UnsupportedProtocolVersionError and list what it supports, so the client can retry:

Output
{'http': 400, 'session': None, 'jsonrpc': '2.0', 'id': 1, 'error': {'code': -32022, 'message': 'Unsupported protocol version', 'data': {'supported': ['2026-07-28'], 'requested': '2099-01-01'}}}

For a release gate, add three more checks: a round-robin test with at least two replicas, a test that calls every tool with a handle created on another replica, and the official MCP conformance suite. Testing MCP servers in Python covers unit tests without a live server.


[13]

The complete example

The migrated server is server_after.py from Step 3. Here is an agent using it. Confirmation moved out of the server's elicitation and into the agent's approval policy, so a person still approves every checkout:

Pythonagent_after.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
"""A Promptise agent shops through the stateless server; checkout needs a yes."""
import asyncio

from dotenv import load_dotenv

from promptise import ApprovalDecision, ApprovalPolicy, CallbackApprovalHandler, HTTPServerSpec, build_agent

load_dotenv("/Users/rubenmanser/Documents/Development/AgentMCP/.env")


async def approve(request) -> ApprovalDecision:
    print(f"approval asked: {request.tool_name}({request.arguments}) -> yes")
    return ApprovalDecision(approved=True)


async def main() -> None:
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"cart": HTTPServerSpec(url="http://127.0.0.1:8002/mcp")},
        approval=ApprovalPolicy(tools=["checkout"], handler=CallbackApprovalHandler(approve)),
    )
    result = await agent.ainvoke({"messages": [{
        "role": "user",
        "content": "Order SKUs mug-01 and tea-02 for me. I confirm the order.",
    }]})
    for message in result["messages"]:
        for call in getattr(message, "tool_calls", None) or []:
            print("tool call:", call["name"], call["args"])
    print("answer:", result["messages"][-1].content)
    await agent.shutdown()


asyncio.run(main())
Output
approval asked: checkout({'cart_id': 'cart_0bc3c012', 'confirmed': True}) -> yes
tool call: create_cart {}
tool call: add_item {'cart_id': 'cart_0bc3c012', 'sku': 'mug-01'}
tool call: add_item {'cart_id': 'cart_0bc3c012', 'sku': 'tea-02'}
tool call: checkout {'cart_id': 'cart_0bc3c012', 'confirmed': True}
answer: …your order has been placed.

Order summary:
- Cart ID: cart_0bc3c012
- Items ordered: mug-01, tea-02
…

The model created a cart, passed cart_0bc3c012 to the next three calls with nothing but the tool descriptions to go on, and the approval handler ran before checkout. All files, including the shell script that starts both servers, are in the runs/mcp-stateless-migration folder of this guide.


[14]

Running a stateless MCP server in production

  • Authenticate every request. There is no session to authenticate once. Foundry already reads the credential from the HTTP request that carries each call; see MCP authentication with API keys, JWT and OAuth.

  • Let gateways use the new headers. Mcp-Method and Mcp-Name let a proxy rate-limit or route by tool without reading the body. The spec says intermediaries that enforce policy on these headers should reject requests whose protocol version doesn't require header validation.

  • Use the cache hints. List results carry ttlMs and cacheScope. Return tools in a stable order so clients and LLM prompt caches can reuse them.

  • Expire handles. A cart nobody checks out stays in the store forever unless you add a TTL.

  • Plan for lost requests. Streams can no longer resume. A client whose stream breaks sends the request again with a new ID, so tools with side effects should be idempotent. MCP long-running tasks covers jobs that outlive a request.

  • Keep the legacy path tested until your logs show no initialize requests, then decide whether to keep it.

  • Roll out behind a flag. Ship the handle-based tools next to the old ones, move clients over, then remove the session-based tools.

Build a production MCP server in Python covers health checks, middleware and Docker, and MCP security best practices the rest of the checklist.


[15]

Common problems and fixes

Symptom

Cause

Fix

Data from the previous call is gone

State stored per session; each 2026-07-28 request is its own session

Return a handle and keep state in a shared store

A confirmation step always cancels

Elicitation has no back-channel; Foundry's Elicitor returns None

Two-step tool, agent-side approval, or the SDK's Resolve

Cannot send 'elicitation/create': ... no back-channel

SDK ctx.elicit() on a 2026-07-28 connection or a stateless server

Return the question as input_required

400 Unsupported protocol version: 2026-07-28

Server runs on mcp 1.x

Upgrade to mcp 2.x

-32020 HeaderMismatch

Client or proxy sent Mcp-Method or Mcp-Name that don't match the body

Fix the client or the proxy rewrite

Legacy clients get 404 Session not found behind a load balancer

Sessions live in one process

asgi_app(stateless=True) or sticky routing on mcp-session-id

Unknown handle after a deploy

Store was in process memory

Move the store out of the process

tools/list_changed never arrives

Change notifications travel only on subscriptions/listen at 2026-07-28

Client opens a listen stream, or polls with the ttlMs hint


[16]

Limitations

  • Foundry has no multi round-trip support in 1.6.0. Its Elicitor, Sampler and elicitation approval gate don't produce input_required results, so on 2026-07-28 connections they behave as declined.

  • Foundry's client stays on 2025-11-25. MCPClient and build_agent open sessions with initialize. That works with servers that serve both eras, which today is most of them.

  • The tasks extension isn't in the Python SDK yet. The MCP Python SDK 2.2.0 release notes list it as a known gap, so long-running work still needs your own job pattern.

  • Statelessness doesn't remove state, it moves it. You now operate a store, its expiry and its access checks.

  • Some clients will stay on 2025-11-25 for a long time, so the legacy path and its tests stay with you.


[17]

FAQ

What is the MCP 2026-07-28 spec?

It is the MCP revision released on July 28, 2026, and the current version on modelcontextprotocol.io. It removes the initialize handshake and protocol sessions, adds server/discover, replaces server-to-client requests with multi round-trip requests, and moves change notifications to subscriptions/listen.

Is Mcp-Session-Id removed from MCP?

Yes, from the 2026-07-28 Streamable HTTP transport. Every request carries its protocol version and capabilities in _meta. Servers that also serve 2025-11-25 clients still issue session IDs on that legacy path unless they run stateless.

Do old MCP clients still work after the migration?

Yes, if your SDK serves both eras. The MCP Python SDK 2.x routes each request by version on the same endpoint, so initialize from a 2025 client and a per-request 2026-07-28 call both work. The old spec stays valid, and deprecated features have at least twelve months before removal.

What replaces elicitation in MCP 2026-07-28?

Elicitation still exists, but the server no longer sends it as its own request. It returns an input_required result listing its questions, and the client retries the original call with inputResponses. The same pattern carries sampling and roots requests.

Does stateless MCP mean my server can't keep state?

No. Only the protocol is stateless. Your tools can keep any state they want in a database, a cache or an API, referenced by IDs the server returns and the model passes back as arguments.

Does Promptise Foundry support MCP 2026-07-28?

Partly. A Foundry 1.6.0 server installed with mcp 2.x answers 2026-07-28 requests and server/discover through the SDK, and asgi_app(stateless=True) serves legacy clients without sessions. Foundry doesn't produce multi round-trip results, and its client still connects with initialize.


[18]

Next steps

  • MCP transports: stdio vs Streamable HTTP vs SSE explains the transport this migration changes.

  • What is the Model Context Protocol covers the protocol from the start.

  • AI agent approval goes deeper on the agent-side confirmation used above.

  • Connect MCP servers to an AI agent shows the client side in Promptise.

  • The Foundry deployment docs cover workers, sticky sessions and stateless=True.

Contents

  1. 01How do you migrate an MCP server to the 2026-07-28 spec?
  2. 02What changed in MCP 2026-07-28?
  3. 03How does a stateless MCP request work?
  4. 04When do you need to migrate, and when not?
  5. 05What you need
  6. 06Step 1: Find the session state in your server
  7. 07Step 2: Upgrade the SDK and check that both eras answer
  8. 08Step 3: Replace session state with server-minted handles
  9. 09Step 4: Move user questions out of server callbacks
  10. 10Step 5: Replace sampling, roots and logging
  11. 11Step 6: Serve it stateless behind a load balancer
  12. 12Step 7: Test both protocol eras
  13. 13The complete example
  14. 14Running a stateless MCP server in production
  15. 15Common problems and fixes
  16. 16Limitations
  17. 17FAQ
  18. 18Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Tools and the Model Context Protocol (MCP)

Related guides

All guides in this topic →
  • // 01Intermediate · 22 minFastAPI MCP Server: Turn Your FastAPI App into MCP ToolsTurn your FastAPI app into an MCP server for Claude and AI agents: fix auth, names and descriptions, gate writes, and test it with a real agent.
  • // 02Intermediate · 15 minOpenAPI to MCP: Turn Any REST API into an MCP ServerTurn an OpenAPI spec into a working MCP server in minutes. Read-only by default, human approval on every write, and tested by a real agent.
  • // 03Intermediate · 18 minMake Your SaaS Agent-Ready: MCP, OpenAPI or BothMake your SaaS agent-ready: when to ship an MCP server, an OpenAPI spec or both, what to expose to agents, how auth works, and a runnable example.
  • // 04Advanced · 20 minMCP Authentication in Python: API Keys, JWT and OAuthMCP authentication in Python on one server: API keys, JWTs and OAuth client credentials with JWKS, plus roles, scopes, 401s and agent token forwarding.
  • // 05Intermediate · 25 minBuild an Authenticated MCP Server for Your AI Agent (Python)An MCP server authentication tutorial in Python: JWT login and roles, an agent that acts for each user, an attack script and a verified audit log.
  • // 06Intermediate · 20 minMCP Security Best Practices: A Checklist for MCP ServersMCP security best practices as a checklist: token audience binding, per-tool scopes, input validation, human approval, rate limits and signed audit logs.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths