Introduction to MCP
The Model Context Protocol (MCP) is an open standard introduced by Anthropic that defines how AI assistants connect to external data sources and tools. Think of MCP as the "USB-C for AI applications" — a universal interface that lets language models query databases, read files, call APIs, and execute custom logic in a standardized way.
In this tutorial, you'll learn how to build a fully functional MCP server in Python from scratch. We'll cover the protocol's architecture, implement tools and resources, expose them over a transport, and connect a client to verify everything works.
What Is an MCP Server?
An MCP server is a lightweight process that exposes capabilities to an MCP client (typically an AI assistant or IDE plugin). The protocol defines three primary capability types:
- Tools — executable functions the model can invoke, such as
get_weather(city)orquery_database(sql). - Resources — read-only data sources identified by URIs, such as
file:///config.jsonordb://users/schema. - Prompts — reusable prompt templates the client can surface to users.
The server communicates with clients using JSON-RPC 2.0 messages. Two transports are commonly used: stdio (for local integrations like Claude Desktop or IDEs) and Streamable HTTP (for remote deployments).
Why MCP Matters
Before MCP, every AI integration required bespoke glue code. A chatbot that needed to read GitHub issues, query a Postgres database, and search Confluence would need three separate adapters with three different auth flows. MCP solves this by providing:
- Standardization — one protocol, many integrations. Write your server once; any MCP-compatible client can consume it.
- Composability — clients can connect to multiple servers simultaneously, each contributing its own tools and resources.
- Security boundaries — the server controls what's exposed; the client controls what the model may call. Permissions are explicit.
- Language agnosticism — servers can be written in Python, TypeScript, Go, Rust, or anything that speaks JSON-RPC.
Prerequisites and Setup
You'll need Python 3.10 or newer and a virtual environment. Let's start by creating the project structure and installing the official SDK.
# Create project directory
mkdir mcp-weather-server && cd mcp-weather-server
# Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install the MCP Python SDK
pip install "mcp[cli]"
# Verify installation
python -c "import mcp; print(mcp.__version__)"
Create the following file layout:
mcp-weather-server/
├── .venv/
├── server.py
├── pyproject.toml
└── README.md
Add a minimal pyproject.toml so the project is installable:
[project]
name = "mcp-weather-server"
version = "0.1.0"
description = "A simple MCP server exposing weather tools"
requires-python = ">=3.10"
dependencies = ["mcp>=1.0"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.backends._legacy:_Backend"
Building Your First MCP Server
We'll build a weather server that exposes two tools: one to fetch current conditions for a city, and one to fetch a short forecast. To keep the example self-contained, we'll use a small in-memory data store instead of a live API — but the structure maps directly to a real HTTP integration.
The Minimal Server
Open server.py and add the following:
"""A minimal MCP server exposing weather tools."""
from mcp.server.fastmcp import FastMCP
# Initialize the server with a name and version
mcp = FastMCP("weather-server")
# In-memory mock data. In production, replace with calls to a real API.
WEATHER_DATA = {
"san francisco": {"temp_c": 16, "condition": "Foggy", "humidity": 80},
"new york": {"temp_c": 22, "condition": "Sunny", "humidity": 55},
"london": {"temp_c": 14, "condition": "Rainy", "humidity": 88},
"tokyo": {"temp_c": 26, "condition": "Cloudy", "humidity": 70},
}
@mcp.tool()
def get_weather(city: str) -> dict:
"""Get the current weather for a given city.
Args:
city: Name of the city (case-insensitive).
Returns:
A dictionary with temp_c, condition, and humidity.
"""
key = city.strip().lower()
if key not in WEATHER_DATA:
return {"error": f"No data available for '{city}'."}
return {"city": city.title(), **WEATHER_DATA[key]}
@mcp.tool()
def list_cities() -> list[str]:
"""List all cities for which weather data is available."""
return [c.title() for c in WEATHER_DATA]
if __name__ == "__main__":
# Run over stdio — the default transport for local integrations.
mcp.run()
That's a complete, working MCP server. Let's unpack what's happening:
FastMCPis a high-level helper from the SDK that handles JSON-RPC wiring, schema generation, and transport management.- Decorating a function with
@mcp.tool()automatically registers it as an MCP tool. The SDK inspects the function signature and docstring to generate the JSON schema the client uses to validate calls. - Calling
mcp.run()starts the server on thestdiotransport, reading JSON-RPC messages from stdin and writing responses to stdout.
Adding a Resource
Tools are great for actions, but sometimes you want to expose static or semi-static data the model can read on demand. That's what resources are for. Let's add one that returns the full weather dataset as JSON.
import json
@mcp.resource("weather://all")
def all_weather() -> str:
"""Return the entire weather dataset as a JSON string."""
return json.dumps(WEATHER_DATA, indent=2)
@mcp.resource("weather://{city}")
def weather_by_city(city: str) -> str:
"""Return weather data for a specific city as JSON."""
key = city.strip().lower()
data = WEATHER_DATA.get(key)
if data is None:
return json.dumps({"error": f"Unknown city: {city}"})
return json.dumps({"city": city.title(), **data}, indent=2)
The URI template weather://{city} lets the client request any city by name, while weather://all is a static URI. The SDK handles routing automatically.
Adding a Prompt Template
Prompts are reusable message templates the client can offer to users. They're optional but useful for guiding the model toward a specific workflow.
from mcp.types import TextContent
@mcp.prompt()
def weather_report(city: str) -> list[TextContent]:
"""Generate a prompt asking for a natural-language weather summary."""
return [
TextContent(
type="text",
text=(
f"You are a friendly meteorologist. "
f"Use the get_weather tool to fetch current conditions "
f"for {city}, then write a two-sentence summary for a "
f"general audience."
),
)
]
Running and Testing the Server
Manual Testing with the MCP Inspector
The SDK ships with an interactive inspector that lets you call tools, read resources, and view prompts in a browser UI. Run it with:
mcp dev server.py
This launches a local web interface (typically at http://localhost:5173) where you can:
- Inspect the generated tool schemas.
- Invoke tools with sample arguments and view raw responses.
- Read resources by URI.
- Render prompt templates.
Programmatic Testing with a Client
For automated tests, you can spin up an in-process client using the SDK's ClientSession. Create test_server.py:
"""Smoke test for the weather MCP server."""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main() -> None:
params = StdioServerParameters(
command="python",
args=["server.py"],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# List available tools
tools = await session.list_tools()
print("Tools:", [t.name for t in tools.tools])
# Call get_weather
result = await session.call_tool(
"get_weather", {"city": "Tokyo"}
)
print("get_weather('Tokyo'):", result.content[0].text)
# Read a resource
res = await session.read_resource("weather://all")
print("weather://all:", res.contents[0].text)
if __name__ == "__main__":
asyncio.run(main())
Run it with python test_server.py. You should see the tool list, the Tokyo weather response, and the full dataset printed to your terminal.
Connecting to Claude Desktop
To use your server inside Claude Desktop, edit the configuration file at ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or the equivalent path on your OS:
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
Restart Claude Desktop. You should now see a "weather" server in the tools menu, and you can ask Claude things like "What's the weather in London?" — it will automatically invoke your get_weather tool.
Deploying Over HTTP
For remote deployments, switch from stdio to the Streamable HTTP transport. The SDK provides a StreamableHTTPServerTransport you can mount inside any ASGI app. Here's a minimal example using Starlette:
"""HTTP transport variant of the weather server."""
from mcp.server.fastmcp import FastMCP
from mcp.server.streamable_http import StreamableHTTPServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
import uvicorn
mcp = FastMCP("weather-server")
# ... reuse the same @mcp.tool, @mcp.resource, @mcp.prompt definitions ...
async def create_app() -> Starlette:
transport = StreamableHTTPServerTransport(mcp_server=mcp._mcp_server)
await transport.startup()
async def handle_scope(scope, receive, send):
await transport.handle_request(scope, receive, send)
return Starlette(
routes=[Mount("/mcp", app=handle_scope)],
on_shutdown=[transport.shutdown],
)
if __name__ == "__main__":
app = asyncio.run(create_app())
uvicorn.run(app, host="0.0.0.0", port=8000)
Clients can now connect to http://localhost:8000/mcp. In production, place this behind a reverse proxy and add authentication — typically an API key passed via the Authorization header.
Best Practices
Write Precise Docstrings
The model uses your function's docstring to decide when and how to call it. Be specific about arguments, units, and return shapes. Avoid vague descriptions like "Gets data" — prefer "Returns current temperature in Celsius and humidity percentage for a named city."
Validate Inputs Explicitly
Don't rely solely on the JSON schema. Re-validate inside the function, especially when arguments come from an untrusted model. Use pydantic for complex inputs:
from pydantic import BaseModel, Field
class WeatherQuery(BaseModel):
city: str = Field(min_length=1, max_length=100)
units: str = Field(default="celsius", pattern="^(celsius|fahrenheit)$")
@mcp.tool()
def get_weather_v2(query: WeatherQuery) -> dict:
"""Get weather with validated, structured input."""
base = WEATHER_DATA.get(query.city.strip().lower())
if base is None:
return {"error": "Unknown city"}
temp = base["temp_c"]
if query.units == "fahrenheit":
temp = temp * 9 / 5 + 32
return {"city": query.city.title(), "temp": temp, "units": query.units}
Keep Tools Focused and Idempotent
Each tool should do one thing well. Prefer many small tools over a single "do everything" function. Where possible, make tools idempotent so retries are safe — a get_weather call should never have side effects.
Handle Errors Gracefully
Return structured error objects rather than raising exceptions. The SDK will translate uncaught exceptions into JSON-RPC errors, but a clean error dict gives the model something it can reason about and surface to the user.
Log Everything
Use Python's logging module to record tool invocations, argument values, and durations. Logs go to stderr by default, which keeps stdout clean for JSON-RPC traffic on the stdio transport.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("weather-server")
@mcp.tool()
def get_weather(city: str) -> dict:
logger.info("get_weather called", extra={"city": city})
# ... existing logic ...
Secure Sensitive Operations
If a tool performs destructive actions (deleting records, sending emails), require explicit confirmation by surfacing it through the prompt flow, and always log who called what. Never embed secrets in source code — read them from environment variables or a secrets manager.
Conclusion
Building an MCP server in Python is straightforward thanks to the FastMCP helper: decorate functions to expose tools, resources, and prompts, then run over stdio for local use or HTTP for remote deployments. By following the best practices around precise docstrings, input validation, focused tools, graceful error handling, and thorough logging, you'll produce servers that integrate cleanly with any MCP-compatible client — from Claude Desktop to custom AI pipelines. The protocol's real power emerges when you compose multiple servers together, so once your weather server is solid, try adding a second one for news, databases, or internal APIs and watch your assistant's capabilities multiply.