If your MCP server stores anything per connection, the 2026-07-28 spec revision is going to break an assumption. The revision removes the initialize handshake and the Mcp-Session-Id header, makes every request carry its own version and capabilities, and replaces server-initiated questions with a retry pattern called MRTR. This post is the server-author checklist, plus a small Python server I ran to see the new wire format instead of guessing at it.
Two earlier posts cover neighbouring ground. The client-side Python tutorial shows how a client copes with both eras, and its author noted that the stub server never returned input_required. This one fills that gap from the server side. For the threat model, pair it with the MCP server security checklist.
Everything below was checked against the spec's key-changes page and the MRTR page on 2026-10-30. The spec revision itself is dated July 28, so the news here is SDK support, not the protocol.
What changed in MCP 2026-07-28 for server code?
Here is the changelog reduced to what you have to touch. Each row is something I read on the spec's "Key Changes" page.
| Old behaviour (up to 2025-11-25) | 2026-07-28 | What to do in your server |
|---|---|---|
initialize then notifications/initialized | Removed. Each request carries io.modelcontextprotocol/protocolVersion and clientCapabilities in _meta | Stop reading capabilities from connection state; read them per request |
Mcp-Session-Id header, per-connection state | Removed. List endpoints must not vary per connection | Delete session-keyed dictionaries. Cross-call state becomes explicit handles passed as tool arguments |
| No discovery RPC | server/discover is mandatory for servers | Return supported versions, capabilities, identity (an SDK usually does this) |
resources/subscribe, GET stream | subscriptions/listen, one long-lived POST-response stream | Re-test your change notifications |
ping, logging/setLevel, notifications/roots/list_changed | Removed. Log level arrives per request as io.modelcontextprotocol/logLevel | Do not emit notifications/message unless the request asked |
Server sends elicitation/create or sampling/createMessage mid-call | MRTR: return input_required, client retries | Rewrite any tool that asks the user a question |
| Results had no type | Every result carries resultType: complete or input_required | Usually handled by the SDK |
| List results had no cache hints | ttlMs and cacheScope required on list and read results | Set a real ttlMs if the list is stable |
| Tool order unspecified | Servers SHOULD return tools/list in deterministic order | Sort tools, so client and prompt caches hit |
Resource not found was -32002 | Now -32602 | Update error handling and tests |
SSE resumability (Last-Event-ID) | Removed. A broken stream loses the in-flight request | Make tool calls safe to re-issue |
Deprecated, not removed: Roots, Sampling and Logging, the HTTP+SSE transport, and Dynamic Client Registration in favour of Client ID Metadata Documents. The spec's new lifecycle policy sets a minimum twelve-month deprecation window, so you have time, but new code should not build on them.
Two items deserve a second look because they cause the most surprising breakage.
Session state. If you kept a user's draft, cursor or cart in a dict keyed by session id, that state now has nowhere to live. The spec's answer is a server-minted handle: your start_draft tool returns draft_id, and later tools take draft_id as an ordinary argument. That also makes your server safe behind a load balancer.
Server-initiated requests. A tool that called ctx.elicit(...) and waited is the case the next section walks through.
How does MRTR work, and what does a server return?
MRTR means the tool call ends early. The server answers the first tools/call with an interim result, the client gathers the missing input, and the client sends a fresh tools/call with the answers. The JSON-RPC id must differ between the two requests, and nothing about the second request depends on the server remembering the first.
The interim result has this shape, quoted in outline from the spec:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": { "github_login": { "method": "elicitation/create", "params": { "...": "..." } } },
"requestState": "AEAD-protected blob"
}
}
The spec's rules that matter most for you:
- Return at least one of
inputRequestsorrequestState. - Do not request something the client did not declare. If its
_metacapabilities lackelicitation, you cannot sendelicitation/create. - MRTR is allowed on
tools/call,prompts/getandresources/readonly. - Treat
requestStateas attacker-controlled. If it influences authorization or business logic, protect its integrity and reject anything that fails verification. - To limit replay, put the authenticated principal, a short expiry and an identifier of the originating request inside the protected payload. The spec warns this bounds replay but does not guarantee single use; one-time actions need server-side enforcement.
That last point is the one people skip. A requestState that says "the user already confirmed deletion" is a forged-approval bug waiting to happen if it is merely base64.
What does a working MRTR server look like in Python?
I used the v2 Python SDK. On 2026-10-30 pip index versions mcp listed 2.3.0 as the latest release, and I installed exactly that into a fresh virtual environment. The server below asks for confirmation before deleting a note. The SDK's Resolve and Elicit helpers turn the question into an input_required result when the client speaks 2026-07-28, and into the old mid-call request for older clients (per the SDK's own docstring in resolve.py).
from typing import Annotated
from pydantic import BaseModel
from mcp.server.mcpserver import MCPServer, Elicit, Resolve
from mcp.server.request_state import RequestStateSecurity
# Demo key only. In production load 32+ random bytes from a secret store and
# share the same key across every instance behind your load balancer.
DEMO_KEY = "0123456789abcdef0123456789abcdef-demo-only"
mcp = MCPServer(
"notes-demo",
request_state_security=RequestStateSecurity(keys=[DEMO_KEY], ttl=120),
)
NOTES = {"n1": "Buy milk", "n2": "Call the bank"}
class Confirm(BaseModel):
confirm: bool
def ask_confirm(note_id: str) -> Elicit[Confirm]:
return Elicit(f"Delete note {note_id!r}? This cannot be undone.", Confirm)
@mcp.tool()
def delete_note(note_id: str, ok: Annotated[Confirm, Resolve(ask_confirm)]) -> str:
"""Delete a note by id. Asks the user to confirm first."""
if not ok.confirm:
return "kept"
NOTES.pop(note_id, None)
return f"deleted {note_id}; left: {sorted(NOTES)}"
if __name__ == "__main__":
mcp.run("streamable-http")
Notice what is absent: no session, no stored question, no await that holds a connection open while a human thinks. The ok parameter is filled by the resolver before your function body runs. If the answer is not there yet, the framework returns input_required and your body does not execute.
Using the SDK's own client with an elicitation callback that says yes, over stdio, I got this real output:
protocol_version: 2026-07-28
client asked: Delete note 'n1'? This cannot be undone.
["deleted n1; left: ['n2']"] False
What does it look like on the wire?
The SDK client hides the retry loop, so I also sent raw HTTP requests with Python's urllib, against the same server over streamable HTTP. I put MCP-Protocol-Version: 2026-07-28 in the headers and the three _meta keys in each request body. Without the header, my first attempt was treated as a legacy request and rejected with Bad Request: Missing session ID, which is a useful reminder that a dual-era server decides the era per request.
Round 1, the interim result (the long requestState is truncated here; in the real response the _meta serverInfo block also followed):
{"jsonrpc":"2.0","id":2,"result":{"inputRequests":{"__main__:ask_confirm":{"method":"elicitation/create","params":{"message":"Delete note 'n2'? This cannot be undone.","mode":"form","requestedSchema":{"properties":{"confirm":{"title":"Confirm","type":"boolean"}},"required":["confirm"],"type":"object"}}}},"requestState":"v1.p-F6Z6ePywRL...","resultType":"input_required"}}
Round 2, the same call with inputResponses and the echoed requestState:
{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"deleted n2; left: ['n1']","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":"deleted n2; left: ['n1']"}}}
The first response had no mcp-session-id header, which I checked in code. Then the two attacks the spec worries about:
tampered state | HTTP 400 | {"error":{"code":-32602,"message":"Invalid or expired requestState","data":{"reason":"invalid_request_state"}}}
state replayed, other args | HTTP 400 | {"error":{"code":-32602,"message":"Invalid or expired requestState","data":{"reason":"invalid_request_state"}}}
For the second case I took a valid token minted for note_id: n2 and sent it with note_id: n1. The SDK binds the token to the method and a digest of the arguments, so it refused. That is the "identifier for the originating request" control from the spec, working. The token is also encrypted (AES-GCM in the SDK source), so a client cannot read it.
What I did not test: multiple server instances sharing a key, key rotation, a real client UI, and subscriptions/listen. The SDK's docstring says the default process-local key is rejected after a restart and multi-instance deployments must share keys, so do not ship the default.
What should I set for ttlMs and cacheScope?
The list result in my test came back with "ttlMs":0 and "cacheScope":"private", which means "do not reuse". That is a safe default and a missed saving. If your tool list only changes on deploy, a nonzero ttlMs lets clients skip repeated tools/list calls, and a deterministic order helps them keep a stable tool prefix. Use private for anything that varies by user, and public only when a shared intermediary may cache it. I did not measure cache savings; the spec describes ttlMs as a freshness hint, and clients decide what to do with it.
Migration checklist
- Pin your SDK major version and read its release notes for 2026-07-28 support. I tested the v2 Python SDK only.
- Search your code for session-keyed state. Replace each with a handle returned by one tool and passed to the next.
- Find every
elicit,create_messageandlist_rootscall. Convert to MRTR or, for sampling and roots, plan the deprecation migration the spec suggests. - Turn on request-state protection with a shared secret key, a short TTL, and principal binding if you use OAuth.
- Sort
tools/listoutput and set an honestttlMs. - Update the not-found error code to
-32602in code and tests. - Test twice: once as a 2026-07-28 client, once as a legacy client, since you will meet both.
- Make tool calls safe to re-issue, because a broken stream now loses the request and the client must resend it.
For authorization changes in the same revision (Client ID Metadata Documents, iss validation), see the OAuth explainer rather than relying on this post, which did not test auth.
Sources
- MCP 2026-07-28 key changes: modelcontextprotocol.io/specification/2026-07-28/changelog, read 2026-10-30.
- MRTR pattern page: modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr, read 2026-10-30.
- Python SDK
mcp2.3.0 from PyPI, installed and run 2026-10-30.



