From 028c5f3291536e9f8bc4756571d5f921b12738e9 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 30 Dec 2025 05:35:22 +0900 Subject: [PATCH] Support `icons` parameter This PR adds support for the optional `icons` parameter in prompt, resource, resource template, and server info. The specifications for `icons` are as follows. https://modelcontextprotocol.io/specification/2025-11-25/schema#icon Resolves https://github.com/modelcontextprotocol/ruby-sdk/issues/127. --- lib/mcp.rb | 1 + lib/mcp/icon.rb | 22 +++++++++++++++++ lib/mcp/prompt.rb | 14 ++++++++++- lib/mcp/resource.rb | 6 +++-- lib/mcp/resource_template.rb | 6 +++-- lib/mcp/server.rb | 5 +++- lib/mcp/tool.rb | 14 ++++++++++- test/mcp/icon_test.rb | 48 ++++++++++++++++++++++++++++++++++++ test/mcp/prompt_test.rb | 5 ++++ test/mcp/server_test.rb | 4 +++ test/mcp/tool_test.rb | 29 ++++++++++++++++------ 11 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 lib/mcp/icon.rb create mode 100644 test/mcp/icon_test.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index 4087befd..c918f776 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -3,6 +3,7 @@ require_relative "json_rpc_handler" require_relative "mcp/configuration" require_relative "mcp/content" +require_relative "mcp/icon" require_relative "mcp/instrumentation" require_relative "mcp/methods" require_relative "mcp/prompt" diff --git a/lib/mcp/icon.rb b/lib/mcp/icon.rb new file mode 100644 index 00000000..5758c422 --- /dev/null +++ b/lib/mcp/icon.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module MCP + class Icon + SUPPORTED_THEMES = ["light", "dark"] + + attr_reader :mime_type, :sizes, :src, :theme + + def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil) + raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme) + + @mime_type = mime_type + @sizes = sizes + @src = src + @theme = theme + end + + def to_h + { mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact + end + end +end diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index a414bdc9..317df7e0 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -7,6 +7,7 @@ class << self attr_reader :title_value attr_reader :description_value + attr_reader :icons_value attr_reader :arguments_value attr_reader :meta_value @@ -19,6 +20,7 @@ def to_h name: name_value, title: title_value, description: description_value, + icons: icons&.map(&:to_h), arguments: arguments_value&.map(&:to_h), _meta: meta_value, }.compact @@ -29,6 +31,7 @@ def inherited(subclass) subclass.instance_variable_set(:@name_value, nil) subclass.instance_variable_set(:@title_value, nil) subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) subclass.instance_variable_set(:@arguments_value, nil) subclass.instance_variable_set(:@meta_value, nil) end @@ -61,6 +64,14 @@ def description(value = NOT_SET) end end + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + def arguments(value = NOT_SET) if value == NOT_SET @arguments_value @@ -77,11 +88,12 @@ def meta(value = NOT_SET) end end - def define(name: nil, title: nil, description: nil, arguments: [], meta: nil, &block) + def define(name: nil, title: nil, description: nil, icons: [], arguments: [], meta: nil, &block) Class.new(self) do prompt_name name title title description description + icons icons arguments arguments define_singleton_method(:template) do |args, server_context: nil| instance_exec(args, server_context:, &block) diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index 5086a8eb..95b74b01 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -2,13 +2,14 @@ module MCP class Resource - attr_reader :uri, :name, :title, :description, :mime_type + attr_reader :uri, :name, :title, :description, :icons, :mime_type - def initialize(uri:, name:, title: nil, description: nil, mime_type: nil) + def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil) @uri = uri @name = name @title = title @description = description + @icons = icons @mime_type = mime_type end @@ -18,6 +19,7 @@ def to_h name: name, title: title, description: description, + icons: icons.map(&:to_h), mimeType: mime_type, }.compact end diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index 9134c227..bfb3c548 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -2,13 +2,14 @@ module MCP class ResourceTemplate - attr_reader :uri_template, :name, :title, :description, :mime_type + attr_reader :uri_template, :name, :title, :description, :icons, :mime_type - def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil) + def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil) @uri_template = uri_template @name = name @title = title @description = description + @icons = icons @mime_type = mime_type end @@ -18,6 +19,7 @@ def to_h name: name, title: title, description: description, + icons: icons.map(&:to_h), mimeType: mime_type, }.compact end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5e34e0a7..38f36d8e 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -40,10 +40,11 @@ def initialize(method_name) include Instrumentation - attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport + attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport def initialize( description: nil, + icons: [], name: "model_context_protocol", title: nil, version: DEFAULT_VERSION, @@ -59,6 +60,7 @@ def initialize( transport: nil ) @description = description + @icons = icons @name = name @title = title @version = version @@ -288,6 +290,7 @@ def default_capabilities def server_info @server_info ||= { description:, + icons:, name:, title:, version:, diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 81755112..b96e76cc 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -7,6 +7,7 @@ class << self attr_reader :title_value attr_reader :description_value + attr_reader :icons_value attr_reader :annotations_value attr_reader :meta_value @@ -19,6 +20,7 @@ def to_h name: name_value, title: title_value, description: description_value, + icons: icons&.map(&:to_h), inputSchema: input_schema_value.to_h, outputSchema: @output_schema_value&.to_h, annotations: annotations_value&.to_h, @@ -31,6 +33,7 @@ def inherited(subclass) subclass.instance_variable_set(:@name_value, nil) subclass.instance_variable_set(:@title_value, nil) subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) subclass.instance_variable_set(:@input_schema_value, nil) subclass.instance_variable_set(:@output_schema_value, nil) subclass.instance_variable_set(:@annotations_value, nil) @@ -71,6 +74,14 @@ def description(value = NOT_SET) end end + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + def input_schema(value = NOT_SET) if value == NOT_SET input_schema_value @@ -107,11 +118,12 @@ def annotations(hash = NOT_SET) end end - def define(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, meta: nil, annotations: nil, &block) + def define(name: nil, title: nil, description: nil, icons: [], input_schema: nil, output_schema: nil, meta: nil, annotations: nil, &block) Class.new(self) do tool_name name title title description description + icons icons input_schema input_schema meta meta output_schema output_schema diff --git a/test/mcp/icon_test.rb b/test/mcp/icon_test.rb new file mode 100644 index 00000000..a4ac1191 --- /dev/null +++ b/test/mcp/icon_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class IconTest < ActiveSupport::TestCase + def test_initialization + icon = Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light") + + assert_equal("image/png", icon.mime_type) + assert_equal(["48x48", "96x96"], icon.sizes) + assert_equal("https://example.com", icon.src) + assert_equal("light", icon.theme) + + assert_equal({ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }, icon.to_h) + end + + def test_initialization_by_default + icon = Icon.new + + assert_nil(icon.mime_type) + assert_nil(icon.sizes) + assert_nil(icon.src) + assert_nil(icon.theme) + + assert_empty(icon.to_h) + end + + def test_valid_theme_for_light + assert_nothing_raised do + Icon.new(theme: "light") + end + end + + def test_valid_theme_for_dark + assert_nothing_raised do + Icon.new(theme: "dark") + end + end + + def test_invalid_theme + exception = assert_raises(ArgumentError) do + Icon.new(theme: "unexpected") + end + assert_equal('The value of theme must specify "light" or "dark".', exception.message) + end + end +end diff --git a/test/mcp/prompt_test.rb b/test/mcp/prompt_test.rb index 41f3857f..64630b8f 100644 --- a/test/mcp/prompt_test.rb +++ b/test/mcp/prompt_test.rb @@ -6,6 +6,7 @@ module MCP class PromptTest < ActiveSupport::TestCase class TestPrompt < Prompt description "Test prompt" + icons [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }] arguments [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] @@ -43,6 +44,7 @@ def template(args, server_context:) class MockPrompt < Prompt prompt_name "my_mock_prompt" description "a mock prompt for testing" + icons [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }] arguments [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] @@ -64,6 +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 "test_argument", prompt.arguments.first.name assert_equal "Test argument", prompt.arguments.first.description assert prompt.arguments.first.required @@ -112,6 +115,7 @@ def template(args, server_context:) name: "mock_prompt", title: "Mock Prompt", description: "a mock prompt for testing", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], arguments: [ Prompt::Argument.new( name: "test_argument", @@ -135,6 +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 "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 diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index b3ca64ec..3aa8d907 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -52,6 +52,7 @@ class ServerTest < ActiveSupport::TestCase name: "test-resource", title: "Test Resource", description: "Test resource", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], mime_type: "text/plain", ) @@ -60,6 +61,7 @@ class ServerTest < ActiveSupport::TestCase name: "test-resource", title: "Test Resource", description: "Test resource", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], mime_type: "text/plain", ) @@ -69,6 +71,7 @@ class ServerTest < ActiveSupport::TestCase @server = Server.new( description: "Test server", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], name: @server_name, title: "Example Server Display Name", version: "1.2.3", @@ -142,6 +145,7 @@ class ServerTest < ActiveSupport::TestCase }, serverInfo: { description: "Test server", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], name: @server_name, title: "Example Server Display Name", version: "1.2.3", diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index 0de4278e..aeeb2c6d 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -7,6 +7,7 @@ class ToolTest < ActiveSupport::TestCase class TestTool < Tool tool_name "test_tool" description "a test tool for testing" + icons [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")] input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) annotations( destructive_hint: false, @@ -26,13 +27,22 @@ def call(message:, server_context: nil) end end - test "#to_h returns a hash with name, description, and inputSchema" do + test "#to_h returns a hash including name, description, icons, and inputSchema" do + expected = { + name: "mock_tool", + title: "Mock Tool", + description: "a mock tool for testing", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], + inputSchema: { type: "object" }, + } tool = Tool.define( name: "mock_tool", title: "Mock Tool", description: "a mock tool for testing", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")], ) - assert_equal({ name: "mock_tool", title: "Mock Tool", description: "a mock tool for testing", inputSchema: { type: "object" } }, tool.to_h) + + assert_equal(expected, tool.to_h) end test "#to_h does not have `:title` key when title is omitted" do @@ -308,19 +318,22 @@ def call(message, server_context: nil) end test "#to_h includes outputSchema when present" do - tool = Tool.define( - name: "mock_tool", - title: "Mock Tool", - description: "a mock tool for testing", - output_schema: { properties: { result: { type: "string" } }, required: ["result"] }, - ) expected = { name: "mock_tool", title: "Mock Tool", description: "a mock tool for testing", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], inputSchema: { type: "object" }, outputSchema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] }, } + tool = Tool.define( + name: "mock_tool", + title: "Mock Tool", + description: "a mock tool for testing", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")], + output_schema: { properties: { result: { type: "string" } }, required: ["result"] }, + ) + assert_equal expected, tool.to_h end