Building Your First MCP Server in Python

A step-by-step tutorial: build a Model Context Protocol server with the Python SDK, test it with the inspector, connect it to Claude Code, harden it, and write descriptions a model will actually use correctly. Working code throughout.

The Model Context Protocol is how agents discover and call tools, and every major provider and agent product supports it. If you have a system you want an agent to use (a database, an internal API, a folder of files), the modern answer is: write an MCP server. This tutorial builds one from zero, tests it, connects it to a coding agent, and hardens it. Thirty minutes if you have Python and a terminal.

Step 1: What You Are Building

An MCP server is a small process that exposes three kinds of things: tools (functions the model can call), resources (data the host can read into context), and prompts (reusable templates). A client (Claude Code, a chat app, your own agent) connects, lists what the server offers, and calls it.

We will build a server for a to-do list stored in SQLite: list, add, complete. Three tools, one resource, and a deliberate side effect so you learn how those are handled.

Step 2: Install

bash
python -m venv .venv && source .venv/bin/activate pip install "mcp[cli]"

Step 3: The Server

python
# todo_server.py import sqlite3 from mcp.server.fastmcp import FastMCP DB = "todo.db" mcp = FastMCP("todo") def conn(): c = sqlite3.connect(DB) c.execute("CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0)") return c @mcp.tool() def list_todos(include_done: bool = False, limit: int = 20) -> list[dict]: """List to-do items, open ones first. Use this before completing or discussing a task so you have the item's id. Returns up to `limit` items with `id`, `title`, and `done`. """ q = "SELECT id, title, done FROM todos" + ("" if include_done else " WHERE done = 0") + " ORDER BY done, id LIMIT ?" with conn() as c: return [{"id": i, "title": t, "done": bool(d)} for i, t, d in c.execute(q, (min(limit, 100),))] @mcp.tool() def add_todo(title: str) -> dict: """Create a new to-do item. This writes to the database. `title` must be 1-200 characters. Returns the created item's id. """ title = title.strip() if not 1 <= len(title) <= 200: raise ValueError("title must be 1-200 characters") with conn() as c: cur = c.execute("INSERT INTO todos (title) VALUES (?)", (title,)) return {"id": cur.lastrowid, "title": title} @mcp.tool() def complete_todo(todo_id: int) -> dict: """Mark a to-do item as done. This writes to the database. Only call with an `id` returned by list_todos. Returns the updated item. """ with conn() as c: if c.execute("UPDATE todos SET done = 1 WHERE id = ?", (todo_id,)).rowcount == 0: raise ValueError(f"no todo with id {todo_id}") return {"id": todo_id, "done": True} @mcp.resource("todo://summary") def summary() -> str: """One-line summary: open and done counts.""" with conn() as c: open_, done = c.execute("SELECT SUM(done = 0), SUM(done = 1) FROM todos").fetchone() return f"{open_ or 0} open, {done or 0} done" if __name__ == "__main__": mcp.run()

Run python todo_server.py and it waits on stdin: that is the stdio transport, where the host launches your server as a subprocess and talks to it over pipes.

Step 4: Test Without a Model

bash
mcp dev todo_server.py

This opens the MCP inspector, a small web UI connected to your server. Call add_todo, then list_todos, then complete_todo, and read the resource. Every tool should be exercised here before a model touches it. Most bugs you find now would have shown up as "the agent did something weird" later.

Step 5: Connect to Claude Code

bash
claude mcp add todo -- python /full/path/to/todo_server.py

Start Claude Code in any directory and ask it: "What is on my to-do list? Add 'write the MCP tutorial' and mark the first item done." Watch the tool calls. Two things to notice: the agent calls list_todos first because the description told it to, and the agent's own harness may ask you to approve add_todo and complete_todo because their descriptions say they write. You did not configure that; you described it, and a good harness gates side effects.

Step 6: Harden

Three things every server should do before anyone else uses it.

Validate every input. The title length check and the rowcount check above are the minimum. Tools are an attack surface: a model can be induced to call them with anything.

Return less. list_todos caps at 100 and returns only three fields. A tool that returns whole rows of a wide table floods the model's context and costs money on every call.

Say what has side effects. "This writes to the database" in the docstring is not decoration. The model reads it when deciding whether to call, and harnesses use it to decide whether to ask a human.

Step 7: Descriptions Are the Interface

The model chooses and calls tools using only the name, the description, and the schema. If the agent misuses your tool, fix the description before touching anything else.

  • Name says what, first sentence says when. "Use this before completing or discussing a task" is the sentence the model plans with.
  • Describe the return shape. The model plans the next step from what it expects back.
  • Constrain in the schema and repeat in prose. Types and enums stop bad values; the sentence stops the model from trying them.

A quick eval: write ten natural requests ("add milk to my list", "what did I finish this week", "close the first one") and, for each, the tools that should be called in what order. Run them through the agent and check. Fix failures with description edits. That ten-line table in your README is worth more than any screenshot.

Step 8: Use It From Your Own Code

If you are building an agent rather than using one, your program is the host:

python
import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): params = StdioServerParameters(command="python", args=["todo_server.py"]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as s: await s.initialize() print([t.name for t in (await s.list_tools()).tools]) print(await s.call_tool("list_todos", {"limit": 5})) asyncio.run(main())

From here, convert the tool schemas to your model's format, filter by what the current task is allowed to use, and dispatch the model's calls through call_tool. Permissions, budgets, and approvals are still your harness's job; MCP is the plug.

What to Practice Next

Replace the to-do table with a system you actually care about (a real API, a folder of notes, a database you have access to). Keep the structure: read tools, write tools marked as such, one resource, input validation, small returns, and a ten-task eval in the README. Then read mcp-and-agent-protocols-building-tool-servers for tool design in depth and where A2A and the other protocols fit.

Related Posts

More posts

Agentic Coding: Working With Claude Code, Codex, and Cursor

Coding agents are now the default way software gets written. Learn the gather-act-verify loop, how to write CLAUDE.md and AGENTS.md files that actually steer an agent, when to use skills and subagents, and how to review agent output like a senior engineer.

#coding-agents#agents#agent-engineering#python

Context Engineering: Designing What the Model Sees

The context window is a budget, and everything competes for it: the system prompt, the tool list, retrieved documents, memory, and the conversation so far. Learn to design the context deliberately, scope tools per task, compact without losing what matters, and treat cache hit rate as the metric it has become.

#context-engineering#agent-engineering#prompt-caching#agent-memory#rag#llm

Harness Engineering: The Runtime Around the Model

Agent = model + harness. The harness is the deterministic runtime that validates, authorizes, executes, and logs every action the model proposes. Learn its five layers, build one from scratch, and adopt the loop that turns every agent failure into a permanent fix.

#harness-engineering#agent-engineering#agents#durable-execution#guardrails#system-design