Module 5: MCP Server in Python
Setup: MCP Server in Python
Setup: MCP Server in Python
Capsule description
Before implementing tools, resources, or async patterns, you need a configured Python project and a running MCP server. This capsule takes you from zero to a functional server in less than 10 minutes.
Setting up an MCP server in Python is significantly simpler than in TypeScript. There's no tsconfig.json, no build step, no compilation. You create a virtual environment, install the SDK, write a Python file, and run it. That's all.
The centerpiece is FastMCP — a high-level helper included in the SDK that abstracts the server configuration, the transport, and the event loop. With FastMCP, your server.py looks clean and declarative from the first line.
Installing the SDK
Step 1: Create the project directory
mkdir mcp-server-python
cd mcp-server-python
Step 2: Create a virtual environment
Always use a virtual environment for Python projects. It isolates the dependencies and avoids conflicts:
python -m venv .venv
source .venv/bin/activate # macOS/Linux
# .venv\Scripts\activate # Windows
Verify that you're in the venv:
which python
# Should show: /path/to/your/project/.venv/bin/python
Step 3: Install the SDK
pip install "mcp[cli]"
The [cli] flag installs additional command-line tools that let you run and test the server directly.
What gets installed?
mcp — The core SDK
├── pydantic — Data validation and serialization
├── httpx — Async HTTP client
├── uvicorn — ASGI server (for the HTTP transport)
├── anyio — Async compatibility
└── mcp[cli] — CLI tools (mcp dev, mcp run, etc.)
Step 4: Verify the installation
python -c "import mcp; print(mcp.__version__)"
If you see a version number without errors, the SDK is ready.
You can also verify the CLI tools:
mcp version
Project structure
Minimal structure
For a simple MCP server, you need only one file:
mcp-server-python/
├── .venv/
├── server.py # Your MCP server
└── requirements.txt # Dependencies
Recommended structure for real projects
When the server grows, organize it like this:
mcp-server-python/
├── .venv/
├── src/
│ ├── __init__.py
│ ├── server.py # MCP server entry point
│ ├── tools/ # Tools organized by domain
│ │ ├── __init__.py
│ │ ├── search.py
│ │ └── crud.py
│ ├── resources/ # Resources organized by type
│ │ ├── __init__.py
│ │ └── data.py
│ └── models/ # Pydantic models for validation
│ ├── __init__.py
│ └── schemas.py
├── tests/
│ ├── __init__.py
│ └── test_server.py
├── requirements.txt
├── pyproject.toml # Project metadata (optional)
└── README.md
The requirements.txt file
mcp[cli]>=1.0.0
httpx>=0.27.0
pydantic>=2.0.0
Generate the lockfile after installing:
pip freeze > requirements.txt
Your first MCP server: Hello World
The minimal server
Create server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-world")
@mcp.tool()
async def greet(name: str) -> str:
"""Greets a person by their name."""
return f"Hello, {name}! Welcome to the world of MCP with Python."
@mcp.resource("info://server/status")
async def server_status() -> str:
"""Current status of the server."""
return "The server is working correctly."
if __name__ == "__main__":
mcp.run()
Line-by-line breakdown
from mcp.server.fastmcp import FastMCP
Imports FastMCP, the high-level helper. It's the recommended way to create MCP servers in Python.
mcp = FastMCP("hello-world")
Creates a server instance with a name. This name appears when Claude Code discovers the server.
@mcp.tool()
async def greet(name: str) -> str:
"""Greets a person by their name."""
return f"Hello, {name}! Welcome to the world of MCP with Python."
Registers a tool. The @mcp.tool() decorator does everything:
- Tool name:
greet(from the function name) - Description:
"Greets a person by their name."(from the docstring) - Input schema:
{ name: string }(from thename: strtype hint) - Return type: plain text (from
-> str)
@mcp.resource("info://server/status")
async def server_status() -> str:
"""Current status of the server."""
return "The server is working correctly."
Registers a resource at the URI info://server/status. When a client requests that URI, it runs the function and returns the result.
if __name__ == "__main__":
mcp.run()
Starts the server. mcp.run() automatically configures the stdio transport and the asyncio event loop.
Run the server
python server.py
The server starts and waits for connections via stdin/stdout. You won't see output in the terminal because it uses stdio — the communication is via the JSON-RPC protocol, not text on the console.
Test with MCP Inspector
The fastest way to verify that your server works:
mcp dev server.py
This opens MCP Inspector in your browser. From there you can:
- See the registered tools
- See the registered resources
- Invoke tools with parameters
- Read resources by URI
Comparison with the TypeScript setup
| Step | TypeScript | Python |
|---|---|---|
| Initialize project | npm init -y + tsconfig.json | python -m venv .venv |
| Install SDK | npm install @modelcontextprotocol/sdk zod | pip install "mcp[cli]" |
| Config file | tsconfig.json + package.json | None needed |
| Build step | tsc | No build |
| Run | node dist/index.js | python server.py |
| Inspector | npx @modelcontextprotocol/inspector | mcp dev server.py |
Python eliminates the build step entirely. You edit → run. No compilation.
Understanding FastMCP
What does FastMCP do for you?
FastMCP is a high-level wrapper that simplifies the SDK's API. Without FastMCP, you'd have to handle manually:
# Without FastMCP (low level) — NOT recommended to start
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
server = Server("hello-world")
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="greet",
description="Greets a person",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "The person's name"}
},
"required": ["name"],
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "greet":
return [types.TextContent(type="text", text=f"Hello, {arguments['name']}!")]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream, write_stream,
server.create_initialization_options()
)
import asyncio
asyncio.run(main())
Compare with FastMCP:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-world")
@mcp.tool()
async def greet(name: str) -> str:
"""Greets a person."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run()
FastMCP reduces ~30 lines to ~10. It does so by automating:
- Tool registration: Extracts the name, description, and schema from the function
- Resource registration: Associates the URI with the handler
- Transport: Configures stdio automatically
- Event loop: Handles asyncio for you
- Serialization: Converts return values to MCP content
FastMCP configuration options
mcp = FastMCP(
"my-server",
version="1.0.0", # Server version
instructions="Server for...", # Instructions for the model
)
The instructions parameter is particularly useful — it tells the model how to use your server effectively.
Anatomy of the decorators
@mcp.tool() — Register functions as tools
The decorator extracts all the information from the Python function:
@mcp.tool()
async def calculate_area(
width: float,
height: float,
unit: str = "m"
) -> str:
"""Calculates the area of a rectangle.
Takes width and height, returns the area with the specified unit.
"""
area = width * height
return f"Area: {area} {unit}²"
What the decorator infers:
- name:
"calculate_area"(from thedef) - description:
"Calculates the area of a rectangle..."(from the docstring) - inputSchema:
{ "type": "object", "properties": { "width": { "type": "number" }, "height": { "type": "number" }, "unit": { "type": "string", "default": "m" } }, "required": ["width", "height"] } unitis optional because it has a default value
@mcp.resource() — Register data as resources
@mcp.resource("config://app/settings")
async def get_settings() -> str:
"""Current application configuration."""
import json
settings = {
"debug": False,
"version": "2.1.0",
"max_connections": 100,
}
return json.dumps(settings, indent=2)
The URI config://app/settings is the address clients use to request this resource.
@mcp.prompt() — Register templates as prompts
@mcp.prompt()
async def code_review(code: str, language: str = "python") -> str:
"""Template to request a code review."""
return f"""Review the following {language} code and provide:
1. Potential errors
2. Performance improvements
3. Readability improvements
4. Adherence to {language} best practices
Code:
```{language}
{code}
```"""
Customize the tool name
If you don't want the tool's name to be the function's name:
@mcp.tool(name="search_files")
async def find_files(directory: str, pattern: str) -> str:
"""Searches for files that match a pattern."""
...
This registers the tool as search_files even though the function is called find_files.
Connect to Claude Code
Step 1: Register the server
From the terminal (outside Claude Code):
claude mcp add my-python-server python /full/path/to/server.py
Or if you use a virtual environment:
claude mcp add my-python-server /full/path/to/.venv/bin/python /full/path/to/server.py
It's important to use the absolute path to the venv's Python so Claude Code uses the correct dependencies.
Step 2: Verify in Claude Code
Open Claude Code and start a new session:
claude
Type /mcp to see the connected servers. Your server should appear in the list.
Step 3: Test the server
In Claude Code, ask for something that uses your tool:
Greet Maria using the MCP server
Claude Code should invoke your greet tool with name="Maria" and show the result.
Alternative configuration: JSON file
You can also configure the server by directly editing Claude Code's configuration file:
{
"mcpServers": {
"my-python-server": {
"command": "/full/path/to/.venv/bin/python",
"args": ["/full/path/to/server.py"]
}
}
}
Debug: if the server doesn't appear
If /mcp doesn't show your server:
- Verify that the path to the venv's Python is correct
- Verify that the server runs without errors:
python server.py(it should stay waiting with no error output) - Check Claude Code's logs:
claude mcp listto see the status - Use MCP Inspector to test independently:
mcp dev server.py
Exercises
Exercise 1: Server with multiple tools (Easy)
Create an MCP server called "text-utils" with 3 tools:
count_words: takes a text, returns the word countreverse_text: takes a text, returns it reversedto_uppercase: takes a text, returns it in uppercase
See solution
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("text-utils")
@mcp.tool()
async def count_words(text: str) -> str:
"""Counts the number of words in a text."""
word_count = len(text.split())
return f"The text has {word_count} words."
@mcp.tool()
async def reverse_text(text: str) -> str:
"""Reverses a text from right to left."""
return text[::-1]
@mcp.tool()
async def to_uppercase(text: str) -> str:
"""Converts a text to uppercase."""
return text.upper()
if __name__ == "__main__":
mcp.run()
Exercise 2: Server with dynamic resources (Medium)
Create an MCP server "system-info" with:
- A resource
system://timethat returns the current date and time - A resource
system://platformthat returns operating-system information - A tool
run_commandthat runs a shell command and returns the output
See solution
import platform
import subprocess
from datetime import datetime
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("system-info")
@mcp.resource("system://time")
async def current_time() -> str:
"""Current system date and time."""
now = datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
@mcp.resource("system://platform")
async def platform_info() -> str:
"""Operating-system information."""
import json
info = {
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
"python_version": platform.python_version(),
}
return json.dumps(info, indent=2)
@mcp.tool()
async def run_command(command: str) -> str:
"""Runs a shell command and returns the output.
Read-only commands for safety.
"""
blocked = ["rm", "del", "format", "mkfs", "dd"]
cmd_parts = command.split()
if cmd_parts and cmd_parts[0] in blocked:
return f"Error: the command '{cmd_parts[0]}' is blocked for security."
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
return f"Error (code {result.returncode}):\n{result.stderr}"
return result.stdout or "(no output)"
except subprocess.TimeoutExpired:
return "Error: the command exceeded the 10-second timeout."
except Exception as e:
return f"Error running the command: {e}"
if __name__ == "__main__":
mcp.run()
Exercise 3: Project with an organized structure (Medium)
Create an MCP server "notes-manager" using the recommended project structure:
src/server.py— entry pointsrc/tools/notes.py— tools to create and list notessrc/resources/notes.py— resource to view the current notes
The notes are stored in an in-memory list.
See solution
src/tools/notes.py:
from datetime import datetime
notes: list[dict] = []
async def create_note(title: str, content: str) -> str:
"""Creates a new note with title and content."""
note = {
"id": len(notes) + 1,
"title": title,
"content": content,
"created_at": datetime.now().isoformat(),
}
notes.append(note)
return f"Note created: '{title}' (ID: {note['id']})"
async def list_notes() -> str:
"""Lists all the existing notes."""
import json
if not notes:
return "No notes yet."
return json.dumps(notes, indent=2, ensure_ascii=False)
src/resources/notes.py:
import json
from src.tools.notes import notes
async def get_all_notes() -> str:
"""All the stored notes."""
return json.dumps(
{"total": len(notes), "notes": notes}, indent=2, ensure_ascii=False
)
src/server.py:
from mcp.server.fastmcp import FastMCP
from src.tools.notes import create_note, list_notes
from src.resources.notes import get_all_notes
mcp = FastMCP("notes-manager")
mcp.tool()(create_note)
mcp.tool()(list_notes)
mcp.resource("notes://all")(get_all_notes)
if __name__ == "__main__":
mcp.run()
Note: when you register functions defined in another module, you can use the decorator as a function (mcp.tool()(func)) instead of the @mcp.tool() syntax.
Exercise 4: Server with prompts (Medium)
Add to the previous exercise's server a prompt that generates a daily summary template based on the existing notes.
See solution
from mcp.server.fastmcp import FastMCP
from datetime import datetime
mcp = FastMCP("notes-with-prompts")
notes: list[dict] = []
@mcp.tool()
async def create_note(title: str, content: str, category: str = "general") -> str:
"""Creates a new note with title, content, and category."""
note = {
"id": len(notes) + 1,
"title": title,
"content": content,
"category": category,
"created_at": datetime.now().isoformat(),
}
notes.append(note)
return f"Note created: '{title}' in category '{category}' (ID: {note['id']})"
@mcp.resource("notes://all")
async def get_notes() -> str:
"""All the stored notes."""
import json
return json.dumps(notes, indent=2, ensure_ascii=False)
@mcp.prompt()
async def daily_summary(date: str = "") -> str:
"""Generates a daily summary template based on the notes."""
target_date = date or datetime.now().strftime("%Y-%m-%d")
import json
notes_text = json.dumps(notes, indent=2, ensure_ascii=False) if notes else "No notes."
return f"""Generate a daily summary for the date {target_date}.
Available notes:
{notes_text}
The summary should include:
1. Executive summary (2-3 sentences)
2. Key points by category
3. Suggested next steps
Format: Markdown with headers and bullet points."""
if __name__ == "__main__":
mcp.run()
Exercise 5: Connect to Claude Code (Practical)
Take any of the previous servers and:
- Register it in Claude Code with
claude mcp add - Verify with
/mcpthat it appears - Use at least one tool and one resource from Claude Code
- Document what steps you followed and what output you got
See solution
cd /path/to/mcp-server-python
source .venv/bin/activate
mcp dev server.py
claude mcp add notes-server \
/full/path/.venv/bin/python \
/full/path/server.py
claude
Inside Claude Code:
> /mcp
# Verify that "notes-server" appears with the status "connected"
> Create a note titled "Team meeting" with content
"Discuss Q2 roadmap and task assignment"
# Claude Code invokes create_note and shows the result
> Show me all the current notes
# Claude Code reads the notes://all resource or invokes list_notes
Successful verification shows that Claude Code discovers and uses the tools/resources of your Python server.
Troubleshooting
"ModuleNotFoundError: No module named 'mcp'"
Cause: The SDK isn't installed in the Python you're using, or you didn't activate the virtual environment.
Solution:
source .venv/bin/activate
pip install "mcp[cli]"
python -c "import mcp; print('OK')"
If you use Claude Code, make sure the command points to the venv's Python:
claude mcp add my-server /full/path/.venv/bin/python /full/path/server.py
"SyntaxError: invalid syntax" in type hints
Cause: You're using Python 3.9 or lower, which doesn't support list[str] or str | None directly.
Solution:
python --version
# If it's lower than 3.10, update Python
# Alternative: use from __future__ import annotations
from __future__ import annotations
"The server runs but Claude Code doesn't find it"
Cause: The path to the executable or the file is incorrect.
Solution:
# Verify that the server runs correctly
python server.py
# If you see errors, fix the server first
# Use absolute paths
claude mcp add my-server \
$(which python) \
$(pwd)/server.py
# Verify the configuration
claude mcp list
"RuntimeError: asyncio event loop is already running"
Cause: You're running the server inside a context that already has an event loop (like a Jupyter notebook).
Solution:
# Instead of mcp.run(), use:
import asyncio
if __name__ == "__main__":
asyncio.run(mcp.run_async())
"The tool appears but doesn't work correctly"
Cause: The docstring or the type hints are poorly defined.
Solution:
@mcp.tool()
async def my_tool(param: str) -> str:
"""Clear description of what the tool does.
This description is what the model sees to decide
when to use the tool. Make it descriptive.
"""
return f"Result: {param}"
Summary
In this capsule you learned:
- Install the SDK with
pip install "mcp[cli]"in a virtual environment - Create an MCP server with
FastMCPin a few lines - FastMCP automates the registration of tools, resources, and prompts via decorators
- The decorators (
@mcp.tool(),@mcp.resource(),@mcp.prompt()) are the main interface - The name, description, and schema are inferred from the Python function
mcp dev server.pyopens MCP Inspector for quick testing- Connect to Claude Code with
claude mcp addusing absolute paths - The Python setup is simpler than TypeScript: no build step, no additional config files
Next capsule: Tools and Resources in Python — advanced implementations with Pydantic, validation of complex inputs, and the idiomatic differences from TypeScript.
Additional resources
- MCP Python SDK — Getting Started — Official quickstart
- FastMCP Documentation — Helper reference
- Python venv Documentation — Virtual environments in Python
- MCP Inspector — Visual debugging tool
- Claude Code — MCP Configuration — How to configure MCP servers in Claude Code
- Pydantic v2 Documentation — Pydantic reference (installed with the SDK)
Next capsule: Implement tools and resources with Python decorators, validation with Pydantic, and how the Python approach differs from TypeScript.