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.
@@ -40,6 +40,13 @@ module MCPClient
40
40
  MAX_RECONNECT_DELAY = 30
41
41
  JITTER_FACTOR = 0.25
42
42
 
43
+ # Maximum bytes the SSE parse buffer may hold while waiting for an event
44
+ # terminator. The stream is peer-controlled: without a cap, a hostile
45
+ # server could withhold the blank-line delimiter forever and grow the
46
+ # buffer until the host runs out of memory. Generous enough for any
47
+ # legitimate JSON-RPC response event.
48
+ MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
49
+
43
50
  # @!attribute [r] base_url
44
51
  # @return [String] The base URL of the MCP server
45
52
  # @!attribute [r] tools
@@ -91,8 +98,14 @@ module MCPClient
91
98
  @tools_data = nil
92
99
  @request_id = 0
93
100
  @sse_results = {}
101
+ # Ids of requests a caller is actively waiting on. Only responses for
102
+ # these ids are stored in @sse_results — everything else on the peer
103
+ # controlled stream is unsolicited and discarded.
104
+ @pending_request_ids = Set.new
94
105
  @mutex = Monitor.new
95
- @buffer = ''
106
+ @buffer = +''
107
+ # How much of @buffer has already been searched for an event terminator
108
+ @buffer_scanned = 0
96
109
  @sse_connected = false
97
110
  @connection_established = false
98
111
  @connection_cv = @mutex.new_cond
@@ -423,7 +436,18 @@ module MCPClient
423
436
 
424
437
  # Reset the SSE parse buffer so a reconnect never inherits a leftover
425
438
  # partial event from the previous connection.
426
- @buffer = ''
439
+ @buffer = +''
440
+ @buffer_scanned = 0
441
+
442
+ # Drop results nobody is waiting for, so peer-supplied state cannot
443
+ # accumulate across reconnects. Results for still-pending requests are
444
+ # KEPT: a response can arrive while its POST is still returning, and
445
+ # the waiter (which reconnects through ensure_sse_connection_active)
446
+ # is about to consume it. Discarding those reported a timeout for a
447
+ # tool call the server had already executed — inviting a duplicate
448
+ # manual retry. unregister_pending_request clears each entry when its
449
+ # request finishes, so nothing lingers.
450
+ @sse_results.select! { |id, _| @pending_request_ids.include?(id) }
427
451
 
428
452
  # Log cleanup for debugging
429
453
  @logger.debug('Cleaning up SSE connection')
@@ -519,8 +543,11 @@ module MCPClient
519
543
  send_error_response(request_id, -32_601, "Method not found: #{method}")
520
544
  end
521
545
  rescue StandardError => e
546
+ # The exception message is host-internal (file paths, connection
547
+ # strings, library internals): log it locally, but answer the peer with
548
+ # a constant message so failures cannot be used to probe the host.
522
549
  @logger.error("Error handling server request: #{e.message}")
523
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
550
+ send_error_response(request_id, -32_603, 'Internal error')
524
551
  end
525
552
 
526
553
  # Handle a server-initiated ping request (MCP ping utility)
@@ -717,7 +744,7 @@ module MCPClient
717
744
  req.body = json_body
718
745
  end
719
746
 
720
- @logger.debug("Sent response via HTTP POST: #{json_body}")
747
+ @logger.debug("Sent response via HTTP POST: #{describe_jsonrpc_message(response)}")
721
748
  rescue StandardError => e
722
749
  @logger.error("Failed to send response via HTTP POST: #{e.message}")
723
750
  end
@@ -839,7 +866,9 @@ module MCPClient
839
866
  # Process an SSE chunk from the server
840
867
  # @param chunk [String] the chunk to process
841
868
  def process_sse_chunk(chunk)
842
- @logger.debug("Processing SSE chunk: #{chunk.inspect}")
869
+ # Size only: the chunk is raw wire data carrying sampling prompts,
870
+ # elicitation content and tool results.
871
+ @logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
843
872
 
844
873
  # Only record activity for real events
845
874
  record_activity if chunk.include?('event:')
@@ -897,21 +926,61 @@ module MCPClient
897
926
  # @return [Array<String>, nil] array of complete events or nil if none
898
927
  # @private
899
928
  def extract_complete_events(chunk)
900
- event_buffers = nil
929
+ event_buffers = []
901
930
  @mutex.synchronize do
902
- @buffer += chunk
903
-
904
- # Extract all complete events from the buffer
905
- # Handle both Unix (\n\n) and Windows (\r\n\r\n) line endings
906
- event_buffers = []
907
- while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
908
- event_data = extract_single_event(event_end)
909
- event_buffers << event_data
931
+ # Append in place. `@buffer += chunk` allocates and copies the whole
932
+ # buffer on every callback, so an unterminated event delivered in N
933
+ # chunks costs O(N^2) copying memory stays capped but a peer can
934
+ # still burn CPU and thrash the allocator on the way there.
935
+ @buffer << chunk
936
+
937
+ # Rescan only the newly arrived bytes, backing up by the longest
938
+ # delimiter minus one so one split across two chunks is still found.
939
+ scan_from = [@buffer_scanned - 3, 0].max
940
+ while (event_end = next_event_end(scan_from))
941
+ event_buffers << extract_single_event(event_end)
942
+ # The buffer shifted; what remains is short (one event at most).
943
+ scan_from = 0
944
+ @buffer_scanned = 0
910
945
  end
946
+ @buffer_scanned = @buffer.length
947
+
948
+ # Whatever is left is a partial event still awaiting its terminator.
949
+ # The cap is applied here rather than before appending so a single
950
+ # oversized chunk that DOES contain complete events is still parsed.
951
+ fail_oversized_sse_buffer! if @buffer.bytesize > MAX_SSE_BUFFER_BYTES
911
952
  end
912
953
  event_buffers
913
954
  end
914
955
 
956
+ # Index of the earliest event terminator at or after an offset.
957
+ # @param offset [Integer] character offset to start searching from
958
+ # @return [Integer, nil] index of the terminator, or nil if none yet
959
+ def next_event_end(offset)
960
+ lf = @buffer.index("\n\n", offset)
961
+ crlf = @buffer.index("\r\n\r\n", offset)
962
+ [lf, crlf].compact.min
963
+ end
964
+
965
+ # Drop an oversized partial event and fail the connection.
966
+ #
967
+ # Recording the cause matters: this runs inside Faraday's on_data callback
968
+ # on the SSE worker thread, whose generic rescue would otherwise leave
969
+ # callers with a bare "connection lost" and no reason. Mirrors the
970
+ # endpoint-URI failure path so wait_for_connection surfaces it promptly.
971
+ # @raise [MCPClient::Errors::ConnectionError] always
972
+ def fail_oversized_sse_buffer!
973
+ message = "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
974
+ 'without a terminator'
975
+ @buffer = +''
976
+ @buffer_scanned = 0
977
+ @connection_error = message
978
+ @connection_established = false
979
+ @connection_cv.broadcast
980
+ @logger.error(message)
981
+ raise MCPClient::Errors::ConnectionError, message
982
+ end
983
+
915
984
  # Extract a single event from the buffer
916
985
  # @param event_end [Integer] the position where the event ends
917
986
  # @return [String] the extracted event data
@@ -66,7 +66,7 @@ module MCPClient
66
66
  # @return [void]
67
67
  # @raise [MCPClient::Errors::TransportError] on write errors
68
68
  def send_request(req)
69
- @logger.debug("Sending JSONRPC request: #{req.to_json}")
69
+ @logger.debug("Sending JSONRPC request: #{describe_jsonrpc_message(req)}")
70
70
  @stdin.puts(req.to_json)
71
71
  rescue StandardError => e
72
72
  # A request that failed to send will never receive a response, so drop
@@ -118,7 +118,7 @@ module MCPClient
118
118
  # @raise [MCPClient::Errors::ToolCallError] on tool call errors
119
119
  def rpc_request(method, params = {}, timeout: nil)
120
120
  ensure_initialized
121
- with_retry do
121
+ with_retry(method) do
122
122
  req_id = next_id
123
123
  req = build_jsonrpc_request(method, params, req_id)
124
124
  send_request(req)
@@ -160,7 +160,7 @@ module MCPClient
160
160
  # @return [void]
161
161
  def handle_line(line)
162
162
  msg = JSON.parse(line)
163
- @logger.debug("Received line: #{line.chomp}")
163
+ @logger.debug("Received line: #{describe_jsonrpc_message(msg)}")
164
164
 
165
165
  # A JSON-parseable line that is not an object cannot be a JSON-RPC
166
166
  # message; skip it rather than raising inside the reader thread
@@ -542,8 +542,11 @@ module MCPClient
542
542
  send_error_response(request_id, -32_601, "Method not found: #{method}")
543
543
  end
544
544
  rescue StandardError => e
545
+ # The exception message is host-internal (file paths, connection
546
+ # strings, library internals): log it locally, answer the peer with a
547
+ # constant message, matching the SSE and Streamable HTTP transports.
545
548
  @logger.error("Error handling server request: #{e.message}")
546
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
549
+ send_error_response(request_id, -32_603, 'Internal error')
547
550
  end
548
551
 
549
552
  # Handle a server-initiated ping request (MCP ping utility)
@@ -694,7 +697,7 @@ module MCPClient
694
697
  json = JSON.generate(message)
695
698
  @stdin.puts(json)
696
699
  @stdin.flush
697
- @logger.debug("Sent message: #{json}")
700
+ @logger.debug("Sent message: #{describe_jsonrpc_message(message)}")
698
701
  rescue StandardError => e
699
702
  @logger.error("Error sending message: #{e.message}")
700
703
  end
@@ -12,12 +12,45 @@ module MCPClient
12
12
  module JsonRpcTransport
13
13
  include HttpTransportBase
14
14
 
15
+ # Default ceiling on the expanded size of a gzip-encoded response body.
16
+ # The peer controls the compression ratio, so without a bound a tiny
17
+ # compressed response ("gzip bomb") could expand to an arbitrarily large
18
+ # string and exhaust host memory before JSON parsing.
19
+ #
20
+ # Hosts that legitimately exchange very large payloads (e.g. base64
21
+ # resource blobs or audio) can raise it per server with the
22
+ # max_decompressed_body_bytes option, so that whether a response is
23
+ # accepted does not depend on the server's choice to gzip it.
24
+ MAX_DECOMPRESSED_BODY_BYTES = 64 * 1024 * 1024
25
+ DECOMPRESS_CHUNK_BYTES = 64 * 1024
26
+
15
27
  private
16
28
 
29
+ # Whether a server-supplied SSE event id may be retained as the
30
+ # resumption cursor: non-empty, bounded, and safe to place in an HTTP
31
+ # header. Shared by every SSE parsing path (GET events stream, POST
32
+ # response stream, and resumed GET), since all three feed the same
33
+ # Last-Event-ID header.
34
+ # @param id [String, nil] the raw id field
35
+ # @return [Boolean]
36
+ def retainable_event_id?(id)
37
+ return false if id.nil? || id.empty?
38
+
39
+ if id.length > MAX_EVENT_ID_LENGTH
40
+ @logger.warn("Ignoring oversized SSE event id (#{id.length} chars)")
41
+ return false
42
+ end
43
+
44
+ return true if id.match?(EVENT_ID_PATTERN)
45
+
46
+ @logger.warn('Ignoring SSE event id with characters illegal in a header value')
47
+ false
48
+ end
49
+
17
50
  # Log HTTP response for Streamable HTTP
18
51
  # @param response [Faraday::Response] the HTTP response
19
52
  def log_response(response)
20
- @logger.debug("Received Streamable HTTP response: #{response.status} #{response.body}")
53
+ @logger.debug("Received Streamable HTTP response: #{response.status} (#{describe_body_size(response.body)})")
21
54
  end
22
55
 
23
56
  # Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
@@ -31,7 +64,7 @@ module MCPClient
31
64
  content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
32
65
  content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
33
66
 
34
- body = Zlib::GzipReader.new(StringIO.new(body)).read if content_encoding.include?('gzip')
67
+ body = decompress_gzip(body) if content_encoding.include?('gzip')
35
68
  body = body&.strip
36
69
 
37
70
  # Determine response format based on Content-Type header per MCP 2025 spec
@@ -45,7 +78,37 @@ module MCPClient
45
78
 
46
79
  process_jsonrpc_response(data)
47
80
  rescue JSON::ParserError => e
48
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
81
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
82
+ end
83
+
84
+ # Incrementally decompress a gzip response body, aborting once the
85
+ # expanded output exceeds the configured ceiling.
86
+ # @param body [String] the gzip-compressed response body
87
+ # @return [String] the decompressed body
88
+ # @raise [MCPClient::Errors::ResponseTooLargeError] if the expansion limit is exceeded
89
+ def decompress_gzip(body)
90
+ limit = max_decompressed_body_bytes
91
+ reader = Zlib::GzipReader.new(StringIO.new(body))
92
+ decompressed = +''
93
+ while (chunk = reader.read(DECOMPRESS_CHUNK_BYTES))
94
+ decompressed << chunk
95
+ next unless decompressed.bytesize > limit
96
+
97
+ # ResponseTooLargeError (not a plain TransportError) so with_retry
98
+ # does not re-POST a request the server has already executed.
99
+ raise MCPClient::Errors::ResponseTooLargeError,
100
+ "Gzip response expanded beyond #{limit} bytes"
101
+ end
102
+ decompressed
103
+ ensure
104
+ reader&.close
105
+ end
106
+
107
+ # Configured ceiling for decompressed response bodies.
108
+ # @return [Integer] positive byte limit
109
+ def max_decompressed_body_bytes
110
+ configured = defined?(@max_decompressed_body_bytes) ? @max_decompressed_body_bytes : nil
111
+ configured || MAX_DECOMPRESSED_BODY_BYTES
49
112
  end
50
113
 
51
114
  # Parse a Server-Sent Event formatted response body.
@@ -88,7 +151,9 @@ module MCPClient
88
151
  # @raise [MCPClient::Errors::ServerError] when resumption fails
89
152
  # @raise [MCPClient::Errors::TransportError] when no cursor was received
90
153
  def resume_or_fail(events, request_id, retry_ms = nil)
91
- cursor = events.reverse.find { |e| e[:id] && !e[:id].empty? }&.dig(:id)
154
+ # Only a validated id may become a cursor: it is sent back as a
155
+ # Last-Event-ID header on the resumption GET.
156
+ cursor = events.reverse.find { |e| retainable_event_id?(e[:id]) }&.dig(:id)
92
157
  if request_id && cursor
93
158
  # Resume with THIS stream's cursor and retry directive (both are
94
159
  # per-stream), not the shared @last_event_id / @sse_retry_ms which a
@@ -162,7 +227,10 @@ module MCPClient
162
227
 
163
228
  events.each do |event|
164
229
  if event[:id] && !event[:id].empty?
165
- @mutex.synchronize { @last_event_id = event[:id] }
230
+ # The POST SSE stream is peer-controlled like the GET one, so its
231
+ # ids get the same bound/charset check before being retained or
232
+ # echoed in a Last-Event-ID header.
233
+ @mutex.synchronize { @last_event_id = event[:id] } if retainable_event_id?(event[:id])
166
234
  @logger.debug("Tracking event ID for resumability: #{event[:id]}")
167
235
  end
168
236
  next unless event[:type] == 'message'
@@ -190,10 +258,12 @@ module MCPClient
190
258
  message = JSON.parse(json_data)
191
259
  return message if message.is_a?(Hash)
192
260
 
193
- @logger.warn("Skipping non-object JSON-RPC message in SSE event: #{message.inspect}")
261
+ # Type only: the value is peer-controlled payload and may carry tool
262
+ # arguments, results or elicitation content.
263
+ @logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
194
264
  nil
195
265
  rescue JSON::ParserError => e
196
- @logger.warn("Skipping invalid JSON in SSE event: #{e.message}")
266
+ @logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json_data)}")
197
267
  :invalid
198
268
  end
199
269