ruby-mcp-client 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,13 +16,17 @@ module MCPClient
16
16
  # @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
17
17
  # @!attribute [r] logger
18
18
  # @return [Logger] logger for client operations
19
- attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger
19
+ # @!attribute [r] roots
20
+ # @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
21
+ attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots
20
22
 
21
23
  # Initialize a new MCPClient::Client
22
24
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
23
25
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
24
26
  # @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
25
- def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil)
27
+ # @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
28
+ # @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-06-18)
29
+ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
26
30
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
27
31
  @logger.progname = self.class.name
28
32
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
@@ -37,6 +41,10 @@ module MCPClient
37
41
  @notification_listeners = []
38
42
  # Elicitation handler (MCP 2025-06-18)
39
43
  @elicitation_handler = elicitation_handler
44
+ # Sampling handler (MCP 2025-06-18)
45
+ @sampling_handler = sampling_handler
46
+ # Roots (MCP 2025-06-18)
47
+ @roots = normalize_roots(roots)
40
48
  # Register default and user-defined notification handlers on each server
41
49
  @servers.each do |server|
42
50
  server.on_notification do |method, params|
@@ -49,6 +57,10 @@ module MCPClient
49
57
  if server.respond_to?(:on_elicitation_request)
50
58
  server.on_elicitation_request(&method(:handle_elicitation_request))
51
59
  end
60
+ # Register roots list handler on each server (MCP 2025-06-18)
61
+ server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
62
+ # Register sampling handler on each server (MCP 2025-06-18)
63
+ server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
52
64
  end
53
65
  end
54
66
 
@@ -330,6 +342,16 @@ module MCPClient
330
342
  @notification_listeners << block
331
343
  end
332
344
 
345
+ # Set the roots for this client (MCP 2025-06-18)
346
+ # When roots are changed, a notification is sent to all connected servers
347
+ # @param new_roots [Array<MCPClient::Root, Hash>] the new roots to set
348
+ # @return [void]
349
+ def roots=(new_roots)
350
+ @roots = normalize_roots(new_roots)
351
+ # Notify servers that roots have changed
352
+ notify_roots_changed
353
+ end
354
+
333
355
  # Find a server by name
334
356
  # @param name [String] the name of the server to find
335
357
  # @return [MCPClient::ServerBase, nil] the server with the given name, or nil if not found
@@ -353,8 +375,10 @@ module MCPClient
353
375
  end
354
376
 
355
377
  # Call multiple tools in batch
356
- # @param calls [Array<Hash>] array of calls in the form:
357
- # { name: tool_name, parameters: {...}, server: optional_server_name }
378
+ # @param calls [Array<Hash>] array of call hashes with keys:
379
+ # - name: tool name (required)
380
+ # - parameters: tool parameters (optional, default empty hash)
381
+ # - server: server name for routing (optional)
358
382
  # @return [Array<Object>] array of results for each tool invocation
359
383
  def call_tools(calls)
360
384
  calls.map do |call|
@@ -459,6 +483,28 @@ module MCPClient
459
483
  srv.rpc_notify(method, params)
460
484
  end
461
485
 
486
+ # Request completion suggestions from a server (MCP 2025-06-18)
487
+ # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
488
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
489
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
490
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
491
+ # @raise [MCPClient::Errors::ServerNotFound] if no server is available
492
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
493
+ def complete(ref:, argument:, server: nil)
494
+ srv = select_server(server)
495
+ srv.complete(ref: ref, argument: argument)
496
+ end
497
+
498
+ # Set the logging level on all connected servers (MCP 2025-06-18)
499
+ # To set on a specific server, use: client.find_server('name').log_level = 'debug'
500
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
501
+ # 'critical', 'alert', 'emergency')
502
+ # @return [Array<Hash>] results from servers
503
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
504
+ def log_level=(level)
505
+ @servers.map { |srv| srv.log_level = level }
506
+ end
507
+
462
508
  private
463
509
 
464
510
  # Process incoming JSON-RPC notifications with default handlers
@@ -480,12 +526,43 @@ module MCPClient
480
526
  when 'notifications/resources/list_changed'
481
527
  logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
482
528
  @resource_cache.clear
529
+ when 'notifications/message'
530
+ # MCP 2025-06-18: Handle logging messages from server
531
+ handle_log_message(server_id, params)
483
532
  else
484
533
  # Log unknown notification types for debugging purposes
485
534
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
486
535
  end
487
536
  end
488
537
 
538
+ # Handle logging message notification from server (MCP 2025-06-18)
539
+ # @param server_id [String] server identifier for log prefix
540
+ # @param params [Hash] log message params (level, logger, data)
541
+ # @return [void]
542
+ def handle_log_message(server_id, params)
543
+ level = params['level'] || 'info'
544
+ logger_name = params['logger']
545
+ data = params['data']
546
+
547
+ # Format the message
548
+ prefix = logger_name ? "[#{server_id}:#{logger_name}]" : "[#{server_id}]"
549
+ message = data.is_a?(String) ? data : data.inspect
550
+
551
+ # Map MCP log levels to Ruby Logger levels
552
+ case level.to_s.downcase
553
+ when 'debug'
554
+ logger.debug("#{prefix} #{message}")
555
+ when 'info', 'notice'
556
+ logger.info("#{prefix} #{message}")
557
+ when 'warning'
558
+ logger.warn("#{prefix} #{message}")
559
+ when 'error', 'critical', 'alert', 'emergency'
560
+ logger.error("#{prefix} #{message}")
561
+ else
562
+ logger.info("#{prefix} [#{level}] #{message}")
563
+ end
564
+ end
565
+
489
566
  # Select a server based on index, name, type, or instance
490
567
  # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
491
568
  # @return [MCPClient::ServerBase]
@@ -607,7 +684,7 @@ module MCPClient
607
684
  end
608
685
 
609
686
  # Handle elicitation request from server (MCP 2025-06-18)
610
- # @param request_id [String, Integer] the JSON-RPC request ID
687
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
611
688
  # @param params [Hash] the elicitation parameters
612
689
  # @return [Hash] the elicitation response
613
690
  def handle_elicitation_request(_request_id, params)
@@ -670,5 +747,112 @@ module MCPClient
670
747
  @logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
671
748
  result.merge('action' => 'accept')
672
749
  end
750
+
751
+ # Normalize roots array - convert Hashes to Root objects (MCP 2025-06-18)
752
+ # @param roots [Array<MCPClient::Root, Hash>, nil] the roots to normalize
753
+ # @return [Array<MCPClient::Root>] normalized roots array
754
+ def normalize_roots(roots)
755
+ return [] if roots.nil?
756
+
757
+ roots.map do |root|
758
+ case root
759
+ when MCPClient::Root
760
+ root
761
+ when Hash
762
+ MCPClient::Root.from_json(root)
763
+ else
764
+ raise ArgumentError, "Invalid root type: #{root.class}. Expected MCPClient::Root or Hash."
765
+ end
766
+ end
767
+ end
768
+
769
+ # Handle roots/list request from server (MCP 2025-06-18)
770
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
771
+ # @param _params [Hash] the request parameters (unused)
772
+ # @return [Hash] the roots list response
773
+ def handle_roots_list_request(_request_id, _params)
774
+ { 'roots' => @roots.map(&:to_h) }
775
+ end
776
+
777
+ # Send notification to all servers that roots have changed (MCP 2025-06-18)
778
+ # @return [void]
779
+ def notify_roots_changed
780
+ @servers.each do |server|
781
+ server.rpc_notify('notifications/roots/list_changed', {})
782
+ rescue StandardError => e
783
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
784
+ @logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
785
+ end
786
+ end
787
+
788
+ # Handle sampling/createMessage request from server (MCP 2025-06-18)
789
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
790
+ # @param params [Hash] the sampling parameters (messages, modelPreferences, systemPrompt, maxTokens)
791
+ # @return [Hash] the sampling response (role, content, model, stopReason)
792
+ def handle_sampling_request(_request_id, params)
793
+ # If no handler is configured, return an error
794
+ unless @sampling_handler
795
+ @logger.warn('Received sampling request but no handler configured')
796
+ return { 'error' => { 'code' => -1, 'message' => 'Sampling not supported' } }
797
+ end
798
+
799
+ messages = params['messages'] || []
800
+ model_preferences = params['modelPreferences']
801
+ system_prompt = params['systemPrompt']
802
+ max_tokens = params['maxTokens']
803
+
804
+ begin
805
+ # Call the user-defined handler
806
+ result = case @sampling_handler.arity
807
+ when 0
808
+ @sampling_handler.call
809
+ when 1
810
+ @sampling_handler.call(messages)
811
+ when 2
812
+ @sampling_handler.call(messages, model_preferences)
813
+ when 3
814
+ @sampling_handler.call(messages, model_preferences, system_prompt)
815
+ else
816
+ @sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
817
+ end
818
+
819
+ # Validate and format response
820
+ validate_sampling_response(result)
821
+ rescue StandardError => e
822
+ @logger.error("Sampling handler error: #{e.message}")
823
+ @logger.debug(e.backtrace.join("\n"))
824
+ { 'error' => { 'code' => -1, 'message' => "Sampling error: #{e.message}" } }
825
+ end
826
+ end
827
+
828
+ # Validate sampling response from handler (MCP 2025-06-18)
829
+ # @param result [Hash] the result from the sampling handler
830
+ # @return [Hash] validated sampling response
831
+ def validate_sampling_response(result)
832
+ return { 'error' => { 'code' => -1, 'message' => 'Sampling rejected' } } if result.nil?
833
+
834
+ # Convert symbol keys to string keys
835
+ result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
836
+
837
+ # Ensure required fields are present
838
+ unless result.is_a?(Hash) && result['content']
839
+ return {
840
+ 'role' => 'assistant',
841
+ 'content' => { 'type' => 'text', 'text' => result.to_s },
842
+ 'model' => 'unknown',
843
+ 'stopReason' => 'endTurn'
844
+ }
845
+ end
846
+
847
+ # Set defaults for missing fields
848
+ result['role'] ||= 'assistant'
849
+ result['model'] ||= 'unknown'
850
+ result['stopReason'] ||= 'endTurn'
851
+
852
+ # Normalize content if it's a string
853
+ result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
854
+
855
+ result
856
+ end
673
857
  end
674
858
  end
@@ -47,5 +47,8 @@ module MCPClient
47
47
 
48
48
  # Raised when multiple resources with the same URI exist across different servers
49
49
  class AmbiguousResourceURI < MCPError; end
50
+
51
+ # Raised when transport type cannot be determined from target URL/command
52
+ class TransportDetectionError < MCPError; end
50
53
  end
51
54
  end
@@ -256,14 +256,20 @@ module MCPClient
256
256
  end
257
257
 
258
258
  # Create a Faraday connection for HTTP requests
259
+ # Applies default configuration first, then allows user customization via @faraday_config block
259
260
  # @return [Faraday::Connection] the configured connection
260
261
  def create_http_connection
261
- Faraday.new(url: @base_url) do |f|
262
+ conn = Faraday.new(url: @base_url) do |f|
262
263
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
263
264
  f.options.open_timeout = @read_timeout
264
265
  f.options.timeout = @read_timeout
265
266
  f.adapter Faraday.default_adapter
266
267
  end
268
+
269
+ # Apply user's Faraday customizations after defaults
270
+ @faraday_config&.call(conn)
271
+
272
+ conn
267
273
  end
268
274
 
269
275
  # Log HTTP response (to be overridden by specific transports)
@@ -62,11 +62,15 @@ module MCPClient
62
62
  # Generate initialization parameters for MCP protocol
63
63
  # @return [Hash] the initialization parameters
64
64
  def initialization_params
65
+ capabilities = {
66
+ 'elicitation' => {}, # MCP 2025-06-18: Support for server-initiated user interactions
67
+ 'roots' => { 'listChanged' => true }, # MCP 2025-06-18: Support for roots
68
+ 'sampling' => {} # MCP 2025-06-18: Support for server-initiated LLM sampling
69
+ }
70
+
65
71
  {
66
72
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
67
- 'capabilities' => {
68
- 'elicitation' => {} # MCP 2025-06-18: Support for server-initiated user interactions
69
- },
73
+ 'capabilities' => capabilities,
70
74
  'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
71
75
  }
72
76
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Represents an MCP Root - a URI that defines a boundary where servers can operate
5
+ # Roots are declared by clients to inform servers about relevant resources and their locations
6
+ class Root
7
+ attr_reader :uri, :name
8
+
9
+ # Create a new Root
10
+ # @param uri [String] The URI for the root (typically file:// URI)
11
+ # @param name [String, nil] Optional human-readable name for display purposes
12
+ def initialize(uri:, name: nil)
13
+ @uri = uri
14
+ @name = name
15
+ end
16
+
17
+ # Create a Root from a JSON hash
18
+ # @param json [Hash] The JSON hash with 'uri' and optional 'name' keys
19
+ # @return [Root]
20
+ def self.from_json(json)
21
+ new(
22
+ uri: json['uri'] || json[:uri],
23
+ name: json['name'] || json[:name]
24
+ )
25
+ end
26
+
27
+ # Convert to JSON-serializable hash
28
+ # @return [Hash]
29
+ def to_h
30
+ result = { 'uri' => @uri }
31
+ result['name'] = @name if @name
32
+ result
33
+ end
34
+
35
+ # Convert to JSON string
36
+ # @return [String]
37
+ def to_json(*)
38
+ to_h.to_json(*)
39
+ end
40
+
41
+ # Check equality
42
+ def ==(other)
43
+ return false unless other.is_a?(Root)
44
+
45
+ uri == other.uri && name == other.name
46
+ end
47
+
48
+ alias eql? ==
49
+
50
+ def hash
51
+ [uri, name].hash
52
+ end
53
+
54
+ # String representation
55
+ def to_s
56
+ name ? "#{name} (#{uri})" : uri
57
+ end
58
+
59
+ def inspect
60
+ "#<MCPClient::Root uri=#{uri.inspect} name=#{name.inspect}>"
61
+ end
62
+ end
63
+ end
@@ -77,7 +77,8 @@ module MCPClient
77
77
  retry_backoff: config[:retry_backoff] || 1,
78
78
  name: config[:name],
79
79
  logger: logger,
80
- oauth_provider: config[:oauth_provider]
80
+ oauth_provider: config[:oauth_provider],
81
+ faraday_config: config[:faraday_config]
81
82
  )
82
83
  end
83
84
 
@@ -97,7 +98,8 @@ module MCPClient
97
98
  retry_backoff: config[:retry_backoff] || 1,
98
99
  name: config[:name],
99
100
  logger: logger,
100
- oauth_provider: config[:oauth_provider]
101
+ oauth_provider: config[:oauth_provider],
102
+ faraday_config: config[:faraday_config]
101
103
  )
102
104
  end
103
105
 
@@ -55,6 +55,7 @@ module MCPClient
55
55
  # @option options [String, nil] :name Optional name for this server
56
56
  # @option options [Logger, nil] :logger Optional logger
57
57
  # @option options [MCPClient::Auth::OAuthProvider, nil] :oauth_provider Optional OAuth provider
58
+ # @option options [Proc, nil] :faraday_config Optional block to customize the Faraday connection
58
59
  def initialize(base_url:, **options)
59
60
  opts = default_options.merge(options)
60
61
  super(name: opts[:name])
@@ -99,6 +100,7 @@ module MCPClient
99
100
  })
100
101
 
101
102
  @read_timeout = opts[:read_timeout]
103
+ @faraday_config = opts[:faraday_config]
102
104
  @tools = nil
103
105
  @tools_data = nil
104
106
  @request_id = 0
@@ -320,6 +322,33 @@ module MCPClient
320
322
  raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
321
323
  end
322
324
 
325
+ # Request completion suggestions from the server (MCP 2025-06-18)
326
+ # @param ref [Hash] reference to complete (prompt or resource)
327
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
328
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
329
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
330
+ def complete(ref:, argument:)
331
+ result = rpc_request('completion/complete', { ref: ref, argument: argument })
332
+ result['completion'] || { 'values' => [] }
333
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
334
+ raise
335
+ rescue StandardError => e
336
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
337
+ end
338
+
339
+ # Set the logging level on the server (MCP 2025-06-18)
340
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
341
+ # 'critical', 'alert', 'emergency')
342
+ # @return [Hash] empty result on success
343
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
344
+ def log_level=(level)
345
+ rpc_request('logging/setLevel', { level: level })
346
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
347
+ raise
348
+ rescue StandardError => e
349
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
350
+ end
351
+
323
352
  # List all resource templates available from the MCP server
324
353
  # @param cursor [String, nil] optional cursor for pagination
325
354
  # @return [Hash] result containing resourceTemplates array and optional nextCursor
@@ -420,7 +449,8 @@ module MCPClient
420
449
  retry_backoff: 1,
421
450
  name: nil,
422
451
  logger: nil,
423
- oauth_provider: nil
452
+ oauth_provider: nil,
453
+ faraday_config: nil
424
454
  }
425
455
  end
426
456
 
@@ -105,6 +105,8 @@ module MCPClient
105
105
  @last_activity_time = Time.now
106
106
  @activity_timer_thread = nil
107
107
  @elicitation_request_callback = nil # MCP 2025-06-18
108
+ @roots_list_request_callback = nil # MCP 2025-06-18
109
+ @sampling_request_callback = nil # MCP 2025-06-18
108
110
  end
109
111
 
110
112
  # Stream tool call fallback for SSE transport (yields single result)
@@ -307,15 +309,11 @@ module MCPClient
307
309
  # Call a tool with the given parameters
308
310
  # @param tool_name [String] the name of the tool to call
309
311
  # @param parameters [Hash] the parameters to pass to the tool
310
- # @return [Object] the result of the tool invocation
312
+ # @return [Object] the result of the tool invocation (with string keys for backward compatibility)
311
313
  # @raise [MCPClient::Errors::ServerError] if server returns an error
312
314
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
313
315
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
314
316
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
315
- # Call a tool with the given parameters
316
- # @param tool_name [String] the name of the tool to call
317
- # @param parameters [Hash] the parameters to pass to the tool
318
- # @return [Object] the result of the tool invocation (with string keys for backward compatibility)
319
317
  def call_tool(tool_name, parameters)
320
318
  rpc_request('tools/call', {
321
319
  name: tool_name,
@@ -329,6 +327,33 @@ module MCPClient
329
327
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
330
328
  end
331
329
 
330
+ # Request completion suggestions from the server (MCP 2025-06-18)
331
+ # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
332
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
333
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
334
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
335
+ def complete(ref:, argument:)
336
+ result = rpc_request('completion/complete', { ref: ref, argument: argument })
337
+ result['completion'] || { 'values' => [] }
338
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
339
+ raise
340
+ rescue StandardError => e
341
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
342
+ end
343
+
344
+ # Set the logging level on the server (MCP 2025-06-18)
345
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
346
+ # 'critical', 'alert', 'emergency')
347
+ # @return [Hash] empty result on success
348
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
349
+ def log_level=(level)
350
+ rpc_request('logging/setLevel', { level: level })
351
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
352
+ raise
353
+ rescue StandardError => e
354
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
355
+ end
356
+
332
357
  # Connect to the MCP server over HTTP/HTTPS with SSE
333
358
  # @return [Boolean] true if connection was successful
334
359
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
@@ -423,6 +448,20 @@ module MCPClient
423
448
  @elicitation_request_callback = block
424
449
  end
425
450
 
451
+ # Register a callback for roots/list requests (MCP 2025-06-18)
452
+ # @param block [Proc] callback that receives (request_id, params) and returns response hash
453
+ # @return [void]
454
+ def on_roots_list_request(&block)
455
+ @roots_list_request_callback = block
456
+ end
457
+
458
+ # Register a callback for sampling requests (MCP 2025-06-18)
459
+ # @param block [Proc] callback that receives (request_id, params) and returns response hash
460
+ # @return [void]
461
+ def on_sampling_request(&block)
462
+ @sampling_request_callback = block
463
+ end
464
+
426
465
  # Handle incoming JSON-RPC request from server (MCP 2025-06-18)
427
466
  # @param msg [Hash] the JSON-RPC request message
428
467
  # @return [void]
@@ -436,6 +475,10 @@ module MCPClient
436
475
  case method
437
476
  when 'elicitation/create'
438
477
  handle_elicitation_create(request_id, params)
478
+ when 'roots/list'
479
+ handle_roots_list(request_id, params)
480
+ when 'sampling/createMessage'
481
+ handle_sampling_create_message(request_id, params)
439
482
  else
440
483
  # Unknown request method, send error response
441
484
  send_error_response(request_id, -32_601, "Method not found: #{method}")
@@ -464,6 +507,88 @@ module MCPClient
464
507
  send_elicitation_response(request_id, result)
465
508
  end
466
509
 
510
+ # Handle roots/list request from server (MCP 2025-06-18)
511
+ # @param request_id [String, Integer] the JSON-RPC request ID
512
+ # @param params [Hash] the request parameters
513
+ # @return [void]
514
+ def handle_roots_list(request_id, params)
515
+ # If no callback is registered, return empty roots list
516
+ unless @roots_list_request_callback
517
+ @logger.debug('Received roots/list request but no callback registered, returning empty list')
518
+ send_roots_list_response(request_id, { 'roots' => [] })
519
+ return
520
+ end
521
+
522
+ # Call the registered callback
523
+ result = @roots_list_request_callback.call(request_id, params)
524
+
525
+ # Send the response back to the server
526
+ send_roots_list_response(request_id, result)
527
+ end
528
+
529
+ # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
530
+ # @param request_id [String, Integer] the JSON-RPC request ID
531
+ # @param result [Hash] the roots list result
532
+ # @return [void]
533
+ def send_roots_list_response(request_id, result)
534
+ ensure_initialized
535
+
536
+ response = {
537
+ 'jsonrpc' => '2.0',
538
+ 'id' => request_id,
539
+ 'result' => result
540
+ }
541
+
542
+ # Send response via HTTP POST to the RPC endpoint
543
+ post_jsonrpc_response(response)
544
+ rescue StandardError => e
545
+ @logger.error("Error sending roots/list response: #{e.message}")
546
+ end
547
+
548
+ # Handle sampling/createMessage request from server (MCP 2025-06-18)
549
+ # @param request_id [String, Integer] the JSON-RPC request ID
550
+ # @param params [Hash] the sampling parameters
551
+ # @return [void]
552
+ def handle_sampling_create_message(request_id, params)
553
+ # If no callback is registered, return error
554
+ unless @sampling_request_callback
555
+ @logger.warn('Received sampling request but no callback registered, returning error')
556
+ send_error_response(request_id, -1, 'Sampling not supported')
557
+ return
558
+ end
559
+
560
+ # Call the registered callback
561
+ result = @sampling_request_callback.call(request_id, params)
562
+
563
+ # Send the response back to the server
564
+ send_sampling_response(request_id, result)
565
+ end
566
+
567
+ # Send sampling response back to server via HTTP POST (MCP 2025-06-18)
568
+ # @param request_id [String, Integer] the JSON-RPC request ID
569
+ # @param result [Hash] the sampling result (role, content, model, stopReason)
570
+ # @return [void]
571
+ def send_sampling_response(request_id, result)
572
+ ensure_initialized
573
+
574
+ # Check if result contains an error
575
+ if result.is_a?(Hash) && result['error']
576
+ send_error_response(request_id, result['error']['code'] || -1, result['error']['message'] || 'Sampling error')
577
+ return
578
+ end
579
+
580
+ response = {
581
+ 'jsonrpc' => '2.0',
582
+ 'id' => request_id,
583
+ 'result' => result
584
+ }
585
+
586
+ # Send response via HTTP POST to the RPC endpoint
587
+ post_jsonrpc_response(response)
588
+ rescue StandardError => e
589
+ @logger.error("Error sending sampling response: #{e.message}")
590
+ end
591
+
467
592
  # Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
468
593
  # @param request_id [String, Integer] the JSON-RPC request ID
469
594
  # @param result [Hash] the elicitation result (action and optional content)