Building an MCP Server in Python: A Step-by-Step Guide
The Model Context Protocol (MCP) is a standard way to expose tools, data, and prompts to LLM clients like Claude Desktop, IDEs, and agent frameworks. You write plain Python functions; the SDK turns them into protocol-compliant tools, generates their JSON schemas from your type hints, validates inputs, and handles the protocol lifecycle.
This guide walks through building a server with the official Python SDK and FastMCP. We build two tools: a minimal hello world and a get_weather tool that demonstrates structured inputs and outputs.
What MCP is
A server communicates with a client over a transport. The two you will use most often:
- stdio — the server runs as a local subprocess and the client speaks to it over stdin/stdout. This is what Claude Desktop uses, and what we use here.
- streamable HTTP — the server runs as a web service for remote clients.
Prerequisites
- Python 3.10 or later (check with
python --version) - uv, the recommended package manager. pip works too; both are shown below.
- A way to test: the MCP Inspector (a dev UI, no client needed) and/or Claude Desktop.
Project setup and install
uv init mcp-weather
cd mcp-weather
uv add "mcp[cli]"
The [cli] extra matters: it pulls in the mcp command-line tool used to run the dev inspector and install into Claude Desktop.
The pip equivalent:
mkdir mcp-weather && cd mcp-weather
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "mcp[cli]"
Tool one: hello world
Create server.py:
from mcp.server.fastmcp import FastMCP
# Name shows up in the client's tool list.
mcp = FastMCP("weather-demo")
@mcp.tool()
def say_hello(name: str) -> str:
"""Return a friendly greeting for the given name."""
return f"Hello, {name}! Your MCP server is working."
if __name__ == "__main__":
mcp.run() # defaults to stdio transport
That is a complete, working server. Three things are doing the work:
@mcp.tool()registers the function as a callable tool.- The type hint
name: strbecomes the input schema the model sees. - The docstring becomes the tool description the model uses to decide when to call it. Write it for the model, not just for humans.
Tool two: structured inputs and outputs
The hello-world tool takes one string. Real tools take several typed parameters and return structured data. We define explicit Pydantic models so the input and output schemas are precise.
Add this to server.py, above the if __name__ block:
from enum import Enum
from pydantic import BaseModel, Field
class Units(str, Enum):
celsius = "celsius"
fahrenheit = "fahrenheit"
class WeatherReport(BaseModel):
"""Structured weather result returned to the client."""
location: str = Field(description="The location this report is for.")
temperature: float = Field(description="Current temperature in the requested units.")
units: Units = Field(description="Units the temperature is expressed in.")
conditions: str = Field(description="Short text description, e.g. 'Partly cloudy'.")
forecast_days: int = Field(description="Number of days this forecast covers.")
@mcp.tool()
def get_weather(
location: str = Field(description="City name or 'City, Country', e.g. 'Toronto, CA'."),
units: Units = Field(
default=Units.celsius,
description="Temperature units to return.",
),
days: int = Field(
default=1,
ge=1,
le=7,
description="Number of forecast days (1-7).",
),
) -> WeatherReport:
"""Look up current weather and a short forecast for a location.
Use this when the user asks about temperature, conditions, or a
multi-day forecast for a specific place.
"""
# Demo data. In a real server, call a weather API here.
temp_c = 21.0
temp = temp_c if units == Units.celsius else round(temp_c * 9 / 5 + 32, 1)
return WeatherReport(
location=location,
temperature=temp,
units=units,
conditions="Partly cloudy",
forecast_days=days,
)
This one tool demonstrates everything you need for structured I/O:
- Multiple typed parameters —
location(string),units(enum),days(constrained int). - An enum —
Unitsrestrictsunitsto two valid values; the client renders this as a closed choice. - Optional params with defaults —
unitsanddayshave defaults, so the model can omit them. - Validation constraints —
ge=1, le=7means out-of-range values are rejected before your code runs. Field(description=...)— each description is injected into the JSON schema. This is the single biggest lever on whether the model uses the tool correctly.- A structured return type — returning a
WeatherReportmakes FastMCP generate an output schema, so the client receives typed, machine-readable data instead of a blob of text.
How your Python becomes a schema
When a client connects, it asks the server to list its tools. FastMCP builds each tool’s contract from your function:
- Parameter names and type hints become the JSON Schema properties and types.
- Enums become an
enumconstraint listing the allowed values. Field(...)constraints (ge,le, defaults) become schema validation rules and defaults.- The docstring becomes the tool’s top-level description.
Field(description=...)becomes per-parameter descriptions.- The return type becomes the tool’s output schema.
The model only ever sees this generated contract, never your implementation. So the names and descriptions are your real interface. Vague names and empty docstrings are the most common reason a model picks the wrong tool or fills parameters badly.
Run and test with the MCP Inspector
The Inspector is the fastest way to exercise tools without wiring up a full client:
uv run mcp dev server.py
This launches the server and a local web UI. In the browser:
- Click Connect.
- Open the Tools tab; you should see
say_helloandget_weather. - Select
get_weather, fill in a location, pick units, set days, and Run. - Confirm the response comes back as structured fields matching
WeatherReport.
If a tool does not appear, it usually means the file failed to import; check the terminal for a traceback.
Install into Claude Desktop
The CLI can register the server for you:
uv run mcp install server.py --name "Weather Demo"
This writes an entry into Claude Desktop’s claude_desktop_config.json. To do it manually, the config lives at:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
A manual entry looks like this. Use an absolute path to the project directory:
{
"mcpServers": {
"weather-demo": {
"command": "uv",
"args": [
"run",
"--directory",
"/absolute/path/to/mcp-weather",
"server.py"
]
}
}
}
Restart Claude Desktop fully (quit, do not just close the window). The tools then appear in the client, and you can ask it something like “What is the weather in Toronto for the next 3 days?”
Common pitfalls
- Never
print()to stdout in a stdio server. stdout is the protocol channel; stray prints corrupt the message stream and the connection dies. Use theloggingmodule (it writes to stderr) orprint(..., file=sys.stderr). - Use absolute paths in the config. The client launches your server from an unpredictable working directory; relative paths fail silently.
- Restart the client after config changes. Claude Desktop reads the config on launch only.
- Match your Python version. The interpreter the client launches must be 3.10+ and must have
mcpinstalled. This is whyuv runwith--directoryis convenient: it uses the project’s environment. - Keep docstrings and
Fielddescriptions filled in. An undescribed tool is one the model will misuse or skip.
Full server.py
import sys
from enum import Enum
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("weather-demo")
@mcp.tool()
def say_hello(name: str) -> str:
"""Return a friendly greeting for the given name."""
return f"Hello, {name}! Your MCP server is working."
class Units(str, Enum):
celsius = "celsius"
fahrenheit = "fahrenheit"
class WeatherReport(BaseModel):
"""Structured weather result returned to the client."""
location: str = Field(description="The location this report is for.")
temperature: float = Field(description="Current temperature in the requested units.")
units: Units = Field(description="Units the temperature is expressed in.")
conditions: str = Field(description="Short text description, e.g. 'Partly cloudy'.")
forecast_days: int = Field(description="Number of days this forecast covers.")
@mcp.tool()
def get_weather(
location: str = Field(description="City name or 'City, Country', e.g. 'Toronto, CA'."),
units: Units = Field(default=Units.celsius, description="Temperature units to return."),
days: int = Field(default=1, ge=1, le=7, description="Number of forecast days (1-7)."),
) -> WeatherReport:
"""Look up current weather and a short forecast for a location.
Use this when the user asks about temperature, conditions, or a
multi-day forecast for a specific place.
"""
temp_c = 21.0
temp = temp_c if units == Units.celsius else round(temp_c * 9 / 5 + 32, 1)
return WeatherReport(
location=location,
temperature=temp,
units=units,
conditions="Partly cloudy",
forecast_days=days,
)
if __name__ == "__main__":
mcp.run()
Run it with uv run mcp dev server.py to test, then uv run mcp install server.py to use it in Claude Desktop.
