Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mcp-client-ruby/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ source "https://rubygems.org"

gem "anthropic", '>= 1.60.0'
gem "dotenv"
gem "mcp", '>= 0.15.0'
gem "mcp", ">= 1.1.0"
8 changes: 8 additions & 0 deletions mcp-client-ruby/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# 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.

## 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/draft/server/tools#structured-content).

`MCP::Client::Stdio` offers `2026-07-28` by default, so no negotiation setup is needed here.
16 changes: 16 additions & 0 deletions mcp-client-ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ def connect_to_server(server_script_path)
@mcp_client = MCP::Client.new(transport: @transport)
@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)
puts "\nConnected to server with tools: #{tool_names}"
end
Expand Down Expand Up @@ -96,6 +103,15 @@ def process_query(query)
result = @mcp_client.call_tool(name: content.name, arguments: content.input)
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")
result_text = if tool_result_content.is_a?(Array)
tool_result_content.filter_map { |content_item| content_item["text"] }.join("\n")
Expand Down
2 changes: 1 addition & 1 deletion weather-server-ruby/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

source "https://rubygems.org"

gem "mcp"
gem "mcp", ">= 1.1.0"
18 changes: 18 additions & 0 deletions weather-server-ruby/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# 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.

## 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/draft/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.
175 changes: 133 additions & 42 deletions weather-server-ruby/weather.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def make_nws_request(url)
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

Expand All @@ -24,17 +24,22 @@ def make_nws_request(url)
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
Expand All @@ -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"]
}
)

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

if data["features"].empty?
return MCP::Tool::Response.new([{
type: "text",
text: "No active alerts for this state."
}])
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

alerts = data["features"].map { |feature| format_alert(feature) }
MCP::Tool::Response.new([{
type: "text",
text: alerts.join("\n---\n")
}])
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

Expand All @@ -90,37 +130,88 @@ 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"]
)

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

# Get the forecast URL from the points response.
forecast_url = points_data["properties"]["forecast"]
forecast_data = make_nws_request(forecast_url)
make_nws_request(forecast_url)
rescue => e
return error_response("Unable to fetch forecast data for this location: #{e.message}")
end

# Format the periods into a readable forecast.
periods = forecast_data["properties"]["periods"]
forecasts = periods.first(5).map do |period|
# 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["temperatureUnit"]}
Wind: #{period["windSpeed"]} #{period["windDirection"]}
Forecast: #{period["detailedForecast"]}
#{period[:name]}:
Temperature: #{period[:temperature]}°#{period[:temperature_unit]}
Wind: #{period[:wind_speed]} #{period[:wind_direction]}
Forecast: #{period[:detailed_forecast]}
FORECAST
end
end.join("\n---\n")

MCP::Tool::Response.new([{
type: "text",
text: forecasts.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]
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)
Expand Down
Loading