Build Your Own MCP Server · 45 min · Python · MCP Python SDK 2.x (MCPServer) · httpx
Objective
Tool 2 — filesystem, with a real boundary
A filesystem tool that can read/write anywhere is a server you shouldn't run. Scope it to one directory, explicitly, in code — not as a suggestion in the docstring.
import os
ALLOWED_DIR = os.path.expanduser("~/mcp-toolkit-workspace")
@mcp.tool()
def read_workspace_file(filename: str) -> str:
"""Read a file from the user's MCP workspace directory. Use this to
access files the user has explicitly saved there for the agent."""
path = os.path.join(ALLOWED_DIR, filename)
if not os.path.abspath(path).startswith(os.path.abspath(ALLOWED_DIR)):
return "Error: path escapes the allowed workspace directory"
if not os.path.exists(path):
return f"Error: {filename} not found in workspace"
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
The os.path.abspath(...).startswith(...) check exists specifically to reject filename values like ../../etc/passwd — a model given a filesystem tool will occasionally construct exactly this kind of path, not out of malice, but because a user's phrasing led it somewhere unexpected. This check is not optional hardening for later; it's the difference between a filesystem tool and a filesystem vulnerability.
This is the tool students most often ship without a boundary
It is very tempting to write open(filename) directly against whatever path the model sends, because it works in your own testing. It works right up until a task phrasing leads the model somewhere you didn't intend. Scope every filesystem tool to one directory, check the resolved path stays inside it, and don't treat this as a "harden it later" item — do it now, in this lesson, before the tool leaves your test script.
Tool 3 — wrap a real external API
Pick something genuinely useful (a weather API, a currency converter, GitHub's public API — anything with a free tier and no auth complexity) and wrap one endpoint as a tool. The lesson here isn't the HTTP call, it's translating a REST response into something the model can use well: don't return the raw JSON blob, extract and format the 2-3 fields that actually matter.
Tool 4 — data transformation
A tool that takes data already in context (say, from query_notes's output) and reshapes it — summarizing, reformatting as a table, converting units. These tools have no I/O of their own; they're pure functions, which makes them the easiest of the five to test and the easiest to get a bad description on, because "transform data" as a description tells Claude nothing about what transform or when.
Tool 5 — notification
A tool that sends a webhook (to a service like a Discord webhook URL, or your own simple endpoint) or an email. This is the first tool with a real side effect visible outside your machine — treat "are you sure?" framing in the tool description or a confirmation step as seriously as you'd want a human assistant to before it sends something on your behalf.
With 5 tools now registered, you ask Claude "check the weather and let me know if I should bring an umbrella," and it correctly calls your weather tool. You then ask "log this decision" and it calls the notification tool instead of the filesystem tool you actually meant. What's the fix?
At 5 tools, description precision stops being a nice-to-have. "Send a notification via webhook when the user explicitly asks to notify, alert, or message someone" and "Write to a file in the workspace when the user asks to save, log, or record something" are different enough that the model has a real signal to work with. Vague, overlapping descriptions are the single most common cause of wrong-tool selection once you're past 2-3 tools.
What you're building
server.py extended to 5 tools total: query_notes (Lesson 2), read_workspace_file, an external API wrapper, a data-transformation tool, and a notification tool — each tested individually with a real input before you ask Claude to use all of them together.