## 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
171 lines
4.3 KiB
Ruby
171 lines
4.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "resource/contents"
|
|
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)
|
|
@uri = uri
|
|
@name = name
|
|
@title = title
|
|
@description = description
|
|
@icons = icons
|
|
@mime_type = mime_type
|
|
@annotations = annotations
|
|
@size = size
|
|
@meta = meta
|
|
end
|
|
|
|
def to_h
|
|
{
|
|
uri: uri,
|
|
name: name,
|
|
title: title,
|
|
description: description,
|
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
mimeType: mime_type,
|
|
annotations: annotations&.to_h,
|
|
size: size,
|
|
_meta: meta,
|
|
}.compact
|
|
end
|
|
end
|
|
end
|