diff --git a/connectors/connectors.json b/connectors/connectors.json
index fa2d693..db5e03e 100644
--- a/connectors/connectors.json
+++ b/connectors/connectors.json
@@ -170,6 +170,9 @@
"server",
"sysadmin"
],
+ "auth": {
+ "type": "none"
+ },
"folder": "ssh",
"files": [
{
@@ -228,6 +231,48 @@
"size": 2057
}
]
+ },
+ {
+ "id": "weather",
+ "name": "Weather (Open-Meteo)",
+ "type": "mcp_local",
+ "scope": "global",
+ "icon_small": "weather/icon_sm.svg",
+ "icon_large": "weather/icon_lg.svg",
+ "user_description": "Global weather, forecast, and air quality via Open-Meteo \u2014 free, no API key required.",
+ "auth": {
+ "type": "none"
+ },
+ "tags": ["weather", "mcp", "local", "free", "forecast", "air-quality"],
+ "folder": "weather",
+ "files": [
+ {"path": "weather_mcp_server.py", "sha256": "5927333dee45ec5db1134034ee5af51d9e2fd95b6d128834de9a882e499b0ce4", "size": 29865},
+ {"path": "connector.json", "sha256": "1dd58ebe8c83036fb9e2fe245fe691ca28466d64d74a4820e2f377154a925760", "size": 1165},
+ {"path": "requirements.txt", "sha256": "bff057979879bf8d16acdc64317c6f2ac3dba4f1c4cd8df5ccd2a592fa804c95", "size": 14},
+ {"path": "icon_sm.svg", "sha256": "23ad1eaf8370e97bea7928ff4aebff8c09563092d00d05662df34b4661643b4c", "size": 308},
+ {"path": "icon_lg.svg", "sha256": "b1a16c5da95783d79e06388e5a18bc6c8291875335e8ac48665edeff5aeafca7", "size": 372}
+ ]
+ },
+ {
+ "id": "wikipedia",
+ "name": "Wikipedia",
+ "type": "mcp_local",
+ "scope": "global",
+ "icon_small": "wikipedia/icon_sm.svg",
+ "icon_large": "wikipedia/icon_lg.svg",
+ "user_description": "Search and read Wikipedia articles \u2014 free, no API key required.",
+ "auth": {
+ "type": "none"
+ },
+ "tags": ["wikipedia", "mcp", "local", "free", "reference", "research"],
+ "folder": "wikipedia",
+ "files": [
+ {"path": "wikipedia_mcp_server.py", "sha256": "e8dcf08a23c2b61c366acc1f46ca2d18332438b8a1d832d46d7b680641b68c59", "size": 12110},
+ {"path": "connector.json", "sha256": "ee71337fe0f9c4a10b26223f00747d98691204d0f10f1ca307c8e57cf303877b", "size": 1055},
+ {"path": "requirements.txt", "sha256": "bff057979879bf8d16acdc64317c6f2ac3dba4f1c4cd8df5ccd2a592fa804c95", "size": 14},
+ {"path": "icon_sm.svg", "sha256": "4110f6b49f5953f124099e99c1616aff63855215ceffa87d12800d2d413ddda2", "size": 286},
+ {"path": "icon_lg.svg", "sha256": "f9a8dde47baf3842331534ddc4333024b195c53b5f3bda40b0b99cea3daa9a96", "size": 285}
+ ]
}
]
}
\ No newline at end of file
diff --git a/connectors/weather/connector.json b/connectors/weather/connector.json
new file mode 100644
index 0000000..4479d52
--- /dev/null
+++ b/connectors/weather/connector.json
@@ -0,0 +1,28 @@
+{
+ "id": "weather",
+ "name": "Weather (Open-Meteo)",
+ "version": "1.0.0",
+ "type": "mcp_local",
+ "launch_command": "python3 weather_mcp_server.py",
+ "transport": "stdio",
+ "dependencies": [
+ "httpx>=0.27.0"
+ ],
+ "docs": [{
+ "lang": "en",
+ "description": "Global weather, forecast, and air quality via Open-Meteo — free, no API key required. Get current conditions (temperature, humidity, wind, UV, visibility), multi-day forecasts (min/max temps, rain probability, sunrise/sunset), and air quality (European & US AQI, PM2.5, PM10, NO\u2082, O\u2083, CO, SO\u2082, NH\u2083) for any city worldwide.",
+ "llm_short_description": "Weather & air quality MCP: current conditions, 16-day forecast, AQI, pollutants. Free, no API key."
+ }],
+ "auth": {
+ "type": "none"
+ },
+ "setup_instructions": [
+ "Install dependencies: pip install -r requirements.txt",
+ "No configuration needed — Open-Meteo is a free public API with no API key."
+ ],
+ "homepage": "https://open-meteo.com",
+ "icon_small": "icon_sm.svg",
+ "icon_large": "icon_lg.svg",
+ "scope": "global",
+ "tags": ["weather", "mcp", "local", "free", "forecast", "air-quality"]
+}
diff --git a/connectors/weather/icon_lg.svg b/connectors/weather/icon_lg.svg
new file mode 100644
index 0000000..5bb8574
--- /dev/null
+++ b/connectors/weather/icon_lg.svg
@@ -0,0 +1,7 @@
+
diff --git a/connectors/weather/icon_sm.svg b/connectors/weather/icon_sm.svg
new file mode 100644
index 0000000..0b8a6dc
--- /dev/null
+++ b/connectors/weather/icon_sm.svg
@@ -0,0 +1,6 @@
+
diff --git a/connectors/weather/requirements.txt b/connectors/weather/requirements.txt
new file mode 100644
index 0000000..aa69c38
--- /dev/null
+++ b/connectors/weather/requirements.txt
@@ -0,0 +1 @@
+httpx>=0.27.0
diff --git a/connectors/weather/weather_mcp_server.py b/connectors/weather/weather_mcp_server.py
new file mode 100644
index 0000000..9b5c64a
--- /dev/null
+++ b/connectors/weather/weather_mcp_server.py
@@ -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__`):
+ 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
+ 0–100+ scale: 0–20 Good, 20–40 Fair, 40–60 Moderate, 60–80 Poor,
+ 80–100 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 0–500
+ scale: 0–50 Good, 51–100 Moderate, 101–150 Unhealthy for sensitive
+ groups, 151–200 Unhealthy, 201–300 Very Unhealthy, 301–500 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 0–100+); falls back to the US AQI (0–500).
+ 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 (1–16). 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()
diff --git a/connectors/wikipedia/connector.json b/connectors/wikipedia/connector.json
new file mode 100644
index 0000000..8baaf23
--- /dev/null
+++ b/connectors/wikipedia/connector.json
@@ -0,0 +1,28 @@
+{
+ "id": "wikipedia",
+ "name": "Wikipedia",
+ "version": "1.0.0",
+ "type": "mcp_local",
+ "launch_command": "python3 wikipedia_mcp_server.py",
+ "transport": "stdio",
+ "dependencies": [
+ "httpx>=0.27.0"
+ ],
+ "docs": [{
+ "lang": "en",
+ "description": "Search and read Wikipedia articles — free, no API key required. Search by keyword (returns titles, summaries, and URLs) or read the full text content of any Wikipedia article by title. Automatic redirect resolution and article truncation for long pages.",
+ "llm_short_description": "Wikipedia MCP: search articles and read full text. Free, no API key. Tools: search, readArticle."
+ }],
+ "auth": {
+ "type": "none"
+ },
+ "setup_instructions": [
+ "Install dependencies: pip install -r requirements.txt",
+ "No configuration needed — Wikipedia API is free and public."
+ ],
+ "homepage": "https://www.wikipedia.org",
+ "icon_small": "icon_sm.svg",
+ "icon_large": "icon_lg.svg",
+ "scope": "global",
+ "tags": ["wikipedia", "mcp", "local", "free", "reference", "research"]
+}
diff --git a/connectors/wikipedia/icon_lg.svg b/connectors/wikipedia/icon_lg.svg
new file mode 100644
index 0000000..5a08a8c
--- /dev/null
+++ b/connectors/wikipedia/icon_lg.svg
@@ -0,0 +1,4 @@
+
diff --git a/connectors/wikipedia/icon_sm.svg b/connectors/wikipedia/icon_sm.svg
new file mode 100644
index 0000000..2c94183
--- /dev/null
+++ b/connectors/wikipedia/icon_sm.svg
@@ -0,0 +1,4 @@
+
diff --git a/connectors/wikipedia/requirements.txt b/connectors/wikipedia/requirements.txt
new file mode 100644
index 0000000..aa69c38
--- /dev/null
+++ b/connectors/wikipedia/requirements.txt
@@ -0,0 +1 @@
+httpx>=0.27.0
diff --git a/connectors/wikipedia/wikipedia_mcp_server.py b/connectors/wikipedia/wikipedia_mcp_server.py
new file mode 100644
index 0000000..765721b
--- /dev/null
+++ b/connectors/wikipedia/wikipedia_mcp_server.py
@@ -0,0 +1,352 @@
+#!/usr/bin/env python3
+"""Wikipedia MCP server (JSON-RPC 2.0 over stdio) using the free Wikipedia API.
+
+Capabilities (callable as `mcp__wikipedia__`):
+ status — self-check: confirms Wikipedia API is reachable
+ search — search Wikipedia articles by keyword
+ readArticle — get the full text content of a Wikipedia article
+
+Data source (free, no API key required):
+ - Wikipedia API: https://en.wikipedia.org/w/api.php
+
+Run with:
+ python3 wikipedia_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"[wikipedia_mcp] {msg}", file=sys.stderr, flush=True)
+
+
+# ── Constants ───────────────────────────────────────────────────────────────────
+
+WIKI_API = "https://en.wikipedia.org/w/api.php"
+USER_AGENT = "SkaldWikipediaMCP/1.0"
+HTTP_TIMEOUT = 15.0
+
+
+# ── Tool implementations ────────────────────────────────────────────────────────
+
+def _status(args: dict[str, Any]) -> str:
+ """Self-check: confirm Wikipedia API is reachable."""
+ try:
+ params = {
+ "action": "query",
+ "meta": "siteinfo",
+ "format": "json",
+ }
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
+ resp = client.get(WIKI_API, params=params,
+ headers={"User-Agent": USER_AGENT})
+ resp.raise_for_status()
+ data = resp.json()
+ if "query" in data:
+ return ("OK: Wikipedia API is reachable. "
+ "All tools (search, readArticle) are operational.")
+ return "Error: Wikipedia API responded but returned unexpected data."
+ except httpx.TimeoutException:
+ return "Error: Wikipedia API request timed out."
+ except httpx.HTTPStatusError as e:
+ return f"Error: Wikipedia API returned HTTP {e.response.status_code}."
+ except httpx.HTTPError as e:
+ return f"Error: Wikipedia API request failed: {e}"
+
+
+def _search(args: dict[str, Any]) -> str:
+ """Search Wikipedia articles by keyword, returning titles and summaries."""
+ query = args.get("query", "").strip()
+ if not query:
+ return "Error: Missing required parameter 'query'."
+
+ limit = args.get("limit", 8)
+ try:
+ limit = int(limit)
+ except (TypeError, ValueError):
+ limit = 8
+ if limit < 1:
+ limit = 1
+ if limit > 50:
+ limit = 50
+
+ try:
+ params = {
+ "action": "opensearch",
+ "search": query,
+ "limit": limit,
+ "namespace": 0,
+ "format": "json",
+ }
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
+ resp = client.get(WIKI_API, params=params,
+ headers={"User-Agent": USER_AGENT})
+ resp.raise_for_status()
+ data = resp.json()
+ except httpx.TimeoutException:
+ return "Error: Wikipedia search request timed out."
+ except httpx.HTTPStatusError as e:
+ return f"Error: Wikipedia API returned HTTP {e.response.status_code}."
+ except httpx.HTTPError as e:
+ return f"Error: Wikipedia API request failed: {e}"
+
+ # opensearch returns [query, [titles], [descriptions], [urls]]
+ titles = data[1] if len(data) > 1 else []
+ descriptions = data[2] if len(data) > 2 else []
+ urls = data[3] if len(data) > 3 else []
+
+ if not titles:
+ return f"No results found for '{query}'."
+
+ lines = [f"📚 Wikipedia search results for '{query}'"]
+ lines.append("")
+ for i, title in enumerate(titles):
+ desc = descriptions[i] if i < len(descriptions) else ""
+ url = urls[i] if i < len(urls) else ""
+ lines.append(f" {i+1}. **{title}**")
+ if desc:
+ lines.append(f" {desc}")
+ if url:
+ lines.append(f" {url}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def _read_article(args: dict[str, Any]) -> str:
+ """Get the full text content of a Wikipedia article by title."""
+ title = args.get("title", "").strip()
+ if not title:
+ return "Error: Missing required parameter 'title'."
+
+ try:
+ # First, get the page ID / exact title
+ params = {
+ "action": "query",
+ "titles": title,
+ "redirects": 1,
+ "format": "json",
+ }
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
+ resp = client.get(WIKI_API, params=params,
+ headers={"User-Agent": USER_AGENT})
+ resp.raise_for_status()
+ data = resp.json()
+
+ pages = data.get("query", {}).get("pages", {})
+ if not pages:
+ return f"Error: Article '{title}' not found."
+
+ page_id = next(iter(pages))
+ page_info = pages[page_id]
+
+ if "missing" in page_info:
+ return f"Error: Article '{title}' not found on Wikipedia."
+
+ actual_title = page_info.get("title", title)
+
+ # Now get the extract (full text)
+ params = {
+ "action": "query",
+ "pageids": page_id,
+ "prop": "extracts",
+ "explaintext": 1,
+ "exlimit": 1,
+ "format": "json",
+ }
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
+ resp = client.get(WIKI_API, params=params,
+ headers={"User-Agent": USER_AGENT})
+ resp.raise_for_status()
+ data = resp.json()
+
+ pages = data.get("query", {}).get("pages", {})
+ extract = pages.get(page_id, {}).get("extract", "")
+
+ if not extract:
+ return f"**{actual_title}**\n\n_(No content available.)_"
+
+ # Get the URL
+ article_url = f"https://en.wikipedia.org/wiki/{actual_title.replace(' ', '_')}"
+
+ # Truncate very long articles to avoid blowing up context
+ max_chars = 15000
+ if len(extract) > max_chars:
+ extract = extract[:max_chars] + "\n\n_...(article truncated, see full version on Wikipedia)_"
+
+ return (
+ f"# {actual_title}\n\n"
+ f"_{article_url}_\n\n"
+ f"{extract}"
+ )
+
+ except httpx.TimeoutException:
+ return "Error: Wikipedia API request timed out."
+ except httpx.HTTPStatusError as e:
+ return f"Error: Wikipedia API returned HTTP {e.response.status_code}."
+ except httpx.HTTPError as e:
+ return f"Error: Wikipedia API request failed: {e}"
+
+
+# ── Tool manifest ────────────────────────────────────────────────────────────────
+
+TOOLS = [
+ {
+ "name": "status",
+ "description": (
+ "Self-check that the Wikipedia integration is operational: confirms "
+ "the Wikipedia API is reachable by issuing a cheap siteinfo query. "
+ "Call this first if any Wikipedia tool fails."
+ ),
+ "inputSchema": {"type": "object", "properties": {}},
+ },
+ {
+ "name": "search",
+ "description": (
+ "Search Wikipedia articles by keyword. Returns a list of matching "
+ "article titles with short descriptions and URLs. Use this to find "
+ "the correct article title before calling readArticle."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search query (keywords or phrase).",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of results to return (1-50). Default: 8.",
+ },
+ },
+ "required": ["query"],
+ },
+ },
+ {
+ "name": "readArticle",
+ "description": (
+ "Get the full text content of a Wikipedia article by title. "
+ "Returns the article body as Markdown-like text, with a link to the "
+ "live Wikipedia page. Long articles are truncated at 15,000 characters. "
+ "First use search() to find the exact article title."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "Exact Wikipedia article title (e.g. 'Alan Turing', 'Rome').",
+ },
+ },
+ "required": ["title"],
+ },
+ },
+]
+
+TOOL_DISPATCH = {
+ "status": _status,
+ "search": _search,
+ "readArticle": _read_article,
+}
+
+
+# ── 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 _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": "wikipedia",
+ "version": "1.0.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)
+ 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 Wikipedia MCP server")
+ 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()