Your First MCP Server in Python: Tools, Resources, and the Traps Nobody Warns You About
2026-08-17 · by bixzell
The default way of giving an assistant access to your data is embarrassing: you copy data out of your tools, paste it into the chat, get an answer, and do it again tomorrow. The same data, re-pasted every single day, because the model literally cannot see anything you didn’t type into its context window. A brilliant mind trapped in a room with no windows — that’s what an LLM is without a connection to your own data.
MCP (Model Context Protocol) exists to end that loop. It’s the industry’s attempt to give every assistant one standard plug for every data source, the same way USB-C standardized charging. One server you write works for any assistant that speaks MCP — a commercial or a local model, it doesn’t matter. This is the complete pattern for building your first MCP server in Python, including the traps that waste the most time, learned the hard way.
The three-things mental model
Every MCP server exposes exactly three kinds of things:
- Tools — actions the model can execute. If it contains a verb, it’s a tool: search the catalog, create a ticket, send an email.
- Resources — data the model can read. If it’s a noun, it’s a resource: a file, a product sheet, a table.
- Prompts — instruction templates the user invokes manually. In practice the least-used of the three.
You only need one tool and two resources to have a working server. That’s the whole pattern.
Who talks to whom
Three actors: the host (the application you actually use — a desktop client or a coding agent), the MCP client living inside that host, and your MCP server, which is what you write. The server is not a website you start and leave running; the client launches it as a subprocess the moment it’s needed.
There are two transports worth knowing:
- stdio — the server runs on your machine and talks over standard input/output. This is the case for ~90% of setups and the one to start with.
- streamable HTTP — the server lives somewhere remote and talks over HTTP. That’s for sharing a server with a whole team or publishing one publicly, and it’s a much bigger project.
Start with stdio. There’s no transport configuration to write at all — the official SDK defaults to it.
The library confusion (and the SDK 2.0 gotcha)
Before writing code, know that in Python there are two different libraries, and most tutorials on the internet are now obsolete. This is the single biggest source of confusion, and it’s why so many MCP tutorials “stop compiling” on the first line.
- FastMCP (27,254⭐) — an independent project at version 3.x, installed with
pip install fastmcp, imported asfrom fastmcp import FastMCP. Powers a large share of the MCP servers out there. - The official Python SDK — installed with
pip install mcp, class isMCPServer.
The confusing part: version 1 of the official SDK shipped with a bundled copy of FastMCP at the path mcp.server.fastmcp. Version 2 removed that copy. So code written against the old import (from mcp.server.fastmcp import FastMCP) now dies with ModuleNotFoundError on the very first line — not because anything you did is wrong, but because the SDK moved. The FastMCP project itself is alive and well; what disappeared is only the copy that lived inside the official SDK.
The good news: switching between the two is a one-line change, because the decorators are identical — @mcp.tool and @mcp.resource behave the same in both. Everything below works for either library; today we use the official SDK because it’s the reference implementation with no extra dependencies.
Building the server: a real example
The example: a server exposing a course catalog from a small JSON file — a search tool plus two resources to read a course’s details. Replace the JSON with your database and you have a production use case.
Set up with uv, the Python package manager:
uv init --name catalog # Python 3.13
uv add "mcp[cli]" # [cli] pulls in the inspector, the test tool we'll use
The absolute minimum server is four lines:
from mcp.server import MCPServer
server = MCPServer("catalog")
def main():
server.run()
That’s a valid, empty MCP server. It does nothing, but it’s valid — and it already defaults to stdio with zero configuration.
A tool from a single function
Tools come from a decorated function, and this is where MCP earns its keep: there’s no JSON schema to write by hand, no type declarations, no tool config file. @mcp.tool reads the function signature and docstring and generates the schema itself — the parameter name becomes the schema’s parameter name, the type hint becomes its type, the default value becomes its default.
@server.tool
def search_courses(topic: str, level: str = "all") -> list[dict]:
"""Use whenever the user asks what courses are available on a topic,
what to learn for a particular goal, or requests a recommendation."""
...
Here’s the insight that’s worth the whole tutorial: that docstring is not documentation — it’s the prompt. It’s literally what the model reads to decide whether to call your tool. When a tool “never fires,” 90% of the time it’s not a code bug — it’s a docstring that describes what the tool does instead of when to use it. Write when to use it (“whenever the user asks about courses available on a topic”), not what it does (“search courses”).
Two resources
Resources are even simpler. One is a fixed URI returning the whole index:
@server.resource("catalog://courses")
def all_courses() -> str: ...
The second is the interesting one: a URI template. The {slug} placeholder becomes the function’s parameter, and the SDK handles the routing for you:
@server.resource("catalog://course/{slug}")
def course_info(slug: str) -> str: ...
One more habit: never print()
Because the server talks to the client over standard input/output, stdout is the protocol channel. A stray print() corrupts the JSON-RPC conversation and produces failures that explain nothing. Debug via stderr — write a tiny stderr() helper and use it everywhere you’d have used print. If nobody tells you this, you don’t waste an afternoon, you waste a week.
Test before you plug it in
Don’t wire this into your assistant yet. The [cli] extra includes the MCP Inspector — the debug UI for seeing exactly what your server exposes:
uv run mcp dev server.py
It opens in your browser: a Tools tab (call search_courses live), a Resources tab (read the catalog, or fetch a single course via the template), and a Console tab showing the protocol calls and their latency in milliseconds. The golden rule: if a tool or resource doesn’t show up here, the problem is yours, not the assistant’s — don’t move on until it does.
Registering it with your assistant
With the server tested, register it with the assistant you already use. The registration saves the launch command exactly as you typed it — which is why “the server doesn’t show up” is almost always a path problem. Use an absolute path or an explicit working directory, never a relative one:
mcp add catalog --dir "$PWD" -- uv run python server.py
Scope matters, and it’s a common sticking point:
- Local scope — registered for you, in this folder only.
- Project scope — writes an
mcp.jsonyou can commit to the repo so the whole team picks it up. The assistant will keep the server pending approval until someone authorizes it. That’s not a bug; nobody wants a git pull to start an automated process on their machine.
Once registered, list the servers (mcp list) and then just ask a normal, everyday question — not “use your catalog tool” — because the whole point is watching the model decide on its own. The first time, it will ask for permission to call the tool (a genuinely useful guardrail against a model running shell commands on your machine without consent). It invokes the search, reads a course’s info sheet through the resource, and answers with data that was neither in its training nor pasted into the chat. The terminal was untouched. That’s the moment it clicks.
The four traps that burn the most time
- The server doesn’t show up → it’s the path.
mcp addsaves the command verbatim; absolute paths or an explicit working directory make it work on the first try. - It shows up but the AI never uses it → weak docstring. Rewrite it to say when to use the tool, not just what it does.
- Everything breaks in a weird way after you add a
print→ stdout is the protocol channel. Route all debugging to stderr. - You return a giant object → every tool response enters the model’s context, and you pay for it in tokens. Return only what’s needed — the necessary fields, formatted and readable, not the entire database row.
Not everything should be an MCP server
The most valuable lesson, and the one nobody says out loud: the reflex to make everything an MCP server is wrong.
- Instructions or knowledge — how you write, your team’s rules, your style guide — is not a server. It’s a skill or a context file (like a CLAUDE.md). Don’t stand up a whole server to serve plain text that never changes.
- A one-off operation isn’t a server either — it’s a script. Write it, run it, done.
- An MCP server earns its place in exactly two situations: a data source that is live — it changes without you touching it — or actions the AI has to decide when to execute, where the model, not a fixed schedule, makes the call.
Run every candidate idea against that test and you’ll save yourself a mountain of useless work — and a lot of tokens.
The complete pattern
Tools, resources, the transport, and the client registration — that’s the whole mental model, and it fits in one afternoon. Swap the example JSON for your real data source and it’s done. The spec keeps moving (the latest revision landed July 2026, and it brings long-running tasks, an extensions framework, and MCP apps that render interfaces inside the chat), so the exact syntax will drift — but the pattern won’t. A model that can read your live data on its own, without you copying and pasting anything, is the difference between using an assistant and actually connecting it to your sources of information.
That first afternoon of work is the difference. FastMCP’s page covers the independent library in depth if you want to compare approaches before choosing.