Build an MCP Server in Python
Create your first MCP server with the official Python SDK (MCPServer, formerly FastMCP), add tools with type hints, and test it with the MCP Inspector.
What we're building
In this lesson you'll build a small MCP server in Python for a student to-do list. It exposes three tools — add a task, list tasks, complete a task — that any MCP-compatible AI app can use.
We'll use the official MCP Python SDK and its high-level MCPServer API (called FastMCP in SDK versions before 2.0), which turns ordinary Python functions into MCP tools using their type hints and docstrings.
Setup
You need Python 3.10 or newer.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "mcp[cli]"
The [cli] extra adds the mcp command-line tool, which includes the developer Inspector.
Step 1 — create the server
Create todo_server.py:
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("student-todo")
tasks: list[dict] = [] # in-memory storage for this lesson
@mcp.tool()
def add_task(title: str, subject: str = "general") -> str:
"""Add a new task to the student's to-do list.
Args:
title: What needs to be done, e.g. "Finish the SQL assignment".
subject: Course or category, e.g. "databases".
"""
task = {"id": len(tasks) + 1, "title": title, "subject": subject, "done": False}
tasks.append(task)
return f"Added task #{task['id']}: {title} ({subject})"
@mcp.tool()
def list_tasks(include_done: bool = False) -> list[dict]:
"""List tasks on the to-do list. Completed tasks are hidden unless include_done is true."""
return [t for t in tasks if include_done or not t["done"]]
@mcp.tool()
def complete_task(task_id: int) -> str:
"""Mark a task as done by its numeric id."""
for task in tasks:
if task["id"] == task_id:
task["done"] = True
return f"Completed task #{task_id}: {task['title']}"
raise ToolError(f"No task with id {task_id}. Call list_tasks to see valid ids.")
if __name__ == "__main__":
mcp.run() # stdio transport by default
What MCPServer does for you:
- The function name becomes the tool name.
- The type hints (
str,int,bool) become the tool's JSON Schema. - The docstring becomes the description the model reads — so write it well.
- Raising
ToolErrorsends your message back as a tool error (is_error = true), so the model can read it and recover — for example by callinglist_tasksto find a valid id.
ToolErrorvs any other exception. UseToolErrorfor failures you expect (bad id, missing record). Any other exception is treated as a crash: the model only sees a generic "Error executing tool complete_task" and your server logs a traceback. Expected problems deserve a helpful message; real bugs should stay out of the model's context.
Step 2 — test it with the MCP Inspector
mcp dev todo_server.py
This starts your server and opens the MCP Inspector in your browser. From there you can:
- Click Connect.
- Open the Tools tab and see your three tools and their schemas.
- Call
add_taskwith a title, thenlist_tasks, thencomplete_task.
Always test tools in the Inspector before connecting an AI app — it's much easier to debug a server on its own.
Step 3 — connect it to an AI app
Most MCP hosts (desktop AI assistants, IDEs, coding agents) are configured with a JSON file listing servers. The entry usually looks like this:
{
"mcpServers": {
"student-todo": {
"command": "python",
"args": ["/absolute/path/to/todo_server.py"]
}
}
}
The host launches the server as a subprocess over stdio. Use the absolute path and, if you use a virtual environment, point command at that environment's Python. Restart the host, and you can ask the assistant: "Add a task to revise recursion for my DSA exam, then show my list."
Running as a remote server
To share a server with many users, run it over Streamable HTTP instead of stdio:
if __name__ == "__main__":
mcp.run(transport="streamable-http") # serves on http://127.0.0.1:8000/mcp by default
A remote server is reachable over the network, so it must have authentication and input validation before you deploy it — see the security lesson.
Logging without breaking stdio
With the stdio transport, standard output is the protocol channel. A stray print() corrupts the JSON-RPC stream. Log to standard error instead:
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logging.info("student-todo server started")
Improving the server
- Persist tasks in SQLite instead of a Python list so they survive restarts.
- Add a due date parameter and a
tasks_due_this_weektool. - Validate inputs — reject empty titles and overly long text.
Common mistakes
- Using
print()in a stdio server. It breaks the protocol; log to stderr. - Relative paths in the host config. The host may start the server from a different working directory.
- Weak docstrings. The model chooses tools based on descriptions — "does stuff with tasks" won't cut it.
- Returning huge payloads. Paginate or summarise; every byte ends up in the model's context.
Key takeaways
MCPServer(formerlyFastMCP) turns typed, documented Python functions into MCP tools.- Test with
mcp devand the Inspector before connecting a host. - Local servers run over stdio; shared servers run over Streamable HTTP with authentication.
- Never write to stdout in a stdio server.