From a49ab43825719bae96d28a432f0a8cbe6c791988 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 2 Oct 2026 01:39:41 +0900 Subject: [PATCH] Validate icons given as Hashes through `MCP::Icon` ## Motivation and Context `MCP::Icon.new` checks an icon against the specification's `Icon` type, but the places that take icons never saw that check when an application passed a Hash: `Server.new(icons:)`, the class-level `icons` of a tool, prompt, resource, and resource template, and `Resource.new(icons:)` and `ResourceTemplate.new(icons:)` stored whatever they were given, and `to_h` serialized each element with its own `to_h`. A Hash is a supported way to give an icon (the SDK's own tests pass wire-shaped ones), and on that path `{ src: "x", sizes: "51x51" }` reached the client as a JSON string, while a Hash written with the keyword names, such as `{ mime_type: "image/png", src: "x" }`, was emitted with the key `mime_type`, which the wire `Icon` does not have. The TypeScript SDK types those places as `Icon[]` and the Python SDK's models hold `list[Icon]`, so neither lets an unchecked shape through there. Every place that takes icons now passes them through `Icon.from_list`, which keeps `nil`, converts each element of an Array with `Icon.from`, and refuses anything else. `Icon.from` returns an `Icon` as it is, builds one from a Hash, and refuses any other object. A Hash may use the keyword names of `Icon.new` or the wire name `mimeType`, as Symbols or Strings, so both spellings serialize the same way; a key outside the `Icon` type, or one member given under two spellings, is refused rather than emitted. A rejected element is reported with its position (`icons[1]: ...`) and the message `Icon.new` would give, at definition time, where the annotations of a tool or resource are already built from a Hash the same way. The docs gain an Icons page describing `MCP::Icon`, the Hash form, and where icons attach. The page sits right after the Resources page in the navigation, so the pages that followed move down by one. ## How Has This Been Tested? New tests in `test/mcp/icon_test.rb`, `test/mcp/tool_test.rb`, `test/mcp/prompt_test.rb`, `test/mcp/resource_test.rb`, `test/mcp/resource_template_test.rb`, and `test/mcp/server_test.rb`. Against the previous library, a Hash icon with a String `sizes` was serialized as given and a `mime_type:` key was emitted as `mime_type`. ## Breaking Changes An element of `icons` that is neither an `MCP::Icon` nor a Hash is refused with `ArgumentError`, as is an `icons` that is neither `nil` nor an Array; a Hash icon with a key that is not a Symbol or a String, a key outside the `Icon` type, a member given under two spellings, or a value `MCP::Icon.new` rejects now raises at definition time, and through `Server#icons=`, instead of being serialized. A Hash written with the keyword names now serializes with the wire names. The `icons` readers of a server, tool, prompt, resource, and resource template return a new, frozen Array of `MCP::Icon` instances where they returned the Array and the Hashes as given, so a later change to the Array given is no longer seen, and adding to the Array read back raises `FrozenError` instead of slipping past the checks. --- docs/_server/cancellation.md | 2 +- docs/_server/completion.md | 2 +- docs/_server/configuration.md | 2 +- docs/_server/custom-methods.md | 2 +- docs/_server/elicitation.md | 2 +- docs/_server/icons.md | 83 ++++++++++++++++++++++++++ docs/_server/logging.md | 2 +- docs/_server/mrtr.md | 2 +- docs/_server/notifications.md | 2 +- docs/_server/pagination.md | 2 +- docs/_server/ping.md | 2 +- docs/_server/progress.md | 2 +- docs/_server/prompts.md | 2 + docs/_server/resources.md | 2 + docs/_server/roots.md | 2 +- docs/_server/sampling.md | 2 +- docs/_server/server-context.md | 2 +- docs/_server/subscriptions.md | 2 +- docs/_server/tools.md | 2 + lib/mcp/icon.rb | 61 +++++++++++++++++++ lib/mcp/prompt.rb | 2 +- lib/mcp/resource.rb | 4 +- lib/mcp/resource_template.rb | 4 +- lib/mcp/server.rb | 11 +++- lib/mcp/tool.rb | 2 +- test/mcp/icon_test.rb | 96 ++++++++++++++++++++++++++++++ test/mcp/prompt_test.rb | 26 +++++++- test/mcp/resource_template_test.rb | 22 +++++++ test/mcp/resource_test.rb | 22 +++++++ test/mcp/server_test.rb | 69 +++++++++++++++++++++ test/mcp/tool_test.rb | 43 +++++++++++++ 31 files changed, 455 insertions(+), 26 deletions(-) create mode 100644 docs/_server/icons.md diff --git a/docs/_server/cancellation.md b/docs/_server/cancellation.md index 1bc0b176..1939a24e 100644 --- a/docs/_server/cancellation.md +++ b/docs/_server/cancellation.md @@ -1,7 +1,7 @@ --- layout: default title: Cancellation -nav_order: 13 +nav_order: 14 --- # Cancellation diff --git a/docs/_server/completion.md b/docs/_server/completion.md index 583a4bc6..50bc4817 100644 --- a/docs/_server/completion.md +++ b/docs/_server/completion.md @@ -1,7 +1,7 @@ --- layout: default title: Completion -nav_order: 16 +nav_order: 17 redirect_from: - /server/completions/ --- diff --git a/docs/_server/configuration.md b/docs/_server/configuration.md index 9480df8a..073d7ccb 100644 --- a/docs/_server/configuration.md +++ b/docs/_server/configuration.md @@ -1,7 +1,7 @@ --- layout: default title: Configuration -nav_order: 20 +nav_order: 21 --- # Configuration diff --git a/docs/_server/custom-methods.md b/docs/_server/custom-methods.md index 85dc87a3..7c9da45b 100644 --- a/docs/_server/custom-methods.md +++ b/docs/_server/custom-methods.md @@ -1,7 +1,7 @@ --- layout: default title: Custom Methods -nav_order: 21 +nav_order: 22 --- # Custom Methods diff --git a/docs/_server/elicitation.md b/docs/_server/elicitation.md index 4eea52b2..9e508bcf 100644 --- a/docs/_server/elicitation.md +++ b/docs/_server/elicitation.md @@ -1,7 +1,7 @@ --- layout: default title: Elicitation -nav_order: 9 +nav_order: 10 --- # Elicitation diff --git a/docs/_server/icons.md b/docs/_server/icons.md new file mode 100644 index 00000000..846673e7 --- /dev/null +++ b/docs/_server/icons.md @@ -0,0 +1,83 @@ +--- +layout: default +title: Icons +nav_order: 7 +--- + +# Icons + +The MCP spec lets a server attach [icons](https://modelcontextprotocol.io/specification/2025-11-25/basic#icons) +to its own `serverInfo` and to each tool, prompt, resource, and resource template, so a client can show a visual identifier next to them. + +## Defining Icons + +`MCP::Icon.new` takes the members of the specification's `Icon` type as keyword arguments: + +- `src`: the URI of the icon, required. The specification allows an HTTP/HTTPS URL or a `data:` URI with Base64-encoded image data +- `mime_type`: a MIME type such as `"image/png"` or `"image/svg+xml"`, for when the type of the source is missing or generic +- `sizes`: an Array of Strings in `WxH` form, such as `["48x48", "96x96"]`, or `["any"]` for a scalable format like SVG +- `theme`: `"light"` or `"dark"` when the icon is designed for one background + +```ruby +icon = MCP::Icon.new(src: "https://example.com/icon.png", mime_type: "image/png", sizes: ["48x48"]) +``` + +Each argument is checked against the type the specification's schema declares, and an `ArgumentError` is raised at definition time for +a `src` that is missing, empty, or not a String, and, when one of the optional arguments is given, for a `sizes` that is not an Array of Strings, +a `mime_type` that is not a String, or a `theme` other than the two values. +What a value means is not checked: the scheme of `src` and the `WxH` form of a size are the server author's to get right, and a client applies +the security rules of the specification (HTTPS or `data:` URIs only, the same origin as the server, size limits) when it fetches an icon. + +A Hash is accepted wherever an `MCP::Icon` is and is converted through `MCP::Icon.new`, so it is checked the same way. +Its keys may be Symbols or Strings and may use the keyword names above or the wire name `mimeType`; a Hash with any other key, +or with the same member given twice, is refused. + +```ruby +icons = [{ src: "https://example.com/icon.png", mimeType: "image/png", sizes: ["48x48"] }] +``` + +## Attaching Icons + +- `MCP::Server.new(icons: [...])` advertises the icons in `serverInfo` +- Tools and prompts take `icons [...]` in a class definition, or `icons:` on `MCP::Tool.define` and `MCP::Prompt.define` +- Resources and resource templates take `icons [...]` in a class definition, `icons:` on `define`, `icons:` on `MCP::Resource.new` + and `MCP::ResourceTemplate.new`, and `icons:` on `MCP::Server#define_resource` and `MCP::Server#define_resource_template` + +```ruby +class WeatherTool < MCP::Tool + description "Reports the weather" + icons [MCP::Icon.new(src: "https://example.com/weather.png", mime_type: "image/png", sizes: ["48x48"])] + + def self.call(server_context:) + MCP::Tool::Response.new([{ type: "text", text: "sunny" }]) + end +end + +prompt = MCP::Prompt.define( + name: "greeting", + description: "Greets the user", + icons: [{ src: "https://example.com/greeting.svg", mimeType: "image/svg+xml", sizes: ["any"] }] +) do |args, server_context:| + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new(role: "user", content: MCP::Content::Text.new("Hello!")) + ] + ) +end + +server = MCP::Server.new( + name: "weather_server", + icons: [ + MCP::Icon.new(src: "https://example.com/server.png", theme: "light"), + MCP::Icon.new(src: "https://example.com/server-dark.png", theme: "dark") + ], + tools: [WeatherTool], + prompts: [prompt] +) +``` + +An `icons` of `nil` or `[]` leaves the `icons` member out of the wire representation. + +{: .note } +> Icons were added in the 2025-11-25 revision of the specification. The icons in `serverInfo` are sent only when +> the negotiated protocol version is 2025-11-25 or later; the icons of tools, prompts, and resources are listed as defined. diff --git a/docs/_server/logging.md b/docs/_server/logging.md index b8616289..6873b80b 100644 --- a/docs/_server/logging.md +++ b/docs/_server/logging.md @@ -1,7 +1,7 @@ --- layout: default title: Logging -nav_order: 17 +nav_order: 18 --- # Logging diff --git a/docs/_server/mrtr.md b/docs/_server/mrtr.md index 3113bdf2..80b2c60f 100644 --- a/docs/_server/mrtr.md +++ b/docs/_server/mrtr.md @@ -1,7 +1,7 @@ --- layout: default title: Multi Round-Trip Requests -nav_order: 10 +nav_order: 11 redirect_from: - /server/multi-round-trip-results/ --- diff --git a/docs/_server/notifications.md b/docs/_server/notifications.md index 7cf9460a..da53d4cf 100644 --- a/docs/_server/notifications.md +++ b/docs/_server/notifications.md @@ -1,7 +1,7 @@ --- layout: default title: Notifications -nav_order: 11 +nav_order: 12 --- # Notifications diff --git a/docs/_server/pagination.md b/docs/_server/pagination.md index c721f18a..7be144c5 100644 --- a/docs/_server/pagination.md +++ b/docs/_server/pagination.md @@ -1,7 +1,7 @@ --- layout: default title: Pagination -nav_order: 18 +nav_order: 19 --- # Pagination diff --git a/docs/_server/ping.md b/docs/_server/ping.md index d8c6627c..bbd8eb9d 100644 --- a/docs/_server/ping.md +++ b/docs/_server/ping.md @@ -1,7 +1,7 @@ --- layout: default title: Ping -nav_order: 15 +nav_order: 16 --- # Ping diff --git a/docs/_server/progress.md b/docs/_server/progress.md index f82645b0..a935fcfa 100644 --- a/docs/_server/progress.md +++ b/docs/_server/progress.md @@ -1,7 +1,7 @@ --- layout: default title: Progress -nav_order: 14 +nav_order: 15 --- # Progress diff --git a/docs/_server/prompts.md b/docs/_server/prompts.md index e8f5a484..86fcf0e6 100644 --- a/docs/_server/prompts.md +++ b/docs/_server/prompts.md @@ -126,6 +126,8 @@ end The [`server_context`](/server/server-context/) parameter is the `server_context` passed into the server and can be used to pass per request information, e.g. around authentication state or user preferences. +Icons for prompts are documented on the [Icons](/server/icons/) page. + ## Key Components - `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag diff --git a/docs/_server/resources.md b/docs/_server/resources.md index f53548a4..bf6eb140 100644 --- a/docs/_server/resources.md +++ b/docs/_server/resources.md @@ -140,6 +140,8 @@ server.resources_read_handler do |params| end ``` +Icons for resources and resource templates are documented on the [Icons](/server/icons/) page. + ## Reading Binary Resources For binary resources, respond with a base64-encoded `blob` field instead of `text`. diff --git a/docs/_server/roots.md b/docs/_server/roots.md index a3341c6c..6a22ada8 100644 --- a/docs/_server/roots.md +++ b/docs/_server/roots.md @@ -1,7 +1,7 @@ --- layout: default title: Roots -nav_order: 7 +nav_order: 8 --- # Roots diff --git a/docs/_server/sampling.md b/docs/_server/sampling.md index af37df00..e6a2bdc9 100644 --- a/docs/_server/sampling.md +++ b/docs/_server/sampling.md @@ -1,7 +1,7 @@ --- layout: default title: Sampling -nav_order: 8 +nav_order: 9 --- # Sampling diff --git a/docs/_server/server-context.md b/docs/_server/server-context.md index f45f5766..f90ae5e4 100644 --- a/docs/_server/server-context.md +++ b/docs/_server/server-context.md @@ -1,7 +1,7 @@ --- layout: default title: Server Context -nav_order: 19 +nav_order: 20 --- # Server Context diff --git a/docs/_server/subscriptions.md b/docs/_server/subscriptions.md index 37b4d8fb..8c807972 100644 --- a/docs/_server/subscriptions.md +++ b/docs/_server/subscriptions.md @@ -1,7 +1,7 @@ --- layout: default title: Subscriptions -nav_order: 12 +nav_order: 13 redirect_from: - /server/notification-subscriptions/ --- diff --git a/docs/_server/tools.md b/docs/_server/tools.md index 0ebf6fed..e5902bef 100644 --- a/docs/_server/tools.md +++ b/docs/_server/tools.md @@ -89,6 +89,8 @@ end The [`server_context`](/server/server-context/) parameter is the `server_context` passed into the server and can be used to pass per request information, e.g. around authentication state. +Icons for tools are documented on the [Icons](/server/icons/) page. + ## Tool argument keys Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects diff --git a/lib/mcp/icon.rb b/lib/mcp/icon.rb index 56ac6adb..b66dd8d3 100644 --- a/lib/mcp/icon.rb +++ b/lib/mcp/icon.rb @@ -9,9 +9,70 @@ module MCP # is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String` # is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String` # (the specification expects `WxH` or `"any"`). + # + # Wherever an `Icon` is accepted (`Server.new(icons:)` and the `icons` of a tool, prompt, resource, or resource template), + # a Hash is accepted too and converted through `Icon.from`, so it is checked the same way. class Icon SUPPORTED_THEMES = ["light", "dark"].freeze + # The keys `from` accepts in a Hash: the keyword names of `new` and the wire name `mimeType`, + # as Symbols or Strings. + HASH_KEYWORDS = { + "src" => :src, + "mimeType" => :mime_type, + "mime_type" => :mime_type, + "sizes" => :sizes, + "theme" => :theme, + }.freeze + private_constant :HASH_KEYWORDS + + class << self + # Returns `value` when it is already an `Icon`, builds one from a Hash, and refuses anything else. + def from(value) + return value if value.is_a?(Icon) + + unless value.is_a?(Hash) + raise ArgumentError, "An icon must be an MCP::Icon or a Hash (got #{value.class})." + end + + keywords = {} + value.each do |key, member| + unless key.is_a?(Symbol) || key.is_a?(String) + raise ArgumentError, "An icon Hash key must be a Symbol or a String (got #{key.class})." + end + + keyword = HASH_KEYWORDS[key.to_s] + unless keyword + raise ArgumentError, "An icon Hash may only hold src, mimeType (or mime_type), sizes, and theme (got #{key.inspect})." + end + raise ArgumentError, "An icon Hash gives #{keyword} twice." if keywords.key?(keyword) + + keywords[keyword] = member + end + + new(**keywords) + end + + # Converts the `icons` argument of a server, tool, prompt, resource, or resource template: `nil` stays `nil`, + # each element of an Array goes through `from` into a frozen Array, and anything else is refused. + # The frozen Array keeps a later `<<` on a reader from adding an element these checks never saw. + def from_list(value) + return if value.nil? + + unless value.is_a?(Array) + raise ArgumentError, "icons must be nil or an Array of MCP::Icon or Hash (got #{value.class})." + end + + icons = value.each_with_index.map do |icon, index| + from(icon) + rescue ArgumentError => e + raise ArgumentError, "icons[#{index}]: #{e.message}" + end + + icons.freeze + end + end + attr_reader :mime_type, :sizes, :src, :theme def initialize(mime_type: nil, sizes: nil, src:, theme: nil) diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index c2ed2db0..eb5aa986 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -72,7 +72,7 @@ def icons(value = NOT_SET) if value == NOT_SET @icons_value else - @icons_value = value + @icons_value = Icon.from_list(value) end end diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index dd87ee23..dc978c48 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -88,7 +88,7 @@ def icons(value = NOT_SET) if value == NOT_SET @icons_value else - @icons_value = value + @icons_value = Icon.from_list(value) end end @@ -147,7 +147,7 @@ def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: @name = name @title = title @description = description - @icons = icons + @icons = Icon.from_list(icons) @mime_type = mime_type @annotations = annotations @size = size diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index 71db2312..9ede2247 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -89,7 +89,7 @@ def icons(value = NOT_SET) if value == NOT_SET @icons_value else - @icons_value = value + @icons_value = Icon.from_list(value) end end @@ -158,7 +158,7 @@ def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mi @name = name @title = title @description = description - @icons = icons + @icons = Icon.from_list(icons) @mime_type = mime_type @annotations = annotations @meta = meta diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 051a8928..160757a9 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -181,8 +181,13 @@ class ValidationError < StandardError; end Methods::RESOURCES_READ, ].freeze - attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security + attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification + attr_reader :icons, :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security + + # Replaces the icons advertised in `serverInfo`; a Hash is converted through `Icon.from`. + def icons=(value) + @icons = Icon.from_list(value) + end def initialize( description: nil, @@ -207,7 +212,7 @@ def initialize( transport: nil ) @description = description - @icons = icons + @icons = Icon.from_list(icons) @name = name @title = title @version = version diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 1b76b806..2ccdab5c 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -86,7 +86,7 @@ def icons(value = NOT_SET) if value == NOT_SET @icons_value else - @icons_value = value + @icons_value = Icon.from_list(value) end end diff --git a/test/mcp/icon_test.rb b/test/mcp/icon_test.rb index f2fbc9a2..594abc93 100644 --- a/test/mcp/icon_test.rb +++ b/test/mcp/icon_test.rb @@ -124,5 +124,101 @@ def test_theme_rejects_false end assert_equal('The value of theme must specify "light" or "dark".', exception.message) end + + def test_from_returns_an_icon_as_is + icon = Icon.new(src: "https://example.com/icon.png") + + assert_same(icon, Icon.from(icon)) + end + + def test_from_builds_an_icon_from_a_hash_with_either_key_spelling + expected = { mimeType: "image/png", sizes: ["48x48"], src: "https://example.com/icon.png", theme: "light" } + [ + { mimeType: "image/png", sizes: ["48x48"], src: "https://example.com/icon.png", theme: "light" }, + { mime_type: "image/png", sizes: ["48x48"], src: "https://example.com/icon.png", theme: "light" }, + { "mimeType" => "image/png", "sizes" => ["48x48"], "src" => "https://example.com/icon.png", "theme" => "light" }, + ].each do |hash| + assert_equal(expected, Icon.from(hash).to_h) + end + end + + def test_from_rejects_an_unknown_key + exception = assert_raises(ArgumentError) do + Icon.from({ src: "https://example.com/icon.png", size: "48x48" }) + end + assert_equal("An icon Hash may only hold src, mimeType (or mime_type), sizes, and theme (got :size).", exception.message) + end + + def test_from_rejects_a_key_that_is_neither_a_symbol_nor_a_string + key = Object.new + key.define_singleton_method(:to_s) { "src" } + + [1, key].each do |bad_key| + exception = assert_raises(ArgumentError) do + Icon.from({ bad_key => "https://example.com/icon.png" }) + end + assert_equal("An icon Hash key must be a Symbol or a String (got #{bad_key.class}).", exception.message) + end + end + + def test_from_rejects_a_member_given_twice + exception = assert_raises(ArgumentError) do + Icon.from({ src: "https://example.com/icon.png", mimeType: "image/png", mime_type: "image/png" }) + end + assert_equal("An icon Hash gives mime_type twice.", exception.message) + end + + def test_from_rejects_anything_but_an_icon_or_a_hash + exception = assert_raises(ArgumentError) do + Icon.from("https://example.com/icon.png") + end + assert_equal("An icon must be an MCP::Icon or a Hash (got String).", exception.message) + end + + def test_from_checks_a_hash_the_way_new_does + exception = assert_raises(ArgumentError) do + Icon.from({ mimeType: "image/png" }) + end + assert_equal("missing keyword: :src", exception.message) + + exception = assert_raises(ArgumentError) do + Icon.from({ src: "https://example.com/icon.png", sizes: "51x51" }) + end + assert_equal('The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end + + def test_from_list_keeps_nil_and_converts_each_element + assert_nil(Icon.from_list(nil)) + assert_equal([], Icon.from_list([])) + + icon = Icon.new(src: "https://example.com/icon.png") + icons = Icon.from_list([icon, { src: "https://example.com/other.png" }]) + + assert_same(icon, icons[0]) + assert_equal({ src: "https://example.com/other.png" }, icons[1].to_h) + end + + def test_from_list_returns_a_frozen_array + icons = Icon.from_list([{ src: "https://example.com/icon.png" }]) + + assert_predicate(icons, :frozen?) + assert_raises(FrozenError) do + icons << { src: "https://example.com/other.png", sizes: "51x51" } + end + end + + def test_from_list_rejects_anything_but_nil_or_an_array + exception = assert_raises(ArgumentError) do + Icon.from_list({ src: "https://example.com/icon.png" }) + end + assert_equal("icons must be nil or an Array of MCP::Icon or Hash (got Hash).", exception.message) + end + + def test_from_list_names_the_position_of_a_rejected_element + exception = assert_raises(ArgumentError) do + Icon.from_list([{ src: "https://example.com/icon.png" }, { src: "https://example.com/other.png", sizes: "51x51" }]) + end + assert_equal('icons[1]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end end end diff --git a/test/mcp/prompt_test.rb b/test/mcp/prompt_test.rb index a6fe18a5..be175634 100644 --- a/test/mcp/prompt_test.rb +++ b/test/mcp/prompt_test.rb @@ -66,7 +66,7 @@ def template(args, server_context:) assert_equal "my_mock_prompt", prompt.name_value assert_equal "a mock prompt for testing", prompt.description - assert_equal([{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], prompt.icons) + assert_equal([{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], prompt.icons.map(&:to_h)) assert_equal "test_argument", prompt.arguments.first.name assert_equal "Test argument", prompt.arguments.first.description assert prompt.arguments.first.required @@ -139,7 +139,7 @@ def template(args, server_context:) assert_equal "mock_prompt", prompt.name_value assert_equal "a mock prompt for testing", prompt.description - assert_equal([{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], prompt.icons) + assert_equal([{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], prompt.icons.map(&:to_h)) assert_equal "test_argument", prompt.arguments.first.name assert_equal "Test argument title", prompt.arguments.first.title assert_equal "This is a test argument description", prompt.arguments.first.description @@ -259,5 +259,27 @@ class NoArgumentsPrompt < Prompt refute prompt.to_h.key?(:icons) end + + test "icons given as Hashes are converted through MCP::Icon" do + prompt = Prompt.define( + name: "prompt_with_hash_icons", + description: "a prompt with icons given as Hashes", + icons: [{ mime_type: "image/png", sizes: ["48x48"], src: "https://example.com" }], + ) + + assert_equal [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com" }], prompt.to_h[:icons] + end + + test "icons given as Hashes are validated when the prompt is defined" do + exception = assert_raises(ArgumentError) do + Prompt.define( + name: "prompt_with_invalid_icons", + description: "a prompt with an invalid icon", + icons: [{ src: "https://example.com", sizes: "51x51" }], + ) + end + + assert_equal('icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end end end diff --git a/test/mcp/resource_template_test.rb b/test/mcp/resource_template_test.rb index ffe742dd..c028e934 100644 --- a/test/mcp/resource_template_test.rb +++ b/test/mcp/resource_template_test.rb @@ -37,6 +37,28 @@ class ResourceTemplateTest < ActiveSupport::TestCase assert_equal expected_icons, resource_template.to_h[:icons] end + test "icons given as Hashes are converted through MCP::Icon" do + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "resource_template_with_hash_icons", + icons: [{ mime_type: "image/png", sizes: ["48x48"], src: "https://example.com" }], + ) + + assert_equal [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com" }], resource_template.to_h[:icons] + end + + test "icons given as Hashes are validated when the resource template is built" do + exception = assert_raises(ArgumentError) do + ResourceTemplate.new( + uri_template: "file:///{path}", + name: "resource_template_with_invalid_icons", + icons: [{ src: "https://example.com", sizes: "51x51" }], + ) + end + + assert_equal('icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end + test "#to_h omits _meta when nil" do resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_meta") diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb index 3c8ff1b1..6dac40a6 100644 --- a/test/mcp/resource_test.rb +++ b/test/mcp/resource_test.rb @@ -37,6 +37,28 @@ class ResourceTest < ActiveSupport::TestCase assert_equal expected_icons, resource.to_h[:icons] end + test "icons given as Hashes are converted through MCP::Icon" do + resource = Resource.new( + uri: "file:///test.txt", + name: "resource_with_hash_icons", + icons: [{ mime_type: "image/png", sizes: ["48x48"], src: "https://example.com" }], + ) + + assert_equal [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com" }], resource.to_h[:icons] + end + + test "icons given as Hashes are validated when the resource is built" do + exception = assert_raises(ArgumentError) do + Resource.new( + uri: "file:///test.txt", + name: "resource_with_invalid_icons", + icons: [{ src: "https://example.com", sizes: "51x51" }], + ) + end + + assert_equal('icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end + test "#to_h omits _meta when nil" do resource = Resource.new(uri: "file:///test.txt", name: "resource_without_meta") diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 80162e5b..aab958ed 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -2320,6 +2320,75 @@ def read_resource_request(uri) assert_equal expected_icons, response[:result][:serverInfo][:icons] end + test "server icons given as Hashes are converted through MCP::Icon" do + server = Server.new( + name: "test_server", + icons: [{ mime_type: "image/png", sizes: ["48x48"], src: "https://example.com" }], + ) + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params, + } + response = server.handle(request) + + assert_equal [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com" }], response[:result][:serverInfo][:icons] + end + + test "server icons assigned after construction are converted through MCP::Icon" do + server = Server.new(name: "test_server") + server.icons = [{ mime_type: "image/png", src: "https://example.com" }] + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params, + } + response = server.handle(request) + + assert_equal [{ mimeType: "image/png", src: "https://example.com" }], response[:result][:serverInfo][:icons] + end + + test "server icons given as Hashes are validated when built or assigned" do + expected_message = 'icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).' + + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", icons: [{ src: "https://example.com", sizes: "51x51" }]) + end + assert_equal expected_message, exception.message + + server = Server.new(name: "test_server") + exception = assert_raises(ArgumentError) do + server.icons = [{ src: "https://example.com", sizes: "51x51" }] + end + assert_equal expected_message, exception.message + end + + test "resources defined on the server validate icons given as Hashes" do + server = Server.new(name: "test_server") + + exception = assert_raises(ArgumentError) do + server.define_resource(uri: "file:///test.txt", name: "resource", icons: [{ src: "https://example.com", sizes: "51x51" }]) + end + + assert_equal('icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end + + test "resource templates defined on the server validate icons given as Hashes" do + server = Server.new(name: "test_server") + + exception = assert_raises(ArgumentError) do + server.define_resource_template( + uri_template: "file:///{path}", + name: "resource_template", + icons: [{ src: "https://example.com", sizes: "51x51" }], + ) + end + + assert_equal('icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end + test "server uses default version when not configured" do server = Server.new(name: "test_server") request = { diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index bdec7b6a..0f921642 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -72,6 +72,49 @@ def call(message:, server_context: nil) refute tool.to_h.key?(:icons) end + test "icons given as Hashes are converted through MCP::Icon" do + tool = Tool.define( + name: "tool_with_hash_icons", + description: "a tool with icons given as Hashes", + icons: [{ mime_type: "image/png", sizes: ["48x48"], src: "https://example.com" }], + ) + + assert_equal [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com" }], tool.to_h[:icons] + end + + test "icons declared in a class definition are converted through MCP::Icon" do + tool = Class.new(Tool) do + tool_name "tool_with_hash_icons" + icons [{ mime_type: "image/png", src: "https://example.com" }] + end + + assert_equal [{ mimeType: "image/png", src: "https://example.com" }], tool.to_h[:icons] + end + + test "icons cannot be added through the reader after the tool is defined" do + tool = Tool.define( + name: "tool_with_frozen_icons", + description: "a tool whose icons are frozen", + icons: [{ src: "https://example.com" }], + ) + + assert_raises(FrozenError) do + tool.icons << { src: "https://example.com", sizes: "51x51" } + end + end + + test "icons given as Hashes are validated when the tool is defined" do + exception = assert_raises(ArgumentError) do + Tool.define( + name: "tool_with_invalid_icons", + description: "a tool with an invalid icon", + icons: [{ src: "https://example.com", sizes: "51x51" }], + ) + end + + assert_equal('icons[0]: The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', exception.message) + end + test "#to_h includes annotations when present" do tool = TestTool expected_annotations = {