An AI assistant that can “use tools” needs a standard way to reach the outside world. The Model Context Protocol (MCP) is an open standard for that connection: you write a server once, and any MCP client, such as an AI desktop app, an IDE, or an agent, can use it.

In this lesson we walk through a small taskboard server written in Python. It comes from my taskboard-mcp repository, and it is deliberately tiny, so we can focus on the part that is easiest to mix up: MCP’s three building blocks, and when to use each one.

Step 01

What an MCP server is

An MCP server is a small program that exposes capabilities to AI applications. The application, called the client, connects to the server, asks what it offers, and decides when to use it. The server does not need to know which model is on the other side.

  1. MCP clientMCP Inspector, ChatGPT Desktop, an IDE, or an agent
  2. taskboard-mcpTools · Resources · Prompts
  3. tasks.dbSQLite file beside server.py
The client talks MCP to the server; only the server touches the database.

Our server keeps tasks in a SQLite database. Setting it up takes two lines with the official Python SDK (version 2):

server.pypython
from mcp.server import MCPServer

mcp = MCPServer("taskboard-mcp")

Everything else is ordinary Python functions with a decorator on top.

Step 02

The three building blocks

MCP servers can expose three kinds of capability. The clearest way to tell them apart is to ask who decides when each one is used. Pick one to see it in this server.

Tools: actions the model can take

A tool is a function the model can choose to call. The decorator registers it, and the function’s name, parameters, and docstring describe it to the client. That description is what the model reads when deciding whether to call the tool, so a clear docstring matters.

In this server: add_task, list_tasks, complete_task, say_hello

server.pypython
@mcp.tool()
def add_task(title: str) -> dict[str, str]:
    """Create an open task and return it."""
    task_id = str(uuid4())
    task = {"id": task_id, "title": title, "status": "open"}
    with _database() as connection:
        connection.execute(
            "INSERT INTO tasks (id, title, status) VALUES (?, ?, ?)",
            (task_id, title, "open"),
        )
    return task

Resources: data the application reads

A resource is data that the client reads and can hand to the model as context. It has a URI, and reading it is not a tool call: nothing changes.

In this server: taskboard://tasks

server.pypython
@mcp.resource("taskboard://tasks")
def tasks_resource() -> list[dict[str, str]]:
    """Show the current tasks on the taskboard."""
    return list_tasks()

Use a resource when the application should decide what context to load, for example showing the current tasks before the conversation starts.

Prompts: templates the user picks

A prompt is a reusable message template. The user selects it, the client fills in the arguments, and the result is sent to the model. The prompt itself does not call tools or change data; it only writes the request.

In this server: plan_open_tasks(goal)

server.pypython
@mcp.prompt()
def plan_open_tasks(goal: str) -> str:
    """Create a planning prompt using the current open tasks."""
    return (
        f"Help me make a practical plan for this goal: {goal}\n\n"
        'Use the taskboard-mcp `list_tasks` tool with status="open" to retrieve '
        "the current open tasks. Then order them into a practical plan and say "
        "what to do first. Do not change any task statuses."
    )

Notice that this prompt tells the model which tool to use and what not to do. Prompts are a good place to package instructions your users would otherwise have to type every time.

If it does something, such as changing data, it’s a tool. If it provides context to read, it’s a resource. If it’s a reusable way to ask for something, it’s a prompt.

Step 03

Tools in depth

Type hints do real work here. In list_tasks, the Literal type limits the status filter to two values, so the model cannot invent a third:

server.pypython
@mcp.tool()
def list_tasks(status: Literal["open", "completed"] | None = None) -> list[dict[str, str]]:
    """Return all tasks, or filter by open or completed status."""

When something goes wrong, raise ToolError. The client receives a clear error message instead of a crashed server:

server.pypython
@mcp.tool()
def complete_task(task_id: str) -> dict[str, str]:
    """Mark a task complete and return the updated task."""
    with _database() as connection:
        row = connection.execute(
            "SELECT title FROM tasks WHERE id = ?", (task_id,)
        ).fetchone()
        if row is None:
            raise ToolError(f"Task not found: {task_id}")
        connection.execute(
            "UPDATE tasks SET status = ? WHERE id = ?", ("complete", task_id)
        )
    return {"id": task_id, "title": row[0], "status": "complete"}
Step 04

STDIO and Streamable HTTP

The same server can talk to clients in two ways, chosen when it starts:

server.pypython
if args.http:
    mcp.run(
        transport="streamable-http",
        host="127.0.0.1",
        port=8000,
        streamable_http_path="/mcp",
    )
else:
    mcp.run()

Default

STDIO

The client launches the server as a local process and exchanges messages over standard input and output. This suits desktop apps on your own machine.

--http

Streamable HTTP

The server listens at http://127.0.0.1:8000/mcp, for clients that connect over a network.

To try it, install the dependencies and start the server in either mode:

terminalbash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

python server.py           # STDIO, for clients that launch the server
python server.py --http    # Streamable HTTP at http://127.0.0.1:8000/mcp

Any client that can launch a local process connects the same way over STDIO: point it at your virtual environment’s Python as the command, and at the absolute path to server.py as the argument.

Step 05

Testing with MCP Inspector

Before connecting a real AI client, test every building block with MCP Inspector. It launches the server over STDIO and opens a browser interface:

terminalbash
npx @modelcontextprotocol/inspector@latest .venv/bin/python server.py

Use the Tools tab to call add_task or list_tasks, the Resources tab to read taskboard://tasks, and the Prompts tab to fill in plan_open_tasks with a goal such as “Prepare the demo by Friday”.

You can also use the server from your own code. The repository’s client.py launches the server and reads the resource:

client.pypython
server = StdioServerParameters(
    command=sys.executable,
    args=[str(SERVER_PATH)],
    cwd=str(SERVER_PATH.parent),
)

async with Client(server) as client:
    result = await client.read_resource("taskboard://tasks")
Step 07

Key takeaways

  1. One server, many clients. MCP standardizes how AI applications connect to tools and data, so a server you build once works with any MCP client.
  2. Ask who decides. Tools are actions the model chooses, resources are context the application reads, and prompts are templates the user picks.
  3. Python makes it small. A decorator, type hints, and a clear docstring are enough to expose each building block.
  4. Pick the transport for the client. STDIO suits local apps; Streamable HTTP suits networked clients and needs authentication before it is exposed.
  5. Test first. MCP Inspector lets you exercise every tool, resource, and prompt before a real AI client uses them.

The complete server, tests, and setup guides for ChatGPT Desktop and the web are on GitHub.

taskboard-mcp