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.
- checksums.yaml +4 -4
- data/README.md +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
603
|
+
# Follow nextCursor across pages so the full prompt list is returned.
|
|
604
|
+
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
607
605
|
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
#
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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-
|
|
895
|
-
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
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
|
-
#
|
|
900
|
-
|
|
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
|
|
905
|
-
|
|
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(
|
|
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-
|
|
995
|
-
#
|
|
996
|
-
#
|
|
997
|
-
#
|
|
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(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1150
|
+
response = {
|
|
1010
1151
|
'jsonrpc' => '2.0',
|
|
1011
|
-
'
|
|
1012
|
-
'
|
|
1152
|
+
'id' => request_id,
|
|
1153
|
+
'result' => result
|
|
1013
1154
|
}
|
|
1014
1155
|
|
|
1015
|
-
|
|
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
|
|