MCP is Now a Stateless Protocol
The latest MCP specification, released on the 28th of July 2026, makes a major architectural change to the MCP protocol: it's now stateless.
Previously, an MCP client and server had to complete a handshake before the client could call a tool. They used it to agree on the protocol version and capabilities, and to share session identifiers.
That's all gone now.

Clients can just call the MCP tools they want immediately. If they need to know what capabilities a server has, there's a new optional (for clients, at least) server/discover RPC method.
Basically, stateless MCP looks a lot more like a typical JSON-RPC API, but with a few helpful standards for server and tool discovery, multi-round-trip requests and optional extensions for long-running tasks.
Why does this matter?
Stateless servers are a lot easier to host and manage. For one thing, they don't require sticky load balancing or shared session storage.

However, just because the protocol is stateless doesn't mean the application has to be. If a tool needs to remember something between calls, it can return a state ID, which the client includes in the next request.
Building a stateless MCP server
I'm going to construct a basic MCP server and client so we can see exactly what it looks like.
This example uses version 2 of the official MCP Python SDK for the server. It uses curl for the client.
The Python SDK uses /mcp as the default endpoint, and the message body uses JSON-RPC. There are Mcp-Method and Mcp-Name headers to identify the request and help with routing and authorisation. It also supports Streamable HTTP, where the client sends each MCP message as a separate POST request.
Server
First, install version 2 of the mcp package:
uv add "mcp>=2,<3"
Resolved 182 packages in 0.49ms Audited 178 packages in 0.05ms
Let's start with a trivial example of a calculator that can only add numbers.
I'll create a new instance of an MCPServer, add a single tool called add and then set a few server options:
stateless_http=Truedisables transport session tracking.json_response=Truemakes the server return a JSON object instead of an SSE stream.
This starts the server in the background:
from mcp.server import MCPServer
mcp = MCPServer("Calculator")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
mcp.run(
transport="streamable-http",
port=3001,
stateless_http=True,
json_response=True,
)
INFO: Started server process [21783]
INFO: Waiting for application startup.
[08/06/26 14:14:04] INFO StreamableHTTP streamable_http_manager.py:151
session manager
started
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:3001 (Press CTRL+C to quit)
This example is running directly in my Obsidian notebook through my Obsidian Markdown Notebook plugin. To run it outside Obsidian, save the code as mcp_server.py, then run uv run python mcp_server.py. Leave that terminal open while you run the client commands below in another terminal.
Client
The tools/call method allows us to call a known tool directly.
curl --silent --show-error http://127.0.0.1:3001/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: add' \
--data '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "add",
"arguments": {"a": 2, "b": 3},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}' | python3 -m json.tool
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"text": "5",
"type": "text"
}
],
"isError": false,
"resultType": "complete",
"structuredContent": {
"result": 5
},
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "Calculator",
"version": ""
}
}
}
}
The server returns 5 in an MCP tool result.
As mentioned, we can also use the server/discover method to see what the server supports. The request uses the same _meta object as the tool call.
curl --silent --show-error http://127.0.0.1:3001/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
--data '{
"jsonrpc": "2.0",
"id": 2,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}' | python3 -m json.tool
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"cacheScope": "private",
"capabilities": {
"prompts": {
"listChanged": true
},
"resources": {
"listChanged": true,
"subscribe": true
},
"tools": {
"listChanged": true
}
},
"resultType": "complete",
"supportedVersions": [
"2026-07-28"
],
"ttlMs": 0,
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "Calculator",
"version": ""
}
}
}
}
This tells us that the server supports tools. A client can call tools/list if it needs the tool names and schemas.
curl --silent --show-error http://127.0.0.1:3001/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
--data '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}' | python3 -m json.tool
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"cacheScope": "private",
"resultType": "complete",
"tools": [
{
"description": "Add two numbers.",
"inputSchema": {
"type": "object",
"properties": {
"a": {
"title": "A",
"type": "integer"
},
"b": {
"title": "B",
"type": "integer"
}
},
"required": [
"a",
"b"
],
"title": "addArguments"
},
"name": "add",
"outputSchema": {
"properties": {
"result": {
"title": "Result",
"type": "integer"
}
},
"required": [
"result"
],
"title": "addOutput",
"type": "object"
}
}
],
"ttlMs": 0,
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "Calculator",
"version": ""
}
}
}
}
There is our add tool, including the input and output schemas generated from the Python types.
An MCP request is now a self-contained unit of work. Any compatible server instance can process the request, and the server does not need hidden transport state, so it can be load-balanced easily.
Much better.