Your chatbot answers questions well. Then you ask it to check the weather, and it freezes. So you write a tool function, wire it into the prompt loop, and it works. Next you want it to read your Postgres database. Another integration. Then your ticketing system. Every new capability means more bespoke glue code, and none of it carries over to the next app you build.
Model Context Protocol (MCP) exists to break that cycle. It's an open protocol built on JSON-RPC 2.0 that standardizes how AI applications call tools and read data. You write a server once, and any MCP host can use it. Claude Desktop, Claude Code, VS Code, Cursor, they all speak the same protocol.
This tutorial builds a complete MCP server in Python using the current SDK, version 2.x, which targets the 2026-07-28 specification. Most tutorials online still show the old 1.x API (FastMCP). The v2 API is simpler, and every command in this article was run against the real SDK before publishing.
What you will build
A weather server with two tools. get_alerts returns active weather alerts for a US state. get_forecast returns a five-period forecast for a location. Both pull live data from the National Weather Service API, which is free and needs no API key. The server also exposes one resource and one prompt, so all three MCP primitives show up in a single file.
Prerequisites
- Python 3.10 or newer. The SDK requires it.
- uv. The official docs use it, and it keeps the environment tidy. Plain pip works too.
- An MCP host if you want to test from a chat app. Claude Desktop runs on macOS and Windows. On Linux, use Claude Code, VS Code, or the test client in this article.
Step 1: Set up the project
uv init weather
cd weather
uv venv
source .venv/bin/activate
uv add "mcp[cli]"
The [cli] extra adds the mcp command line tool on top of the SDK: mcp dev, mcp run, mcp install. With pip, the equivalent is pip install "mcp[cli]".
Step 2: Write the server
Create weather.py:
from typing import Any
import httpx2
from mcp.server import MCPServer
mcp = MCPServer("weather")
NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"
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 httpx2.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")}
"""
@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)
@mcp.resource("nws://states")
def supported_states() -> list[str]:
"""US state codes the alerts tool supports."""
# Full list: all 50 US state codes. Trimmed here for brevity.
return ["CA", "NY", "TX", "FL", "WA", "CO", "IL", "GA", "HI", "AK"]
@mcp.prompt()
def weather_report(city: str, state: str) -> str:
"""Ask for a weather report for a city."""
return (
f"Use get_forecast to fetch the weather for {city}, {state}. "
f"Then summarize it in two sentences, mentioning temperature and wind."
)
if __name__ == "__main__":
mcp.run(transport="stdio")
A few things worth noticing. The MCPServer class turns type hints and docstrings into JSON Schema automatically, so you never write a schema by hand. The tools are async because they make HTTP calls. The docstring matters: it's what the model reads when it decides whether to call a tool.
The httpx2 import is the HTTP client the SDK itself depends on, so installing mcp already brought it in.
Step 3: Run it in the MCP Inspector
The fastest way to poke at a server is the MCP Inspector, a browser-based client maintained by the SDK team.
uv run mcp dev weather.py
It boots a web UI at http://localhost:6274. Open it and you'll see get_alerts and get_forecast listed. Call get_alerts with CA and you get live alert data back. The inspector is also where you can inspect the JSON Schema generated for each tool.
Step 4: Test it with an in-memory client
The SDK ships a Client class, and you can pass it the server object directly. No subprocess, no port. This is how the SDK's own test suite works, and it's the fastest feedback loop you'll get. Save this as test_client.py:
import asyncio
from mcp import Client
from weather import mcp
async def main() -> None:
async with Client(mcp) as client:
print(client.server_info)
print("protocol:", client.protocol_version)
tools = await client.list_tools()
print("tools:", [t.name for t in tools.tools])
result = await client.call_tool("get_alerts", {"state": "CA"})
for block in result.content:
print(block.text[:300])
asyncio.run(main())
Run it:
uv run python test_client.py
Output looks like this (alerts are live data, so yours will differ):
protocol: 2026-07-28
tools: ['get_alerts', 'get_forecast']
Event: Extreme Heat Warning
Area: Coachella Valley; San Diego County Deserts
Severity: Severe
Step 5: Connect over stdio, like a real host
Chat apps don't import your Python file. They launch the server as a subprocess and exchange JSON-RPC messages over stdin and stdout. That's the stdio transport, and it's what local MCP servers use.
A minimal client looks like this. Save it as test_stdio.py and run it from the project directory:
import asyncio
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
server = StdioServerParameters(
command="uv",
args=["run", "weather.py"],
)
async def main() -> None:
async with Client(stdio_client(server)) as client:
result = await client.call_tool(
"get_forecast", {"latitude": 37.7749, "longitude": -122.4194}
)
for block in result.content:
print(block.text)
asyncio.run(main())
This is the same shape of code an MCP host runs internally. Run it and you get a real forecast for San Francisco:
Tonight:
Temperature: 58°F
Wind: 6 to 13 mph WSW
Forecast: Mostly cloudy, with a low around 58.
Saturday:
Temperature: 72°F
Wind: 6 to 12 mph WSW
Forecast: Mostly sunny, with a high near 72.
Step 6: Plug it into a chat app
On macOS or Windows, Claude Desktop reads its config from ~/Library/Application Support/Claude/claude_desktop_config.json (Windows: %APPDATA%\Claude\claude_desktop_config.json). Add your server under mcpServers:
{
"mcpServers": {
"weather": {
"command": "uv",
"args": ["--directory", "/ABSOLUTE/PATH/TO/weather", "run", "weather.py"]
}
}
}
Restart Claude Desktop and the tools show up. Two notes from the official docs: you may need the full path to the uv executable (which uv), and the directory in args must be absolute. Claude Desktop is not available on Linux. There, use Claude Code, VS Code with its MCP extension, or the stdio client from Step 5.
When to build your own server vs use an existing one
Check the official server registry and the reference implementations in the modelcontextprotocol/servers repo before writing anything. Filesystem, Postgres, GitHub, Slack, Sentry, they all have maintained servers you can point your host at today.
Write your own when:
- You have an internal API or database schema that no public server covers
- The existing server doesn't fit your auth model or the shape of your data
- You want one tool that combines several internal systems
Python vs TypeScript SDK is mostly a team question. Pick the language your codebase already speaks; the protocol is identical either way. Local vs remote is a runtime question. Stdio keeps the server on the same machine as the host, with no network and no auth. When multiple hosts or users need it, run it over Streamable HTTP and protect it with OAuth, which is what the spec recommends.
Two mistakes that cost an hour
print()in a stdio server. Stdout carries the JSON-RPC messages. One stray print corrupts the stream and the connection dies with a confusing error. Use theloggingmodule, which writes to stderr. The official docs are blunt about this: never write to stdout in a stdio server.Launching the server with the wrong Python. If the subprocess can't import
mcp, the connection fails at startup. Make sure the command in your host config runs inside the environment where you installed the SDK. This is exactly why the official examples useuv run.
Where to go next
You now have a server that several chat apps can consume, plus a test loop that runs in milliseconds. Next steps, in rough order:
- Replace the weather API with something you own. Your Postgres schema, an internal API, a folder of documents. The tools keep the same shape.
- Read the server concepts page for resources and prompts in depth, and the official build-server tutorial for the full walkthrough.
- When the server needs to serve many users, move it to Streamable HTTP and add OAuth. The SDK ships an ASGI integration, so the server can sit inside an existing FastAPI app.
The point of MCP is building a capability once. One server, and every host in your toolchain can use it. That's the payoff for the hour you just spent.