So you want to build an MCP server. You’ve seen Claude Desktop pulling files off your laptop, calling a database, or hitting an internal API, and now you want Claude doing the same thing with your own tools. Maybe Cursor too. Maybe a custom client you’re writing.
Good news: figuring out how to build an MCP server in Python is way easier than the docs make it look. The whole setup, from an empty folder to a working build wired into Claude Desktop, takes about an hour the first time. Maybe less if you don’t trip on the config file.
I built my first MCP server a few weeks ago. Some of it was painless. Some of it cost me a Friday afternoon. What’s below is the version I wish I’d had on day one: a working server with two tools and one resource, connected to Claude Desktop, plus every spot that broke the first time so you can skip over them.
If you already know what MCP is, jump past the next section.
Quick answer: how to build an MCP server in 5 steps
- Install Python 3.10+ and the MCP SDK with
pip install "mcp[cli]". - Create a
server.pyfile and importFastMCPfrom the SDK. - Register your functions as tools with the
@mcp.tool()decorator. - Run the server over stdio with
mcp.run(transport="stdio"). - Add the server to
claude_desktop_config.jsonwith absolute paths and restart Claude Desktop.
That’s the shape of it. The rest of this post is the version with the details filled in, so each step actually works.
What an MCP server actually is (in 60 seconds)
MCP stands for Model Context Protocol. Anthropic released it as an open standard in late 2024. The idea is simple: instead of every LLM app inventing its own plugin system, there’s one protocol for connecting models to outside tools and data.
There are two sides:
- Client. The app the human is using (Claude Desktop, Cursor, etc.). It talks to the model.
- Server. A small process you write. It exposes things the model can use.
A server exposes three kinds of things:
- Tools. Functions the model can call. Like
read_file(path)orquery_db(sql). - Resources. Data the model can read. Like a file, a config blob, or a database row.
- Prompts. Reusable prompt templates the user can pick from a menu.
Underneath it’s JSON-RPC. You don’t write JSON-RPC by hand. The SDK does it.
Transport is either stdio (server runs as a local subprocess of the client) or HTTP with Server-Sent Events (for remote servers). Most people start with stdio because it works on your laptop with zero networking setup.
That’s the whole mental model. Now we build one.
Step 1: set up your MCP server project
You need Python 3.10 or newer. Check with:
python3 --version
Make a folder and a virtualenv:
mkdir weather-mcp && cd weather-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]"
The [cli] extra pulls in the mcp command-line helper, which is useful for testing servers without firing up a full client.
Create one file:
touch server.py
That’s the whole project. No framework, no scaffolding, no codegen. Good.
Step 2: write the MCP server in Python
Open server.py and start with the simplest version that actually does something useful:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
def get_temperature(city: str) -> str:
"""Return the current temperature for a city, in Celsius."""
# In a real server you'd hit a weather API here.
# Hardcoded for the example.
fake_data = {
"tokyo": 14,
"berlin": 7,
"saskatoon": -12,
}
temp = fake_data.get(city.lower())
if temp is None:
return f"No data for {city}"
return f"{city}: {temp} C"
if __name__ == "__main__":
mcp.run(transport="stdio")
That’s a working MCP server. Run it once to make sure it doesn’t crash on startup:
python server.py
It’ll sit there waiting for JSON-RPC messages on stdin. Hit Ctrl-C to exit.
A few things worth pointing out:
- The
FastMCPclass is the high-level API. There’s a lower-levelServerclass if you want full control, butFastMCPcovers 95% of cases. - The decorator
@mcp.tool()registers the function as a callable tool. The function’s docstring becomes the description the model sees. Write it like you’re writing for the model, because you are. - Type hints matter. The SDK reads them to build the JSON schema the model uses to decide which arguments to send.
Add a second tool
Let’s add one that takes multiple arguments, so the model has to think about parameters:
@mcp.tool()
def compare_cities(city_a: str, city_b: str) -> str:
"""Compare the temperatures of two cities."""
fake_data = {
"tokyo": 14,
"berlin": 7,
"saskatoon": -12,
}
a = fake_data.get(city_a.lower())
b = fake_data.get(city_b.lower())
if a is None or b is None:
return "One or both cities are unknown."
diff = a - b
warmer = city_a if diff > 0 else city_b
return f"{warmer} is warmer by {abs(diff)} C"
Add a resource
Tools are functions. Resources are data the model can read. Let’s expose the supported cities as a resource:
@mcp.resource("weather://cities")
def list_cities() -> str:
"""List of cities this server knows about."""
return "tokyo, berlin, saskatoon"
Resources have URIs. The model (or the user) can ask for a resource by URI and get the content back. Use them for static-ish data: config, lookup tables, documentation snippets, file contents.
Your server.py should now have two tools, one resource, and the mcp.run() line at the bottom.
Step 3: connect your MCP server to Claude Desktop
This is the step where most people get stuck. Pay attention to paths.
Claude Desktop reads a config file. Location depends on your OS:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux: Claude Desktop isn’t officially supported, but the path is
~/.config/Claude/claude_desktop_config.jsonfor community builds.
If the file doesn’t exist, create it. Add an entry under mcpServers:
{
"mcpServers": {
"weather": {
"command": "/full/path/to/weather-mcp/.venv/bin/python",
"args": ["/full/path/to/weather-mcp/server.py"]
}
}
}
Two things to get right:
- Use the full absolute path. Claude Desktop doesn’t run with your shell’s PATH. So
python3won’t resolve. The path needs to be exact. - Point at the venv’s Python. If you point at the system Python, the
mcppackage won’t be installed and the server will crash on import. Usewhich pythonwhile your venv is active to get the right path.
Save the file. Fully quit Claude Desktop (Cmd-Q on macOS, not just close the window). Reopen it.
If it worked, you’ll see a small icon in the input area showing your MCP server is connected. Click it and you should see your two tools and one resource listed.
Ask Claude: “What’s the temperature in Tokyo?” It should call your get_temperature tool and answer.
Step 4: common MCP server gotchas (the parts that broke for me)
Same gotchas trip up almost everyone the first time.
“My server doesn’t show up at all”
Check the Claude Desktop logs:
- macOS:
~/Library/Logs/Claude/mcp*.log - Windows:
%APPDATA%\Claude\logs\
Look for the file matching your server name (mcp-server-weather.log here). Most of the time, the error is one of:
- Wrong Python path (interpreter doesn’t exist or can’t find
mcp). - Wrong script path (typo, or path has spaces that aren’t quoted).
server.pyraised an exception during startup.
“It shows up but the model never uses it”
Two common reasons:
- Bad docstrings. The model picks tools based on the description. If your docstring is
"do thing", the model has no idea when to call it. Write descriptions like you’d write API docs for a new dev. - Wrong types. If you forget the return-type annotation, the SDK still works but the schema gets fuzzier. Always annotate.
“I added a print() statement and now the server is broken”
Stdio transport uses stdin/stdout for the actual JSON-RPC messages. If you print() to stdout, you corrupt the protocol stream and the client will disconnect.
Use the logging module and send logs to stderr:
import logging
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
log = logging.getLogger("weather")
@mcp.tool()
def get_temperature(city: str) -> str:
log.info(f"get_temperature called with {city}")
...
Logs to stderr show up in the Claude Desktop log file. Logs to stdout kill your server. I learned this one the hard way.
“I changed the code but the server still runs the old version”
Claude Desktop launches your server as a subprocess at startup. If you edit server.py, you need to restart Claude Desktop for the new code to load. There’s no hot reload.
For faster iteration, test the server standalone with the mcp CLI before involving Claude Desktop:
mcp dev server.py
That opens the MCP Inspector, a web UI where you can call your tools by hand. Way faster than reloading Claude Desktop every change.
How to build your own MCP server beyond the basics
What you have now is a local stdio server with mocked data. If you want to build your own MCP server for something real, here are the next steps that actually matter:
- Real API calls. Replace the
fake_datadict with an actual HTTP call to a weather API. The tool function can beasync defand callhttpxor whatever you like. - Authentication. If your tool hits an internal service, the server needs credentials. Read them from env vars or a config file. The
envfield inclaude_desktop_config.jsonlets you pass them in. - Multiple servers. Add more entries under
mcpServers. Claude Desktop runs them all. - HTTP transport. For servers you want to share with teammates or run in production, swap
stdiofor HTTP+SSE. The SDK supports both. The client config changes to a URL instead of a command.
The protocol is simple enough that once you’ve shipped one server, the second one takes 15 minutes.
What this gets you
You now have a server that exposes Python functions to any MCP-compatible client. That’s a small thing with a large blast radius. Internal tooling that used to require a custom plugin per app (one for Slack, one for the CLI, one for the IDE) collapses to a single MCP server everyone’s tools can talk to.
If your team has a knowledge base, a deploy pipeline, a queue of pending reviews, or any other tool that engineers query through a chat-style interface, an MCP server in front of it is usually the right move.
FAQ
If you ship the server, post the repo link. I’m collecting examples of small, useful MCP servers that aren’t just wrappers around someone else’s API. The most interesting ones expose something only your team has access to.