Skip to main content

rmcp_weather/
lib.rs

1use rmcp::{
2    handler::server::{router::tool::ToolRouter, ServerHandler, wrapper::Parameters},
3    model::*,
4    ErrorData as McpError,
5};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug)]
10pub struct WeatherServer {
11    pub tool_router: ToolRouter<Self>,
12    client: reqwest::Client,
13}
14
15impl Default for WeatherServer {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl WeatherServer {
22    pub fn new() -> Self {
23        Self {
24            tool_router: Self::tool_router(),
25            client: reqwest::Client::new(),
26        }
27    }
28
29    async fn fetch_weather(&self, location: &str) -> Result<WttrResponse, McpError> {
30        let url = format!("https://wttr.in/{}?format=j1", urlencoding::encode(location));
31
32        let response = self.client
33            .get(&url)
34            .header("User-Agent", "rmcp-weather/0.1.0")
35            .send()
36            .await
37            .map_err(|e| McpError::internal_error(format!("HTTP request failed: {}", e), None))?;
38
39        if !response.status().is_success() {
40            return Err(McpError::internal_error(
41                format!("Weather API returned status: {}", response.status()),
42                None,
43            ));
44        }
45
46        response
47            .json::<WttrResponse>()
48            .await
49            .map_err(|e| McpError::internal_error(format!("Failed to parse weather data: {}", e), None))
50    }
51}
52
53// wttr.in JSON response structures
54#[derive(Debug, Deserialize)]
55pub struct WttrResponse {
56    pub current_condition: Vec<CurrentCondition>,
57    pub nearest_area: Vec<NearestArea>,
58    pub weather: Vec<WeatherDay>,
59}
60
61#[derive(Debug, Deserialize)]
62pub struct CurrentCondition {
63    pub temp_F: String,
64    pub temp_C: String,
65    #[serde(rename = "FeelsLikeF")]
66    pub feels_like_f: String,
67    #[serde(rename = "FeelsLikeC")]
68    pub feels_like_c: String,
69    pub humidity: String,
70    pub weatherDesc: Vec<WeatherDesc>,
71    pub windspeedMiles: String,
72    pub windspeedKmph: String,
73    pub winddir16Point: String,
74    pub precipMM: String,
75    pub visibility: String,
76    pub pressure: String,
77    pub uvIndex: String,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct WeatherDesc {
82    pub value: String,
83}
84
85#[derive(Debug, Deserialize)]
86pub struct NearestArea {
87    pub areaName: Vec<AreaValue>,
88    pub region: Vec<AreaValue>,
89    pub country: Vec<AreaValue>,
90}
91
92#[derive(Debug, Deserialize)]
93pub struct AreaValue {
94    pub value: String,
95}
96
97#[derive(Debug, Deserialize)]
98pub struct WeatherDay {
99    pub date: String,
100    pub maxtempF: String,
101    pub maxtempC: String,
102    pub mintempF: String,
103    pub mintempC: String,
104    pub hourly: Vec<HourlyForecast>,
105}
106
107#[derive(Debug, Deserialize)]
108pub struct HourlyForecast {
109    pub time: String,
110    pub tempF: String,
111    pub tempC: String,
112    pub weatherDesc: Vec<WeatherDesc>,
113    pub chanceofrain: String,
114}
115
116// Tool parameter structs
117#[derive(Debug, Serialize, Deserialize, JsonSchema)]
118pub struct LocationParams {
119    #[schemars(description = "Location to get weather for (city name, zip code, or 'lat,lon')")]
120    pub location: String,
121}
122
123#[derive(Debug, Serialize, Deserialize, JsonSchema)]
124pub struct ForecastParams {
125    #[schemars(description = "Location to get forecast for")]
126    pub location: String,
127    #[schemars(description = "Number of days (1-3, default 3)")]
128    #[serde(default)]
129    pub days: Option<u8>,
130}
131
132#[rmcp::tool_router]
133impl WeatherServer {
134    #[rmcp::tool(description = "Get current weather conditions for a location")]
135    pub async fn get_weather(
136        &self,
137        Parameters(params): Parameters<LocationParams>,
138    ) -> Result<CallToolResult, McpError> {
139        let data = self.fetch_weather(&params.location).await?;
140
141        let current = data.current_condition.first()
142            .ok_or_else(|| McpError::internal_error("No current conditions", None))?;
143
144        let area = data.nearest_area.first()
145            .map(|a| format!("{}, {}",
146                a.areaName.first().map(|v| v.value.as_str()).unwrap_or("Unknown"),
147                a.region.first().map(|v| v.value.as_str()).unwrap_or("")
148            ))
149            .unwrap_or_else(|| params.location.clone());
150
151        let desc = current.weatherDesc.first()
152            .map(|d| d.value.as_str())
153            .unwrap_or("Unknown");
154
155        let output = format!(
156            "Weather for {}:\n\
157             Conditions: {}\n\
158             Temperature: {}°F / {}°C\n\
159             Feels like: {}°F / {}°C\n\
160             Humidity: {}%\n\
161             Wind: {} mph {} ({})\n\
162             Visibility: {} miles\n\
163             Pressure: {} mb\n\
164             UV Index: {}",
165            area, desc,
166            current.temp_F, current.temp_C,
167            current.feels_like_f, current.feels_like_c,
168            current.humidity,
169            current.windspeedMiles, current.winddir16Point, current.windspeedKmph,
170            current.visibility,
171            current.pressure,
172            current.uvIndex
173        );
174
175        Ok(CallToolResult::success(vec![Content::text(output)]))
176    }
177
178    #[rmcp::tool(description = "Get weather forecast for upcoming days")]
179    pub async fn get_forecast(
180        &self,
181        Parameters(params): Parameters<ForecastParams>,
182    ) -> Result<CallToolResult, McpError> {
183        let data = self.fetch_weather(&params.location).await?;
184        let days = params.days.unwrap_or(3).min(3) as usize;
185
186        let area = data.nearest_area.first()
187            .map(|a| format!("{}, {}",
188                a.areaName.first().map(|v| v.value.as_str()).unwrap_or("Unknown"),
189                a.region.first().map(|v| v.value.as_str()).unwrap_or("")
190            ))
191            .unwrap_or_else(|| params.location.clone());
192
193        let mut output = format!("Forecast for {} ({} days):\n\n", area, days);
194
195        for day in data.weather.iter().take(days) {
196            output.push_str(&format!(
197                "{}:\n  High: {}°F / {}°C | Low: {}°F / {}°C\n",
198                day.date, day.maxtempF, day.maxtempC, day.mintempF, day.mintempC
199            ));
200
201            // Show a few hourly forecasts
202            for hour in day.hourly.iter().step_by(3) {
203                let time_hr = hour.time.parse::<u32>().unwrap_or(0) / 100;
204                let desc = hour.weatherDesc.first()
205                    .map(|d| d.value.as_str())
206                    .unwrap_or("?");
207                output.push_str(&format!(
208                    "  {:02}:00 - {}°F, {}, {}% rain\n",
209                    time_hr, hour.tempF, desc, hour.chanceofrain
210                ));
211            }
212            output.push('\n');
213        }
214
215        Ok(CallToolResult::success(vec![Content::text(output)]))
216    }
217}
218
219#[rmcp::tool_handler]
220impl ServerHandler for WeatherServer {
221    fn get_info(&self) -> ServerInfo {
222        ServerInfo {
223            protocol_version: ProtocolVersion::V_2024_11_05,
224            capabilities: ServerCapabilities::builder()
225                .enable_tools()
226                .build(),
227            server_info: Implementation::from_build_env(),
228            instructions: Some("Weather information server using wttr.in".into()),
229        }
230    }
231}