Designing Stateless MCP Servers in 2026
What the 2026-07-28 MCP specification changes about sessions, long-running work, multi-round interactions, and where reasoning belongs.
Introduction
Last July, the Model Context Protocol project, now under the Agentic AI Foundation (AAIF), released the 2026-07-28 specification, the largest revision of MCP since its launch. Its biggest architectural change is a stateless protocol core. The initialize/initialized handshake and Mcp-Session-Id, which previously allowed clients and servers to maintain protocol-level sessions, are gone. Instead, each request carries the protocol and client context needed to process it. The change was one of the most requested by the wider community and makes MCP much easier to run on ordinary horizontally scaled HTTP infrastructure.
While the specification introduces several new patterns, one notable capability it deprecates is Sampling, which allowed an MCP server to borrow the client's LLM for inference. It was a feature I always found intriguing but never quite got around to implementing, which is somewhat ironic given that low adoption was one of the reasons for its deprecation.
I have been playing around with MCP servers built against the new specification, trying to stress-test the stateless model, the move to MRTR and requestState, and what to do as Sampling is phased out. This article walks through how I approached those problems. Some of the solutions come directly from mechanisms introduced by the specification; others are opinionated design choices layered on top.
The Todo MCP Server
I built a deliberately boring Todo MCP server as the concrete implementation for this article, which you can find here. It exposes a few simple capabilities: creating and listing todos, moving them between lists, bulk updates, and importing todos as a long-running Task.
The server runs as two replicas on Minikube, which makes it easy to test consecutive requests landing on different server instances and verify that nothing depends on local session state.
For testing, I used MCPJam, where the Inspector makes it easy to inspect the MCP connection and individual tool calls, while the Playground is useful for the more agentic flows later in the article.
Stateless by Default
Having spent most of my career building REST APIs and cloud-native systems, statelessness is firmly in my comfort zone. Being able to design an MCP server that scales horizontally behind a load balancer without introducing a session store is highly appealing to me.
The 2026-07-28 specification formalizes this by eliminating the initialization handshake used in previous versions, along with Mcp-Session-Id. Instead, every request carries its protocol version and client capabilities in _meta, with client identity included as request metadata as well.
A tool call now looks roughly like this:
{
"jsonrpc": "2.0",
"id": 17,
"method": "tools/call",
"params": {
"name": "list_todos",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "mcpjam-inspector", "version": "0.0.0" },
"io.modelcontextprotocol/clientCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } }
}
}
}
The client can optionally make a server/discover call to inspect the server's capabilities first, but discovery is not required before normal interactions. Each request is self-contained, so any healthy MCP server replica behind the load balancer can process it independently without relying on protocol session state from a previous request.
The new HTTP headers also make these requests easier to operate at the infrastructure layer. Mcp-Method and Mcp-Name expose what is being called without requiring a gateway to parse the JSON-RPC body. A gateway can, for example, apply one rate limit to list_todos and another to import_todos, or route particular MCP operations differently.
Discovery and list-result caching are also explicit now. Results from operations such as tools/list, prompts/list, and resources/list carry cache hints such as ttlMs and cacheScope.
{
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "public",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "todo-mcp-2026", "version": "0.1.0" } },
"supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"],
"capabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} }, "prompts": { "listChanged": true }, "tools": { "listChanged": true } }
}
ttlMs tells the client how long the result can be considered fresh. cacheScope specifies whether the result may be cached as public or must remain private.
I did not implement header-based routing or discovery caching in the accompanying demo, but they are worth highlighting because both become much more useful once an MCP server is deployed as production infrastructure.
Long-Running Work with Tasks
MCP introduced Tasks as an experimental core feature in the 2025-11-25 specification. In 2026-07-28, Tasks move into the formal extension model under io.modelcontextprotocol/tasks. The API is also reshaped around the stateless protocol, with task handles replacing any need for a protocol session and the earlier tasks/list operation removed.
If the client advertises support for io.modelcontextprotocol/tasks, the server can respond to a normal tools/call with a Task handle instead of the final tool result. Task creation is server-directed: the client declares support for the extension, but the server decides on a per-request basis whether an invocation should materialize as a Task.
To demonstrate the capability, I used a batch Todo import.
A call to import_todos starts like this:
{
"jsonrpc": "2.0",
"id": "import-evidence",
"method": "tools/call",
"params": {
"name": "import_todos",
"arguments": { "items": [] },
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } }
}
}
The MCP server validates the supplied Todo items and starts the import. Instead of keeping the original request open until every Todo has been processed, it returns:
{
"resultType": "task",
"taskId": "eee05339-3387-421a-913f-21cc96bb1988",
"status": "working",
"createdAt": "2026-09-27T18:03:25Z",
"lastUpdatedAt": "2026-09-27T18:03:25Z",
"ttlMs": 600000,
"pollIntervalMs": 500
}
The client can then poll the Task independently through tasks/get:
{
"jsonrpc": "2.0",
"id": 19,
"method": "tasks/get",
"params": {
"taskId": "eee05339-3387-421a-913f-21cc96bb1988",
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "openai-mcp", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } }
}
}
and receive progress such as:
{
"resultType": "complete",
"taskId": "eee05339-3387-421a-913f-21cc96bb1988",
"status": "working",
"statusMessage": "Imported 1 / 2 todos",
"createdAt": "2026-09-27T18:03:25Z",
"lastUpdatedAt": "2026-09-27T18:07:35Z",
"ttlMs": 600000,
"pollIntervalMs": 500
}
Eventually, the Task carries the completed tool result, including what was imported, skipped, or rejected.
The redesigned Tasks API also includes tasks/update and tasks/cancel. There is no tasks/list; clients retain Task handles and address Tasks directly through those handles.
One important requirement is that the Task must be durably created before the server returns its handle, so that tasks/get can resolve it immediately. In my implementation, I also hand execution off to a long-lived process outside the original request scope and persist its state in a durable store. That allows the work to outlive both the HTTP request and the particular MCP server instance that created it.
Multi-Round Interactions Without Sessions
One aspect of the previous stateful model that was intuitive was elicitation. Elicitations let an MCP server request specific user input when a tool encounters a decision it cannot make on its own during execution.
Previously, the server could issue a nested elicitation/create request while the original tool call was still in progress. The client collected the user's response and returned it over the same interaction, allowing the server to continue the original call within the existing protocol session.
The 2026-07-28 specification solves the same problem without a protocol session through Multi Round-Trip Requests (MRTR). Instead of keeping the original interaction alive while the server calls back into the client, the server returns a result with resultType: "input_required", an inputRequests map describing what it needs, and optionally an opaque requestState. The client fulfils those requests and retries the original MCP operation with the resulting inputResponses and the exact requestState returned by the server.
I used a move_todo scenario to demonstrate this. If the server discovers that the Todo being moved has incomplete subtasks, the tool returns an input_required result containing an elicitation request asking the user whether to move only the parent, move the subtasks as well, or cancel the operation.
{
"resultType": "input_required",
"inputRequests": {
"subtask_decision": {
"method": "elicitation/create",
"params": {
"message": "This todo has 2 incomplete subtasks. What should happen?",
"requestedSchema": {
"type": "object",
"properties": {
"choice": {
"type": "string",
"enum": ["move_parent_only", "move_with_subtasks", "cancel"]
}
},
"required": ["choice"]
}
}
}
},
"requestState": "<opaque, signed state token>"
}
requestState is opaque to the client and must be treated as untrusted when it returns to the server. In my implementation, I integrity-protect it before sending it to the client. Conceptually, the protected value represents state something like:
Conceptual requestState payload before sealing:
{
"Operation": "move_todo",
"TodoID": "e9fac094-f5e3-4752-9801-c3a893abcef0",
"TargetListID": "done",
"TodoVersion": 1,
"IncompleteSubtaskIDs": ["6717c187-3ed8-403b-9412-a9ccd9d2ab2d", "314b5f6b-82e0-4fe3-a9c1-3a07cf185c1f"],
"ExpiresAt": "2026-09-27T17:10:28.390420864Z"
}
Once the user makes a choice, the client retries the original move_todo call with the original arguments, the resulting inputResponses, and the exact requestState returned by the server:
{
"method": "tools/call",
"params": {
"name": "move_todo",
"arguments": {
"todoId": "e9fac094-f5e3-4752-9801-c3a893abcef0",
"target": "done"
},
"inputResponses": {
"subtask_decision": {
"action": "accept",
"content": { "choice": "move_with_subtasks" }
}
},
"requestState": "<exact opaque value returned by the server>"
}
}
At this point, any MCP server instance can pick up the retry. In my implementation, the server verifies and decodes requestState, validates the supplied inputResponses, re-reads the current Todo state, and continues the operation.
That last re-check matters. The subtask state could have changed while the user was deciding, so the server should validate the relevant domain state again before applying the mutation.
An Alternative to Sampling
Sampling was one of the more intriguing capabilities in MCP. It allowed an MCP server to ask the connected client to run inference using the client's own model through sampling/createMessage. In theory, that gave a server access to LLM reasoning without requiring its own model-provider integration.
The 2026-07-28 specification deprecates Sampling. It remains functional during the deprecation period, but new implementations are advised not to build around it. The recommended migration is straightforward: if an MCP server genuinely needs an LLM, integrate directly with a model provider.
I am not particularly fond of that approach for every use case. The ability to borrow inference from the harness feels more intuitive to me than introducing a separate model integration inside the MCP server. It also keeps model choice, credentials, and inference cost with the client rather than making them responsibilities of the server operator.
Unlike many of the other deliberate changes in the 2026-07-28 specification, Sampling's deprecation was driven in part by low adoption. That makes direct model integration feel less like a technically superior replacement and more like a pragmatic alternative.
There is another option for a narrower class of use cases. It is not a drop-in replacement for Sampling, and it does not help when inference is genuinely part of the server's domain capability. But when the model is primarily being used to decide how to compose capabilities the MCP server already exposes, the reasoning can remain with the client.
For those cases, my alternative to direct model integration is to use one of MCP's core capabilities: Prompts.
To demonstrate this, I added a plan_my_day prompt. The Todo server has the domain knowledge to explain how tasks should be prioritized, but producing the actual plan is a reasoning task the client model is already well positioned to perform.
Instead of creating a plan_my_day tool that invokes an LLM inside the MCP server, the prompt gives the client model planning instructions and tells it how to compose the existing Todo capabilities.
The MCP server still owns the capabilities and domain rules, while the client owns the reasoning and orchestration.
I added bulk_update_todos specifically to make this pattern practical. Once the model has produced an approved plan, it can apply the changes in one operation instead of making a long sequence of individual updates.
That is the distinction I would use when deciding whether an MCP server needs its own model. If inference is genuinely part of the domain capability, direct model-provider integration makes sense. If the model is only deciding how to compose capabilities the MCP already exposes, I would leave that reasoning with the client.
Closing Thoughts
The 2026-07-28 specification pushes MCP in a direction I generally agree with: no protocol-level session state, clearer primitives for long-running and multi-round interactions, and much better alignment with how modern HTTP infrastructure is already built.
But statelessness does not eliminate state. It makes the ownership of state explicit.
Tasks still need durable execution state. Multi-round interactions still need enough state to resume safely. Domain data still lives somewhere. And reasoning still has to happen somewhere. What the new protocol does is force those concerns out of an implicit connection-level session and make us decide where each of them actually belongs.
For me, the guiding principle is fairly simple: keep the MCP server focused on exposing durable domain capabilities, and let the client own as much of the interaction, reasoning, and orchestration layer as possible. The new specification makes that architecture much easier to build.