# Building a Stateless MCP Server in Python: A Complete Guide to the 2026 Protocol
## Introduction: The New Era of MCP
The Model Context Protocol has undergone a significant transformation with the release of the 2026-07-28 specification. The most impactful change is the shift to a **stateless protocol core**, which fundamentally alters how clients and servers communicate. In the new model, modern clients no longer need to establish a persistent protocol session before issuing requests, and servers have moved away from relying on `Mcp-Session-Id` for everyday operations.
This architectural shift has profound implications for deployment. MCP servers can now scale behind standard HTTP infrastructure with minimal friction, using conventional load balancing strategies without worrying about session affinity or sticky routing.
Alongside the specification update, the official Python SDK has reached **v2**, introducing a streamlined `MCPServer` API that makes it remarkably simple to define tools, resources, and prompts using ordinary Python functions and decorators.
In this guide, we will walk through building a fully functional MCP server from scratch, running it over Streamable HTTP, inspecting it locally, and connecting to it with a Python client.
—
## What We Are Building
We will construct a compact **developer knowledge-base server** that exposes three core MCP primitives:
– **Tool:** `search_kb(query, limit)` — allows an LLM to search through a curated knowledge base
– **Resource:** `kb://articles` — provides a read-only listing of available articles
– **Prompt:** `draft_support_reply(customer_message)` — offers a reusable template for generating support responses
This server contains absolutely no user session state. Every request is entirely self-contained, which makes it an ideal demonstration of the stateless MCP model.
“`
LLM Host
|
| MCP request
v
+———————–+
| Python MCP Server |
| |
| search_kb() |
| kb://articles |
| draft_support_reply() |
+———————–+
“`
—
## Getting Started: Project Setup
The Python SDK v2 requires Python 3.10 or newer. We recommend installing the CLI extra, which gives you access to the development command and the MCP Inspector workflow.
**Using uv:**
“`bash
mkdir my-mcp-server
cd my-mcp-server
uv init
uv add “mcp[cli]”
“`
**Or using pip:**
“`bash
pip install “mcp[cli]”
“`
Your project structure can be as minimal as two files:
“`
my-mcp-server/
├── server.py
└── client.py
“`
No framework boilerplate or configuration files are required to get started. The SDK handles everything you need.
—
## Defining Your MCP Server
Create a file called `server.py` and begin by instantiating the `MCPServer` class:
“`python
from mcp.server import MCPServer
mcp = MCPServer(
“Developer Support KB”,
instructions=(
“Use the knowledge-base tools to answer support questions. ”
“Prefer retrieved KB information over guessing.”
),
)
“`
The `MCPServer` class is the high-level server API in the current Python SDK. It is the recommended starting point for most use cases. The SDK also exposes a lower-level `Server` class, which is designed for scenarios requiring fine-grained control over schemas, protocol metadata, or custom protocol methods.
Next, let us populate our server with data. We will store a small collection of articles in memory:
“`python
ARTICLES = [
{
“id”: “python-env”,
“title”: “Creating a Python virtual environment”,
“body”: (
“Create a virtual environment with `python -m venv .venv`, ”
“then activate it before installing dependencies.”
),
},
{
“id”: “reset-password”,
“title”: “Resetting your password”,
“body”: (
“Open Account Settings, choose Security, and select ”
“Reset Password. A verification email will be sent.”
),
},
{
“id”: “api-rate-limit”,
“title”: “Understanding API rate limits”,
“body”: (
“API rate limits restrict the number of requests allowed ”
“within a time window. Clients should retry using ”
“exponential backoff after receiving a rate-limit response.”
),
},
]
“`
At this point, we simply have a Python dictionary and a list. The real power emerges when we expose these through the MCP protocol.
—
Creating an MCP Tool
An MCP **tool** is a function that the model can autonomously decide to invoke during a conversation. Tools represent actions the system can perform.
Add the following decorator and function to your `server.py`:
“`python
@mcp.tool()
def search_kb(query: str, limit: int = 3) -> list[dict[str, str]]:
“””Search the support knowledge base.
Args:
query: Words or phrases to search for.
limit: Maximum number of articles to return.
“””
query = query.lower()
matches = []
for article in ARTICLES:
searchable_text = (
article[“title”] + ” ” + article[“body”]
).lower()
if query in searchable_text:
matches.append(article)
return matches[:limit]
“`
Notice what you did **not** need to write:
– No JSON Schema definitions
– No manually crafted tool manifests
– No argument parsers or validation logic
The SDK automatically derives the tool definition from the Python function itself. The function’s type hints become the MCP input schema, and default values such as `limit: int = 3` make parameters optional in the generated schema.
Your function signature:
“`python
def search_kb(
query: str,
limit: int = 3
)
“`
effectively becomes an MCP tool definition that looks like:
“`json
{
“name”: “search_kb”,
“inputSchema”: {
“type”: “object”,
“properties”: {
“query”: {
“type”: “string”
},
“limit”: {
“type”: “integer”,
“default”: 3
}
},
“required”: [“query”]
}
}
“`
This is one of the most pleasant aspects of developing MCP servers in Python: your function signature is your interface definition. There is very little ceremony involved.
—
## Exposing an MCP Resource
While tools are actions the model can invoke, **resources** serve a different purpose. They expose data that the host application can load into its context, making them ideal for read-only content.
Add this to your server:
“`python
@mcp.resource(“kb://articles”)
def list_articles() -> str:
“””Return the available knowledge-base articles.”””
return ”
“.join(
f”{article[‘id’]}: {article[‘title’]}”
for article in ARTICLES
)
“`
The resource is identified by the URI `kb://articles`. A client can read it directly without ever invoking a tool.
“`
Resource ≈ data that can be read
Tool ≈ function that can perform work
“`
The SDK documentation draws a rough analogy: resources behave similarly to `GET` requests, while tools are more like action-oriented `POST` requests.
—
## Adding an MCP Prompt
We can also expose a reusable prompt template that helps the model generate consistent responses.
“`python
@mcp.prompt()
def draft_support_reply(customer_message: str) -> str:
“””Create a prompt for drafting a concise support response.”””
return f”””
You are a technical support assistant.
Write a concise and helpful response to this customer message:
{customer_message}
Use the support knowledge base when relevant.
Do not invent product policies.
“””.strip()
“`
Prompts are typically initiated by the user or host application rather than being called autonomously by the model. The current SDK supports tools, resources, and prompts through the same clean, decorator-based server interface.
—
## The Complete Server Code
At this point, your `server.py` should look like this:
“`python
from mcp.server import MCPServer
mcp = MCPServer(
“Developer Support KB”,
instructions=(
“Use the knowledge-base tools to answer support questions. ”
“Prefer retrieved KB information over guessing.”
),
)
ARTICLES = [
{
“id”: “python-env”,
“title”: “Creating a Python virtual environment”,
“body”: (
“Create a virtual environment with `python -m venv .venv`, ”
“then activate it before installing dependencies.”
),
},
{
“id”: “reset-password”,
“title”: “Resetting your password”,
“body”: (
“Open Account Settings, choose Security, and select ”
“Reset Password. A verification email will be sent.”
),
},
{
“id”: “api-rate-limit”,
“title”: “Understanding API rate limits”,
“body”: (
“API rate limits restrict the number of requests allowed ”
“within a time window. Clients should retry using ”
“exponential backoff after receiving a rate-limit response.”
),
},
]
@mcp.tool()
def search_kb(
query: str,
limit: int = 3,
) -> list[dict[str, str]]:
“””Search the support knowledge base.”””
query = query.lower()
matches = []
for article in ARTICLES:
searchable_text = (
article[“title”] + ” ” + article[“body”]
).lower()
if query in searchable_text:
matches.append(article)
return matches[:limit]
@mcp.resource(“kb://articles”)
def list_articles() -> str:
“””Return the available knowledge-base articles.”””
return ”
“.join(
f”{article[‘id’]}: {article[‘title’]}”
for article in ARTICLES
)
@mcp.prompt()
def draft_support_reply(
customer_message: str,
) -> str:
“””Create a support-response prompt.”””
return f”””
You are a technical support assistant.
Write a concise and helpful response to this customer message:
{customer_message}
Use the support knowledge base when relevant.
Do not invent product policies.
“””.strip()
if __name__ == “__main__”:
mcp.run(“streamable-http”)
“`
That is a complete, network-accessible MCP application in fewer than eighty lines of Python.
—
## Running the Server
### Development Mode
For local development and testing, the SDK provides a convenient command that launches the server with MCP Inspector support:
“`bash
uv run mcp dev server.py
“`
The MCP Inspector provides a user interface for listing and invoking tools directly from your browser. The development command prints an Inspector URL to your terminal. Once opened, you should see `search_kb` listed under Tools.
Try calling it with the following input:
“`json
{
“query”: “rate limit”
}
“`
You should receive a result containing the matching article about API rate limits, returned as structured JSON.
### Production HTTP Mode
To run the server as a proper HTTP endpoint, simply execute:
“`bash
uv run python server.py
“`
By default, the MCP endpoint is exposed at `/mcp` using the Streamable HTTP transport. The SDK’s Streamable HTTP server uses this path as its standard endpoint.
### Using ASGI for Larger Deployments
If you prefer to integrate MCP into a broader ASGI application, you can replace the `__main__` block with:
“`python
app = mcp.streamable_http_app()
“`
Then launch it with **Uvicorn**:
“`bash
uvicorn server:app
“`
This approach is particularly useful when MCP is one component inside a larger **FastAPI** or **Starlette** deployment, since `streamable_http_app()` returns a standard Starlette-compatible ASGI application.
—
## Understanding the Stateless Protocol Change
The 2026-07-28 specification represents a deliberate architectural pivot. Under older versions of the protocol, the communication flow looked like this:
1. The client sends an `initialize` request to the server.
2. The server responds with an `Mcp-Session-Id`.
3. All subsequent requests from the client include that session ID, tying them to a specific logical session on the server.
This created significant challenges for deployment at scale. Multi-instance setups required sticky routing or shared session infrastructure to ensure that a client’s requests always reached the same server instance.
Under the new stateless model, every request is self-contained and independent:
“`
Request 1 ──► Server A
Request 2 ──► Server C
Request 3 ──► Server B
“`
No protocol-level session is needed to tie those calls together. The MCP team describes this as moving from a bidirectional, stateful protocol core to a stateless request/response model. The practical benefit is straightforward: ordinary load balancing just works, and you can deploy any number of workers behind a standard reverse proxy or load balancer without any special configuration.
—
## Writing a Python Client
To verify your server works independently of any AI application, you can write a simple Python client using the SDK:
“`python
import asyncio
from mcp import Client
async def main() -> None:
async with Client(
“http://localhost:8000/mcp”
) as client:
print(
“Protocol:”,
client.protocol_version,
)
tools = await client.list_tools()
print(”
Available tools:”)
for tool in tools.tools:
print(“-“, tool.name)
result = await client.call_tool(
“search_kb”,
{
“query”: “rate limit”,
“limit”: 2,
},
)
print(”
Tool result:”)
if result.structured_content:
print(result.structured_content)
else:
print(result.content)
if __name__ == “__main__”:
asyncio.run(main())
“`
Run the server in one terminal and the client in another:
“`bash
# Terminal 1
uv run python server.py
# Terminal 2
uv run python client.py
“`
The v2 `Client` accepts an HTTP URL directly and automatically uses the Streamable HTTP transport. It also exposes the negotiated protocol version, so you should see the modern protocol version reported by the connection.
Expected output:
“`
Protocol: 2026-07-28
Available tools:
– search_kb
Tool result:
{‘result’: [{‘id’: ‘api-rate-limit’, ‘title’: ‘Understanding API rate limits’, ‘body’: ‘API rate limits restrict the number of requests allowed within a time window. Clients should retry using exponential backoff after receiving a rate-limit response.’}]}
“`
—
## Managing Application State in a Stateless World
A common point of confusion: **stateless protocol does not mean your application can never maintain state.** It means MCP itself no longer hides application state behind a protocol session.
If you were building a shopping server, for example, instead of relying on the idea that:
“`
MCP session 42 owns this basket
“`
you would expose explicit operations:
“`python
@mcp.tool()
def create_basket() -> dict[str, str]:
basket_id = create_new_basket()
return {
“basket_id”: basket_id
}
“`
Then, for adding items:
“`python
@mcp.tool()
def add_item(
basket_id: str,
product_id: str,
) -> dict:
return add_product(
basket_id,
product_id,
)
“`
Now the model sees and passes `basket_id` explicitly as part of its tool calls. The MCP maintainers specifically recommend this explicit-handle pattern for application-level state under the new stateless protocol.
The philosophical shift is worth noting:
– **Old approach:** Protocol remembers state.
– **New approach:** Application owns state, and identifiers travel explicitly between calls.
This makes your server more predictable and easier to reason about, even if it requires slightly more discipline on the application side.
—
## Scaling the Server
The stateless core becomes especially valuable when scaling horizontally. You can run multiple server workers behind a load balancer:
“`bash
uvicorn server:app –workers 4
“`
Because every MCP request is self-contained, any worker can handle any request. There is no need for a client to return to the specific worker that handled a previous request in the same logical conversation.
“`
+–> Worker 1
Client ——> LB +–> Worker 2
+–> Worker 3
+–> Worker 4
“`
Advanced topics such as multi-round-trip interactions, shared subscription events, authorization, and distributed state are important application architecture concerns, but they are orthogonal to basic MCP tool execution. The stateless protocol keeps the simple cases simple while leaving room for sophisticated patterns when needed.
—
## Frequently Asked Questions
### Do I need a framework like FastAPI or Flask to run an MCP server?
No. The SDK provides a built-in `streamable-http` runner that works out of the box with `mcp.run(“streamable-http”)`. Frameworks like FastAPI or Starlette are only necessary if you want to integrate MCP alongside other API endpoints in the same application.
### Can I use the lower-level `Server` class instead of `MCPServer`?
Yes. The SDK exposes both. `MCPServer` is the recommended high-level API for most servers, offering decorator-based tool, resource, and prompt definitions with minimal boilerplate. The lower-level `Server` class is intended for cases where you need exact control over JSON schemas, protocol metadata, or custom protocol methods.
### Is the stateless protocol change backward compatible?
The 2026-07-28 specification introduces the stateless model as the core protocol behavior. Older clients that rely on session-based workflows may not interoperate with servers configured for the new spec. Both clients and servers should align on the protocol version they support.
### How do I handle user authentication or authorization?
Authentication and authorization are handled at the HTTP layer (e.g., via API keys, OAuth tokens, or middleware in your ASGI app). The MCP protocol itself does not define auth mechanisms. You can use standard ASGI middleware with Uvicorn or embed your auth logic before requests reach the MCP server.
### Can I store persistent data in my MCP server?
Absolutely. Stateless refers only to the protocol layer — MCP does not manage session state. Your server can connect to databases, file systems, caches, or any other storage backend. The only convention is that application-level identifiers (like a `basket_id`) must be passed explicitly through tool calls rather than stored in an MCP session.
### What Python versions are supported?
The SDK v2 requires Python 3.10 or newer.
### How do I debug my MCP server?
The `mcp dev` command launches the MCP Inspector, which provides a browser-based UI for listing tools, examining schemas, and invoking functions interactively. This is the recommended starting point for debugging.
—
## Conclusion
The shift to a stateless MCP protocol is a meaningful but manageable evolution. The day-to-day developer experience remains remarkably consistent: you still decorate functions with `@mcp.tool()`, you still return dictionaries and let the framework handle serialization, and you still call `mcp.run()` to serve your application. The difference is what happens beneath the surface — no handshake negotiation, no session to maintain, and no sticky routing to configure.
The Python SDK v2 makes building MCP servers feel like writing regular Python code. With `MCPServer`, Streamable HTTP transport, and a client library that connects with a single URL, the barrier to entry is lower than ever. Get comfortable with the `MCPServer` API, keep your stdout clean, and the rest of the stateless specification largely stays out of your way.
Whether you are building a simple knowledge base, integrating MCP into a larger FastAPI deployment, or scaling to multiple workers behind a load balancer, the new protocol makes the entire process cleaner and more straightforward.
Thank you for reading



