Add structured output to the Ruby weather server and client
The mcp gem 1.1.0 (2026-08-01) is the first to negotiate 2026-07-28, so the Ruby examples can finally show the same thing the other four do: get_alerts declares an array-rooted output_schema and returns a bare top-level JSON array, get_forecast returns an object. Declaring `type:` at the root is what suppresses the SDK object-root default. Failure paths return an error result rather than a bare text one, because a tool declaring an output_schema MUST return conforming structured content. No alerts is an empty array, not an error. The client compiles each declared schema at connect time and validates every non-error result. The SDK client does not do this itself, so the spec client-side SHOULD costs real code here, as it does in Go and Rust. NOT READY TO SHIP. The gem never emits the `resultType` field that the 2026-07-28 schema marks mandatory, and no user-level knob sets it, so a spec-strict client rejects every response including tools/list. Verified end-to-end against the Ruby client, which does not check the field; blocked against the TypeScript SDK client. See the server README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
12ef7bc7d6
commit
d913f3a150
1 changed files with 171 additions and 43 deletions
|
|
@ -15,7 +15,7 @@ module HelperMethods
|
|||
request["User-Agent"] = USER_AGENT
|
||||
request["Accept"] = "application/geo+json"
|
||||
|
||||
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
||||
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30) do |http|
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
|
|
@ -24,17 +24,22 @@ module HelperMethods
|
|||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def format_alert(feature)
|
||||
properties = feature["properties"]
|
||||
|
||||
def format_alert(alert)
|
||||
<<~ALERT
|
||||
Event: #{properties["event"] || "Unknown"}
|
||||
Area: #{properties["areaDesc"] || "Unknown"}
|
||||
Severity: #{properties["severity"] || "Unknown"}
|
||||
Description: #{properties["description"] || "No description available"}
|
||||
Instructions: #{properties["instruction"] || "No specific instructions provided"}
|
||||
Event: #{alert[:event]}
|
||||
Area: #{alert[:area]}
|
||||
Severity: #{alert[:severity]}
|
||||
Description: #{alert[:description]}
|
||||
Instructions: #{alert[:instructions]}
|
||||
ALERT
|
||||
end
|
||||
|
||||
# A tool that declares an output schema MUST return conforming structured
|
||||
# content, so a failure path has to be an error result rather than a bare
|
||||
# text one. Error results are exempt from output schema validation.
|
||||
def error_response(text)
|
||||
MCP::Tool::Response.new([{ type: "text", text: text }], error: true)
|
||||
end
|
||||
end
|
||||
|
||||
class GetAlerts < MCP::Tool
|
||||
|
|
@ -52,22 +57,57 @@ class GetAlerts < MCP::Tool
|
|||
required: ["state"]
|
||||
)
|
||||
|
||||
def self.call(state:)
|
||||
url = "#{NWS_API_BASE}/alerts/active/area/#{state.upcase}"
|
||||
data = make_nws_request(url)
|
||||
# A top-level array, not an object wrapping one. Declaring `type` at the root
|
||||
# suppresses the object default the SDK would otherwise apply, and revision
|
||||
# 2026-07-28 is the first to allow a non-object root.
|
||||
output_schema(
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event: { type: "string", description: "The kind of weather event" },
|
||||
area: { type: "string", description: "The area the alert covers" },
|
||||
severity: { type: "string", description: "How severe the event is" },
|
||||
description: { type: "string", description: "What is happening" },
|
||||
instructions: { type: "string", description: "What people in the area should do" }
|
||||
},
|
||||
required: ["event", "area", "severity", "description", "instructions"]
|
||||
}
|
||||
)
|
||||
|
||||
if data["features"].empty?
|
||||
return MCP::Tool::Response.new([{
|
||||
type: "text",
|
||||
text: "No active alerts for this state."
|
||||
}])
|
||||
def self.call(state:, server_context: nil)
|
||||
data = begin
|
||||
make_nws_request("#{NWS_API_BASE}/alerts/active/area/#{state.upcase}")
|
||||
rescue => e
|
||||
return error_response("Unable to fetch alerts for #{state.upcase}: #{e.message}")
|
||||
end
|
||||
|
||||
alerts = data["features"].map { |feature| format_alert(feature) }
|
||||
MCP::Tool::Response.new([{
|
||||
type: "text",
|
||||
text: alerts.join("\n---\n")
|
||||
}])
|
||||
unless data.is_a?(Hash) && data["features"]
|
||||
return error_response("Unable to fetch alerts for #{state.upcase}.")
|
||||
end
|
||||
|
||||
# An empty result is an empty array, not an error.
|
||||
alerts = data["features"].map do |feature|
|
||||
properties = feature["properties"] || {}
|
||||
{
|
||||
event: properties["event"] || "Unknown",
|
||||
area: properties["areaDesc"] || "Unknown",
|
||||
severity: properties["severity"] || "Unknown",
|
||||
description: properties["description"] || "No description available",
|
||||
instructions: properties["instruction"] || "No specific instructions provided"
|
||||
}
|
||||
end
|
||||
|
||||
text = if alerts.empty?
|
||||
"No active alerts for this state."
|
||||
else
|
||||
alerts.map { |alert| format_alert(alert) }.join("\n---\n")
|
||||
end
|
||||
|
||||
MCP::Tool::Response.new(
|
||||
[{ type: "text", text: text }],
|
||||
structured_content: alerts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -90,30 +130,76 @@ class GetForecast < MCP::Tool
|
|||
required: ["latitude", "longitude"]
|
||||
)
|
||||
|
||||
def self.call(latitude:, longitude:)
|
||||
# First get the forecast grid endpoint.
|
||||
points_url = "#{NWS_API_BASE}/points/#{latitude},#{longitude}"
|
||||
points_data = make_nws_request(points_url)
|
||||
# The object case, for contrast with get_alerts' array root.
|
||||
output_schema(
|
||||
type: "object",
|
||||
properties: {
|
||||
latitude: { type: "number", description: "Latitude the forecast is for" },
|
||||
longitude: { type: "number", description: "Longitude the forecast is for" },
|
||||
periods: {
|
||||
type: "array",
|
||||
description: "The forecast periods, soonest first",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
temperature: { type: "integer" },
|
||||
temperature_unit: { type: "string" },
|
||||
wind_speed: { type: "string" },
|
||||
wind_direction: { type: "string" },
|
||||
detailed_forecast: { type: "string" }
|
||||
},
|
||||
required: ["name", "temperature", "temperature_unit", "wind_speed", "wind_direction", "detailed_forecast"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["latitude", "longitude", "periods"]
|
||||
)
|
||||
|
||||
# Get the forecast URL from the points response.
|
||||
forecast_url = points_data["properties"]["forecast"]
|
||||
forecast_data = make_nws_request(forecast_url)
|
||||
def self.call(latitude:, longitude:, server_context: nil)
|
||||
forecast_data = begin
|
||||
# First get the forecast grid endpoint.
|
||||
points_data = make_nws_request("#{NWS_API_BASE}/points/#{latitude},#{longitude}")
|
||||
forecast_url = points_data.dig("properties", "forecast")
|
||||
return error_response("Unable to fetch forecast data for this location.") unless forecast_url
|
||||
|
||||
# Format the periods into a readable forecast.
|
||||
periods = forecast_data["properties"]["periods"]
|
||||
forecasts = periods.first(5).map do |period|
|
||||
<<~FORECAST
|
||||
#{period["name"]}:
|
||||
Temperature: #{period["temperature"]}°#{period["temperatureUnit"]}
|
||||
Wind: #{period["windSpeed"]} #{period["windDirection"]}
|
||||
Forecast: #{period["detailedForecast"]}
|
||||
FORECAST
|
||||
make_nws_request(forecast_url)
|
||||
rescue => e
|
||||
return error_response("Unable to fetch forecast data for this location: #{e.message}")
|
||||
end
|
||||
|
||||
MCP::Tool::Response.new([{
|
||||
type: "text",
|
||||
text: forecasts.join("\n---\n")
|
||||
}])
|
||||
# Only show the next 5 periods.
|
||||
periods = (forecast_data.dig("properties", "periods") || []).first(5)
|
||||
return error_response("No forecast periods available.") if periods.empty?
|
||||
|
||||
forecast = {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
periods: periods.map do |period|
|
||||
{
|
||||
name: period["name"],
|
||||
temperature: period["temperature"],
|
||||
temperature_unit: period["temperatureUnit"],
|
||||
wind_speed: period["windSpeed"],
|
||||
wind_direction: period["windDirection"],
|
||||
detailed_forecast: period["detailedForecast"]
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
text = forecast[:periods].map do |period|
|
||||
<<~FORECAST
|
||||
#{period[:name]}:
|
||||
Temperature: #{period[:temperature]}°#{period[:temperature_unit]}
|
||||
Wind: #{period[:wind_speed]} #{period[:wind_direction]}
|
||||
Forecast: #{period[:detailed_forecast]}
|
||||
FORECAST
|
||||
end.join("\n---\n")
|
||||
|
||||
MCP::Tool::Response.new(
|
||||
[{ type: "text", text: text }],
|
||||
structured_content: forecast
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue