ruby-mcp-client 1.0.1 → 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
+
415
+ req.headers['Mcp-Protocol-Version'] = @protocol_version
416
+ @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
414
417
 
415
- req.headers['Last-Event-ID'] = @last_event_id
416
- @logger.debug("Adding Last-Event-ID header: #{@last_event_id}")
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
@@ -575,24 +581,15 @@ module MCPClient
575
581
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
576
582
  def request_tools_list
577
583
  @mutex.synchronize do
578
- return @tools_data if @tools_data
584
+ return @tools_data.dup if @tools_data
579
585
  end
580
586
 
581
- result = rpc_request('tools/list')
587
+ # Follow nextCursor across pages so the full tool list is returned even
588
+ # when the server paginates.
589
+ tools = request_paginated_list('tools/list', 'tools')
582
590
 
583
- if result.is_a?(Hash) && result['tools']
584
- @mutex.synchronize do
585
- @tools_data = result['tools']
586
- end
587
- return @mutex.synchronize { @tools_data.dup }
588
- elsif result.is_a?(Array) || result
589
- @mutex.synchronize do
590
- @tools_data = result
591
- end
592
- return @mutex.synchronize { @tools_data.dup }
593
- end
594
-
595
- raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
591
+ @mutex.synchronize { @tools_data = tools }
592
+ @mutex.synchronize { @tools_data.dup }
596
593
  end
597
594
 
598
595
  # Request the prompts list using JSON-RPC
@@ -600,24 +597,14 @@ module MCPClient
600
597
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
601
598
  def request_prompts_list
602
599
  @mutex.synchronize do
603
- return @prompts_data if @prompts_data
600
+ return @prompts_data.dup if @prompts_data
604
601
  end
605
602
 
606
- result = rpc_request('prompts/list')
603
+ # Follow nextCursor across pages so the full prompt list is returned.
604
+ prompts = request_paginated_list('prompts/list', 'prompts')
607
605
 
608
- if result.is_a?(Hash) && result['prompts']
609
- @mutex.synchronize do
610
- @prompts_data = result['prompts']
611
- end
612
- return @mutex.synchronize { @prompts_data.dup }
613
- elsif result.is_a?(Array) || result
614
- @mutex.synchronize do
615
- @prompts_data = result
616
- end
617
- return @mutex.synchronize { @prompts_data.dup }
618
- end
619
-
620
- raise MCPClient::Errors::PromptGetError, 'Failed to get prompts list from JSON-RPC request'
606
+ @mutex.synchronize { @prompts_data = prompts }
607
+ @mutex.synchronize { @prompts_data.dup }
621
608
  end
622
609
 
623
610
  # Request the resources list using JSON-RPC
@@ -707,7 +694,7 @@ module MCPClient
707
694
  break unless @mutex.synchronize { @connection_established }
708
695
 
709
696
  @logger.info('Events connection closed, reconnecting...')
710
- sleep reconnect_delay
697
+ sleep events_reconnect_delay(reconnect_delay)
711
698
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
712
699
 
713
700
  # Intentional shutdown
@@ -716,31 +703,172 @@ module MCPClient
716
703
  break unless @mutex.synchronize { @connection_established }
717
704
 
718
705
  @logger.debug('Events connection timed out after inactivity, reconnecting...')
719
- sleep reconnect_delay
706
+ sleep events_reconnect_delay(reconnect_delay)
720
707
  rescue Faraday::ConnectionFailed => e
721
708
  break unless @mutex.synchronize { @connection_established }
722
709
 
723
710
  @logger.warn("Events connection failed: #{e.message}, retrying in #{reconnect_delay}s...")
724
- sleep reconnect_delay
711
+ sleep events_reconnect_delay(reconnect_delay)
725
712
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
726
713
  rescue StandardError => e
727
714
  break unless @mutex.synchronize { @connection_established }
728
715
 
729
716
  @logger.error("Unexpected error in events connection: #{e.class} - #{e.message}")
730
717
  @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
731
- sleep reconnect_delay
718
+ sleep events_reconnect_delay(reconnect_delay)
732
719
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
733
720
  end
734
721
  ensure
735
722
  @logger.info('Events connection thread terminated')
736
723
  end
737
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
+
738
860
  # Apply headers for events connection
739
861
  # @param req [Faraday::Request] HTTP request
740
862
  def apply_events_headers(req)
741
863
  @headers.each { |k, v| req.headers[k] = v }
742
864
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
743
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
744
872
  end
745
873
 
746
874
  # Process event chunks from the server
@@ -798,9 +926,11 @@ module MCPClient
798
926
  event[:id] = line[3..].strip
799
927
  @last_event_id = event[:id]
800
928
  elsif line.start_with?('retry:')
801
- # Server can suggest reconnection delay (in milliseconds)
802
- retry_ms = line[6..].strip.to_i
803
- @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
804
934
  end
805
935
  end
806
936
 
@@ -817,24 +947,34 @@ module MCPClient
817
947
  return if data.empty?
818
948
 
819
949
  begin
820
- message = JSON.parse(data)
821
-
822
- # Handle ping requests from server (keepalive mechanism)
823
- if message['method'] == 'ping' && message.key?('id')
824
- handle_ping_request(message['id'])
825
- elsif message['method'] && message.key?('id')
826
- # Handle server-to-client requests (MCP 2025-06-18)
827
- handle_server_request(message)
828
- elsif message['method'] && !message.key?('id')
829
- # Handle server notifications (messages without id)
830
- @notification_callback&.call(message['method'], message['params'])
831
- end
950
+ dispatch_server_message(JSON.parse(data))
832
951
  rescue JSON::ParserError => e
833
952
  @logger.error("Invalid JSON in server message: #{e.message}")
834
953
  @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
835
954
  end
836
955
  end
837
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
+
838
978
  # Handle ping request from server
839
979
  # Sends pong response to maintain session keepalive
840
980
  # @param ping_id [Integer, String] the ping request ID
@@ -852,6 +992,8 @@ module MCPClient
852
992
  @headers.each { |k, v| req.headers[k] = v }
853
993
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
854
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)
855
997
  req.body = pong_response.to_json
856
998
  end
857
999
 
@@ -891,26 +1033,24 @@ module MCPClient
891
1033
  send_error_response(request_id, -32_603, "Internal error: #{e.message}")
892
1034
  end
893
1035
 
894
- # Handle elicitation/create request from server (MCP 2025-06-18)
895
- # @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
896
1038
  # @param params [Hash] the elicitation parameters
897
1039
  # @return [void]
898
1040
  def handle_elicitation_create(request_id, params)
899
- # The request_id is the elicitationId per MCP spec
900
- elicitation_id = request_id
901
-
902
- # 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".
903
1043
  unless @elicitation_request_callback
904
- @logger.warn('Received elicitation request but no callback registered, declining')
905
- 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')
906
1046
  return
907
1047
  end
908
1048
 
909
1049
  # Call the registered callback
910
1050
  result = @elicitation_request_callback.call(request_id, params)
911
1051
 
912
- # Send the response back to the server
913
- 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))
914
1054
  end
915
1055
 
916
1056
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -928,8 +1068,8 @@ module MCPClient
928
1068
  # Call the registered callback
929
1069
  result = @roots_list_request_callback.call(request_id, params)
930
1070
 
931
- # Send the response back to the server
932
- 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))
933
1073
  end
934
1074
 
935
1075
  # Handle sampling/createMessage request from server (MCP 2025-11-25)
@@ -947,8 +1087,8 @@ module MCPClient
947
1087
  # Call the registered callback
948
1088
  result = @sampling_request_callback.call(request_id, params)
949
1089
 
950
- # Send the response back to the server
951
- 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))
952
1092
  end
953
1093
 
954
1094
  # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
@@ -991,29 +1131,29 @@ module MCPClient
991
1131
  @logger.error("Error sending sampling response: #{e.message}")
992
1132
  end
993
1133
 
994
- # Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
995
- # For streamable HTTP, this is sent as a JSON-RPC request (not response)
996
- # because HTTP is unidirectional.
997
- # @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
998
1139
  # @param result [Hash] the elicitation result (action and optional content)
999
1140
  # @return [void]
1000
- def send_elicitation_response(elicitation_id, result)
1001
- params = {
1002
- 'elicitationId' => elicitation_id,
1003
- 'action' => result['action']
1004
- }
1005
-
1006
- # Only include content if present (typically for 'accept' action)
1007
- 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
1008
1149
 
1009
- request = {
1150
+ response = {
1010
1151
  'jsonrpc' => '2.0',
1011
- 'method' => 'elicitation/response',
1012
- 'params' => params
1152
+ 'id' => request_id,
1153
+ 'result' => result
1013
1154
  }
1014
1155
 
1015
- # Send as a JSON-RPC request via HTTP POST
1016
- post_jsonrpc_response(request)
1156
+ post_jsonrpc_response(response)
1017
1157
  rescue StandardError => e
1018
1158
  @logger.error("Error sending elicitation response: #{e.message}")
1019
1159
  end
@@ -1053,6 +1193,8 @@ module MCPClient
1053
1193
  @headers.each { |k, v| req.headers[k] = v }
1054
1194
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
1055
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)
1056
1198
  req.body = json_body
1057
1199
  end
1058
1200