Contents · 18 sections↓
- 01How do you migrate an MCP server to the 2026-07-28 spec?
- 02What changed in MCP 2026-07-28?
- 03How does a stateless MCP request work?
- 04When do you need to migrate, and when not?
- 05What you need
- 06Step 1: Find the session state in your server
- 07Step 2: Upgrade the SDK and check that both eras answer
- 08Step 3: Replace session state with server-minted handles
- 09Step 4: Move user questions out of server callbacks
- 10Step 5: Replace sampling, roots and logging
- 11Step 6: Serve it stateless behind a load balancer
- 12Step 7: Test both protocol eras
- 13The complete example
- 14Running a stateless MCP server in production
- 15Common problems and fixes
- 16Limitations
- 17FAQ
- 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.
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:
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:
"""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.
{
"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.
uv venv && uv pip install "promptise==1.6.0" "mcp==2.3.0" uvicorn httpx python-dotenvIf 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:
"""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:
"""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())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:
"""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))== 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.
"""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 4Three 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):
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:
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.
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.
Approval in the agent. If your own agent calls the server, enforce confirmation there. The complete example below uses Promptise's ApprovalPolicy.
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.
uvicorn server_after:app --host 0.0.0.0 --port 8002 --workers 4The 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:
"""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())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:
{'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:
"""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())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 |
| SDK ctx.elicit() on a 2026-07-28 connection or a stateless server | Return the question as input_required |
| 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 | 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.