What I learned building MCP servers in Python

The Model Context Protocol (MCP) lets an AI assistant such as Claude call tools that you write. I have built a number of MCP servers for marketing APIs, such as ad platforms, analytics and product feeds. This post shows a small server with the official Python SDK, and the habits that made those servers safe and easy for an assistant to use.

A small server

Install the SDK with pip install mcp httpx. Version 2 of the SDK calls the server class MCPServer. Older tutorials use FastMCP, its name in version 1.

import json
from pathlib import Path

import httpx
from mcp.server.mcpserver import MCPServer

mcp = MCPServer(
    "weather-notes",
    instructions=(
        "Look up the forecast with get_forecast. "
        "save_note only shows a preview by default. Show that preview to the user "
        "and call it again with dry_run=False only after they agree."
    ),
)

NOTES = Path("notes.json")


@mcp.tool()
async def get_forecast(latitude: float, longitude: float, days: int = 3) -> dict:
    """Daily minimum and maximum temperature in °C for a location, from Open-Meteo."""
    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.get(
            "https://api.open-meteo.com/v1/forecast",
            params={
                "latitude": latitude,
                "longitude": longitude,
                "daily": "temperature_2m_min,temperature_2m_max",
                "forecast_days": days,
                "timezone": "auto",
            },
        )
    response.raise_for_status()
    daily = response.json()["daily"]
    return {
        day: {"min": low, "max": high}
        for day, low, high in zip(daily["time"], daily["temperature_2m_min"], daily["temperature_2m_max"])
    }


@mcp.tool()
def save_note(title: str, text: str, dry_run: bool = True) -> str:
    """Save a note. Returns a preview unless dry_run is False."""
    if dry_run:
        return f"Preview, nothing saved yet: '{title}' ({len(text)} characters). Call again with dry_run=False to save."
    notes = json.loads(NOTES.read_text()) if NOTES.exists() else {}
    notes[title] = text
    NOTES.write_text(json.dumps(notes, indent=2))
    return f"Saved '{title}'."


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

Every function with @mcp.tool() becomes a tool. The type hints become the input schema and the docstring becomes the description the assistant reads. mcp.run() talks over stdio, which is how desktop clients such as Claude Desktop start a local server. With mcp.run(transport="streamable-http") the same server listens on http://127.0.0.1:8000/mcp, so you can host it for a team.

Give the assistant a workflow

The assistant only knows what the instructions and tool descriptions tell it. My servers start with a short workflow in instructions, for example which tool to call first and what to do when an account is missing. Without that, the assistant has to guess the order of the calls.

Look accounts up instead of configuring them

For APIs with many accounts I do not keep a list of accounts in a config file. The server has a tool that lists the accounts the credentials can access, and the instructions tell the assistant to call it first and match the name the user mentions. A new account then works without a change to the server.

Make writes a dry run by default

Reading data is harmless, but changing a budget or deleting a product is not. In my servers, tools that change something have dry_run: bool = True. The first call returns a preview, the assistant shows it, and only a second call with dry_run=False makes the change, after the user agrees. save_note above works the same way.

Return values a person can read

Ad APIs often return money in micros, where one euro is 1,000,000. I return the original field with a readable amount next to it, so the assistant does not have to convert it and is less likely to get it wrong.

Test it with a real client

The SDK includes a client. A short script that starts the server over stdio, lists the tools and calls them catches mistakes before an assistant ever sees the server.

import asyncio
import sys

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


async def main():
    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([tool.name for tool in tools.tools])
            result = await session.call_tool("save_note", {"title": "test", "text": "hello"})
            print(result.content[0].text)


asyncio.run(main())

The code in this post was tested with version 2.3 of the mcp package.