243 lines
8.1 KiB
Python
243 lines
8.1 KiB
Python
"""
|
|
open_meteo_mcp - MCP server for global weather data via the Open-Meteo API.
|
|
|
|
Provides tools for fetching current weather conditions and multi-day forecasts
|
|
for any location worldwide. No API key required.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging & Output Redirection
|
|
# ---------------------------------------------------------------------------
|
|
import os
|
|
import sys
|
|
|
|
LOG_FILE = os.environ.get("OPEN_METEO_MCP_LOG_FILE", "open_meteo_mcp.log")
|
|
|
|
def setup_redirection():
|
|
"""Reduces noise by suppressing stderr and logging to a file."""
|
|
# Suppress stderr
|
|
devnull = open(os.devnull, "w")
|
|
os.dup2(devnull.fileno(), sys.stderr.fileno())
|
|
|
|
# Configure logging to write to the log file directly
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
filename=LOG_FILE,
|
|
filemode="a"
|
|
)
|
|
|
|
setup_redirection()
|
|
logger = logging.getLogger("open_meteo_mcp")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
OPEN_METEO_BASE = "https://api.open-meteo.com/v1/forecast"
|
|
|
|
# WMO Weather interpretation codes
|
|
WMO_CODES = {
|
|
0: "Clear sky",
|
|
1: "Mainly clear",
|
|
2: "Partly cloudy",
|
|
3: "Overcast",
|
|
45: "Fog",
|
|
48: "Depositing rime fog",
|
|
51: "Light drizzle",
|
|
53: "Moderate drizzle",
|
|
55: "Dense drizzle",
|
|
56: "Light freezing drizzle",
|
|
57: "Dense freezing drizzle",
|
|
61: "Slight rain",
|
|
63: "Moderate rain",
|
|
65: "Heavy rain",
|
|
66: "Light freezing rain",
|
|
67: "Heavy freezing rain",
|
|
71: "Slight snowfall",
|
|
73: "Moderate snowfall",
|
|
75: "Heavy snowfall",
|
|
77: "Snow grains",
|
|
80: "Slight rain showers",
|
|
81: "Moderate rain showers",
|
|
82: "Violent rain showers",
|
|
85: "Slight snow showers",
|
|
86: "Heavy snow showers",
|
|
95: "Thunderstorm",
|
|
96: "Thunderstorm with slight hail",
|
|
99: "Thunderstorm with heavy hail",
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MCP Server
|
|
# ---------------------------------------------------------------------------
|
|
mcp = FastMCP(
|
|
"open_meteo_mcp",
|
|
instructions="Global weather data via Open-Meteo API",
|
|
)
|
|
|
|
|
|
def _describe_weather_code(code: int) -> str:
|
|
return WMO_CODES.get(code, f"Unknown ({code})")
|
|
|
|
|
|
def _c_to_f(celsius: float) -> float:
|
|
return round(celsius * 9 / 5 + 32, 1)
|
|
|
|
|
|
def _temp_both(celsius: float) -> str:
|
|
return f"{celsius}°C / {_c_to_f(celsius)}°F"
|
|
|
|
|
|
def _kmh_to_mph(kmh: float) -> float:
|
|
return round(kmh * 0.621371, 1)
|
|
|
|
|
|
def _wind_both(kmh: float) -> str:
|
|
return f"{kmh}km/h / {_kmh_to_mph(kmh)}mph"
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_current_weather(latitude: float, longitude: float) -> str:
|
|
"""Get current weather conditions for a location.
|
|
|
|
Args:
|
|
latitude: Latitude of the location (e.g. 51.752 for Oxford, UK)
|
|
longitude: Longitude of the location (e.g. -1.258 for Oxford, UK)
|
|
"""
|
|
try:
|
|
params = {
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"current": ",".join([
|
|
"temperature_2m",
|
|
"relative_humidity_2m",
|
|
"apparent_temperature",
|
|
"weather_code",
|
|
"wind_speed_10m",
|
|
"wind_direction_10m",
|
|
"wind_gusts_10m",
|
|
"precipitation",
|
|
"cloud_cover",
|
|
"pressure_msl",
|
|
]),
|
|
"timezone": "auto",
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(OPEN_METEO_BASE, params=params, timeout=30)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
current = data["current"]
|
|
units = data["current_units"]
|
|
tz = data.get("timezone", "Unknown")
|
|
|
|
lines = [
|
|
f"Current Weather (timezone: {tz})",
|
|
f" Time: {current['time']}",
|
|
f" Condition: {_describe_weather_code(current['weather_code'])}",
|
|
f" Temperature: {_temp_both(current['temperature_2m'])}",
|
|
f" Feels like: {_temp_both(current['apparent_temperature'])}",
|
|
f" Humidity: {current['relative_humidity_2m']}{units['relative_humidity_2m']}",
|
|
f" Wind: {_wind_both(current['wind_speed_10m'])} from {current['wind_direction_10m']}{units['wind_direction_10m']}",
|
|
f" Gusts: {_wind_both(current['wind_gusts_10m'])}",
|
|
f" Precipitation: {current['precipitation']}{units['precipitation']}",
|
|
f" Cloud cover: {current['cloud_cover']}{units['cloud_cover']}",
|
|
f" Pressure: {current['pressure_msl']}{units['pressure_msl']}",
|
|
]
|
|
|
|
return "\n".join(lines)
|
|
except Exception as e:
|
|
logger.error("Error in get_current_weather(%s, %s): %s", latitude, longitude, e, exc_info=True)
|
|
return f"Error fetching current weather: {type(e).__name__}: {e}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_forecast(
|
|
latitude: float,
|
|
longitude: float,
|
|
days: int = 7,
|
|
) -> str:
|
|
"""Get a multi-day weather forecast for a location.
|
|
|
|
Args:
|
|
latitude: Latitude of the location
|
|
longitude: Longitude of the location
|
|
days: Number of forecast days (1-16, default 7)
|
|
"""
|
|
try:
|
|
days = max(1, min(16, days))
|
|
|
|
params = {
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"daily": ",".join([
|
|
"weather_code",
|
|
"temperature_2m_max",
|
|
"temperature_2m_min",
|
|
"apparent_temperature_max",
|
|
"apparent_temperature_min",
|
|
"precipitation_sum",
|
|
"precipitation_probability_max",
|
|
"wind_speed_10m_max",
|
|
"wind_gusts_10m_max",
|
|
"wind_direction_10m_dominant",
|
|
"sunrise",
|
|
"sunset",
|
|
]),
|
|
"temperature_unit": "celsius",
|
|
"timezone": "auto",
|
|
"forecast_days": days,
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(OPEN_METEO_BASE, params=params, timeout=30)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
daily = data["daily"]
|
|
units = data["daily_units"]
|
|
tz = data.get("timezone", "Unknown")
|
|
|
|
sections = [f"Forecast for {days} day(s) (timezone: {tz})"]
|
|
|
|
for i in range(len(daily["time"])):
|
|
section = [
|
|
f"\n--- {daily['time'][i]} ---",
|
|
f" Condition: {_describe_weather_code(daily['weather_code'][i])}",
|
|
f" High: {_temp_both(daily['temperature_2m_max'][i])} Low: {_temp_both(daily['temperature_2m_min'][i])}",
|
|
f" Feels like: {_temp_both(daily['apparent_temperature_max'][i])} / {_temp_both(daily['apparent_temperature_min'][i])}",
|
|
f" Precipitation: {daily['precipitation_sum'][i]}{units['precipitation_sum']} (chance: {daily['precipitation_probability_max'][i]}{units['precipitation_probability_max']})",
|
|
f" Wind: {_wind_both(daily['wind_speed_10m_max'][i])} gusts {_wind_both(daily['wind_gusts_10m_max'][i])} from {daily['wind_direction_10m_dominant'][i]}{units['wind_direction_10m_dominant']}",
|
|
f" Sunrise: {daily['sunrise'][i]} Sunset: {daily['sunset'][i]}",
|
|
]
|
|
sections.append("\n".join(section))
|
|
|
|
return "\n".join(sections)
|
|
except Exception as e:
|
|
logger.error("Error in get_forecast(%s, %s, days=%s): %s", latitude, longitude, days, e, exc_info=True)
|
|
return f"Error fetching forecast: {type(e).__name__}: {e}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entrypoints
|
|
# ---------------------------------------------------------------------------
|
|
def main():
|
|
logger.info("Starting open_meteo_mcp on streamable-http")
|
|
mcp.run(transport="streamable-http")
|
|
|
|
|
|
def main_stdio():
|
|
logger.info("Starting open_meteo_mcp via stdio")
|
|
mcp.run(transport="stdio")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|