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
3 changed files with 952 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue