Skip to content
Blog

Building an MCP Server with the Python SDK

Build a production-ready MCP server using the Python SDK. Expose tools, resources, and prompts to AI agents through the Model Context Protocol.

Published on September 8, 2026

AI Assistant

The Model Context Protocol (MCP) is the standard for connecting AI applications to external systems. Building an MCP server lets you expose your APIs, databases, and tools to any MCP-compatible AI agent — from Claude to ChatGPT to custom builds. This guide walks through building a complete MCP server with the Python SDK.

What MCP Enables

MCP gives AI applications a standardized way to:

  • Discover tools — Agents learn what tools are available and how to call them
  • Execute functions — Agents invoke your tools with structured inputs
  • Read resources — Agents access your data through a file-like interface
  • Use prompts — Agents leverage pre-built prompt templates

Think of it as building an API, but specifically designed for AI consumption.

Setting Up the Project

# Create project directory
uv init my-mcp-server
cd my-mcp-server

# Set up virtual environment
uv venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate

# Install MCP SDK
uv add "mcp[cli]"

# Create server file
touch server.py

Building Your First MCP Server

Here’s a complete MCP server that exposes a task management system:

from mcp.server import MCPServer
from typing import Optional
from datetime import datetime
import json

# Initialize the MCP server
mcp = MCPServer("task-manager")

# In-memory task store (use a database in production)
tasks: dict[str, dict] = {}
task_counter = 0

@mcp.tool()
async def create_task(
    title: str,
    description: str = "",
    priority: str = "medium",
    due_date: Optional[str] = None
) -> str:
    """Create a new task with title, description, and priority.
    
    Args:
        title: The task title
        description: Optional task description
        priority: Task priority (low, medium, high, critical)
        due_date: Optional due date in ISO format (YYYY-MM-DD)
    """
    global task_counter
    task_counter += 1
    
    task_id = f"task-{task_counter}"
    tasks[task_id] = {
        "id": task_id,
        "title": title,
        "description": description,
        "priority": priority,
        "status": "pending",
        "due_date": due_date,
        "created_at": datetime.now().isoformat(),
    }
    
    return json.dumps({
        "success": True,
        "task": tasks[task_id],
        "message": f"Task '{title}' created with ID {task_id}"
    })

@mcp.tool()
async def list_tasks(
    status: Optional[str] = None,
    priority: Optional[str] = None
) -> str:
    """List all tasks, optionally filtered by status or priority.
    
    Args:
        status: Filter by status (pending, in_progress, completed)
        priority: Filter by priority (low, medium, high, critical)
    """
    filtered = tasks.values()
    
    if status:
        filtered = [t for t in filtered if t["status"] == status]
    if priority:
        filtered = [t for t in filtered if t["priority"] == priority]
    
    return json.dumps({
        "tasks": list(filtered),
        "count": len(list(filtered))
    })

@mcp.tool()
async def update_task(
    task_id: str,
    title: Optional[str] = None,
    status: Optional[str] = None,
    priority: Optional[str] = None
) -> str:
    """Update an existing task's properties.
    
    Args:
        task_id: The ID of the task to update
        title: New title (optional)
        status: New status (optional)
        priority: New priority (optional)
    """
    if task_id not in tasks:
        return json.dumps({"error": f"Task {task_id} not found"})
    
    task = tasks[task_id]
    if title:
        task["title"] = title
    if status:
        task["status"] = status
    if priority:
        task["priority"] = priority
    
    return json.dumps({"success": True, "task": task})

@mcp.tool()
async def delete_task(task_id: str) -> str:
    """Delete a task by ID.
    
    Args:
        task_id: The ID of the task to delete
    """
    if task_id not in tasks:
        return json.dumps({"error": f"Task {task_id} not found"})
    
    deleted = tasks.pop(task_id)
    return json.dumps({
        "success": True,
        "message": f"Task '{deleted['title']}' deleted"
    })

Exposing Resources

Resources provide read-only access to your data:

@mcp.resource("tasks://summary")
async def get_task_summary() -> str:
    """Get a summary of all tasks by status and priority."""
    summary = {
        "total": len(tasks),
        "by_status": {},
        "by_priority": {}
    }
    
    for task in tasks.values():
        status = task["status"]
        priority = task["priority"]
        summary["by_status"][status] = summary["by_status"].get(status, 0) + 1
        summary["by_priority"][priority] = summary["by_priority"].get(priority, 0) + 1
    
    return json.dumps(summary, indent=2)

@mcp.resource("tasks://overdue")
async def get_overdue_tasks() -> str:
    """Get all tasks that are past their due date."""
    now = datetime.now()
    overdue = [
        task for task in tasks.values()
        if task["due_date"] and datetime.fromisoformat(task["due_date"]) < now
    ]
    return json.dumps(overdue, indent=2)

Adding Prompts

Prompts provide templates that help users accomplish specific tasks:

@mcp.prompt()
async def task_review_prompt(project_name: str) -> str:
    """Generate a prompt for reviewing project tasks.
    
    Args:
        project_name: The name of the project to review
    """
    return f"""Please review the following tasks for project '{project_name}':

1. List all pending tasks and their priorities
2. Identify any overdue tasks
3. Suggest task prioritization for the next sprint
4. Flag any tasks that may need more details

Provide a structured summary with actionable recommendations."""

@mcp.prompt()
async def daily_standup_prompt(team_member: str) -> str:
    """Generate a standup update prompt.
    
    Args:
        team_member: Name of the team member
    """
    return f"""Help me prepare a standup update for {team_member}:

1. What tasks were completed yesterday?
2. What tasks are planned for today?
3. Are there any blockers or dependencies?
4. What's the overall progress percentage?

Format as a concise standup update."""

Running and Testing

Start the Server

# Run with stdio transport (for Claude for Desktop)
uv run server.py

# Run with HTTP transport (for web clients)
mcp run server.py --transport http --port 8080

Configure Claude for Desktop

Add your server to claude_desktop_config.json:

{
  "mcpServers": {
    "task-manager": {
      "command": "uv",
      "args": ["--directory", "/path/to/your/server", "run", "server.py"]
    }
  }
}

Test with the MCP Inspector

mcp dev server.py

This opens a web UI where you can test tools, resources, and prompts interactively.

Production Considerations

Error Handling

Wrap tool implementations with proper error handling:

@mcp.tool()
async def safe_create_task(title: str) -> str:
    """Create a task with error handling."""
    try:
        # Validation
        if not title.strip():
            return json.dumps({"error": "Title cannot be empty"})
        
        if len(title) > 200:
            return json.dumps({"error": "Title too long (max 200 chars)"})
        
        # Create task
        task_id = f"task-{len(tasks) + 1}"
        tasks[task_id] = {"id": task_id, "title": title, "status": "pending"}
        
        return json.dumps({"success": True, "task_id": task_id})
    
    except Exception as e:
        return json.dumps({"error": f"Failed to create task: {str(e)}"})

Logging

Use stderr for logging (stdout is reserved for MCP protocol messages):

import logging
import sys

logging.basicConfig(
    stream=sys.stderr,
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)

@mcp.tool()
async def logged_tool(query: str) -> str:
    """A tool with proper logging."""
    logger.info(f"Received query: {query}")
    # ... tool implementation
    logger.info("Tool execution completed")
    return "result"

Transport Selection

TransportUse CaseProsCons
stdioDesktop apps (Claude)Simple, secureSingle client
HTTPWeb servers, APIsMulti-client, scalableRequires auth
SSEReal-time updatesStreaming supportComplex setup

Authentication for HTTP Transport

from mcp.server.auth import BearerTokenAuth

mcp = MCPServer(
    "secure-server",
    auth=BearerTokenAuth(token="your-secret-token")
)

Deploying to Production

Docker

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install "mcp[cli]"
EXPOSE 8080
CMD ["mcp", "run", "server.py", "--transport", "http", "--port", "8080"]

Environment Variables

import os

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///tasks.db")
API_KEY = os.getenv("API_KEY")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")

Connecting Multiple MCP Servers

MCP clients can connect to multiple servers simultaneously:

{
  "mcpServers": {
    "task-manager": {
      "command": "uv",
      "args": ["--directory", "/path/to/tasks", "run", "server.py"]
    },
    "calendar": {
      "command": "uv",
      "args": ["--directory", "/path/to/calendar", "run", "server.py"]
    },
    "email": {
      "command": "uv",
      "args": ["--directory", "/path/to/email", "run", "server.py"]
    }
  }
}

The AI agent discovers all tools from all servers and can use them together in complex workflows.

Conclusion

Building an MCP server with the Python SDK is straightforward — define tools with decorators, add resources and prompts, and run the server. The protocol handles discovery, serialization, and transport. Start with stdio for development, add HTTP for production, and layer in authentication and error handling as you scale.