← Back to Blog

Build Your Own MCP Server in Python

You have an LLM app, and the model needs to check the weather, look up a customer, or hit an internal API. So you write the same glue for the third time: a JSON schema for each function, argument validation, a formatter so the model can read the result. Every app does this. None of them do it compatibly.

MCP (Model Context Protocol) replaces that glue with a standard. It is an open protocol built on JSON-RPC 2.0 that lets any LLM application talk to any server exposing tools, resources, or prompts. Claude Desktop, Claude Code, Cursor, and VS Code Copilot all speak it natively. You write one server, and every one of those hosts can use it.

One warning before you copy code from the internet: the Python SDK shipped a major v2 rewrite in 2026, and most older tutorials show the v1 API built around a class called FastMCP. That code will not run against the current SDK. Everything in this post was tested against mcp 2.0.0 on Python 3.12, and the snippets below are the exact code I ran.

Prerequisites

  • Python 3.10 or newer
  • uv or pip
  • npx (Node.js) only if you want the Inspector web UI
  • Internet access, because the example calls the Open-Meteo weather API (free, no API key)

Step 1: Install the SDK

pip install "mcp[cli]"

The [cli] extra adds the mcp command-line tool (dev, run, install) on top of the SDK. With uv, the equivalent is uv add "mcp[cli]". Check what you got:

mcp version
MCP version 2.0.0

If it prints 1.x, pip resolved an old release. Install again in a fresh environment.

Step 2: Write the server

Create server.py with this. It is a complete, working MCP server:

import json
import urllib.parse
import urllib.request

from mcp.server import MCPServer
from pydantic import BaseModel, Field

mcp = MCPServer("Weather")


class WeatherReport(BaseModel):
    """Current conditions at a location."""

    temperature_c: float = Field(description="Temperature in Celsius")
    wind_speed_kmh: float = Field(description="Wind speed in km/h")
    weather_code: int = Field(
        description="WMO weather code: 0 = clear, 3 = overcast, 51 = light drizzle, 61 = rain"
    )


@mcp.tool()
def get_weather(
    latitude: float = Field(description="Latitude, e.g. -6.2088 for Jakarta"),
    longitude: float = Field(description="Longitude, e.g. 106.8456 for Jakarta"),
) -> WeatherReport:
    """Get current weather for any location. Uses the free Open-Meteo API, no API key needed."""
    params = urllib.parse.urlencode(
        {
            "latitude": latitude,
            "longitude": longitude,
            "current": "temperature_2m,wind_speed_10m,weather_code",
        }
    )
    url = f"https://api.open-meteo.com/v1/forecast?{params}"
    with urllib.request.urlopen(url, timeout=10) as resp:
        current = json.load(resp)["current"]

    return WeatherReport(
        temperature_c=current["temperature_2m"],
        wind_speed_kmh=current["wind_speed_10m"],
        weather_code=current["weather_code"],
    )


@mcp.resource("weather://location/{city}")
def location_info(city: str) -> str:
    """Coordinates for a few Indonesian cities. Stand-in for a real geocoding database."""
    cities = {
        "jakarta": (-6.2088, 106.8456),
        "bandung": (-6.9175, 107.6191),
        "surabaya": (-7.2575, 112.7521),
        "yogyakarta": (-7.7956, 110.3695),
    }
    lat, lon = cities.get(city.lower())
    if lat is None:
        return f"Unknown city: {city}. Known: {', '.join(sorted(cities))}"
    return f"{city}: latitude {lat}, longitude {lon}"


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

Three things are going on here, and this is where the SDK earns its keep.

@mcp.tool() turns get_weather into a tool. The model sees the function name, the docstring as the description, and the type hints as the argument schema. You never write a JSON Schema by hand, and the schema is sent to the client automatically during tools/list.

get_weather is a plain def, not async def. The SDK runs sync tools in a thread so they never block the server. If your tool does I/O with an async library, declare it async def and await inside; the SDK awaits it. Either style works, nothing to configure.

The return type is a pydantic model, so the result is structured output. The model gets a JSON text block, the host application gets a typed dict, both from the same return statement. That split is the whole point: the LLM reads prose, your code reads data.

The tool itself calls Open-Meteo, a free weather API that needs no key. urllib.parse.urlencode builds the query string, json.load parses the response, and you return a WeatherReport. If the request fails, urllib raises, the SDK catches it and turns it into a tool error, and the model sees isError with a message it can act on. I tested that path with invalid coordinates and got Error executing tool get_weather: HTTP Error 400: Bad Request back. You did not write any of that plumbing.

@mcp.resource("weather://location/{city}") registers a resource template. A resource is data the application loads into context, not something the model decides to call. The {city} placeholder becomes the function parameter, so weather://location/bandung is a real, readable URI.

The if __name__ == "__main__": guard matters. mcp dev, mcp run, mcp install, and your tests all import this file. An unguarded mcp.run() would start a server every time something loads the module.

Step 3: Test it in memory

The SDK's Client connects straight to the server object. No subprocess, no port, but the call still goes through the full protocol layer. This is the test harness the SDK's own docs use, and it doubles as an embedding API:

import asyncio

from mcp import Client

from server import mcp


async def main() -> None:
    async with Client(mcp) as client:
        tools = await client.list_tools()
        print("TOOLS:")
        for t in tools.tools:
            print(f"  - {t.name}: {t.description}")

        result = await client.call_tool(
            "get_weather", {"latitude": -6.2088, "longitude": 106.8456}
        )
        print("\nCALL get_weather(jakarta):")
        print("  content:           ", result.content)
        print("  structured_content:", result.structured_content)

        templates = await client.list_resource_templates()
        print(
            "\nRESOURCE TEMPLATES:",
            [t.uri_template for t in templates.resource_templates],
        )

        resource = await client.read_resource("weather://location/bandung")
        print("READ weather://location/bandung:", resource.contents)


asyncio.run(main())
python test_client.py
TOOLS:
  - get_weather: Get current weather for any location. Uses the free Open-Meteo API, no API key needed.

CALL get_weather(jakarta):
  content:            [TextContent(text='{\n  "temperature_c": 33.5,\n  "wind_speed_kmh": 7.4,\n  "weather_code": 51\n}', ...)]
  structured_content: {'temperature_c': 33.5, 'wind_speed_kmh': 7.4, 'weather_code': 51}

RESOURCE TEMPLATES: ['weather://location/{city}']
READ weather://location/bandung: bandung: latitude -6.9175, longitude 107.6191

That is a real API call. At the time I ran it, Jakarta reported 33.5 degrees, wind at 7.4 km/h, weather code 51 (light drizzle). content is what the model reads. structured_content is typed data for your application.

Step 4: Test over stdio, the way a host connects

A desktop host launches your server as a subprocess and speaks over its stdin and stdout. That transport is called stdio, and you can reproduce it exactly:

import asyncio

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

server = StdioServerParameters(
    command="uv",
    args=["run", "--with", "mcp[cli]", "server.py"],
)


async def main() -> None:
    async with Client(stdio_client(server)) as client:
        tools = await client.list_tools()
        print("Tools over stdio:", [t.name for t in tools.tools])

        result = await client.call_tool(
            "get_weather", {"latitude": -6.9175, "longitude": 107.6191}
        )
        print("Bandung weather:", result.structured_content)


asyncio.run(main())
python test_stdio.py
Tools over stdio: ['get_weather']
Bandung weather: {'temperature_c': 30.4, 'wind_speed_kmh': 8.4, 'weather_code': 0}

This spawns server.py, negotiates the protocol version, lists the tools, and calls get_weather over a real pipe. Bandung came back 30.4 degrees, clear sky. If this works, host configuration is the only thing left to get wrong.

Step 5: Kick the tires in the MCP Inspector

uv run mcp dev server.py

mcp dev starts your server and opens the Inspector in the browser. It needs npx on your PATH. You get one tab per primitive. The Tools tab renders a form with latitude and longitude fields, built from your type hints the same way every client will build it. The Resources tab lists the template; open weather://location/bandung and you see the coordinates.

Step 6: Connect a real host

Every host is configured with one launch command:

uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

It works from any directory with no virtualenv to activate. That matters, because a host launches your server from its own working directory with a near-empty environment.

Claude Desktop is the one host the CLI configures for you:

uv run mcp install server.py

It writes the launch command into claude_desktop_config.json, converting your path to an absolute one and pinning the SDK version. Fully quit Claude Desktop, not just its window, and reopen it.

Claude Code takes a one-liner:

claude mcp add weather -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

Cursor reads .cursor/mcp.json in your project root:

{
  "mcpServers": {
    "weather": {
      "command": "uv",
      "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
    }
  }
}

VS Code reads .vscode/mcp.json. Same idea, two differences: the wrapper key is servers instead of mcpServers, and each entry declares "type": "stdio". The official docs have both files.

Two failures cover most support questions. A relative path, because the host does not launch from your shell's directory, so server.py must be absolute. And a host that was not fully restarted, so it still runs its old config.

Step 7: Serve it over HTTP

stdio is for local use. To hand your server to people who do not have your file, give them a URL. Change the last line:

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

The client is the same Client, with a URL instead of a server object:

client = Client("http://127.0.0.1:3001/mcp")

Transport options go to run(), never to MCPServer(...). The constructor describes what your server is: name, version, instructions. run() describes how it is served. Get it backwards and Python raises TypeError before MCP is involved. The older SSE transport still exists for legacy clients, but Streamable HTTP replaced it in the 2025-03-26 spec revision. Do not build anything new on SSE.

When to use MCP vs the alternatives

Approach Use when
Plain function calling in your own app You have one client and one server in one codebase. MCP is ceremony.
Hand-rolled tool glue over REST You control one app and one API, and nobody else will ever consume them.
An MCP server Multiple AI apps should use your tools, or you want tool discovery, schema generation, and validation without writing them yourself.

The honest tradeoff: MCP costs a dependency and a protocol to learn, and for a single app it is pure overhead. It earns that cost the moment a second host shows up, because you change zero server code.

Gotchas

  • On stdio, stdout is the wire. Do not print() from your server; use the logging module. The SDK diverts flushed stray output to stderr while serving, but anything written to stdout before serving starts corrupts the protocol stream.
  • Keep run() under if __name__ == "__main__":. Every tool that loads your server imports the file first.
  • Hosts give your server a minimal environment, not your shell's. If the server needs an API key, pass it with mcp install -v KEY=value or the env= parameter of StdioServerParameters.
  • mcp install needs Claude Desktop's config directory to exist, which means Claude Desktop has to have been run once.

Next steps

  • The server exposes three primitives; this post built tools and one resource template. The prompts page is next, then structured output for richer return types.
  • If you deploy over HTTP, read the authorization docs before putting it behind a real hostname.
  • Check the MCP registry before writing your own servers. A lot of tools are already one config line away.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation