MCP Resources: Adding Climate Data to Your Weather Server

In the previous MCP tutorial, we built a weather forecast server with Tools that let clients like Claude fetch current conditions and forecasts. Today, we're extending it with Resources - giving the client access to historical climate data for richer context and year-over-year comparisons.
By the end of this tutorial, the client will be able to answer questions like "Is Seattle warmer this January than the 30-year average?" by combining real-time forecast data with historical climate normals.
Understanding MCP Resources
The Model Context Protocol defines three core capabilities:
- Tools: Functions the LLM can invoke (actions)
- Resources: Data the LLM can read (content)
- Prompts: Reusable templates for common workflows
We've already implemented Tools. Now let's explore Resources.
Tools vs Resources: When to Use Each
| Aspect | Tools | Resources |
|---|---|---|
| Purpose | Perform actions | Provide data |
| Mutability | Can modify state | Read-only |
| Invocation | Called with arguments | Accessed via URI |
| Schema | JSON Schema for inputs | URI templates for discovery |
| Examples | get_forecast(lat, lon) | weather://climate/seattle/01 |
| Response | Dynamic results | Static or semi-static content |
Use Tools when:
- Action or computation required
- Dynamic input parameters
- State changes or side effects
- Real-time API calls
Use Resources when:
- Read-only data access
- Hierarchical information (file-like URIs)
- Historical or reference data
- Contextual information for prompts
Real-World Example: Current Weather
Before (Tools only):
User: "Is it unusually warm in Seattle today?"
Claude: [calls get_forecast] "It's 58°F."
Claude: "I don't have historical data to compare."
After (Tools + Resources):
User: "Is it unusually warm in Seattle today?"
Claude: [reads weather://climate/seattle-wa/01] # January normals
Claude: [calls get_forecast] # Current conditions
Claude: "It's 58°F today, which is 5°F above the January average of 53°F."
Resources provide the context that Tools alone cannot deliver.
Resource Templates: Dynamic URIs
Resource Templates let you define URI patterns with variables, enabling discovery without enumerating all possible URIs.
Syntax
@mcp.resource("weather://climate/{location}/{month}")
async def get_climate_data(uri: str) -> str:
# Parse {location} and {month} from URI
# Return JSON content
How MCP Hosts Use Templates
- Discovery: Host calls
resources/list→ receives template patterns - Matching: User query mentions "Seattle January"
- Construction: Host generates
weather://climate/seattle-wa/01 - Access: Host calls
resources/readwith that URI - Response: Server parses URI, fetches data, returns JSON
Templates enable millions of potential URIs without listing them all!
Adding Climate Normals Resource
We'll use the NOAA Climate Data Online (CDO) API to fetch 30-year climate normals (1991-2020 averages).
Step 1: Get NOAA API Token
The CDO API requires a free token:
- Request at: https://www.ncdc.noaa.gov/cdo-web/token
- Enter your email
- Receive token via email (usually within seconds)
Rate Limits: 5 requests/second, 10,000/day — extremely generous for personal use!
Step 2: Configure Environment
Add your token to the environment:
# ~/.bashrc or ~/.zshrc
export NOAA_API_TOKEN="your_token_here"
# Or create .env file (if using python-dotenv)
echo "NOAA_API_TOKEN=your_token_here" > .env
Step 3: Update Dependencies
Add to pyproject.toml:
dependencies = [
"mcp[cli]>=1.2.0",
"httpx>=0.28.0",
"python-dotenv>=1.0.0", # For .env support
]
Sync dependencies:
uv sync
Step 4: Implement Climate Normals Resource
Update src/weather_forecast_mcp_server/server.py:
#!/usr/bin/env python3
"""
Weather forecast MCP server with Tools and Resources.
"""
import os
import re
from typing import Any
from urllib.parse import urlparse
import httpx
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Initialize MCP server
mcp = FastMCP("weather-server")
# Constants
NWS_API_BASE = "https://api.weather.gov"
NOAA_CDO_BASE = "https://www.ncei.noaa.gov/cdo-web/api/v2"
NOMINATIM_BASE = "https://nominatim.openstreetmap.org"
USER_AGENT = "WeatherMCPServer/2.0 (https://github.com/slin-master/weather-forecast-mcp-server)"
NOAA_TOKEN = os.getenv("NOAA_API_TOKEN")
# Month name to number mapping
MONTH_MAP = {
"january": "01", "jan": "01",
"february": "02", "feb": "02",
"march": "03", "mar": "03",
"april": "04", "apr": "04",
"may": "05",
"june": "06", "jun": "06",
"july": "07", "jul": "07",
"august": "08", "aug": "08",
"september": "09", "sep": "09",
"october": "10", "oct": "10",
"november": "11", "nov": "11",
"december": "12", "dec": "12",
}
# ... [existing tool functions: geocode_city, get_forecast, get_alerts] ...
@mcp.resource("weather://climate/normals/{location}/{month}")
async def get_climate_normals(uri: str) -> str:
"""
Get 30-year climate normals (1991-2020) for a location and month.
Provides average temperature, precipitation, and other climate statistics
to compare current conditions against historical baselines.
URI format: weather://climate/normals/{location}/{month}
- location: City name (e.g., "seattle-wa", "new-york-ny")
- month: Month number 01-12 or name (e.g., "01", "january")
Example URIs:
- weather://climate/normals/seattle-wa/01
- weather://climate/normals/portland-or/july
- weather://climate/normals/miami-fl/12
"""
if not NOAA_TOKEN:
return json.dumps({
"error": "NOAA_API_TOKEN not configured",
"help": "Get a free token at https://www.ncdc.noaa.gov/cdo-web/token",
"instructions": "Set environment variable: export NOAA_API_TOKEN='your_token'"
}, indent=2)
# Parse URI
parsed = urlparse(uri)
path_parts = parsed.path.strip('/').split('/')
if len(path_parts) != 3 or path_parts[0] != "normals":
return json.dumps({
"error": "Invalid URI format",
"expected": "weather://climate/normals/{location}/{month}",
"received": uri
}, indent=2)
location_slug = path_parts[1] # e.g., "seattle-wa"
month_input = path_parts[2].lower() # e.g., "01" or "january"
# Normalize month to number
if month_input in MONTH_MAP:
month = MONTH_MAP[month_input]
elif month_input.isdigit() and 1 <= int(month_input) <= 12:
month = month_input.zfill(2)
else:
return json.dumps({
"error": f"Invalid month: {month_input}",
"valid_formats": "01-12 or january-december"
}, indent=2)
# Parse location (e.g., "seattle-wa" -> "Seattle, WA")
location_parts = location_slug.split('-')
if len(location_parts) < 2:
return json.dumps({
"error": "Invalid location format",
"expected": "city-state (e.g., seattle-wa)",
"received": location_slug
}, indent=2)
city = ' '.join(location_parts[:-1]).title()
state = location_parts[-1].upper()
location_name = f"{city}, {state}"
async with httpx.AsyncClient() as client:
try:
# Step 1: Geocode to find nearest NOAA station
geocode_response = await client.get(
f"{NOMINATIM_BASE}/search",
params={
"q": location_name,
"format": "json",
"limit": 1,
"countrycodes": "us"
},
headers={"User-Agent": USER_AGENT},
timeout=10.0
)
geocode_response.raise_for_status()
geocode_results = geocode_response.json()
if not geocode_results:
return json.dumps({
"error": f"Location not found: {location_name}"
}, indent=2)
lat = float(geocode_results[0]["lat"])
lon = float(geocode_results[0]["lon"])
# Step 2: Find nearest NOAA station with NORMAL_MLY data
# Search within ~50km radius
extent = f"{lat-0.5},{lon-0.5},{lat+0.5},{lon+0.5}"
stations_response = await client.get(
f"{NOAA_CDO_BASE}/stations",
headers={
"token": NOAA_TOKEN,
"User-Agent": USER_AGENT
},
params={
"datasetid": "NORMAL_MLY",
"extent": extent,
"limit": 5,
"sortfield": "datacoverage",
"sortorder": "desc"
},
timeout=15.0
)
stations_response.raise_for_status()
stations_data = stations_response.json()
if not stations_data.get("results"):
return json.dumps({
"error": "No climate stations found near location",
"location": location_name,
"coordinates": {"lat": lat, "lon": lon},
"note": "Climate normals may not be available for all locations"
}, indent=2)
station = stations_data["results"][0]
station_id = station["id"]
# Step 3: Fetch monthly climate normals
# Dataset: NORMAL_MLY (Monthly Normals)
# Data types: MLY-TAVG-NORMAL, MLY-TMAX-NORMAL, MLY-TMIN-NORMAL, MLY-PRCP-NORMAL
start_date = f"2010-{month}-01" # Normals dataset uses 2010 as base year
end_date = f"2010-{month}-01"
data_response = await client.get(
f"{NOAA_CDO_BASE}/data",
headers={
"token": NOAA_TOKEN,
"User-Agent": USER_AGENT
},
params={
"datasetid": "NORMAL_MLY",
"stationid": station_id,
"startdate": start_date,
"enddate": end_date,
"datatypeid": [
"MLY-TAVG-NORMAL", # Average temperature
"MLY-TMAX-NORMAL", # Average maximum
"MLY-TMIN-NORMAL", # Average minimum
"MLY-PRCP-NORMAL", # Precipitation
],
"limit": 100,
"units": "standard" # Fahrenheit, inches
},
timeout=15.0
)
data_response.raise_for_status()
climate_data = data_response.json()
if not climate_data.get("results"):
return json.dumps({
"error": "No climate normal data available",
"station": station["name"],
"month": month
}, indent=2)
# Parse data types into structured format
normals = {}
for record in climate_data["results"]:
datatype = record["datatype"]
value = record["value"]
if datatype == "MLY-TAVG-NORMAL":
normals["avg_temp_f"] = value / 10.0 # Tenths of degrees
elif datatype == "MLY-TMAX-NORMAL":
normals["avg_high_f"] = value / 10.0
elif datatype == "MLY-TMIN-NORMAL":
normals["avg_low_f"] = value / 10.0
elif datatype == "MLY-PRCP-NORMAL":
normals["avg_precipitation_inches"] = value / 100.0 # Hundredths of inches
return json.dumps({
"location": {
"name": location_name,
"coordinates": {"lat": lat, "lon": lon},
"station": station["name"],
"station_id": station_id,
"elevation_meters": station.get("elevation"),
},
"period": "1991-2020", # Current normals period
"month": {
"number": month,
"name": list(MONTH_MAP.keys())[list(MONTH_MAP.values()).index(month)]
},
"normals": normals,
"metadata": {
"source": "NOAA Climate Data Online",
"dataset": "NORMAL_MLY",
"units": {
"temperature": "Fahrenheit",
"precipitation": "inches"
}
}
}, indent=2)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return json.dumps({
"error": "Rate limit exceeded",
"limit": "5 requests/second, 10,000/day",
"retry_after": e.response.headers.get("Retry-After", "60 seconds")
}, indent=2)
return json.dumps({
"error": f"NOAA API error: {e.response.status_code}",
"message": str(e)
}, indent=2)
except Exception as e:
return json.dumps({
"error": f"Failed to fetch climate data: {str(e)}"
}, indent=2)
Step 5: Register Resource Template
FastMCP automatically registers the resource template from the decorator. The URI pattern weather://climate/normals/{location}/{month} tells MCP hosts which URIs are available.
Testing the Resource
With MCP Inspector
npx @modelcontextprotocol/inspector uv run mcp run src/weather_forecast_mcp_server/server.py
- Open http://localhost:5173
- Go to Resources tab
- See template:
weather://climate/normals/{location}/{month} - Test URI:
weather://climate/normals/seattle-wa/01 - View response with 30-year averages
With Claude Desktop
Update claude_desktop_config.json to include the token:
{
"mcpServers": {
"weather": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/weather-forecast-mcp-server",
"run",
"mcp",
"run",
"src/weather_forecast_mcp_server/server.py"
],
"env": {
"NOAA_API_TOKEN": "your_token_here"
}
}
}
}
Restart Claude Desktop and try:
User: "Is Seattle warmer than normal this January?"
Claude:
- Reads
weather://climate/normals/seattle-wa/01→ 43°F average - Calls
get_forecast(47.6062, -122.3321)→ 52°F current - Responds: "Yes, Seattle is about 9°F above the January average of 43°F."
Advanced Patterns
Combining Resources and Tools
Resources provide context, Tools provide current state. Together, they enable sophisticated analysis:
# Resource: Historical baseline
weather://climate/normals/portland-or/12
# Tool: Current conditions
get_forecast(45.5152, -122.6784)
# LLM combines both:
"Portland is experiencing unseasonably warm weather—
58°F today vs. 47°F December average (+11°F)"
Resource Hierarchies
You can define multiple resource levels:
@mcp.resource("weather://climate/normals/{location}/{month}")
async def monthly_normals(uri: str) -> str:
"""30-year monthly averages"""
@mcp.resource("weather://climate/extremes/{location}")
async def climate_extremes(uri: str) -> str:
"""All-time records for a location"""
@mcp.resource("weather://climate/trends/{location}/{decade}")
async def climate_trends(uri: str) -> str:
"""Decade-by-decade climate changes"""
This creates a URI namespace that feels like a filesystem!
Pagination for Large Datasets
For resources with many records, implement pagination:
@mcp.resource("weather://history/{location}/{year}?page={page}")
async def historical_data(uri: str) -> str:
parsed = urlparse(uri)
params = parse_qs(parsed.query)
page = int(params.get("page", ["1"])[0])
# Return paginated results with metadata
return json.dumps({
"data": [...],
"pagination": {
"page": page,
"total_pages": 12,
"next_uri": f"weather://history/{location}/{year}?page={page+1}"
}
})
Docker Deployment with Secrets
Update compose.yml to pass the API token securely:
services:
weather-mcp:
build: .
container_name: weather-mcp-server
restart: unless-stopped
environment:
- NOAA_API_TOKEN=${NOAA_API_TOKEN}
# Use .env file or pass via command line:
# NOAA_API_TOKEN=xyz docker compose up -d
Create .env file (add to .gitignore!):
NOAA_API_TOKEN=your_token_here
Build and run:
docker compose up -d
The server now has access to climate normals in production!
Resource Best Practices
1. URI Design
✅ Good: Hierarchical, predictable
weather://climate/normals/seattle-wa/01
weather://climate/extremes/seattle-wa
❌ Bad: Flat, random
weather://get_normals?city=seattle&month=1
weather://climate_data_12345
2. Error Handling
Always return valid JSON, even for errors:
if not data:
return json.dumps({
"error": "No data available",
"help": "Try a different location or month"
}, indent=2)
3. Metadata
Include context for better LLM understanding:
{
"data": {...},
"metadata": {
"source": "NOAA CDO",
"period": "1991-2020",
"units": {"temp": "F"},
"last_updated": "2021-01-01"
}
}
4. Caching
Climate normals don't change often - cache aggressively:
from functools import lru_cache
@lru_cache(maxsize=100)
async def fetch_climate_normals(station_id: str, month: str):
# Expensive API call
# Cached for the session
When NOT to Use Resources
❌ Don't use Resources for:
- Frequently changing data (use Tools instead)
- User input that triggers actions
- Data requiring authentication per-request
- Binary data (images, PDFs) — use Tools to generate download links
✅ Do use Resources for:
- Reference data (documentation, schemas)
- Historical records
- Static configurations
- Semi-static datasets (updated monthly/yearly)
Publishing to MCP Registries
With Resources added, your server is more discoverable and useful. Consider publishing to:
Resources make your server self-documenting - registries can discover capabilities automatically!
Next Steps
You've now built a weather MCP server with:
- ✅ Tools: Actions for current forecasts and alerts
- ✅ Resources: Historical climate data for context
- ✅ Templates: Dynamic URI patterns for discovery
Future enhancements:
- Add Prompts capability for common weather queries
- Implement caching layer (Redis) for normals
- Support international weather APIs
- Add air quality and UV index data
- Publish to PyPI as installable package
Learn More
- MCP Specification - Resources
- NOAA Climate Data Online API
- Building Your First MCP Server (Tools tutorial)
- Agent Skills vs MCP Security (Architecture comparison)
Conclusion
Resources complement Tools by providing read-only context that enriches AI interactions. By adding climate normals to our weather server, we've enabled Claude to:
- Compare current conditions to historical baselines
- Answer "is this normal?" questions confidently
- Provide year-over-year trend analysis
The combination of Tools (actions) and Resources (data) makes MCP servers incredibly powerful—giving AI agents the context they need to deliver truly intelligent responses.
Ready to add Resources to your own MCP server? Start with reference data your LLM frequently needs, expose it via URI templates, and watch the quality of responses improve!