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.
- checksums.yaml +4 -4
- data/README.md +121 -3
- data/lib/mcp_client/auth/oauth_provider.rb +236 -26
- data/lib/mcp_client/auth.rb +40 -9
- data/lib/mcp_client/client.rb +417 -139
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +35 -1
- data/lib/mcp_client/http_transport_base.rb +227 -37
- data/lib/mcp_client/json_rpc_common.rb +126 -13
- 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 +66 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +63 -8
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +10 -3
- data/lib/mcp_client/server_sse/sse_parser.rb +46 -9
- data/lib/mcp_client/server_sse.rb +49 -21
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +29 -6
- data/lib/mcp_client/server_stdio.rb +92 -17
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +238 -77
- data/lib/mcp_client/tool.rb +18 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +3 -2
|
@@ -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
|
-
return unless @last_event_id
|
|
413
|
+
return unless @protocol_version
|
|
414
414
|
|
|
415
|
-
req.headers['
|
|
416
|
-
@logger.debug("Adding
|
|
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
|
-
#
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
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-
|
|
876
|
-
# @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
|
|
877
1038
|
# @param params [Hash] the elicitation parameters
|
|
878
1039
|
# @return [void]
|
|
879
1040
|
def handle_elicitation_create(request_id, params)
|
|
880
|
-
#
|
|
881
|
-
|
|
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
|
|
886
|
-
|
|
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(
|
|
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-
|
|
976
|
-
#
|
|
977
|
-
#
|
|
978
|
-
#
|
|
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(
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
1150
|
+
response = {
|
|
991
1151
|
'jsonrpc' => '2.0',
|
|
992
|
-
'
|
|
993
|
-
'
|
|
1152
|
+
'id' => request_id,
|
|
1153
|
+
'result' => result
|
|
994
1154
|
}
|
|
995
1155
|
|
|
996
|
-
|
|
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
|
|
data/lib/mcp_client/tool.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/mcp_client/version.rb
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module MCPClient
|
|
4
4
|
# Current version of the MCP client gem
|
|
5
|
-
VERSION = '
|
|
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:
|
|
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-
|
|
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
|