Build a Stateless MCP Server for the 2026-07-28 Specification
Inspired by the MCP 2026-07-28 specification (released July 28, 2026, stewarded by AAIF under the Linux Foundation), this dispatch builds stateless-mcp, a Python FastMCP server on the new stateless core: initialize and Mcp-Session-Id are gone, Mcp-Method/Mcp-Name headers drive routing, results carry ttlMs/cacheScope cache hints, MRTR (SEP-2322) handles elicitation, and the Tasks extension (SEP-2663) powers poll-based tasks/get.
Deepak Bagada
CEO, SaaSNext
- The 2026-07-28 spec removes initialize/initialized and Mcp-Session-Id — the core is now fully stateless request/response with version and capabilities in per-request _meta.
- Mcp-Method and Mcp-Name HTTP headers are mandatory on Streamable HTTP, so gateways route and meter on headers without parsing JSON bodies.
- MRTR (SEP-2322) replaces server-initiated requests with input_required results and retries, while the Tasks extension (SEP-2663) adds poll-based tasks/get and tasks/update.
- ttlMs and cacheScope make list and read results cacheable, and OAuth now requires RFC 9207 issuer verification.
By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.
On July 28, 2026, the Model Context Protocol shipped its largest revision since launch, and the direction was unambiguous: stateless. Stewarded by the Agentic AI Foundation (AAIF) under the Linux Foundation, the 2026-07-28 specification removed the initialize/notifications/initialized handshake and the Mcp-Session-Id header entirely, made Mcp-Method and Mcp-Name HTTP headers mandatory on Streamable HTTP, added ttlMs/cacheScope cache hints to list and read results, replaced server-initiated requests with the Multi Round-Trip Request (MRTR) pattern, moved Tasks into an official extension with poll-based tasks/get, and tightened OAuth with RFC 9207 issuer verification. If you have built an MCP server in the last year, the code you wrote for sessions is now the code you should delete.
This dispatch builds a production-grade server on the new core. You will implement stateless-mcp, a Python FastMCP server on the 2026-07-28 spec with four things to study closely: a stateless tool that returns ttlMs and cacheScope cache hints, an MRTR elicitation flow that collects missing input through retried requests instead of a held-open stream, a long-running job surfaced through the Tasks extension and polled with tasks/get, and an mcpServers client configuration that routes on Mcp-Method and Mcp-Name headers so a load balancer can steer traffic without parsing JSON bodies. Keep the MCP directory open while you build — the migration wave after July 28 is exactly the churn it should track.
What actually changed in the 2026-07-28 specification
The release is a coordinated set of breaking changes, not a feature drop. Here is the scorecard:
| Change | Before (2025-11-25) | After (2026-07-28) | SEP |
|---|---|---|---|
| Session lifecycle | initialize handshake + Mcp-Session-Id | None. Stateless core | SEP-2575 / SEP-2567 |
| Version + capabilities | Negotiated once at initialize | Per-request _meta on every request |
SEP-2575 |
| HTTP headers | Optional | Mcp-Method + Mcp-Name required; custom x-mcp-header |
SEP-2243 |
| List/read caching | None | ttlMs + cacheScope hints |
SEP-2549 |
| Server-initiated requests | SSE streams, long-lived GET | MRTR: input_required + retries |
SEP-2322 |
| Long-running work | Experimental tasks/result | Tasks extension: tasks/get, tasks/update, tasks/cancel | SEP-2663 |
| Result typing | Implicit | Required resultType: complete |
input_required |
| OAuth | Implicit issuer | RFC 9207 issuer verification | RFC 9207 |
Two of these are worth internalizing before you write any code. First, every result now carries a resultType field — complete for ordinary results, input_required for MRTR interim results, and task for the Tasks extension — and clients distinguish them without any shared session state. Second, method and tool names travel in HTTP headers, so a gateway, rate limiter, or WAF can route and meter on Mcp-Method/Mcp-Name without ever looking at the JSON-RPC body. That single change is what makes agent traffic observable at scale. The workflows library keeps companion templates for header-based routing if you want the infrastructure half, and the latest-ai-news coverage of the July 28 release explains why the maintainers bet the protocol on statelessness.
Why stateless matters for agent infrastructure
The old protocol forced sticky sessions. A load balancer had to pin a client to one server instance because session state lived server-side; a server crash mid-conversation lost the session; and a fleet behind a proxy needed session-affinity rules that broke exactly when you scaled. The stateless core removes all of it. Requests are independent, any instance can serve any request, and the little state that remains — a task's progress, an MRTR exchange — is carried explicitly: requestState echoed by the client on retries, taskId in the Mcp-Name header for task polling. The cost is discipline: handlers must be self-contained, and anything you used to stash in memory keyed by session now travels with the request.
Architecture
flowchart TD
A[MCP client] -->|POST + Mcp-Method + Mcp-Name + OAuth token| B[LB / gateway routes on headers]
B --> C[stateless-mcp instance A]
B --> D[stateless-mcp instance B]
C --> E{resultType dispatch}
D --> E
E --> F[complete + ttlMs/cacheScope]
E --> G[input_required MRTR elicitation]
G -->|client retries original call with inputResponses| E
E --> H[task handle via tasks/get]
H -->|Mcp-Name = taskId routes polls to holding instance| H
No connection, no session, no affinity. The only routing requirement is that tasks/get polls for a given task land on the instance that owns it, which is exactly why the spec pins the Mcp-Name header to the taskId.
The Python implementation
The server uses the Python FastMCP SDK with @mcp.tool(). Every handler returns a dict with an explicit resultType, so the wire contract is obvious even where the SDK adds defaults.
from __future__ import annotations
import secrets
import threading
import time
from datetime import datetime, timezone
from typing import Any
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
'stateless-mcp',
version='1.0.0',
protocol_version='2026-07-28',
)
# --- Cacheable, stateless tool -------------------------------------------
RATES: dict[tuple[str, str], float] = {
('USD', 'EUR'): 0.923,
('USD', 'INR'): 83.42,
('EUR', 'USD'): 1.084,
}
@mcp.tool()
async def get_exchange_rate(base: str, quote: str) -> dict[str, Any]:
"""Return the latest exchange rate with a cache hint.
The result is safe to cache for 60 seconds and is not
user-specific, so it is marked cacheScope: public.
"""
rate = RATES.get((base.upper(), quote.upper()))
if rate is None:
raise ValueError(f'No rate for {base}/{quote}')
return {
'resultType': 'complete',
'base': base.upper(),
'quote': quote.upper(),
'rate': rate,
'updatedAt': datetime.now(timezone.utc).isoformat(),
'ttlMs': 60_000,
'cacheScope': 'public',
}
# --- MRTR: Multi Round-Trip Request elicitation ---------------------------
@mcp.tool()
async def schedule_deployment(
service: str,
environment: str,
region: str,
inputResponses: dict[str, Any] | None = None,
requestState: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Schedule a deployment, eliciting confirmation mid-call via MRTR.
First call returns resultType: input_required. The client collects
the confirmation and retries the original call with inputResponses
and the echoed requestState. The handler stays stateless between
the two round trips.
"""
if inputResponses is None:
return {
'resultType': 'input_required',
'requestState': {
'service': service,
'environment': environment,
'region': region,
},
'inputRequests': [
{
'name': 'confirmation',
'reason': 'Deploying to production requires explicit operator confirmation.',
'prompt': {
'type': 'confirmation',
'title': f'Deploy {service} to {environment} in {region}?',
'style': 'primary',
},
},
{
'name': 'rollout_percent',
'reason': 'Choose the canary rollout percentage.',
'schema': {
'type': 'integer',
'minimum': 0,
'maximum': 100,
'default': 10,
},
},
],
}
if inputResponses.get('confirmation') is not True:
return {'resultType': 'complete', 'status': 'cancelled'}
rollout = int(inputResponses.get('rollout_percent', 10))
return {
'resultType': 'complete',
'status': 'scheduled',
'service': service,
'environment': environment,
'region': region,
'rollout_percent': rollout,
'requestState': requestState,
}
# --- Tasks extension: long-running work polled with tasks/get -------------
_TASKS: dict[str, dict[str, Any]] = {}
@mcp.tool()
async def long_report(topic: str, format: str = 'markdown') -> dict[str, Any]:
"""Start a long-running report and return a task handle.
The client polls tasks/get with the taskId; polls are routed to
the holding instance via the Mcp-Name header.
"""
task_id = f'task_{int(time.time() * 1000)}_{secrets.token_hex(4)}'
_TASKS[task_id] = {
'status': 'working',
'progress': 0.0,
'topic': topic,
'format': format,
}
threading.Thread(
target=_run_report,
args=(task_id, topic, format),
daemon=True,
).start()
return {
'resultType': 'task',
'taskId': task_id,
'status': 'working',
}
def _run_report(task_id: str, topic: str, format: str) -> None:
for i in range(5):
time.sleep(2)
_TASKS[task_id]['progress'] = (i + 1) / 5
_TASKS[task_id]['status'] = 'complete'
_TASKS[task_id]['result'] = {
'topic': topic,
'format': format,
'sections': ['Summary', 'Evidence', 'Recommendations'],
'words': 1240,
}
@mcp.tool()
async def tasks_get(taskId: str) -> dict[str, Any]:
"""Poll a task's status. Carried over HTTP with Mcp-Name: taskId."""
state = _TASKS.get(taskId)
if state is None:
return {'resultType': 'complete', 'taskId': taskId, 'status': 'unknown'}
if state['status'] == 'complete':
return {
'resultType': 'complete',
'taskId': taskId,
'status': 'complete',
'progress': 1.0,
'result': state['result'],
}
return {
'resultType': 'complete',
'taskId': taskId,
'status': state['status'],
'progress': state['progress'],
}
The patterns to copy from this file: explicit resultType on every return, no session anywhere, requestState for MRTR, and taskId as the routing key for the Tasks extension. The trade-off to remember is that long_report stores state in _TASKS on purpose — returning a task handle is the one place the stateless discipline explicitly yields, because the task lifecycle is stateful by design.
Tool inputSchema definitions
Clients fetch these from tools/list, which on this spec returns cache hints of its own. The three tool schemas a client will cache:
get_exchange_rate
{
"name": "get_exchange_rate",
"inputSchema": {
"type": "object",
"properties": {
"base": { "type": "string", "description": "Base currency code, e.g. USD." },
"quote": { "type": "string", "description": "Quote currency code, e.g. EUR." }
},
"required": ["base", "quote"]
}
}
schedule_deployment
{
"name": "schedule_deployment",
"inputSchema": {
"type": "object",
"properties": {
"service": { "type": "string" },
"environment": { "type": "string", "enum": ["staging", "production"] },
"region": { "type": "string" },
"inputResponses": { "type": "object", "description": "Present on the retry with answers to the MRTR inputRequests." },
"requestState": { "type": "object", "description": "Echoed back from the previous input_required result." }
},
"required": ["service", "environment", "region"]
}
}
long_report and tasks_get
{
"name": "long_report",
"inputSchema": {
"type": "object",
"properties": {
"topic": { "type": "string" },
"format": { "type": "string", "enum": ["markdown", "html"], "default": "markdown" }
},
"required": ["topic"]
}
}
{
"name": "tasks_get",
"inputSchema": {
"type": "object",
"properties": {
"taskId": { "type": "string" }
},
"required": ["taskId"]
}
}
mcpServers client configuration with header-based routing
Because Mcp-Method and Mcp-Name are now mandatory, the client config is where header routing becomes visible. In a load-balanced deployment, the gateway reads Mcp-Name to pin task polls to the instance that owns the task:
{
"mcpServers": {
"stateless-mcp": {
"transport": "streamable-http",
"url": "https://mcp.acme-corp.dev/stateless",
"headers": {
"Mcp-Method": "tools/call",
"Mcp-Name": "get_exchange_rate"
},
"oauth": {
"client_id": "stateless-client",
"scopes": ["mcp.read", "mcp.write"],
"issuer": "https://auth.acme-corp.dev",
"pkce": true
}
}
}
}
And the equivalent routing rule for the edge: an L7 rule that pins any request whose Mcp-Name starts with a task ID to the same origin pod, while everything else round-robins across the fleet. That one rule — route on the header, not the body — is the operational payoff of SEP-2243.
OAuth 2.0 security with RFC 9207
The stateless core shifts security from sessions to per-request tokens, which raises the bar on OAuth. Authorization Code with PKCE is the baseline for interactive clients; client credentials grants for unattended agents. The 2026-07-28 spec requires issuer verification per RFC 9207: the authorization server must include the iss parameter in the authorization request, and your server must verify the iss in the returned authorization response and in every validated token. A token whose issuer does not match your configured authorization server is rejected before any tool runs. Scope-lean tokens are mandatory — mcp.read for polling tools, mcp.write for tools that start tasks or schedule deployments — and the server checks scopes at every dispatch because nothing else will. Never log tokens, never embed them in task state, and store the client secret in a secrets manager, not in the mcpServers block.
Retry rules
Statelessness changes retries in one important way: every retry is a fresh request, so idempotency is on you. get_exchange_rate and other cacheable reads: retry up to three times with exponential backoff (base 200 ms, factor 2, jitter 25%) on 429 and 503; 4xx are terminal. MRTR elicitation: never retry automatically — the flow depends on the client collecting human input, so a timed-out schedule_deployment returns to pending state and the client decides whether to re-issue the original call with inputResponses attached. Task polling via tasks_get: poll with a fixed cadence (2 seconds), back off on 429, and treat a 404 or 'unknown' status as a signal to re-create the task rather than hammer the same ID. Starting a task (long_report): retry once on 5xx; task IDs are unique per invocation so a retry creates a fresh task and any orphaned first attempt can be cancelled. Timeouts: reads at 10 seconds, MRTR first leg at 15 seconds, task polls at 5 seconds.
Frequently Asked Questions
What breaks when I move a server from the 2025-11-25 spec to 2026-07-28?
The initialize handshake, the initialized notification, and Mcp-Session-Id are gone. You must send Mcp-Method and Mcp-Name headers on every Streamable HTTP POST, carry protocol version and capabilities in _meta, and return a resultType on every result. If you used the experimental tasks API, tasks/result is removed and the lifecycle moved into the tasks extension with tasks/get polling.
How does a stateless server know who is calling it?
Identity comes from the transport, not a session. Every request carries OAuth tokens verified per request with RFC 9207 issuer validation, and any state the server needs is passed back by the client — MRTR uses requestState echoed on retries, and Tasks uses the taskId carried in the Mcp-Name header so a load balancer can steer polls to the instance holding the task state.
When do I return input_required instead of a normal result?
When a tool needs information from the caller mid-execution — a confirmation, a missing parameter, an approval. Return resultType: 'input_required' with inputRequests describing what you need and a requestState blob. The client collects the answers and retries the original call with inputResponses attached. Do not hold server memory between the two round trips; that is the whole point.
What is the difference between MRTR and the Tasks extension?
MRTR (SEP-2322) is for ephemeral input gathering: the tool call stays pending while the client answers inputRequests and retries the original method. Tasks (SEP-2663) is for persistent, long-running work: tools/call returns a task handle with resultType: 'task', and the client polls tasks/get and pushes input via tasks/update. Once you return a task handle you have committed to storing state server-side.
Do I still need to set cacheScope on every response?
On cacheable results, yes: servers must include caching hints (ttlMs and cacheScope) on tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Tool-call results like get_exchange_rate may carry ttlMs too, but choose cacheScope carefully — 'public' means any shared gateway may serve the cached copy to any user, so per-user data must be 'private'.
Closing thoughts
The 2026-07-28 specification is not a bump; it is a bet that agent infrastructure scales only when the protocol stops carrying state. The handshake is gone, the session ID is gone, routing lives in HTTP headers, elicitation is a retry, and long-running work is a polled task. stateless-mcp puts each of those bets into working Python — cacheable stateless tools, an MRTR confirmation flow, a Tasks-extension job surfaced through tasks/get, and a client config that routes on Mcp-Method and Mcp-Name. Build handlers that are self-contained, keep the explicit resultType on every response, verify the issuer on every token, and let your fleet scale like an ordinary stateless HTTP service again.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
MCP 2026-07-28: The Stateless Core That Made MCP Serverless
Next Story →Build a Least-Privilege Agent Sandbox Workflow with OS Isolation
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...