Pranav Srivastava
Learning tracks
publishedBeginner to Intermediate10 chapters

MCP for Practical Builders

Learn Model Context Protocol from scratch — how it works, how to build servers in Python, and how to give AI assistants safe access to your files, databases, and APIs. Ends with a complete hands-on project.

MCPPythonClaudeAI ToolsFastMCPSecurityAgents

Course overview

Imagine asking Claude to check your notes, summarise a document from your disk, and save a draft — all in one go. By default, Claude cannot do any of that. It has no access to your computer.

MCP (Model Context Protocol) is the bridge that changes this. It is an open standard that lets AI assistants connect to your tools and data in a structured, safe, and predictable way.

By the end of this course you will know how MCP works, you will have built three working MCP servers, and you will have a complete Personal Content Assistant that Claude can use in real sessions.

No prior MCP experience needed. You need Python basics and a working Python 3.10+ environment.


18 min

The Problem MCP Solves

What you will learn
  • Understand why AI assistants are limited without external tools
  • Learn what the N×M integration problem is and how MCP fixes it
  • Understand two useful analogies: USB-C and the Language Server Protocol

AI assistants live in a bubble

When you use Claude, GPT-4, or Gemini in a chat window, the AI can only work with what is inside the conversation. It cannot open files, query your database, check your calendar, or call your internal APIs. Every time you need the AI to do something with real data, you copy-paste it in manually.

This feels like a minor inconvenience at first. Then it compounds:

  • You have 50 notes you want summarised — you cannot paste them all
  • You want the AI to check a live price from your system — it has no way in
  • You want an agent to run a multi-step workflow on your machine — it is sealed out

The old approach: N × M integrations

Before MCP, every AI application had to build its own custom connections to every tool it needed.

The N×M Integration Problem — before MCP
  ┌──────────────┐     custom code     ┌─────────────┐
  │  AI App 1    │────────────────────►│  File System│
  │              │────────────────────►│  Database   │
  │              │────────────────────►│  Slack      │
  └──────────────┘                     │  Calendar   │
                                       └─────────────┘
  ┌──────────────┐     custom code
  │  AI App 2    │────────────────────► (rebuilds all 4)
  └──────────────┘

  ┌──────────────┐     custom code
  │  AI App 3    │────────────────────► (rebuilds all 4)
  └──────────────┘

  Total: 3 apps × 4 tools = 12 separate custom integrations
3 AI apps × 4 tools = 12 separate integrations, each built and maintained differently

If you have 3 AI apps and 4 tools, you need 12 custom integrations. Every new tool means updating every app. Every new app means wiring up every tool.

MCP: N + M instead of N × M

MCP fixes this by defining a single protocol that any AI client can use to talk to any MCP server.

With MCP — one protocol connects everything
  ┌──────────────┐                     ┌──────────────────┐
  │  Claude      │  MCP protocol       │  File Server     │
  │  Desktop     │◄───────────────────►│  (your Python)   │
  └──────────────┘                     └──────────────────┘
                                       ┌──────────────────┐
  ┌──────────────┐                     │  DB Server       │
  │  Claude Code │◄───────────────────►│  (your Python)   │
  └──────────────┘  MCP protocol       └──────────────────┘
                                       ┌──────────────────┐
  ┌──────────────┐                     │  Slack Server    │
  │  Your App    │◄───────────────────►│  (any language)  │
  └──────────────┘                     └──────────────────┘

  Any MCP client ↔ Any MCP server. Build once, use everywhere.
Each tool is wrapped once. Every MCP-compatible AI client can use it instantly.

Who made MCP and who uses it?

MCP was created by Anthropic and released as an open standard in November 2024. It is now supported by Claude Desktop, Claude Code, and many third-party AI applications. There are already MCP servers for GitHub, Google Drive, Notion, Slack, databases, web browsers, and hundreds of other tools.

Chapter summary
  • AI assistants cannot access your local files, databases, or APIs without extra setup
  • Before MCP, each AI app needed custom code for each tool (N×M problem)
  • MCP defines one protocol — write a server once, any compatible AI can use it
  • MCP was created by Anthropic in 2024 and is an open standard
Check your understanding
  1. What is the N×M integration problem?
  2. Name one real-world analogy for what MCP does.
  3. Who created MCP, and in what year?

210 min

How MCP Works

What you will learn
  • Understand the three-part architecture: Host, Client, Server
  • Learn the three MCP primitives: Tools, Resources, Prompts
  • Understand JSON-RPC communication and the two transport options

The three components

MCP Architecture — Host, Client, Server
  ┌────────────────────────────────────────────────────┐
  │  Host (e.g. Claude Desktop)                        │
  │                                                    │
  │  ┌────────────────┐    ┌────────────────┐          │
  │  │  MCP Client 1  │    │  MCP Client 2  │  ...     │
  │  └───────┬────────┘    └───────┬────────┘          │
  └──────────┼─────────────────────┼───────────────────┘
             │  MCP protocol       │  MCP protocol
             ▼                     ▼
  ┌──────────────────┐   ┌──────────────────┐
  │  Your File       │   │  Your Database   │
  │  MCP Server      │   │  MCP Server      │
  │  (Python)        │   │  (Python)        │
  └──────────────────┘   └──────────────────┘
One host can have multiple clients, each connected to one server

Host — the AI application the user interacts with. Examples: Claude Desktop, Claude Code, a custom AI app you build.

Client — the MCP client lives inside the host. It manages the connection to exactly one MCP server. If you have three MCP servers connected, the host creates three clients.

Server — the thing you build. A separate process (Python, TypeScript, or any language) that exposes tools, resources, and prompts to the AI.

The three primitives

Every MCP server can expose up to three types of things:

The Three MCP Primitives
  ┌─────────────────────────────────────────────────────┐
  │  Primitive   │  What it is         │  When to use   │
  ├─────────────────────────────────────────────────────┤
  │  Tools       │  Functions the AI   │  When you want │
  │              │  can call           │  the AI to DO  │
  │              │  e.g. read_file()   │  something     │
  ├─────────────────────────────────────────────────────┤
  │  Resources   │  Data sources the   │  When you want │
  │              │  AI can read        │  the AI to READ│
  │              │  e.g. notes://list  │  something     │
  ├─────────────────────────────────────────────────────┤
  │  Prompts     │  Reusable prompt    │  When you want │
  │              │  templates          │  standard ways │
  │              │  e.g. /summarise    │  to ask things │
  └─────────────────────────────────────────────────────┘
Tools are the most important — start there

How communication works

MCP uses JSON-RPC 2.0 — a simple message format. Every message is JSON. The AI sends a request, the server sends a response.

MCP Communication Flow — request / response pairs
  AI Client                           MCP Server
      │                                    │
      │── initialize ──────────────────────►│
      │◄─ capabilities (tools list) ────────│
      │                                    │
      │── tools/list ──────────────────────►│
      │◄─ [{name, description, schema}] ───│
      │                                    │
      │── tools/call {read_file, args} ────►│
      │◄─ {content: "file contents..."} ───│
      │                                    │
      │── tools/call {save_note, args} ────►│
      │◄─ {content: "Saved."} ─────────────│

Transport: how they connect

stdio — the host launches your MCP server as a child process and communicates through stdin/stdout. Fast, simple, no network needed. This is how local MCP servers work and what you will use in this course.

Streamable HTTP — the server runs as an HTTP endpoint. Used for remote MCP servers deployed to the cloud.

Chapter summary
  • A Host contains one Client per connected MCP Server
  • Three primitives: Tools (actions), Resources (data), Prompts (templates)
  • Communication uses JSON-RPC 2.0 — simple request/response pairs
  • Local servers use stdio transport; remote servers use HTTP
Check your understanding
  1. What is the difference between a Host, a Client, and a Server?
  2. You want to give Claude the ability to send an email — which primitive do you use?
  3. What does stdio transport mean?

35 min

Setting Up Your Environment

What you will learn
  • Install the Python MCP SDK using pip or uv
  • Install Claude Desktop for testing your servers
  • Verify your setup with a minimal "hello" server

What you need

  • Python 3.10 or newer (python --version to check)
  • pip or uv (package manager)
  • Claude Desktop (free) — claude.ai/download

Install the MCP SDK

pip install mcp

Or if you use uv:

uv add mcp

Verify with a minimal server

Create a file called hello_mcp.py:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Hello Server")

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

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

Run it:

python hello_mcp.py

If the server starts and waits silently (no output, no error), your setup is correct. Press Ctrl+C to stop.


415 min

Your First MCP Server

What you will learn
  • Build a real MCP server with multiple tools
  • Connect it to Claude Desktop via config file
  • Understand the anatomy of a well-written tool

Build a simple multi-tool server

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("My First Server")


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


@mcp.tool()
def reverse_text(text: str) -> str:
    """Reverse a piece of text character by character."""
    return text[::-1]


@mcp.tool()
def word_count(text: str) -> dict:
    """
    Count words, characters, and lines in a piece of text.
    Returns a dict with keys: words, characters, lines.
    """
    return {
        "words": len(text.split()),
        "characters": len(text),
        "lines": len(text.splitlines()),
    }


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

Connect it to Claude Desktop

Open Claude Desktop → Settings → Developer → Edit Config. This opens claude_desktop_config.json. Add:

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

Restart Claude Desktop. You should see a hammer icon (🔨) near the chat input — this means your MCP server is connected and its tools are available.

Try asking Claude: "Add 17 and 25" or "How many words are in 'The quick brown fox'?"

How it works end to end

What happens when Claude uses your tool
  You type: "Add 17 and 25"
       │
       ▼
  Claude thinks:  I should use the add_numbers tool
       │
       ▼
  Claude sends:   tools/call → add_numbers({a: 17, b: 25})
       │
       ▼
  Your server:    runs add_numbers(17, 25) → returns 42
       │
       ▼
  Claude replies: "17 + 25 = 42"

  Claude never runs the code. It only requests it.
  YOU run it. This is by design — you stay in control.

The anatomy of a tool

@mcp.tool()
def your_tool(
    param: str,          # type annotation is required
    optional: int = 10,  # defaults work
) -> dict:               # return type is used by the AI
    """
    This description is what Claude reads to decide when to use this tool.
    Be specific. Say what the inputs are and what it returns.

    Bad:  "Does something with text"
    Good: "Count words and characters in a string.
           Returns a dict with 'words' and 'characters' keys."
    """
    return {"words": len(param.split()), "characters": len(param)}
Chapter summary
  • FastMCP lets you define tools with the @mcp.tool() decorator
  • Claude Desktop picks up servers from claude_desktop_config.json
  • Claude requests tool calls — your server runs the code — Claude sees the result
  • The docstring is critical: write exactly what the tool does, inputs, and outputs
Check your understanding
  1. Where does Claude read to decide when to use a tool?
  2. What file tells Claude Desktop which MCP servers to connect to?
  3. Does Claude run your tool code directly? Why or why not?

520 min

File System Tools

What you will learn
  • Build a file server with read, write, list, and delete tools
  • Understand and implement path traversal prevention
  • Know why safe_path() is the most important function in any file server

Why you need safety controls

Without them, a poorly designed file server could allow Claude (or a prompt injection attack) to read your passwords, SSH keys, or system files. The fix is to confine all file operations to a specific allowed directory and verify every path before touching it.

import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("File Server")

WORKSPACE = Path.home() / "ai-workspace"
WORKSPACE.mkdir(exist_ok=True)


def safe_path(relative: str) -> Path:
    """
    Resolve a path and verify it is inside WORKSPACE.
    Raises ValueError if the path tries to escape (e.g. using ../).
    """
    resolved = (WORKSPACE / relative).resolve()
    if not str(resolved).startswith(str(WORKSPACE.resolve())):
        raise ValueError(f"Access denied: path is outside the workspace")
    return resolved


@mcp.tool()
def read_file(path: str) -> str:
    """
    Read a file from the workspace. Path is relative to the workspace root.
    Example: read_file("notes/todo.txt")
    """
    try:
        return safe_path(path).read_text(encoding="utf-8")
    except ValueError as e:
        return f"Error: {e}"
    except FileNotFoundError:
        return f"Error: File not found — {path}"


@mcp.tool()
def write_file(path: str, content: str) -> str:
    """
    Write content to a file, creating the file and any missing directories.
    Path is relative to the workspace root.
    """
    try:
        p = safe_path(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        return f"Written {len(content)} characters to {path}"
    except ValueError as e:
        return f"Error: {e}"


@mcp.tool()
def list_files(directory: str = "") -> list[str]:
    """
    List files in a workspace directory. Leave empty for the workspace root.
    Returns relative paths from the workspace root.
    """
    try:
        target = safe_path(directory) if directory else WORKSPACE
        if not target.is_dir():
            return [f"Error: {directory} is not a directory"]
        return sorted(
            str(p.relative_to(WORKSPACE))
            for p in target.rglob("*")
            if p.is_file()
        )
    except ValueError as e:
        return [f"Error: {e}"]


@mcp.tool()
def delete_file(path: str) -> str:
    """Delete a file from the workspace. Cannot delete directories."""
    try:
        p = safe_path(path)
        if not p.exists():
            return f"Error: Not found — {path}"
        if p.is_dir():
            return "Error: Cannot delete a directory with this tool"
        p.unlink()
        return f"Deleted: {path}"
    except ValueError as e:
        return f"Error: {e}"


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

The path traversal attack — and how safe_path stops it

Path Traversal Attack — without safe_path
  User (or prompt injection) sends:   path = "../../etc/passwd"
       │
       ▼
  Without safe_path:
  WORKSPACE / "../../etc/passwd"
    resolves to: /etc/passwd    ← system file! reads passwords

  With safe_path:
  resolved = /etc/passwd
  starts_with(WORKSPACE)?   NO
  → raises ValueError("Access denied")   ✓ protected

The .resolve() call expands all ../ segments before we check. That is why it works. Without .resolve(), a string comparison would miss encoded or relative path tricks.

Chapter summary
  • Always confine file operations to a WORKSPACE directory
  • safe_path() resolves and checks every path before use — never skip this
  • Return "Error: ..." strings from tools instead of raising exceptions
  • Create missing directories with p.parent.mkdir(parents=True, exist_ok=True)
Check your understanding
  1. What does a path traversal attack look like?
  2. Why does safe_path use .resolve() before comparing paths?
  3. What happens if you raise an exception in a tool instead of returning an error string?

620 min

Database Tools

What you will learn
  • Build a SQLite MCP server with query and insert tools
  • Understand SQL injection and why parameterized queries prevent it
  • Know how to give Claude read-only vs read-write access

SQL injection — the single most important thing to understand

Never build SQL queries by joining strings:

# NEVER DO THIS — SQL injection vulnerability
query = f"SELECT * FROM users WHERE name = '{name}'"

If name is '; DROP TABLE users; --, your entire database is gone. Always use parameterized queries:

# ALWAYS DO THIS — safe
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))

The database driver handles the escaping. No injection possible.

import sqlite3
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Database Server")

DB_PATH = Path.home() / "ai-workspace" / "data.db"


def get_conn():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row  # returns dicts instead of tuples
    return conn


@mcp.tool()
def list_tables() -> list[str]:
    """List all tables in the database."""
    with get_conn() as conn:
        rows = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
        ).fetchall()
    return [row["name"] for row in rows]


@mcp.tool()
def describe_table(table_name: str) -> list[dict]:
    """Return column names and types for a table."""
    # Validate: only alphanumeric + underscore allowed in table names
    if not table_name.replace("_", "").isalnum():
        return [{"error": "Invalid table name"}]
    with get_conn() as conn:
        rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
    return [{"column": r["name"], "type": r["type"]} for r in rows]


@mcp.tool()
def query_database(sql: str, limit: int = 50) -> list[dict]:
    """
    Run a SELECT query and return up to `limit` rows.
    Only SELECT statements are allowed — no INSERT, UPDATE, DELETE, or DROP.
    Example: query_database("SELECT * FROM notes WHERE title LIKE '%MCP%'")
    """
    clean = sql.strip().upper()
    if not clean.startswith("SELECT"):
        return [{"error": "Only SELECT queries are allowed"}]
    if ";" in sql:
        return [{"error": "Multiple statements not allowed"}]

    with get_conn() as conn:
        try:
            rows = conn.execute(sql).fetchmany(limit)
            return [dict(r) for r in rows]
        except sqlite3.Error as e:
            return [{"error": str(e)}]


@mcp.tool()
def insert_record(table_name: str, data: dict) -> str:
    """
    Insert a new record into a table.
    Example: insert_record("notes", {"title": "My note", "content": "Hello"})
    """
    if not table_name.replace("_", "").isalnum():
        return "Error: Invalid table name"

    cols = ", ".join(data.keys())
    placeholders = ", ".join(["?" for _ in data])
    with get_conn() as conn:
        try:
            conn.execute(
                f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders})",
                list(data.values())
            )
            conn.commit()
            return f"Inserted 1 record into {table_name}"
        except sqlite3.Error as e:
            return f"Error: {e}"


# Create a demo table on first run
def seed():
    with get_conn() as conn:
        conn.execute("""CREATE TABLE IF NOT EXISTS notes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            content TEXT,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )""")
        if conn.execute("SELECT COUNT(*) FROM notes").fetchone()[0] == 0:
            conn.executemany(
                "INSERT INTO notes (title, content) VALUES (?, ?)",
                [("MCP Idea", "Build a calendar MCP server"),
                 ("Blog post", "Write about AI observability"),
                 ("Project idea", "Multi-agent pipeline")]
            )
            conn.commit()

seed()

if __name__ == "__main__":
    mcp.run()
Chapter summary
  • Always use parameterized queries — cursor.execute("... WHERE x = ?", (val,))
  • Validate table names manually since they cannot be parameterized
  • Check the SQL verb (SELECT vs INSERT) to enforce read-only or read-write policies
  • row_factory = sqlite3.Row makes results return as dicts, not raw tuples
Check your understanding
  1. What would happen without parameterized queries if a user sent '; DROP TABLE notes; -- as input?
  2. How does query_database prevent writes?
  3. Why does describe_table validate the table name manually before using it in SQL?

715 min

Web and API Tools

What you will learn
  • Build tools that fetch webpage content and call REST APIs
  • Validate URLs before making requests
  • Handle timeouts, errors, and content size limits safely
import httpx
from bs4 import BeautifulSoup
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Web Tools")


@mcp.tool()
async def fetch_webpage(url: str) -> str:
    """
    Fetch the readable text content of a webpage.
    Returns cleaned text (no HTML tags, scripts, or styles).
    Works best on public pages. Does not work behind login walls.
    """
    if not url.startswith(("http://", "https://")):
        return "Error: URL must start with http:// or https://"

    try:
        async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
            r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
            r.raise_for_status()

        soup = BeautifulSoup(r.text, "html.parser")
        for tag in soup(["script", "style", "nav", "footer", "header"]):
            tag.decompose()
        text = soup.get_text(separator="\n", strip=True)
        return text[:8000]  # prevent context overflow

    except httpx.HTTPStatusError as e:
        return f"HTTP {e.response.status_code}: {url}"
    except httpx.RequestError as e:
        return f"Network error: {e}"


@mcp.tool()
async def call_api(url: str, method: str = "GET", headers: dict | None = None, body: dict | None = None) -> dict:
    """
    Make an HTTP API request. Returns status code and response body.
    method: GET, POST, PUT, PATCH, DELETE
    headers: optional dict (for auth tokens, content-type, etc.)
    body: optional dict sent as JSON (for POST/PUT/PATCH)
    """
    allowed = {"GET", "POST", "PUT", "PATCH", "DELETE"}
    if method.upper() not in allowed:
        return {"error": f"method must be one of {allowed}"}
    if not url.startswith(("http://", "https://")):
        return {"error": "URL must start with http:// or https://"}

    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            r = await client.request(method.upper(), url, headers=headers or {}, json=body)
        try:
            data = r.json()
        except Exception:
            data = r.text
        return {"status_code": r.status_code, "ok": r.is_success, "data": data}
    except httpx.RequestError as e:
        return {"error": str(e)}


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

Install dependencies:

pip install httpx beautifulsoup4

812 min

Resources and Prompts

What you will learn
  • Understand when to use Resources instead of Tools
  • Build a resource endpoint that exposes data via URI
  • Build a Prompt template for consistent AI behaviour

Resources: expose data as URIs

Resources are read-only data sources with URI addresses (like notes://list or docs://readme). Unlike tools, the AI does not call a resource to get a result — it reads it like a file.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Resources Demo")

notes = {
    "project-ideas": "1. Calendar MCP server\n2. AI blog writer\n3. RAG over notes",
    "daily-log": "Finished Chapter 7. Starting Chapter 8.",
}


@mcp.resource("notes://list")
def list_notes() -> str:
    """List all note IDs available."""
    return "\n".join(f"notes://{k}" for k in notes)


@mcp.resource("notes://{note_id}")
def get_note(note_id: str) -> str:
    """Get a specific note by its ID. URI: notes://project-ideas"""
    return notes.get(note_id, f"Note not found: {note_id}")

Prompts: reusable prompt templates

from mcp.types import PromptMessage, TextContent

@mcp.prompt()
def summarise(document: str, style: str = "bullet") -> list[PromptMessage]:
    """
    Template for summarising a document.
    style: 'bullet' (list of points) or 'paragraph' (prose)
    """
    instruction = (
        "as a bulleted list of key points" if style == "bullet"
        else "as two clear paragraphs"
    )
    return [
        PromptMessage(
            role="user",
            content=TextContent(
                type="text",
                text=f"Summarise the following document {instruction}:\n\n{document}"
            )
        )
    ]

In Claude Desktop, prompts are invocable from the / command menu.

Chapter summary
  • Resources expose read-only data via URI patterns (e.g. notes://{id})
  • Prompts are reusable instruction templates invoked by users or apps
  • When in doubt, use Tools — they cover 90% of use cases

912 min

Security Patterns

What you will learn
  • Know the 6 security rules for MCP servers
  • Implement a logging decorator for every tool call
  • Understand why shell access must never be exposed publicly

The 6 rules

RuleWhat it means
1. Validate pathsAlways use safe_path() — prevent directory traversal
2. Parameterize SQLNever concatenate user input into SQL strings
3. Whitelist inputsDefine what is allowed; do not try to block what is not
4. No public shellNever expose subprocess.run(cmd, shell=True) over the internet
5. Log everythingEvery tool call needs a record: input, output, timestamp
6. Set limitsTimeouts on web calls, size limits on file reads, row limits on queries

A logging decorator

Add this to any server and wrap your tools with it:

import logging
from functools import wraps

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("mcp-server.log"),
        logging.StreamHandler(),
    ],
)
logger = logging.getLogger(__name__)


def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logger.info(f"→ {func.__name__} | args={args} kwargs={kwargs}")
        try:
            result = func(*args, **kwargs)
            logger.info(f"✓ {func.__name__} | result_size={len(str(result))}")
            return result
        except Exception as e:
            logger.error(f"✗ {func.__name__} | error={e}")
            raise
    return wrapper


# Usage:
# @mcp.tool()
# @log_call
# def read_file(path: str) -> str:
#     ...

1030 min

Hands-on Project: Personal Content Assistant

What you will learn
  • Combine file, database, and web tools in one server
  • Connect it to Claude Desktop and verify multi-step tool use
  • Have a working MCP server you can keep improving

This final chapter combines everything into one production-ready server.

"""
personal_assistant.py — Complete Personal Content Assistant MCP server.
Gives Claude access to your notes, content database, and the web.

Install:  pip install mcp httpx beautifulsoup4
Run:      python personal_assistant.py
Connect:  add to claude_desktop_config.json
"""

import logging
import sqlite3
from pathlib import Path

import httpx
from bs4 import BeautifulSoup
from mcp.server.fastmcp import FastMCP

# ─── Config ───────────────────────────────────────────────────────────────────

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler("assistant.log"), logging.StreamHandler()],
)
logger = logging.getLogger(__name__)

WORKSPACE = Path.home() / "ai-workspace"
WORKSPACE.mkdir(exist_ok=True)
DB_PATH = WORKSPACE / "content.db"

mcp = FastMCP("Personal Content Assistant")


# ─── Safety ───────────────────────────────────────────────────────────────────

def safe_path(rel: str) -> Path:
    resolved = (WORKSPACE / rel).resolve()
    if not str(resolved).startswith(str(WORKSPACE.resolve())):
        raise ValueError("Path is outside the workspace")
    return resolved


# ─── Database ─────────────────────────────────────────────────────────────────

def db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    with db() as conn:
        conn.execute("""CREATE TABLE IF NOT EXISTS drafts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL, content TEXT,
            status TEXT DEFAULT 'draft',
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )""")
        conn.commit()

init_db()


# ─── File tools ───────────────────────────────────────────────────────────────

@mcp.tool()
def read_note(path: str) -> str:
    """Read a note file. Path is relative to the workspace."""
    logger.info(f"read_note: {path}")
    try:
        return safe_path(path).read_text(encoding="utf-8")
    except (FileNotFoundError, ValueError) as e:
        return f"Error: {e}"


@mcp.tool()
def write_note(path: str, content: str) -> str:
    """Write or overwrite a note file in the workspace."""
    logger.info(f"write_note: {path}")
    try:
        p = safe_path(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        return f"Saved {len(content)} chars to {path}"
    except ValueError as e:
        return f"Error: {e}"


@mcp.tool()
def list_notes() -> list[str]:
    """List all note files (*.md and *.txt) in the workspace."""
    return sorted(
        str(p.relative_to(WORKSPACE))
        for p in WORKSPACE.rglob("*")
        if p.is_file() and p.suffix in (".md", ".txt")
    )


# ─── Database tools ───────────────────────────────────────────────────────────

@mcp.tool()
def list_drafts(status: str = "all") -> list[dict]:
    """List blog drafts. Filter by status: draft, ready, published, or all."""
    logger.info(f"list_drafts: status={status}")
    with db() as conn:
        if status == "all":
            rows = conn.execute("SELECT id, title, status, updated_at FROM drafts ORDER BY updated_at DESC").fetchall()
        else:
            rows = conn.execute("SELECT id, title, status, updated_at FROM drafts WHERE status=? ORDER BY updated_at DESC", (status,)).fetchall()
    return [dict(r) for r in rows]


@mcp.tool()
def save_draft(title: str, content: str, status: str = "draft") -> str:
    """Save a new blog draft. status: draft, ready, or published."""
    logger.info(f"save_draft: {title}")
    with db() as conn:
        conn.execute("INSERT INTO drafts (title, content, status) VALUES (?, ?, ?)", (title, content, status))
        conn.commit()
    return f"Saved draft: '{title}' ({status})"


@mcp.tool()
def get_draft(draft_id: int) -> dict:
    """Get the full content of a draft by its ID."""
    with db() as conn:
        row = conn.execute("SELECT * FROM drafts WHERE id=?", (draft_id,)).fetchone()
    return dict(row) if row else {"error": f"Draft {draft_id} not found"}


# ─── Web tools ────────────────────────────────────────────────────────────────

@mcp.tool()
async def research_url(url: str) -> str:
    """Fetch and extract readable text from a webpage. Use this to research topics."""
    logger.info(f"research_url: {url}")
    if not url.startswith(("http://", "https://")):
        return "Error: URL must start with http:// or https://"
    try:
        async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
            r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
            r.raise_for_status()
        soup = BeautifulSoup(r.text, "html.parser")
        for t in soup(["script", "style", "nav", "footer"]):
            t.decompose()
        return soup.get_text(separator="\n", strip=True)[:8000]
    except Exception as e:
        return f"Error: {e}"


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

Claude Desktop config

{
  "mcpServers": {
    "personal-assistant": {
      "command": "python",
      "args": ["/absolute/path/to/personal_assistant.py"]
    }
  }
}

Try this prompt in Claude Desktop

"Research the topic of 'AI agents' from the web, write the key points as a note called research/ai-agents.md, and then create a blog draft outline titled 'What are AI Agents?' that I can fill in later."

Claude will: call research_url → call write_note → call save_draft. That is a three-tool sequence from one natural language instruction, with your server doing all the actual work.

Chapter summary
  • Combine tools from earlier chapters into a single, focused server
  • A well-named, well-described server is more useful than a feature-packed one
  • Every tool call is logged in assistant.log for debugging
  • Extend this server over time: add a calendar tool, a GitHub tool, an email tool

What to build next

IdeaTools to add
Calendar assistantRead/write Google Calendar via API
Email assistantRead inbox, draft replies via Gmail API
GitHub assistantCreate issues, read PRs (official GitHub MCP server already exists)
Semantic note searchEmbed notes with a vector DB, add a search_notes(query) tool
Slack assistantPost messages, read channels via Slack API

The pattern is always the same: wrap the system in tools, validate inputs, log calls, keep scope tight.


Quick reference

TermMeaning
MCPModel Context Protocol — open standard for AI tool connections
HostAI app the user talks to (Claude Desktop, Claude Code)
ClientManages one server connection inside the host
ServerYour Python process that exposes tools/resources/prompts
ToolA function the AI can call
ResourceRead-only data exposed via URI
PromptReusable prompt template
FastMCPHigh-level Python framework for building MCP servers
safe_pathPattern for preventing directory traversal attacks
Parameterized querySQL with ? placeholders — always use instead of f-strings

Questions? Email hello@pranavsrivastava.com

All tracksQuestions? Get in touch →