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.
- checksums.yaml +4 -4
- data/README.md +220 -1229
- data/lib/mcp_client/client.rb +189 -5
- data/lib/mcp_client/errors.rb +3 -0
- data/lib/mcp_client/http_transport_base.rb +7 -1
- data/lib/mcp_client/json_rpc_common.rb +7 -3
- data/lib/mcp_client/root.rb +63 -0
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +31 -1
- data/lib/mcp_client/server_sse.rb +130 -5
- data/lib/mcp_client/server_stdio.rb +140 -0
- data/lib/mcp_client/server_streamable_http.rb +131 -1
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +317 -4
- metadata +3 -2
data/lib/mcp_client/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
357
|
-
#
|
|
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
|
|
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
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -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)
|