""" 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()