Skip to content

Commit 538a364

Browse files
authored
Merge pull request #563 from koic/validate_icon_src_and_sizes
Validate the src and sizes of an icon against the specification
2 parents 66b2efa + 8173a38 commit 538a364

2 files changed

Lines changed: 122 additions & 9 deletions

File tree

lib/mcp/icon.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
11
# frozen_string_literal: true
22

33
module MCP
4+
# An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type:
5+
# https://modelcontextprotocol.io/specification/2026-07-28/basic#icons
6+
#
7+
# Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs,
8+
# so an icon that would serialize into something a client rejects fails here instead. What a value means
9+
# is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
10+
# is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
11+
# (the specification expects `WxH` or `"any"`).
412
class Icon
513
SUPPORTED_THEMES = ["light", "dark"].freeze
614

715
attr_reader :mime_type, :sizes, :src, :theme
816

9-
def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil)
10-
raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme)
17+
def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
18+
unless src.is_a?(String) && !src.empty?
19+
raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})."
20+
end
21+
22+
unless mime_type.nil? || mime_type.is_a?(String)
23+
raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})."
24+
end
25+
26+
if (problem = sizes_problem(sizes))
27+
raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})."
28+
end
29+
30+
unless theme.nil? || SUPPORTED_THEMES.include?(theme)
31+
raise ArgumentError, 'The value of theme must specify "light" or "dark".'
32+
end
1133

1234
@mime_type = mime_type
1335
@sizes = sizes
@@ -18,5 +40,16 @@ def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil)
1840
def to_h
1941
{ mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact
2042
end
43+
44+
private
45+
46+
def sizes_problem(sizes)
47+
return if sizes.nil?
48+
return "got #{sizes.class}" unless sizes.is_a?(Array)
49+
50+
index = sizes.index { |size| !size.is_a?(String) }
51+
52+
"got #{sizes[index].class} inside the Array" if index
53+
end
2154
end
2255
end

test/mcp/icon_test.rb

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,112 @@ def test_initialization
1515
assert_equal({ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }, icon.to_h)
1616
end
1717

18-
def test_initialization_by_default
19-
icon = Icon.new
18+
def test_initialization_with_only_src
19+
icon = Icon.new(src: "https://example.com/icon.png")
2020

2121
assert_nil(icon.mime_type)
2222
assert_nil(icon.sizes)
23-
assert_nil(icon.src)
23+
assert_equal("https://example.com/icon.png", icon.src)
2424
assert_nil(icon.theme)
2525

26-
assert_equal({}, icon.to_h)
26+
assert_equal({ src: "https://example.com/icon.png" }, icon.to_h)
27+
end
28+
29+
def test_src_is_required
30+
exception = assert_raises(ArgumentError) do
31+
Icon.new
32+
end
33+
assert_equal("missing keyword: :src", exception.message)
34+
end
35+
36+
def test_src_rejects_nil_and_an_empty_string
37+
[nil, ""].each do |src|
38+
exception = assert_raises(ArgumentError) do
39+
Icon.new(src: src)
40+
end
41+
assert_equal("The value of src must be a non-empty String (got #{src.class}).", exception.message)
42+
end
43+
end
44+
45+
def test_src_rejects_a_non_string
46+
exception = assert_raises(ArgumentError) do
47+
Icon.new(src: :icon)
48+
end
49+
assert_equal("The value of src must be a non-empty String (got Symbol).", exception.message)
50+
end
51+
52+
def test_sizes_accepts_an_array_of_strings
53+
[["48x48", "96x96"], ["any"], []].each do |sizes|
54+
icon = Icon.new(sizes: sizes, src: "https://example.com/icon.png")
55+
56+
assert_equal(sizes, icon.to_h[:sizes])
57+
end
58+
end
59+
60+
# https://github.com/modelcontextprotocol/ruby-sdk/issues/562
61+
def test_sizes_rejects_a_string
62+
exception = assert_raises(ArgumentError) do
63+
Icon.new(mime_type: "image/png", sizes: "51x51", src: "https://example.com/icon.png")
64+
end
65+
assert_equal(
66+
'The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).',
67+
exception.message,
68+
)
69+
end
70+
71+
def test_sizes_rejects_an_array_holding_a_non_string
72+
{
73+
["48x48", 96] => "Integer",
74+
[nil] => "NilClass",
75+
["48x48", nil] => "NilClass",
76+
[nil, 96] => "NilClass",
77+
}.each do |sizes, offender|
78+
exception = assert_raises(ArgumentError) do
79+
Icon.new(sizes: sizes, src: "https://example.com/icon.png")
80+
end
81+
assert_equal(
82+
"The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (got #{offender} inside the Array).",
83+
exception.message,
84+
)
85+
end
86+
end
87+
88+
def test_src_accepts_any_scheme_and_sizes_accept_any_string
89+
string_subclass = Class.new(String)
90+
icon = Icon.new(sizes: ["unconventional", +"48x48", string_subclass.new("any")], src: "custom:icon")
91+
92+
assert_equal({ sizes: ["unconventional", "48x48", "any"], src: "custom:icon" }, icon.to_h)
93+
end
94+
95+
def test_mime_type_rejects_a_non_string
96+
exception = assert_raises(ArgumentError) do
97+
Icon.new(mime_type: :png, src: "https://example.com/icon.png")
98+
end
99+
assert_equal("The value of mime_type must be a String (got Symbol).", exception.message)
27100
end
28101

29102
def test_valid_theme_for_light
30103
assert_nothing_raised do
31-
Icon.new(theme: "light")
104+
Icon.new(src: "https://example.com/icon.png", theme: "light")
32105
end
33106
end
34107

35108
def test_valid_theme_for_dark
36109
assert_nothing_raised do
37-
Icon.new(theme: "dark")
110+
Icon.new(src: "https://example.com/icon.png", theme: "dark")
38111
end
39112
end
40113

41114
def test_invalid_theme
42115
exception = assert_raises(ArgumentError) do
43-
Icon.new(theme: "unexpected")
116+
Icon.new(src: "https://example.com/icon.png", theme: "unexpected")
117+
end
118+
assert_equal('The value of theme must specify "light" or "dark".', exception.message)
119+
end
120+
121+
def test_theme_rejects_false
122+
exception = assert_raises(ArgumentError) do
123+
Icon.new(src: "https://example.com/icon.png", theme: false)
44124
end
45125
assert_equal('The value of theme must specify "light" or "dark".', exception.message)
46126
end

0 commit comments

Comments
 (0)