diff --git a/docs/_extensions/index.md b/docs/_extensions/index.md index 5b52c0fd..c9dc1fb2 100644 --- a/docs/_extensions/index.md +++ b/docs/_extensions/index.md @@ -15,3 +15,4 @@ as described on [Capability Extensions](/extensions/capability-extensions/). The extensions this SDK ships support for: - [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results +- [Skills](/extensions/skills/) (SEP-2640) - Agent Skills served as resources, with `skills/list`, `skills/get` and scoped directory reads diff --git a/docs/_extensions/skills.md b/docs/_extensions/skills.md new file mode 100644 index 00000000..d39865a5 --- /dev/null +++ b/docs/_extensions/skills.md @@ -0,0 +1,107 @@ +--- +layout: default +title: Skills +nav_order: 4 +--- + +# Skills + +Skills (SEP-2640) is a Final extension (negotiated via [Capability Extensions](/extensions/capability-extensions/)) +that serves [Agent Skills](https://agentskills.io/specification) over the existing Resources primitive. +A skill is a directory of files, minimally a `SKILL.md`, and each of those files is an ordinary MCP resource +under the `skill://` scheme. A host that already treats resources as a virtual filesystem consumes an +MCP-served skill exactly as it consumes one from disk. + +The extension adds three methods on top of that: `skills/list` enumerates the skills a server serves, +`skills/get` returns one entry by URI, and the optional `resources/directory/read` lists a directory's +direct children. Skill *content* is always read through ordinary `resources/read`. + +```ruby +skill_md = File.read("skills/refunds/SKILL.md") +email_md = File.read("skills/refunds/examples/email.md") +uri = MCP::Skills.uri_for("acme/billing/refunds") # => "skill://acme/billing/refunds/SKILL.md" + +capabilities = MCP::Server::Capabilities.new +capabilities.support_resources # required: skill files are read through `resources/read` +capabilities.support_extensions(MCP::Skills.capability(directory_read: true)) + +server = MCP::Server.new( + name: "billing_server", + capabilities: capabilities, + skills: [ + MCP::Skill.new( + uri: uri, + # The verbatim SKILL.md frontmatter: every field the author wrote, not a curated subset. + frontmatter: { "name" => "refunds", "description" => "Process customer refund requests per company policy" }, + # The complete manifest: every file of the skill, SKILL.md included, with digests and sizes. + resources: [ + { uri: uri, digest: MCP::Skills.digest(skill_md), size: skill_md.bytesize }, + { uri: MCP::Skills.resolve(uri, "examples/email.md"), digest: MCP::Skills.digest(email_md), size: email_md.bytesize }, + ], + ), + ], +) + +# Skill files are served as ordinary resources. +server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: "text/markdown", text: load_skill_file(params[:uri]) }] +end +``` + +That server answers `skills/list`, `skills/get` and `resources/directory/read` with no further wiring: +directory children are derived from the registered skills' manifests. + +## Entries + +A `skills/list` entry is a complete manifest rather than a summary, so a host that pages the listing has, +in that one pass, everything it needs to build its registry, present the skill for approval, bind that +approval to content, and verify every file it later reads. `skills/get` returns the identical shape for a +single skill and is never a step a host must take to complete a listed entry; it exists to refresh one +entry's digests, and to answer for a skill the listing omitted. + +`MCP::Skill` enforces the extension's structural rules at construction: the URI addresses the skill's +`SKILL.md`, the frontmatter carries `name` and `description`, the frontmatter `name` equals the final +segment of the skill path, and the manifest is complete, duplicate-free and confined to the skill's root. + +{: .important } +A skill whose content is generated per request cannot publish stable digests. Pass +`resources: MCP::Skill::DYNAMIC` instead of a manifest. Such a skill offers no content integrity and +cannot be content-bound, and hosts MAY decline to load it. + +## Limits + +SEP-2640 fixes two per-skill limits every conforming host accepts: 512 resources and 16 MiB in total. +A registered skill that exceeds either is kept and warned about rather than refused — servers SHOULD stay +within them, but only the host decides whether to load an oversized skill. `MCP::Skill#limit_violations` +reports what a given skill exceeds. + +## Unenumerable catalogs + +A server whose skill catalog is large, generated, or otherwise unenumerable MAY return an empty or partial +`skills/list`; hosts MUST NOT read that as proof the server has no skills. Such a server replaces the +default lookups, and MUST still answer `skills/get` for every skill it serves: + +```ruby +server.skills_list_handler { |_params| [] } +server.skills_get_handler { |params| SkillCatalog.find(params[:uri]) } +server.resources_directory_read_handler { |params| SkillCatalog.children_of(params[:uri]) } +``` + +Each block may declare `server_context:` to receive the request context, the same opt-in +`resources_list_handler` uses. + +## Directory reads + +`resources/directory/read` is gated behind the `directoryRead` setting, which +`MCP::Skills.capability(directory_read: true)` declares; a client MUST NOT call it otherwise. It returns the +direct children of a directory resource as the same `Resource` objects `resources/list` returns, +subdirectories carrying `mimeType: "inode/directory"`. The listing is never recursive: a client descends by +calling the method again on a child directory. A URI that does not exist, or that is not a directory +resource, answers `-32602`, as `skills/get` does for an unknown skill. + +{: .note } +For a skill whose entry carries a manifest, a directory read tells a host nothing the manifest did not. +It earns its place for dynamically generated skills, for resource trees that are not skills at all, and for +observing a directory without refreshing the entry. A host MUST NOT treat the result as extending a manifest. + +See the [Skills extension specification](https://modelcontextprotocol.io/seps/2640-skills-extension). diff --git a/lib/mcp.rb b/lib/mcp.rb index 2213f231..48ae850e 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -24,6 +24,8 @@ module MCP autoload :ResultType, "mcp/result_type" autoload :Server, "mcp/server" autoload :ServerSession, "mcp/server_session" + autoload :Skill, "mcp/skill" + autoload :Skills, "mcp/skills" autoload :Tool, "mcp/tool" autoload :TraceContext, "mcp/trace_context" diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 60af7084..cb3adace 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -25,6 +25,14 @@ module Methods TOOLS_CALL = "tools/call" TOOLS_LIST = "tools/list" + # Skills extension (SEP-2640). `skills/list` and `skills/get` are required of every server + # declaring `io.modelcontextprotocol/skills`; `resources/directory/read` is additionally gated + # behind the declaration's `directoryRead` setting. All three are negotiated through + # `capabilities.extensions` (SEP-2133) rather than a top-level capability. + SKILLS_LIST = "skills/list" + SKILLS_GET = "skills/get" + RESOURCES_DIRECTORY_READ = "resources/directory/read" + # RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575): # `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`, # `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource @@ -91,6 +99,11 @@ def ensure_capability!(method, capabilities) require_capability!(method, capabilities, :resources, :subscribe) when TOOLS_CALL, TOOLS_LIST require_capability!(method, capabilities, :tools) + when SKILLS_LIST, SKILLS_GET + require_extension!(method, capabilities, Skills::EXTENSION_ID) + when RESOURCES_DIRECTORY_READ + require_capability!(method, capabilities, :resources) + require_extension!(method, capabilities, Skills::EXTENSION_ID, :directoryRead) when NOTIFICATIONS_TOOLS_LIST_CHANGED require_capability!(method, capabilities, :tools) require_capability!(method, capabilities, :tools, :listChanged) @@ -112,6 +125,25 @@ def ensure_capability!(method, capabilities) private + # Extension declarations are keyed by reverse-DNS identifier (SEP-2133), which callers may + # write as either a String or a Symbol, so neither `dig` alone nor a fixed key form suffices. + # `setting` additionally requires an optional feature within the declaration to be enabled. + def require_extension!(method, capabilities, extension_id, setting = nil) + extensions = read_key(capabilities, :extensions) + declaration = read_key(extensions, extension_id) + name = setting ? "extensions.#{extension_id}.#{setting}" : "extensions.#{extension_id}" + + raise MissingRequiredCapabilityError.new(method, name) unless declaration.is_a?(Hash) + raise MissingRequiredCapabilityError.new(method, name) if setting && read_key(declaration, setting) != true + end + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + def require_capability!(method, capabilities, *keys) name = keys.join(".") # :resources, :subscribe -> "resources.subscribe" has_capability = capabilities.dig(*keys) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 051a8928..16128bda 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -128,6 +128,34 @@ def initialize(uri, request = nil) end end + # Raised when `skills/get` names a URI the server does not serve. SEP-2640 specifies the same + # `-32602` the resource methods use for unknown URIs, with the requested URI in the error `data`. + class SkillNotFoundError < RequestHandlerError + def initialize(uri, request = nil) + super( + "Skill not found: #{uri}", + request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + error_data: { uri: uri }, + ) + end + end + + # Raised when `resources/directory/read` names a URI that does not exist or is not a directory + # resource. SEP-2640 gives both cases the same `-32602` as an unknown resource. + class DirectoryNotFoundError < RequestHandlerError + def initialize(uri, request = nil) + super( + "Directory not found: #{uri}", + request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + error_data: { uri: uri }, + ) + end + end + # Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout. # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources, # and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this. @@ -179,10 +207,28 @@ class ValidationError < StandardError; end Methods::RESOURCES_LIST, Methods::RESOURCES_TEMPLATES_LIST, Methods::RESOURCES_READ, + # SEP-2640: `skills/list` carries the list-caching attributes at 2026-07-28. `skills/get` does not; + # the SEP leaves that open, so its result stays hint-free rather than guessing. + Methods::SKILLS_LIST, ].freeze + # A skill's files are declared by URI alone, so the default directory listing infers each child's + # `mimeType` from its extension and omits the field when it cannot. Servers that know better supply a + # `resources_directory_read_handler`. + DIRECTORY_CHILD_MIME_TYPES = { + ".md" => Skills::SKILL_MIME_TYPE, + ".txt" => "text/plain", + ".json" => "application/json", + ".yaml" => "application/yaml", + ".yml" => "application/yaml", + ".csv" => "text/csv", + ".html" => "text/html", + ".py" => "text/x-python", + ".sh" => "application/x-sh", + }.freeze + 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, :request_state_security + attr_reader :resources, :skills, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security def initialize( description: nil, @@ -196,6 +242,7 @@ def initialize( prompts: [], resources: [], resource_templates: [], + skills: [], server_context: nil, configuration: nil, capabilities: nil, @@ -220,6 +267,10 @@ def initialize( @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) @resources_list_handler = nil + @skills_list_handler = nil + @skills_get_handler = nil + @resources_directory_read_handler = nil + self.skills = skills @server_context = server_context self.page_size = page_size self.ttl_ms = ttl_ms @@ -243,6 +294,8 @@ def initialize( else capabilities || default_capabilities end + validate_skills_capability! + @client_capabilities = nil @logging_message_notification = nil @@ -250,6 +303,9 @@ def initialize( Methods::RESOURCES_LIST => method(:list_resources), Methods::RESOURCES_READ => method(:read_resource), Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates), + Methods::RESOURCES_DIRECTORY_READ => method(:read_resource_directory), + Methods::SKILLS_LIST => method(:list_skills), + Methods::SKILLS_GET => method(:get_skill), Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, Methods::TOOLS_LIST => method(:list_tools), @@ -339,6 +395,18 @@ def resources=(resources) @resource_index = index_resources_by_uri(resources) end + # Registers the skills this server serves (SEP-2640). Entries may be `MCP::Skill` instances or the + # `{uri:, frontmatter:, resources:}` Hash shape. A skill exceeding the extension's per-skill limits is + # registered anyway and warned about: servers SHOULD stay within them, but only the host decides + # whether to load an oversized skill. + def skills=(skills) + @skills = (skills || []).map { |skill| Skill.from(skill) } + @skill_index = @skills.each_with_object({}) { |skill, index| index[skill.uri] = skill } + @skills.each do |skill| + skill.limit_violations.each { |violation| warn("MCP skill #{skill.uri}: #{violation}") } + end + end + def define_custom_method(method_name:, &block) if @handlers.key?(method_name) raise MethodAlreadyDefinedError, method_name @@ -449,6 +517,30 @@ def resources_list_handler(&block) # # @yield [params] The request params containing `:uri`. # @yieldreturn [Array, Hash] Resource contents. + # Replaces the default `skills/list`, for a catalog this server cannot hold in memory. + # The block receives the request params and returns skills (instances or Hashes); pagination, + # serialization and the SEP-2549 cache hints are applied to whatever it returns. + # SEP-2640 lets a server whose catalog is large, generated or otherwise unenumerable return an + # empty or partial listing, so a block returning `[]` is conformant. + def skills_list_handler(&block) + @skills_list_handler = block + end + + # Replaces the default `skills/get` lookup. The block receives the request params and returns one + # skill, or `nil` for a URI this server does not serve. A server MUST answer for every skill it + # serves, including ones its listing omits, which is what this block is for. + def skills_get_handler(&block) + @skills_get_handler = block + end + + # Replaces the default `resources/directory/read`, which derives a directory's children from the + # manifests of the registered skills. A block is required for directories the manifests do not + # describe, dynamically generated skills among them. It receives the request params and returns + # the direct children as resource Hashes, subdirectories carrying `mimeType: "inode/directory"`. + def resources_directory_read_handler(&block) + @resources_directory_read_handler = block + end + def resources_read_handler(&block) @handlers[Methods::RESOURCES_READ] = block end @@ -578,6 +670,18 @@ def validate! end end + # SEP-2640: a server declaring the Skills extension MUST also declare the `resources` capability, + # since `resources/read` is where every skill file is actually served from. Caught at construction + # rather than on the first `resources/read`, which would otherwise fail per request. + def validate_skills_capability! + return unless Skills.declared?(@capabilities) + return if @capabilities[:resources] || @capabilities["resources"] + + raise ArgumentError, + "Declaring the #{Skills::EXTENSION_ID} extension requires the `resources` capability: " \ + "skill files are served through `resources/read`" + end + def validate_tool_name! duplicated_tool_names = @tool_names.tally.filter_map { |name, count| name if count >= 2 } @@ -1343,11 +1447,96 @@ def list_resources(request, server_context: nil) # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies). def invoke_resources_list_handler(request, server_context) - if handler_declares_server_context?(@resources_list_handler) - @resources_list_handler.call(request, server_context: server_context) + invoke_with_optional_context(@resources_list_handler, request, server_context) + end + + def invoke_with_optional_context(handler, request, server_context) + if handler_declares_server_context?(handler) + handler.call(request, server_context: server_context) + else + handler.call(request) + end + end + + # `skills/list` (SEP-2640). Each entry is a complete manifest rather than a summary, so a host that + # pages the listing has everything it needs to build its registry and verify every file it later reads. + def list_skills(request, server_context: nil) + skills = if @skills_list_handler + invoke_with_optional_context(@skills_list_handler, request, server_context) + else + @skills + end + + page = paginate(Array(skills), cursor: cursor_from(request), page_size: @page_size, request: request) do |skill| + Skill.from(skill).to_h + end + + apply_cache_metadata({ skills: page[:items], nextCursor: page[:next_cursor] }.compact) + end + + # `skills/get` (SEP-2640): one entry by URI, in the same shape `skills/list` returns, including for a + # skill the listing omits. Never a step a host must take to complete a listed entry. + def get_skill(request, server_context: nil) + uri = skill_uri_param!(request) + add_instrumentation_data(skill_uri: uri) + + skill = if @skills_get_handler + invoke_with_optional_context(@skills_get_handler, request, server_context) + else + @skill_index[uri] + end + + raise SkillNotFoundError.new(uri, request) if skill.nil? + + { skill: Skill.from(skill).to_h } + end + + # `resources/directory/read` (SEP-2640): the direct children of a directory resource, as the same + # `Resource` objects `resources/list` returns and under the same pagination contract. The listing is + # never recursive; a client descends by calling the method again on a child directory. + def read_resource_directory(request, server_context: nil) + uri = skill_uri_param!(request) + add_instrumentation_data(resource_uri: uri) + + children = if @resources_directory_read_handler + invoke_with_optional_context(@resources_directory_read_handler, request, server_context) else - @resources_list_handler.call(request) + skill_directory_children(uri, request) end + + page = paginate(Array(children), cursor: cursor_from(request), page_size: @page_size, request: request) do |child| + child.is_a?(Hash) ? child : child.to_h + end + + { resources: page[:items], nextCursor: page[:next_cursor] }.compact + end + + # Derives a directory's children from the registered skills' manifests, which name every file of every + # non-dynamic skill. A directory no manifest describes is indistinguishable here from one that does not + # exist, and SEP-2640 gives both the same error. + def skill_directory_children(uri, request) + directory = uri.delete_suffix("/") + prefix = "#{directory}/" + files = @skills.reject(&:dynamic?).flat_map { |skill| skill.resources.map(&:uri) }.uniq + descendants = files.select { |file| file.start_with?(prefix) } + + raise DirectoryNotFoundError.new(uri, request) if descendants.empty? + + descendants.map do |file| + segment, nested = file.delete_prefix(prefix).split("/", 2) + if nested + { uri: "#{directory}/#{segment}", name: segment, mimeType: Skills::DIRECTORY_MIME_TYPE } + else + { uri: file, name: segment, mimeType: DIRECTORY_CHILD_MIME_TYPES[File.extname(segment).downcase] }.compact + end + end.uniq.sort_by { |child| child[:uri] } + end + + def skill_uri_param!(request) + uri = request.is_a?(Hash) ? request[:uri] : nil + raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params) unless uri.is_a?(String) + + uri end # Default `resources/read` handler: routes to class-based resources and resource templates. diff --git a/lib/mcp/skill.rb b/lib/mcp/skill.rb new file mode 100644 index 00000000..ebe7fc31 --- /dev/null +++ b/lib/mcp/skill.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +module MCP + # A skill entry as served by the Skills extension (SEP-2640): the verbatim `SKILL.md` + # frontmatter plus the complete manifest of the skill's files. `skills/list` returns an + # array of these and `skills/get` returns one; the shape is identical in both, so a host + # that paged the listing never needs a follow-up call to complete an entry. + # + # The skill format itself (directory layout, frontmatter fields, naming rules, progressive + # disclosure) belongs to the Agent Skills specification, which this extension delegates to + # wholesale: https://agentskills.io/specification + # + # https://modelcontextprotocol.io/seps/2640-skills-extension + class Skill + # Marker taking the place of the `resources` manifest for a skill whose content is generated + # per request, so no stable digest can be published. Such a skill offers no content integrity + # and cannot be content-bound; hosts MAY decline to load it. + DYNAMIC = "dynamic" + + # Every skill URI addresses the skill's `SKILL.md`, never its directory. + SKILL_FILE = "SKILL.md" + + # Per-skill ceilings every conforming host accepts. Servers SHOULD stay within them; a skill + # that exceeds either is not guaranteed to be loadable, which is what {#limit_violations} reports. + MAX_RESOURCES = 512 + MAX_TOTAL_SIZE = 16 * 1024 * 1024 + + DIGEST_PATTERN = /\Asha256:[0-9a-f]{64}\z/.freeze + + # One file of a skill, as it appears in the entry's `resources` manifest. + class Resource + attr_reader :uri, :digest, :size + + # @param uri [String] the file's resource URI. + # @param digest [String] SHA-256 of the file's raw bytes, as `sha256:<64 lowercase hex>`. + # @param size [Integer] length in bytes of the same raw content the digest covers. + def initialize(uri:, digest:, size:) + raise ArgumentError, "Skill resource uri must be a non-empty String" unless uri.is_a?(String) && !uri.empty? + + unless digest.is_a?(String) && DIGEST_PATTERN.match?(digest) + raise ArgumentError, "Skill resource digest must match sha256:<64 lowercase hex> (got #{digest.inspect})" + end + + unless size.is_a?(Integer) && size >= 0 + raise ArgumentError, "Skill resource size must be a non-negative Integer (got #{size.inspect})" + end + + @uri = uri + @digest = digest + @size = size + freeze + end + + class << self + # Accepts a {Resource} or the `{uri:, digest:, size:}` Hash shape, with string or symbol keys. + def from(value) + return value if value.is_a?(Resource) + + unless value.is_a?(Hash) + raise ArgumentError, "Skill resources must be #{Resource} or Hash entries (got #{value.class})" + end + + new(uri: fetch(value, :uri), digest: fetch(value, :digest), size: fetch(value, :size)) + end + + private + + def fetch(hash, key) + hash.key?(key) ? hash[key] : hash[key.to_s] + end + end + + def to_h + { uri: @uri, digest: @digest, size: @size } + end + end + + # `name` repeats the URI's final skill-path segment, so it is recoverable from the URI alone + # without reading frontmatter; `root` is the skill's root directory, the URI with the + # `/SKILL.md` suffix removed and no trailing slash. + attr_reader :uri, :frontmatter, :resources, :name, :root + + # @param uri [String] resource URI of the skill's `SKILL.md`. + # @param frontmatter [Hash] the `SKILL.md` YAML frontmatter rendered verbatim as a JSON object. + # Every field the author wrote, not a curated subset; `name` and `description` are always present. + # @param resources [Array, String] the skill's complete file manifest, or {DYNAMIC}. + def initialize(uri:, frontmatter:, resources:) + @uri = validate_uri!(uri) + @root = @uri.delete_suffix("/#{SKILL_FILE}") + @name = self.class.name_from_uri(@uri) + @frontmatter = validate_frontmatter!(frontmatter) + @resources = validate_resources!(resources) + + freeze + end + + def dynamic? + @resources == DYNAMIC + end + + # Sum of the manifest's `size` values, the figure {MAX_TOTAL_SIZE} bounds. `nil` for a dynamic + # skill, whose entry offers nothing to count. + def total_size + return if dynamic? + + @resources.sum(&:size) + end + + # The SEP-2640 limits this skill exceeds, as human-readable strings. Empty for a conforming + # skill and for a dynamic one, where the ceiling applies to what a host actually retrieves. + def limit_violations + return [] if dynamic? + + violations = [] + if @resources.size > MAX_RESOURCES + violations << "#{@resources.size} resources exceeds the #{MAX_RESOURCES} per-skill limit" + end + if total_size > MAX_TOTAL_SIZE + violations << "#{total_size} bytes exceeds the #{MAX_TOTAL_SIZE} per-skill limit" + end + violations + end + + def to_h + { + uri: @uri, + frontmatter: @frontmatter, + resources: dynamic? ? DYNAMIC : @resources.map(&:to_h), + } + end + + class << self + # Accepts a {Skill} or the `{uri:, frontmatter:, resources:}` Hash shape, with string or symbol keys. + def from(value) + return value if value.is_a?(Skill) + + raise ArgumentError, "Skills must be #{Skill} or Hash entries (got #{value.class})" unless value.is_a?(Hash) + + new( + uri: fetch(value, :uri), + frontmatter: fetch(value, :frontmatter), + resources: fetch(value, :resources), + ) + end + + # The skill name encoded in a skill URI: the final segment of the skill path, which the + # extension requires to equal the frontmatter `name`. + def name_from_uri(uri) + path = uri.delete_suffix("/#{SKILL_FILE}") + path = path.split("://", 2).last + path.split("/").last + end + + private + + def fetch(hash, key) + hash.key?(key) ? hash[key] : hash[key.to_s] + end + end + + private + + def validate_uri!(uri) + unless uri.is_a?(String) && uri.end_with?("/#{SKILL_FILE}") + raise ArgumentError, "Skill uri must be the URI of the skill's #{SKILL_FILE} (got #{uri.inspect})" + end + + raise ArgumentError, "Skill uri is missing a skill path: #{uri.inspect}" if self.class.name_from_uri(uri).to_s.empty? + + uri + end + + def validate_frontmatter!(frontmatter) + raise ArgumentError, "Skill frontmatter must be a Hash (got #{frontmatter.class})" unless frontmatter.is_a?(Hash) + + ["name", "description"].each do |field| + value = frontmatter.key?(field.to_sym) ? frontmatter[field.to_sym] : frontmatter[field] + raise ArgumentError, "Skill frontmatter is missing the required #{field.inspect} field" if value.nil? + end + + declared = frontmatter.key?(:name) ? frontmatter[:name] : frontmatter["name"] + if declared.to_s != name + raise ArgumentError, + "Skill frontmatter name #{declared.inspect} must equal the final segment of the skill path (#{name.inspect})" + end + + frontmatter + end + + def validate_resources!(resources) + return DYNAMIC if resources == DYNAMIC + + unless resources.is_a?(Array) + raise ArgumentError, "Skill resources must be an Array or #{DYNAMIC.inspect} (got #{resources.inspect})" + end + + entries = resources.map { |entry| Resource.from(entry) } + + uris = entries.map(&:uri) + raise ArgumentError, "Skill resources must list #{@uri} itself" unless uris.include?(@uri) + + duplicates = uris.tally.select { |_, count| count > 1 }.keys + raise ArgumentError, "Skill resources list #{duplicates.join(", ")} more than once" unless duplicates.empty? + + prefix = "#{root}/" + outside = uris.reject { |uri| uri == @uri || uri.start_with?(prefix) } + raise ArgumentError, "Skill resources fall outside #{root}: #{outside.join(", ")}" unless outside.empty? + + entries.freeze + end + end +end diff --git a/lib/mcp/skills.rb b/lib/mcp/skills.rb new file mode 100644 index 00000000..0c7fee4c --- /dev/null +++ b/lib/mcp/skills.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "digest" + +module MCP + # Server-side vocabulary and helpers for the Skills extension (SEP-2640, Extensions Track, Final): + # Agent Skills served over the existing Resources primitive. Each file of a skill directory is an + # ordinary MCP resource, conventionally under the `skill://` scheme, so a host that already treats + # resources as a virtual filesystem consumes MCP-served skills exactly as it does local ones. + # + # The extension is negotiated per SEP-2133 through `capabilities.extensions`. Declaring it commits + # the server to `skills/list` and `skills/get`; `resources/directory/read` is additionally gated + # behind the `directoryRead` setting. A server declaring the extension MUST also declare the + # `resources` capability, since that is where every skill file is actually read from. + # + # @example Declaring a skills-serving server + # capabilities = MCP::Server::Capabilities.new + # capabilities.support_resources + # capabilities.support_extensions(MCP::Skills.capability) + # + # server = MCP::Server.new( + # name: "docs_server", + # capabilities: capabilities, + # skills: [ + # MCP::Skill.new( + # uri: MCP::Skills.uri_for("git-workflow"), + # frontmatter: { "name" => "git-workflow", "description" => "This team's Git conventions" }, + # resources: [{ uri: MCP::Skills.uri_for("git-workflow"), digest: MCP::Skills.digest(skill_md), size: skill_md.bytesize }], + # ), + # ], + # ) + # + # server.resources_read_handler { |params| [{ uri: params[:uri], mimeType: "text/markdown", text: load(params[:uri]) }] } + # + # https://modelcontextprotocol.io/seps/2640-skills-extension + module Skills + # Reverse-DNS extension identifier, shared wire vocabulary with the other official SDKs. + EXTENSION_ID = "io.modelcontextprotocol/skills" + + # Conventional scheme for skill resources. No scheme is privileged: a server MAY serve skills + # under a scheme native to its domain, and the structural constraints hold either way. + URI_SCHEME = "skill://" + + # MIME type identifying a directory resource, the only kind `resources/directory/read` accepts. + DIRECTORY_MIME_TYPE = "inode/directory" + + # MIME type a skill's `SKILL.md` SHOULD carry. + SKILL_MIME_TYPE = "text/markdown" + + extend self + + # The `capabilities.extensions` fragment advertising Skills support. Pass to + # `MCP::Server::Capabilities#support_extensions` or merge into a client's declared capabilities. + # An empty declaration means the extension with none of its optional features. + def capability(directory_read: false) + { EXTENSION_ID => directory_read ? { directoryRead: true } : {} } + end + + # Whether `capabilities` declares the extension (symbol or string keys throughout). + def declared?(capabilities) + !declaration(capabilities).nil? + end + + # Whether `capabilities` declares `resources/directory/read`. Clients MUST NOT call the method + # against a server that has not. + def directory_read?(capabilities) + read_key(declaration(capabilities), :directoryRead) == true + end + + alias_method :client_supports?, :declared? + + # The skill URI for a skill path: one or more `/`-separated segments whose last is the skill's + # `name`, with any preceding segments a server-chosen organizational prefix. + # + # uri_for("git-workflow") # => "skill://git-workflow/SKILL.md" + # uri_for("acme/billing/refunds") # => "skill://acme/billing/refunds/SKILL.md" + def uri_for(skill_path, scheme: URI_SCHEME) + path = skill_path.to_s.delete_prefix("/").delete_suffix("/") + raise ArgumentError, "skill_path must name at least one segment" if path.empty? + + "#{scheme}#{path}/#{Skill::SKILL_FILE}" + end + + # Resolves a skill's internal relative reference (`references/GUIDE.md`) against its root, + # exactly as the same path would resolve on a filesystem. + def resolve(skill_uri, relative_path) + root = skill_uri.delete_suffix("/#{Skill::SKILL_FILE}") + "#{root}/#{relative_path.to_s.delete_prefix("./").delete_prefix("/")}" + end + + # The `sha256:<64 lowercase hex>` digest of a file's raw bytes, the form every manifest entry takes. + def digest(content) + "sha256:#{Digest::SHA256.hexdigest(content)}" + end + + private + + def declaration(capabilities) + declaration = read_key(read_key(capabilities, :extensions), EXTENSION_ID) + declaration.is_a?(Hash) ? declaration : nil + end + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + end +end diff --git a/test/mcp/server_skills_test.rb b/test/mcp/server_skills_test.rb new file mode 100644 index 00000000..f21b2ea6 --- /dev/null +++ b/test/mcp/server_skills_test.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerSkillsTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + SKILL_MD = "# Refunds" + EMAIL_MD = "Dear customer" + EU_MD = "EU invoice" + + setup do + @skill = Skill.new( + uri: Skills.uri_for("acme/billing/refunds"), + frontmatter: { "name" => "refunds", "description" => "Process customer refund requests" }, + resources: [ + entry(Skills.uri_for("acme/billing/refunds"), SKILL_MD), + entry("skill://acme/billing/refunds/examples/email.md", EMAIL_MD), + entry("skill://acme/billing/refunds/templates/regional/eu-invoice.md", EU_MD), + ], + ) + @server = build_server(skills: [@skill]) + end + + test "skills/list serves the complete entry, manifest and all" do + response = @server.handle({ jsonrpc: "2.0", method: "skills/list", id: 1 }) + + assert_equal({ skills: [@skill.to_h] }, response[:result]) + end + + test "skills/list paginates entries atomically" do + other = Skill.new( + uri: Skills.uri_for("git-workflow"), + frontmatter: { "name" => "git-workflow", "description" => "Branching conventions" }, + resources: [entry(Skills.uri_for("git-workflow"), SKILL_MD)], + ) + server = build_server(skills: [@skill, other], page_size: 1) + + first = server.handle({ jsonrpc: "2.0", method: "skills/list", id: 1 })[:result] + assert_equal [@skill.to_h], first[:skills] + assert_equal "1", first[:nextCursor] + + second = server.handle({ jsonrpc: "2.0", method: "skills/list", id: 2, params: { cursor: first[:nextCursor] } })[:result] + assert_equal [other.to_h], second[:skills] + assert_nil second[:nextCursor] + end + + test "a server serving no skills answers skills/list with an empty listing" do + server = build_server(skills: []) + + assert_equal({ skills: [] }, server.handle({ jsonrpc: "2.0", method: "skills/list", id: 1 })[:result]) + end + + test "#skills_list_handler replaces the served catalog and may return a partial listing" do + @server.skills_list_handler { |_params| [] } + + assert_equal({ skills: [] }, @server.handle({ jsonrpc: "2.0", method: "skills/list", id: 1 })[:result]) + end + + test "#skills_list_handler receives server_context when it opts in" do + seen = nil + @server.skills_list_handler do |_params, server_context:| + seen = server_context + [] + end + + @server.handle({ jsonrpc: "2.0", method: "skills/list", id: 1 }) + + refute_nil seen + end + + test "skills/get returns the same entry shape as the listing" do + response = @server.handle({ jsonrpc: "2.0", method: "skills/get", id: 1, params: { uri: @skill.uri } }) + + assert_equal({ skill: @skill.to_h }, response[:result]) + end + + test "skills/get answers an unknown URI with -32602 and the URI in the error data" do + response = @server.handle({ jsonrpc: "2.0", method: "skills/get", id: 1, params: { uri: "skill://nope/SKILL.md" } }) + + assert_equal(-32602, response[:error][:code]) + assert_equal({ uri: "skill://nope/SKILL.md" }, response[:error][:data]) + end + + test "skills/get records the requested skill in the instrumentation data" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = build_server(skills: [@skill], configuration: configuration) + + server.handle({ jsonrpc: "2.0", method: "skills/get", id: 1, params: { uri: @skill.uri } }) + + assert_instrumentation_data({ method: "skills/get", skill_uri: @skill.uri }) + end + + test "skills/get rejects a request without a uri" do + response = @server.handle({ jsonrpc: "2.0", method: "skills/get", id: 1, params: {} }) + + assert_equal(-32602, response[:error][:code]) + end + + test "#skills_get_handler answers for a skill the listing omits" do + unlisted = Skill.new( + uri: Skills.uri_for("generated"), + frontmatter: { "name" => "generated", "description" => "Built per request" }, + resources: Skill::DYNAMIC, + ) + server = build_server(skills: []) + server.skills_list_handler { |_params| [] } + server.skills_get_handler { |params| unlisted if params[:uri] == unlisted.uri } + + response = server.handle({ jsonrpc: "2.0", method: "skills/get", id: 1, params: { uri: unlisted.uri } }) + + assert_equal({ skill: unlisted.to_h }, response[:result]) + assert_equal "dynamic", response[:result][:skill][:resources] + end + + test "skills/list and skills/get require the extension declaration" do + server = MCP::Server.new(name: "test", capabilities: { resources: {} }, skills: [@skill]) + + ["skills/list", "skills/get"].each do |method| + response = server.handle({ jsonrpc: "2.0", method: method, id: 1, params: { uri: @skill.uri } }) + + assert_match(/extensions.io.modelcontextprotocol\/skills/, response[:error][:data]) + end + end + + test "declaring the extension without the resources capability is refused at construction" do + error = assert_raises(ArgumentError) do + MCP::Server.new(name: "test", capabilities: { tools: {}, extensions: Skills.capability }) + end + + assert_match(/requires the `resources` capability/, error.message) + end + + test "resources/directory/read lists a directory's direct children, subdirectories included" do + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/directory/read", + id: 1, + params: { uri: @skill.root }, + }) + + assert_equal( + [ + { uri: "#{@skill.root}/SKILL.md", name: "SKILL.md", mimeType: "text/markdown" }, + { uri: "#{@skill.root}/examples", name: "examples", mimeType: "inode/directory" }, + { uri: "#{@skill.root}/templates", name: "templates", mimeType: "inode/directory" }, + ], + response[:result][:resources], + ) + end + + test "resources/directory/read is not recursive" do + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/directory/read", + id: 1, + params: { uri: "#{@skill.root}/templates" }, + }) + + assert_equal( + [{ uri: "#{@skill.root}/templates/regional", name: "regional", mimeType: "inode/directory" }], + response[:result][:resources], + ) + end + + test "resources/directory/read answers a URI that is not a served directory with -32602" do + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/directory/read", + id: 1, + params: { uri: "#{@skill.root}/missing" }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal({ uri: "#{@skill.root}/missing" }, response[:error][:data]) + end + + test "resources/directory/read requires the directoryRead setting, which the extension does not imply" do + server = build_server(skills: [@skill], directory_read: false) + + response = server.handle({ + jsonrpc: "2.0", + method: "resources/directory/read", + id: 1, + params: { uri: @skill.root }, + }) + + assert_match(/directoryRead/, response[:error][:data]) + end + + test "#resources_directory_read_handler replaces the manifest-derived listing" do + child = { uri: "skill://generated/reports", name: "reports", mimeType: "inode/directory" } + @server.resources_directory_read_handler { |_params| [child] } + + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/directory/read", + id: 1, + params: { uri: "skill://generated" }, + }) + + assert_equal([child], response[:result][:resources]) + end + + test "skills/list carries the SEP-2549 cache hints on the modern wire and skills/get does not" do + list = @server.handle(modern_request("skills/list"))[:result] + assert_equal "complete", list[:resultType] + assert_equal 0, list[:ttlMs] + assert_equal "private", list[:cacheScope] + + get = @server.handle(modern_request("skills/get", uri: @skill.uri))[:result] + assert_equal "complete", get[:resultType] + refute get.key?(:ttlMs) + refute get.key?(:cacheScope) + end + + test "a skill over the per-skill limits is registered with a warning rather than refused" do + oversized = { + uri: Skills.uri_for("huge"), + frontmatter: { "name" => "huge", "description" => "d" }, + resources: [{ uri: Skills.uri_for("huge"), digest: Skills.digest(SKILL_MD), size: Skill::MAX_TOTAL_SIZE + 1 }], + } + + # `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing. + original_verbose = $VERBOSE + $VERBOSE = false + server = nil + assert_output(nil, /exceeds the #{Skill::MAX_TOTAL_SIZE} per-skill limit/) do + server = build_server(skills: [oversized]) + end + + assert_equal 1, server.skills.size + ensure + $VERBOSE = original_verbose + end + + private + + def entry(uri, content) + { uri: uri, digest: Skills.digest(content), size: content.bytesize } + end + + def build_server(skills:, page_size: nil, directory_read: true, configuration: nil) + MCP::Server.new( + name: "test", + capabilities: { resources: {}, extensions: Skills.capability(directory_read: directory_read) }, + skills: skills, + page_size: page_size, + configuration: configuration, + ) + end + + def modern_request(method, params = {}) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + RequestEnvelope::PROTOCOL_VERSION_META_KEY => "2026-07-28", + RequestEnvelope::CLIENT_CAPABILITIES_META_KEY => {}, + }, + ), + } + end + end +end diff --git a/test/mcp/skill_test.rb b/test/mcp/skill_test.rb new file mode 100644 index 00000000..de126ecd --- /dev/null +++ b/test/mcp/skill_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class SkillTest < ActiveSupport::TestCase + SKILL_URI = "skill://acme/billing/refunds/SKILL.md" + DIGEST = "sha256:#{"a" * 64}" + + test "exposes the SEP-2640 vocabulary" do + assert_equal "dynamic", Skill::DYNAMIC + assert_equal "SKILL.md", Skill::SKILL_FILE + assert_equal 512, Skill::MAX_RESOURCES + assert_equal 16_777_216, Skill::MAX_TOTAL_SIZE + end + + test "derives name and root from the URI alone" do + skill = build + + assert_equal "refunds", skill.name + assert_equal "skill://acme/billing/refunds", skill.root + assert_equal "git-workflow", Skill.name_from_uri("skill://git-workflow/SKILL.md") + end + + test "serializes the entry shape skills/list and skills/get share" do + skill = build(frontmatter: { "name" => "refunds", "description" => "d", "license" => "Apache-2.0" }) + + assert_equal( + { + uri: SKILL_URI, + frontmatter: { "name" => "refunds", "description" => "d", "license" => "Apache-2.0" }, + resources: [{ uri: SKILL_URI, digest: DIGEST, size: 10 }], + }, + skill.to_h, + ) + end + + test "passes frontmatter through verbatim rather than curating it" do + frontmatter = { "name" => "refunds", "description" => "d", "metadata" => { "version" => "2.1.0" } } + + assert_equal frontmatter, build(frontmatter: frontmatter).frontmatter + end + + test "requires the URI to address the skill's SKILL.md" do + error = assert_raises(ArgumentError) do + Skill.new(uri: "skill://refunds", frontmatter: { name: "refunds", description: "d" }, resources: Skill::DYNAMIC) + end + + assert_match(/must be the URI of the skill's SKILL.md/, error.message) + end + + test "requires the frontmatter name to equal the final skill-path segment" do + error = assert_raises(ArgumentError) { build(frontmatter: { "name" => "rebates", "description" => "d" }) } + + assert_match(/must equal the final segment of the skill path/, error.message) + end + + test "requires name and description in the frontmatter" do + assert_raises(ArgumentError) { build(frontmatter: { "name" => "refunds" }) } + assert_raises(ArgumentError) { build(frontmatter: { "description" => "d" }) } + end + + test "requires the manifest to list SKILL.md itself" do + error = assert_raises(ArgumentError) do + build(resources: [{ uri: "skill://acme/billing/refunds/examples/email.md", digest: DIGEST, size: 1 }]) + end + + assert_match(/must list #{Regexp.escape(SKILL_URI)} itself/, error.message) + end + + test "rejects duplicate and out-of-skill manifest entries" do + duplicated = assert_raises(ArgumentError) { build(resources: [manifest_entry, manifest_entry]) } + assert_match(/more than once/, duplicated.message) + + outside = assert_raises(ArgumentError) do + build(resources: [manifest_entry, { uri: "skill://other/file.md", digest: DIGEST, size: 1 }]) + end + assert_match(/fall outside/, outside.message) + end + + test "rejects a malformed digest or size" do + assert_raises(ArgumentError) { build(resources: [{ uri: SKILL_URI, digest: "sha256:zz", size: 1 }]) } + assert_raises(ArgumentError) { build(resources: [{ uri: SKILL_URI, digest: DIGEST, size: -1 }]) } + assert_raises(ArgumentError) { build(resources: [{ uri: SKILL_URI, digest: DIGEST, size: "10" }]) } + end + + test "accepts the dynamic marker in place of a manifest" do + skill = build(resources: Skill::DYNAMIC) + + assert_predicate skill, :dynamic? + assert_nil skill.total_size + assert_empty skill.limit_violations + assert_equal "dynamic", skill.to_h[:resources] + end + + test "rejects a resources value that is neither a manifest nor the dynamic marker" do + assert_raises(ArgumentError) { build(resources: nil) } + assert_raises(ArgumentError) { build(resources: "generated") } + end + + test "reports the per-skill limits it exceeds instead of refusing the skill" do + oversized = build(resources: [{ uri: SKILL_URI, digest: DIGEST, size: Skill::MAX_TOTAL_SIZE + 1 }]) + + assert_equal 1, oversized.limit_violations.size + assert_match(/exceeds the #{Skill::MAX_TOTAL_SIZE} per-skill limit/, oversized.limit_violations.first) + end + + test ".from accepts an instance, a symbol-keyed Hash and a string-keyed Hash" do + skill = build + + assert_same skill, Skill.from(skill) + assert_equal skill.to_h, Skill.from(skill.to_h).to_h + assert_equal skill.to_h, Skill.from({ "uri" => SKILL_URI, "frontmatter" => skill.frontmatter, "resources" => [manifest_entry] }).to_h + assert_raises(ArgumentError) { Skill.from("skill://refunds/SKILL.md") } + end + + private + + def manifest_entry + { uri: SKILL_URI, digest: DIGEST, size: 10 } + end + + NOT_GIVEN = Object.new + + def build(uri: SKILL_URI, frontmatter: { "name" => "refunds", "description" => "d" }, resources: NOT_GIVEN) + resources = [manifest_entry] if resources == NOT_GIVEN + Skill.new(uri: uri, frontmatter: frontmatter, resources: resources) + end + end +end diff --git a/test/mcp/skills_test.rb b/test/mcp/skills_test.rb new file mode 100644 index 00000000..96827e80 --- /dev/null +++ b/test/mcp/skills_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class SkillsTest < ActiveSupport::TestCase + test "exposes the SEP-2640 wire vocabulary" do + # These strings are shared with the ext-skills reference package and the other official SDKs. + assert_equal "io.modelcontextprotocol/skills", Skills::EXTENSION_ID + assert_equal "skill://", Skills::URI_SCHEME + assert_equal "inode/directory", Skills::DIRECTORY_MIME_TYPE + assert_equal "text/markdown", Skills::SKILL_MIME_TYPE + end + + test ".capability builds the extensions fragment, empty when no optional feature is offered" do + assert_equal({ "io.modelcontextprotocol/skills" => {} }, Skills.capability) + assert_equal({ "io.modelcontextprotocol/skills" => { directoryRead: true } }, Skills.capability(directory_read: true)) + end + + test ".declared? and .directory_read? read either key form" do + assert Skills.declared?({ extensions: Skills.capability }) + assert Skills.declared?({ "extensions" => { "io.modelcontextprotocol/skills" => {} } }) + assert Skills.declared?({ extensions: { "io.modelcontextprotocol/skills": {} } }) + refute Skills.declared?({ extensions: { "io.modelcontextprotocol/ui" => {} } }) + refute Skills.declared?({}) + refute Skills.declared?(nil) + + assert Skills.directory_read?({ extensions: Skills.capability(directory_read: true) }) + refute Skills.directory_read?({ extensions: Skills.capability }) + end + + test ".client_supports? is the client-side spelling of .declared?" do + assert Skills.client_supports?({ extensions: Skills.capability }) + refute Skills.client_supports?({ extensions: {} }) + end + + test ".uri_for addresses SKILL.md under a flat or prefixed skill path" do + assert_equal "skill://git-workflow/SKILL.md", Skills.uri_for("git-workflow") + assert_equal "skill://acme/billing/refunds/SKILL.md", Skills.uri_for("acme/billing/refunds") + assert_equal "skill://git-workflow/SKILL.md", Skills.uri_for("/git-workflow/") + assert_equal "github://owner/repo/skills/refunds/SKILL.md", Skills.uri_for("owner/repo/skills/refunds", scheme: "github://") + assert_raises(ArgumentError) { Skills.uri_for("") } + end + + test ".resolve resolves a skill's internal reference against its own root" do + uri = "skill://acme/billing/refunds/SKILL.md" + + assert_equal "skill://acme/billing/refunds/references/GUIDE.md", Skills.resolve(uri, "references/GUIDE.md") + assert_equal "skill://acme/billing/refunds/examples/email.md", Skills.resolve(uri, "./examples/email.md") + end + + test ".digest formats the SHA-256 of the raw bytes the manifest covers" do + digest = Skills.digest("# Refunds") + + assert_match(/\Asha256:[0-9a-f]{64}\z/, digest) + assert_equal "sha256:#{Digest::SHA256.hexdigest("# Refunds")}", digest + end + end +end