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
4 changed files with 761 additions and 207 deletions

View file

@ -47,6 +47,17 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "async-trait"
version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -121,10 +132,8 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@ -146,9 +155,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "darling"
version = "0.21.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a79c4acb1fd5fa3d9304be4c76e031c54d2e92d172a393e24b19a14fe8532fe9"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
@ -156,27 +165,26 @@ dependencies = [
[[package]]
name = "darling_core"
version = "0.21.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74875de90daf30eb59609910b84d4d368103aaec4c924824c6799b28f77d6a1d"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
"syn 2.0.104",
]
[[package]]
name = "darling_macro"
version = "0.21.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79f8e61677d5df9167cd85265f8e5f64b215cdea3fb55eebc3e622e44c7a146"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -187,7 +195,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -313,7 +321,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -365,10 +373,21 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
]
[[package]]
name = "gimli"
version = "0.31.1"
@ -861,7 +880,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -906,10 +925,10 @@ dependencies = [
]
[[package]]
name = "paste"
version = "1.0.15"
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "percent-encoding"
@ -968,6 +987,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.15"
@ -994,7 +1019,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1070,14 +1095,15 @@ dependencies = [
[[package]]
name = "rmcp"
version = "0.3.0"
version = "3.0.0-beta.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b008b927a85d514699ff304be84c5084598596e6cad4a6f5bc67207715fafe5f"
checksum = "3875f7c471c2d709062bc2165d8dc883b021b44255bd00bb4d8025f5108178c8"
dependencies = [
"async-trait",
"base64",
"chrono",
"futures",
"paste",
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars",
@ -1087,19 +1113,20 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "0.3.0"
version = "3.0.0-beta.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7465280d5f73f2c5c99017a04af407b2262455a149f255ad22f2b0b29087695c"
checksum = "37a5ce7541aff2f3df9e8c3c48c7241800fa6fa68e2fbcba386c0d7dcdfc953c"
dependencies = [
"darling",
"proc-macro2",
"quote",
"serde_json",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1198,7 +1225,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1247,7 +1274,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1258,7 +1285,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1360,6 +1387,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@ -1377,7 +1415,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1431,7 +1469,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1481,7 +1519,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1581,7 +1619,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1658,6 +1696,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@ -1716,7 +1765,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
"wasm-bindgen-shared",
]
@ -1751,7 +1800,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -1810,7 +1859,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -1821,7 +1870,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]
[[package]]
@ -2049,7 +2098,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
"synstructure",
]
@ -2070,7 +2119,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
"synstructure",
]
@ -2110,5 +2159,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.104",
]

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
rmcp = { version = "0.3", features = ["server", "macros", "transport-io"] }
rmcp = { version = "3.0.0-beta.2", features = ["server", "macros", "transport-io"] }
tokio = { version = "1.46", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }

View file

@ -1,3 +1,11 @@
# A Simple MCP Weather Server written in Rust
See the [Build an MCP server](https://modelcontextprotocol.io/docs/develop/build-server) tutorial for more information.
## Structured output
Both tools declare an `output_schema` and return `structured_content`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content) in the spec.
Note that an array-rooted schema requires a `2026-07-28` client: rmcp sends it as written on every connection rather than projecting it down, so an older client rejects the tool list.
`list_tools` is hand-written because `#[tool_handler]` omits the SEP-2549 `ttl_ms` and `cache_scope` fields, which are required on a paginated result at this revision.

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(())
}