Build a Custom MCP Server in Python: Step-by-Step Tutorial (2026)

TL;DR: MCP (Model Context Protocol) hit 97M monthly SDK downloads in 2026, with 5,800+ public servers. This tutorial walks you through building your own — a document reader MCP server using Python’s FastMCP 3.0 — in under 100 lines of code. No prior MCP experience needed.


“MCP is a User Interface for AI agents.” — Philipp Schmid, huggingface

The Model Context Protocol has become the default way AI agents connect to tools and data. Every frontier lab ships client support — Claude, Gemini, Cursor, Copilot. The public server registry grew 7.8x in a single year.

But here’s the thing: the real power of MCP isn’t using someone else’s servers. It’s building your own.

When you build a custom MCP server, you decide exactly what data and operations are available to the AI. No more trying to cram your workflow into a generic tool. No more security concerns about third-party servers having access to your data.

This tutorial builds a document reader server — a server that lets AI agents read PDFs, DOCX files, images, and other documents — because it’s useful by itself and demonstrates every MCP concept you need.


What You’ll Build

By the end of this tutorial, you’ll have a running MCP server that:

  • Reads PDF, DOCX, and plain text files
  • Exposes a dynamic resource for recently used documents
  • Provides a debugging prompt template
  • Is fully testable via MCP Inspector
  • Can connect to Claude Desktop, Cursor, or any MCP-compatible host

All in about 80 lines of Python.


Prerequisites

  • Python 3.11+ installed
  • Node.js 18+ (for MCP Inspector and testing tools)
  • uv package manager (recommended, but pip works too)
# Install uv (macOS/Linux)
curl -sSf https://install.python-uv.org | bash

# Verify
uv --version
python --version
node --version

Step 1: Project Setup

mkdir mcp-document-reader
cd mcp-document-reader
uv init
uv add "fastmcp>=3.0" "markitdown[all]"

markitdown[all] is Microsoft’s document conversion library — it handles PDFs, DOCX, PPTX, images (OCR), HTML, and more. fastmcp is the Python framework that wraps the raw MCP SDK with a clean decorator API.


Step 2: Your First Server

Create server.py:

from mcp.server.fastmcp import FastMCP

# Create the server instance
mcp = FastMCP("DocumentReader")

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

That’s it. This is a valid MCP server. It exposes nothing, but it connects. Run it:

python server.py

You’ll see it start and wait for connections via stdio. Hit Ctrl+C to stop.


Step 3: Add Your First Tool

Tools are functions the AI model can call. Let’s add a tool that reads text files:

from pathlib import Path

MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB

@mcp.tool()
def read_text_file(file_path: str) -> str:
    """Read the contents of a plain text file."""
    path = Path(file_path)

    if not path.exists():
        return f"Error: File not found at {file_path}"

    if path.stat().st_size > MAX_FILE_SIZE:
        return f"Error: File exceeds 10MB limit"

    try:
        return path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return "Error: File is not valid UTF-8 text"
    except Exception as e:
        return f"Error reading file: {str(e)}"

This tool:

  • Validates the file exists and isn’t too large
  • Handles errors gracefully instead of crashing
  • Returns a string the AI can understand and use

The @mcp.tool() decorator registers it with the FastMCP framework.


Step 4: Add Document Reading (PDF, DOCX, Images)

Now let’s add a tool that handles multiple document formats using markitdown:

from markitdown import MarkItDown

md_converter = MarkItDown()

@mcp.tool(annotations={
    "title": "Read Any Document",
    "readOnlyHint": True,
    "openWorldHint": False
})
def read_document(file_path: str) -> str:
    """Extract text content from PDF, DOCX, images, or HTML files."""
    path = Path(file_path)

    if not path.exists():
        return f"Error: File not found at {file_path}"

    if path.stat().st_size > MAX_FILE_SIZE:
        return f"Error: File exceeds 10MB limit ({path.stat().st_size / 1024 / 1024:.1f}MB)"

    allowed_exts = {".pdf", ".docx", ".doc", ".pptx", ".ppt", ".xlsx",
                    ".html", ".htm", ".jpg", ".jpeg", ".png", ".gif", ".txt"}
    if path.suffix.lower() not in allowed_exts:
        return f"Error: Unsupported format '{path.suffix}'. Supported: {', '.join(allowed_exts)}"

    try:
        result = md_converter.convert(str(path))
        text = result.text_content

        # Truncate for token budgets
        if len(text) > 50000:
            text = text[:50000] + f"\n\n[... truncated at 50,000 chars ...]"

        return text
    except Exception as e:
        return f"Error converting document: {str(e)}"

Key design choices:

  • readOnlyHint: True — tells the MCP client this tool doesn’t modify state
  • Extension whitelist — only allow known-safe file types
  • 50K char truncation — prevents token overflow in the AI’s context window
  • Informative error messages — the AI can act on “file not found” vs “unsupported format”

Step 5: Add a Resource

Resources are data the server controls — the AI can read them but doesn’t decide when they’re created. They’re useful for configuration, recent history, or curated reference data.

from datetime import datetime

RECENT_DOCUMENTS = []  # In production, use a real store

@mcp.resource("history://documents/recent")
def get_recent_documents() -> str:
    """Get a list of recently accessed documents."""
    if not RECENT_DOCUMENTS:
        return "No documents have been accessed yet."
    return "\n".join(
        f"{i+1}. {doc}{timestamp}"
        for i, (doc, timestamp) in enumerate(RECENT_DOCUMENTS)
    )

# Update the read_document tool to log access
def _log_access(file_path: str):
    RECENT_DOCUMENTS.append((file_path, datetime.now().isoformat()))
    if len(RECENT_DOCUMENTS) > 20:
        RECENT_DOCUMENTS.pop(0)

# Add _log_access(file_path) to your read_document tool body

Step 6: Add a Prompt

Prompts are templates the user can invoke. They’re like macros for the AI — structured instructions for common tasks.

@mcp.prompt()
def debug_conversion(error: str, file_path: str = "") -> list:
    """Troubleshoot document conversion issues."""
    return [
        {"role": "system", "content": "You are a document conversion specialist."},
        {"role": "user", "content": f"I encountered this error: {error}\n\n"
         f"File: {file_path or 'Unknown'}\n\n"
         "Please help me diagnose and fix this issue."}
    ]

Step 7: Connect to Claude Desktop

Create claude_desktop_config.json:

{
  "mcpServers": {
    "DocumentReader": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/mcp-document-reader", "server.py"]
    }
  }
}

Place this in your Claude Desktop config (~/.config/Claude/claude_desktop_config.json on Linux, ~/Library/Application Support/Claude/ on macOS). Restart Claude Desktop. You’ll see a hammer icon — click it to see your DocumentReader tools.


Step 8: Test with MCP Inspector

FastMCP has built-in debugging via MCP Inspector:

# With FastMCP installed
uv run mcp dev server.py
# or
python -m mcp dev server.py

This opens a web UI at http://localhost:5173 where you can:

  • Browse all tools, resources, and prompts
  • Call tools with custom parameters
  • Read resource contents
  • See raw JSON-RPC messages

No more guessing what the server is doing. The Inspector shows every request and response.


Step 9: Production Hardening

Before deploying a server your team depends on, add these layers:

Path Traversal Protection

ALLOWED_DIRECTORIES = [
    Path.home() / "Documents",
    Path("/data/shared/documents"),
]

def is_safe_path(file_path: str) -> bool:
    resolved = Path(file_path).resolve()
    return any(
        str(resolved).startswith(str(allowed_dir))
        for allowed_dir in ALLOWED_DIRECTORIES
    )

Rate Limiting

from collections import defaultdict
import time

_call_counts = defaultdict(list)
MAX_CALLS = 100
WINDOW_SECONDS = 60

def check_rate_limit(user_id: str = "default") -> bool:
    now = time.time()
    window_ago = now - WINDOW_SECONDS
    _call_counts[user_id] = [t for t in _call_counts[user_id] if t > window_ago]
    if len(_call_counts[user_id]) >= MAX_CALLS:
        return False
    _call_counts[user_id].append(now)
    return True

OpenTelemetry (FastMCP 3.0)

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider

trace.set_tracer_provider(TracerProvider())
mcp = FastMCP("DocumentReader")
# FastMCP auto-instruments — no manual spans needed

Complete Server (All Together)

from pathlib import Path
from datetime import datetime
from mcp.server.fastmcp import FastMCP
from markitdown import MarkItDown

mcp = FastMCP("DocumentReader")
md_converter = MarkItDown()
MAX_FILE_SIZE = 10 * 1024 * 1024
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".doc", ".pptx", ".ppt",
                      ".html", ".htm", ".jpg", ".jpeg", ".png", ".txt"}
RECENT_DOCUMENTS = []

@mcp.tool(annotations={"title": "Read Any Document", "readOnlyHint": True})
def read_document(file_path: str) -> str:
    """Extract text from PDF, DOCX, images, or HTML files."""
    path = Path(file_path)
    if not path.exists():
        return f"Error: File not found at {file_path}"
    if path.stat().st_size > MAX_FILE_SIZE:
        return f"Error: File exceeds 10MB limit"
    if path.suffix.lower() not in ALLOWED_EXTENSIONS:
        return f"Error: Unsupported format '{path.suffix}'"

    try:
        text = md_converter.convert(str(path)).text_content
        RECENT_DOCUMENTS.append((file_path, datetime.now().isoformat()))
        if len(RECENT_DOCUMENTS) > 20:
            RECENT_DOCUMENTS.pop(0)
        return text[:50000] if len(text) > 50000 else text
    except Exception as e:
        return f"Error: {str(e)}"

@mcp.resource("history://documents/recent")
def get_recent() -> str:
    if not RECENT_DOCUMENTS:
        return "No documents accessed yet."
    return "\n".join(f"{i+1}. {d}{t}" for i, (d, t) in enumerate(RECENT_DOCUMENTS))

@mcp.prompt()
def debug_conversion(error: str) -> list:
    return [
        {"role": "system", "content": "You are a document conversion specialist."},
        {"role": "user", "content": f"I got this error: {error}\nPlease diagnose."}
    ]

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

72 lines. That’s your production-ready MCP server.


What’s Next?

This document reader is a foundation. From here you can:

Enhancement Why
Add authentication @mcp.tool(auth=lambda ctx: ctx.user.role == "admin")
Version your tools @mcp.tool(version="2.0") with backward compat
Add search Expose vector search over your document corpus
Deploy remotely Wrap with SSE transport instead of stdio
Write tests Use MCPClient for integration tests in CI

The key insight is this: MCP servers are just Python functions with JSON Schema contracts. You don’t need to change your business logic. You write a thin layer that maps your existing code to tool definitions, and suddenly every MCP-compatible AI can use it.


Further Reading


Want to go deeper? Check out the MCP specification and the FastMCP docs.

← Back to all posts