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:
parent
8e373f0f2e
commit
65d1a92cf8
1 changed files with 761 additions and 207 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue