## Motivation and Context Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block with manual URI routing. `MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool` and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`, `size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods and `MCP::Server#define_resource` / `#define_resource_template`. The default `resources/read` handler now routes requests automatically: an exact URI match calls the class-based resource's `contents` method, and class-based resource templates match simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments. `contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164. ## How Has This Been Tested? - Added regression tests covering the class-level DSL, URI template matching, auto-routing, `server_context:` opt-in, error responses, and backward compatibility of the no-op default. - Verified end-to-end with a smoke script exercising exact-match reads, template-match reads, unknown URIs, and legacy instance-only setups. ## Breaking Changes No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds the internal URI index. Closes #98
210 lines
7.3 KiB
Ruby
210 lines
7.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class ResourceTemplateTest < ActiveSupport::TestCase
|
|
test "#to_h does not have `:icons` key when icons is empty" do
|
|
resource_template = ResourceTemplate.new(
|
|
uri_template: "file:///{path}",
|
|
name: "resource_template_without_icons",
|
|
description: "a resource template without icons",
|
|
)
|
|
|
|
refute resource_template.to_h.key?(:icons)
|
|
end
|
|
|
|
test "#to_h does not have `:icons` key when icons is nil" do
|
|
resource_template = ResourceTemplate.new(
|
|
uri_template: "file:///{path}",
|
|
name: "resource_template_without_icons",
|
|
description: "a resource template without icons",
|
|
icons: nil,
|
|
)
|
|
|
|
refute resource_template.to_h.key?(:icons)
|
|
end
|
|
|
|
test "#to_h includes icons when present" do
|
|
resource_template = ResourceTemplate.new(
|
|
uri_template: "file:///{path}",
|
|
name: "resource_template_with_icons",
|
|
description: "a resource template with icons",
|
|
icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")],
|
|
)
|
|
expected_icons = [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light" }]
|
|
|
|
assert_equal expected_icons, resource_template.to_h[:icons]
|
|
end
|
|
|
|
test "#to_h omits _meta when nil" do
|
|
resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_meta")
|
|
|
|
refute resource_template.to_h.key?(:_meta)
|
|
end
|
|
|
|
test "#to_h includes _meta when present" do
|
|
meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } }
|
|
resource_template = ResourceTemplate.new(
|
|
uri_template: "file:///{path}",
|
|
name: "template_with_meta",
|
|
meta: meta,
|
|
)
|
|
|
|
assert_equal meta, resource_template.to_h[:_meta]
|
|
end
|
|
|
|
test "#to_h omits annotations when nil" do
|
|
resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_annotations")
|
|
|
|
refute resource_template.to_h.key?(:annotations)
|
|
end
|
|
|
|
test "#to_h includes annotations when present" do
|
|
annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: "2025-01-12T15:00:58Z")
|
|
resource_template = ResourceTemplate.new(
|
|
uri_template: "file:///{path}",
|
|
name: "template_with_annotations",
|
|
annotations: annotations,
|
|
)
|
|
|
|
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
|