Add structured output to the Rust weather server and client

Both tools declare an output_schema and return structured_content alongside
the text.

get_alerts declares Vec<Alert>, so it answers with a top-level JSON array
rather than an array nested in an object, which protocol revision 2026-07-28
is the first to allow. "No alerts" is simply []. get_forecast returns an
object, for contrast.

Error paths return a tool-level error: a tool declaring an output_schema MUST
return conforming structured content, so a path with no data has to fail.

The client compiles every declared outputSchema at connect time and validates
results against it. rmcp does not do this; its docs point at the jsonschema
crate, which is the one new dependency here. Each channel goes to its stated
reader: content is forwarded to the model, structured_content is used as data,
reporting how many items came back. A missing .env is no longer fatal, and the
API key is checked after connecting rather than before.

This supersedes #143, which bumped rmcp 0.3 -> 1.4 and failed CI because the
0.3 #[tool] macro API does not survive the jump. The rewrite was required
either way, so the bump is folded in and taken to 3.0.0-beta.2, the first
version with the 2026-07-28 model.

list_tools is hand-written. #[tool_handler] generates one hardcoding
ttl_ms: None, cache_scope: None, but both are required on a paginated result
at 2026-07-28 (SEP-2549) and a strict client rejects the response outright.
get_info does not pin ProtocolVersion::V_2026_07_28 either: rmcp's server
never implements server/discover, so pinning would overstate support.

Model identifier moves to claude-sonnet-5, replacing claude-sonnet-4-20250514,
which is past end of life.

Note that rmcp sends an array-rooted schema as written on every connection
rather than projecting it down, so an older client rejects the tool list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
olaservo 2026-07-25 17:56:06 -07:00
commit 65d1a92cf8

View file

@ -1,12 +1,15 @@
use anyhow::Result;
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, tool::Parameters},
ErrorData, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, tool::schema_for_output, wrapper::Parameters},
model::*,
schemars, tool, tool_handler, tool_router,
schemars::{self, JsonSchema},
tool, tool_handler, tool_router,
transport::stdio,
};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde::Serialize;
const NWS_API_BASE: &str = "https://api.weather.gov";
const USER_AGENT: &str = "weather-app/1.0";
@ -65,6 +68,44 @@ struct ForecastPeriod {
detailed_forecast: String,
}
/// One element of `get_alerts`' structured output. Declaring `Vec<Alert>` as
/// the output schema makes `structuredContent` a top-level JSON array rather
/// than an object wrapping one, which revision 2026-07-28 is the first to allow.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Alert {
/// The kind of weather event.
event: String,
/// The area the alert covers.
area: String,
/// How severe the event is.
severity: String,
/// What is happening.
description: String,
/// What people in the area should do.
instructions: String,
}
/// `get_forecast`' structured output: the object case.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Forecast {
/// Latitude the forecast is for.
latitude: f32,
/// Longitude the forecast is for.
longitude: f32,
/// The forecast periods, soonest first.
periods: Vec<Period>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct Period {
name: String,
temperature: i32,
temperature_unit: String,
wind_speed: String,
wind_direction: String,
detailed_forecast: String,
}
async fn make_nws_request<T: DeserializeOwned>(url: &str) -> Result<T> {
let client = reqwest::Client::new();
let rsp = client
@ -77,25 +118,14 @@ async fn make_nws_request<T: DeserializeOwned>(url: &str) -> Result<T> {
Ok(rsp.json::<T>().await?)
}
fn format_alert(feature: &AlertFeature) -> String {
let props = &feature.properties;
fn format_alert(alert: &Alert) -> String {
format!(
"Event: {}\nArea: {}\nSeverity: {}\nDescription: {}\nInstructions: {}",
props.event.as_deref().unwrap_or("Unknown"),
props.area_desc.as_deref().unwrap_or("Unknown"),
props.severity.as_deref().unwrap_or("Unknown"),
props
.description
.as_deref()
.unwrap_or("No description available"),
props
.instruction
.as_deref()
.unwrap_or("No specific instructions provided")
alert.event, alert.area, alert.severity, alert.description, alert.instructions
)
}
fn format_period(period: &ForecastPeriod) -> String {
fn format_period(period: &Period) -> String {
format!(
"{}:\nTemperature: {}°{}\nWind: {} {}\nForecast: {}",
period.name,
@ -107,22 +137,39 @@ fn format_period(period: &ForecastPeriod) -> String {
)
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
/// Build a result carrying both channels.
fn dual_channel_result(
text: String,
structured: impl Serialize,
) -> Result<CallToolResult, ErrorData> {
let value = serde_json::to_value(structured).map_err(|e| {
ErrorData::internal_error(format!("failed to serialize structured content: {e}"), None)
})?;
let mut result = CallToolResult::success(vec![ContentBlock::text(text)]);
result.structured_content = Some(value);
Ok(result)
}
#[derive(serde::Deserialize, JsonSchema)]
pub struct MCPForecastRequest {
/// Latitude of the location.
latitude: f32,
/// Longitude of the location.
longitude: f32,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
#[derive(serde::Deserialize, JsonSchema)]
pub struct MCPAlertRequest {
/// Two-letter US state code (e.g. CA, NY).
state: String,
}
#[derive(Clone)]
pub struct Weather {
tool_router: ToolRouter<Weather>,
}
#[tool_router]
#[tool_router(router = tool_router)]
impl Weather {
fn new() -> Self {
Self {
@ -130,77 +177,135 @@ impl Weather {
}
}
#[tool(description = "Get weather alerts for a US state.")]
#[tool(
description = "Get weather alerts for a US state.",
output_schema = schema_for_output::<Vec<Alert>>()
)]
async fn get_alerts(
&self,
Parameters(MCPAlertRequest { state }): Parameters<MCPAlertRequest>,
) -> String {
let url = format!(
"{}/alerts/active/area/{}",
NWS_API_BASE,
state.to_uppercase()
);
) -> Result<CallToolResult, ErrorData> {
let state = state.to_uppercase();
let url = format!("{NWS_API_BASE}/alerts/active/area/{state}");
match make_nws_request::<AlertsResponse>(&url).await {
Ok(data) => {
if data.features.is_empty() {
"No active alerts for this state.".to_string()
} else {
data.features
.iter()
.map(format_alert)
.collect::<Vec<_>>()
.join("\n---\n")
let Ok(data) = make_nws_request::<AlertsResponse>(&url).await else {
return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"Unable to fetch alerts for {state}."
))]));
};
// An empty result is an empty array, not an error.
let alerts: Vec<Alert> = data
.features
.into_iter()
.map(|feature| {
let props = feature.properties;
Alert {
event: props.event.unwrap_or_else(|| "Unknown".into()),
area: props.area_desc.unwrap_or_else(|| "Unknown".into()),
severity: props.severity.unwrap_or_else(|| "Unknown".into()),
description: props
.description
.unwrap_or_else(|| "No description available".into()),
instructions: props
.instruction
.unwrap_or_else(|| "No specific instructions provided".into()),
}
}
Err(_) => "Unable to fetch alerts or no alerts found.".to_string(),
}
})
.collect();
let text = if alerts.is_empty() {
"No active alerts for this state.".to_string()
} else {
alerts
.iter()
.map(format_alert)
.collect::<Vec<_>>()
.join("\n---\n")
};
dual_channel_result(text, &alerts)
}
#[tool(description = "Get weather forecast for a location.")]
#[tool(
description = "Get weather forecast for a location.",
output_schema = schema_for_output::<Forecast>()
)]
async fn get_forecast(
&self,
Parameters(MCPForecastRequest {
latitude,
longitude,
}): Parameters<MCPForecastRequest>,
) -> String {
) -> Result<CallToolResult, ErrorData> {
let points_url = format!("{NWS_API_BASE}/points/{latitude},{longitude}");
let Ok(points_data) = make_nws_request::<PointsResponse>(&points_url).await else {
return "Unable to fetch forecast data for this location.".to_string();
return Ok(CallToolResult::error(vec![ContentBlock::text(
"Unable to fetch forecast data for this location.",
)]));
};
let forecast_url = points_data.properties.forecast;
let Ok(forecast_data) = make_nws_request::<ForecastResponse>(&forecast_url).await else {
return "Unable to fetch forecast data for this location.".to_string();
return Ok(CallToolResult::error(vec![ContentBlock::text(
"Unable to fetch detailed forecast.",
)]));
};
let periods = &forecast_data.properties.periods;
let forecast_summary: String = periods
let forecast = Forecast {
latitude,
longitude,
periods: forecast_data
.properties
.periods
.into_iter()
.take(5) // Next 5 periods only
.map(|p| Period {
name: p.name,
temperature: p.temperature,
temperature_unit: p.temperature_unit,
wind_speed: p.wind_speed,
wind_direction: p.wind_direction,
detailed_forecast: p.detailed_forecast,
})
.collect(),
};
let text = forecast
.periods
.iter()
.take(5) // Next 5 periods only
.map(format_period)
.collect::<Vec<String>>()
.collect::<Vec<_>>()
.join("\n---\n");
forecast_summary
dual_channel_result(text, &forecast)
}
}
#[tool_handler]
#[tool_handler(router = self.tool_router)]
impl ServerHandler for Weather {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..Default::default()
}
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info
}
// Hand-written because `#[tool_handler]` generates `ttl_ms: None,
// cache_scope: None`, and both are required at revision 2026-07-28.
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult::with_all_items(self.tool_router.list_all())
.with_ttl_ms(60_000)
.with_cache_scope(CacheScope::Public))
}
}
#[tokio::main]
async fn main() -> Result<()> {
let transport = (tokio::io::stdin(), tokio::io::stdout());
let service = Weather::new().serve(transport).await?;
let service = Weather::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}