ruby-mcp-client 1.1.0 → 2.0.0

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.
@@ -111,6 +111,8 @@ module MCPClient
111
111
  @http_conn = nil
112
112
  @session_id = nil
113
113
  @last_event_id = nil
114
+ @sse_retry_ms = nil
115
+ @pending_stream_responses = {}
114
116
  @oauth_provider = opts[:oauth_provider]
115
117
 
116
118
  # SSE events connection state
@@ -196,11 +198,7 @@ module MCPClient
196
198
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
197
199
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
198
200
  def call_tool(tool_name, parameters)
199
- rpc_request('tools/call', {
200
- name: tool_name,
201
- arguments: parameters.except(:_meta),
202
- **parameters.slice(:_meta)
203
- })
201
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
204
202
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
205
203
  # Re-raise connection/transport errors directly to match test expectations
206
204
  raise
@@ -226,11 +224,14 @@ module MCPClient
226
224
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
227
225
  # @raise [MCPClient::Errors::ServerError] if server returns an error
228
226
  def complete(ref:, argument:, context: nil)
227
+ ensure_connected
228
+ require_capability!('completions', method: 'completion/complete')
229
229
  params = { ref: ref, argument: argument }
230
230
  params[:context] = context if context
231
231
  result = rpc_request('completion/complete', params)
232
232
  result['completion'] || { 'values' => [] }
233
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
233
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
234
+ MCPClient::Errors::CapabilityError
234
235
  raise
235
236
  rescue StandardError => e
236
237
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
@@ -242,8 +243,11 @@ module MCPClient
242
243
  # @return [Hash] empty result on success
243
244
  # @raise [MCPClient::Errors::ServerError] if server returns an error
244
245
  def log_level=(level)
246
+ ensure_connected
247
+ require_capability!('logging', method: 'logging/setLevel')
245
248
  rpc_request('logging/setLevel', { level: level })
246
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
249
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
250
+ MCPClient::Errors::CapabilityError
247
251
  raise
248
252
  rescue StandardError => e
249
253
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
@@ -282,11 +286,7 @@ module MCPClient
282
286
  # @return [Object] the result of the prompt (with string keys for backward compatibility)
283
287
  # @raise [MCPClient::Errors::PromptGetError] if prompt retrieval fails
284
288
  def get_prompt(prompt_name, parameters)
285
- rpc_request('prompts/get', {
286
- name: prompt_name,
287
- arguments: parameters.except(:_meta),
288
- **parameters.slice(:_meta)
289
- })
289
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
290
290
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
291
291
  # Re-raise connection/transport errors directly
292
292
  raise
@@ -371,9 +371,12 @@ module MCPClient
371
371
  # @return [Boolean] true if subscription successful
372
372
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
373
373
  def subscribe_resource(uri)
374
+ ensure_connected
375
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
374
376
  rpc_request('resources/subscribe', { uri: uri })
375
377
  true
376
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
378
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
379
+ MCPClient::Errors::CapabilityError
377
380
  raise
378
381
  rescue StandardError => e
379
382
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
@@ -384,9 +387,12 @@ module MCPClient
384
387
  # @return [Boolean] true if unsubscription successful
385
388
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
386
389
  def unsubscribe_resource(uri)
390
+ ensure_connected
391
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
387
392
  rpc_request('resources/unsubscribe', { uri: uri })
388
393
  true
389
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
394
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
395
+ MCPClient::Errors::CapabilityError
390
396
  raise
391
397
  rescue StandardError => e
392
398
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
@@ -397,23 +403,20 @@ module MCPClient
397
403
  super
398
404
 
399
405
  # Add session and protocol version headers for non-initialize requests
400
- if request['method'] != 'initialize'
401
- if @session_id
402
- req.headers['Mcp-Session-Id'] = @session_id
403
- @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
404
- end
406
+ return unless request['method'] != 'initialize'
405
407
 
406
- if @protocol_version
407
- req.headers['Mcp-Protocol-Version'] = @protocol_version
408
- @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
409
- end
408
+ if @session_id
409
+ req.headers['Mcp-Session-Id'] = @session_id
410
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
410
411
  end
411
412
 
412
- # Add Last-Event-ID header for resumability (if available)
413
- return unless @last_event_id
413
+ return unless @protocol_version
414
414
 
415
- req.headers['Last-Event-ID'] = @last_event_id
416
- @logger.debug("Adding Last-Event-ID header: #{@last_event_id}")
415
+ req.headers['Mcp-Protocol-Version'] = @protocol_version
416
+ @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
417
+
418
+ # NOTE: Last-Event-ID is deliberately NOT sent on POSTs — per SEP-1699,
419
+ # resumption is always via HTTP GET with Last-Event-ID.
417
420
  end
418
421
 
419
422
  # Override handle_successful_response to capture session ID
@@ -478,6 +481,9 @@ module MCPClient
478
481
  @events_connection = nil
479
482
  @session_id = nil
480
483
  @last_event_id = nil
484
+ @sse_retry_ms = nil
485
+ @pending_stream_responses.each_value(&:close)
486
+ @pending_stream_responses.clear
481
487
 
482
488
  # Clear cached data
483
489
  @tools = nil
@@ -688,7 +694,7 @@ module MCPClient
688
694
  break unless @mutex.synchronize { @connection_established }
689
695
 
690
696
  @logger.info('Events connection closed, reconnecting...')
691
- sleep reconnect_delay
697
+ sleep events_reconnect_delay(reconnect_delay)
692
698
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
693
699
 
694
700
  # Intentional shutdown
@@ -697,31 +703,172 @@ module MCPClient
697
703
  break unless @mutex.synchronize { @connection_established }
698
704
 
699
705
  @logger.debug('Events connection timed out after inactivity, reconnecting...')
700
- sleep reconnect_delay
706
+ sleep events_reconnect_delay(reconnect_delay)
701
707
  rescue Faraday::ConnectionFailed => e
702
708
  break unless @mutex.synchronize { @connection_established }
703
709
 
704
710
  @logger.warn("Events connection failed: #{e.message}, retrying in #{reconnect_delay}s...")
705
- sleep reconnect_delay
711
+ sleep events_reconnect_delay(reconnect_delay)
706
712
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
707
713
  rescue StandardError => e
708
714
  break unless @mutex.synchronize { @connection_established }
709
715
 
710
716
  @logger.error("Unexpected error in events connection: #{e.class} - #{e.message}")
711
717
  @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
712
- sleep reconnect_delay
718
+ sleep events_reconnect_delay(reconnect_delay)
713
719
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
714
720
  end
715
721
  ensure
716
722
  @logger.info('Events connection thread terminated')
717
723
  end
718
724
 
725
+ # Reconnect delay for the events stream: the server's SSE retry directive
726
+ # (in ms) when present, otherwise the caller's backoff value.
727
+ # @param fallback_seconds [Numeric] exponential-backoff fallback
728
+ # @return [Numeric] delay in seconds
729
+ def events_reconnect_delay(fallback_seconds)
730
+ retry_ms = @sse_retry_ms
731
+ retry_ms ? retry_ms / 1000.0 : fallback_seconds
732
+ end
733
+
734
+ # Wait for a response replayed after the POST stream was closed before
735
+ # delivering it (SEP-1699 polling pattern). A dedicated worker issues
736
+ # HTTP GETs carrying the disconnected stream's Last-Event-ID cursor — the
737
+ # general events stream (opened without that cursor) cannot receive the
738
+ # replay.
739
+ # @param request_id [Integer, String] id of the outstanding request
740
+ # @param cursor [String] last event id received on the closed stream
741
+ # @param retry_ms [Integer, nil] retry directive received on the closed
742
+ # stream itself (not the shared events-stream directive)
743
+ # @return [Hash, nil] the replayed JSON-RPC response, or nil on timeout
744
+ def resume_response_via_get(request_id, cursor, retry_ms = nil)
745
+ queue = Thread::Queue.new
746
+ @mutex.synchronize { @pending_stream_responses[request_id] = queue }
747
+
748
+ resume_thread = Thread.new { run_resumption_loop(request_id, cursor, retry_ms) }
749
+ begin
750
+ queue.pop(timeout: @read_timeout)
751
+ ensure
752
+ @mutex.synchronize { @pending_stream_responses.delete(request_id) }
753
+ resume_thread.kill
754
+ resume_thread.join(1)
755
+ end
756
+ end
757
+
758
+ # Reconnecting worker for SEP-1699 resumption. The server MAY close a
759
+ # resumed stream again before returning the response (polling pattern),
760
+ # so each iteration issues a GET with the CURRENT cursor until the
761
+ # overall read timeout elapses or the waiter has been served. `id:`
762
+ # fields received on the resumed stream advance the cursor and `retry:`
763
+ # fields update the delay honored before the next reconnect (zero is a
764
+ # valid immediate-reconnect directive).
765
+ # @param request_id [Integer, String] id of the outstanding request
766
+ # @param cursor [String] Last-Event-ID cursor from the closed stream
767
+ # @param retry_ms [Integer, nil] retry directive from the closed stream
768
+ # @return [void]
769
+ def run_resumption_loop(request_id, cursor, retry_ms)
770
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
771
+ state = { cursor: cursor, retry_ms: retry_ms }
772
+ # SEP-1699: the client MUST respect the server's retry directive before
773
+ # attempting to reconnect.
774
+ delay = retry_ms ? retry_ms / 1000.0 : 0
775
+
776
+ loop do
777
+ sleep(delay) if delay.positive?
778
+ issue_resumption_get(state)
779
+ break unless @mutex.synchronize { @pending_stream_responses.key?(request_id) }
780
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
781
+
782
+ delay = state[:retry_ms] ? state[:retry_ms] / 1000.0 : SSE_RECONNECT_DELAY
783
+ end
784
+ end
785
+
786
+ # One GET with the current cursor; complete SSE events are dispatched
787
+ # through the standard server-message path, which routes replayed
788
+ # responses to their registered waiters. `id:` and `retry:` fields
789
+ # received on this stream update the resumption state live.
790
+ # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
791
+ # @return [void]
792
+ def issue_resumption_get(state)
793
+ conn = Faraday.new(url: @base_url) do |f|
794
+ f.options.open_timeout = 10
795
+ f.options.timeout = @read_timeout
796
+ f.adapter :net_http
797
+ end
798
+
799
+ buffer = +''
800
+ conn.get(@endpoint) do |req|
801
+ apply_events_headers(req)
802
+ req.headers['Last-Event-ID'] = state[:cursor]
803
+ req.options.on_data = proc do |chunk, _bytes|
804
+ buffer << chunk
805
+ process_resumption_buffer(buffer, state)
806
+ end
807
+ end
808
+ rescue StandardError => e
809
+ @logger.debug("Resumption GET failed: #{e.message}")
810
+ end
811
+
812
+ # Extract complete SSE events (terminated by a blank line, LF or CRLF)
813
+ # from the resumption buffer and handle each of them.
814
+ # @param buffer [String] mutable stream buffer
815
+ # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
816
+ # @return [void]
817
+ def process_resumption_buffer(buffer, state)
818
+ while (separator = buffer.match(/\r\n\r\n|\n\n/))
819
+ event_text = buffer.slice!(0, separator.end(0))
820
+ handle_resumption_event(event_text, state)
821
+ end
822
+ end
823
+
824
+ # Parse one SSE event received on a resumption stream: track `id:` lines
825
+ # (advancing the cursor used by the next reconnect) and `retry:` lines
826
+ # (updating the reconnect delay; zero is valid), then dispatch any data.
827
+ # @param event_text [String] one complete SSE event, separator included
828
+ # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
829
+ # @return [void]
830
+ def handle_resumption_event(event_text, state)
831
+ data_lines = []
832
+ event_text.each_line do |raw_line|
833
+ line = raw_line.chomp
834
+ if line.start_with?('data:')
835
+ data_lines << line.sub(/\Adata:\s*/, '')
836
+ elsif line.start_with?('id:')
837
+ id = line.sub(/\Aid:\s*/, '').strip
838
+ state[:cursor] = id unless id.empty?
839
+ elsif line.start_with?('retry:')
840
+ raw = line.sub(/\Aretry:\s*/, '').strip
841
+ state[:retry_ms] = raw.to_i if raw.match?(/\A\d+\z/)
842
+ end
843
+ end
844
+ handle_server_message(data_lines.join("\n")) unless data_lines.empty?
845
+ end
846
+
847
+ # Deliver a response replayed on the events stream to a waiting request.
848
+ # The pending entry is removed on delivery so resumption workers can see
849
+ # that their waiter has been served.
850
+ # @param message [Hash] a JSON-RPC response message
851
+ # @return [void]
852
+ def deliver_stream_response(message)
853
+ queue = @mutex.synchronize do
854
+ key = [message['id'], message['id'].to_s].find { |k| @pending_stream_responses.key?(k) }
855
+ @pending_stream_responses.delete(key) unless key.nil?
856
+ end
857
+ queue << message if queue
858
+ end
859
+
719
860
  # Apply headers for events connection
720
861
  # @param req [Faraday::Request] HTTP request
721
862
  def apply_events_headers(req)
722
863
  @headers.each { |k, v| req.headers[k] = v }
723
864
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
724
865
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
866
+ # MCP: authorization MUST be included in every HTTP request
867
+ @oauth_provider&.apply_authorization(req)
868
+ # SEP-1699: resumption is via GET with the Last-Event-ID cursor, so the
869
+ # server can replay messages missed since the last received event.
870
+ last_event_id = @mutex.synchronize { @last_event_id }
871
+ req.headers['Last-Event-ID'] = last_event_id if last_event_id
725
872
  end
726
873
 
727
874
  # Process event chunks from the server
@@ -779,9 +926,11 @@ module MCPClient
779
926
  event[:id] = line[3..].strip
780
927
  @last_event_id = event[:id]
781
928
  elsif line.start_with?('retry:')
782
- # Server can suggest reconnection delay (in milliseconds)
783
- retry_ms = line[6..].strip.to_i
784
- @logger.debug("Server suggested retry delay: #{retry_ms}ms") if @logger.level <= Logger::DEBUG
929
+ # SEP-1699: the client MUST respect the server's retry directive
930
+ # (milliseconds) when reconnecting; zero is a valid directive.
931
+ raw = line[6..].strip
932
+ @sse_retry_ms = raw.to_i if raw.match?(/\A\d+\z/)
933
+ @logger.debug("Server suggested retry delay: #{raw}ms") if @logger.level <= Logger::DEBUG
785
934
  end
786
935
  end
787
936
 
@@ -798,24 +947,34 @@ module MCPClient
798
947
  return if data.empty?
799
948
 
800
949
  begin
801
- message = JSON.parse(data)
802
-
803
- # Handle ping requests from server (keepalive mechanism)
804
- if message['method'] == 'ping' && message.key?('id')
805
- handle_ping_request(message['id'])
806
- elsif message['method'] && message.key?('id')
807
- # Handle server-to-client requests (MCP 2025-06-18)
808
- handle_server_request(message)
809
- elsif message['method'] && !message.key?('id')
810
- # Handle server notifications (messages without id)
811
- @notification_callback&.call(message['method'], message['params'])
812
- end
950
+ dispatch_server_message(JSON.parse(data))
813
951
  rescue JSON::ParserError => e
814
952
  @logger.error("Invalid JSON in server message: #{e.message}")
815
953
  @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
816
954
  end
817
955
  end
818
956
 
957
+ # Dispatch a parsed server message (request, ping, or notification).
958
+ # Used for messages arriving on the GET events stream and for messages
959
+ # interleaved on a POST SSE response stream.
960
+ # @param message [Hash] the parsed JSON-RPC message
961
+ def dispatch_server_message(message)
962
+ # Handle ping requests from server (keepalive mechanism)
963
+ if message['method'] == 'ping' && message.key?('id')
964
+ handle_ping_request(message['id'])
965
+ elsif message['method'] && message.key?('id')
966
+ # Handle server-to-client requests (MCP 2025-06-18)
967
+ handle_server_request(message)
968
+ elsif message['method'] && !message.key?('id')
969
+ # Handle server notifications (messages without id)
970
+ @notification_callback&.call(message['method'], message['params'])
971
+ elsif message.key?('id')
972
+ # A response replayed on the events stream after its POST stream was
973
+ # closed before delivery (SEP-1699 resumption)
974
+ deliver_stream_response(message)
975
+ end
976
+ end
977
+
819
978
  # Handle ping request from server
820
979
  # Sends pong response to maintain session keepalive
821
980
  # @param ping_id [Integer, String] the ping request ID
@@ -833,6 +992,8 @@ module MCPClient
833
992
  @headers.each { |k, v| req.headers[k] = v }
834
993
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
835
994
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
995
+ # MCP: authorization MUST be included in every HTTP request
996
+ @oauth_provider&.apply_authorization(req)
836
997
  req.body = pong_response.to_json
837
998
  end
838
999
 
@@ -872,26 +1033,24 @@ module MCPClient
872
1033
  send_error_response(request_id, -32_603, "Internal error: #{e.message}")
873
1034
  end
874
1035
 
875
- # Handle elicitation/create request from server (MCP 2025-06-18)
876
- # @param request_id [String, Integer] the JSON-RPC request ID (used as elicitationId)
1036
+ # Handle elicitation/create request from server (MCP 2025-11-25)
1037
+ # @param request_id [String, Integer] the JSON-RPC request ID
877
1038
  # @param params [Hash] the elicitation parameters
878
1039
  # @return [void]
879
1040
  def handle_elicitation_create(request_id, params)
880
- # The request_id is the elicitationId per MCP spec
881
- elicitation_id = request_id
882
-
883
- # If no callback is registered, decline the request
1041
+ # Without a callback there is no user to interact with: answer with a
1042
+ # JSON-RPC error rather than fabricating a user "decline".
884
1043
  unless @elicitation_request_callback
885
- @logger.warn('Received elicitation request but no callback registered, declining')
886
- send_elicitation_response(elicitation_id, { 'action' => 'decline' })
1044
+ @logger.warn('Received elicitation request but no callback registered')
1045
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
887
1046
  return
888
1047
  end
889
1048
 
890
1049
  # Call the registered callback
891
1050
  result = @elicitation_request_callback.call(request_id, params)
892
1051
 
893
- # Send the response back to the server
894
- send_elicitation_response(elicitation_id, result)
1052
+ # Send the response back to the server (echoing related-task _meta)
1053
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
895
1054
  end
896
1055
 
897
1056
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -909,8 +1068,8 @@ module MCPClient
909
1068
  # Call the registered callback
910
1069
  result = @roots_list_request_callback.call(request_id, params)
911
1070
 
912
- # Send the response back to the server
913
- send_roots_list_response(request_id, result)
1071
+ # Send the response back to the server (echoing related-task _meta)
1072
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
914
1073
  end
915
1074
 
916
1075
  # Handle sampling/createMessage request from server (MCP 2025-11-25)
@@ -928,8 +1087,8 @@ module MCPClient
928
1087
  # Call the registered callback
929
1088
  result = @sampling_request_callback.call(request_id, params)
930
1089
 
931
- # Send the response back to the server
932
- send_sampling_response(request_id, result)
1090
+ # Send the response back to the server (echoing related-task _meta)
1091
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
933
1092
  end
934
1093
 
935
1094
  # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
@@ -972,29 +1131,29 @@ module MCPClient
972
1131
  @logger.error("Error sending sampling response: #{e.message}")
973
1132
  end
974
1133
 
975
- # Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
976
- # For streamable HTTP, this is sent as a JSON-RPC request (not response)
977
- # because HTTP is unidirectional.
978
- # @param elicitation_id [String] the elicitation ID from the server
1134
+ # Send elicitation response back to server via HTTP POST (MCP 2025-11-25)
1135
+ # The reply to a server's elicitation/create request is a standard
1136
+ # JSON-RPC response echoing the request id, POSTed like every other
1137
+ # response on this transport.
1138
+ # @param request_id [String, Integer] the JSON-RPC request ID
979
1139
  # @param result [Hash] the elicitation result (action and optional content)
980
1140
  # @return [void]
981
- def send_elicitation_response(elicitation_id, result)
982
- params = {
983
- 'elicitationId' => elicitation_id,
984
- 'action' => result['action']
985
- }
986
-
987
- # Only include content if present (typically for 'accept' action)
988
- params['content'] = result['content'] if result['content']
1141
+ def send_elicitation_response(request_id, result)
1142
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
1143
+ # an undeclared elicitation mode), mirroring the sampling error path.
1144
+ if result.is_a?(Hash) && result['error']
1145
+ send_error_response(request_id, result['error']['code'] || -32_603,
1146
+ result['error']['message'] || 'Elicitation error')
1147
+ return
1148
+ end
989
1149
 
990
- request = {
1150
+ response = {
991
1151
  'jsonrpc' => '2.0',
992
- 'method' => 'elicitation/response',
993
- 'params' => params
1152
+ 'id' => request_id,
1153
+ 'result' => result
994
1154
  }
995
1155
 
996
- # Send as a JSON-RPC request via HTTP POST
997
- post_jsonrpc_response(request)
1156
+ post_jsonrpc_response(response)
998
1157
  rescue StandardError => e
999
1158
  @logger.error("Error sending elicitation response: #{e.message}")
1000
1159
  end
@@ -1034,6 +1193,8 @@ module MCPClient
1034
1193
  @headers.each { |k, v| req.headers[k] = v }
1035
1194
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
1036
1195
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
1196
+ # MCP: authorization MUST be included in every HTTP request
1197
+ @oauth_provider&.apply_authorization(req)
1037
1198
  req.body = json_body
1038
1199
  end
1039
1200
 
@@ -20,7 +20,12 @@ module MCPClient
20
20
  # @!attribute [r] task_support
21
21
  # @return [String, nil] tool-level task negotiation (MCP 2025-11-25):
22
22
  # 'forbidden' (default), 'optional', or 'required'; nil when not advertised
23
- attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server, :task_support
23
+ # @!attribute [r] icons
24
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
25
+ # @!attribute [r] meta
26
+ # @return [Hash, nil] optional `_meta` metadata attached to the tool (MCP 2025-11-25)
27
+ attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server, :task_support,
28
+ :icons, :meta
24
29
 
25
30
  # Initialize a new Tool
26
31
  # @param name [String] the name of the tool
@@ -31,8 +36,11 @@ module MCPClient
31
36
  # @param annotations [Hash, nil] optional annotations describing tool behavior
32
37
  # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
33
38
  # @param task_support [String, nil] execution.taskSupport value (MCP 2025-11-25)
39
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
40
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the tool (MCP 2025-11-25)
41
+ # rubocop:disable Metrics/ParameterLists
34
42
  def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil,
35
- task_support: nil)
43
+ task_support: nil, icons: nil, meta: nil)
36
44
  @name = name
37
45
  @title = title
38
46
  @description = description
@@ -41,7 +49,10 @@ module MCPClient
41
49
  @annotations = annotations
42
50
  @server = server
43
51
  @task_support = task_support
52
+ @icons = icons
53
+ @meta = meta
44
54
  end
55
+ # rubocop:enable Metrics/ParameterLists
45
56
 
46
57
  # Create a Tool instance from JSON data
47
58
  # @param data [Hash] JSON data from MCP server
@@ -56,6 +67,8 @@ module MCPClient
56
67
  title = data['title'] || data[:title]
57
68
  execution = data['execution'] || data[:execution]
58
69
  task_support = execution && (execution['taskSupport'] || execution[:taskSupport])
70
+ icons = data['icons'] || data[:icons]
71
+ meta = data['_meta'] || data[:_meta]
59
72
  new(
60
73
  name: data['name'] || data[:name],
61
74
  description: data['description'] || data[:description],
@@ -64,7 +77,9 @@ module MCPClient
64
77
  output_schema: output_schema,
65
78
  annotations: annotations,
66
79
  server: server,
67
- task_support: task_support
80
+ task_support: task_support,
81
+ icons: icons,
82
+ meta: meta
68
83
  )
69
84
  end
70
85
 
@@ -2,8 +2,13 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '1.1.0'
5
+ VERSION = '2.0.0'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
8
  PROTOCOL_VERSION = '2025-11-25'
9
+
10
+ # Protocol revisions this client can speak, newest first. Used during
11
+ # version negotiation: if the server answers initialize with a version
12
+ # outside this set, the client must disconnect (MCP lifecycle).
13
+ SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
9
14
  end
data/lib/mcp_client.rb CHANGED
@@ -11,6 +11,7 @@ require_relative 'mcp_client/audio_content'
11
11
  require_relative 'mcp_client/resource_link'
12
12
  require_relative 'mcp_client/root'
13
13
  require_relative 'mcp_client/elicitation_validator'
14
+ require_relative 'mcp_client/schema_validator'
14
15
  require_relative 'mcp_client/task'
15
16
  require_relative 'mcp_client/server_base'
16
17
  require_relative 'mcp_client/server_stdio'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mcp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Szymon Kurcab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-04 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -150,6 +150,7 @@ files:
150
150
  - lib/mcp_client/resource_link.rb
151
151
  - lib/mcp_client/resource_template.rb
152
152
  - lib/mcp_client/root.rb
153
+ - lib/mcp_client/schema_validator.rb
153
154
  - lib/mcp_client/server_base.rb
154
155
  - lib/mcp_client/server_factory.rb
155
156
  - lib/mcp_client/server_http.rb