ruby-mcp-client 0.8.1 → 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.
@@ -9,9 +9,9 @@ require 'faraday/retry'
9
9
  require 'faraday/follow_redirects'
10
10
 
11
11
  module MCPClient
12
- # Implementation of MCP server that communicates via Streamable HTTP transport (MCP 2025-03-26)
12
+ # Implementation of MCP server that communicates via Streamable HTTP transport (MCP 2025-06-18)
13
13
  # This transport uses HTTP POST for RPC calls with optional SSE responses, and GET for event streams
14
- # Compliant with MCP specification version 2025-03-26
14
+ # Compliant with MCP specification version 2025-06-18
15
15
  #
16
16
  # Key features:
17
17
  # - Supports server-sent events (SSE) for real-time notifications
@@ -91,12 +91,13 @@ module MCPClient
91
91
  @headers = opts[:headers].merge({
92
92
  'Content-Type' => 'application/json',
93
93
  'Accept' => 'text/event-stream, application/json',
94
- 'Accept-Encoding' => 'gzip, deflate',
94
+ 'Accept-Encoding' => 'gzip',
95
95
  'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}",
96
96
  'Cache-Control' => 'no-cache'
97
97
  })
98
98
 
99
99
  @read_timeout = opts[:read_timeout]
100
+ @faraday_config = opts[:faraday_config]
100
101
  @tools = nil
101
102
  @tools_data = nil
102
103
  @prompts = nil
@@ -116,6 +117,9 @@ module MCPClient
116
117
  @events_connection = nil
117
118
  @events_thread = nil
118
119
  @buffer = '' # Buffer for partial SSE event data
120
+ @elicitation_request_callback = nil # MCP 2025-06-18
121
+ @roots_list_request_callback = nil # MCP 2025-06-18
122
+ @sampling_request_callback = nil # MCP 2025-06-18
119
123
  end
120
124
 
121
125
  # Connect to the MCP server over Streamable HTTP
@@ -215,6 +219,33 @@ module MCPClient
215
219
  end
216
220
  end
217
221
 
222
+ # Request completion suggestions from the server (MCP 2025-06-18)
223
+ # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
224
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
225
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
226
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
227
+ def complete(ref:, argument:)
228
+ result = rpc_request('completion/complete', { ref: ref, argument: argument })
229
+ result['completion'] || { 'values' => [] }
230
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
231
+ raise
232
+ rescue StandardError => e
233
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
234
+ end
235
+
236
+ # Set the logging level on the server (MCP 2025-06-18)
237
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
238
+ # 'critical', 'alert', 'emergency')
239
+ # @return [Hash] empty result on success
240
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
241
+ def log_level=(level)
242
+ rpc_request('logging/setLevel', { level: level })
243
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
244
+ raise
245
+ rescue StandardError => e
246
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
247
+ end
248
+
218
249
  # List all prompts available from the MCP server
219
250
  # @return [Array<MCPClient::Prompt>] list of available prompts
220
251
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
@@ -451,6 +482,27 @@ module MCPClient
451
482
  end
452
483
  end
453
484
 
485
+ # Register a callback for elicitation requests (MCP 2025-06-18)
486
+ # @param block [Proc] callback that receives (request_id, params) and returns response hash
487
+ # @return [void]
488
+ def on_elicitation_request(&block)
489
+ @elicitation_request_callback = block
490
+ end
491
+
492
+ # Register a callback for roots/list requests (MCP 2025-06-18)
493
+ # @param block [Proc] callback that receives (request_id, params) and returns response hash
494
+ # @return [void]
495
+ def on_roots_list_request(&block)
496
+ @roots_list_request_callback = block
497
+ end
498
+
499
+ # Register a callback for sampling requests (MCP 2025-06-18)
500
+ # @param block [Proc] callback that receives (request_id, params) and returns response hash
501
+ # @return [void]
502
+ def on_sampling_request(&block)
503
+ @sampling_request_callback = block
504
+ end
505
+
454
506
  private
455
507
 
456
508
  def perform_initialize
@@ -475,7 +527,8 @@ module MCPClient
475
527
  retry_backoff: 1,
476
528
  name: nil,
477
529
  logger: nil,
478
- oauth_provider: nil
530
+ oauth_provider: nil,
531
+ faraday_config: nil
479
532
  }
480
533
  end
481
534
 
@@ -621,6 +674,9 @@ module MCPClient
621
674
  end
622
675
  end
623
676
 
677
+ # Apply user's Faraday customizations after defaults
678
+ @faraday_config&.call(conn)
679
+
624
680
  @logger.debug("Establishing SSE events connection to #{@endpoint}") if @logger.level <= Logger::DEBUG
625
681
 
626
682
  response = conn.get(@endpoint) do |req|
@@ -755,12 +811,12 @@ module MCPClient
755
811
  # Handle ping requests from server (keepalive mechanism)
756
812
  if message['method'] == 'ping' && message.key?('id')
757
813
  handle_ping_request(message['id'])
814
+ elsif message['method'] && message.key?('id')
815
+ # Handle server-to-client requests (MCP 2025-06-18)
816
+ handle_server_request(message)
758
817
  elsif message['method'] && !message.key?('id')
759
818
  # Handle server notifications (messages without id)
760
819
  @notification_callback&.call(message['method'], message['params'])
761
- elsif message.key?('id')
762
- # This might be a server-to-client request (future MCP versions)
763
- @logger.warn("Received unhandled server request: #{message['method']}")
764
820
  end
765
821
  rescue JSON::ParserError => e
766
822
  @logger.error("Invalid JSON in server message: #{e.message}")
@@ -796,5 +852,205 @@ module MCPClient
796
852
  @logger.error("Failed to send pong response: #{e.message}")
797
853
  end
798
854
  end
855
+
856
+ # Handle incoming JSON-RPC request from server (MCP 2025-06-18)
857
+ # @param msg [Hash] the JSON-RPC request message
858
+ # @return [void]
859
+ def handle_server_request(msg)
860
+ request_id = msg['id']
861
+ method = msg['method']
862
+ params = msg['params'] || {}
863
+
864
+ @logger.debug("Received server request: #{method} (id: #{request_id})")
865
+
866
+ case method
867
+ when 'elicitation/create'
868
+ handle_elicitation_create(request_id, params)
869
+ when 'roots/list'
870
+ handle_roots_list(request_id, params)
871
+ when 'sampling/createMessage'
872
+ handle_sampling_create_message(request_id, params)
873
+ else
874
+ # Unknown request method, send error response
875
+ send_error_response(request_id, -32_601, "Method not found: #{method}")
876
+ end
877
+ rescue StandardError => e
878
+ @logger.error("Error handling server request: #{e.message}")
879
+ send_error_response(request_id, -32_603, "Internal error: #{e.message}")
880
+ end
881
+
882
+ # Handle elicitation/create request from server (MCP 2025-06-18)
883
+ # @param request_id [String, Integer] the JSON-RPC request ID (used as elicitationId)
884
+ # @param params [Hash] the elicitation parameters
885
+ # @return [void]
886
+ def handle_elicitation_create(request_id, params)
887
+ # The request_id is the elicitationId per MCP spec
888
+ elicitation_id = request_id
889
+
890
+ # If no callback is registered, decline the request
891
+ unless @elicitation_request_callback
892
+ @logger.warn('Received elicitation request but no callback registered, declining')
893
+ send_elicitation_response(elicitation_id, { 'action' => 'decline' })
894
+ return
895
+ end
896
+
897
+ # Call the registered callback
898
+ result = @elicitation_request_callback.call(request_id, params)
899
+
900
+ # Send the response back to the server
901
+ send_elicitation_response(elicitation_id, result)
902
+ end
903
+
904
+ # Handle roots/list request from server (MCP 2025-06-18)
905
+ # @param request_id [String, Integer] the JSON-RPC request ID
906
+ # @param params [Hash] the request parameters
907
+ # @return [void]
908
+ def handle_roots_list(request_id, params)
909
+ # If no callback is registered, return empty roots list
910
+ unless @roots_list_request_callback
911
+ @logger.debug('Received roots/list request but no callback registered, returning empty list')
912
+ send_roots_list_response(request_id, { 'roots' => [] })
913
+ return
914
+ end
915
+
916
+ # Call the registered callback
917
+ result = @roots_list_request_callback.call(request_id, params)
918
+
919
+ # Send the response back to the server
920
+ send_roots_list_response(request_id, result)
921
+ end
922
+
923
+ # Handle sampling/createMessage request from server (MCP 2025-06-18)
924
+ # @param request_id [String, Integer] the JSON-RPC request ID
925
+ # @param params [Hash] the sampling parameters
926
+ # @return [void]
927
+ def handle_sampling_create_message(request_id, params)
928
+ # If no callback is registered, return error
929
+ unless @sampling_request_callback
930
+ @logger.warn('Received sampling request but no callback registered, returning error')
931
+ send_error_response(request_id, -1, 'Sampling not supported')
932
+ return
933
+ end
934
+
935
+ # Call the registered callback
936
+ result = @sampling_request_callback.call(request_id, params)
937
+
938
+ # Send the response back to the server
939
+ send_sampling_response(request_id, result)
940
+ end
941
+
942
+ # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
943
+ # @param request_id [String, Integer] the JSON-RPC request ID
944
+ # @param result [Hash] the roots list result
945
+ # @return [void]
946
+ def send_roots_list_response(request_id, result)
947
+ response = {
948
+ 'jsonrpc' => '2.0',
949
+ 'id' => request_id,
950
+ 'result' => result
951
+ }
952
+
953
+ # Send response via HTTP POST
954
+ post_jsonrpc_response(response)
955
+ rescue StandardError => e
956
+ @logger.error("Error sending roots/list response: #{e.message}")
957
+ end
958
+
959
+ # Send sampling response back to server via HTTP POST (MCP 2025-06-18)
960
+ # @param request_id [String, Integer] the JSON-RPC request ID
961
+ # @param result [Hash] the sampling result (role, content, model, stopReason)
962
+ # @return [void]
963
+ def send_sampling_response(request_id, result)
964
+ # Check if result contains an error
965
+ if result.is_a?(Hash) && result['error']
966
+ send_error_response(request_id, result['error']['code'] || -1, result['error']['message'] || 'Sampling error')
967
+ return
968
+ end
969
+
970
+ response = {
971
+ 'jsonrpc' => '2.0',
972
+ 'id' => request_id,
973
+ 'result' => result
974
+ }
975
+
976
+ # Send response via HTTP POST
977
+ post_jsonrpc_response(response)
978
+ rescue StandardError => e
979
+ @logger.error("Error sending sampling response: #{e.message}")
980
+ end
981
+
982
+ # Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
983
+ # For streamable HTTP, this is sent as a JSON-RPC request (not response)
984
+ # because HTTP is unidirectional.
985
+ # @param elicitation_id [String] the elicitation ID from the server
986
+ # @param result [Hash] the elicitation result (action and optional content)
987
+ # @return [void]
988
+ def send_elicitation_response(elicitation_id, result)
989
+ params = {
990
+ 'elicitationId' => elicitation_id,
991
+ 'action' => result['action']
992
+ }
993
+
994
+ # Only include content if present (typically for 'accept' action)
995
+ params['content'] = result['content'] if result['content']
996
+
997
+ request = {
998
+ 'jsonrpc' => '2.0',
999
+ 'method' => 'elicitation/response',
1000
+ 'params' => params
1001
+ }
1002
+
1003
+ # Send as a JSON-RPC request via HTTP POST
1004
+ post_jsonrpc_response(request)
1005
+ rescue StandardError => e
1006
+ @logger.error("Error sending elicitation response: #{e.message}")
1007
+ end
1008
+
1009
+ # Send error response back to server via HTTP POST (MCP 2025-06-18)
1010
+ # @param request_id [String, Integer] the JSON-RPC request ID
1011
+ # @param code [Integer] the error code
1012
+ # @param message [String] the error message
1013
+ # @return [void]
1014
+ def send_error_response(request_id, code, message)
1015
+ response = {
1016
+ 'jsonrpc' => '2.0',
1017
+ 'id' => request_id,
1018
+ 'error' => {
1019
+ 'code' => code,
1020
+ 'message' => message
1021
+ }
1022
+ }
1023
+
1024
+ # Send response via HTTP POST to the endpoint
1025
+ post_jsonrpc_response(response)
1026
+ rescue StandardError => e
1027
+ @logger.error("Error sending error response: #{e.message}")
1028
+ end
1029
+
1030
+ # Post a JSON-RPC response message to the server via HTTP
1031
+ # @param response [Hash] the JSON-RPC response
1032
+ # @return [void]
1033
+ # @private
1034
+ def post_jsonrpc_response(response)
1035
+ # Send response in a separate thread to avoid blocking event processing
1036
+ Thread.new do
1037
+ conn = http_connection
1038
+ json_body = JSON.generate(response)
1039
+
1040
+ resp = conn.post(@endpoint) do |req|
1041
+ @headers.each { |k, v| req.headers[k] = v }
1042
+ req.headers['Mcp-Session-Id'] = @session_id if @session_id
1043
+ req.body = json_body
1044
+ end
1045
+
1046
+ if resp.success?
1047
+ @logger.debug("Sent JSON-RPC response: #{json_body}")
1048
+ else
1049
+ @logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
1050
+ end
1051
+ rescue StandardError => e
1052
+ @logger.error("Failed to send JSON-RPC response: #{e.message}")
1053
+ end
1054
+ end
799
1055
  end
800
1056
  end
@@ -8,20 +8,28 @@ module MCPClient
8
8
  # @!attribute [r] description
9
9
  # @return [String] the description of the tool
10
10
  # @!attribute [r] schema
11
- # @return [Hash] the JSON schema for the tool
11
+ # @return [Hash] the JSON schema for the tool inputs
12
+ # @!attribute [r] output_schema
13
+ # @return [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
14
+ # @!attribute [r] annotations
15
+ # @return [Hash, nil] optional annotations describing tool behavior (e.g., readOnly, destructive)
12
16
  # @!attribute [r] server
13
17
  # @return [MCPClient::ServerBase, nil] the server this tool belongs to
14
- attr_reader :name, :description, :schema, :server
18
+ attr_reader :name, :description, :schema, :output_schema, :annotations, :server
15
19
 
16
20
  # Initialize a new Tool
17
21
  # @param name [String] the name of the tool
18
22
  # @param description [String] the description of the tool
19
- # @param schema [Hash] the JSON schema for the tool
23
+ # @param schema [Hash] the JSON schema for the tool inputs
24
+ # @param output_schema [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
25
+ # @param annotations [Hash, nil] optional annotations describing tool behavior
20
26
  # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
21
- def initialize(name:, description:, schema:, server: nil)
27
+ def initialize(name:, description:, schema:, output_schema: nil, annotations: nil, server: nil)
22
28
  @name = name
23
29
  @description = description
24
30
  @schema = schema
31
+ @output_schema = output_schema
32
+ @annotations = annotations
25
33
  @server = server
26
34
  end
27
35
 
@@ -33,10 +41,14 @@ module MCPClient
33
41
  # Some servers (Playwright MCP CLI) use 'inputSchema' instead of 'schema'
34
42
  # Handle both string and symbol keys
35
43
  schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
44
+ output_schema = data['outputSchema'] || data[:outputSchema]
45
+ annotations = data['annotations'] || data[:annotations]
36
46
  new(
37
47
  name: data['name'] || data[:name],
38
48
  description: data['description'] || data[:description],
39
49
  schema: schema,
50
+ output_schema: output_schema,
51
+ annotations: annotations,
40
52
  server: server
41
53
  )
42
54
  end
@@ -74,6 +86,30 @@ module MCPClient
74
86
  }
75
87
  end
76
88
 
89
+ # Check if the tool is marked as read-only
90
+ # @return [Boolean] true if the tool is read-only
91
+ def read_only?
92
+ @annotations && @annotations['readOnly'] == true
93
+ end
94
+
95
+ # Check if the tool is marked as destructive
96
+ # @return [Boolean] true if the tool is destructive
97
+ def destructive?
98
+ @annotations && @annotations['destructive'] == true
99
+ end
100
+
101
+ # Check if the tool requires confirmation before execution
102
+ # @return [Boolean] true if the tool requires confirmation
103
+ def requires_confirmation?
104
+ @annotations && @annotations['requiresConfirmation'] == true
105
+ end
106
+
107
+ # Check if the tool supports structured outputs (MCP 2025-06-18)
108
+ # @return [Boolean] true if the tool has an output schema defined
109
+ def structured_output?
110
+ !@output_schema.nil? && !@output_schema.empty?
111
+ end
112
+
77
113
  private
78
114
 
79
115
  # Recursively remove "$schema" keys that are not accepted by Vertex AI
@@ -2,8 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.8.1'
5
+ VERSION = '0.9.1'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
- PROTOCOL_VERSION = '2025-03-26'
8
+ PROTOCOL_VERSION = '2025-06-18'
9
9
  end