Skip to content

Resource template support #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
require "securerandom"
require "logger"
require "zeitwerk"
require "addressable"

loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect("ruby_mcp" => "RubyMCP")
Expand Down
9 changes: 9 additions & 0 deletions lib/ruby_mcp/capabilities/resources.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions lib/ruby_mcp/handlers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def self.parse(json)
ResourcesRead
when "logging/setLevel"
LoggingSetLevel
when "resources/templates/list"
ResourcesTemplatesList
end.new
end
end
Expand Down
15 changes: 9 additions & 6 deletions lib/ruby_mcp/handlers/resources_read.rb
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
12 changes: 12 additions & 0 deletions lib/ruby_mcp/handlers/resources_templates_list.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions lib/ruby_mcp/requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def self.parse(json)
ResourcesRead
when "logging/setLevel"
LoggingSetLevel
when "resources/templates/list"
ResourcesTemplatesList
else
Request
end.new(parsed)
Expand Down
5 changes: 5 additions & 0 deletions lib/ruby_mcp/requests/resources_templates_list.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class RubyMCP::Requests::ResourcesTemplatesList < RubyMCP::Request
def allowed_in_lifecycle?(lifecycle)
lifecycle.operation_phase?
end
end
24 changes: 23 additions & 1 deletion lib/ruby_mcp/resources.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
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)
@resources[uri] = Resource.new(uri, name, description, mime_type, reader)
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
Expand All @@ -24,5 +33,18 @@ def as_json
}
end
end

def templates
@resource_templates
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
1 change: 1 addition & 0 deletions lib/ruby_mcp/server.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module RubyMCP
class Server
include Capabilities::Logging
include Capabilities::Resources

attr_reader :lifecycle, :prompts, :resources

Expand Down
3 changes: 2 additions & 1 deletion ruby_mcp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
88 changes: 88 additions & 0 deletions test/capabilities/test_resources_capability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,92 @@ 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

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