Merge pull request #447 from koic/support_class_based_resource_definitions
Support class-based Resource and ResourceTemplate definitions
This commit is contained in:
commit
d71ec506c4
8 changed files with 952 additions and 18 deletions
130
README.md
130
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,141 @@ require_relative "resource/embedded"
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ module MCP
|
|||
# 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 @@ module MCP
|
|||
|
||||
@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 @@ module MCP
|
|||
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 @@ module MCP
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue