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:
parent
61233ed43b
commit
b6df40b134
1 changed files with 952 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue