Skip to content
Merged
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
130 changes: 126 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,71 @@ MCP spec includes [Resources](https://modelcontextprotocol.io/specification/late

### Reading Resources

The `MCP::Resource` class provides a way to register resources with the server.
Like tools and prompts, resources can be defined in three ways.

1. As a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body:

```ruby
class MyResource < MCP::Resource
uri "https://example.com/my_resource"
resource_name "my-resource"
title "My Resource"
description "Lorem ipsum dolor sit amet"
mime_type "text/html"

class << self
def contents
[MCP::Resource::TextContents.new(
uri: uri,
mime_type: mime_type,
text: "Hello from example resource!"
)]
end
end
end

server = MCP::Server.new(
name: "my_server",
resources: [MyResource],
)
```

`resources/read` requests are routed automatically: when the requested URI matches a registered
class-based resource, its `contents` method is called. `contents` may return an array of
`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one.
Like tools, `contents` can opt in to a `server_context:` keyword argument to receive per-request context.

When class-based resources or resource templates are registered and a `resources/read` request
does not match any of them, the server responds with the standard JSON-RPC Invalid Params error
(`-32602`) carrying the requested URI in the error `data` member, per SEP-2164.

2. With the `MCP::Resource.define` method, whose block implements `contents`:

```ruby
resource = MCP::Resource.define(
uri: "https://example.com/my_resource",
name: "my-resource",
mime_type: "text/html",
) do
[MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")]
end
```

3. Using the `MCP::Server#define_resource` method:

```ruby
server = MCP::Server.new(name: "my_server")
server.define_resource(
uri: "https://example.com/my_resource",
name: "my-resource",
mime_type: "text/html",
) do
[MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")]
end
```

Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`,
in which case the server only lists them:

```ruby
resource = MCP::Resource.new(
Expand All @@ -991,7 +1055,8 @@ server = MCP::Server.new(
)
```

The server must register a handler for the `resources/read` method to retrieve a resource dynamically.
With plain data resources, the server must register a handler for the `resources/read` method to
retrieve a resource dynamically.

```ruby
server.resources_read_handler do |params|
Expand All @@ -1003,7 +1068,8 @@ server.resources_read_handler do |params|
end
```

otherwise `resources/read` requests will be a no-op.
otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces
the default `resources/read` handling, including the automatic routing to class-based resources described above.

For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler.
Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`)
Expand All @@ -1020,7 +1086,63 @@ end

### Resource Templates

The `MCP::ResourceTemplate` class provides a way to register resource templates with the server.
Resource templates follow the same pattern. Class-based templates declare a `uri_template` and
receive the variables extracted from the requested URI as keyword arguments to `contents`:

```ruby
class UserProfileTemplate < MCP::ResourceTemplate
uri_template "users://{user_id}/profile"
resource_template_name "user-profile"
title "User Profile"
description "Profile data for a user"
mime_type "application/json"

class << self
def contents(user_id:)
[MCP::Resource::TextContents.new(
uri: "users://#{user_id}/profile",
mime_type: mime_type,
text: { id: user_id }.to_json
)]
end
end
end

server = MCP::Server.new(
name: "my_server",
resource_templates: [UserProfileTemplate],
)
```

A `resources/read` request for `users://42/profile` calls `UserProfileTemplate.contents(user_id: "42")`.
An exact match against a registered resource takes precedence over template matching.
`contents` can also opt in to a `server_context:` keyword argument.

URI template matching supports simple [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only:

- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI.
- A variable matches one or more characters excluding `/`.
- Extracted values are not percent-decoded.

The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available,
mirroring the resource variants:

```ruby
server.define_resource_template(
uri_template: "users://{user_id}/profile",
name: "user-profile",
mime_type: "application/json",
) do |user_id:|
[MCP::Resource::TextContents.new(
uri: "users://#{user_id}/profile",
mime_type: "application/json",
text: { id: user_id }.to_json
)]
end
```

Resource templates can also be registered as plain data objects with `MCP::ResourceTemplate.new`,
in which case reads must be served by a `resources_read_handler`:

```ruby
resource_template = MCP::ResourceTemplate.new(
Expand Down
22 changes: 15 additions & 7 deletions docs/building-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,20 +291,28 @@ class MyResource < MCP::Resource
resource_name "config"
description "Application configuration"
mime_type "application/json"

class << self
def contents
[MCP::Resource::TextContents.new(
uri: uri,
mime_type: mime_type,
text: File.read("config.json")
)]
end
end
end

server = MCP::Server.new(
name: "my_server",
resources: [MyResource],
resources_read_handler: ->(uri, _server_context) {
case uri
when "file:///data/config.json"
{ uri: uri, text: File.read("config.json"), mimeType: "application/json" }
end
}
resources: [MyResource]
)
```

The server automatically routes `resources/read` requests to the matching class-based resource's `contents` method.
Requests for unregistered URIs respond with the JSON-RPC Invalid Params error (`-32602`). To handle reads manually instead,
register a block with `server.resources_read_handler`, which fully replaces the automatic routing.

## Configuration

```ruby
Expand Down
135 changes: 135 additions & 0 deletions lib/mcp/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,141 @@

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)
Expand Down
Loading