Skip to main content
Back to Guides

Building MCP Servers with Python

A comprehensive tutorial for Python developers looking to create MCP servers using the official Python SDK.

Dr. Priya Sharma
Updated January 14, 2025
14 min read

Python's rich ecosystem makes it ideal for building MCP servers that integrate with data science tools, automation scripts, and backend services. This guide walks through creating a production-ready Python MCP server from scratch.

1. Prerequisites

Before we begin, ensure you have:

  • Python 3.10 or higher: The MCP SDK uses modern Python features
  • Basic understanding of async/await: MCP servers are asynchronous
  • Familiarity with type hints: The SDK leverages Python's typing system

Verify Your Python Version

python --version  # Should be 3.10 or higher
# or
python3 --version

2. Project Setup

Start by creating a new project with a virtual environment:

# Create project directory
mkdir my-python-mcp-server
cd my-python-mcp-server

# Create and activate virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install the MCP SDK
pip install mcp

Using uv (Recommended)

For faster dependency management, consider using uv—it's significantly faster than pip:

# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create project with uv
uv init my-python-mcp-server
cd my-python-mcp-server
uv add mcp

Project Structure

Organize your project like this:

my-python-mcp-server/
├── src/
│   └── my_mcp_server/
│       ├── __init__.py
│       ├── server.py
│       └── tools/
│           ├── __init__.py
│           └── calculator.py
├── tests/
│   └── test_server.py
├── pyproject.toml
└── README.md

3. Basic Server Structure

Create src/my_mcp_server/server.py:

import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server

# Create the server instance
app = Server("my-python-server")

# Main entry point
async def main():
    async with stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            app.create_initialization_options()
        )

if __name__ == "__main__":
    asyncio.run(main())

Understanding the Structure

  • Server instance: The Server class is your main entry point. Give it a unique name.
  • stdio_server: Sets up communication over standard input/output—the most common transport.
  • asyncio.run: Starts the async event loop and runs your server.

4. Defining Tools with Decorators

The Python SDK uses decorators to define tools. The docstring becomes the tool description, and type hints generate the input schema automatically.

from mcp.server import Server
from mcp.types import TextContent

app = Server("calculator-server")

@app.tool()
async def add(a: float, b: float) -> str:
    """
    Add two numbers together.
    
    Args:
        a: The first number
        b: The second number
    
    Returns:
        The sum of the two numbers
    """
    result = a + b
    return f"The sum of {a} and {b} is {result}"

@app.tool()
async def multiply(a: float, b: float) -> str:
    """
    Multiply two numbers.
    
    Args:
        a: The first number
        b: The second number
    
    Returns:
        The product of the two numbers
    """
    result = a * b
    return f"The product of {a} and {b} is {result}"

@app.tool()
async def calculate_percentage(value: float, percentage: float) -> str:
    """
    Calculate a percentage of a value.
    
    Args:
        value: The base value
        percentage: The percentage to calculate (e.g., 15 for 15%)
    
    Returns:
        The calculated percentage
    """
    result = value * (percentage / 100)
    return f"{percentage}% of {value} is {result}"

Tool Definition Best Practices

  • Use clear, descriptive function names
  • Write detailed docstrings—the AI uses these to decide when to call your tool
  • Use type hints for all parameters and return values
  • Document each parameter in the Args section
  • Return strings for simple responses, or use TextContent for rich content

Complex Input Types

For more complex inputs, use Pydantic models or dataclasses:

from dataclasses import dataclass
from typing import List, Optional

@dataclass
class SearchQuery:
    query: str
    max_results: int = 10
    include_archived: bool = False

@app.tool()
async def search_documents(params: SearchQuery) -> str:
    """
    Search through documents.
    
    Args:
        params: Search parameters including query and options
    """
    # Implementation here
    return f"Found documents matching '{params.query}'"

5. Adding Resources

Resources allow your server to expose data that the AI can read:

@app.resource("config://settings")
async def get_settings() -> str:
    """Current application settings"""
    return """{
    "debug": true,
    "max_items": 100,
    "api_version": "2.0"
}"""

@app.resource("stats://usage")
async def get_usage_stats() -> str:
    """Current usage statistics"""
    import json
    stats = {
        "total_requests": 1234,
        "active_users": 56,
        "uptime_hours": 720
    }
    return json.dumps(stats, indent=2)

Dynamic Resources with Templates

@app.resource("user://{user_id}/profile")
async def get_user_profile(user_id: str) -> str:
    """Get profile for a specific user"""
    # Fetch user data
    profile = await fetch_user(user_id)
    return json.dumps(profile)

6. Working with Context and State

For servers that need to maintain state or access shared resources:

from dataclasses import dataclass
from mcp.server import RequestContext

@dataclass
class AppContext:
    database_url: str
    cache: dict
    api_client: Any

# Create context
context = AppContext(
    database_url="postgresql://localhost/mydb",
    cache={},
    api_client=None
)

@app.tool()
async def query_database(
    table: str,
    ctx: RequestContext[AppContext]
) -> str:
    """
    Query data from the database.
    
    Args:
        table: Name of the table to query
        ctx: Request context with shared state
    """
    db_url = ctx.state.database_url
    
    # Check cache first
    cache_key = f"table:{table}"
    if cache_key in ctx.state.cache:
        return ctx.state.cache[cache_key]
    
    # Query database
    result = await perform_query(db_url, table)
    ctx.state.cache[cache_key] = result
    
    return result

7. Error Handling

Proper error handling is crucial for a good user experience:

from mcp.types import McpError, ErrorCode

@app.tool()
async def fetch_data(url: str) -> str:
    """
    Fetch data from a URL.
    
    Args:
        url: The URL to fetch data from
    """
    import aiohttp
    
    # Validate input
    if not url.startswith(("http://", "https://")):
        raise McpError(
            ErrorCode.InvalidParams,
            f"Invalid URL: must start with http:// or https://"
        )
    
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(url, timeout=30) as response:
                if response.status != 200:
                    raise McpError(
                        ErrorCode.InternalError,
                        f"HTTP {response.status}: {response.reason}"
                    )
                return await response.text()
                
    except asyncio.TimeoutError:
        raise McpError(
            ErrorCode.InternalError,
            f"Request timed out after 30 seconds"
        )
    except aiohttp.ClientError as e:
        raise McpError(
            ErrorCode.InternalError,
            f"Network error: {str(e)}"
        )

8. Testing Your Server

Use the MCP Inspector to test your server:

npx @modelcontextprotocol/inspector python src/my_mcp_server/server.py

Unit Testing

# tests/test_server.py
import pytest
from my_mcp_server.server import add, multiply

@pytest.mark.asyncio
async def test_add():
    result = await add(2, 3)
    assert "5" in result

@pytest.mark.asyncio
async def test_multiply():
    result = await multiply(4, 5)
    assert "20" in result

@pytest.mark.asyncio
async def test_add_negative():
    result = await add(-1, 1)
    assert "0" in result

Running with Claude Desktop

Add this to your Claude Desktop config:

{
  "mcpServers": {
    "my-python-server": {
      "command": "python",
      "args": ["/path/to/src/my_mcp_server/server.py"]
    }
  }
}

9. Packaging for Distribution

To share your server, create a proper Python package:

pyproject.toml

[project]
name = "my-mcp-server"
version = "1.0.0"
description = "An MCP server for calculations"
requires-python = ">=3.10"
dependencies = [
    "mcp>=1.0.0",
]

[project.scripts]
my-mcp-server = "my_mcp_server.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/my_mcp_server"]

Publishing to PyPI

# Build the package
pip install build
python -m build

# Upload to PyPI
pip install twine
twine upload dist/*

Users can then install and run your server with:

pip install my-mcp-server
my-mcp-server  # Runs the server

Or use uvx for zero-install execution:

uvx my-mcp-server

Next Steps

Needs Review

Last updated

January 9, 2025

377 days ago

This content may be outdated

This content may contain outdated information. Please verify details before use.