Skip to content

Commit 29f57ef

Browse files
authored
Merge pull request #30 from patvice/roots-and-sampling
Client Features: Support for Roots and Sampling
2 parents f574a6d + cda977e commit 29f57ef

File tree

49 files changed

+2592
-174
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+2592
-174
lines changed

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This project is a Ruby client for the [Model Context Protocol (MCP)](https://mod
1212
- 🛠️ **Tool Integration**: Automatically converts MCP tools into RubyLLM-compatible tools
1313
- 📄 **Resource Management**: Access and include MCP resources (files, data) and resource templates in conversations
1414
- 🎯 **Prompt Integration**: Use predefined MCP prompts with arguments for consistent interactions
15+
- 🎛️ **Client Features**: Support for sampling and roots
1516
- 🎨 **Enhanced Chat Interface**: Extended RubyLLM chat methods for seamless MCP integration
1617
- 📚 **Simple API**: Easy-to-use interface that integrates seamlessly with RubyLLM
1718

@@ -462,6 +463,81 @@ puts result
462463
# Result: { status: "success", data: "Processed data" }
463464
```
464465

466+
## Client Features
467+
468+
The RubyLLM::MCP client provides support functionality that can be exposed to MCP servers. These features must be explicitly configured before creating client objects to ensure you're opting into this functionality.
469+
470+
### Roots
471+
472+
Roots provide MCP servers with access to underlying file system information. The implementation starts with a lightweight approach due to the MCP specification's current limitations on root usage.
473+
474+
When roots are configured, the client will:
475+
476+
- Expose roots as a supported capability to MCP servers
477+
- Support dynamic addition and removal of roots during the client lifecycle
478+
- Fire `notifications/roots/list_changed` events when roots are modified
479+
480+
#### Configuration
481+
482+
```ruby
483+
RubyLLM::MCP.config do |config|
484+
config.roots = ["to/a/path", Rails.root]
485+
end
486+
487+
client = RubyLLM::MCP::Client.new(...)
488+
```
489+
490+
#### Usage
491+
492+
```ruby
493+
# Access current root paths
494+
client.roots.paths
495+
# => ["to/a/path", #<Pathname:/to/rails/root/path>]
496+
497+
# Add a new root (fires list_changed notification)
498+
client.roots.add("new/path")
499+
client.roots.paths
500+
# => ["to/a/path", #<Pathname:/to/rails/root/path>, "new/path"]
501+
502+
# Remove a root (fires list_changed notification)
503+
client.roots.remove("to/a/path")
504+
client.roots.paths
505+
# => [#<Pathname:/to/rails/root/path>, "new/path"]
506+
```
507+
508+
### Sampling
509+
510+
Sampling allows MCP servers to offload LLM requests to the MCP client rather than making them directly from the server. This enables MCP servers to optionally use LLM connections through the client.
511+
512+
#### Configuration
513+
514+
```ruby
515+
RubyLLM::MCP.configure do |config|
516+
config.sampling.enabled = true
517+
config.sampling.preferred_model = "gpt-4.1"
518+
519+
# Optional: Use a block for dynamic model selection
520+
config.sampling.preferred_model do |model_preferences|
521+
model_preferences.hints.first
522+
end
523+
524+
# Optional: Add guards to filter sampling requests
525+
config.sampling.guard do |sample|
526+
sample.message.include("Hello")
527+
end
528+
end
529+
```
530+
531+
#### How It Works
532+
533+
With the above configuration:
534+
535+
- Clients will respond to all incoming sample requests using the specified model (`gpt-4.1`)
536+
- Sample messages will only be approved if they contain the word "Hello" (when using the guard)
537+
- The `preferred_model` can be a string or a proc that provides dynamic model selection based on MCP server characteristics
538+
539+
The `preferred_model` proc receives model preferences from the MCP server, allowing you to make intelligent model selection decisions based on the server's requirements for success.
540+
465541
## Transport Types
466542

467543
### SSE (Server-Sent Events)

lib/ruby_llm/mcp/client.rb

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module MCP
77
class Client
88
extend Forwardable
99

10-
attr_reader :name, :config, :transport_type, :request_timeout, :log_level, :on
10+
attr_reader :name, :config, :transport_type, :request_timeout, :log_level, :on, :roots
1111

1212
def initialize(name:, transport_type:, start: true, request_timeout: MCP.config.request_timeout, config: {})
1313
@name = name
@@ -25,10 +25,13 @@ def initialize(name:, transport_type:, start: true, request_timeout: MCP.config.
2525

2626
@log_level = nil
2727

28+
setup_roots
29+
setup_sampling
30+
2831
@coordinator.start_transport if start
2932
end
3033

31-
def_delegators :@coordinator, :alive?, :capabilities, :ping
34+
def_delegators :@coordinator, :alive?, :capabilities, :ping, :client_capabilities
3235

3336
def start
3437
@coordinator.start_transport
@@ -165,6 +168,15 @@ def on_logging(level: Logging::WARNING, logger: nil, &block)
165168
self
166169
end
167170

171+
def sampling_callback_enabled?
172+
@on.key?(:sampling) && !@on[:sampling].nil?
173+
end
174+
175+
def on_sampling(&block)
176+
@on[:sampling] = block
177+
self
178+
end
179+
168180
private
169181

170182
def setup_coordinator
@@ -187,6 +199,14 @@ def build_map(raw_data, klass)
187199
acc[instance.name] = instance
188200
end
189201
end
202+
203+
def setup_roots
204+
@roots = Roots.new(paths: MCP.config.roots, coordinator: @coordinator)
205+
end
206+
207+
def setup_sampling
208+
@on[:sampling] = MCP.config.sampling.guard
209+
end
190210
end
191211
end
192212
end

lib/ruby_llm/mcp/configuration.rb

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,48 @@
33
module RubyLLM
44
module MCP
55
class Configuration
6-
attr_accessor :request_timeout, :log_file, :log_level, :has_support_complex_parameters
6+
class Sampling
7+
attr_accessor :enabled
8+
attr_writer :preferred_model
9+
10+
def initialize
11+
set_defaults
12+
end
13+
14+
def reset!
15+
set_defaults
16+
end
17+
18+
def guard(&block)
19+
@guard = block if block_given?
20+
@guard
21+
end
22+
23+
def preferred_model(&block)
24+
@preferred_model = block if block_given?
25+
@preferred_model
26+
end
27+
28+
def enabled?
29+
@enabled
30+
end
31+
32+
private
33+
34+
def set_defaults
35+
@enabled = false
36+
@preferred_model = nil
37+
@guard = nil
38+
end
39+
end
40+
41+
attr_accessor :request_timeout, :log_file, :log_level, :has_support_complex_parameters, :roots, :sampling
742
attr_writer :logger
843

944
REQUEST_TIMEOUT_DEFAULT = 8000
1045

1146
def initialize
47+
@sampling = Sampling.new
1248
set_defaults
1349
end
1450

@@ -60,6 +96,9 @@ def set_defaults
6096
@log_level = ENV["RUBYLLM_MCP_DEBUG"] ? Logger::DEBUG : Logger::INFO
6197
@has_support_complex_parameters = false
6298
@logger = nil
99+
@roots = []
100+
101+
@sampling.reset!
63102
end
64103
end
65104
end

lib/ruby_llm/mcp/coordinator.rb

Lines changed: 50 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ def initialize(client, transport_type:, config: {})
2323
@capabilities = nil
2424
end
2525

26+
def name
27+
client.name
28+
end
29+
2630
def request(body, **options)
2731
@transport.request(body, **options)
2832
rescue RubyLLM::MCP::Errors::TimeoutError => e
@@ -83,39 +87,11 @@ def ping
8387

8488
def process_notification(result)
8589
notification = result.notification
86-
87-
case notification.type
88-
when "notifications/tools/list_changed"
89-
client.reset_tools!
90-
when "notifications/resources/list_changed"
91-
client.reset_resources!
92-
when "notifications/resources/updated"
93-
uri = notification.params["uri"]
94-
resource = client.resources.find { |r| r.uri == uri }
95-
resource&.reset_content!
96-
when "notifications/prompts/list_changed"
97-
client.reset_prompts!
98-
when "notifications/message"
99-
process_logging_message(notification)
100-
when "notifications/progress"
101-
process_progress_message(notification)
102-
when "notifications/cancelled"
103-
# TODO: - do nothing at the moment until we support client operations
104-
else
105-
message = "Unknown notification type: #{notification.type} params:#{notification.params.to_h}"
106-
raise Errors::UnknownNotification.new(message: message)
107-
end
90+
NotificationHandler.new(self).execute(notification)
10891
end
10992

11093
def process_request(result)
111-
if result.ping?
112-
ping_response(id: result.id)
113-
return
114-
end
115-
116-
# Handle server-initiated requests
117-
# Currently, we do not support any client operations but will
118-
raise RubyLLM::MCP::Errors::UnknownRequest.new(message: "Unknown request type: #{result.inspect}")
94+
ResponseHandler.new(self).execute(result)
11995
end
12096

12197
def initialize_request
@@ -190,20 +166,56 @@ def completion_prompt(**args)
190166
RubyLLM::MCP::Requests::CompletionPrompt.new(self, **args).call
191167
end
192168

169+
def set_logging(**args)
170+
RubyLLM::MCP::Requests::LoggingSetLevel.new(self, **args).call
171+
end
172+
173+
## Notifications
174+
#
193175
def initialize_notification
194-
RubyLLM::MCP::Requests::InitializeNotification.new(self).call
176+
RubyLLM::MCP::Notifications::Initialize.new(self).call
195177
end
196178

197179
def cancelled_notification(**args)
198-
RubyLLM::MCP::Requests::CancelledNotification.new(self, **args).call
180+
RubyLLM::MCP::Notifications::Cancelled.new(self, **args).call
181+
end
182+
183+
def roots_list_change_notification
184+
RubyLLM::MCP::Notifications::RootsListChange.new(self).call
185+
end
186+
187+
## Responses
188+
#
189+
def ping_response(**args)
190+
RubyLLM::MCP::Responses::Ping.new(self, **args).call
191+
end
192+
193+
def roots_list_response(**args)
194+
RubyLLM::MCP::Responses::RootsList.new(self, **args).call
195+
end
196+
197+
def sampling_create_message_response(**args)
198+
RubyLLM::MCP::Responses::SamplingCreateMessage.new(self, **args).call
199199
end
200200

201-
def ping_response(id: nil)
202-
RubyLLM::MCP::Requests::PingResponse.new(self, id: id).call
201+
def error_response(**args)
202+
RubyLLM::MCP::Responses::Error.new(self, **args).call
203203
end
204204

205-
def set_logging(level:)
206-
RubyLLM::MCP::Requests::LoggingSetLevel.new(self, level: level).call
205+
def client_capabilities
206+
capabilities = {}
207+
208+
if client.roots.active?
209+
capabilities[:roots] = {
210+
listChanged: true
211+
}
212+
end
213+
214+
if sampling_enabled?
215+
capabilities[:sampling] = {}
216+
end
217+
218+
capabilities
207219
end
208220

209221
def build_transport
@@ -230,46 +242,10 @@ def build_transport
230242
end
231243
end
232244

233-
def process_logging_message(notification)
234-
if client.logging_handler_enabled?
235-
client.on[:logging].call(notification)
236-
else
237-
default_process_logging_message(notification)
238-
end
239-
end
240-
241-
def default_process_logging_message(notification, logger: RubyLLM::MCP.logger)
242-
level = notification.params["level"]
243-
logger_message = notification.params["logger"]
244-
message = notification.params["data"]
245-
246-
message = "#{logger_message}: #{message}"
247-
248-
case level
249-
when "debug"
250-
logger.debug(message["message"])
251-
when "info", "notice"
252-
logger.info(message["message"])
253-
when "warning"
254-
logger.warn(message["message"])
255-
when "error", "critical"
256-
logger.error(message["message"])
257-
when "alert", "emergency"
258-
logger.fatal(message["message"])
259-
end
260-
end
261-
262-
def name
263-
client.name
264-
end
265-
266245
private
267246

268-
def process_progress_message(notification)
269-
progress_obj = RubyLLM::MCP::Progress.new(self, client.on[:progress], notification.params)
270-
if client.tracking_progress?
271-
progress_obj.execute_progress_handler
272-
end
247+
def sampling_enabled?
248+
MCP.config.sampling.enabled?
273249
end
274250
end
275251
end

lib/ruby_llm/mcp/errors.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class CompletionNotAvailable < BaseError; end
1717
class ResourceSubscribeNotAvailable < BaseError; end
1818
end
1919

20+
class InvalidFormatError < BaseError; end
21+
2022
class InvalidProtocolVersionError < BaseError; end
2123

2224
class InvalidTransportType < BaseError; end

0 commit comments

Comments
 (0)