tutorial mcp python claude

The Ultimate Guide to Model Context Protocol, Part 4: Build Your Own MCP Server

27 min read

Stop using other people's MCP servers and build your own. A step-by-step Python tutorial that takes you from zero to a working MCP server in under 30 minutes.

Part of the Claude topic hub.

Hero image for The Ultimate Guide to Model Context Protocol, Part 4: Build Your Own MCP Server
Table of Contents

The MCP Series

This is Part 4 of my Ultimate Guide to Model Context Protocol series. Catch up on Part 1: What is MCP, Part 2: Behind the Scenes, and Part 3: Tying It All Together if you haven’t already.

Right then. In Part 1 I told you what MCP is. In Part 2 I showed you how it works behind the scenes. In Part 3 I showed off all the cool workflows you can build with other people’s MCP servers.

This is the post where we roll up the sleeves, crack open a terminal, and build a real, working MCP server from scratch. By the end of this tutorial, you’ll have a personal notes MCP server that lets Claude save, search, and manage notes for you, all running locally on your machine.

It’s way easier than you’d think. The Python SDK makes building MCP servers feel like writing a Flask app. If you can write a Python function, you can build an MCP server. And if you can’t, well just use Claude Code to do it for you.

Let’s get to it.

Why Build Your Own MCP Server?

There are thousands of MCP servers out there. The MCP registry is basically an app store at this point. So why would you build your own?

Your workflow is unique. Public MCP servers are built for the general case. They connect to Slack, GitHub, Google Drive. But what about that internal tool your team uses? Or the weird API your company built? Or that janky spreadsheet you use to track everything? No one’s building an MCP server for that. You are.

You want tighter control. A third-party MCP server that connects to your database is a trust exercise. You’re handing someone else’s code the keys to your data. When you build your own, you control exactly what gets exposed and how. You can add rate limits, logging, or restrict access to specific tables. Your server, your rules.

You can combine multiple sources. The most powerful MCP servers I’ve built aren’t wrappers around a single API. They’re bridges between systems that don’t know about each other. A server that reads from your CRM, checks your calendar, and drafts a follow-up email. A server that pulls data from three different databases and merges it. You can’t get that off the shelf.

It’s a great way to learn MCP. Reading about tools and resources is one thing. Building a server that exposes them and watching Claude use them for the first time is something else. You’ll understand the protocol at a much deeper level, which makes you better at using other people’s servers too.

And honestly? It’s just fun. There’s something satisfying about telling Claude to do something and watching it call a function you wrote.

What We’re Building

We’re going to build a personal notes server called NoteKeeper (wow so creative). When it’s done, you’ll be able to say things like:

  • “Save a note about the MCP architecture patterns I learned today”
  • “Find my notes about Python testing”
  • “Show me all my notes tagged with ‘ideas’”
  • “Delete that note about the old API design”

Claude will handle all of this through your MCP server, reading and writing notes to a local SQLite database on your machine. No cloud services, no API keys, no third-party dependencies. Just you, Claude, and a database file.

Here’s why I chose this project: it’s simple enough to understand in one sitting, but meaty enough to teach you every core MCP concept: tools, resources, and how they fit together.

Prerequisites

You’ll need three things:

  1. Python 3.10+ installed on your machine
  2. uv (the fast Python package manager) or pip
  3. Claude Desktop or Claude Code to test the server

If you don’t have uv yet, install it:

# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

I’m going to use uv throughout this tutorial because it’s faster and handles virtual environments automatically. If you prefer pip, you can substitute pip install for uv add and things will work fine.

Project Setup

First, create a new project:

# Create the project directory
mkdir notekeeper-mcp && cd notekeeper-mcp

# Initialize a new Python project with uv
uv init

# Add the MCP SDK with CLI extras
uv add "mcp[cli]"

That mcp[cli] package is the official Python SDK from Anthropic. The [cli] extra gives you the mcp command-line tool, which we’ll use for testing later.

Your project structure should look like this:

notekeeper-mcp/
├── pyproject.toml
├── .python-version
└── hello.py          # we'll replace this

Delete hello.py and create server.py. This is where all the action happens.

Your First MCP Server (The Basics)

Before we build NoteKeeper, let’s start with the absolute simplest MCP server possible. This will teach you the core pattern.

Create server.py:

from mcp.server.fastmcp import FastMCP

# Create the server
mcp = FastMCP("hello-world")

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

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

That’s it. That’s a working MCP server. Seven lines of actual code.

Let’s break down what’s happening:

  1. FastMCP("hello-world") creates a new MCP server with the name “hello-world”. FastMCP is the high-level API that handles all the protocol details for you.
  2. @mcp.tool() registers a Python function as an MCP tool. The decorator automatically reads the function’s type hints and docstring to generate the tool’s schema. Claude will see this tool and know it takes a name string and returns a greeting.
  3. mcp.run() starts the server using stdio transport (the default). This means it communicates via standard input/output, which is how Claude Desktop and Claude Code talk to local MCP servers.

Your type hints and docstring become the tool description that Claude sees. Write good docstrings and Claude will know how to use your tools.

Testing with MCP Inspector

Before connecting to Claude, let’s test this with the MCP Inspector. It’s an interactive debugging tool from Anthropic that lets you poke at your server in a web UI.

npx @modelcontextprotocol/inspector uv run server.py

This spins up the Inspector at http://localhost:6274. Open that in your browser and you’ll see a dashboard with tabs for Tools, Resources, and Prompts.

Click on the Tools tab and you should see your greet tool listed. Click it, type a name in the input field, and hit “Run.” You’ll see the greeting response come back.

If you see your tool and it returns a response, your server works. Simple as that.

Building NoteKeeper: The Foundation

Now let’s build something real. Replace the contents of server.py with the skeleton of our NoteKeeper server:

import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

from mcp.server.fastmcp import FastMCP

# Create the server
mcp = FastMCP("notekeeper")

# Database setup
DB_PATH = Path.home() / ".notekeeper" / "notes.db"


def get_db() -> sqlite3.Connection:
    """Get a database connection, creating the DB if needed."""
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(str(DB_PATH))
    conn.row_factory = sqlite3.Row
    conn.execute("""
        CREATE TABLE IF NOT EXISTS notes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            content TEXT NOT NULL,
            tags TEXT DEFAULT '[]',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        )
    """)
    conn.commit()
    return conn


# We'll add tools and resources below...


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

Nothing fancy here. We import FastMCP, set up a SQLite database that lives in ~/.notekeeper/notes.db, and write a helper function to get a database connection. The get_db() function creates the database and table automatically on first run, so there’s no setup step for the user.

Now let’s add the interesting parts.

Writing Tools: Actions Claude Can Take

Tools are the core of any MCP server. They’re functions that Claude can call to do things: create, read, update, delete, fetch, compute, whatever you need. If Part 2 was the theory of tools, this is where they become real.

Let’s write our first tool: saving a note.

@mcp.tool()
def save_note(title: str, content: str, tags: list[str] | None = None) -> str:
    """Save a new note to the local database.

    Args:
        title: A short title for the note.
        content: The full text content of the note.
        tags: Optional list of tags for categorization (e.g. ["python", "ideas"]).
    """
    conn = get_db()
    now = datetime.now(timezone.utc).isoformat()
    tags_json = json.dumps(tags or [])

    cursor = conn.execute(
        "INSERT INTO notes (title, content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
        (title, content, tags_json, now, now),
    )
    conn.commit()
    note_id = cursor.lastrowid
    conn.close()

    return f"Saved note #{note_id}: '{title}'"

This is a regular Python function with one decorator slapped on top. But there are three things doing a lot of heavy lifting here that are worth understanding.

1. Type Hints Are Your Schema

def save_note(title: str, content: str, tags: list[str] | None = None) -> str:

FastMCP reads these type hints and generates a JSON schema that tells Claude:

  • title is a required string
  • content is a required string
  • tags is an optional list of strings

If you skip the type hints, Claude won’t know what to pass in. It’s like giving someone a form with no labels. Always type your parameters.

2. Docstrings Are Your User Manual

"""Save a new note to the local database.

Args:
    title: A short title for the note.
    content: The full text content of the note.
    tags: Optional list of tags for categorization (e.g. ["python", "ideas"]).
"""

The docstring becomes the tool description that Claude sees. This is literally how Claude decides whether and how to use your tool. A vague docstring like “Save a note” gives Claude nothing to work with. A detailed one with Args: descriptions and examples means Claude will use it correctly on the first try.

Write them like you’re explaining the tool to a colleague who’s never seen your codebase.

3. Return Strings, Not Data Structures

Notice the tool returns a plain string: f"Saved note #{note_id}: '{title}'". MCP tools return text content that gets fed back into Claude’s context. Keep responses concise and informative. Claude doesn’t need a JSON blob here. It needs to know the note was saved and what ID it got.

What Other Tools Does NoteKeeper Need?

The save_note tool handles creation. For a complete notes server, you’ll also need:

  • search_notes(query: str): Search notes by title or content using SQL LIKE. This is the tool Claude will call most often when you ask “find my notes about X.”
  • list_notes_by_tag(tag: str): Filter notes by a specific tag. Useful for browsing categories like “ideas” or “python”.
  • delete_note(note_id: int): Delete a note by its numeric ID. Simple but essential.
  • update_note(note_id: int, title: str | None, content: str | None, tags: list[str] | None): Update specific fields of an existing note. The None defaults mean Claude only has to pass in what’s changing.

Each one follows the exact same pattern: @mcp.tool() decorator, typed parameters, detailed docstring, return a string. The search_notes tool is the most interesting because it formats multiple results into a readable list:

@mcp.tool()
def search_notes(query: str) -> str:
    """Search notes by title or content. Returns matching notes.

    Args:
        query: Text to search for in note titles and content.
    """
    conn = get_db()
    rows = conn.execute(
        "SELECT id, title, content, tags, created_at FROM notes "
        "WHERE title LIKE ? OR content LIKE ? ORDER BY created_at DESC",
        (f"%{query}%", f"%{query}%"),
    ).fetchall()
    conn.close()

    if not rows:
        return f"No notes found matching '{query}'."

    results = []
    for row in rows:
        tags = json.loads(row["tags"])
        tag_str = f" [{', '.join(tags)}]" if tags else ""
        results.append(f"#{row['id']} - {row['title']}{tag_str}\n{row['content']}")

    return f"Found {len(rows)} note(s):\n\n" + "\n\n---\n\n".join(results)

The rest are straightforward CRUD operations. Add all five tools to your server.py and you’ve got a fully functional notes backend that Claude can operate.

Writing Resources: Data Claude Can Read

Tools let Claude do things. Resources let Claude see things.

A resource is data exposed at a URI that the client can read at any time. Think of them like files in a filesystem. Claude can open them to get background context without calling a tool.

ToolsResources
PurposeActions that do somethingData that can be read
AnalogyButtons you pressFiles you open
ExamplesSave a note, delete a noteList of all notes, tag index
Triggered byClaude decides to call themClient can read them anytime
Side effectsYes (create, update, delete)No (read-only)

Here’s a resource that exposes all notes:

@mcp.resource("notes://all")
def get_all_notes() -> str:
    """Get all notes as a JSON list."""
    conn = get_db()
    rows = conn.execute(
        "SELECT id, title, content, tags, created_at, updated_at "
        "FROM notes ORDER BY created_at DESC"
    ).fetchall()
    conn.close()

    notes = []
    for row in rows:
        notes.append({
            "id": row["id"],
            "title": row["title"],
            "content": row["content"],
            "tags": json.loads(row["tags"]),
            "created_at": row["created_at"],
            "updated_at": row["updated_at"],
        })

    return json.dumps(notes, indent=2)

The key difference from a tool: resources take a URI string ("notes://all") instead of just a name. The URI scheme is up to you. I used notes:// but you could use db://, app://, whatever makes sense.

Resources return data as JSON, text, or any format the client can consume. Since there’s no user input (no parameters), they’re always read-only.

What Other Resources Does NoteKeeper Need?

Two more round out the server:

  • notes://tags: Returns a list of all unique tags used across notes. Useful for Claude to know what categories exist before searching.
  • notes://stats: Returns metadata about the database: total note count, number of unique tags, the database file path. Good for orientation.
@mcp.resource("notes://stats")
def get_stats() -> str:
    """Get statistics about the notes database."""
    conn = get_db()
    total = conn.execute("SELECT COUNT(*) as count FROM notes").fetchone()["count"]
    rows = conn.execute("SELECT tags FROM notes").fetchall()
    conn.close()

    all_tags = set()
    for row in rows:
        tags = json.loads(row["tags"])
        all_tags.update(tags)

    stats = {
        "total_notes": total,
        "unique_tags": len(all_tags),
        "database_path": str(DB_PATH),
    }
    return json.dumps(stats, indent=2)

In practice, most of what you’ll build will be tools. Resources are great for giving Claude background context it can reference, like “here’s the current state of things” before it decides which tools to call. Add all three resources to your server.py and NoteKeeper is complete.

Testing Your Server

With MCP Inspector

Fire up the Inspector again:

npx @modelcontextprotocol/inspector uv run server.py

Open http://localhost:6274 in your browser. You should see:

Tools tab: All five tools listed: save_note, search_notes, list_notes_by_tag, delete_note, update_note.

Resources tab: Three resources: notes://all, notes://tags, notes://stats.

Try it out:

  1. Click save_note, fill in a title (“MCP Tutorial Notes”), content (“Building my first MCP server”), and tags (["mcp", "tutorial"]), then hit Run
  2. Click search_notes, search for “MCP”, and verify your note comes back
  3. Check notes://stats in the Resources tab to see the count go up

If everything works in the Inspector, your server is solid.

From the Command Line

You can also test directly using the MCP CLI:

# Start an interactive session
uv run mcp dev server.py

This gives you a REPL where you can call tools and read resources directly.

Connecting to Claude Desktop

This is the fun part. Let’s wire NoteKeeper up to Claude so you can actually use it in conversation.

Open your Claude Desktop config file:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

Windows: %AppData%\Claude\claude_desktop_config.json

Add your server to the config:

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

Replace /absolute/path/to/notekeeper-mcp with the actual path to your project directory.

Now fully quit Claude Desktop (Cmd+Q on Mac, not just close the window) and reopen it.

You should see the hammer icon in the chat input area. Click it and you’ll see your NoteKeeper tools listed. If you don’t see them, check the logs:

# macOS log locations
cat ~/Library/Logs/Claude/mcp.log
cat ~/Library/Logs/Claude/mcp-server-notekeeper.log

Common issues:

  • Wrong path: Make sure the path in your config is absolute, not relative
  • uv not found: Claude Desktop might not have uv in its PATH. Try using the full path: /Users/yourname/.local/bin/uv
  • Python version: Make sure your project is using Python 3.10+

Once it’s connected, try chatting with Claude:

“Save a note titled ‘MCP Server Ideas’ with the content ‘Build a weather server, a Spotify controller, and a bookmark manager’ and tag it with ‘ideas’ and ‘projects’”

Claude will call your save_note tool, ask for your permission, and save the note. You can verify by asking:

“Show me all my notes”

And Claude will call search_notes or read the notes://all resource to show you what’s stored.

Connecting to Claude Code

If you use Claude Code (and you should), adding your MCP server is even easier:

claude mcp add --transport stdio notekeeper -- uv --directory /absolute/path/to/notekeeper-mcp run server.py

That’s it. One command. You can verify it’s connected:

claude mcp list

Or use the /mcp command inside a Claude Code session to check the server status.

Claude Code also supports a project-scoped .mcp.json file that you can commit to your repo. Create it at the root of any project:

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

This way, anyone who clones your project and uses Claude Code will automatically have access to the MCP server.

Going Further: Adding a Web API Tool

NoteKeeper stores local notes. But what if you want your MCP server to talk to external APIs? Let’s add a tool that fetches a URL and saves it as a note, like a read-later service.

First, add httpx to your project:

uv add httpx

Then add this tool to your server.py:

import httpx

@mcp.tool()
async def save_from_url(url: str, tags: list[str] | None = None) -> str:
    """Fetch a webpage and save its content as a note.

    Args:
        url: The URL to fetch and save.
        tags: Optional tags to categorize the saved page.
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(url, follow_redirects=True)
        response.raise_for_status()

    # Use the page title or URL as the note title
    title = url.split("//")[-1].split("/")[0]  # domain name as fallback

    conn = get_db()
    now = datetime.now(timezone.utc).isoformat()
    tags_json = json.dumps(tags or ["saved-page"])

    cursor = conn.execute(
        "INSERT INTO notes (title, content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
        (f"Saved: {title}", response.text[:5000], tags_json, now, now),
    )
    conn.commit()
    note_id = cursor.lastrowid
    conn.close()

    return f"Saved page as note #{note_id}: '{title}' ({len(response.text)} chars)"

Notice this tool is async. FastMCP handles both sync and async tools. Use async when you’re doing I/O-heavy work like HTTP requests.

Now you can tell Claude: “Save that article at [some URL] for me to read later” and it’ll fetch the page and store it in your notes database.

Python vs TypeScript: Which SDK Should You Use?

The MCP spec has official SDKs for both Python and TypeScript (plus community SDKs for Go, Rust, Java, C#, and Kotlin). Here’s how the two main ones compare:

Python (FastMCP)TypeScript
Packagemcp on PyPI@modelcontextprotocol/sdk on npm
API StyleDecorators (@mcp.tool())Method calls (server.tool())
Schema DefinitionType hints + docstringsZod schemas
Learning CurveLower (feels like Flask)Moderate (need to know Zod)
Async SupportBoth sync and asyncAsync only
Best ForQuick prototyping, data/ML toolsWeb-focused servers, existing TS projects

Here’s the same greet tool in TypeScript for comparison:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "hello-world",
  version: "1.0.0",
});

server.tool(
  "greet",
  "Say hello to someone",
  { name: z.string().describe("The person's name") },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}! Welcome to MCP.` }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

It’s a bit more verbose because of the Zod schema definition and the explicit content structure. Python’s FastMCP infers all of that from your type hints.

My recommendation: start with Python unless you’re already deep in a TypeScript project. FastMCP is the fastest path from idea to working server. You can always rewrite in TypeScript later if you need it for a specific deployment target.

Tips and Gotchas

After building several MCP servers myself (including the WordPress and Twitter ones I mentioned in Part 3), here’s what I’ve learned:

Don’t print to stdout. Stdio transport uses stdout for JSON-RPC messages. If your code does print("debugging..."), it’ll corrupt the protocol stream and crash. Use stderr for logging:

import sys
print("debug info", file=sys.stderr)

Or better, use Python’s logging module configured to write to stderr.

Keep tool responses concise. Claude has a context window. If your tool returns 50,000 characters of raw data, you’re wasting tokens. Summarize, truncate, or paginate large responses.

Write detailed docstrings. The docstring is how Claude understands your tool. A one-liner like “Get notes” is way less useful than “Search notes by title or content. Returns matching notes with their IDs, titles, tags, and content.”

Handle errors gracefully. Don’t let your server crash on bad input. Return a helpful error message instead:

@mcp.tool()
def get_note(note_id: int) -> str:
    """Get a specific note by ID."""
    conn = get_db()
    row = conn.execute("SELECT * FROM notes WHERE id = ?", (note_id,)).fetchone()
    conn.close()

    if not row:
        return f"Note #{note_id} not found. Use search_notes to find the right ID."

    tags = json.loads(row["tags"])
    tag_str = f"\nTags: {', '.join(tags)}" if tags else ""
    return f"#{row['id']} - {row['title']}{tag_str}\n\n{row['content']}"

Test with the Inspector first. Always verify your tools work in the MCP Inspector before connecting to Claude Desktop. It saves a lot of restarting.

Use the mcp dev command for fast iteration. While developing, uv run mcp dev server.py gives you a REPL where you can test tools without restarting anything.

Ideas for Your Next MCP Server

Now that you know how to build one, here are some servers worth building:

  • Bookmark manager: Save and categorize URLs with automatic metadata extraction
  • Local file indexer: Let Claude search through your documents, PDFs, and code
  • Calendar bridge: Connect to Google Calendar or Apple Calendar for scheduling
  • Database explorer: Give Claude read-only access to a Postgres or MySQL database
  • Spotify controller: Play, pause, skip tracks, and create playlists
  • Home automation: Connect to Home Assistant or similar platforms
  • Wordpress: Publish to your blog!

The pattern is always the same: find something you do manually, wrap it in a Python function, add @mcp.tool(), and let Claude handle it.

What’s Next

You’ve gone from “what is MCP” to “I can build my own MCP server” in four posts. That’s the full journey.

If you’re looking to go deeper, check out:

Now go build something. And when you do, drop your email below so I can check it out.

Related Posts

Read Cartesia AI Tutorial: Build an AI Podcast Generator
Hero image for Cartesia AI Tutorial: Build an AI Podcast Generator
tutorial ai-agents python

Cartesia AI Tutorial: Build an AI Podcast Generator

Convert any blog post into a podcast with natural voice, emotional range, and realistic pacing—all powered by Cartesia's ultra-realistic TTS.

11 min