The Model Context Protocol (MCP) is Anthropic’s open standard for connecting AI agents to external tools and data sources. It uses JSON-RPC over stdio or HTTP, giving any MCP-compatible client – Claude Desktop, Claude Code, Cursor, or your own agent – a way to discover and call tools on your server. Think of it as a USB-C port for AI: one protocol, many connections.

Here’s the fastest way to get an MCP server running:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-server")

@mcp.tool()
def greet(name: str) -> str:
    """Say hello to someone."""
    return f"Hello, {name}!"

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

That’s a complete, working MCP server. Install the SDK with pip install mcp, run the script, and any MCP client can connect over stdio and call greet. The @mcp.tool() decorator handles all the protocol plumbing – it reads your function signature, type hints, and docstring to generate the JSON schema that clients need for tool discovery.

Setting Up Your Project

Start by creating a project directory and installing the MCP Python SDK. You need Python 3.10 or higher.

1
2
3
4
mkdir my-mcp-server && cd my-mcp-server
python -m venv .venv
source .venv/bin/activate
pip install mcp httpx

The mcp package includes the FastMCP high-level server class and all the transport layers. We’re pulling in httpx because our example server will make async HTTP calls.

If you prefer uv (and you probably should for new projects), the setup is even cleaner:

1
2
uv init my-mcp-server && cd my-mcp-server
uv add "mcp[cli]" httpx

Building a Server with Tools

Tools are the most common MCP capability. They let an AI agent take actions – query a database, call an API, read a file. Each tool gets a name, a description (from the docstring), and a typed parameter schema (from the function signature).

Here’s a server that wraps a real API. This one queries the Open-Meteo weather API, which is free and requires no API key:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-server")

GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"


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

    Args:
        city: Name of the city (e.g. 'London', 'New York')
    """
    async with httpx.AsyncClient() as client:
        # Geocode the city name to coordinates
        geo_resp = await client.get(
            GEOCODING_URL,
            params={"name": city, "count": 1, "format": "json"},
            timeout=10.0,
        )
        geo_resp.raise_for_status()
        geo_data = geo_resp.json()

        if "results" not in geo_data or len(geo_data["results"]) == 0:
            return f"Could not find coordinates for '{city}'."

        lat = geo_data["results"][0]["latitude"]
        lon = geo_data["results"][0]["longitude"]
        resolved_name = geo_data["results"][0]["name"]

        # Fetch current weather
        weather_resp = await client.get(
            FORECAST_URL,
            params={
                "latitude": lat,
                "longitude": lon,
                "current": "temperature_2m,wind_speed_10m,relative_humidity_2m",
            },
            timeout=10.0,
        )
        weather_resp.raise_for_status()
        weather = weather_resp.json()["current"]

        return (
            f"Weather in {resolved_name}:\n"
            f"  Temperature: {weather['temperature_2m']}C\n"
            f"  Wind speed: {weather['wind_speed_10m']} km/h\n"
            f"  Humidity: {weather['relative_humidity_2m']}%"
        )


@mcp.tool()
async def get_forecast(city: str, days: int = 3) -> str:
    """Get a multi-day weather forecast for a city.

    Args:
        city: Name of the city (e.g. 'Tokyo', 'Berlin')
        days: Number of forecast days, 1 to 7. Defaults to 3.
    """
    async with httpx.AsyncClient() as client:
        geo_resp = await client.get(
            GEOCODING_URL,
            params={"name": city, "count": 1, "format": "json"},
            timeout=10.0,
        )
        geo_resp.raise_for_status()
        geo_data = geo_resp.json()

        if "results" not in geo_data or len(geo_data["results"]) == 0:
            return f"Could not find coordinates for '{city}'."

        lat = geo_data["results"][0]["latitude"]
        lon = geo_data["results"][0]["longitude"]
        resolved_name = geo_data["results"][0]["name"]

        clamped_days = max(1, min(days, 7))
        weather_resp = await client.get(
            FORECAST_URL,
            params={
                "latitude": lat,
                "longitude": lon,
                "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
                "forecast_days": clamped_days,
            },
            timeout=10.0,
        )
        weather_resp.raise_for_status()
        daily = weather_resp.json()["daily"]

        lines = [f"Forecast for {resolved_name} ({clamped_days} days):"]
        for i in range(len(daily["time"])):
            lines.append(
                f"  {daily['time'][i]}: "
                f"{daily['temperature_2m_min'][i]}C - {daily['temperature_2m_max'][i]}C, "
                f"precip {daily['precipitation_sum'][i]} mm"
            )
        return "\n".join(lines)


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

A few things worth noting. The @mcp.tool() decorator must be called with parentheses – @mcp.tool, without them, throws a TypeError. Async functions work out of the box. The docstring becomes the tool description that the AI model reads, so write it like you’re explaining the tool to a colleague. The Args section in the docstring maps to parameter descriptions in the generated schema.

Exposing Data with Resources

Resources are MCP’s way of exposing read-only data. While tools perform actions, resources serve up content – config files, database records, status pages. Clients can list and read resources by URI.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from mcp.server.fastmcp import FastMCP
import json

mcp = FastMCP("data-server")

# Static resource -- always returns the same data
@mcp.resource("config://app/settings")
def get_app_settings() -> str:
    """Return the application configuration."""
    settings = {
        "app_name": "MyAgent",
        "version": "2.1.0",
        "max_retries": 3,
        "timeout_seconds": 30,
        "features": ["search", "summarize", "translate"],
    }
    return json.dumps(settings, indent=2)


# Dynamic resource template -- URI contains a parameter
@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str:
    """Fetch a user profile by ID."""
    # In production, this would query a database
    profiles = {
        "alice": {"name": "Alice Chen", "role": "engineer", "team": "platform"},
        "bob": {"name": "Bob Smith", "role": "designer", "team": "product"},
    }
    profile = profiles.get(user_id)
    if profile is None:
        return json.dumps({"error": f"User '{user_id}' not found"})
    return json.dumps(profile, indent=2)


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

The curly-brace syntax in "users://{user_id}/profile" creates a resource template. The client can request users://alice/profile and MCP routes the alice value into your function’s user_id parameter. Static resources (no placeholders) are simpler – they return the same data every time.

Connecting Clients to Your Server

The most common way to test an MCP server is through Claude Desktop or Claude Code. Both use a JSON config file to know which servers to launch.

For Claude Desktop, edit ~/.config/Claude/claude_desktop_config.json (Linux) or ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

1
2
3
4
5
6
7
8
{
  "mcpServers": {
    "weather-server": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

If you used uv, point the command at uv instead:

1
2
3
4
5
6
7
8
{
  "mcpServers": {
    "weather-server": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/project", "run", "server.py"]
    }
  }
}

Restart Claude Desktop after editing the config. The server shows up in the connectors menu, and Claude can immediately call your tools.

You can also test your server programmatically with the MCP Python SDK’s client:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def test_server():
    server_params = StdioServerParameters(
        command="python",
        args=["server.py"],
    )

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

            # List available tools
            tools = await session.list_tools()
            print("Available tools:")
            for tool in tools.tools:
                print(f"  - {tool.name}: {tool.description}")

            # Call a tool
            result = await session.call_tool(
                "get_weather", arguments={"city": "San Francisco"}
            )
            print(f"\nResult: {result.content[0].text}")

asyncio.run(test_server())

This spins up your server as a subprocess, connects over stdio, and lets you call tools directly. It’s the fastest feedback loop for development.

Common Errors and Fixes

TypeError: FastMCP.tool() missing 1 required positional argument – You wrote @mcp.tool instead of @mcp.tool(). The decorator needs parentheses even with no arguments.

Server hangs or produces garbled output – You’re printing to stdout in a stdio-mode server. Any print() call corrupts the JSON-RPC message stream. Use print("debug info", file=sys.stderr) or the logging module instead, which defaults to stderr.

Claude Desktop doesn’t show the server – Check three things: (1) the path in your config JSON is absolute, not relative, (2) you restarted Claude Desktop fully (quit, not just close the window), (3) your Python/uv command is correct. Run which python to get the exact path if needed.

ModuleNotFoundError: No module named 'mcp' – The server launched with a different Python than the one where you installed mcp. Use the full path to the Python binary in your config: "command": "/home/you/project/.venv/bin/python".

Tool calls return empty or error responses – Your tool function is probably raising an unhandled exception. MCP catches it but the error message may not surface. Add a try/except in your tool function and return a descriptive error string instead of letting exceptions propagate silently.

httpx.ConnectError or timeout errors – Your async HTTP client can’t reach the external API. Check your network connection, and make sure you’re setting a reasonable timeout on requests. The Open-Meteo API used in our examples is free and doesn’t need authentication, but rate limits apply.