# How to build an MCP server in Python

- Tool: MCP
- Difficulty: Intermediate
- Time required: 25-35 min
- Compatibility: MCP Python SDK 1.x, Python 3.10+
- Last updated: March 2026

## TL;DR

Build a complete MCP server in Python using the FastMCP framework. Define tools with the @mcp.tool() decorator, resources with @mcp.resource(), and prompts with @mcp.prompt(). Use type hints and docstrings for parameter descriptions and validation. Run the server with mcp.run() on stdio or Streamable HTTP transport.

## Building a Complete MCP Server in Python

The MCP Python SDK provides FastMCP, a high-level framework that makes building MCP servers feel natural in Python. Instead of calling registerTool() with schemas, you decorate async functions with @mcp.tool(), use type hints for parameter types, and write docstrings for descriptions. FastMCP handles JSON Schema generation, validation, and protocol communication automatically.

This tutorial builds a complete server with tools, resources, and prompts. It covers the Python-specific patterns that differ from the TypeScript SDK, including type annotation conventions, error handling with exceptions, and running the server in different modes.

## Before you start

- Python 3.10+ installed (for union type syntax and modern type hints)
- MCP Python SDK installed: pip install mcp
- Basic familiarity with Python async/await and type hints
- Understanding of MCP concepts (tools, resources, prompts)

## Step-by-step guide

### 1. Install the SDK and create a server instance

Install the MCP Python SDK with pip. Then create a new Python file and instantiate FastMCP with a server name. The FastMCP class handles all protocol details — you just decorate functions to register them as tools, resources, or prompts.

```
# Install
pip install mcp

# server.py
from mcp.server.fastmcp import FastMCP
import sys

# Create server instance
mcp = FastMCP("my-python-server")

# All logging goes to stderr (stdout is reserved for MCP protocol)
print("Server initializing...", file=sys.stderr)
```

**Expected result:** A FastMCP server instance ready to accept tool, resource, and prompt registrations.

### 2. Define tools with decorators and type hints

Use the @mcp.tool() decorator to register a function as an MCP tool. The function name becomes the tool name (with underscores converted to hyphens). Type hints define parameter types, and the docstring provides the tool description and parameter descriptions in Google docstring format. Return a string for simple text results.

```
# Python
import json
import httpx

@mcp.tool()
async def get_weather(city: str, units: str = "celsius") -> str:
    """Get current weather for a city.

    Args:
        city: City name, e.g. San Francisco
        units: Temperature units (celsius or fahrenheit)
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(
            "https://api.weather.example/v1/current",
            params={"city": city, "units": units},
        )
        response.raise_for_status()
        data = response.json()
        return f"{city}: {data['temperature']}° {units}, {data['condition']}"

@mcp.tool()
async def calculate(expression: str) -> str:
    """Evaluate a mathematical expression safely.

    Args:
        expression: Math expression like '2 + 3 * 4'
    """
    # Safe eval for basic math
    allowed = set("0123456789+-*/.() ")
    if not all(c in allowed for c in expression):
        raise ValueError("Error: Only basic math operators allowed")
    result = eval(expression)  # safe after character validation
    return str(result)
```

**Expected result:** Two tools registered: get_weather and calculate, each with validated parameters from type hints.

### 3. Define resources with URI patterns

Use @mcp.resource() to register data sources. Pass the URI as the decorator argument. For static resources, use a fixed URI. For templates, use curly-brace parameters that match function arguments. Return a string for text content or bytes for binary data.

```
# Python
import os

@mcp.resource("config://app")
async def get_config() -> str:
    """Application configuration."""
    config = {
        "app_name": "My Python App",
        "version": "1.0.0",
        "debug": os.getenv("DEBUG", "false") == "true",
    }
    return json.dumps(config, indent=2)

@mcp.resource("file://project/{path}")
async def read_project_file(path: str) -> str:
    """Read a project source file."""
    # Security: prevent directory traversal
    safe_path = os.path.normpath(path)
    if safe_path.startswith("..") or os.path.isabs(safe_path):
        raise ValueError("Access denied: invalid path")

    full_path = os.path.join(os.getcwd(), safe_path)
    with open(full_path, "r") as f:
        return f.read()
```

**Expected result:** A static config resource and a parameterized file resource are available to MCP clients.

### 4. Define prompts with argument decorators

Use @mcp.prompt() to register message templates. The function returns a string (for single-message prompts) or a list of message dictionaries (for multi-message sequences). Arguments become prompt parameters that users fill in through the client UI.

```
# Python
@mcp.prompt()
async def review_code(code: str, language: str = "python") -> str:
    """Generate a structured code review.

    Args:
        code: The code to review
        language: Programming language of the code
    """
    return f"""Review this {language} code for bugs, security issues, and improvements:

```{language}
{code}
```

Provide feedback in this format:
1. Critical issues (bugs, security)
2. Performance improvements
3. Style and readability suggestions"""

@mcp.prompt()
async def debug_error(error: str, context: str | None = None) -> str:
    """Help debug an error with structured analysis.

    Args:
        error: The error message or stack trace
        context: Additional context about when the error occurs
    """
    ctx = f"\nContext: {context}" if context else ""
    return f"""Debug this error:{ctx}

{error}

Analyze:
1. Root cause
2. Step-by-step fix
3. Prevention strategy"""
```

**Expected result:** Two prompts registered that clients can invoke with arguments to start structured AI interactions.

### 5. Add error handling and run the server

Handle errors by raising ValueError or other exceptions in your tool functions — FastMCP catches them and returns isError responses to the client. Then start the server with mcp.run() specifying the transport. Use 'stdio' for local development and CLI integration, or 'streamable-http' for remote access. For teams building production Python MCP servers, RapidDev can help with deployment, monitoring, and scaling strategies.

```
# Python — error handling in tools
@mcp.tool()
async def query_database(sql: str) -> str:
    """Run a read-only SQL query.

    Args:
        sql: SELECT query to execute
    """
    if not sql.strip().upper().startswith("SELECT"):
        raise ValueError("Only SELECT queries are allowed")

    try:
        # Using asyncpg for async PostgreSQL
        import asyncpg
        conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
        try:
            rows = await conn.fetch(sql)
            return json.dumps([dict(r) for r in rows], indent=2, default=str)
        finally:
            await conn.close()
    except asyncpg.PostgresError as e:
        raise ValueError(f"Database error: {e}")

# Run the server
if __name__ == "__main__":
    mcp.run(transport="stdio")

# Or for HTTP:
# mcp.run(transport="streamable-http", host="0.0.0.0", port=3000)
```

**Expected result:** The server starts, listens on the chosen transport, and handles tool calls with proper error responses.

## Complete code example

File: `server.py`

```python
"""MCP server built with Python FastMCP."""
import json
import os
import sys
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("python-demo-server")

# --- Tools ---

@mcp.tool()
async def calculate(expression: str) -> str:
    """Evaluate a mathematical expression.

    Args:
        expression: Math expression like '2 + 3 * 4'
    """
    allowed = set("0123456789+-*/.() ")
    if not all(c in allowed for c in expression):
        raise ValueError("Only basic math operators are allowed")
    return str(eval(expression))

@mcp.tool()
async def read_file(path: str) -> str:
    """Read a file from the project directory.

    Args:
        path: Relative file path within the project
    """
    safe = os.path.normpath(path)
    if safe.startswith("..") or os.path.isabs(safe):
        raise ValueError("Access denied: path outside project")
    full = os.path.join(os.getcwd(), safe)
    if not os.path.exists(full):
        raise ValueError(f"File not found: {path}")
    with open(full, "r") as f:
        return f.read()

@mcp.tool()
async def list_files(directory: str = ".") -> str:
    """List files in a project directory.

    Args:
        directory: Relative directory path (default: project root)
    """
    safe = os.path.normpath(directory)
    if safe.startswith("..") or os.path.isabs(safe):
        raise ValueError("Access denied")
    full = os.path.join(os.getcwd(), safe)
    if not os.path.isdir(full):
        raise ValueError(f"Not a directory: {directory}")
    entries = []
    for entry in sorted(os.listdir(full)):
        entry_path = os.path.join(full, entry)
        kind = "dir" if os.path.isdir(entry_path) else "file"
        entries.append({"name": entry, "type": kind})
    return json.dumps(entries, indent=2)

# --- Resources ---

@mcp.resource("config://app")
async def app_config() -> str:
    """Application configuration."""
    return json.dumps({
        "name": "Python Demo",
        "version": "1.0.0",
        "python": sys.version,
    }, indent=2)

# --- Prompts ---

@mcp.prompt()
async def review_code(code: str, language: str = "python") -> str:
    """Generate a code review.

    Args:
        code: Code to review
        language: Programming language
    """
    return f"""Review this {language} code:\n\n```{language}\n{code}\n```\n\nCheck for bugs, security, and style."""

if __name__ == "__main__":
    print("Starting Python MCP server...", file=sys.stderr)
    mcp.run(transport="stdio")
```

## Common mistakes

- **Printing to stdout instead of stderr** — undefined Fix: Use print(..., file=sys.stderr) or logging configured to stderr. stdout is reserved for the MCP JSON-RPC protocol.
- **Missing Args section in docstrings** — undefined Fix: FastMCP extracts parameter descriptions from the Google-style Args section. Without it, parameters have no descriptions and AI models generate worse arguments.
- **Using synchronous functions instead of async** — undefined Fix: FastMCP expects async functions for tools, resources, and prompts. Use 'async def' and 'await' for all handlers.
- **Not validating file paths** — undefined Fix: Always normalize paths with os.path.normpath() and check for directory traversal (.. or absolute paths) before file access.
- **Forgetting to handle tool errors** — undefined Fix: Raise ValueError with a descriptive message for expected errors. FastMCP converts it to isError: true. Unhandled exceptions return generic errors.

## Best practices

- Use Python 3.10+ for modern type hint syntax (str | None instead of Optional[str])
- Write Google-style docstrings with an Args section for every tool and prompt parameter
- Use async/await throughout — FastMCP is built on asyncio
- Raise ValueError for expected tool errors so the AI gets a clear message
- Log to sys.stderr, never stdout, to avoid corrupting the protocol stream
- Use httpx instead of requests for async HTTP calls in tool handlers
- Validate file paths and user inputs even in Python tools
- Test with the MCP Inspector: npx @modelcontextprotocol/inspector python server.py

## Frequently asked questions

### Can I use synchronous functions with FastMCP?

FastMCP expects async functions. If you need to call synchronous code, use asyncio.to_thread() to run it in a thread pool: result = await asyncio.to_thread(sync_function, args).

### How do I add dependencies to my Python MCP server?

Create a requirements.txt or pyproject.toml with your dependencies. Install with pip install -r requirements.txt. Common dependencies include httpx for HTTP, asyncpg for PostgreSQL, and aiofiles for async file I/O.

### How does FastMCP generate JSON Schema from type hints?

FastMCP uses Pydantic internally to convert Python type hints into JSON Schema. str becomes {type: 'string'}, int becomes {type: 'integer'}, list[str] becomes {type: 'array', items: {type: 'string'}}. Docstring Args descriptions become schema descriptions.

### Can I use Pydantic models for complex tool inputs?

Yes. Define a Pydantic BaseModel and use it as a parameter type. FastMCP will generate the full nested JSON Schema from the model definition.

### How do I test my Python MCP server?

Run: npx @modelcontextprotocol/inspector python server.py. The Inspector connects to your server over stdio and lets you invoke tools, read resources, and test prompts interactively.

### What is the difference between FastMCP and the low-level Server class?

FastMCP is a high-level wrapper that uses decorators and type hints. The low-level Server class requires manual JSON Schema definitions and message handling. Use FastMCP for most projects — it is simpler and covers all common patterns. For complex Python MCP projects that need low-level control, the RapidDev team can help evaluate which approach fits best.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-build-mcp-server-python
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-build-mcp-server-python
