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.
The Problem MCP Solves
- 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.
┌──────────────┐ 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
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.
┌──────────────┐ ┌──────────────────┐
│ 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.
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.
- 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
- What is the N×M integration problem?
- Name one real-world analogy for what MCP does.
- Who created MCP, and in what year?
How MCP Works
- 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
┌────────────────────────────────────────────────────┐
│ 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) │
└──────────────────┘ └──────────────────┘
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:
┌─────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────┘
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.
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.
- 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
- What is the difference between a Host, a Client, and a Server?
- You want to give Claude the ability to send an email — which primitive do you use?
- What does stdio transport mean?
Setting Up Your Environment
- 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 --versionto 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.
Your First MCP Server
- 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
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)}
- 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
- Where does Claude read to decide when to use a tool?
- What file tells Claude Desktop which MCP servers to connect to?
- Does Claude run your tool code directly? Why or why not?
File System Tools
- 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
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.
- Always confine file operations to a
WORKSPACEdirectory 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)
- What does a path traversal attack look like?
- Why does
safe_pathuse.resolve()before comparing paths? - What happens if you raise an exception in a tool instead of returning an error string?
Database Tools
- 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()
- 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.Rowmakes results return as dicts, not raw tuples
- What would happen without parameterized queries if a user sent
'; DROP TABLE notes; --as input? - How does
query_databaseprevent writes? - Why does
describe_tablevalidate the table name manually before using it in SQL?
Web and API Tools
- 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
Resources and Prompts
- 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.
- 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
Security Patterns
- 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
| Rule | What it means |
|---|---|
| 1. Validate paths | Always use safe_path() — prevent directory traversal |
| 2. Parameterize SQL | Never concatenate user input into SQL strings |
| 3. Whitelist inputs | Define what is allowed; do not try to block what is not |
| 4. No public shell | Never expose subprocess.run(cmd, shell=True) over the internet |
| 5. Log everything | Every tool call needs a record: input, output, timestamp |
| 6. Set limits | Timeouts 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:
# ...
Hands-on Project: Personal Content Assistant
- 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.
- 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.logfor debugging - Extend this server over time: add a calendar tool, a GitHub tool, an email tool
What to build next
| Idea | Tools to add |
|---|---|
| Calendar assistant | Read/write Google Calendar via API |
| Email assistant | Read inbox, draft replies via Gmail API |
| GitHub assistant | Create issues, read PRs (official GitHub MCP server already exists) |
| Semantic note search | Embed notes with a vector DB, add a search_notes(query) tool |
| Slack assistant | Post 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
| Term | Meaning |
|---|---|
| MCP | Model Context Protocol — open standard for AI tool connections |
| Host | AI app the user talks to (Claude Desktop, Claude Code) |
| Client | Manages one server connection inside the host |
| Server | Your Python process that exposes tools/resources/prompts |
| Tool | A function the AI can call |
| Resource | Read-only data exposed via URI |
| Prompt | Reusable prompt template |
| FastMCP | High-level Python framework for building MCP servers |
| safe_path | Pattern for preventing directory traversal attacks |
| Parameterized query | SQL with ? placeholders — always use instead of f-strings |
Questions? Email hello@pranavsrivastava.com