ruby-mcp-client 2.0.0 → 2.1.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 +95 -7
- data/lib/mcp_client/auth/oauth_provider.rb +91 -3
- data/lib/mcp_client/auth.rb +0 -4
- data/lib/mcp_client/client.rb +109 -14
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +8 -0
- data/lib/mcp_client/http_transport_base.rb +29 -6
- data/lib/mcp_client/json_rpc_common.rb +70 -1
- data/lib/mcp_client/resource.rb +0 -2
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +0 -2
- data/lib/mcp_client/schema_validator.rb +56 -12
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +34 -6
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +38 -7
- data/lib/mcp_client/server_sse.rb +83 -14
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +2 -2
- data/lib/mcp_client/server_stdio.rb +6 -3
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +77 -7
- data/lib/mcp_client/server_streamable_http.rb +246 -43
- data/lib/mcp_client/tool.rb +0 -2
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +8 -1
- metadata +4 -6
|
@@ -33,6 +33,52 @@ module MCPClient
|
|
|
33
33
|
SSE_MAX_RECONNECT_DELAY = 30 # Maximum reconnect delay in seconds
|
|
34
34
|
THREAD_JOIN_TIMEOUT = 5 # Timeout for thread cleanup
|
|
35
35
|
|
|
36
|
+
# Floor for the delay between resumption GETs. SEP-1699's polling pattern
|
|
37
|
+
# wants fast reconnects, so this is far smaller than the events-stream
|
|
38
|
+
# floor — it only prevents a peer-supplied "retry: 0" from turning the
|
|
39
|
+
# deadline window into a back-to-back request loop.
|
|
40
|
+
MIN_RESUMPTION_RECONNECT_DELAY = 0.01
|
|
41
|
+
|
|
42
|
+
# Maximum length of a server-supplied SSE event id retained as the
|
|
43
|
+
# resumption cursor. The id is echoed in the Last-Event-ID header of
|
|
44
|
+
# subsequent requests, so an unbounded value means unbounded retained
|
|
45
|
+
# memory and oversized outbound headers.
|
|
46
|
+
MAX_EVENT_ID_LENGTH = 1024
|
|
47
|
+
|
|
48
|
+
# Characters allowed in a retained event id: printable ASCII, since the
|
|
49
|
+
# value becomes an HTTP header value. Notably excludes CR/LF. The range
|
|
50
|
+
# starts at 0x20 because a space is legal inside a field value, and
|
|
51
|
+
# rejecting ids like "cursor 42" would silently strand resumption on a
|
|
52
|
+
# stale cursor.
|
|
53
|
+
EVENT_ID_PATTERN = /\A[\x20-\x7E]+\z/
|
|
54
|
+
|
|
55
|
+
# Ceiling on concurrent threads POSTing server-initiated responses
|
|
56
|
+
# (pongs, roots/sampling/elicitation replies, error responses). Each
|
|
57
|
+
# server request on the events stream costs one blocking HTTP POST in
|
|
58
|
+
# its own thread; without a bound, a peer flooding requests could
|
|
59
|
+
# accumulate threads and connections until the host is exhausted.
|
|
60
|
+
# Responses beyond the budget are dropped, with saturation logged at most
|
|
61
|
+
# once per SATURATION_LOG_INTERVAL seconds.
|
|
62
|
+
MAX_CONCURRENT_RESPONSE_POSTS = 8
|
|
63
|
+
|
|
64
|
+
# Minimum gap between "response budget saturated" warnings
|
|
65
|
+
SATURATION_LOG_INTERVAL = 5
|
|
66
|
+
|
|
67
|
+
# Floor for server-supplied retry directives on the long-lived events
|
|
68
|
+
# stream. The directive is peer-controlled: honoring "retry: 0" literally
|
|
69
|
+
# would let a hostile server that closes every stream drive a tight
|
|
70
|
+
# reconnect loop (sustained CPU/TLS/connection churn). Waiting longer
|
|
71
|
+
# than the directive stays SEP-1699 compliant — the retry field is a
|
|
72
|
+
# lower bound on the reconnect delay, not an exact schedule.
|
|
73
|
+
MIN_EVENTS_RECONNECT_DELAY = 0.1
|
|
74
|
+
|
|
75
|
+
# Maximum bytes an SSE parse buffer (events stream or resumption GET) may
|
|
76
|
+
# hold while waiting for an event terminator. The stream is
|
|
77
|
+
# peer-controlled: without a cap, a hostile server could withhold the
|
|
78
|
+
# blank-line delimiter forever and grow the buffer until the host runs
|
|
79
|
+
# out of memory. Generous enough for any legitimate JSON-RPC event.
|
|
80
|
+
MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
|
|
81
|
+
|
|
36
82
|
# @!attribute [r] base_url
|
|
37
83
|
# @return [String] The base URL of the MCP server
|
|
38
84
|
# @!attribute [r] endpoint
|
|
@@ -98,6 +144,7 @@ module MCPClient
|
|
|
98
144
|
|
|
99
145
|
@read_timeout = opts[:read_timeout]
|
|
100
146
|
@faraday_config = opts[:faraday_config]
|
|
147
|
+
@max_decompressed_body_bytes = validate_decompression_limit(opts[:max_decompressed_body_bytes])
|
|
101
148
|
@tools = nil
|
|
102
149
|
@tools_data = nil
|
|
103
150
|
@prompts = nil
|
|
@@ -113,12 +160,18 @@ module MCPClient
|
|
|
113
160
|
@last_event_id = nil
|
|
114
161
|
@sse_retry_ms = nil
|
|
115
162
|
@pending_stream_responses = {}
|
|
163
|
+
@response_post_count = 0
|
|
164
|
+
# Saturation bookkeeping for the response-POST budget
|
|
165
|
+
@dropped_response_posts = 0
|
|
166
|
+
@last_saturation_log_at = nil
|
|
116
167
|
@oauth_provider = opts[:oauth_provider]
|
|
117
168
|
|
|
118
169
|
# SSE events connection state
|
|
119
170
|
@events_connection = nil
|
|
120
171
|
@events_thread = nil
|
|
121
|
-
@buffer = '' # Buffer for partial SSE event data
|
|
172
|
+
@buffer = +'' # Buffer for partial SSE event data
|
|
173
|
+
# How much of @buffer has already been searched for an event terminator
|
|
174
|
+
@buffer_scanned = 0
|
|
122
175
|
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
123
176
|
@roots_list_request_callback = nil # MCP 2025-06-18
|
|
124
177
|
@sampling_request_callback = nil # MCP 2025-11-25
|
|
@@ -492,7 +545,8 @@ module MCPClient
|
|
|
492
545
|
@prompts_data = nil
|
|
493
546
|
@resources = nil
|
|
494
547
|
@resources_data = nil
|
|
495
|
-
@buffer = ''
|
|
548
|
+
@buffer = +''
|
|
549
|
+
@buffer_scanned = 0
|
|
496
550
|
|
|
497
551
|
@logger.info('Cleanup completed')
|
|
498
552
|
end
|
|
@@ -534,6 +588,17 @@ module MCPClient
|
|
|
534
588
|
|
|
535
589
|
# Default options for server initialization
|
|
536
590
|
# @return [Hash] Default options
|
|
591
|
+
# Validate the configured decompression ceiling.
|
|
592
|
+
# @param value [Object] the max_decompressed_body_bytes option
|
|
593
|
+
# @return [Integer] the validated positive byte limit
|
|
594
|
+
# @raise [ArgumentError] if the value is not a positive Integer
|
|
595
|
+
def validate_decompression_limit(value)
|
|
596
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
597
|
+
|
|
598
|
+
raise ArgumentError,
|
|
599
|
+
"max_decompressed_body_bytes must be a positive Integer, got #{value.inspect}"
|
|
600
|
+
end
|
|
601
|
+
|
|
537
602
|
def default_options
|
|
538
603
|
{
|
|
539
604
|
endpoint: '/rpc',
|
|
@@ -544,7 +609,8 @@ module MCPClient
|
|
|
544
609
|
name: nil,
|
|
545
610
|
logger: nil,
|
|
546
611
|
oauth_provider: nil,
|
|
547
|
-
faraday_config: nil
|
|
612
|
+
faraday_config: nil,
|
|
613
|
+
max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
|
|
548
614
|
}
|
|
549
615
|
end
|
|
550
616
|
|
|
@@ -723,12 +789,17 @@ module MCPClient
|
|
|
723
789
|
end
|
|
724
790
|
|
|
725
791
|
# Reconnect delay for the events stream: the server's SSE retry directive
|
|
726
|
-
# (in ms) when present, otherwise the caller's backoff value.
|
|
792
|
+
# (in ms) when present, otherwise the caller's backoff value. The
|
|
793
|
+
# peer-controlled directive is floored at MIN_EVENTS_RECONNECT_DELAY so a
|
|
794
|
+
# zero/near-zero value cannot drive a tight reconnect loop; the
|
|
795
|
+
# deadline-bounded resumption loop intentionally keeps honoring zero.
|
|
727
796
|
# @param fallback_seconds [Numeric] exponential-backoff fallback
|
|
728
797
|
# @return [Numeric] delay in seconds
|
|
729
798
|
def events_reconnect_delay(fallback_seconds)
|
|
730
799
|
retry_ms = @sse_retry_ms
|
|
731
|
-
|
|
800
|
+
return fallback_seconds unless retry_ms
|
|
801
|
+
|
|
802
|
+
[retry_ms / 1000.0, MIN_EVENTS_RECONNECT_DELAY].max
|
|
732
803
|
end
|
|
733
804
|
|
|
734
805
|
# Wait for a response replayed after the POST stream was closed before
|
|
@@ -770,8 +841,11 @@ module MCPClient
|
|
|
770
841
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
|
|
771
842
|
state = { cursor: cursor, retry_ms: retry_ms }
|
|
772
843
|
# SEP-1699: the client MUST respect the server's retry directive before
|
|
773
|
-
# attempting to reconnect.
|
|
774
|
-
|
|
844
|
+
# attempting to reconnect. With no directive the FIRST GET goes out
|
|
845
|
+
# immediately, as before this change: delaying it adds latency to every
|
|
846
|
+
# resumption and, on a short read_timeout, can burn the whole budget
|
|
847
|
+
# before any I/O happens.
|
|
848
|
+
delay = retry_ms ? resumption_delay(retry_ms) : 0
|
|
775
849
|
|
|
776
850
|
loop do
|
|
777
851
|
sleep(delay) if delay.positive?
|
|
@@ -779,10 +853,21 @@ module MCPClient
|
|
|
779
853
|
break unless @mutex.synchronize { @pending_stream_responses.key?(request_id) }
|
|
780
854
|
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
781
855
|
|
|
782
|
-
delay = state[:retry_ms]
|
|
856
|
+
delay = resumption_delay(state[:retry_ms])
|
|
783
857
|
end
|
|
784
858
|
end
|
|
785
859
|
|
|
860
|
+
# Delay before the next resumption GET: the peer's retry directive when
|
|
861
|
+
# present, floored so "retry: 0" cannot turn the deadline window into a
|
|
862
|
+
# back-to-back request loop, otherwise the standard reconnect delay.
|
|
863
|
+
# @param retry_ms [Integer, nil] retry directive in milliseconds
|
|
864
|
+
# @return [Numeric] delay in seconds
|
|
865
|
+
def resumption_delay(retry_ms)
|
|
866
|
+
return SSE_RECONNECT_DELAY unless retry_ms
|
|
867
|
+
|
|
868
|
+
[retry_ms / 1000.0, MIN_RESUMPTION_RECONNECT_DELAY].max
|
|
869
|
+
end
|
|
870
|
+
|
|
786
871
|
# One GET with the current cursor; complete SSE events are dispatched
|
|
787
872
|
# through the standard server-message path, which routes replayed
|
|
788
873
|
# responses to their registered waiters. `id:` and `retry:` fields
|
|
@@ -803,6 +888,10 @@ module MCPClient
|
|
|
803
888
|
req.options.on_data = proc do |chunk, _bytes|
|
|
804
889
|
buffer << chunk
|
|
805
890
|
process_resumption_buffer(buffer, state)
|
|
891
|
+
# A peer replaying delimiter-free data must not grow this buffer
|
|
892
|
+
# without bound: abort the GET (rescued below); the deadline-bounded
|
|
893
|
+
# resumption loop decides whether to retry with a fresh buffer.
|
|
894
|
+
enforce_sse_buffer_cap!(buffer)
|
|
806
895
|
end
|
|
807
896
|
end
|
|
808
897
|
rescue StandardError => e
|
|
@@ -815,10 +904,16 @@ module MCPClient
|
|
|
815
904
|
# @param state [Hash] mutable resumption state (:cursor, :retry_ms)
|
|
816
905
|
# @return [void]
|
|
817
906
|
def process_resumption_buffer(buffer, state)
|
|
818
|
-
|
|
907
|
+
# Same incremental scan as the events stream: without a cursor every
|
|
908
|
+
# chunk re-matched the whole accumulated buffer from byte zero.
|
|
909
|
+
scan_from = [state[:scanned].to_i - 3, 0].max
|
|
910
|
+
while (separator = buffer.match(/\r\n\r\n|\n\n/, scan_from))
|
|
819
911
|
event_text = buffer.slice!(0, separator.end(0))
|
|
820
912
|
handle_resumption_event(event_text, state)
|
|
913
|
+
scan_from = 0
|
|
914
|
+
state[:scanned] = 0
|
|
821
915
|
end
|
|
916
|
+
state[:scanned] = buffer.length
|
|
822
917
|
end
|
|
823
918
|
|
|
824
919
|
# Parse one SSE event received on a resumption stream: track `id:` lines
|
|
@@ -835,7 +930,9 @@ module MCPClient
|
|
|
835
930
|
data_lines << line.sub(/\Adata:\s*/, '')
|
|
836
931
|
elsif line.start_with?('id:')
|
|
837
932
|
id = line.sub(/\Aid:\s*/, '').strip
|
|
838
|
-
|
|
933
|
+
# Same validation as the events stream: this cursor is echoed in
|
|
934
|
+
# the Last-Event-ID header of the next resumption GET.
|
|
935
|
+
state[:cursor] = id if retainable_event_id?(id)
|
|
839
936
|
elsif line.start_with?('retry:')
|
|
840
937
|
raw = line.sub(/\Aretry:\s*/, '').strip
|
|
841
938
|
state[:retry_ms] = raw.to_i if raw.match?(/\A\d+\z/)
|
|
@@ -875,22 +972,64 @@ module MCPClient
|
|
|
875
972
|
# Buffers partial chunks and processes complete SSE events
|
|
876
973
|
# @param chunk [String] the chunk to process
|
|
877
974
|
def process_event_chunk(chunk)
|
|
878
|
-
|
|
975
|
+
# Size only: the chunk is raw wire data carrying sampling prompts,
|
|
976
|
+
# elicitation content and tool results.
|
|
977
|
+
@logger.debug("Processing event chunk (#{describe_body_size(chunk)})") if @logger.level <= Logger::DEBUG
|
|
879
978
|
|
|
880
979
|
@mutex.synchronize do
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
#
|
|
884
|
-
|
|
980
|
+
# Append in place and scan only the newly arrived bytes. `+= chunk`
|
|
981
|
+
# reallocated and copied the whole buffer per callback, and each
|
|
982
|
+
# search restarted at index 0, so an unterminated event delivered in
|
|
983
|
+
# N chunks cost O(N^2) work: capped memory, uncapped CPU.
|
|
984
|
+
@buffer << chunk
|
|
985
|
+
|
|
986
|
+
scan_from = [@buffer_scanned - 3, 0].max
|
|
987
|
+
while (event_end = next_buffer_event_end(scan_from))
|
|
885
988
|
event_data = extract_event(event_end)
|
|
886
989
|
parse_and_handle_event(event_data)
|
|
990
|
+
scan_from = 0
|
|
991
|
+
@buffer_scanned = 0
|
|
992
|
+
end
|
|
993
|
+
@buffer_scanned = @buffer.length
|
|
994
|
+
|
|
995
|
+
# Everything left is a partial event awaiting its terminator; cap how
|
|
996
|
+
# much a peer may accumulate. The raise propagates (unlike processing
|
|
997
|
+
# errors below) so the events loop drops this connection and its
|
|
998
|
+
# backoff takes over — memory stays bounded either way.
|
|
999
|
+
begin
|
|
1000
|
+
enforce_sse_buffer_cap!(@buffer)
|
|
1001
|
+
rescue MCPClient::Errors::ConnectionError
|
|
1002
|
+
@buffer = +''
|
|
1003
|
+
@buffer_scanned = 0
|
|
1004
|
+
raise
|
|
887
1005
|
end
|
|
888
1006
|
end
|
|
1007
|
+
rescue MCPClient::Errors::ConnectionError
|
|
1008
|
+
raise
|
|
889
1009
|
rescue StandardError => e
|
|
890
1010
|
@logger.error("Error processing event chunk: #{e.message}")
|
|
891
1011
|
@logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
|
|
892
1012
|
end
|
|
893
1013
|
|
|
1014
|
+
# Index of the earliest event terminator at or after an offset.
|
|
1015
|
+
# @param offset [Integer] character offset to start searching from
|
|
1016
|
+
# @return [Integer, nil] index of the terminator, or nil if none yet
|
|
1017
|
+
def next_buffer_event_end(offset)
|
|
1018
|
+
lf = @buffer.index("\n\n", offset)
|
|
1019
|
+
crlf = @buffer.index("\r\n\r\n", offset)
|
|
1020
|
+
[lf, crlf].compact.min
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
# @param buffer [String] a partial-event SSE buffer
|
|
1024
|
+
# @raise [MCPClient::Errors::ConnectionError] when the buffer exceeds MAX_SSE_BUFFER_BYTES
|
|
1025
|
+
def enforce_sse_buffer_cap!(buffer)
|
|
1026
|
+
return if buffer.bytesize <= MAX_SSE_BUFFER_BYTES
|
|
1027
|
+
|
|
1028
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1029
|
+
"SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
|
|
1030
|
+
'without a terminator'
|
|
1031
|
+
end
|
|
1032
|
+
|
|
894
1033
|
# Extract a single event from the buffer
|
|
895
1034
|
# @param event_end [Integer] the position where the event ends
|
|
896
1035
|
# @return [String] the extracted event data
|
|
@@ -922,9 +1061,12 @@ module MCPClient
|
|
|
922
1061
|
# SSE allows multiple data lines that should be joined with newlines
|
|
923
1062
|
data_lines << line[5..].strip
|
|
924
1063
|
elsif line.start_with?('id:')
|
|
925
|
-
# Track event ID for resumability (MCP future enhancement)
|
|
1064
|
+
# Track event ID for resumability (MCP future enhancement). The id
|
|
1065
|
+
# is peer-controlled and gets echoed in the Last-Event-ID header, so
|
|
1066
|
+
# only a bounded, header-safe value is retained; the previous valid
|
|
1067
|
+
# cursor is kept when one is rejected.
|
|
926
1068
|
event[:id] = line[3..].strip
|
|
927
|
-
@last_event_id = event[:id]
|
|
1069
|
+
@last_event_id = event[:id] if retainable_event_id?(event[:id])
|
|
928
1070
|
elsif line.start_with?('retry:')
|
|
929
1071
|
# SEP-1699: the client MUST respect the server's retry directive
|
|
930
1072
|
# (milliseconds) when reconnecting; zero is a valid directive.
|
|
@@ -947,10 +1089,19 @@ module MCPClient
|
|
|
947
1089
|
return if data.empty?
|
|
948
1090
|
|
|
949
1091
|
begin
|
|
950
|
-
|
|
1092
|
+
message = JSON.parse(data)
|
|
1093
|
+
unless message.is_a?(Hash)
|
|
1094
|
+
# A JSON-parseable scalar/array is not a JSON-RPC message; dispatch
|
|
1095
|
+
# would raise on it. Log the type, never the value.
|
|
1096
|
+
@logger.warn("Skipping non-object JSON-RPC message on the events stream (#{message.class})")
|
|
1097
|
+
return
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
dispatch_server_message(message)
|
|
951
1101
|
rescue JSON::ParserError => e
|
|
952
|
-
|
|
953
|
-
|
|
1102
|
+
# The parser message names the failure position, not the payload; the
|
|
1103
|
+
# payload itself stays out of the log.
|
|
1104
|
+
@logger.error("Invalid JSON in server message: #{describe_parse_error(e, data)}")
|
|
954
1105
|
end
|
|
955
1106
|
end
|
|
956
1107
|
|
|
@@ -985,26 +1136,9 @@ module MCPClient
|
|
|
985
1136
|
result: {}
|
|
986
1137
|
}
|
|
987
1138
|
|
|
988
|
-
#
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
response = conn.post(@endpoint) do |req|
|
|
992
|
-
@headers.each { |k, v| req.headers[k] = v }
|
|
993
|
-
req.headers['Mcp-Session-Id'] = @session_id if @session_id
|
|
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)
|
|
997
|
-
req.body = pong_response.to_json
|
|
998
|
-
end
|
|
999
|
-
|
|
1000
|
-
if response.success?
|
|
1001
|
-
@logger.debug("Sent pong response for ping ID: #{ping_id}") if @logger.level <= Logger::DEBUG
|
|
1002
|
-
else
|
|
1003
|
-
@logger.warn("Failed to send pong response: HTTP #{response.status}")
|
|
1004
|
-
end
|
|
1005
|
-
rescue StandardError => e
|
|
1006
|
-
@logger.error("Failed to send pong response: #{e.message}")
|
|
1007
|
-
end
|
|
1139
|
+
# Pongs go through the same bounded response path as every other
|
|
1140
|
+
# server-initiated reply, so a ping flood cannot fan out threads.
|
|
1141
|
+
post_jsonrpc_response(pong_response)
|
|
1008
1142
|
end
|
|
1009
1143
|
|
|
1010
1144
|
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
@@ -1029,8 +1163,11 @@ module MCPClient
|
|
|
1029
1163
|
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
1030
1164
|
end
|
|
1031
1165
|
rescue StandardError => e
|
|
1166
|
+
# The exception message is host-internal (file paths, connection
|
|
1167
|
+
# strings, library internals): log it locally, but answer the peer with
|
|
1168
|
+
# a constant message so failures cannot be used to probe the host.
|
|
1032
1169
|
@logger.error("Error handling server request: #{e.message}")
|
|
1033
|
-
send_error_response(request_id, -32_603,
|
|
1170
|
+
send_error_response(request_id, -32_603, 'Internal error')
|
|
1034
1171
|
end
|
|
1035
1172
|
|
|
1036
1173
|
# Handle elicitation/create request from server (MCP 2025-11-25)
|
|
@@ -1184,7 +1321,31 @@ module MCPClient
|
|
|
1184
1321
|
# @return [void]
|
|
1185
1322
|
# @private
|
|
1186
1323
|
def post_jsonrpc_response(response)
|
|
1187
|
-
# Send response in a separate thread to avoid blocking event
|
|
1324
|
+
# Send the response in a separate thread to avoid blocking event
|
|
1325
|
+
# processing, but never beyond the concurrency budget: server requests
|
|
1326
|
+
# arrive at the peer's rate, and each unanswered POST would otherwise
|
|
1327
|
+
# pin one thread and one connection.
|
|
1328
|
+
unless acquire_response_post_slot
|
|
1329
|
+
log_response_post_saturation
|
|
1330
|
+
return
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
begin
|
|
1334
|
+
start_response_post_thread(response)
|
|
1335
|
+
rescue ThreadError => e
|
|
1336
|
+
# Thread.new failed, so the ensure inside the block never runs and the
|
|
1337
|
+
# reservation would leak. Eight such failures would silently mute this
|
|
1338
|
+
# instance's replies for the rest of its life, including after
|
|
1339
|
+
# reconnect (cleanup does not reset the counter).
|
|
1340
|
+
release_response_post_slot
|
|
1341
|
+
@logger.error("Failed to start response POST thread: #{e.message}")
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
# Spawn the worker that POSTs one server-initiated response.
|
|
1346
|
+
# @param response [Hash] the JSON-RPC response
|
|
1347
|
+
# @return [Thread]
|
|
1348
|
+
def start_response_post_thread(response)
|
|
1188
1349
|
Thread.new do
|
|
1189
1350
|
conn = http_connection
|
|
1190
1351
|
json_body = JSON.generate(response)
|
|
@@ -1199,13 +1360,55 @@ module MCPClient
|
|
|
1199
1360
|
end
|
|
1200
1361
|
|
|
1201
1362
|
if resp.success?
|
|
1202
|
-
@logger.debug("Sent JSON-RPC response: #{
|
|
1363
|
+
@logger.debug("Sent JSON-RPC response: #{describe_jsonrpc_message(response)}")
|
|
1203
1364
|
else
|
|
1204
1365
|
@logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
|
|
1205
1366
|
end
|
|
1206
1367
|
rescue StandardError => e
|
|
1207
1368
|
@logger.error("Failed to send JSON-RPC response: #{e.message}")
|
|
1369
|
+
ensure
|
|
1370
|
+
release_response_post_slot
|
|
1208
1371
|
end
|
|
1209
1372
|
end
|
|
1373
|
+
|
|
1374
|
+
# Warn that the response-POST budget is saturated, at most once per
|
|
1375
|
+
# SATURATION_LOG_INTERVAL.
|
|
1376
|
+
#
|
|
1377
|
+
# The peer controls how often this path is reached, so logging every
|
|
1378
|
+
# rejection (at WARN, which the default logger emits) would just trade a
|
|
1379
|
+
# thread-exhaustion vector for a log-volume one. The peer-supplied request
|
|
1380
|
+
# id is deliberately omitted for the same reason.
|
|
1381
|
+
# @return [void]
|
|
1382
|
+
def log_response_post_saturation
|
|
1383
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1384
|
+
dropped = @mutex.synchronize do
|
|
1385
|
+
@dropped_response_posts += 1
|
|
1386
|
+
next nil if @last_saturation_log_at && now - @last_saturation_log_at < SATURATION_LOG_INTERVAL
|
|
1387
|
+
|
|
1388
|
+
@last_saturation_log_at = now
|
|
1389
|
+
@dropped_response_posts
|
|
1390
|
+
end
|
|
1391
|
+
return unless dropped
|
|
1392
|
+
|
|
1393
|
+
@logger.warn("Dropping server-initiated responses: #{MAX_CONCURRENT_RESPONSE_POSTS} " \
|
|
1394
|
+
"response POSTs already in flight (#{dropped} dropped so far)")
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
# Reserve one slot of the response-POST concurrency budget.
|
|
1398
|
+
# @return [Boolean] whether a slot was available
|
|
1399
|
+
def acquire_response_post_slot
|
|
1400
|
+
@mutex.synchronize do
|
|
1401
|
+
return false if @response_post_count >= MAX_CONCURRENT_RESPONSE_POSTS
|
|
1402
|
+
|
|
1403
|
+
@response_post_count += 1
|
|
1404
|
+
true
|
|
1405
|
+
end
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
# Release a slot reserved by acquire_response_post_slot.
|
|
1409
|
+
# @return [void]
|
|
1410
|
+
def release_response_post_slot
|
|
1411
|
+
@mutex.synchronize { @response_post_count -= 1 }
|
|
1412
|
+
end
|
|
1210
1413
|
end
|
|
1211
1414
|
end
|
data/lib/mcp_client/tool.rb
CHANGED
|
@@ -38,7 +38,6 @@ module MCPClient
|
|
|
38
38
|
# @param task_support [String, nil] execution.taskSupport value (MCP 2025-11-25)
|
|
39
39
|
# @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
|
|
40
40
|
# @param meta [Hash, nil] optional `_meta` metadata attached to the tool (MCP 2025-11-25)
|
|
41
|
-
# rubocop:disable Metrics/ParameterLists
|
|
42
41
|
def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil,
|
|
43
42
|
task_support: nil, icons: nil, meta: nil)
|
|
44
43
|
@name = name
|
|
@@ -52,7 +51,6 @@ module MCPClient
|
|
|
52
51
|
@icons = icons
|
|
53
52
|
@meta = meta
|
|
54
53
|
end
|
|
55
|
-
# rubocop:enable Metrics/ParameterLists
|
|
56
54
|
|
|
57
55
|
# Create a Tool instance from JSON data
|
|
58
56
|
# @param data [Hash] JSON data from MCP server
|
data/lib/mcp_client/version.rb
CHANGED
data/lib/mcp_client.rb
CHANGED
|
@@ -453,11 +453,17 @@ module MCPClient
|
|
|
453
453
|
# @param retry_backoff [Integer] Backoff delay in seconds (default: 1)
|
|
454
454
|
# @param name [String, nil] Optional name for this server
|
|
455
455
|
# @param logger [Logger, nil] Optional logger for server operations
|
|
456
|
+
# @param max_decompressed_body_bytes [Integer] ceiling on how far a gzip-encoded
|
|
457
|
+
# response may expand before it is rejected, guarding against a small highly
|
|
458
|
+
# compressed body exhausting memory (default: 64 MiB)
|
|
456
459
|
# @yieldparam faraday [Faraday::Connection] the configured connection instance for additional customization
|
|
457
460
|
# (e.g., SSL settings, custom middleware). The block is called after default configuration is applied.
|
|
458
461
|
# @return [Hash] server configuration
|
|
459
462
|
def self.streamable_http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3,
|
|
460
|
-
retry_backoff: 1, name: nil, logger: nil,
|
|
463
|
+
retry_backoff: 1, name: nil, logger: nil,
|
|
464
|
+
max_decompressed_body_bytes:
|
|
465
|
+
MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
|
|
466
|
+
&faraday_config)
|
|
461
467
|
{
|
|
462
468
|
type: 'streamable_http',
|
|
463
469
|
base_url: base_url,
|
|
@@ -468,6 +474,7 @@ module MCPClient
|
|
|
468
474
|
retry_backoff: retry_backoff,
|
|
469
475
|
name: name,
|
|
470
476
|
logger: logger,
|
|
477
|
+
max_decompressed_body_bytes: max_decompressed_body_bytes,
|
|
471
478
|
faraday_config: faraday_config
|
|
472
479
|
}
|
|
473
480
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-mcp-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Szymon Kurcab
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: base64
|
|
@@ -157,6 +156,7 @@ files:
|
|
|
157
156
|
- lib/mcp_client/server_http/json_rpc_transport.rb
|
|
158
157
|
- lib/mcp_client/server_sse.rb
|
|
159
158
|
- lib/mcp_client/server_sse/json_rpc_transport.rb
|
|
159
|
+
- lib/mcp_client/server_sse/origin_policy.rb
|
|
160
160
|
- lib/mcp_client/server_sse/reconnect_monitor.rb
|
|
161
161
|
- lib/mcp_client/server_sse/sse_parser.rb
|
|
162
162
|
- lib/mcp_client/server_stdio.rb
|
|
@@ -171,7 +171,6 @@ licenses:
|
|
|
171
171
|
- MIT
|
|
172
172
|
metadata:
|
|
173
173
|
rubygems_mfa_required: 'true'
|
|
174
|
-
post_install_message:
|
|
175
174
|
rdoc_options: []
|
|
176
175
|
require_paths:
|
|
177
176
|
- lib
|
|
@@ -186,8 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
186
185
|
- !ruby/object:Gem::Version
|
|
187
186
|
version: '0'
|
|
188
187
|
requirements: []
|
|
189
|
-
rubygems_version:
|
|
190
|
-
signing_key:
|
|
188
|
+
rubygems_version: 4.0.17
|
|
191
189
|
specification_version: 4
|
|
192
190
|
summary: A Ruby client for the Model Context Protocol (MCP)
|
|
193
191
|
test_files: []
|