Files
Skald-Circle/scripts/weather_mcp_server.py
2026-07-10 15:02:09 +01:00

780 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Weather MCP server (JSON-RPC 2.0 over stdio) using Open-Meteo API.
Capabilities (callable as `mcp__weather__<tool>`):
status — self-check: confirms connectivity to Open-Meteo
get_current_weather — current conditions for any city worldwide
get_forecast — multi-day forecast with daily min/max, rain %, sunrise/sunset
get_air_quality — air quality index, pollutants, health advice
Data sources (free, no API key required):
- Open-Meteo Forecast API (weather, forecast)
- Open-Meteo Air Quality API (air quality)
- Open-Meteo Geocoding API (city name → coordinates)
Run with:
python3 scripts/weather_mcp_server.py
"""
from __future__ import annotations
import json
import sys
from typing import Any
import httpx
# ── Logging ─────────────────────────────────────────────────────────────────────
def log(msg: str) -> None:
print(f"[weather_mcp] {msg}", file=sys.stderr, flush=True)
# ── Constants ───────────────────────────────────────────────────────────────────
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
AIR_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
COMMON_HEADERS = {
"User-Agent": "SkaldWeatherMCP/1.0",
"Accept": "application/json",
}
HTTP_TIMEOUT = 10.0
# WMO weather codes → human-readable description
WMO_CODES: dict[int, str] = {
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",
}
# ── Helpers ─────────────────────────────────────────────────────────────────────
def _wmo_desc(code: int | None) -> str:
"""Convert WMO weather code to human-readable text."""
if code is None:
return "Unknown"
return WMO_CODES.get(code, f"Unknown ({code})")
def _aqi_label_eu(value: Any) -> str:
"""European AQI band name. Open-Meteo returns a numeric EAQI on the
0100+ scale: 020 Good, 2040 Fair, 4060 Moderate, 6080 Poor,
80100 Very poor, >100 Extremely poor."""
v = _num(value)
if v is None:
return "Unknown" if value is None else f"Unknown ({value})"
if v <= 20:
return "Good"
if v <= 40:
return "Fair"
if v <= 60:
return "Moderate"
if v <= 80:
return "Poor"
if v <= 100:
return "Very Poor"
return "Extremely Poor"
def _aqi_label_us(value: Any) -> str:
"""US AQI band name. Open-Meteo returns a numeric USAQI on the 0500
scale: 050 Good, 51100 Moderate, 101150 Unhealthy for sensitive
groups, 151200 Unhealthy, 201300 Very Unhealthy, 301500 Hazardous."""
v = _num(value)
if v is None:
return "Unknown" if value is None else f"Unknown ({value})"
if v <= 50:
return "Good"
if v <= 100:
return "Moderate"
if v <= 150:
return "Unhealthy for sensitive groups"
if v <= 200:
return "Unhealthy"
if v <= 300:
return "Very Unhealthy"
return "Hazardous"
def _wind_direction(degrees: float | None) -> str:
"""Convert wind degrees to compass direction."""
if degrees is None:
return "?"
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
idx = round(degrees / 22.5) % 16
return directions[idx]
def _num(val: Any) -> float | None:
"""Best-effort numeric coercion. Returns None if missing or non-numeric.
Avoids ValueError/TypeError when the API returns null or an unexpected type
and the caller wants a safe numeric comparison.
"""
if val is None or isinstance(val, bool):
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def _format_http_error(e: Exception, api_label: str) -> str:
"""Map an httpx/network exception into an actionable Error: string.
`api_label` names the failing endpoint family (e.g. "Forecast", "Geocoding")
so the user/LLM knows where to look.
"""
if isinstance(e, httpx.TimeoutException):
return f"Error: {api_label} API request timed out. Retry in a moment."
if isinstance(e, httpx.HTTPStatusError):
return f"Error: {api_label} API returned HTTP {e.response.status_code}."
if isinstance(e, httpx.HTTPError):
return f"Error: {api_label} API request failed (network error): {e}."
return f"Error: {api_label} API call failed: {e}"
def _air_quality_advice(eu_aqi: Any, us_aqi: Any) -> str | None:
"""Health-advice string based on the Open-Meteo numeric AQI scales.
Prefers the European AQI (EAQI 0100+); falls back to the US AQI (0500).
Returns None when no AQI value is available.
"""
eu_n = _num(eu_aqi)
us_n = _num(us_aqi)
if eu_n is not None:
if eu_n <= 20:
return "✅ Air quality is good — no health concerns."
if eu_n <= 40:
return "✅ Air quality is fair — no health concerns."
if eu_n <= 60:
return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity."
if eu_n <= 80:
return "⚠️ Poor air quality. Consider reducing outdoor activities, especially if you have respiratory conditions."
return "🚨 Very poor or extremely poor air quality. Avoid outdoor exertion. Wear a mask if you must go out."
if us_n is not None:
if us_n <= 50:
return "✅ Air quality is good — no health concerns."
if us_n <= 100:
return "⚠️ Moderate air quality. Sensitive individuals should limit prolonged outdoor activity."
if us_n <= 150:
return "⚠️ Unhealthy for sensitive groups. Reduce prolonged outdoor exertion."
return "🚨 Unhealthy or hazardous air quality. Avoid outdoor exertion. Wear a mask if you must go out."
return None
def _geocode(city: str) -> tuple[float, float, str, str] | None:
"""Resolve a city name to coordinates. Returns (lat, lon, name, country).
Returns None when the city is not found. Raises httpx.HTTPError on a
network/HTTP failure — the caller is expected to catch and surface it via
`_format_http_error` so the error is actionable rather than "Internal error".
"""
params = {"name": city, "count": 3, "language": "en", "format": "json"}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(GEOCODING_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
if not results:
return None
r = results[0]
return (
float(r["latitude"]),
float(r["longitude"]),
r.get("name", city),
r.get("country", ""),
)
# ── Tool implementations ────────────────────────────────────────────────────────
def _weather_status(args: dict[str, Any]) -> str:
"""Self-check: confirm Open-Meteo is reachable and serving data.
Performs one cheap geocode ("Rome") plus one current-weather probe so we
exercise the Geocoding + Forecast endpoints in a single round-trip.
"""
try:
geo = _geocode("Rome")
if geo is None:
return "Error: Geocoding API returned no result for the probe query."
lat, lon, _, _ = geo
params = {"latitude": lat, "longitude": lon, "current": "temperature_2m"}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
if not data.get("current"):
return "Error: Forecast API responded but returned no current data for the probe."
return (
"OK: Open-Meteo is reachable. Geocoding and Forecast APIs respond.\n"
"All tools (get_current_weather, get_forecast, get_air_quality) are operational."
)
except Exception as e:
return _format_http_error(e, "Forecast")
def _weather_current(args: dict[str, Any]) -> str:
"""Get current weather conditions for a city."""
city = args.get("city", "").strip()
if not city:
return "Error: Missing required parameter 'city'."
units = args.get("units", "metric")
if units not in ("metric", "imperial"):
return "Error: 'units' must be 'metric' or 'imperial'."
try:
geo = _geocode(city)
if geo is None:
return f"Error: Could not find location '{city}'. Check spelling and use English names."
lat, lon, name, country = geo
params = {
"latitude": lat,
"longitude": lon,
"current": (
"temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,"
"wind_speed_10m,wind_direction_10m,wind_gusts_10m,cloud_cover,"
"precipitation,rain,uv_index,pressure_msl,visibility"
),
"timezone": "auto",
}
if units == "imperial":
params["temperature_unit"] = "fahrenheit"
params["wind_speed_unit"] = "mph"
params["precipitation_unit"] = "inch"
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return _format_http_error(e, "Forecast")
cur = data.get("current", {})
if not cur:
return f"Error: No current weather data available for '{city}'."
temp_unit = "°F" if units == "imperial" else "°C"
wind_unit = "mph" if units == "imperial" else "km/h"
precip_unit = "in" if units == "imperial" else "mm"
# Open-Meteo returns visibility always in meters; no unit selector exists,
# so we convert explicitly per unit system.
vis_m = _num(cur.get("visibility"))
if vis_m is not None:
if units == "imperial":
vis_str, vis_unit = f"{vis_m / 1609.34:.1f}", "mi"
else:
vis_str, vis_unit = f"{vis_m / 1000:.1f}", "km"
else:
vis_str, vis_unit = "?", "km" if units == "metric" else "mi"
temp = cur.get("temperature_2m", "?")
feels_like = cur.get("apparent_temperature", "?")
humidity = cur.get("relative_humidity_2m", "?")
wmo = cur.get("weather_code")
wind_speed = cur.get("wind_speed_10m", "?")
wind_deg = cur.get("wind_direction_10m")
wind_gust = cur.get("wind_gusts_10m")
cloud = cur.get("cloud_cover", "?")
precip = _num(cur.get("precipitation"))
rain = _num(cur.get("rain"))
uv = cur.get("uv_index", "?")
pressure = cur.get("pressure_msl", "?")
loc_label = f"{name}, {country}" if country else name
lines = [
f"📍 {loc_label} (Current weather)",
f"",
f"🌡 Temperature: {temp}{temp_unit} (feels like {feels_like}{temp_unit})",
f"☁️ Conditions: {_wmo_desc(wmo)}",
f"💧 Humidity: {humidity}%",
f"🌬 Wind: {wind_speed} {wind_unit} from {_wind_direction(wind_deg)}",
]
wind_gust_n = _num(wind_gust)
if wind_gust_n and wind_gust_n > 0:
lines.append(f" Gusts up to {wind_gust_n:g} {wind_unit}")
lines.append(f"☁️ Cloud cover: {cloud}%")
lines.append(f"📊 Pressure: {pressure} hPa")
lines.append(f"👁 Visibility: {vis_str} {vis_unit}")
lines.append(f"☀️ UV index: {uv}")
if precip and precip > 0:
lines.append(f"🌧 Precipitation: {precip:g} {precip_unit}")
elif rain and rain > 0:
lines.append(f"🌧 Rain: {rain:g} {precip_unit}")
return "\n".join(lines)
def _weather_forecast(args: dict[str, Any]) -> str:
"""Get multi-day weather forecast for a city."""
city = args.get("city", "").strip()
if not city:
return "Error: Missing required parameter 'city'."
days_raw = args.get("days", 5)
try:
days = int(days_raw)
except (TypeError, ValueError):
return f"Error: 'days' must be an integer between 1 and 16. Got: {days_raw!r}."
if days < 1 or days > 16:
return "Error: 'days' must be between 1 and 16."
units = args.get("units", "metric")
if units not in ("metric", "imperial"):
return "Error: 'units' must be 'metric' or 'imperial'."
try:
geo = _geocode(city)
if geo is None:
return f"Error: Could not find location '{city}'. Check spelling and use English names."
lat, lon, name, country = geo
params = {
"latitude": lat,
"longitude": lon,
"daily": (
"weather_code,temperature_2m_max,temperature_2m_min,"
"apparent_temperature_max,apparent_temperature_min,"
"precipitation_sum,rain_sum,precipitation_probability_max,"
"sunrise,sunset,wind_speed_10m_max,wind_direction_10m_dominant"
),
"forecast_days": days,
"timezone": "auto",
}
if units == "imperial":
params["temperature_unit"] = "fahrenheit"
params["wind_speed_unit"] = "mph"
params["precipitation_unit"] = "inch"
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(FORECAST_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return _format_http_error(e, "Forecast")
daily = data.get("daily", {})
if not daily or "time" not in daily:
return f"Error: No forecast data available for '{city}'."
temp_unit = "°F" if units == "imperial" else "°C"
wind_unit = "mph" if units == "imperial" else "km/h"
precip_unit = "in" if units == "imperial" else "mm"
loc_label = f"{name}, {country}" if country else name
lines = [f"📍 {loc_label}{days}-day forecast"]
lines.append("")
times = daily.get("time", [])
max_temps = daily.get("temperature_2m_max", [])
min_temps = daily.get("temperature_2m_min", [])
feel_max = daily.get("apparent_temperature_max", [])
feel_min = daily.get("apparent_temperature_min", [])
wmos = daily.get("weather_code", [])
precip_sum = daily.get("precipitation_sum", [])
rain_sum = daily.get("rain_sum", [])
precip_prob = daily.get("precipitation_probability_max", [])
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
wind_max = daily.get("wind_speed_10m_max", [])
wind_dir = daily.get("wind_direction_10m_dominant", [])
for i, t in enumerate(times):
lines.append(f"── {t} ──")
lines.append(f" 🌡 {min_temps[i] if i < len(min_temps) else '?'}{max_temps[i] if i < len(max_temps) else '?'}{temp_unit}"
f" (feels {feel_min[i] if i < len(feel_min) else '?'}{feel_max[i] if i < len(feel_max) else '?'}{temp_unit})")
lines.append(f" ☁️ {_wmo_desc(wmos[i] if i < len(wmos) else None)}")
prob = precip_prob[i] if i < len(precip_prob) else 0
ps_n = _num(precip_sum[i] if i < len(precip_sum) else None)
rs_n = _num(rain_sum[i] if i < len(rain_sum) else None)
if prob and prob > 0:
lines.append(f" 🌧 Rain: {prob}% chance"
f"{f', {ps_n:g} {precip_unit} precip' if ps_n and ps_n > 0 else ''}"
f"{f' ({rs_n:g} {precip_unit} rain)' if rs_n and rs_n > 0 else ''}")
wd = wind_dir[i] if i < len(wind_dir) else None
wm_n = _num(wind_max[i] if i < len(wind_max) else None)
if wm_n is not None and wm_n > 0:
lines.append(f" 🌬 Wind: up to {wm_n:g} {wind_unit} from {_wind_direction(wd)}")
sr = sunrises[i] if i < len(sunrises) else ""
ss = sunsets[i] if i < len(sunsets) else ""
if sr and ss:
lines.append(f" 🌅 Sunrise: {sr} | 🌇 Sunset: {ss}")
lines.append("")
return "\n".join(lines)
def _weather_air_quality(args: dict[str, Any]) -> str | dict[str, Any]:
"""Get air quality data for a city.
On success returns a structured result carrying the formatted text summary
alongside the raw numeric AQI/pollutant values (see `outputSchema`). On
failure returns a plain `Error:` string.
"""
city = args.get("city", "").strip()
if not city:
return "Error: Missing required parameter 'city'."
try:
geo = _geocode(city)
if geo is None:
return f"Error: Could not find location '{city}'. Check spelling and use English names."
lat, lon, name, country = geo
params = {
"latitude": lat,
"longitude": lon,
"current": (
"european_aqi,us_aqi,pm2_5,pm10,"
"nitrogen_dioxide,ozone,carbon_monoxide,sulphur_dioxide,ammonia"
),
"timezone": "auto",
}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(AIR_URL, params=params, headers=COMMON_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
return _format_http_error(e, "Air Quality")
cur = data.get("current", {})
if not cur:
return f"Error: No air quality data available for '{city}'."
eu_aqi = cur.get("european_aqi")
us_aqi = cur.get("us_aqi")
# Numeric pollutant values (None when missing/non-numeric).
pm25 = _num(cur.get("pm2_5"))
pm10 = _num(cur.get("pm10"))
no2 = _num(cur.get("nitrogen_dioxide"))
o3 = _num(cur.get("ozone"))
co = _num(cur.get("carbon_monoxide"))
so2 = _num(cur.get("sulphur_dioxide"))
nh3 = _num(cur.get("ammonia"))
pollutants = {"pm2_5": pm25, "pm10": pm10, "no2": no2, "o3": o3,
"co": co, "so2": so2, "nh3": nh3}
eu_label = _aqi_label_eu(eu_aqi) if eu_aqi is not None else None
us_label = _aqi_label_us(us_aqi) if us_aqi is not None else None
advice = _air_quality_advice(eu_aqi, us_aqi)
loc_label = f"{name}, {country}" if country else name
# ── Formatted text summary (kept inside the structured payload so the LLM
# still has the human-readable emoji output alongside the raw numbers).
def _fmt(val: float | None) -> str:
return f"{val:g} µg/m³" if val is not None else "?"
lines = [f"📍 {loc_label} (Air Quality)", ""]
if eu_aqi is not None:
lines.append(f"🇪🇺 European AQI: {eu_aqi} ({eu_label})")
if us_aqi is not None:
lines.append(f"🇺🇸 US AQI: {us_aqi} ({us_label})")
lines.append("")
lines.append(" • PM2.5: " + _fmt(pm25))
lines.append(" • PM10: " + _fmt(pm10))
lines.append(" • NO₂: " + _fmt(no2))
lines.append(" • O₃: " + _fmt(o3))
lines.append(" • CO: " + _fmt(co))
lines.append(" • SO₂: " + _fmt(so2))
lines.append(" • NH₃: " + _fmt(nh3))
lines.append("")
if advice:
lines.append(advice)
return {
"location": loc_label,
"summary": "\n".join(lines),
"european_aqi": eu_aqi,
"european_aqi_label": eu_label,
"us_aqi": us_aqi,
"us_aqi_label": us_label,
"pollutants_ug_m3": pollutants,
"health_advice": advice,
}
# ── Tool manifest ────────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "status",
"description": (
"Self-check that the Weather integration is operational: verifies the "
"Open-Meteo Geocoding and Forecast APIs are reachable by performing one "
"cheap probe (geocode 'Rome' + current temperature). Call this first "
"whenever another weather tool fails, or to give the user a quick yes/no "
"on whether weather data is usable right now."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_current_weather",
"description": (
"Get current weather conditions for any city worldwide. "
"Returns temperature, feels-like, humidity, wind (speed/direction/gusts), "
"conditions (clear/rain/snow/etc.), cloud cover, pressure, visibility, "
"UV index, and precipitation. Free, no API key required."
),
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name in English (e.g. 'London', 'Rome', 'Tokyo').",
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.",
},
},
"required": ["city"],
},
},
{
"name": "get_forecast",
"description": (
"Get multi-day weather forecast for any city worldwide. "
"Returns daily min/max temperature (and feels-like), conditions, rain probability, "
"precipitation amounts, wind max/direction, sunrise/sunset times. "
"Use when you need to plan upcoming days. Free, no API key required."
),
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name in English (e.g. 'Paris', 'New York').",
},
"days": {
"type": "integer",
"description": "Number of forecast days (116). Default: 5.",
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Unit system. 'metric' = °C, km/h, mm; 'imperial' = °F, mph, in. Default: 'metric'.",
},
},
"required": ["city"],
},
},
{
"name": "get_air_quality",
"description": (
"Get current air quality for any city worldwide. "
"Returns European and US AQI indices, plus detailed pollutant levels: "
"PM2.5, PM10, NO₂, O₃, CO, SO₂, NH₃. Includes health advice based on the AQI level. "
"Returns structured content: a `summary` text plus the raw numeric AQI and "
"pollutant values for machine consumption. Free, no API key required."
),
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name in English (e.g. 'Beijing', 'London').",
},
},
"required": ["city"],
},
"outputSchema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"summary": {"type": "string"},
"european_aqi": {"type": ["number", "null"]},
"european_aqi_label": {"type": ["string", "null"]},
"us_aqi": {"type": ["number", "null"]},
"us_aqi_label": {"type": ["string", "null"]},
"pollutants_ug_m3": {
"type": "object",
"properties": {
"pm2_5": {"type": ["number", "null"]},
"pm10": {"type": ["number", "null"]},
"no2": {"type": ["number", "null"]},
"o3": {"type": ["number", "null"]},
"co": {"type": ["number", "null"]},
"so2": {"type": ["number", "null"]},
"nh3": {"type": ["number", "null"]},
},
},
"health_advice": {"type": ["string", "null"]},
},
},
},
]
TOOL_DISPATCH = {
"status": _weather_status,
"get_current_weather": _weather_current,
"get_forecast": _weather_forecast,
"get_air_quality": _weather_air_quality,
}
# ── JSON-RPC dispatch ────────────────────────────────────────────────────────────
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def _structured_result(req_id: Any, structured: dict) -> str:
"""Build a JSON-RPC result carrying structuredContent (canonical for MCP
structured tool results) plus a text mirror in `content[]` for plain
clients. The structured object is expected to embed a human-readable
`summary` string alongside the raw numeric fields.
Skald prefers structuredContent when present, so the LLM sees the JSON
object (which contains the formatted summary)."""
summary = structured.get("summary")
if not isinstance(summary, str):
summary = json.dumps(structured, ensure_ascii=False)
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [{"type": "text", "text": summary}],
"structuredContent": structured,
},
})
def _error(req_id: Any, code: int, message: str) -> str:
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": code, "message": message},
})
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "weather",
"version": "1.2.0",
},
})
if method == "notifications/initialized":
return None
if method == "ping":
return _ok(req_id, {})
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
result = handler(tool_args)
# A dict return is a structured result (structuredContent); a str
# return is plain text (an "Error:" prefix marks it as isError).
if isinstance(result, dict):
return _structured_result(req_id, result)
is_err = result.startswith("Error:")
return _text_result(req_id, result, is_error=is_err)
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
return _error(req_id, -32601, f"Method not found: {method}")
# ── Main loop ────────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting weather MCP server (Open-Meteo)")
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()