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
This commit is contained in:
Koichi ITO 2026-07-06 21:39:12 +09:00
commit b6df40b134
3 changed files with 952 additions and 18 deletions

View file

@ -71,5 +71,140 @@ module MCP
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

View file

@ -81,5 +81,111 @@ module MCP
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

View file

@ -925,6 +925,196 @@ module MCP
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 @@ module MCP
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",