MCP Servers Explained: Give Your AI Agent Real Tools

Versions used in this post — read this first: Python 3.12 · MCP Python SDK mcp 2.3.0 (specification revision 2026-07-28) · Transport: stdio · No API key, no account. Version note that matters: SDK v2 renamed the class most older tutorials use — code importing FastMCP was written for v1 and will not run on v2. This post uses MCPServer throughout.

The direct answer

MCP — the Model Context Protocol — is a standard way for an AI application to discover and call tools that live outside the model. Instead of every framework inventing its own plugin format, a tool is packaged once as an MCP server, and any MCP-aware host can list its tools, read their input schemas, and call them over a defined protocol.

In the previous post, the tools lived inside the same file as the agent. That works for one program. MCP is for when a tool should outlive it — usable by a second agent, a different host, or next month's project.

Host, client, server — in plain words

Three roles appear in every MCP setup, and most confusion comes from mixing them up:

  • Host — the application the person uses: an AI coding assistant, a chat app with tool support, or your own agent program. It owns the model connection.
  • Client — the connector inside the host, one per server. It opens the connection, performs the handshake, and forwards tool calls.
  • Server — a separate program that offers tools and answers when called. It has no model of its own. Ours offers two tools over a local SQLite database. Client and server exchange JSON-RPC messages, here over stdio: the server is a subprocess, talking over its standard input and output. Streamable HTTP exists for shared servers; stdio is the right start — no port, no hosting, nothing exposed beyond your machine.

What you will build

A server named project-notes with two read-only tools over a tiny notes database the code creates and seeds itself — search_notes (finds notes containing a keyword) and recent_notes (lists the newest) — plus a small Python client that starts the server and calls both: the same handshake a full AI host performs, with no model in between.

Get the code

All the code in this post lives in the companion GitHub repository, in the topic-02-mcp-server folder — with a README that covers prerequisites, environment setup, install, and how to run and use it. If you would rather follow along here, every step is also written out below.

Step 1 — Set up (no key, no account, nothing to buy)

This build needs no API key and no account: an MCP server is ordinary local code, and testing it involves no model at all. If you later connect it to an AI host, signing in is the host's own setup.

  1. Check you have Python 3.10 or newer (this post uses 3.12): run python --version. On Windows, if the Store opens instead, install Python from python.org and tick "Add python.exe to PATH".
  2. Create a folder called mcp-notes, open a terminal in it, and create a virtual environment:
    # macOS / Linux
    python3 -m venv .venv && source .venv/bin/activate
    
    # Windows PowerShell
    py -m venv .venv ; .venv\Scripts\Activate.ps1
    
  3. Install the SDK, pinned (the [cli] extra adds the mcp command Step 3 uses):
    pip install "mcp[cli]==2.3.0"
    

Step 2 — Write the server

"""A tiny MCP server that searches a local project-notes database."""

import json
import sqlite3
from pathlib import Path

from mcp.server import MCPServer

DB_PATH = Path(__file__).with_name("notes.db")

SEED_NOTES = [
    ("Deploy checklist", "Build the image, run the tests, tag the release, then deploy.", "deploy"),
    ("Database backup", "Take a full backup before any schema change and verify the restore.", "database"),
    ("Grafana alert idea", "Alert when the error rate stays above two percent for ten minutes.", "monitoring"),
    ("FastAPI startup fix", "The startup error came from a missing environment variable, not the code.", "fastapi"),
]


def _connect() -> sqlite3.Connection:
    """Open the notes database, creating and seeding it on first use."""
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS notes "
        "(id INTEGER PRIMARY KEY, title TEXT, body TEXT, tag TEXT)"
    )
    count = conn.execute("SELECT COUNT(*) FROM notes").fetchone()[0]
    if count == 0:
        conn.executemany(
            "INSERT INTO notes (title, body, tag) VALUES (?, ?, ?)", SEED_NOTES
        )
        conn.commit()
    return conn


mcp = MCPServer("project-notes")


@mcp.tool()
def search_notes(keyword: str, limit: int = 5) -> str:
    """Search project notes for a keyword in the title or body.

    Args:
        keyword: Word or phrase to look for, for example "backup".
        limit: Maximum number of notes to return, between 1 and 20.
    """
    limit = max(1, min(limit, 20))
    like = f"%{keyword}%"
    with _connect() as conn:
        rows = conn.execute(
            "SELECT id, title, body, tag FROM notes "
            "WHERE title LIKE ? OR body LIKE ? ORDER BY id LIMIT ?",
            (like, like, limit),
        ).fetchall()
    if not rows:
        return json.dumps({"matches": [], "message": f"No notes match '{keyword}'."})
    return json.dumps(
        {
            "matches": [
                {"id": r[0], "title": r[1], "body": r[2], "tag": r[3]} for r in rows
            ]
        }
    )


@mcp.tool()
def recent_notes(limit: int = 3) -> str:
    """List the most recently added project notes.

    Args:
        limit: How many recent notes to return, between 1 and 20.
    """
    limit = max(1, min(limit, 20))
    with _connect() as conn:
        rows = conn.execute(
            "SELECT id, title, tag FROM notes ORDER BY id DESC LIMIT ?", (limit,)
        ).fetchall()
    return json.dumps(
        {"notes": [{"id": r[0], "title": r[1], "tag": r[2]} for r in rows]}
    )


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

Save it as server.py. Three things do more work than they appear to. The type hints are the contract: keyword: str and limit: int = 5 become the tool's JSON input schema automatically — no schema is written by hand. The docstring is the description the model reads, so write it as instructions to a careful colleague. And the SQL is parameterized: tool arguments are untrusted input — they may come from a model acting on text someone else wrote — and binding the keyword as a parameter keeps a hostile keyword from becoming hostile SQL. Both tools are read-only, and limit is clamped.

Step 3 — Look at your server in the Inspector

Before writing client code, inspect the server the way a host sees it:

mcp dev server.py

This starts the MCP Inspector, a local web page that connects as a client. Open the address it prints, choose List Tools, and you should see both tools with their generated schemas. Call search_notes with the keyword backup. If it works here, the server half is done — later failures live in the client or host configuration.

Running python server.py directly appears to do nothing. That is correct, not a hang: a stdio server waits silently for a client to speak first.

Step 4 — Connect a client and call a tool

Save this as client.py in the same folder:

"""A small MCP client that connects to server.py and calls its tools."""

import asyncio
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main() -> None:
    params = StdioServerParameters(
        command=sys.executable,
        args=["server.py"],
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools = await session.list_tools()
            print("Tools:", [tool.name for tool in tools.tools])

            result = await session.call_tool(
                "search_notes", arguments={"keyword": "backup"}
            )
            print("search_notes ->", result.content[0].text)

            result = await session.call_tool(
                "recent_notes", arguments={"limit": 2}
            )
            print("recent_notes ->", result.content[0].text)


if __name__ == "__main__":
    asyncio.run(main())

Note command=sys.executable: the client starts the server with the same Python running the client, so the server inherits the venv where the SDK is installed. Pointing a host at a python that resolves elsewhere is the most common reason a working server "cannot connect."

Run it with python client.py. Actual output, run against SDK 2.3.0:

Tools: ['search_notes', 'recent_notes']
search_notes -> {"matches": [{"id": 2, "title": "Database backup", "body": "Take a full backup before any schema change and verify the restore.", "tag": "database"}]}
recent_notes -> {"notes": [{"id": 4, "title": "FastAPI startup fix", "tag": "fastapi"}, {"id": 3, "title": "Grafana alert idea", "tag": "monitoring"}]}

Read it as a conversation: subprocess started, handshake completed, two tools discovered, two calls answered with parseable JSON. Put a model in the host's seat and nothing about the server changes. That separation is the entire point of the protocol.

To use the server from a real host, give the host the same launch information in its configuration (file locations differ per host; use absolute paths):

{
  "mcpServers": {
    "project-notes": {
      "command": "C:\\path\\to\\mcp-notes\\.venv\\Scripts\\python.exe",
      "args": ["C:\\path\\to\\mcp-notes\\server.py"]
    }
  }
}

Save, fully restart the host, and the two tools should appear in its tool list.

Errors you may hit

ModuleNotFoundError: No module named 'mcp.server.fastmcp', with a note that FastMCP was renamed. You — or your tutorial — are running v1 code on SDK v2. Change the import to from mcp.server import MCPServer, as here, or pin the old line with pip install "mcp<2". The generations cannot be mixed.

The server "hangs" and prints nothing. It is waiting on stdio for a client; test it with mcp dev or client.py. The mirror-image mistake is worse: a debugging print() inside the server corrupts the JSON-RPC stream on stdout. Log to stderr, or not at all.

A validation error names the argument you got wrong. These are real outputs from this server: a missing argument returns Field required for keyword; passing "five" where an integer belongs returns Input should be a valid integer, unable to parse string as an integer. Both arrive as tool errors, not crashes, so the caller can retry corrected. An unregistered name returns Unknown tool: plus the name — print list_tools() first, as client.py does.

The host cannot start the server, or finds nothing. In order: the Python launched is not the venv's Python (use its full path); the server.py path is relative or wrong (use absolute paths, then restart the host — most read this configuration only at startup); or the SDK sits in a different environment. The Step 3 test separates these: if mcp dev works from your venv, the server is innocent and the launch line is at fault.

Where to go next

Grow this server as real work pulls it: swap the seed data for your own notes, add a third tool only when a real question needs it, and keep every tool narrow, read-only until a write is required, and honest in its docstring.

Previous: building the tool-calling loop by hand, in Python. Next: a complete small AI app — a PDF Q&A tool built with FastAPI.


 

Comments