Contents
Figure 1: MCP Server Development in Python: First Tool to Production — the protocol got simpler, and the habits most tutorials teach got obsolete
Most MCP tutorials you will find were written for an older SDK and an older protocol. They still run, but they teach habits the current spec has dropped: sessions, an initialize handshake, and server-initiated requests.
This guide follows the current Python SDK (v2) and the 2026-07-28 spec. I built and tested a small server with them, so every behaviour below is measured, not assumed — and where something is my reasoning rather than a test result, I say so. If you followed the earlier v1 walkthrough, the differences here are the ones that will break your code.
What Changed, So You Don't Learn the Old Way
The Python SDK's README calls v2 "a major rework" and says it is the current stable line. It also warns that pip install mcp now installs 2.x. If your code depends on v1, pin it (the README's example is mcp>=1.28,<2). The SDK needs Python 3.10 or newer.
The 2026-07-28 spec made MCP stateless. Here is what that means in practice:
| Old habit | Status now | What replaces it |
|---|---|---|
initialize handshake |
Gone | Every request carries its protocol version and client capabilities |
Protocol-level sessions, Mcp-Session-Id header |
Gone from Streamable HTTP | server/discover advertises versions and capabilities |
| Server-initiated requests (sampling, elicitation, roots) | Replaced | "Input required" results the client answers by retrying |
| Roots, Sampling, Logging | Deprecated | Avoid in new servers |
| HTTP+SSE transport | Deprecated | Streamable HTTP |
Why should you care? Because your server can no longer count on remembering a connection. Keep that in mind when we reach state.
Your First Server
The README's quickstart uses the MCPServer class, not the FastMCP name older tutorials use. Install with pip install "mcp[cli]", then write this:
from mcp.server import MCPServer
from pydantic import BaseModel
mcp = MCPServer("Notes", version="0.1.0")
class Note(BaseModel):
id: int
text: str
@mcp.tool()
def add_note(text: str) -> Note:
"""Save a note and return it."""
...
if __name__ == "__main__":
mcp.run() # stdio by default
Type hints become the tool's input schema. In my test, a function taking text: str produced a schema requiring one string. Returning a Pydantic model also produced an output schema, and the client received the result as structured data ({'id': 1, 'text': 'hello mcp'}). Returning a bare int came back wrapped as {'result': 4}.
Write real docstrings. The model reads them to decide when to call your tool, so "Save a note and return it" does more work than you would expect. A vague docstring is a routing bug waiting to happen, and it is the cheapest thing to fix.
I left version empty in my first run, and the server advertised an empty version string. Set it. It shows up in discovery, and an empty value tells clients nothing about what they connected to.
Resources and Prompts
Tools are for actions. Resources expose data by URI, and prompts package reusable instructions. A templated resource looks like @mcp.resource("notes://{note_id}"), and a prompt is a decorated function that returns text. Both worked in my tests.
@mcp.resource("notes://{note_id}")
def read_note(note_id: int) -> str:
"""Read one note by ID."""
...
@mcp.prompt()
def summarise_notes() -> str:
"""Summarise all stored notes."""
return "Summarise the notes in three bullets."
Use a resource when a client should be able to read something without the model deciding to call a tool. That distinction is the whole reason resources exist, and it is the part most tutorials skip straight past.
Errors: The Part That Bites
This is where I got surprised. I wrote three failing tools and compared what the model sees.
| What you raise | What the model sees | What the server does |
|---|---|---|
Plain ValueError |
Only "Error executing tool add_note" — your message withheld | Logs a full traceback |
ToolError |
is_error=True plus your message |
Logs it quietly |
| Wrong argument type | Detailed validation message, automatic | Returns a validation error |
from mcp.server import MCPServer, ToolError
@mcp.tool()
def add_note(text: str) -> Note:
"""Save a note and return it."""
if not text.strip():
raise ToolError("note text cannot be empty; pass at least one character")
...
The spec backs this design. It separates protocol errors from tool execution errors, and says clients should show execution errors to the model so it can self-correct. So raise ToolError for failures you expect, such as bad dates, empty input and missing records. Reserve plain exceptions for real bugs. For resources, I raised ResourceNotFoundError and the client got a clean no note 99 error.
In my view, this is the single most useful habit in the guide. Models retry well when you tell them what went wrong, and they retry blindly when you hide it.
Transports: stdio First, HTTP When You Need It
stdio runs your server as a subprocess of the client. It is the right default for local tools. One rule: log to stderr, never stdout, because stdout carries the protocol messages. My lifespan hook printed its start and stop messages to stderr, and they appeared fine.
I also tested what happens when a server breaks that rule with a stray print(). With this SDK's client, the call still worked, and the client logged a parse failure for the stray line. Do not read that as permission. I tested only this one client, and others may be stricter.
Streamable HTTP is for remote servers. mcp.run("streamable-http") served on port 8000 at /mcp in my test. I made two calls from a client with no session setup, and both worked, which matches the stateless spec. Skip the old SSE transport for new work.
For quick manual testing, the docs recommend mcp dev server.py, which opens the MCP Inspector. The docs say it needs npx on your PATH. I did not run it here, so treat that as a pointer rather than a verified step.
State Without Sessions
My demo keeps notes in a Python dictionary. That is fine for a demo on one process. It breaks the moment you run two copies behind a load balancer, because each copy has its own dictionary. That is my reasoning, not something I tested.
The spec's own guidance is to pass explicit handles. A creation tool returns an ID, and later tools accept it as an argument. The model carries the ID forward. The spec adds three cautions:
- Validate the caller's authorization against the handle on every call.
- Make handles opaque and high-entropy, so they cannot be guessed or enumerated.
- State their lifetime in the tool description, and return a clear error when one expires.
Put real state in a database or cache, keyed by those handles. Point 1 is the one people skip, and it is the difference between an ID and a capability. The agent memory guide covers the storage side if you are choosing a backend.
Hardening Before Production
I sent raw HTTP requests to my server to check what the spec requires.
| Probe | Result | Meaning |
|---|---|---|
Foreign Origin header |
HTTP 403 | Origin validation works; the spec requires it to prevent DNS rebinding |
Changed Mcp-Name to another tool |
HTTP 400, code -32020 | Header mismatches are rejected |
| Unknown protocol version | HTTP 400, code -32022 | Only 2026-07-28 listed as supported |
| Valid request | No session ID | Matches the stateless spec |
Missing MCP-Protocol-Version |
"Missing session ID" | Suggests fallback to old-style handling |
GET on the endpoint |
400 | The spec says servers should answer 405 |
The last two are rough edges rather than vulnerabilities, but test your own clients against both, because "it returned an error" and "it returned the specified error" are different things.
Beyond that, the spec's checklist is short:
- Validate all tool inputs.
- Apply access controls.
- Rate-limit invocations.
- Sanitize outputs.
- Bind to
127.0.0.1, not0.0.0.0, when running locally. - Behind nginx, send
X-Accel-Buffering: noon streamed responses so the proxy does not buffer them. - Return tools in a deterministic order, which helps client caches.
Authentication
Authorization is optional in the spec, and the rules differ by transport. Stdio servers should not use the OAuth flow. They should read credentials from the environment. HTTP servers should follow the OAuth-based spec, and that comes with firm requirements:
- Implement protected resource metadata (RFC 9728), so clients can find your authorization server.
- Validate that every access token was issued for your server as its audience.
- Never accept or pass along tokens meant for another resource.
- Return 401 for missing, invalid or expired tokens, and 403 for insufficient scope.
Client registration moved too. Dynamic Client Registration is now deprecated in favor of Client ID Metadata Documents. The SDK's MCPServer constructor accepts auth settings and a token verifier. I did not test an OAuth flow, so treat that part as a pointer and read the SDK's authorization docs before you build it.
A Production Checklist
[ ] SDK version pinned (mcp>=1.28,<2 for v1 code, or an exact 2.x pin)
[ ] protocol version targeted and recorded
[ ] ToolError raised for every expected failure, with actionable wording
[ ] handle-based state in a shared store, not process memory
[ ] handles opaque, high-entropy, with declared lifetimes
[ ] bound to 127.0.0.1 locally, Origin validated, auth on HTTP
[ ] rate limits and tool-call logging in place
[ ] tested with a real client over stdio and HTTP
[ ] tested with raw requests for the 403, 400 and 401 paths
[ ] X-Accel-Buffering: no set if a proxy sits in front
Common Pitfalls
- Copying a v1 tutorial. Sessions and the initialize handshake no longer exist, so the structure of the code is wrong even when it appears to run.
- Leaving
pip install mcpunpinned. You will move majors on a random Tuesday. - Raising bare exceptions for expected failures. The model sees a generic message and cannot self-correct.
- Printing to stdout. One stray
print()costs the client a parse failure, and stricter clients may fail the call. - Keeping state in a module-level dictionary. It works in development and silently splits in production.
- Logging anything sensitive. Tool arguments end up in logs, and logs are rarely given the same care as a database.
- Skipping the 405/400 edge cases. Building against the spec means the failures you did not test for are the ones your users hit.
Best Book to Read
Three books cover the gaps a protocol guide cannot:
| Cover | Book | Description | Get it |
|---|---|---|---|
![]() |
AI Engineering | evaluation, retrieval and production concerns for the models your server serves, which is where most MCP projects actually fail. | View on Amazon |
![]() |
Release It! | circuit breakers, timeouts and stability patterns; the vocabulary for the failure modes a tool server hits under load. | View on Amazon |
![]() |
Building Microservices | stateless services, handle-based state and the deployment questions Streamable HTTP drags in. | View on Amazon |
As an Amazon Associate I earn from qualifying purchases. Links on this page are affiliate links, and I may earn a commission at no extra cost to you.
Start with Release It! if you already know the protocol. The hard part of production is rarely the decorator syntax.
Unlock AI That Actually Works
Get lifetime access to GPT-6 Astra, Claude Fable 5.1, Gemini 3.5, Grok 4.5, and more — all in one platform. Build websites, apps, videos, content, and digital products from a single command. No monthly fees. No tool-hopping.
Click here to get GPTAstra Max now — one-time payment, lifetime access.
Frequently Asked Questions
What is an MCP server in Python?
An MCP server is a process that exposes tools, resources and prompts to an AI client over the Model Context Protocol. In Python you build one with the official SDK: install it with pip install "mcp[cli]", create an MCPServer instance, and decorate typed functions with @mcp.tool(). Type hints become the tool's input schema automatically, and returning a Pydantic model produces an output schema. The server then runs over stdio as a subprocess of the client, or over Streamable HTTP for remote use.
Is the MCP Python SDK v2 backward compatible with v1?
No, and the README says so plainly. It calls v2 a major rework and warns that pip install mcp now installs 2.x, so an unpinned dependency can move your project across the break. If your code depends on v1 behaviour, pin it, for example mcp>=1.28,<2. The SDK also requires Python 3.10 or newer. Check your CI image's Python version before upgrading, because a 3.9 runner will fail at install rather than at runtime.
Do I still need the initialize handshake or sessions in MCP?
No. The 2026-07-28 specification made MCP stateless: the initialize handshake is gone, every request carries its own protocol version and client capabilities, and protocol-level sessions plus the Mcp-Session-Id header were removed from Streamable HTTP. Servers must implement server/discover to advertise the versions and capabilities they support. A valid request in my tests carried no session ID at all, which means your server cannot assume it remembers the previous call.
How do I return tool errors that the model can actually fix?
Raise ToolError instead of a bare exception. In my tests, a plain ValueError produced only "Error executing tool add_note" for the model, withheld the message and logged a full traceback. ToolError returned is_error=True with the message intact, so the model could read "note text must be under 10 characters; shorten it and retry" and correct its own call. The spec separates protocol errors from tool execution errors and tells clients to show execution errors to the model, so this is the intended design rather than a trick.
Can I run an MCP server over HTTP instead of stdio?
Yes. Call mcp.run("streamable-http") and the server listens on port 8000 at /mcp by default. In my test two client calls worked with no session setup, which matches the stateless spec. Keep stdio as the default for local tools, because it needs no ports, no auth surface and no proxy configuration. Skip the old HTTP+SSE transport for new work; it is deprecated, and the current guidance points at Streamable HTTP for remote servers.
How do I keep state in a stateless MCP server?
Pass explicit handles. A creation tool returns an ID, later tools accept that ID as an argument, and the model carries it forward between calls. The spec adds three requirements: validate the caller's authorization against the handle on every call, make handles opaque and high-entropy, and state their lifetime in the tool description with a clear error when one expires. Back the handles with a database or cache rather than a process dictionary, because two copies behind a load balancer will not share memory.
Wrapping Up
Building an MCP server in Python is easy now: decorate a typed function, return a model, and you have a working tool. The hard parts are the ones tutorials skip. Raise errors the model can read, keep state out of process memory, and lock down the HTTP endpoint before anyone else can reach it.
Start with a stdio server and one useful tool. Then add a ToolError path, move state into a store, and test it over HTTP. Do those three this week, and you will be ahead of most servers in the wild.
Which of the three would break your current server first: the error messages, the state, or the endpoint?
Related Articles
- Build an MCP Server in Python: Step-by-Step Tutorial
- MCP Explained: How LLMs Connect to Tools and Data
- Function Calling and Tool Use: How LLMs Take Actions
- AI Agent Guardrails: Preventing Runaway Actions
- Agent Memory Explained: Short-Term, Long-Term and Vector Stores
Sources
- Python SDK repository and README — the v2 status, the
pip install mcpwarning, the v1 pin example and the Python 3.10 requirement. - MCP specification, 2026-07-28 — statelessness,
server/discover, error semantics and the transport rules. - MCP documentation: build a server — the
mcp devinspector workflow and thenpxrequirement.
Behaviour described as tested was measured against a locally run server on 21 October 2026. Everything marked as my reasoning was not tested.


