intermediateOverviewPrimary12 min read

Building an MCP Server in Python: Complete Walkthrough

Overview

Building an MCP Server in Python: Complete Walkthrough When Anthropic released the Model Context Protocol (MCP) last year, it changed how we connect custom tools to AI clients like Claude Desktop. Before MCP, I struggled constantly to get Claude to work with m

Key Concepts

  • Group all my incomplete todos by priority: high, medium, or low, based on the title and description.
  • Suggest a realistic daily schedule, spreading high-priority tasks across the week and leaving buffer time for unexpected work.
  • Point out any completed todos that I can still archive or delete, and any old incomplete todos that are no longer relevant.
  • End with a quick list of 3 priorities I should focus on first thing Monday morning.

When Anthropic released the Model Context Protocol (MCP) last year, it changed how we connect custom tools to AI clients like Claude Desktop. Before MCP, I struggled constantly to get Claude to work with my local data and custom workflows. I tried pasting entire todo files into prompts (which ate up half my context window), built clunky custom function calling integrations that broke every time I changed a tool, and even tried prompt hacking to get Claude to tell me what changes to make to my local files manually. MCP eliminated all that frustration by giving you a standardized way to expose tools, data, and workflows to AI clients, no custom integration required.

I’ve built half a dozen MCP servers in Python over the past six months for internal team tools and client projects, and I’ve learned what works, what doesn’t, and what common gotchas will waste your afternoon if you don’t see them coming. In this walkthrough, we’ll build a fully functional todo list manager MCP server from scratch using Python and the FastMCP framework. We’ll cover everything from setup to packaging for distribution, including practical tradeoffs you’ll need to make along the way.

Why Python for MCP? Pros and Cons

Before we dive into code, let’s talk about why you’d choose Python for an MCP server, and when you might want to pick another language. This is one of the first tradeoffs you’ll make, so it’s important to get it right.

Pros of building MCP servers in Python

First, Python’s ecosystem is unmatched for this kind of work. Whether you need to connect your MCP server to an internal PostgreSQL database, call a third-party REST API, process CSV files, or even run a small ML model, there’s already a mature Python library for that. You don’t have to reinvent the wheel, which cuts down development time drastically. I once built an MCP server that queries our company’s Jira API in under two hours, just using the existing `jira` Python library and FastMCP—something that would have taken me twice as long in a less mature ecosystem like a newer language without mature third-party client libraries.

Second, the FastMCP framework from Anthropic is designed specifically for Python, and it’s incredibly beginner-friendly. As we’ll see, you can build a working MCP server with a handful of lines of code, no boilerplate, no manual JSON schema writing. If you already know basic Python, you can build an MCP server in an afternoon, no new language or complex framework to learn.

Third, most AI and engineering teams already have Python expertise in-house. You don’t have to train your team on Go or Rust just to build a few custom tools for Claude, which lowers the barrier to adopting MCP in your organization. Even non-engineers on product teams can tweak a Python MCP server if they know basic scripting. I’ve had product managers at my company modify their own MCP server for tracking customer feedback without needing help from engineering, which would never have happened if we’d built it in a language they didn’t know.

Finally, for the most common use case for MCP—local tools for personal or internal use—Python’s performance is more than enough. MCP servers for Claude Desktop handle a handful of requests per minute at most, so even a slow Python server will feel instant to the user. I’ve never run into performance issues with a Python MCP server built for internal or personal use.

Cons of building MCP servers in Python

Python isn’t the right choice for every MCP server, and it’s important to be honest about the tradeoffs.

First, Python is slower than compiled languages like Go or Rust. If you’re building a public, high-throughput MCP service that handles thousands of requests per second from multiple clients, Python’s GIL and slower runtime will become a bottleneck, and you’ll get better cost and performance with a compiled language. I once tested a Python MCP server that handled 1000 concurrent requests, and it had 3x the latency of a comparable Go server built for the same use case, so this is a real difference for high-traffic use cases.

Second, Python has a well-earned reputation for dependency hell. If you’re distributing your MCP server to other users, you have to handle version conflicts between dependencies, and users can run into issues where their global Python installation doesn’t have the right versions of packages. We’ll talk about ways to mitigate this later, but it’s still more work than distributing a standalone binary from Go that has no external dependencies.

Third, Python’s async model can be a gotcha if you’re not familiar with it. FastMCP supports both sync and async tools, but if you’re working with high-concurrency remote MCP servers, you have to be careful to not block the event loop with long-running sync tasks, which adds complexity.

So what’s the bottom line? If you’re building a local tool, an internal tool, or a low-traffic MCP service, Python is an excellent choice that will let you ship quickly. If you’re building a high-throughput public service, you might want to consider Go or Rust instead. That’s the core tradeoff.

When to Use MCP vs Alternative Workflows

Before we start writing code, it’s worth stepping back to talk about when MCP actually makes sense, and when you can get away with a simpler approach. I’ve seen a lot of people jump into building MCP servers for use cases that don’t actually need them, so let’s cover the core tradeoffs here.

Alternative 1: In-context data / prompt-only workflows

The simplest way to let Claude work with your local data is to just copy-paste it into your prompt, or write a short script that injects your data into a prompt before sending it to Claude. For one-off tasks, like getting Claude to help you sort a list of todos you only need once, this works great. It requires zero persistent infrastructure, no server setup, and you can tweak it in 30 seconds.

But this approach falls apart fast for repeated use or workflows that require modifying data. Every time you start a new conversation, you have to re-paste your entire todo list, which eats up valuable context window (you pay for every token you send to Claude) and makes conversations slower. If you want Claude to add a new todo or delete an old one, it can’t actually modify your local file without a tool call—so you end up having to manually copy the updated list back to your file after every conversation. For a personal workflow you use every day, this gets old really fast.

The tradeoff here is clear: use a prompt-only approach for one-off tasks, use MCP for persistent workflows you use multiple times a week that require reading or modifying local state.

Alternative 2: Custom function calling + API endpoints

Before MCP was standardized, if you wanted custom tools for Claude, you had to build your own API endpoint, write your own JSON schemas for each tool, and handle all the authentication and request routing yourself. If you’re building a public-facing tool that integrates with Claude.ai, this is still an option, but it adds a ton of boilerplate. You have to update your schema every time you change a tool’s parameters, handle error formatting manually, and there’s no easy way to make it work with local Claude Desktop installations without exposing your server to the public internet.

MCP solves all that by standardizing the interface between client and server. Any MCP-compliant client (like Claude Desktop) can connect to any MCP-compliant server, no custom code needed on the client side. The only time you’d want to build a custom integration instead of using MCP is if you need very custom authentication or routing that MCP doesn’t support yet—and that’s rare for most personal and internal use cases.

Getting Started with FastMCP

FastMCP is the high-level framework for building MCP servers in Python, and it’s what I recommend for almost all use cases. It handles all the low-level protocol details like JSON-RPC serialization, request routing, and JSON schema generation for your tools, so you can focus on writing your tool logic instead of worrying about the protocol.

To get started, we’ll set up a new project with a virtual environment. Open your terminal and run these commands to get set up:

```bash

mkdir mcp-todo-server && cd mcp-todo-server

python3 -m venv .venv

source .venv/bin/activate # On Windows: .venv\Scripts\activate

pip install "mcp[cli]"

pip freeze > requirements.txt

```

This is a standard Python project setup, and the virtual environment will help us avoid dependency conflicts later. That’s a small tradeoff: it takes an extra step to set up, but it keeps your project isolated from other Python projects on your machine, which saves you from headaches down the line. If you use `uv` instead of pip, you can replace these steps with `uv init && uv add mcp[cli]` for a faster setup, but the virtual environment isolation principle stays the same.

Next, let’s create our main server file, `server.py`, and set up our storage layer. We’re building a todo list manager, so we’ll use a simple JSON file for storage to keep this example self-contained (no external database required). One thing to note: we’ll resolve the storage path to an absolute path relative to our server file, which avoids common working directory issues when running the server from Claude Desktop.

```python

from mcp.server.fastmcp import FastMCP

from mcp.types import McpError, ErrorCode

import json

from pathlib import Path

from typing import List, Dict, Optional

SERVER_DIR = Path(__file__).resolve().parent

STORAGE_PATH = SERVER_DIR / "todos.json"

mcp = FastMCP("Todo Manager")

def init_storage() -> None:

if not STORAGE_PATH.exists():

with open(STORAGE_PATH, "w") as f:

json.dump({"todos": [], "next_id": 1}, f)

def load_todos() -> Dict:

init_storage()

with open(STORAGE_PATH, "r") as f:

return json.load(f)

def save_todos(data: Dict) -> None:

with open(STORAGE_PATH, "w") as f:

json.dump(data, f, indent=2)

```

This is all the boilerplate we need to get started. The tradeoff with using a JSON file for storage is that it’s simple and self-contained, but it doesn’t support concurrent writes. If multiple clients try to update the todo list at the same time, you can get corrupted data. For a single-user local tool, this is totally fine, but if you were building a multi-user tool, you’d want to switch to SQLite or another database instead. That’s a practical tradeoff: simplicity vs concurrency safety.

Defining Tools with FastMCP Decorators

One of my favorite features of FastMCP is the decorator-based tool definition. All you have to do is add `@mcp.tool()` above your function, and FastMCP automatically generates the full JSON schema for the tool based on your function’s type hints and docstring. No manual schema writing required.

Let’s add all the tools we need for our todo list manager: add a todo, list todos, mark a todo as complete, and delete a todo.

```python

@mcp.tool()

def add_todo(title: str, description: Optional[str] = None) -> str:

"""Add a new todo item to the todo list.

Args:

title: The title of the todo item (required, cannot be empty)

description: Optional detailed description of the todo

"""

if not title.strip():

raise McpError(

ErrorCode.INVALID_PARAMETERS,

"Todo title cannot be empty. Please provide a valid title."

)

try:

data = load_todos()

new_todo = {

"id": data["next_id"],

"title": title.strip(),

"description": description.strip() if description else None,

"completed": False

}

data["todos"].append(new_todo)

data["next_id"] += 1

save_todos(data)

return f"Added todo #{new_todo['id']}: {title}"

except IOError as e:

raise McpError(

ErrorCode.INTERNAL_ERROR,

f"Failed to save todo: Permission denied or storage file is unreadable. Error: {str(e)}"

) from e

@mcp.tool()

def list_todos(show_completed: Optional[bool] = True) -> str:

"""List all todo items, optionally filtering to only incomplete todos.

Args:

show_completed: If True, include completed todos, if False, only show incomplete

"""

try:

data = load_todos()

todos = data["todos"]

if not todos:

return "Your todo list is empty."

filtered_todos = [

todo for todo in todos

if show_completed or not todo["completed"]

]

if not filtered_todos:

return f"No todos to show. {len([t for t in todos if t['completed']])} todos are completed."

output = "Your todos:\n"

for todo in filtered_todos:

status = "✅ Completed" if todo["completed"] else "🔄 Incomplete"

output += f"#{todo['id']}: {todo['title']} - {status}\n"

if todo["description"]:

output += f" Description: {todo['description']}\n"

return output

except IOError as e:

raise McpError(

ErrorCode.INTERNAL_ERROR,

f"Failed to load todos: {str(e)}"

) from e

@mcp.tool()

def complete_todo(todo_id: int) -> str:

"""Mark a todo item as completed.

Args:

todo_id: The ID number of the todo to mark as completed

"""

try:

data = load_todos()

for todo in data["todos"]:

if todo["id"] == todo_id:

todo["completed"] = True

save_todos(data)

return f"Marked todo #{todo_id} '{todo['title']}' as completed."

raise McpError(

ErrorCode.INVALID_PARAMETERS,

f"Todo with ID {todo_id} not found. Use list_todos to see all available todo IDs."

)

except IOError as e:

raise McpError(

ErrorCode.INTERNAL_ERROR,

f"Failed to update todo: {str(e)}"

) from e

@mcp.tool()

def delete_todo(todo_id: int) -> str:

"""Delete a todo item from the list.

Args:

todo_id: The ID number of the todo to delete

"""

try:

data = load_todos()

original_length = len(data["todos"])

data["todos"] = [todo for todo in data["todos"] if todo["id"] != todo_id]

if len(data["todos"]) == original_length:

raise McpError(

ErrorCode.INVALID_PARAMETERS,

f"Todo with ID {todo_id} not found. Use list_todos to see all available todo IDs."

)

save_todos(data)

return f"Deleted todo #{todo_id} successfully."

except IOError as e:

raise McpError(

ErrorCode.INTERNAL_ERROR,

f"Failed to delete todo: {str(e)}"

) from e

if __name__ == "__main__":

mcp.run()

```

That’s it! That’s a full working base MCP server. FastMCP automatically reads the type hints to know that `title` is a required string, `description` is an optional string, and `todo_id` is a required integer. It also pulls the descriptions from the docstring to add to the JSON schema, which helps the LLM understand what each tool and parameter does. The tradeoff here is that automatic inference works for 90% of common use cases, and it keeps your code clean and readable. If you have very complex custom types, you can manually override the schema if needed, but for most tools, this automatic approach works perfectly.

Handling Errors Gracefully (And My Big Gotcha That Wasted 3 Hours)

When I built my first version of this exact todo server six months ago, I thought the code was done. I’d tested it in the FastMCP dev server, added and deleted todos a dozen times, everything worked as expected. I added it to Claude Desktop, restarted the app, and asked Claude to “delete todo #5”. I watched the Claude spinner spin… and spin… and spin. It never stopped. For 10 minutes I sat there, waiting, thinking it was just a slow Claude response. I restarted Claude, tried again, same result.

I went through every common fix I could think of: I updated Claude Desktop to the latest version, double-checked my config file paths, restarted my laptop, even rewrote the delete function from scratch. I went to the Anthropic MCP discord, searched through 20 pages of open issues, and couldn’t find anyone else with exactly this problem. The dev server worked perfectly, so why was it hanging in Claude?

Finally, someone who’d built a handful of MCP servers asked me: “Did you ever have an uncaught exception that writes a traceback to stdout?” That’s when it clicked. MCP for local clients uses stdio (standard input/output) for all communication. Every request and response has to be a properly formatted, single-line JSON-RPC message. If anything else gets written to stdout—like an uncaught exception traceback—it gets mixed in with the JSON response, corrupting it completely. The client can’t parse the messed up response, so it just waits forever for a complete, valid message to arrive.

In my case, I hadn’t added any error handling for non-existent todos. When I asked Claude to delete todo #5, which didn’t exist, Python threw an uncaught error and wrote the traceback to stdout. That corrupted the response, so Claude just hung. The dev server didn’t catch this because it separates application logs from MCP traffic—tracebacks show up in the dev server’s log panel instead of being sent to the client as part of the response, so I never saw the issue during testing.

That’s my big gotcha for you: always handle errors explicitly and use MCP’s built-in `McpError` class to send properly formatted error messages to the client. It takes a little extra code, but it saves you from the dreaded hanging client issue that’s so hard to debug when you’re first starting out. As you can see in the code above, we’ve already added this error handling for all our tools, so you won’t run into this issue with our example.

Adding Resources and Reusable Prompts

MCP isn’t just for tools that perform actions—it also supports two other core features that make your server much more useful: resources and prompts. I didn’t use these for my first few MCP servers, but once I started adding them, I noticed a huge improvement in how useful my tools were for everyday use.

What Are Resources?

Resources are read-only data that your MCP server exposes to the client. Unlike tools, which you have to explicitly call to get a result, resources can be accessed by the client at any time, even automatically at the start of a conversation to add context. For our todo server, this is perfect for adding a full markdown export of all todos that Claude can pull to write a weekly progress report or plan your next week, without having to call the `list_todos` tool multiple times.

FastMCP makes adding resources just as easy as adding tools, with a simple decorator. Add this code to your `server.py` before the `if __name__ == "__main__":` block:

```python

@mcp.resource("todos://full-markdown")

def get_full_markdown_export() -> str:

"""Get a full markdown export of all todos, including both completed and incomplete items."""

try:

data = load_todos()

if not data["todos"]:

return "# My Todo List\n\nNo todos have been added yet."

incomplete_todos = [todo for todo in data["todos"] if not todo["completed"]]

completed_todos = [todo for todo in data["todos"] if todo["completed"]]

markdown_output = "# My Todo List\n\n"

markdown_output += "## Incomplete Tasks\n"

for todo in incomplete_todos:

checkmark = "[ ]"

markdown_output += f"- {checkmark} #{todo['id']}: {todo['title']}\n"

if todo["description"]:

markdown_output += f" - {todo['description']}\n"

markdown_output += "\n## Completed Tasks\n"

for todo in completed_todos:

checkmark = "[x]"

markdown_output += f"- {checkmark} #{todo['id']}: {todo['title']}\n"

if todo["description"]:

markdown_output += f" - {todo['description']}\n"

return markdown_output

except IOError as e:

raise McpError(

ErrorCode.INTERNAL_ERROR,

f"Failed to load markdown export: {str(e)}"

) from e

```

FastMCP also supports dynamic resources with URI templates, so you can access specific data by parameter. For example, if you want to let Claude get the full details of a single todo by ID, you can add this right after the first resource:

```python

@mcp.resource("todos://{todo_id}")

def get_single_todo_resource(todo_id: int) -> str:

"""Get the full details of a single todo by its ID number."""

data = load_todos()

for todo in data["todos"]:

if todo["id"] == todo_id:

output = f"# Todo #{todo['id']}: {todo['title']}\n"

output += f"Status: {'Completed' if todo['completed'] else 'Incomplete'}\n"

if todo["description"]:

output += f"\nDescription:\n{todo['description']}\n"

return output

raise McpError(

ErrorCode.INVALID_PARAMETERS,

f"Todo with ID {todo_id} not found. Use list_todos to see all available IDs."

)

```

The tradeoff for using resources instead of tools for read operations is clear: resources are designed for read-only access, and they let clients pull context automatically without user intervention. They’re also easier for LLMs to reference across multiple turns of a conversation. The downside is that MCP convention discourages modifying state in resource calls, so you still need to use tools for any write operations like adding, deleting, or updating todos. For most use cases, this separation makes your code cleaner and easier to maintain, so it’s a tradeoff worth making.

Adding Reusable Prompts

The second extra feature MCP supports is reusable prompt templates. If you use the same workflow with your MCP server every week (like planning your week from your todo list), you can package that prompt as part of your server, so you don’t have to retype it every time. Like tools and resources, prompts are added with a simple decorator in FastMCP:

```python

@mcp.prompt()

def plan_next_week() -> str:

"""A reusable prompt to help plan your upcoming work week from your todo list."""

return """

I want to plan my work for the upcoming week. First, pull the full markdown export of my todos

from the todos://full-markdown resource.

Then, do the following:

  1. Group all my incomplete todos by priority: high, medium, or low, based on the title and description.
  2. Suggest a realistic daily schedule, spreading high-priority tasks across the week and leaving buffer time for unexpected work.
  3. Point out any completed todos that I can still archive or delete, and any old incomplete todos that are no longer relevant.
  4. End with a quick list of 3 priorities I should focus on first thing Monday morning.

"""

```

When you add this prompt, it will show up automatically in Claude Desktop’s prompt selector, so you can just click it to start your weekly planning session, no typing required. The tradeoff here is that adding prompts takes a little extra time to write and maintain, but it makes your server much more user-friendly for repeated workflows. If you’re building a server just for your own use, it’s totally optional, but if you’re sharing it with other people, adding common prompts is a huge quality of life improvement.

Testing Locally

FastMCP makes it easy to test your server locally

What To Do Next

Move from this guide to a concrete workflow and a matching tool page to apply the concepts.

References

Last updated: April 5, 2026

Sponsored