MCP (Model Context Protocol) Lesson 2 of 6 ⏱️ 5 min read

Build an MCP Server in Python

Create your first MCP server with the official Python SDK (MCPServer, formerly FastMCP), add tools with type hints, and test it with the MCP Inspector.

Updated Oct 30, 2026

What we're building

In this lesson you'll build a small MCP server in Python for a student to-do list. It exposes three tools — add a task, list tasks, complete a task — that any MCP-compatible AI app can use.

We'll use the official MCP Python SDK and its high-level MCPServer API (called FastMCP in SDK versions before 2.0), which turns ordinary Python functions into MCP tools using their type hints and docstrings.

Setup

You need Python 3.10 or newer.

Bash
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "mcp[cli]"

The [cli] extra adds the mcp command-line tool, which includes the developer Inspector.

Step 1 — create the server

Create todo_server.py:

Python
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("student-todo")

tasks: list[dict] = []   # in-memory storage for this lesson


@mcp.tool()
def add_task(title: str, subject: str = "general") -> str:
    """Add a new task to the student's to-do list.

    Args:
        title: What needs to be done, e.g. "Finish the SQL assignment".
        subject: Course or category, e.g. "databases".
    """
    task = {"id": len(tasks) + 1, "title": title, "subject": subject, "done": False}
    tasks.append(task)
    return f"Added task #{task['id']}: {title} ({subject})"


@mcp.tool()
def list_tasks(include_done: bool = False) -> list[dict]:
    """List tasks on the to-do list. Completed tasks are hidden unless include_done is true."""
    return [t for t in tasks if include_done or not t["done"]]


@mcp.tool()
def complete_task(task_id: int) -> str:
    """Mark a task as done by its numeric id."""
    for task in tasks:
        if task["id"] == task_id:
            task["done"] = True
            return f"Completed task #{task_id}: {task['title']}"
    raise ToolError(f"No task with id {task_id}. Call list_tasks to see valid ids.")


if __name__ == "__main__":
    mcp.run()   # stdio transport by default

What MCPServer does for you:

  • The function name becomes the tool name.
  • The type hints (str, int, bool) become the tool's JSON Schema.
  • The docstring becomes the description the model reads — so write it well.
  • Raising ToolError sends your message back as a tool error (is_error = true), so the model can read it and recover — for example by calling list_tasks to find a valid id.

ToolError vs any other exception. Use ToolError for failures you expect (bad id, missing record). Any other exception is treated as a crash: the model only sees a generic "Error executing tool complete_task" and your server logs a traceback. Expected problems deserve a helpful message; real bugs should stay out of the model's context.

Step 2 — test it with the MCP Inspector

Bash
mcp dev todo_server.py

This starts your server and opens the MCP Inspector in your browser. From there you can:

  1. Click Connect.
  2. Open the Tools tab and see your three tools and their schemas.
  3. Call add_task with a title, then list_tasks, then complete_task.

Always test tools in the Inspector before connecting an AI app — it's much easier to debug a server on its own.

Step 3 — connect it to an AI app

Most MCP hosts (desktop AI assistants, IDEs, coding agents) are configured with a JSON file listing servers. The entry usually looks like this:

JSON
{
  "mcpServers": {
    "student-todo": {
      "command": "python",
      "args": ["/absolute/path/to/todo_server.py"]
    }
  }
}

The host launches the server as a subprocess over stdio. Use the absolute path and, if you use a virtual environment, point command at that environment's Python. Restart the host, and you can ask the assistant: "Add a task to revise recursion for my DSA exam, then show my list."

Running as a remote server

To share a server with many users, run it over Streamable HTTP instead of stdio:

Python
if __name__ == "__main__":
    mcp.run(transport="streamable-http")   # serves on http://127.0.0.1:8000/mcp by default

A remote server is reachable over the network, so it must have authentication and input validation before you deploy it — see the security lesson.

Logging without breaking stdio

With the stdio transport, standard output is the protocol channel. A stray print() corrupts the JSON-RPC stream. Log to standard error instead:

Python
import logging, sys

logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logging.info("student-todo server started")

Improving the server

  1. Persist tasks in SQLite instead of a Python list so they survive restarts.
  2. Add a due date parameter and a tasks_due_this_week tool.
  3. Validate inputs — reject empty titles and overly long text.

Common mistakes

  • Using print() in a stdio server. It breaks the protocol; log to stderr.
  • Relative paths in the host config. The host may start the server from a different working directory.
  • Weak docstrings. The model chooses tools based on descriptions — "does stuff with tasks" won't cut it.
  • Returning huge payloads. Paginate or summarise; every byte ends up in the model's context.

Key takeaways

  • MCPServer (formerly FastMCP) turns typed, documented Python functions into MCP tools.
  • Test with mcp dev and the Inspector before connecting a host.
  • Local servers run over stdio; shared servers run over Streamable HTTP with authentication.
  • Never write to stdout in a stdio server.
Finished this lesson?
Tick it off to track your MCP (Model Context Protocol) progress.
Next lesson