ruby-sdk/test/mcp/resource_test.rb
Koichi ITO b6df40b134 Support class-based Resource and ResourceTemplate definitions
## 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
2026-07-10 22:39:42 +09:00

191 lines
6.2 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
module MCP
class ResourceTest < ActiveSupport::TestCase
test "#to_h does not have `:icons` key when icons is empty" do
resource = Resource.new(
uri: "file:///test.txt",
name: "resource_without_icons",
description: "a resource without icons",
)
refute resource.to_h.key?(:icons)
end
test "#to_h does not have `:icons` key when icons is nil" do
resource = Resource.new(
uri: "file:///test.txt",
name: "resource_without_icons",
description: "a resource without icons",
icons: nil,
)
refute resource.to_h.key?(:icons)
end
test "#to_h includes icons when present" do
resource = Resource.new(
uri: "file:///test.txt",
name: "resource_with_icons",
description: "a resource 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.to_h[:icons]
end
test "#to_h omits _meta when nil" do
resource = Resource.new(uri: "file:///test.txt", name: "resource_without_meta")
refute resource.to_h.key?(:_meta)
end
test "#to_h includes _meta when present" do
meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } }
resource = Resource.new(uri: "file:///test.txt", name: "resource_with_meta", meta: meta)
assert_equal meta, resource.to_h[:_meta]
end
test "#to_h omits size when nil" do
resource = Resource.new(uri: "file:///test.txt", name: "resource_without_size")
refute resource.to_h.key?(:size)
end
test "#to_h includes size when present" do
resource = Resource.new(uri: "file:///test.txt", name: "resource_with_size", size: 12_345)
assert_equal 12_345, resource.to_h[:size]
end
test "#to_h includes size when zero" do
resource = Resource.new(uri: "file:///empty.txt", name: "empty_resource", size: 0)
assert_equal 0, resource.to_h[:size]
end
test "#to_h omits annotations when nil" do
resource = Resource.new(uri: "file:///test.txt", name: "resource_without_annotations")
refute resource.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 = Resource.new(uri: "file:///test.txt", name: "resource_with_annotations", annotations: annotations)
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