The gem's default_capabilities advertises tools, prompts, resources and
logging. This server has only tools; logging is additionally deprecated as of
2026-07-28 (SEP-2577).
Passing capabilities: { tools: {} } makes server/discover honest. No
listChanged flag, because change notifications at this revision are delivered
over subscriptions/listen and this server does not serve it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
6.7 KiB
Ruby
218 lines
6.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "mcp"
|
|
require "net/http"
|
|
require "uri"
|
|
|
|
NWS_API_BASE = "https://api.weather.gov"
|
|
USER_AGENT = "weather-app/1.0"
|
|
|
|
module HelperMethods
|
|
def make_nws_request(url)
|
|
uri = URI(url)
|
|
request = Net::HTTP::Get.new(uri)
|
|
request["User-Agent"] = USER_AGENT
|
|
request["Accept"] = "application/geo+json"
|
|
|
|
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30) do |http|
|
|
http.request(request)
|
|
end
|
|
|
|
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
|
|
JSON.parse(response.body)
|
|
end
|
|
|
|
def format_alert(alert)
|
|
<<~ALERT
|
|
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
|
|
extend HelperMethods
|
|
|
|
tool_name "get_alerts"
|
|
description "Get weather alerts for a US state"
|
|
input_schema(
|
|
properties: {
|
|
state: {
|
|
type: "string",
|
|
description: "Two-letter US state code (e.g. CA, NY)"
|
|
}
|
|
},
|
|
required: ["state"]
|
|
)
|
|
|
|
# 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"]
|
|
}
|
|
)
|
|
|
|
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
|
|
|
|
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
|
|
|
|
class GetForecast < MCP::Tool
|
|
extend HelperMethods
|
|
|
|
tool_name "get_forecast"
|
|
description "Get weather forecast for a location"
|
|
input_schema(
|
|
properties: {
|
|
latitude: {
|
|
type: "number",
|
|
description: "Latitude of the location"
|
|
},
|
|
longitude: {
|
|
type: "number",
|
|
description: "Longitude of the location"
|
|
}
|
|
},
|
|
required: ["latitude", "longitude"]
|
|
)
|
|
|
|
# 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"]
|
|
)
|
|
|
|
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
|
|
|
|
make_nws_request(forecast_url)
|
|
rescue => e
|
|
return error_response("Unable to fetch forecast data for this location: #{e.message}")
|
|
end
|
|
|
|
# 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
|
|
|
|
server = MCP::Server.new(
|
|
name: "weather",
|
|
version: "1.0.0",
|
|
tools: [GetAlerts, GetForecast],
|
|
# The gem otherwise advertises prompts, resources and logging as well. This
|
|
# server has only tools, and logging is deprecated as of 2026-07-28
|
|
# (SEP-2577). No listChanged: change notifications are delivered over
|
|
# `subscriptions/listen`, which this server does not serve.
|
|
capabilities: { tools: {} }
|
|
)
|
|
|
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
transport.open
|