diff --git a/README.md b/README.md index 0fe29836..01a0a827 100644 --- a/README.md +++ b/README.md @@ -974,7 +974,71 @@ MCP spec includes [Resources](https://modelcontextprotocol.io/specification/late ### Reading Resources -The `MCP::Resource` class provides a way to register resources with the server. +Like tools and prompts, resources can be defined in three ways. + +1. As a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: + +```ruby +class MyResource < MCP::Resource + uri "https://example.com/my_resource" + resource_name "my-resource" + title "My Resource" + description "Lorem ipsum dolor sit amet" + mime_type "text/html" + + class << self + def contents + [MCP::Resource::TextContents.new( + uri: uri, + mime_type: mime_type, + text: "Hello from example resource!" + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resources: [MyResource], +) +``` + +`resources/read` requests are routed automatically: when the requested URI matches a registered +class-based resource, its `contents` method is called. `contents` may return an array of +`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one. +Like tools, `contents` can opt in to a `server_context:` keyword argument to receive per-request context. + +When class-based resources or resource templates are registered and a `resources/read` request +does not match any of them, the server responds with the standard JSON-RPC Invalid Params error +(`-32602`) carrying the requested URI in the error `data` member, per SEP-2164. + +2. With the `MCP::Resource.define` method, whose block implements `contents`: + +```ruby +resource = MCP::Resource.define( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")] +end +``` + +3. Using the `MCP::Server#define_resource` method: + +```ruby +server = MCP::Server.new(name: "my_server") +server.define_resource( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")] +end +``` + +Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, +in which case the server only lists them: ```ruby resource = MCP::Resource.new( @@ -991,7 +1055,8 @@ server = MCP::Server.new( ) ``` -The server must register a handler for the `resources/read` method to retrieve a resource dynamically. +With plain data resources, the server must register a handler for the `resources/read` method to +retrieve a resource dynamically. ```ruby server.resources_read_handler do |params| @@ -1003,7 +1068,8 @@ server.resources_read_handler do |params| end ``` -otherwise `resources/read` requests will be a no-op. +otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces +the default `resources/read` handling, including the automatic routing to class-based resources described above. For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) @@ -1020,7 +1086,63 @@ end ### Resource Templates -The `MCP::ResourceTemplate` class provides a way to register resource templates with the server. +Resource templates follow the same pattern. Class-based templates declare a `uri_template` and +receive the variables extracted from the requested URI as keyword arguments to `contents`: + +```ruby +class UserProfileTemplate < MCP::ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user-profile" + title "User Profile" + description "Profile data for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: mime_type, + text: { id: user_id }.to_json + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resource_templates: [UserProfileTemplate], +) +``` + +A `resources/read` request for `users://42/profile` calls `UserProfileTemplate.contents(user_id: "42")`. +An exact match against a registered resource takes precedence over template matching. +`contents` can also opt in to a `server_context:` keyword argument. + +URI template matching supports simple [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: + +- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. +- A variable matches one or more characters excluding `/`. +- Extracted values are not percent-decoded. + +The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, +mirroring the resource variants: + +```ruby +server.define_resource_template( + uri_template: "users://{user_id}/profile", + name: "user-profile", + mime_type: "application/json", +) do |user_id:| + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: "application/json", + text: { id: user_id }.to_json + )] +end +``` + +Resource templates can also be registered as plain data objects with `MCP::ResourceTemplate.new`, +in which case reads must be served by a `resources_read_handler`: ```ruby resource_template = MCP::ResourceTemplate.new( diff --git a/docs/building-servers.md b/docs/building-servers.md index 30e28803..408f4bec 100644 --- a/docs/building-servers.md +++ b/docs/building-servers.md @@ -291,20 +291,28 @@ class MyResource < MCP::Resource resource_name "config" description "Application configuration" mime_type "application/json" + + class << self + def contents + [MCP::Resource::TextContents.new( + uri: uri, + mime_type: mime_type, + text: File.read("config.json") + )] + end + end end server = MCP::Server.new( name: "my_server", - resources: [MyResource], - resources_read_handler: ->(uri, _server_context) { - case uri - when "file:///data/config.json" - { uri: uri, text: File.read("config.json"), mimeType: "application/json" } - end - } + resources: [MyResource] ) ``` +The server automatically routes `resources/read` requests to the matching class-based resource's `contents` method. +Requests for unregistered URIs respond with the JSON-RPC Invalid Params error (`-32602`). To handle reads manually instead, +register a block with `server.resources_read_handler`, which fully replaces the automatic routing. + ## Configuration ```ruby diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index 8b5f469e..dd87ee23 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -5,6 +5,141 @@ module MCP class Resource + class << self + NOT_SET = Object.new + + attr_reader :uri_value + attr_reader :title_value + attr_reader :description_value + attr_reader :icons_value + attr_reader :mime_type_value + attr_reader :annotations_value + attr_reader :size_value + attr_reader :meta_value + + def contents(server_context: nil) + raise NotImplementedError, "Subclasses must implement contents" + end + + def to_h + { + uri: uri_value, + name: name_value, + title: title_value, + description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + mimeType: mime_type_value, + annotations: annotations_value&.to_h, + size: size_value, + _meta: meta_value, + }.compact + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@uri_value, nil) + 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(:@mime_type_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) + subclass.instance_variable_set(:@size_value, nil) + subclass.instance_variable_set(:@meta_value, nil) + end + + def uri(value = NOT_SET) + if value == NOT_SET + @uri_value + else + @uri_value = value + end + end + + def resource_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value + end + end + + def name_value + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) + end + + def title(value = NOT_SET) + if value == NOT_SET + @title_value + else + @title_value = value + end + end + + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end + + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + + def mime_type(value = NOT_SET) + if value == NOT_SET + @mime_type_value + else + @mime_type_value = value + end + end + + def annotations(value = NOT_SET) + if value == NOT_SET + @annotations_value + else + @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value) + end + end + + def size(value = NOT_SET) + if value == NOT_SET + @size_value + else + @size_value = value + end + end + + def meta(value = NOT_SET) + if value == NOT_SET + @meta_value + else + @meta_value = value + end + end + + def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block) + Class.new(self) do + uri uri + resource_name name + title title + description description + icons icons + mime_type mime_type + self.annotations(annotations) if annotations + size size + meta meta + define_singleton_method(:contents, &block) if block + end + end + end + attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil) diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index e078174f..4edbc5d9 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -2,6 +2,155 @@ module MCP class ResourceTemplate + class << self + NOT_SET = Object.new + + # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`. + # Variable names are restricted to valid Regexp named-group names, + # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match. + VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/ + + attr_reader :uri_template_value + attr_reader :title_value + attr_reader :description_value + attr_reader :icons_value + attr_reader :mime_type_value + attr_reader :annotations_value + attr_reader :meta_value + + def contents(server_context: nil, **params) + raise NotImplementedError, "Subclasses must implement contents" + end + + def to_h + { + uriTemplate: uri_template_value, + name: name_value, + title: title_value, + description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + mimeType: mime_type_value, + annotations: annotations_value&.to_h, + _meta: meta_value, + }.compact + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@uri_template_value, nil) + subclass.instance_variable_set(:@uri_template_pattern, nil) + 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(:@mime_type_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) + subclass.instance_variable_set(:@meta_value, nil) + end + + def uri_template(value = NOT_SET) + if value == NOT_SET + @uri_template_value + else + @uri_template_pattern = nil + @uri_template_value = value + end + end + + def resource_template_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value + end + end + + def name_value + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) + end + + def title(value = NOT_SET) + if value == NOT_SET + @title_value + else + @title_value = value + end + end + + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end + + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + + def mime_type(value = NOT_SET) + if value == NOT_SET + @mime_type_value + else + @mime_type_value = value + end + end + + def annotations(value = NOT_SET) + if value == NOT_SET + @annotations_value + else + @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value) + end + end + + def meta(value = NOT_SET) + if value == NOT_SET + @meta_value + else + @meta_value = value + end + end + + # Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions. + # Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match. + # Variables match one or more characters excluding `/`, and values are not percent-decoded. + def match_uri(uri) + match = uri_template_pattern&.match(uri) + match&.named_captures&.transform_keys(&:to_sym) + end + + def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block) + Class.new(self) do + uri_template uri_template + resource_template_name name + title title + description description + icons icons + mime_type mime_type + self.annotations(annotations) if annotations + meta meta + define_singleton_method(:contents, &block) if block + end + end + + private + + def uri_template_pattern + return if uri_template.nil? + + @uri_template_pattern ||= begin + pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" } + Regexp.new("\\A#{pattern}\\z") + end + end + end + attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index bebf9a49..78d07749 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -105,8 +105,8 @@ class ValidationError < StandardError; end # Allowed values for the SEP-2549 `cacheScope` cache hint. CACHE_SCOPES = ["public", "private"].freeze - attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :page_size, :client_capabilities, :ttl_ms, :cache_scope + 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 def initialize( description: nil, @@ -161,7 +161,7 @@ def initialize( @handlers = { Methods::RESOURCES_LIST => method(:list_resources), - Methods::RESOURCES_READ => method(:read_resource_no_content), + Methods::RESOURCES_READ => method(:read_resource), Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates), Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, @@ -224,6 +224,34 @@ def define_prompt(name: nil, title: nil, description: nil, arguments: [], &block validate! end + def define_resource(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block) + resource = Resource.define( + uri: uri, name: name, title: title, description: description, icons: icons, + mime_type: mime_type, annotations: annotations, size: size, meta: meta, + &block + ) + @resources << resource + @resource_index[resource.uri] = resource + + validate! + end + + def define_resource_template(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block) + resource_template = ResourceTemplate.define( + uri_template: uri_template, name: name, title: title, description: description, icons: icons, + mime_type: mime_type, annotations: annotations, meta: meta, + &block + ) + @resource_templates << resource_template + + validate! + end + + def resources=(resources) + @resources = resources + @resource_index = index_resources_by_uri(resources) + end + def define_custom_method(method_name:, &block) if @handlers.key?(method_name) raise MethodAlreadyDefinedError, method_name @@ -744,10 +772,57 @@ def list_resources(request) apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact) end - # Server implementation should set `resources_read_handler` to override no-op default - def read_resource_no_content(request) - add_instrumentation_data(resource_uri: request[:uri]) - [] + # Default `resources/read` handler: routes to class-based resources and resource templates. + # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered, + # unknown URIs keep the historical no-op `[]` response instead of raising. + def read_resource(request, server_context: nil) + uri = request[:uri] + add_instrumentation_data(resource_uri: uri) + + resource = @resource_index[uri] + if resource.is_a?(Class) + return normalize_resource_contents(call_resource_contents(resource, server_context)) + end + + @resource_templates.each do |template| + next unless template.is_a?(Class) + + params = template.match_uri(uri) + next unless params + + add_instrumentation_data(resource_template: template.uri_template) + return normalize_resource_contents(call_template_contents(template, params, server_context)) + end + + return [] unless class_based_resources_registered? + + raise ResourceNotFoundError.new(uri, request) + end + + def call_resource_contents(resource, server_context) + if accepts_server_context?(resource.method(:contents)) + resource.contents(server_context: server_context) + else + resource.contents + end + end + + def call_template_contents(template, params, server_context) + if accepts_server_context?(template.method(:contents)) + template.contents(**params, server_context: server_context) + else + template.contents(**params) + end + end + + def normalize_resource_contents(result) + contents = result.is_a?(Array) ? result : [result] + contents.map(&:to_h) + end + + def class_based_resources_registered? + @resources.any? { |resource| resource.is_a?(Class) } || + @resource_templates.any? { |template| template.is_a?(Class) } end def list_resource_templates(request) diff --git a/test/mcp/resource_template_test.rb b/test/mcp/resource_template_test.rb index 4a6ef5f8..ffe742dd 100644 --- a/test/mcp/resource_template_test.rb +++ b/test/mcp/resource_template_test.rb @@ -71,5 +71,140 @@ class ResourceTemplateTest < ActiveSupport::TestCase expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } assert_equal expected, resource_template.to_h[:annotations] end + + class UserProfileTemplate < ResourceTemplate + uri_template "users://{user_id}/profile" + title "User Profile" + description "profile for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] + end + end + end + + test "class-based .to_h emits the same keys as the equivalent instance" do + instance = ResourceTemplate.new( + uri_template: "users://{user_id}/profile", + name: "user_profile_template", + title: "User Profile", + description: "profile for a user", + mime_type: "application/json", + ) + + assert_equal instance.to_h, UserProfileTemplate.to_h + end + + test "class-based name defaults to the snake_cased class name" do + assert_equal "user_profile_template", UserProfileTemplate.name_value + end + + test "explicit resource_template_name wins over the class name" do + template_class = Class.new(UserProfileTemplate) do + resource_template_name "custom_template" + end + + assert_equal "custom_template", template_class.resource_template_name + assert_equal "custom_template", template_class.name_value + end + + test "subclasses of a configured class start clean" do + subclass = Class.new(UserProfileTemplate) + + assert_nil subclass.uri_template + assert_nil subclass.title + assert_nil subclass.description + assert_nil subclass.mime_type + end + + test ".contents raises NotImplementedError unless implemented" do + template_class = Class.new(ResourceTemplate) + + assert_raises(NotImplementedError) { template_class.contents(user_id: "42") } + end + + test ".match_uri extracts a single template variable" do + assert_equal({ user_id: "42" }, UserProfileTemplate.match_uri("users://42/profile")) + end + + test ".match_uri extracts multiple template variables" do + template_class = Class.new(ResourceTemplate) do + uri_template "repos://{owner}/{repo}" + end + + assert_equal({ owner: "octo", repo: "sdk" }, template_class.match_uri("repos://octo/sdk")) + end + + test ".match_uri returns an empty hash for an exact match without variables" do + template_class = Class.new(ResourceTemplate) do + uri_template "config://app" + end + + assert_equal({}, template_class.match_uri("config://app")) + end + + test ".match_uri returns nil when the URI does not match" do + assert_nil UserProfileTemplate.match_uri("users://42/settings") + assert_nil UserProfileTemplate.match_uri("posts://42/profile") + end + + test ".match_uri variables do not cross path separators" do + assert_nil UserProfileTemplate.match_uri("users://42/extra/profile") + end + + test ".match_uri returns nil when uri_template is not set" do + template_class = Class.new(ResourceTemplate) + + assert_nil template_class.match_uri("users://42/profile") + end + + test ".match_uri escapes regex metacharacters in literal parts" do + template_class = Class.new(ResourceTemplate) do + uri_template "file:///data.v1/{name}" + end + + assert_equal({ name: "foo" }, template_class.match_uri("file:///data.v1/foo")) + assert_nil template_class.match_uri("file:///dataXv1/foo") + end + + test ".match_uri treats unsupported RFC 6570 operators as literals" do + template_class = Class.new(ResourceTemplate) do + uri_template "search://items{?query}" + end + + assert_nil template_class.match_uri("search://items?query=foo") + assert_equal({}, template_class.match_uri("search://items{?query}")) + end + + test "re-setting uri_template invalidates the compiled pattern" do + template_class = Class.new(ResourceTemplate) do + uri_template "old://{id}" + end + assert_equal({ id: "1" }, template_class.match_uri("old://1")) + + template_class.uri_template("new://{id}") + + assert_nil template_class.match_uri("old://1") + assert_equal({ id: "2" }, template_class.match_uri("new://2")) + end + + test ".define creates a template class whose block implements contents" do + template_class = ResourceTemplate.define( + uri_template: "items://{item_id}", + name: "item_template", + mime_type: "text/plain", + ) do |item_id:| + [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: mime_type, text: "item #{item_id}")] + end + + assert_equal "items://{item_id}", template_class.uri_template + assert_equal "item_template", template_class.name_value + + contents = template_class.contents(item_id: "7") + assert_equal 1, contents.size + assert_equal "item 7", contents.first.text + end end end diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb index a721ad39..3c8ff1b1 100644 --- a/test/mcp/resource_test.rb +++ b/test/mcp/resource_test.rb @@ -81,5 +81,111 @@ class ResourceTest < ActiveSupport::TestCase expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } assert_equal expected, resource.to_h[:annotations] end + + class UserGuideResource < Resource + uri "file:///docs/user_guide.md" + title "User Guide" + description "the user guide" + icons [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")] + mime_type "text/markdown" + annotations audience: ["user"], priority: 0.5 + size 2_048 + meta({ "example" => "value" }) + + class << self + def contents + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "guide body")] + end + end + end + + test "class-based .to_h emits the same keys as the equivalent instance" do + instance = Resource.new( + uri: "file:///docs/user_guide.md", + name: "user_guide_resource", + title: "User Guide", + description: "the user guide", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], + mime_type: "text/markdown", + annotations: Annotations.new(audience: ["user"], priority: 0.5), + size: 2_048, + meta: { "example" => "value" }, + ) + + assert_equal instance.to_h, UserGuideResource.to_h + end + + test "class-based name defaults to the snake_cased class name" do + assert_equal "user_guide_resource", UserGuideResource.name_value + end + + test "explicit resource_name wins over the class name" do + resource_class = Class.new(UserGuideResource) do + resource_name "custom_name" + end + + assert_equal "custom_name", resource_class.resource_name + assert_equal "custom_name", resource_class.name_value + end + + test "class-based .to_h omits keys that are not set" do + resource_class = Class.new(Resource) do + uri "file:///bare.txt" + resource_name "bare" + end + + assert_equal({ uri: "file:///bare.txt", name: "bare" }, resource_class.to_h) + end + + test "class-level annotations accepts an Annotations instance" do + annotations = Annotations.new(audience: ["assistant"]) + resource_class = Class.new(Resource) do + annotations annotations + end + + assert_same annotations, resource_class.annotations + end + + test "subclasses of a configured class start clean" do + subclass = Class.new(UserGuideResource) + + assert_nil subclass.uri + assert_nil subclass.title + assert_nil subclass.description + assert_nil subclass.icons + assert_nil subclass.mime_type + assert_nil subclass.annotations + assert_nil subclass.size + assert_nil subclass.meta + end + + test ".contents raises NotImplementedError unless implemented" do + resource_class = Class.new(Resource) + + assert_raises(NotImplementedError) { resource_class.contents } + end + + test ".define creates a resource class whose block implements contents" do + resource_class = Resource.define( + uri: "file:///defined.txt", + name: "defined_resource", + mime_type: "text/plain", + ) do + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "defined body")] + end + + assert_equal "file:///defined.txt", resource_class.uri + assert_equal "defined_resource", resource_class.name_value + + contents = resource_class.contents + assert_equal 1, contents.size + assert_equal "defined body", contents.first.text + end + + test ".define without a name omits :name from to_h" do + resource_class = Resource.define(uri: "file:///anonymous.txt") + + refute resource_class.to_h.key?(:name) + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 417f3526..df6ad9fc 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -925,6 +925,196 @@ class Example < Tool assert_instrumentation_data({ method: "resources/templates/list" }) end + class GreetingResource < Resource + uri "greeting://hello" + resource_name "greeting" + mime_type "text/plain" + + class << self + def contents + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "hello")] + end + end + end + + class UserProfileTemplate < ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user_profile" + mime_type "text/plain" + + class << self + def contents(user_id:) + [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] + end + end + end + + def read_resource_request(uri) + { + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: uri }, + } + end + + test "#handle resources/read auto-routes to a class-based resource by exact URI" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(name: "test_server", resources: [GreetingResource], configuration: configuration) + + response = server.handle(read_resource_request("greeting://hello")) + + expected = [{ uri: "greeting://hello", mimeType: "text/plain", text: "hello" }] + assert_equal({ contents: expected }, response[:result]) + assert_instrumentation_data({ method: "resources/read", resource_uri: "greeting://hello" }) + end + + test "#handle resources/read wraps a single contents object returned by a class-based resource" do + resource = Resource.define(uri: "single://one", name: "single") do + Resource::TextContents.new(uri: "single://one", mime_type: "text/plain", text: "only one") + end + server = Server.new(name: "test_server", resources: [resource]) + + response = server.handle(read_resource_request("single://one")) + + expected = [{ uri: "single://one", mimeType: "text/plain", text: "only one" }] + assert_equal({ contents: expected }, response[:result]) + end + + test "#handle resources/read passes server_context to contents that opt in" do + received = nil + resource = Resource.define(uri: "ctx://resource", name: "ctx") do |server_context:| + received = server_context + [Resource::TextContents.new(uri: "ctx://resource", mime_type: "text/plain", text: "ctx")] + end + server = Server.new(name: "test_server", resources: [resource]) + + server.handle(read_resource_request("ctx://resource")) + + assert_instance_of ServerContext, received + end + + test "#handle resources/read auto-routes to a class-based resource template with extracted params" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate], configuration: configuration) + + response = server.handle(read_resource_request("users://42/profile")) + + expected = [{ uri: "users://42/profile", mimeType: "text/plain", text: "profile of 42" }] + assert_equal({ contents: expected }, response[:result]) + assert_instrumentation_data({ + method: "resources/read", + resource_uri: "users://42/profile", + resource_template: "users://{user_id}/profile", + }) + end + + test "#handle resources/read prefers an exact resource match over a template match" do + resource = Resource.define(uri: "users://42/profile", name: "pinned_profile") do + [Resource::TextContents.new(uri: "users://42/profile", mime_type: "text/plain", text: "pinned")] + end + server = Server.new(name: "test_server", resources: [resource], resource_templates: [UserProfileTemplate]) + + response = server.handle(read_resource_request("users://42/profile")) + + assert_equal "pinned", response[:result][:contents].first[:text] + end + + test "#handle resources/read returns -32602 for an unknown URI when class-based resources are registered" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + + response = server.handle(read_resource_request("greeting://unknown")) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: greeting://unknown", response[:error][:message]) + assert_equal({ uri: "greeting://unknown" }, response[:error][:data]) + end + + test "#handle resources/read returns -32602 for a non-matching URI when class-based templates are registered" do + server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate]) + + response = server.handle(read_resource_request("users://42/settings")) + + assert_equal(-32602, response[:error][:code]) + assert_equal({ uri: "users://42/settings" }, response[:error][:data]) + end + + test "#handle resources/read keeps returning [] for unknown URIs when only instance-based resources are registered" do + server = Server.new(name: "test_server", resources: [@resource], resource_templates: [@resource_template]) + + response = server.handle(read_resource_request("unknown://resource")) + + assert_equal({ contents: [] }, response[:result]) + end + + test "#resources_read_handler overrides auto-routing for class-based resources" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + server.resources_read_handler do |request| + [{ uri: request[:uri], mimeType: "text/plain", text: "handler wins" }] + end + + response = server.handle(read_resource_request("greeting://hello")) + + assert_equal "handler wins", response[:result][:contents].first[:text] + end + + test "#handle resources/list and resources/templates/list render class-based and instance-based entries together" do + server = Server.new( + name: "test_server", + resources: [@resource, GreetingResource], + resource_templates: [@resource_template, UserProfileTemplate], + ) + + list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal [@resource.to_h, GreetingResource.to_h], list_response[:result][:resources] + + templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + assert_equal [@resource_template.to_h, UserProfileTemplate.to_h], templates_response[:result][:resourceTemplates] + end + + test "#resources= rebuilds the resource index used for auto-routing" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + + replacement = Resource.define(uri: "replacement://res", name: "replacement") do + [Resource::TextContents.new(uri: "replacement://res", mime_type: "text/plain", text: "replaced")] + end + server.resources = [replacement] + + response = server.handle(read_resource_request("replacement://res")) + assert_equal "replaced", response[:result][:contents].first[:text] + + stale_response = server.handle(read_resource_request("greeting://hello")) + assert_equal(-32602, stale_response[:error][:code]) + end + + test "#define_resource registers a resource served by auto-routing" do + server = Server.new(name: "test_server") + server.define_resource(uri: "defined://res", name: "defined", mime_type: "text/plain") do + [Resource::TextContents.new(uri: "defined://res", mime_type: "text/plain", text: "defined body")] + end + + list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal ["defined"], list_response[:result][:resources].map { |resource| resource[:name] } + + response = server.handle(read_resource_request("defined://res")) + assert_equal "defined body", response[:result][:contents].first[:text] + end + + test "#define_resource_template registers a template served by auto-routing" do + server = Server.new(name: "test_server") + server.define_resource_template(uri_template: "items://{item_id}", name: "item_template") do |item_id:| + [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: "text/plain", text: "item #{item_id}")] + end + + templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + assert_equal ["item_template"], templates_response[:result][:resourceTemplates].map { |template| template[:name] } + + response = server.handle(read_resource_request("items://7")) + assert_equal "item 7", response[:result][:contents].first[:text] + end + test "#configure_logging_level returns empty hash on success" do response = @server.handle( { @@ -1556,6 +1746,20 @@ class Example < Tool assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end + test "raises error if `title` of class-based resource is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + resource = Resource.define( + uri: "https://test_resource.invalid", + name: "test-resource", + title: "Test resource", + ) + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", resources: [resource], configuration: configuration) + end + assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + test "allows `$ref` in tool input schema with protocol version 2025-11-25" do tool = Tool.define( name: "ref_tool",