Skip to content
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
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ It implements the Model Context Protocol specification, handling model context r
- Supports resource registration and retrieval
- Supports stdio & Streamable HTTP (including SSE) transports
- Supports notifications for list changes (tools, prompts, resources)
- Supports roots (server-to-client filesystem boundary queries)
- Supports sampling (server-to-client LLM completion requests)

### Supported Methods
Expand All @@ -52,6 +53,7 @@ It implements the Model Context Protocol specification, handling model context r
- `resources/read` - Retrieves a specific resource by name
- `resources/templates/list` - Lists all registered resource templates and their schemas
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
- `roots/list` - Requests filesystem roots from the client (server-to-client)
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
- `elicitation/create` - Requests user input from the client (server-to-client)

Expand Down Expand Up @@ -861,6 +863,70 @@ server = MCP::Server.new(
)
```

### Roots

The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method.
Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available.

**Key Concepts:**

- **Server-to-Client Request**: Like sampling, roots listing is initiated by the server
- **Client Capability**: Clients must declare `roots` capability during initialization
- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change

**Using Roots in Tools:**

Tools that accept a `server_context:` parameter can call `list_roots` on it.
The request is automatically routed to the correct client session:

```ruby
class FileSearchTool < MCP::Tool
description "Search files within the client's project roots"
input_schema(
properties: {
query: { type: "string" }
},
required: ["query"]
)

def self.call(query:, server_context:)
roots = server_context.list_roots
root_uris = roots[:roots].map { |root| root[:uri] }

MCP::Tool::Response.new([{
type: "text",
text: "Searching in roots: #{root_uris.join(", ")}"
}])
end
end
```

Result contains an array of root objects:

```ruby
{
roots: [
{ uri: "file:///home/user/projects/myproject", name: "My Project" },
{ uri: "file:///home/user/repos/backend", name: "Backend Repository" }
]
}
```

**Handling Root Changes:**

Register a callback to be notified when the client's roots change:

```ruby
server.roots_list_changed_handler do
puts "Client's roots have changed, tools will see updated roots on next call."
end
```

**Error Handling:**

- Raises `RuntimeError` if client does not support `roots` capability
- Raises `StandardError` if client returns an error response

### Sampling

The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method.
Expand Down
6 changes: 2 additions & 4 deletions lib/mcp/methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,12 @@ def ensure_capability!(method, capabilities)
require_capability!(method, capabilities, :completions)
when ROOTS_LIST
require_capability!(method, capabilities, :roots)
when NOTIFICATIONS_ROOTS_LIST_CHANGED
require_capability!(method, capabilities, :roots)
require_capability!(method, capabilities, :roots, :listChanged)
when SAMPLING_CREATE_MESSAGE
require_capability!(method, capabilities, :sampling)
when ELICITATION_CREATE
require_capability!(method, capabilities, :elicitation)
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
# No specific capability required.
end
end
Expand Down
9 changes: 9 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def initialize(
Methods::PING => ->(_) { {} },
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),

Expand Down Expand Up @@ -218,6 +219,14 @@ def notify_log_message(data:, level:, logger: nil)
report_exception(e, { notification: "log_message" })
end

# Sets a handler for `notifications/roots/list_changed` notifications.
# Called when a client notifies the server that its filesystem roots have changed.
#
# @yield [params] The notification params (typically `nil`).
def roots_list_changed_handler(&block)
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
end

# Sets a custom handler for `resources/read` requests.
# The block receives the parsed request params and should return resource
# contents. The return value is set as the `contents` field of the response.
Expand Down
9 changes: 9 additions & 0 deletions lib/mcp/server_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ def notify_log_message(data:, level:, logger: nil)
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
end

# Delegates to the session so the request is scoped to the originating client.
def list_roots
if @notification_target.respond_to?(:list_roots)
@notification_target.list_roots(related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'list_roots' for #{self}"
end
end

# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` (via `method_missing`) when `@notification_target`
# does not support sampling.
Expand Down
9 changes: 9 additions & 0 deletions lib/mcp/server_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ def client_capabilities
@client_capabilities || @server.client_capabilities
end

# Sends a `roots/list` request scoped to this session.
def list_roots(related_request_id: nil)
unless client_capabilities&.dig(:roots)
raise "Client does not support roots."
end

send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
end

# Sends a `sampling/createMessage` request scoped to this session.
def create_sampling_message(related_request_id: nil, **kwargs)
params = @server.build_sampling_params(client_capabilities, **kwargs)
Expand Down
5 changes: 1 addition & 4 deletions test/mcp/methods_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,7 @@ def ensure_capability_does_not_raise_for(method, capabilities: {})
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_INITIALIZED

ensure_capability_raises_error_for Methods::ROOTS_LIST, required_capability_name: "roots"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED, required_capability_name: "roots"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED,
required_capability_name: "roots.listChanged",
capabilities: { roots: {} }
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED

ensure_capability_raises_error_for Methods::SAMPLING_CREATE_MESSAGE, required_capability_name: "sampling"

Expand Down
26 changes: 26 additions & 0 deletions test/mcp/server_context_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,32 @@ class ServerContextTest < ActiveSupport::TestCase
assert_raises(NoMethodError) { server_context.nonexistent_method }
end

test "ServerContext#list_roots delegates to notification_target" do
notification_target = mock
notification_target.expects(:list_roots).with(
related_request_id: nil,
).returns({ roots: [{ uri: "file:///project", name: "Project" }] })

context = mock
progress = Progress.new(notification_target: notification_target, progress_token: nil)

server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)

result = server_context.list_roots

assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots]
end

test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do
notification_target = mock
context = mock
progress = Progress.new(notification_target: notification_target, progress_token: nil)

server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)

assert_raises(NoMethodError) { server_context.list_roots }
end

test "ServerContext#create_sampling_message delegates to notification_target over context" do
notification_target = mock
notification_target.expects(:create_sampling_message).with(
Expand Down
Loading