How to Build an MCP Server in Python
A working Python MCP server is about fifteen lines — here's the whole path from empty file to a tool your agent calls.

To build an MCP server in Python, install fastmcp, decorate a function with @mcp.tool(), and call mcp.run() — that gives you a working stdio server your agent can call in about fifteen lines. Everything else in this guide is the stuff that stops it from breaking in front of a real client: input schemas, testing without a subprocess, and wiring it into Claude or Cursor. This is the end-to-end build, not a protocol lecture; if you want the concepts first, read what an MCP server is.
One fact shapes every decision below: about 90% of the servers we track run locally over stdio. You are almost certainly building one of those, so default to stdio and don't touch HTTP until you actually need remote access.
Set up: FastMCP, not the raw protocol
Use FastMCP. The alternative — hand-writing JSON-RPC handlers against the low-level SDK — buys you nothing for a normal tool server and costs you a day of framing code.
FastMCP ships two ways, and the difference matters:
pip install "mcp[cli]"— the official Python SDK, which vendors FastMCP v1 asmcp.server.fastmcp.FastMCP. Zero extra dependency, fine for a simple server.pip install fastmcp— the standalone v2 package. Same decorators, plus auth, server composition, and an in-memory test client.
Start with v2 (fastmcp) unless you have a reason not to — the test client alone earns its keep. Both need Python 3.10+.
Write the server: a tool that does one real thing
An MCP server is a program that exposes tools; a tool is a decorated function whose type hints become the input schema. Here's a complete server that reads a file's word count — a real capability, not a hello world:
from pathlib import Path
from fastmcp import FastMCP
mcp = FastMCP("wordcount")
@mcp.tool()
def count_words(path: str) -> dict:
"""Count words and lines in a UTF-8 text file."""
text = Path(path).read_text(encoding="utf-8")
return {"words": len(text.split()), "lines": text.count(chr(10)) + 1}
if __name__ == "__main__":
mcp.run() # stdio by default
Three things are doing the work here. The docstring becomes the tool description the model reads to decide whether to call it — write it for the model, not for yourself. The type hints (path: str, -> dict) generate the JSON schema automatically, so the client knows what to send. And mcp.run() with no arguments speaks stdio, which is what you want.
Return structured data (dict, list, dataclasses) when you can — the client renders it more reliably than a stringified blob. Raise a normal exception on bad input; FastMCP turns it into a proper MCP error the agent can see.
Test it before you wire it into a client
Test the server in-process first — spawning it as a subprocess to debug is slow and hides the error messages. FastMCP v2 gives you a client that talks to your server object directly:
import asyncio
from fastmcp import Client
from server import mcp
async def main():
async with Client(mcp) as client:
tools = await client.list_tools()
print([t.name for t in tools])
result = await client.call_tool("count_words", {"path": "README.md"})
print(result.data)
asyncio.run(main())
If list_tools() shows your tool and call_tool returns clean data, the server is correct — every failure after this point is a config or client issue, which narrows debugging enormously. For a quick manual poke instead, fastmcp dev server.py opens the MCP Inspector in a browser.
Wire it into Claude, Cursor, or Claude Code
A client launches your server as a subprocess and pipes JSON-RPC over stdin/stdout — so the config is just the command to start it. This is the same shape for every stdio server; the full walkthrough with per-client paths is in how to add an MCP server.
The minimal entry, using uv to guarantee the right environment:
{
"mcpServers": {
"wordcount": {
"command": "uv",
"args": ["run", "--with", "fastmcp", "python", "/abs/path/server.py"]
}
}
}
Use an absolute path — the client does not run from your project directory, and a relative path is the single most common reason a server shows up dead. Restart the client fully after editing config; most read it once at launch.
Design tools like they're scarce
Ship fewer, sharper tools than you think you need — the constraint is the client, not your server. Cursor and similar clients degrade past roughly a 40-tool budget across all connected servers combined, and a user who installs yours alongside four others is near that ceiling before your server even loads.
So resist the urge FastMCP makes easy: exposing fifteen tools because decorators are cheap. Fold read variants into one parameterized tool (get(kind="issue"|"pr") beats two tools), and drop anything the model can derive itself. Capabilities shows how real servers spend their budget, and the arithmetic is in Cursor's tool limit.
Look at the shape of well-scoped servers before you finalize yours. The official Filesystem reference server exposes a tight set of read/write tools scoped to allowed directories — a good model for path-based tools like the one above. Context7 does essentially one job (inject version-specific library docs) and does it with a handful of tools. Compare that to the GitHub MCP Server, which is official, broad, and offered both as local Docker and a hosted remote — a reasonable footprint only because GitHub genuinely spans repos, issues, PRs, and Actions.
What to skip
Skip HTTP transport, auth, and multi-server composition on your first build. They're real features — v2 has all three — but every one of them is a remote-server concern, and you're building a local stdio server. Add HTTP the day someone else needs to reach your server over a network, not before.
Also skip prompts and resources until a tool isn't enough. Tools are actions the model calls; resources and prompts are separate primitives most servers never need. Start with one tool that works, test it in-process, wire it in, then grow. When you're ready to publish, the best MCP servers is a fair bar for what "done" looks like, and troubleshooting covers the failures you'll hit first.