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 the name: str type 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

StepTypeScriptPython
Initialize projectnpm init -y + tsconfig.jsonpython -m venv .venv
Install SDKnpm install @modelcontextprotocol/sdk zodpip install "mcp[cli]"
Config filetsconfig.json + package.jsonNone needed
Build steptscNo build
Runnode dist/index.jspython server.py
Inspectornpx @modelcontextprotocol/inspectormcp 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 the def)
  • 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"]
    }
  • unit is 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:

  1. Verify that the path to the venv's Python is correct
  2. Verify that the server runs without errors: python server.py (it should stay waiting with no error output)
  3. Check Claude Code's logs: claude mcp list to see the status
  4. 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 count
  • reverse_text: takes a text, returns it reversed
  • to_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://time that returns the current date and time
  • A resource system://platform that returns operating-system information
  • A tool run_command that 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 point
  • src/tools/notes.py — tools to create and list notes
  • src/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:

  1. Register it in Claude Code with claude mcp add
  2. Verify with /mcp that it appears
  3. Use at least one tool and one resource from Claude Code
  4. 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 FastMCP in 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.py opens MCP Inspector for quick testing
  • Connect to Claude Code with claude mcp add using 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

  1. MCP Python SDK — Getting Started — Official quickstart
  2. FastMCP Documentation — Helper reference
  3. Python venv Documentation — Virtual environments in Python
  4. MCP Inspector — Visual debugging tool
  5. Claude Code — MCP Configuration — How to configure MCP servers in Claude Code
  6. 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.