Build an MCP Server with Python in 15 Minutes

This tutorial walks through building a working MCP weather server in Python. By the end you will have two tools, get_alerts and get_forecast, registered in Claude for Desktop and callable from chat. The data comes from the free National Weather Service API, no API key needed.
Requirements: Python 3.10+, MCP SDK 1.2.0+, Claude for Desktop.
Step 1: Set Up the Project with uv
The MCP team recommends uv for Python project management. Install it first, then scaffold the project:
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create project and enter it
uv init weather
cd weather
# Create and activate a virtual environment
uv venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install dependencies
uv add "mcp[cli]" httpx
# Create the server file
touch weather.pymcp[cli] includes FastMCP and the STDIO transport. httpx handles async HTTP calls to the NWS API.
Step 2: Initialize the Server
Open weather.py and add the imports and server instance:
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"FastMCP("weather") sets the server name displayed in MCP client UIs. FastMCP also inspects your decorated functions at startup, reading type hints and docstrings to auto-generate the JSON Schema tool definitions. You never write a schema by hand.
Step 3: Add HTTP Helper Functions
Add these two functions below the constants. They handle the NWS API calls and keep the tool functions focused on logic:
async def make_nws_request(url: str) -> dict[str, Any] | None:
"""Make a request to the NWS API with proper error handling."""
headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=30.0)
response.raise_for_status()
return response.json()
except Exception:
return None
def format_alert(feature: dict) -> str:
"""Format an alert feature into a readable string."""
props = feature["properties"]
return f"""
Event: {props.get("event", "Unknown")}
Area: {props.get("areaDesc", "Unknown")}
Severity: {props.get("severity", "Unknown")}
Description: {props.get("description", "No description available")}
Instructions: {props.get("instruction", "No specific instructions provided")}
"""make_nws_request returns None on any error rather than raising, so tools can return a human-readable fallback message instead of crashing the session.
Step 4: Implement the Tools
Decorate each async function with @mcp.tool(). The docstring is what Claude reads when deciding whether and how to call the tool, so write it like API documentation, not an inline comment.
@mcp.tool()
async def get_alerts(state: str) -> str:
"""Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
"""
url = f"{NWS_API_BASE}/alerts/active/area/{state}"
data = await make_nws_request(url)
if not data or "features" not in data:
return "Unable to fetch alerts or no alerts found."
if not data["features"]:
return "No active alerts for this state."
alerts = [format_alert(feature) for feature in data["features"]]
return "\n---\n".join(alerts)
@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
"""Get weather forecast for a location.
Args:
latitude: Latitude of the location
longitude: Longitude of the location
"""
points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}"
points_data = await make_nws_request(points_url)
if not points_data:
return "Unable to fetch forecast data for this location."
forecast_url = points_data["properties"]["forecast"]
forecast_data = await make_nws_request(forecast_url)
if not forecast_data:
return "Unable to fetch detailed forecast."
periods = forecast_data["properties"]["periods"]
forecasts = []
for period in periods[:5]:
forecast = f"""
{period["name"]}:
Temperature: {period["temperature"]}°{period["temperatureUnit"]}
Wind: {period["windSpeed"]} {period["windDirection"]}
Forecast: {period["detailedForecast"]}
"""
forecasts.append(forecast)
return "\n---\n".join(forecasts)get_forecast makes two requests: first to /points/{lat},{lng} to resolve the NWS grid square for that location, then to the returned forecast URL for the actual periods. The NWS API does not accept coordinates directly on the forecast endpoint, so the resolution step is required.
Step 5: Add the Entry Point
Append the main function and the __main__ guard at the bottom of weather.py:
def main():
mcp.run(transport="stdio")
if __name__ == "__main__":
main()transport="stdio" tells FastMCP to communicate over stdin/stdout using JSON-RPC 2.0. This is the standard transport for local MCP servers.
Important: Never call print() inside a STDIO server. Anything written to stdout is parsed as a JSON-RPC message and will corrupt the session. Use print(..., file=sys.stderr) or the logging module instead, both write to stderr safely.
Step 6: Verify the Server Starts
Run the server directly to confirm there are no import or syntax errors:
uv run weather.pyThe process will block, waiting for a client connection over stdin. That is correct behavior. Stop it with Ctrl+C and proceed.
Step 7: Register with Claude for Desktop
Open the Claude for Desktop config file in your editor:
# macOS
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
# Windows
code $env:AppData\Claude\claude_desktop_config.jsonAdd the server under the mcpServers key. The path must be absolute:
{
"mcpServers": {
"weather": {
"command": "uv",
"args": ["--directory", "/ABSOLUTE/PATH/TO/weather", "run", "weather.py"]
}
}
}If uv is not on Claude's $PATH (common on macOS), replace "uv" with the full executable path. Get it with:
which uvSave the file and fully quit and relaunch Claude for Desktop. A hammer icon in the chat input bar confirms the tools are loaded. You can now ask Claude things like "Are there any weather alerts in California?" or "What is the forecast for 40.7128, -74.0060?" and it will call your server in real time.