Production-grade MCP servers
EN
Tutorials

MCP SDK for Python: The Complete Developer Guide to Building MCP Servers

A hands-on tutorial for building production-ready MCP servers with Python. Covers the official mcp SDK, FastMCP, tool definition, resource exposure, authentication, testing, and deployment — with working code examples.

Author
Engineering Team
April 14, 2026
MCP SDK for Python: The Complete Developer Guide to Building MCP Servers
Try Vinkius Free

The official MCP SDK for Python (mcp) lets you build an MCP server in under 50 lines of code. You define tools as decorated Python functions, expose resources as data endpoints, and the SDK handles the JSON-RPC 2.0 protocol, transport negotiation, and capability advertisement automatically. This guide walks through every step — from installation to production deployment — with working code you can copy and run.

Prerequisites

  • Python 3.10 or newer
  • pip or uv package manager
  • Basic familiarity with async Python (async/await)
  • An AI client for testing: Claude Desktop, Cursor, or VS Code

Installation

The recommended approach is using uv for faster installs and better dependency resolution:

# Using uv (recommended)
uv pip install mcp

# Using pip
pip install mcp

# Verify installation
python -c "import mcp; print(mcp.__version__)"

The mcp package includes both the server SDK and the FastMCP high-level API.

Your First MCP Server in 5 Minutes

Create a file called server.py:

from mcp.server.fastmcp import FastMCP

# Initialize the server with a name
mcp = FastMCP("my-first-server")


@mcp.tool()
def greet(name: str) -> str:
    """Greet someone by name."""
    return f"Hello, {name}! Welcome to MCP."


@mcp.tool()
def add(a: float, b: float) -> float:
    """Add two numbers together."""
    return a + b


@mcp.tool()
def word_count(text: str) -> dict:
    """Count words, characters, and sentences in a text."""
    words = text.split()
    sentences = text.count('.') + text.count('!') + text.count('?')
    return {
        "words": len(words),
        "characters": len(text),
        "sentences": max(sentences, 1),
    }


if __name__ == "__main__":
    mcp.run()

Run it:

python server.py

That’s it. You have a working MCP server with three tools. The server starts in stdio mode by default — ready for Claude Desktop to connect.

Connecting to Claude Desktop

Add your server to Claude Desktop’s configuration:

macOS/Linux: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json

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

Restart Claude Desktop. You’ll see the tools icon (🔨) in the chat input — Claude can now call your greet, add, and word_count functions.

Understanding the Three Primitives

MCP servers expose three types of capabilities. Understanding when to use each is critical.

Tools — Functions the AI Can Call

Tools are the most common primitive. They represent actions the AI can invoke with parameters. Use tools when the AI needs to do something — query an API, run a calculation, fetch data, or modify a record.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("api-server")


@mcp.tool()
async def fetch_user(user_id: int) -> dict:
    """Fetch a user from the database by their ID."""
    import httpx
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.example.com/users/{user_id}")
        response.raise_for_status()
        return response.json()


@mcp.tool()
async def create_ticket(
    title: str,
    description: str,
    priority: str = "medium"
) -> dict:
    """Create a new support ticket.

    Args:
        title: Short summary of the issue
        description: Detailed description of the problem
        priority: Ticket priority — low, medium, high, or critical
    """
    import httpx
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.example.com/tickets",
            json={"title": title, "description": description, "priority": priority}
        )
        response.raise_for_status()
        return response.json()

Key rules:

  • Use type hints for all parameters — the SDK uses them to generate the JSON Schema the AI reads
  • Write clear docstrings — they become the tool description the AI uses to decide when to call the function
  • Use async for I/O operations (API calls, database queries, file reads)
  • Return structured data (dicts, lists) — the AI can reason over structured output better than raw strings

Resources — Data the AI Can Read

Resources are read-only data endpoints. Use resources when the AI needs access to reference data, configurations, or documentation — data that provides context rather than requiring action.

@mcp.resource("config://app-settings")
def get_app_settings() -> str:
    """Current application configuration."""
    import json
    settings = {
        "environment": "production",
        "version": "2.4.1",
        "features": {
            "dark_mode": True,
            "beta_features": False,
        }
    }
    return json.dumps(settings, indent=2)


@mcp.resource("docs://api-reference")
def get_api_docs() -> str:
    """API reference documentation."""
    return open("docs/api-reference.md").read()

Resources use URI schemes (config://, docs://, file://) to organize different data types.

Prompts — Reusable Instruction Templates

Prompts are pre-defined instruction templates that guide the AI for specific workflows. They’re less common than tools but powerful for standardizing complex interactions.

@mcp.prompt()
def code_review(code: str, language: str = "python") -> str:
    """Generate a structured code review."""
    return f"""Please review the following {language} code. Analyze:
1. **Correctness** — are there any bugs or logic errors?
2. **Performance** — can anything be optimized?
3. **Security** — are there any vulnerabilities?
4. **Style** — does it follow {language} best practices?

Code:
```{language}
{code}

Provide specific, actionable feedback with code suggestions where applicable."""


## Building a Real-World MCP Server

Let's build a complete server that wraps a REST API — a pattern you'll use repeatedly.

```python
"""
MCP Server for a task management API.
Exposes CRUD operations as MCP tools with full error handling.
"""
import os
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("task-manager")

# Configuration from environment variables — never hardcode secrets
API_BASE = os.environ.get("TASK_API_URL", "https://api.example.com")
API_KEY = os.environ.get("TASK_API_KEY", "")


def _headers() -> dict:
    """Build authenticated request headers."""
    return {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    }


@mcp.tool()
async def list_tasks(
    status: str = "all",
    limit: int = 20
) -> list[dict]:
    """List tasks, optionally filtered by status.

    Args:
        status: Filter by status — all, open, in_progress, done
        limit: Maximum number of tasks to return (1-100)
    """
    limit = max(1, min(limit, 100))  # Clamp to valid range

    async with httpx.AsyncClient() as client:
        params = {"limit": limit}
        if status != "all":
            params["status"] = status

        response = await client.get(
            f"{API_BASE}/tasks",
            headers=_headers(),
            params=params,
        )
        response.raise_for_status()
        return response.json()["tasks"]


@mcp.tool()
async def create_task(
    title: str,
    description: str = "",
    assignee: str = "",
    priority: str = "medium",
    due_date: str = ""
) -> dict:
    """Create a new task.

    Args:
        title: Task title (required)
        description: Detailed description
        assignee: Email of the person to assign the task to
        priority: low, medium, high, or critical
        due_date: Due date in YYYY-MM-DD format
    """
    payload = {"title": title, "priority": priority}
    if description:
        payload["description"] = description
    if assignee:
        payload["assignee"] = assignee
    if due_date:
        payload["due_date"] = due_date

    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{API_BASE}/tasks",
            headers=_headers(),
            json=payload,
        )
        response.raise_for_status()
        return response.json()


@mcp.tool()
async def update_task_status(
    task_id: str,
    new_status: str
) -> dict:
    """Update the status of a task.

    Args:
        task_id: The unique task identifier
        new_status: New status — open, in_progress, done, cancelled
    """
    async with httpx.AsyncClient() as client:
        response = await client.patch(
            f"{API_BASE}/tasks/{task_id}",
            headers=_headers(),
            json={"status": new_status},
        )
        response.raise_for_status()
        return response.json()


@mcp.tool()
async def search_tasks(query: str) -> list[dict]:
    """Search tasks by keyword across titles and descriptions.

    Args:
        query: Search keyword or phrase
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"{API_BASE}/tasks/search",
            headers=_headers(),
            params={"q": query},
        )
        response.raise_for_status()
        return response.json()["results"]


# Resource: expose the API schema for context
@mcp.resource("docs://task-schema")
def task_schema() -> str:
    """Schema documentation for the task management API."""
    return """
    Task Object:
    - id: string (UUID)
    - title: string (required, max 200 chars)
    - description: string (optional)
    - status: enum [open, in_progress, done, cancelled]
    - priority: enum [low, medium, high, critical]
    - assignee: string (email)
    - due_date: string (YYYY-MM-DD)
    - created_at: string (ISO 8601)
    - updated_at: string (ISO 8601)
    """


if __name__ == "__main__":
    mcp.run()

Run with environment variables:

TASK_API_URL=https://api.yourservice.com \
TASK_API_KEY=your-api-key \
python server.py

Error Handling

Never let raw exceptions propagate to the AI. Always return structured, informative error responses:

from mcp.server.fastmcp import FastMCP
import httpx

mcp = FastMCP("robust-server")


@mcp.tool()
async def safe_api_call(endpoint: str) -> dict:
    """Safely call an API endpoint with proper error handling."""
    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.get(endpoint)
            response.raise_for_status()
            return {"status": "success", "data": response.json()}

    except httpx.TimeoutException:
        return {"status": "error", "message": f"Request to {endpoint} timed out after 30 seconds"}

    except httpx.HTTPStatusError as e:
        return {
            "status": "error",
            "message": f"HTTP {e.response.status_code}",
            "detail": e.response.text[:500]
        }

    except Exception as e:
        return {"status": "error", "message": str(e)}

Testing Your Server

The MCP Inspector is the official testing tool. Install and run it:

npx @modelcontextprotocol/inspector python server.py

This opens a web UI where you can:

  • See all registered tools, resources, and prompts
  • Call tools with test parameters
  • Inspect the JSON-RPC messages
  • Verify parameter schemas

For automated testing, use the MCP client SDK:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def test_server():
    server_params = StdioServerParameters(
        command="python",
        args=["server.py"],
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # List available tools
            tools = await session.list_tools()
            print(f"Available tools: {[t.name for t in tools.tools]}")

            # Call a tool
            result = await session.call_tool("greet", {"name": "World"})
            print(f"Result: {result.content[0].text}")


asyncio.run(test_server())

Transport: stdio vs HTTP+SSE

The SDK supports two transport modes:

stdio (default) — communication through standard input/output. Best for local development and Claude Desktop’s direct process spawning.

# Default — stdio transport
if __name__ == "__main__":
    mcp.run()  # Runs in stdio mode

HTTP+SSE — communication over HTTP with Server-Sent Events. Required for remote deployment, multi-user access, and production hosting.

# HTTP transport for remote access
if __name__ == "__main__":
    mcp.run(transport="sse", host="0.0.0.0", port=8080)

For production remote deployments, use a managed MCP platform that handles TLS, authentication, and scaling automatically.

Security Best Practices

  1. Never hardcode credentials. Use environment variables or a secrets manager.
  2. Validate all inputs. Clamp numeric ranges, sanitize strings, reject unexpected values.
  3. Limit tool scope. Expose only the operations your use case requires — don’t expose a generic execute_sql tool when you only need list_users.
  4. Use read-only connections where possible. If the AI only needs to read data, don’t give it write access.
  5. Log everything. Record every tool call for debugging and audit purposes.
  6. Rate limit expensive operations to prevent runaway costs.

For production deployments, use a managed gateway that adds credential isolation, DLP, and semantic intent classification.

Deploying to Production

Once your server works locally, you need to make it remotely accessible. Options:

Self-hosted: Dockerize and deploy to your cloud provider with HTTP transport. You manage infrastructure, TLS, auth, and scaling.

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY server.py .
CMD ["python", "server.py"]

Managed: Deploy through a managed MCP registry that handles infrastructure, security, and governance automatically. One URL, instant multi-user access.

Frequently Asked Questions

Can I use synchronous functions instead of async?

Yes. FastMCP supports both def and async def tool functions. Use async def for I/O-bound operations (API calls, database queries) and regular def for CPU-bound computations.

How do I return images or files from a tool?

Use the Image and Resource content types from the MCP SDK. The AI client will render them appropriately.

Yes. You can organize tools by creating separate FastMCP instances and composing them, or by using Python modules with clear naming conventions.

What happens if my tool call takes too long?

Most MCP clients have a timeout (typically 30-60 seconds). Design your tools to return within that window. For long-running operations, return a job ID and provide a separate check_status tool.

How do I update my server without downtime?

For local servers, restart the process. For remote servers on a managed platform, deploy the new version — the platform handles rolling updates automatically.


Ready to deploy your Python MCP server? Publish it on our managed registry — we handle infrastructure, security, credential isolation, and multi-user access. Browse 2,500+ existing servers for inspiration →


Hardened & governed from day one

Your agents need tools. We make them safe.

Pick an MCP server from the catalog. Subscribe. Copy the URL. Paste it into Claude, Cursor, or any client. One URL — DLP, audit trail, and kill switch included.

V8 sandbox isolation · Semantic DLP · Cryptographic audit trail · Emergency kill switch

Share this article