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.
@@ -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
- retry_ms ? retry_ms / 1000.0 : fallback_seconds
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
- delay = retry_ms ? retry_ms / 1000.0 : 0
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] ? state[:retry_ms] / 1000.0 : SSE_RECONNECT_DELAY
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
- while (separator = buffer.match(/\r\n\r\n|\n\n/))
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
- state[:cursor] = id unless id.empty?
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
- @logger.debug("Processing event chunk: #{chunk.inspect}") if @logger.level <= Logger::DEBUG
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
- @buffer += chunk
882
-
883
- # Extract complete events (SSE format: events end with double newline)
884
- while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
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
- dispatch_server_message(JSON.parse(data))
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
- @logger.error("Invalid JSON in server message: #{e.message}")
953
- @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
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
- # Send pong response in a separate thread to avoid blocking event processing
989
- Thread.new do
990
- conn = http_connection
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, "Internal error: #{e.message}")
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 processing
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: #{json_body}")
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
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '2.0.0'
5
+ VERSION = '2.1.0'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
8
  PROTOCOL_VERSION = '2025-11-25'
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, &faraday_config)
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.0.0
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: 2026-07-21 00:00:00.000000000 Z
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: 3.5.16
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: []