Merge pull request #184 from olaservo/structured-output-2026-07-28/ruby
Add structured content to the Ruby weather server and client
This commit is contained in:
commit
4c55f7dcfb
6 changed files with 177 additions and 44 deletions
|
|
@ -4,4 +4,4 @@ source "https://rubygems.org"
|
||||||
|
|
||||||
gem "anthropic", '>= 1.60.0'
|
gem "anthropic", '>= 1.60.0'
|
||||||
gem "dotenv"
|
gem "dotenv"
|
||||||
gem "mcp", '>= 0.15.0'
|
gem "mcp", ">= 1.1.0"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
# An LLM-Powered Chatbot MCP Client written in Ruby
|
# An LLM-Powered Chatbot MCP Client written in Ruby
|
||||||
|
|
||||||
See the [Build an MCP client](https://modelcontextprotocol.io/docs/develop/build-client) tutorial for more information.
|
See the [Build an MCP client](https://modelcontextprotocol.io/docs/develop/build-client) tutorial for more information.
|
||||||
|
|
||||||
|
## Structured content
|
||||||
|
|
||||||
|
The spec says clients SHOULD validate structured results against the schema the tool declares, and this SDK's client does not do it for you — `MCP::Client::Tool#output_schema` hands back the raw schema from the wire and nothing checks results against it. So the client compiles each declared schema once at connect time with `MCP::Tool::OutputSchema.new` and validates every non-error result.
|
||||||
|
|
||||||
|
The two channels go to different readers: `content` is forwarded to the model, while `structured_content` is used as data — when a tool returns an array, the client counts its items rather than re-reading the prose. See [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content).
|
||||||
|
|
||||||
|
`MCP::Client::Stdio` offers `2026-07-28` by default, so no negotiation setup is needed here.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ class MCPClient
|
||||||
@mcp_client = MCP::Client.new(transport: @transport)
|
@mcp_client = MCP::Client.new(transport: @transport)
|
||||||
@mcp_client.connect
|
@mcp_client.connect
|
||||||
|
|
||||||
|
# The spec says clients SHOULD validate structured results against the
|
||||||
|
# schema the tool declares. This SDK's client does not do it, so compile
|
||||||
|
# each declared schema once here and check results as they arrive.
|
||||||
|
@output_schemas = @mcp_client.tools.each_with_object({}) do |tool, schemas|
|
||||||
|
schemas[tool.name] = MCP::Tool::OutputSchema.new(tool.output_schema) if tool.output_schema
|
||||||
|
end
|
||||||
|
|
||||||
tool_names = @mcp_client.tools.map(&:name)
|
tool_names = @mcp_client.tools.map(&:name)
|
||||||
puts "\nConnected to server with tools: #{tool_names}"
|
puts "\nConnected to server with tools: #{tool_names}"
|
||||||
end
|
end
|
||||||
|
|
@ -96,6 +103,15 @@ class MCPClient
|
||||||
result = @mcp_client.call_tool(name: content.name, arguments: content.input)
|
result = @mcp_client.call_tool(name: content.name, arguments: content.input)
|
||||||
response_parts << "[Calling tool #{content.name} with args #{content.input.to_json}]"
|
response_parts << "[Calling tool #{content.name} with args #{content.input.to_json}]"
|
||||||
|
|
||||||
|
# structured_content is data the application can use directly; when a
|
||||||
|
# tool returns an array, count its items rather than re-reading prose.
|
||||||
|
structured = result.dig("result", "structuredContent")
|
||||||
|
unless result.dig("result", "isError")
|
||||||
|
@output_schemas[content.name]&.validate_result(structured)
|
||||||
|
response_parts << "[#{content.name} returned #{structured.length} items]" if structured.is_a?(Array)
|
||||||
|
end
|
||||||
|
|
||||||
|
# content is what the model reads.
|
||||||
tool_result_content = result.dig("result", "content")
|
tool_result_content = result.dig("result", "content")
|
||||||
result_text = if tool_result_content.is_a?(Array)
|
result_text = if tool_result_content.is_a?(Array)
|
||||||
tool_result_content.filter_map { |content_item| content_item["text"] }.join("\n")
|
tool_result_content.filter_map { |content_item| content_item["text"] }.join("\n")
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "mcp"
|
gem "mcp", ">= 1.2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,21 @@
|
||||||
# A Simple MCP Weather Server written in Ruby
|
# A Simple MCP Weather Server written in Ruby
|
||||||
|
|
||||||
See the [Build an MCP server](https://modelcontextprotocol.io/docs/develop/build-server) tutorial for more information.
|
See the [Build an MCP server](https://modelcontextprotocol.io/docs/develop/build-server) tutorial for more information.
|
||||||
|
|
||||||
|
## Structured content
|
||||||
|
|
||||||
|
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/2026-07-28/server/tools#structured-content) in the spec.
|
||||||
|
|
||||||
|
Declaring `type:` at the root of an `output_schema` is what makes the array case work. The SDK applies a `type: "object"` default only when the schema declares no root keyword, so `output_schema(type: "array", items: {...})` is taken as written.
|
||||||
|
|
||||||
|
Because a tool that declares an `output_schema` MUST return conforming structured content, the failure paths return an error result rather than a bare text one — error results are exempt from that requirement. "No alerts" is an empty array, not an error.
|
||||||
|
|
||||||
|
The tools also return a human-readable `content` block. When you pass your own `content`, the SDK leaves it alone; if you omit it entirely, the SDK serializes non-object structured content into a text block for you.
|
||||||
|
|
||||||
|
## Known limitation: strict `2026-07-28` clients reject this server
|
||||||
|
|
||||||
|
The `mcp` gem never emits the `resultType` field on results. The 2026-07-28 schema makes it mandatory — "Servers implementing this protocol version MUST include this field" — so a client that enforces the revision rejects every response, including `tools/list`, before it ever reaches the structured content.
|
||||||
|
|
||||||
|
There is no way to set it from user code: `MCP::Server` accepts `ttl_ms:` and `cache_scope:` for the SEP-2549 cache hints, but nothing for `resultType`. The gem's own client does not check the field, so the gap is invisible when a Ruby client talks to a Ruby server, and only appears cross-SDK.
|
||||||
|
|
||||||
|
Until the gem emits `resultType`, this example is verified only against the Ruby client. Note also that an array-rooted schema requires a `2026-07-28` client regardless: the gem sends it as written rather than projecting it down, so a `2025-11-25` client rejects the tool list.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ module HelperMethods
|
||||||
request["User-Agent"] = USER_AGENT
|
request["User-Agent"] = USER_AGENT
|
||||||
request["Accept"] = "application/geo+json"
|
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)
|
http.request(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -24,17 +24,22 @@ module HelperMethods
|
||||||
JSON.parse(response.body)
|
JSON.parse(response.body)
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_alert(feature)
|
def format_alert(alert)
|
||||||
properties = feature["properties"]
|
|
||||||
|
|
||||||
<<~ALERT
|
<<~ALERT
|
||||||
Event: #{properties["event"] || "Unknown"}
|
Event: #{alert[:event]}
|
||||||
Area: #{properties["areaDesc"] || "Unknown"}
|
Area: #{alert[:area]}
|
||||||
Severity: #{properties["severity"] || "Unknown"}
|
Severity: #{alert[:severity]}
|
||||||
Description: #{properties["description"] || "No description available"}
|
Description: #{alert[:description]}
|
||||||
Instructions: #{properties["instruction"] || "No specific instructions provided"}
|
Instructions: #{alert[:instructions]}
|
||||||
ALERT
|
ALERT
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
class GetAlerts < MCP::Tool
|
class GetAlerts < MCP::Tool
|
||||||
|
|
@ -52,22 +57,57 @@ class GetAlerts < MCP::Tool
|
||||||
required: ["state"]
|
required: ["state"]
|
||||||
)
|
)
|
||||||
|
|
||||||
def self.call(state:)
|
# A top-level array, not an object wrapping one. Declaring `type` at the root
|
||||||
url = "#{NWS_API_BASE}/alerts/active/area/#{state.upcase}"
|
# suppresses the object default the SDK would otherwise apply, and revision
|
||||||
data = make_nws_request(url)
|
# 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?
|
def self.call(state:, server_context: nil)
|
||||||
return MCP::Tool::Response.new([{
|
data = begin
|
||||||
type: "text",
|
make_nws_request("#{NWS_API_BASE}/alerts/active/area/#{state.upcase}")
|
||||||
text: "No active alerts for this state."
|
rescue => e
|
||||||
}])
|
return error_response("Unable to fetch alerts for #{state.upcase}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
alerts = data["features"].map { |feature| format_alert(feature) }
|
unless data.is_a?(Hash) && data["features"]
|
||||||
MCP::Tool::Response.new([{
|
return error_response("Unable to fetch alerts for #{state.upcase}.")
|
||||||
type: "text",
|
end
|
||||||
text: alerts.join("\n---\n")
|
|
||||||
}])
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -90,37 +130,88 @@ class GetForecast < MCP::Tool
|
||||||
required: ["latitude", "longitude"]
|
required: ["latitude", "longitude"]
|
||||||
)
|
)
|
||||||
|
|
||||||
def self.call(latitude:, longitude:)
|
# The object case, for contrast with get_alerts' array root.
|
||||||
# First get the forecast grid endpoint.
|
output_schema(
|
||||||
points_url = "#{NWS_API_BASE}/points/#{latitude},#{longitude}"
|
type: "object",
|
||||||
points_data = make_nws_request(points_url)
|
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.
|
def self.call(latitude:, longitude:, server_context: nil)
|
||||||
forecast_url = points_data["properties"]["forecast"]
|
forecast_data = begin
|
||||||
forecast_data = make_nws_request(forecast_url)
|
# 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.
|
make_nws_request(forecast_url)
|
||||||
periods = forecast_data["properties"]["periods"]
|
rescue => e
|
||||||
forecasts = periods.first(5).map do |period|
|
return error_response("Unable to fetch forecast data for this location: #{e.message}")
|
||||||
<<~FORECAST
|
|
||||||
#{period["name"]}:
|
|
||||||
Temperature: #{period["temperature"]}°#{period["temperatureUnit"]}
|
|
||||||
Wind: #{period["windSpeed"]} #{period["windDirection"]}
|
|
||||||
Forecast: #{period["detailedForecast"]}
|
|
||||||
FORECAST
|
|
||||||
end
|
end
|
||||||
|
|
||||||
MCP::Tool::Response.new([{
|
# Only show the next 5 periods.
|
||||||
type: "text",
|
periods = (forecast_data.dig("properties", "periods") || []).first(5)
|
||||||
text: forecasts.join("\n---\n")
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
server = MCP::Server.new(
|
server = MCP::Server.new(
|
||||||
name: "weather",
|
name: "weather",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
tools: [GetAlerts, GetForecast]
|
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 = MCP::Server::Transports::StdioTransport.new(server)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue