← Back to DevBytes

Building an MCP Server in Python: Step-by-Step Guide

Introduction to MCP

The Model Context Protocol (MCP) is an open standard introduced by Anthropic that defines how AI assistants connect to external data sources and tools. Think of MCP as the "USB-C for AI applications" — a universal interface that lets language models query databases, read files, call APIs, and execute custom logic in a standardized way.

In this tutorial, you'll learn how to build a fully functional MCP server in Python from scratch. We'll cover the protocol's architecture, implement tools and resources, expose them over a transport, and connect a client to verify everything works.

What Is an MCP Server?

An MCP server is a lightweight process that exposes capabilities to an MCP client (typically an AI assistant or IDE plugin). The protocol defines three primary capability types:

The server communicates with clients using JSON-RPC 2.0 messages. Two transports are commonly used: stdio (for local integrations like Claude Desktop or IDEs) and Streamable HTTP (for remote deployments).

Why MCP Matters

Before MCP, every AI integration required bespoke glue code. A chatbot that needed to read GitHub issues, query a Postgres database, and search Confluence would need three separate adapters with three different auth flows. MCP solves this by providing:

Prerequisites and Setup

You'll need Python 3.10 or newer and a virtual environment. Let's start by creating the project structure and installing the official SDK.

# Create project directory
mkdir mcp-weather-server && cd mcp-weather-server

# Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install the MCP Python SDK
pip install "mcp[cli]"

# Verify installation
python -c "import mcp; print(mcp.__version__)"

Create the following file layout:

mcp-weather-server/
├── .venv/
├── server.py
├── pyproject.toml
└── README.md

Add a minimal pyproject.toml so the project is installable:

[project]
name = "mcp-weather-server"
version = "0.1.0"
description = "A simple MCP server exposing weather tools"
requires-python = ">=3.10"
dependencies = ["mcp>=1.0"]

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.backends._legacy:_Backend"

Building Your First MCP Server

We'll build a weather server that exposes two tools: one to fetch current conditions for a city, and one to fetch a short forecast. To keep the example self-contained, we'll use a small in-memory data store instead of a live API — but the structure maps directly to a real HTTP integration.

The Minimal Server

Open server.py and add the following:

"""A minimal MCP server exposing weather tools."""

from mcp.server.fastmcp import FastMCP

# Initialize the server with a name and version
mcp = FastMCP("weather-server")


# In-memory mock data. In production, replace with calls to a real API.
WEATHER_DATA = {
    "san francisco": {"temp_c": 16, "condition": "Foggy", "humidity": 80},
    "new york": {"temp_c": 22, "condition": "Sunny", "humidity": 55},
    "london": {"temp_c": 14, "condition": "Rainy", "humidity": 88},
    "tokyo": {"temp_c": 26, "condition": "Cloudy", "humidity": 70},
}


@mcp.tool()
def get_weather(city: str) -> dict:
    """Get the current weather for a given city.

    Args:
        city: Name of the city (case-insensitive).

    Returns:
        A dictionary with temp_c, condition, and humidity.
    """
    key = city.strip().lower()
    if key not in WEATHER_DATA:
        return {"error": f"No data available for '{city}'."}
    return {"city": city.title(), **WEATHER_DATA[key]}


@mcp.tool()
def list_cities() -> list[str]:
    """List all cities for which weather data is available."""
    return [c.title() for c in WEATHER_DATA]


if __name__ == "__main__":
    # Run over stdio — the default transport for local integrations.
    mcp.run()

That's a complete, working MCP server. Let's unpack what's happening:

Adding a Resource

Tools are great for actions, but sometimes you want to expose static or semi-static data the model can read on demand. That's what resources are for. Let's add one that returns the full weather dataset as JSON.

import json


@mcp.resource("weather://all")
def all_weather() -> str:
    """Return the entire weather dataset as a JSON string."""
    return json.dumps(WEATHER_DATA, indent=2)


@mcp.resource("weather://{city}")
def weather_by_city(city: str) -> str:
    """Return weather data for a specific city as JSON."""
    key = city.strip().lower()
    data = WEATHER_DATA.get(key)
    if data is None:
        return json.dumps({"error": f"Unknown city: {city}"})
    return json.dumps({"city": city.title(), **data}, indent=2)

The URI template weather://{city} lets the client request any city by name, while weather://all is a static URI. The SDK handles routing automatically.

Adding a Prompt Template

Prompts are reusable message templates the client can offer to users. They're optional but useful for guiding the model toward a specific workflow.

from mcp.types import TextContent


@mcp.prompt()
def weather_report(city: str) -> list[TextContent]:
    """Generate a prompt asking for a natural-language weather summary."""
    return [
        TextContent(
            type="text",
            text=(
                f"You are a friendly meteorologist. "
                f"Use the get_weather tool to fetch current conditions "
                f"for {city}, then write a two-sentence summary for a "
                f"general audience."
            ),
        )
    ]

Running and Testing the Server

Manual Testing with the MCP Inspector

The SDK ships with an interactive inspector that lets you call tools, read resources, and view prompts in a browser UI. Run it with:

mcp dev server.py

This launches a local web interface (typically at http://localhost:5173) where you can:

Programmatic Testing with a Client

For automated tests, you can spin up an in-process client using the SDK's ClientSession. Create test_server.py:

"""Smoke test for the weather MCP server."""

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


async def main() -> None:
    params = StdioServerParameters(
        command="python",
        args=["server.py"],
    )

    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # List available tools
            tools = await session.list_tools()
            print("Tools:", [t.name for t in tools.tools])

            # Call get_weather
            result = await session.call_tool(
                "get_weather", {"city": "Tokyo"}
            )
            print("get_weather('Tokyo'):", result.content[0].text)

            # Read a resource
            res = await session.read_resource("weather://all")
            print("weather://all:", res.contents[0].text)


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

Run it with python test_server.py. You should see the tool list, the Tokyo weather response, and the full dataset printed to your terminal.

Connecting to Claude Desktop

To use your server inside Claude Desktop, edit the configuration file at ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or the equivalent path on your OS:

{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Restart Claude Desktop. You should now see a "weather" server in the tools menu, and you can ask Claude things like "What's the weather in London?" — it will automatically invoke your get_weather tool.

Deploying Over HTTP

For remote deployments, switch from stdio to the Streamable HTTP transport. The SDK provides a StreamableHTTPServerTransport you can mount inside any ASGI app. Here's a minimal example using Starlette:

"""HTTP transport variant of the weather server."""

from mcp.server.fastmcp import FastMCP
from mcp.server.streamable_http import StreamableHTTPServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
import uvicorn

mcp = FastMCP("weather-server")

# ... reuse the same @mcp.tool, @mcp.resource, @mcp.prompt definitions ...


async def create_app() -> Starlette:
    transport = StreamableHTTPServerTransport(mcp_server=mcp._mcp_server)
    await transport.startup()

    async def handle_scope(scope, receive, send):
        await transport.handle_request(scope, receive, send)

    return Starlette(
        routes=[Mount("/mcp", app=handle_scope)],
        on_shutdown=[transport.shutdown],
    )


if __name__ == "__main__":
    app = asyncio.run(create_app())
    uvicorn.run(app, host="0.0.0.0", port=8000)

Clients can now connect to http://localhost:8000/mcp. In production, place this behind a reverse proxy and add authentication — typically an API key passed via the Authorization header.

Best Practices

Write Precise Docstrings

The model uses your function's docstring to decide when and how to call it. Be specific about arguments, units, and return shapes. Avoid vague descriptions like "Gets data" — prefer "Returns current temperature in Celsius and humidity percentage for a named city."

Validate Inputs Explicitly

Don't rely solely on the JSON schema. Re-validate inside the function, especially when arguments come from an untrusted model. Use pydantic for complex inputs:

from pydantic import BaseModel, Field


class WeatherQuery(BaseModel):
    city: str = Field(min_length=1, max_length=100)
    units: str = Field(default="celsius", pattern="^(celsius|fahrenheit)$")


@mcp.tool()
def get_weather_v2(query: WeatherQuery) -> dict:
    """Get weather with validated, structured input."""
    base = WEATHER_DATA.get(query.city.strip().lower())
    if base is None:
        return {"error": "Unknown city"}
    temp = base["temp_c"]
    if query.units == "fahrenheit":
        temp = temp * 9 / 5 + 32
    return {"city": query.city.title(), "temp": temp, "units": query.units}

Keep Tools Focused and Idempotent

Each tool should do one thing well. Prefer many small tools over a single "do everything" function. Where possible, make tools idempotent so retries are safe — a get_weather call should never have side effects.

Handle Errors Gracefully

Return structured error objects rather than raising exceptions. The SDK will translate uncaught exceptions into JSON-RPC errors, but a clean error dict gives the model something it can reason about and surface to the user.

Log Everything

Use Python's logging module to record tool invocations, argument values, and durations. Logs go to stderr by default, which keeps stdout clean for JSON-RPC traffic on the stdio transport.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("weather-server")


@mcp.tool()
def get_weather(city: str) -> dict:
    logger.info("get_weather called", extra={"city": city})
    # ... existing logic ...

Secure Sensitive Operations

If a tool performs destructive actions (deleting records, sending emails), require explicit confirmation by surfacing it through the prompt flow, and always log who called what. Never embed secrets in source code — read them from environment variables or a secrets manager.

Conclusion

Building an MCP server in Python is straightforward thanks to the FastMCP helper: decorate functions to expose tools, resources, and prompts, then run over stdio for local use or HTTP for remote deployments. By following the best practices around precise docstrings, input validation, focused tools, graceful error handling, and thorough logging, you'll produce servers that integrate cleanly with any MCP-compatible client — from Claude Desktop to custom AI pipelines. The protocol's real power emerges when you compose multiple servers together, so once your weather server is solid, try adding a second one for news, databases, or internal APIs and watch your assistant's capabilities multiply.

— Ad —

Google AdSense will appear here after approval

← Back to all articles