Build a Custom MCP Server in Python: Complete 2026 Guide

Build a Model Context Protocol server in Python from scratch. Expose tools, resources, and prompts to Claude and other MCP clients in under 30 minutes.

Problem: You Can't Find an MCP Server That Does Exactly What You Need

The MCP ecosystem has dozens of pre-built servers, but they rarely match your exact internal APIs, data sources, or tooling. You need to expose your own Python functions — a database query, a file processor, an internal REST call — to Claude or another MCP client.

The official Python SDK makes this doable in ~100 lines. Most tutorials stop at "hello world." This one goes further.

You'll learn:

  • How to register tools, resources, and prompts on a single MCP server
  • How to handle typed inputs and return structured output
  • How to connect your server to Claude Desktop and test it end-to-end

Time: 30 min | Difficulty: Intermediate


Why This Happens

MCP (Model Context Protocol) standardizes how AI models talk to external context providers. A server exposes three primitives:

  • Tools — functions the model can call (e.g., search_database, send_email)
  • Resources — readable data the model can pull (e.g., a file, a live metric)
  • Prompts — reusable prompt templates with arguments

Without a custom server, you're limited to what community servers already expose. Your internal systems stay invisible to the model.

What you need before starting:

  • Python 3.11+
  • uv package manager (recommended) or pip
  • Claude Desktop installed and configured

Solution

Step 1: Create the Project and Install Dependencies

# Create and enter project directory
mkdir my-mcp-server && cd my-mcp-server

# Initialize with uv (fast, reproducible)
uv init --python 3.11

# Install the official MCP Python SDK
uv add mcp

Expected output:

Resolved 12 packages in 340ms
Installed 12 packages in 180ms
 + mcp==1.5.0

If it fails:

  • uv: command not found → Install with curl -LsSf https://astral.sh/uv/install.sh | sh
  • Python 3.11 not found → Run uv python install 3.11 first

Step 2: Scaffold the Server File

Create server.py in the project root:

from mcp.server.fastmcp import FastMCP

# FastMCP handles the protocol boilerplate — you just register handlers
app = FastMCP(name="my-server")

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

Run it to confirm the SDK is wired up correctly:

uv run python server.py

Expected output:

Starting MCP server 'my-server' on stdio transport

Press Ctrl+C to stop. The server communicates over stdio — Claude Desktop spawns it as a subprocess and talks to it over stdin/stdout.


Step 3: Register a Tool

Tools are Python functions decorated with @app.tool(). The SDK reads the type hints and docstring to generate the JSON schema that the model sees.

from mcp.server.fastmcp import FastMCP

app = FastMCP(name="my-server")


@app.tool()
def add_numbers(a: float, b: float) -> float:
    """Add two numbers together and return the result."""
    return a + b


@app.tool()
def search_notes(query: str, max_results: int = 5) -> list[dict]:
    """
    Search internal notes by keyword.

    Args:
        query: The search term to look for in note titles and bodies.
        max_results: Maximum number of results to return. Defaults to 5.
    """
    # Replace with your actual data source
    dummy_notes = [
        {"id": 1, "title": "MCP Setup", "body": "Notes on configuring MCP servers"},
        {"id": 2, "title": "Python Tips", "body": "Useful Python patterns for 2026"},
    ]
    results = [n for n in dummy_notes if query.lower() in n["title"].lower() or query.lower() in n["body"].lower()]
    return results[:max_results]


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

Key points:

  • Type hints are required. The SDK uses them to generate the input schema. Missing types = the model can't call the tool correctly.
  • Docstrings become tool descriptions. Write them for the model, not just for humans. Be explicit about what each argument expects.
  • Return type matters. Return str, int, float, list, or dict. Anything else needs a str() cast.

Step 4: Register a Resource

Resources are URIs the model can read on demand — think of them as readable documents or live data endpoints.

@app.resource("notes://all")
def get_all_notes() -> str:
    """Returns all notes as formatted text."""
    notes = [
        {"id": 1, "title": "MCP Setup", "body": "Notes on configuring MCP servers"},
        {"id": 2, "title": "Python Tips", "body": "Useful Python patterns for 2026"},
    ]
    return "\n\n".join(f"# {n['title']}\n{n['body']}" for n in notes)


@app.resource("notes://{note_id}")
def get_note_by_id(note_id: int) -> str:
    """Returns a single note by its integer ID."""
    notes = {
        1: {"title": "MCP Setup", "body": "Notes on configuring MCP servers"},
        2: {"title": "Python Tips", "body": "Useful Python patterns for 2026"},
    }
    note = notes.get(note_id)
    if not note:
        return f"Note {note_id} not found."
    return f"# {note['title']}\n{note['body']}"

The {note_id} in the URI template is automatically extracted and passed as a function argument. The model can request notes://1 and your handler receives note_id=1.


Step 5: Register a Prompt Template

Prompts let you package reusable instructions — the model or the user can invoke them by name.

from mcp.server.fastmcp import FastMCP
from mcp.types import PromptMessage, TextContent


@app.prompt("summarize-note")
def summarize_note_prompt(note_id: int) -> list[PromptMessage]:
    """Generate a prompt asking the model to summarize a specific note."""
    return [
        PromptMessage(
            role="user",
            content=TextContent(
                type="text",
                text=f"Please read the note at notes://{note_id} and write a 3-bullet summary.",
            ),
        )
    ]

Step 6: Connect to Claude Desktop

Open Claude Desktop's config file:

# macOS
open ~/Library/Application\ Support/Claude/claude_desktop_config.json

# Windows
notepad %APPDATA%\Claude\claude_desktop_config.json

Add your server under mcpServers:

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

Replace /absolute/path/to/my-mcp-server with your actual project path. Use pwd in the project directory to get it.

Restart Claude Desktop completely (Quit, not just close the window).

If it fails:

  • Server not showing in Claude → Check the path is absolute, not relative
  • ModuleNotFoundError: mcp → Confirm uv run resolves to the project's venv, not a global Python
  • Server crashes on startup → Run uv run python server.py manually to see the traceback

Verification

After restarting Claude Desktop, click the tools icon (🔧) in the chat input bar. You should see your server listed with its tools.

Test it by asking Claude directly:

Use the search_notes tool to find notes about "MCP"

You should see: Claude calls search_notes with query="MCP" and returns the matching notes.

To verify the resource is accessible:

Read the resource at notes://1 and summarize it

You should see: Claude fetches the resource content and summarizes it inline.

To check tool registration from the command line without Claude Desktop:

# List all registered tools via MCP inspector
uv run mcp dev server.py

This opens the MCP Inspector in your browser — a visual interface to call tools and read resources directly.


Adding Error Handling and Typed Returns

Production servers need structured errors. Raise ValueError for bad input — the SDK converts it to a proper MCP error response:

from pydantic import BaseModel


class NoteResult(BaseModel):
    id: int
    title: str
    body: str
    found: bool


@app.tool()
def get_note(note_id: int) -> NoteResult:
    """
    Fetch a note by ID.

    Args:
        note_id: Integer ID of the note. Must be a positive integer.
    """
    if note_id <= 0:
        # ValueError becomes a structured MCP error — the model sees the message
        raise ValueError(f"note_id must be positive, got {note_id}")

    notes = {1: {"title": "MCP Setup", "body": "Notes on configuring MCP servers"}}
    note = notes.get(note_id)

    if not note:
        return NoteResult(id=note_id, title="", body="", found=False)

    return NoteResult(id=note_id, title=note["title"], body=note["body"], found=True)

Pydantic models as return types give the model a reliable schema to parse. This matters for tool chaining — when one tool's output feeds into another tool's input.


What You Learned

  • FastMCP handles protocol boilerplate — you write plain Python functions
  • Type hints + docstrings are how the model understands what a tool does — treat them as part of the API contract
  • Tools handle actions, resources handle readable data, prompts handle reusable instructions — use the right primitive for the job
  • The MCP Inspector (mcp dev) lets you test your server without needing Claude Desktop

Limitation: FastMCP's stdio transport is single-client. For serving multiple clients or remote connections, use the SSE transport: app.run(transport="sse", host="0.0.0.0", port=8000). This requires additional auth handling before exposing it over a network.

Tested on mcp 1.5.0, Python 3.11, uv 0.5.x, Claude Desktop 0.9.x, macOS Sequoia and Ubuntu 24.04