Build Your Own MCP Server · 30 min · Python · MCP Python SDK 2.x (MCPServer) · SQLite
Objective
Project setup
uv init mcp-toolkit && cd mcp-toolkit
uv add "mcp[cli]>=2.3" httpx
mcp[cli] pulls in MCPServer, the high-level API this course uses throughout (SDK 2.x; tutorials written for 1.x call it FastMCP) — it's part of the official SDK, not a separate project you have to evaluate and choose. The [cli] extra also gives you mcp dev, which runs your server against the MCP Inspector (a debugging UI you'll use properly in Lesson 6, but it's worth knowing it exists from day one).
The anatomy of an MCP tool
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("toolkit")
@mcp.tool()
def query_notes(keyword: str) -> str:
"""Search the local notes database for entries containing a keyword.
Use this whenever the user asks about something they've noted or logged before."""
return "SELECT results would appear here"
if __name__ == "__main__":
mcp.run()
Three things are doing real work here: the @mcp.tool() decorator (registers the function as a callable tool automatically — you never manually write a JSON Schema), the type hint on keyword: str (the SDK generates the tool's input schema FROM this signature — this is why untyped or vaguely-typed parameters produce a worse schema than the equivalent hand-written one), and the docstring (this becomes the tool's description field — the exact same "the model only sees what you tell it" lesson from tool design in any agent framework applies here too).
The docstring is not documentation, it's the interface
It's tempting to write """Search notes.""" and move on. Don't — this is the ONLY thing Claude reads to decide when to call this tool. "Search the local notes database for entries containing a keyword. Use this whenever the user asks about something they've noted or logged before" tells the model both what the tool does and when to reach for it. A one-line docstring produces a tool that gets ignored or misused in exactly the ways an underspecified API endpoint gets misused by developers who've never seen your code.
Wire in the real SQLite tool
Today's actual tool queries a small SQLite database (you'll seed it with a few rows of realistic sample data — notes, tasks, whatever fits a "personal database" framing) using a parameterized query, never string-formatted SQL.
A mistake worth making on purpose
Write f"SELECT * FROM notes WHERE content LIKE '%{keyword}%'" once, ask Claude a question with an apostrophe in it (try "find notes about O'Brien's project"), and watch it either fail or behave unexpectedly. Then fix it with a parameterized query (cursor.execute("SELECT * FROM notes WHERE content LIKE ?", (f"%{keyword}%",))). This isn't a contrived example — LLM-generated tool arguments are exactly the kind of "untrusted-ish" input that makes SQL injection worth taking seriously even in a local personal tool, because the input didn't come from a form you control, it came from a model's interpretation of a user's request.
Register it and actually use it
Add your server to claude_desktop_config.json (same shape as Lesson 1, command: "uv", args: ["--directory", "/absolute/path/to/mcp-toolkit", "run", "server.py"]), restart Claude Desktop, and ask it a question that requires the tool — not "what tools do you have," an actual question your seeded data can answer.
You ask Claude a question your query_notes tool should clearly be able to answer, and Claude answers from general knowledge instead of calling the tool. The tool works fine when you test it directly in Python. What's the most likely cause?
If the tool works when called directly, the tool's logic isn't the problem — the model either isn't seeing the tool at all (check the connection first, exactly like Lesson 1's log-reading habit) or doesn't think the tool is relevant to this phrasing of the question (a docstring problem, fixable by describing the tool's purpose more concretely and by the kinds of questions it answers).
What you're building
server.py: an MCP server with one real tool (query_notes, backed by parameterized SQLite queries against seeded sample data), registered with Claude Desktop, answering a real question you ask it in a live conversation.