Building an MCP Server in Python From Scratch
Learn how MCP's July 2026 redesign makes stateless servers possible in Python.

Model Context Protocol turns out to solve a problem most AI builders were patching over with brittle, one-off integrations: how does a language model actually reach a database, a file system, or an internal API without someone hand-wiring a custom connector every single time? This piece walks through building an MCP server in Python from the ground up, using the FastMCP framework, organized around the three primitives (tools, resources, and prompts) that the spec itself treats as separate and non-negotiable categories.
What the MCP specification defines, and what changed in the July 2026 release
MCP is a JSON-RPC 2.0 specification, and it version its releases by date rather than by the semantic major.minor.patch scheme most software developers are used to. A server built against the 2025-11-25 revision and a client built against 2026-07-28 aren't automatically compatible in the way "2.1" and "2.2" might imply. They're just two dated snapshots of an evolving contract, and someone has to decide whether both sides speak the same dialect.
The July 28, 2026 release is the current specification, and it made a structural change that Python builders in particular should understand before writing a line of code. Streamable HTTP, the transport most production servers now use, became stateless at the protocol layer. Earlier revisions pinned a client to whichever server instance issued its Mcp-Session-Id. Under the July 2026 spec, any server instance can answer any request. Protocol-level sessions are gone, along with the standalone GET stream, SSE event IDs, and Last-Event-ID resumption, all of which existed specifically to make that old pinned-session model work.
The same release deprecated sampling, and introduced Multi Round-Trip Requests, header-based routing, cacheable list results, tighter authorization rules, and a formal framework for extensions. None of that is decorative. A server that scales behind a plain round-robin load balancer contrasts with one that needs sticky sessions and a shared state store just to stay coherent.
The previous stable version, 2025-11-25, hasn't been retired. The v2 Python SDK answers both revisions simultaneously, from the same server code, with no flag to flip. The SDK handles both specification revisions from the same server code, without extra configuration on the developer's part. That backward compatibility is doing a lot of quiet work.
The spec organizes itself around three primitives: tools, resources, and prompts, and this structure produces how each capability is defined and documented. Each one gets its own dedicated page in the 2026-07-28 documentation, and each page opens with a section called "User Interaction Model." That heading is the spec authors telling you, directly, that the three primitives aren't just different data shapes. They're different answers to the question of who is allowed to decide when something runs.
Setting up the Python SDK and development environment
Start with Python 3.10 or later. In practice, most current setups run a recent 3.1x release, and there's no strong reason to fight that trend.
The current stable SDK is v2, a substantial rework built to support the 2026-07-28 specification alongside every earlier revision, plus a handful of architectural fixes that had accumulated as technical debt in v1. Installation via pip install mcp gives you v2, which is now the default, and pip install fastmcp is the alternative if you prefer working with the FastMCP layer directly. For dependency resolution, adopt uv over plain pip. It's noticeably faster at resolving a dependency tree, which matters once a project accumulates a few dozen packages.
One gotcha deserves a flag of its own: uvx ignores the lockfile and the Python version range you've specified. That means it can quietly install a version of a package you didn't test against. Pin your dependencies explicitly rather than trusting uvx to respect constraints it doesn't actually read.
If existing infrastructure depends on v1.x, it hasn't been abandoned. It still receives critical bug fixes and security patches on its own branch. But since pip install mcp now defaults to v2, anyone maintaining a v1 codebase needs an explicit upper bound in their requirements file, something like mcp>=1.28,<2, or the next clean install will pull in a major version nobody asked for.
The three primitives and why the distinction between them is not just conceptual
Go back to that "User Interaction Model" heading, because it's the key to understanding why tools, resources, and prompts aren't interchangeable ways of exposing the same data.
Tools are model-controlled. They're executable functions, often with side effects: running a query, writing a record, calling an external API. The model decides when to invoke one, and it can do so at any point in a conversation, any number of times. Nobody outside the model's own reasoning gates that decision.
Resources are application-controlled. They expose read-only data, a file, a database record, an entry in a knowledge base, but the host application decides when to surface them. The model does not reach out and pull a resource on its own initiative the way it does a tool. That's a deliberate boundary, not an oversight.
Prompts sit in a third category entirely: user or workflow-controlled. They're parameterized templates that shape how the model approaches a task, and a human or an orchestration layer chooses when to invoke them, not the model itself.
Support across the client ecosystem reflects that hierarchy almost exactly. Of 113 tracked clients, only 43 support prompts and only 47 support resources, while tools enjoy support that's close to universal. That gap explains a pattern visible across nearly every MCP tutorial in circulation: they stop at tools, because tools are the primitive guaranteed to actually work wherever the reader deploys.
Building tools: the functions the model can call
FastMCP strips the ceremony out of tool creation down to something close to writing a plain Python function. Standing up a server is one line:
mcp = FastMCP("TaskServer")
No transport configuration, no schema registration, no middleware wiring. Just a name.
The @mcp.tool() decorator does the rest of the heavy lifting. Type hints on the function signature become the JSON Schema the model sees, automatically, with no manual schema-writing step. Consider a task-tracker server, since it's a small enough domain to walk through completely:
@mcp.tool()
def add_task(title: str, description: str = "") -> dict:
"""Create a new task with a unique ID, status, and timestamp."""
...
@mcp.tool()
def complete_task(task_id: int) -> dict:
"""Mark a task complete. Returns the updated task or an error dict."""
...
@mcp.tool()
def delete_task(task_id: int) -> dict:
"""Remove a task from the tracker."""
...
Three functions round out a basic CRUD surface. Each one has a side effect: add_task writes a new record, complete_task mutates state, delete_task removes something permanently. This is why these are tools and not resources. None of them are passive.
What's absent is notable. There's no JSON Schema definition sitting above the function; a: int, b: int in the signature is the schema. There's no request-parsing boilerplate, no validation code checking that task_id arrived as an integer rather than a string. FastMCP infers all of it from the annotations already sitting in the function definition, which is a meaningful contrast against lower-level approaches that require more explicit wiring.
Building resources: read-only data the application surfaces
Resources use a different decorator and a different mental model. Instead of describing an action, @mcp.resource("uri://pattern") describes a location, and the function behind it returns data rather than performing a task.
Extend the task-tracker example by exposing the current list of tasks as a resource rather than a tool:
@mcp.resource("tasks://list")
def get_task_list() -> list[dict]:
"""Return the current list of tasks as read-only context."""
...
The model can read this to understand what state the tracker is in before deciding which tool to call next, but it can't modify anything through the resource itself. That separation, read here versus act there, is the whole point of keeping resources apart from tools.
URI templates can carry parameters, too. A pattern like tasks://{task_id} maps directly onto a function argument, so fetching a single task by ID looks like this:
@mcp.resource("tasks://{task_id}")
def get_task(task_id: int) -> dict:
"""Fetch a single task by ID."""
...
For scenarios where multiple agents interact with the same data, a push-based notification model is more efficient than polling, and the MCP spec's resource design anticipates this kind of concurrent use.
Building prompts: reusable templates that structure the model's behavior
The @mcp.prompt() decorator wraps a function that returns a string, or a list of messages, which becomes a template the host application can surface to the model at the right moment.
Extend the task-tracker one more time with a prompt that guides a prioritization workflow:
@mcp.prompt()
def prioritize_tasks(context: str) -> str:
return (
f"Review the tasks://list resource. Given this context: {context}, "
"identify blocked items, suggest a priority order, then use "
"complete_task to close any tasks the user confirms."
)
That's the detail separating a real prompt from a dressed-up system message: it names the server's own resource and its own tool directly, encoding an actual workflow rather than a generic instruction to "be helpful." A prompt like this captures institutional knowledge about how the server is meant to be used, and it does that once, centrally, rather than leaving every downstream agent to reinvent the same reasoning through trial and error in its own system prompt.
It's disappointing how often prompts get skipped. Most MCP tutorials either ignore the primitive entirely or reduce it to a trivial wrapper around a static system message, missing the leverage a well-built prompt actually offers.
That gap has a practical cause as well as an educational one. With only 43 of 113 tracked clients supporting prompts, testing against whichever client you actually intend to deploy against isn't optional. A prompt that works beautifully in one host may simply not render in another, and there's no universal fallback to lean on yet.
Choosing a transport: stdio for local use, Streamable HTTP for everything else
Three transport options exist on paper, but the real decision comes down to two.
stdio has the client spawn the server as a child process and talk to it over stdin and stdout. It's the right fit for local, single-client, developer-tooling scenarios, the kind of setup Claude Desktop or a local VS Code Copilot configuration uses. There's no networking to configure, no port to open, no authentication layer to think about.
Streamable HTTP exposes a single /mcp endpoint that only accepts POST requests. Every client message travels as its own HTTP POST, and the server answers either with a single JSON object or with an SSE stream scoped to that specific request rather than to a long-lived session. This is the correct choice for anything remote, or anything serving more than one client at once.
The third option, HTTP+SSE, was deprecated back in the 2025-03-26 spec revision. There's no reason to build a new server against it today.
The July 2026 statelessness change (covered above) is what makes Streamable HTTP genuinely practical at scale. Requests can land on any instance sitting behind a plain round-robin load balancer, with no shared session store required to keep track of who talked to whom. That's a real infrastructure simplification, not a minor protocol footnote.
Switching a FastMCP server between transports is a one-line change, not a rewrite:
mcp.run(transport="stdio") # local development
mcp.run(transport="streamable-http") # remote or multi-client deployment
Practical guidance follows naturally from that: build and test with stdio, since it demands nothing in the way of network setup, and switch to Streamable HTTP before any deployment that has to serve more than one client or that runs somewhere other than a developer's own machine.
Running, inspecting, and connecting the server to a real client
The sources checked for this guide are listed below.

