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
4 changed files with 761 additions and 207 deletions
127
weather-server-rust/Cargo.lock
generated
127
weather-server-rust/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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