diff --git a/mcp-client-ruby/Gemfile b/mcp-client-ruby/Gemfile index dfb9b637..5faca67b 100644 --- a/mcp-client-ruby/Gemfile +++ b/mcp-client-ruby/Gemfile @@ -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" diff --git a/mcp-client-ruby/README.md b/mcp-client-ruby/README.md index a4ac5df9..f73e74f4 100644 --- a/mcp-client-ruby/README.md +++ b/mcp-client-ruby/README.md @@ -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. diff --git a/mcp-client-ruby/client.rb b/mcp-client-ruby/client.rb index 5feffb0f..e72a24d5 100644 --- a/mcp-client-ruby/client.rb +++ b/mcp-client-ruby/client.rb @@ -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 @@ -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") diff --git a/weather-server-ruby/Gemfile b/weather-server-ruby/Gemfile index 9950b91c..7764e092 100644 --- a/weather-server-ruby/Gemfile +++ b/weather-server-ruby/Gemfile @@ -2,4 +2,4 @@ source "https://rubygems.org" -gem "mcp" +gem "mcp", ">= 1.1.0" diff --git a/weather-server-ruby/README.md b/weather-server-ruby/README.md index 4c8423b3..e24378af 100644 --- a/weather-server-ruby/README.md +++ b/weather-server-ruby/README.md @@ -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. diff --git a/weather-server-ruby/weather.rb b/weather-server-ruby/weather.rb index 1c84bc15..22d5761d 100644 --- a/weather-server-ruby/weather.rb +++ b/weather-server-ruby/weather.rb @@ -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 @@ -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 @@ -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 @@ -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)