From 03cd319a10f5188c97926b2459b376d16b644393 Mon Sep 17 00:00:00 2001 From: Niklas Haeusele Date: Sun, 30 Mar 2025 23:26:49 +0200 Subject: [PATCH 1/2] Add resource template --- Gemfile.lock | 4 ++ lib/ruby_mcp.rb | 1 + lib/ruby_mcp/capabilities/resources.rb | 9 +++++ lib/ruby_mcp/handlers/resources_read.rb | 15 +++++--- lib/ruby_mcp/resources.rb | 20 +++++++++- lib/ruby_mcp/server.rb | 1 + ruby_mcp.gemspec | 3 +- .../capabilities/test_resources_capability.rb | 37 +++++++++++++++++++ 8 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 lib/ruby_mcp/capabilities/resources.rb diff --git a/Gemfile.lock b/Gemfile.lock index 2fc9ccf..23571fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: ruby_mcp (0.1.0) + addressable (~> 2.8, >= 2.8.7) zeitwerk (~> 2.7, >= 2.7.2) GEM @@ -20,6 +21,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.2.0) benchmark (0.4.0) @@ -53,6 +56,7 @@ GEM psych (5.2.3) date stringio + public_suffix (6.0.1) racc (1.8.1) rack (3.1.12) rainbow (3.1.1) diff --git a/lib/ruby_mcp.rb b/lib/ruby_mcp.rb index a26aa5c..96375f2 100644 --- a/lib/ruby_mcp.rb +++ b/lib/ruby_mcp.rb @@ -2,6 +2,7 @@ require "securerandom" require "logger" require "zeitwerk" +require "addressable" loader = Zeitwerk::Loader.for_gem loader.inflector.inflect("ruby_mcp" => "RubyMCP") diff --git a/lib/ruby_mcp/capabilities/resources.rb b/lib/ruby_mcp/capabilities/resources.rb new file mode 100644 index 0000000..8888ce4 --- /dev/null +++ b/lib/ruby_mcp/capabilities/resources.rb @@ -0,0 +1,9 @@ +module RubyMCP::Capabilities::Resources + def add_resource(...) + @resources.add(...) + end + + def add_resource_template(uri_template:, name:, description:, mime_type:, reader:) + @resources.add_resource_template(uri_template:, name:, description:, mime_type:, reader:) + end +end diff --git a/lib/ruby_mcp/handlers/resources_read.rb b/lib/ruby_mcp/handlers/resources_read.rb index 12ad1cb..087cde0 100644 --- a/lib/ruby_mcp/handlers/resources_read.rb +++ b/lib/ruby_mcp/handlers/resources_read.rb @@ -1,12 +1,15 @@ class RubyMCP::Handlers::ResourcesRead def handle(server, request) - if resource = server.resources.find(request.uri) + resources = server.resources.find(request.uri) + if resources.any? server.answer(request, - contents: [ { - uri: resource.uri, - mimeType: resource.mime_type, - text: resource.reader.call(resource) - } ] + contents: resources.map do |resource| + { + uri: request.uri, + mimeType: resource.mime_type, + text: resource.reader.call(resource) + } + end ) else server.error( diff --git a/lib/ruby_mcp/resources.rb b/lib/ruby_mcp/resources.rb index a2d8c2c..903ea20 100644 --- a/lib/ruby_mcp/resources.rb +++ b/lib/ruby_mcp/resources.rb @@ -1,9 +1,11 @@ module RubyMCP Resource = Data.define(:uri, :name, :description, :mime_type, :reader) + ResourceTemplate = Data.define(:uri_template, :name, :description, :mime_type, :reader) class Resources def initialize @resources = {} + @resource_templates = {} end def add(uri:, name:, description: nil, mime_type: nil, reader: nil) @@ -11,7 +13,14 @@ def add(uri:, name:, description: nil, mime_type: nil, reader: nil) end def find(uri) - @resources[uri] + [ @resources[uri] ].concat(find_in_templates(uri).map(&:last)).compact + end + + def add_resource_template(uri_template:, **args) + @resource_templates[uri_template] = ResourceTemplate.new( + uri_template: Addressable::Template.new(uri_template), + **args + ) end def as_json @@ -24,5 +33,14 @@ def as_json } end end + + private + + def find_in_templates(uri) + addressable_uri = Addressable::URI.parse(uri) + @resource_templates.find_all do |template, resource_template| + resource_template.uri_template.extract(addressable_uri) + end + end end end diff --git a/lib/ruby_mcp/server.rb b/lib/ruby_mcp/server.rb index 1d1ba5c..e31e75a 100644 --- a/lib/ruby_mcp/server.rb +++ b/lib/ruby_mcp/server.rb @@ -1,6 +1,7 @@ module RubyMCP class Server include Capabilities::Logging + include Capabilities::Resources attr_reader :lifecycle, :prompts, :resources diff --git a/ruby_mcp.gemspec b/ruby_mcp.gemspec index 63f07cf..1c4faaa 100644 --- a/ruby_mcp.gemspec +++ b/ruby_mcp.gemspec @@ -28,5 +28,6 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = [ "lib" ] - spec.add_dependency "zeitwerk", '~> 2.7', '>= 2.7.2' + spec.add_dependency "zeitwerk", "~> 2.7", ">= 2.7.2" + spec.add_dependency "addressable", "~> 2.8", ">= 2.8.7" end diff --git a/test/capabilities/test_resources_capability.rb b/test/capabilities/test_resources_capability.rb index 40f0791..84c8d5d 100644 --- a/test/capabilities/test_resources_capability.rb +++ b/test/capabilities/test_resources_capability.rb @@ -122,4 +122,41 @@ def test_read_resources_not_found } ) end + + def test_read_template + @server.add_resource_template( + uri_template: "https://{host}.de", + name: "german_website", + description: "Every german website", + mime_type: "text/html", + reader: ->(resource) { + "The first demo content of #{resource.name}" + } + ) + + @transport.client_message( + jsonrpc: "2.0", + id: 1, + method: "resources/read", + params: { + uri: "https://example.de" + } + ) + + @transport.process_message + + assert_last_response( + jsonrpc: "2.0", + id: 1, + result: { + contents: [ + { + uri: "https://example.de", + mimeType: "text/html", + text: "The first demo content of german_website" + } + ] + } + ) + end end From 1bb6fac032b01436099cf8c78b67ed778aff35bf Mon Sep 17 00:00:00 2001 From: Niklas Haeusele Date: Sun, 30 Mar 2025 23:45:41 +0200 Subject: [PATCH 2/2] list templates --- lib/ruby_mcp/handlers.rb | 2 + .../handlers/resources_templates_list.rb | 12 +++++ lib/ruby_mcp/requests.rb | 2 + .../requests/resources_templates_list.rb | 5 ++ lib/ruby_mcp/resources.rb | 4 ++ .../capabilities/test_resources_capability.rb | 51 +++++++++++++++++++ 6 files changed, 76 insertions(+) create mode 100644 lib/ruby_mcp/handlers/resources_templates_list.rb create mode 100644 lib/ruby_mcp/requests/resources_templates_list.rb diff --git a/lib/ruby_mcp/handlers.rb b/lib/ruby_mcp/handlers.rb index cf49c99..60f55d0 100644 --- a/lib/ruby_mcp/handlers.rb +++ b/lib/ruby_mcp/handlers.rb @@ -21,6 +21,8 @@ def self.parse(json) ResourcesRead when "logging/setLevel" LoggingSetLevel + when "resources/templates/list" + ResourcesTemplatesList end.new end end diff --git a/lib/ruby_mcp/handlers/resources_templates_list.rb b/lib/ruby_mcp/handlers/resources_templates_list.rb new file mode 100644 index 0000000..2bae626 --- /dev/null +++ b/lib/ruby_mcp/handlers/resources_templates_list.rb @@ -0,0 +1,12 @@ +class RubyMCP::Handlers::ResourcesTemplatesList < RubyMCP::Handlers + def handle(server, request) + server.answer(request, resourceTemplates: server.resources.templates.map do |template, value| + { + uriTemplate: template, + name: value.name, + description: value.description, + mimeType: value.mime_type + } + end) + end +end diff --git a/lib/ruby_mcp/requests.rb b/lib/ruby_mcp/requests.rb index 3f3ef25..e50da9c 100644 --- a/lib/ruby_mcp/requests.rb +++ b/lib/ruby_mcp/requests.rb @@ -21,6 +21,8 @@ def self.parse(json) ResourcesRead when "logging/setLevel" LoggingSetLevel + when "resources/templates/list" + ResourcesTemplatesList else Request end.new(parsed) diff --git a/lib/ruby_mcp/requests/resources_templates_list.rb b/lib/ruby_mcp/requests/resources_templates_list.rb new file mode 100644 index 0000000..1ac36d1 --- /dev/null +++ b/lib/ruby_mcp/requests/resources_templates_list.rb @@ -0,0 +1,5 @@ +class RubyMCP::Requests::ResourcesTemplatesList < RubyMCP::Request + def allowed_in_lifecycle?(lifecycle) + lifecycle.operation_phase? + end +end diff --git a/lib/ruby_mcp/resources.rb b/lib/ruby_mcp/resources.rb index 903ea20..db593cf 100644 --- a/lib/ruby_mcp/resources.rb +++ b/lib/ruby_mcp/resources.rb @@ -34,6 +34,10 @@ def as_json end end + def templates + @resource_templates + end + private def find_in_templates(uri) diff --git a/test/capabilities/test_resources_capability.rb b/test/capabilities/test_resources_capability.rb index 84c8d5d..5294274 100644 --- a/test/capabilities/test_resources_capability.rb +++ b/test/capabilities/test_resources_capability.rb @@ -159,4 +159,55 @@ def test_read_template } ) end + + def test_list_templates + @server.add_resource_template( + uri_template: "https://{host}.de", + name: "german_website", + description: "Every german website", + mime_type: "text/html", + reader: ->(resource) { + "The first demo content of #{resource.name}" + } + ) + + @server.add_resource_template( + uri_template: "https://{host}.com", + name: "com_website", + description: "Every com website", + mime_type: "text/html", + reader: ->(resource) { + "The first demo content of #{resource.name}" + } + ) + + @transport.client_message( + jsonrpc: "2.0", + id: 1, + method: "resources/templates/list", + ) + + @transport.process_message + + assert_last_response( + "jsonrpc": "2.0", + "id": 1, + "result": { + "resourceTemplates": [ + { + uriTemplate: "https://{host}.de", + name: "german_website", + description: "Every german website", + mimeType: "text/html" + }, + { + uriTemplate: "https://{host}.com", + name: "com_website", + description: "Every com website", + mimeType: "text/html" + } + ] + } + ) + end end