First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+779
View File
@@ -0,0 +1,779 @@
#!/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()