ruby-mcp-client 2.0.0 → 3.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +914 -49
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1367 -311
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -36
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +851 -478
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/elicitation_validator.rb +56 -13
  35. data/lib/mcp_client/errors.rb +481 -6
  36. data/lib/mcp_client/header_params.rb +320 -0
  37. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  38. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  39. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  40. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  41. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  42. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  43. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  44. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  45. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  46. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  47. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  48. data/lib/mcp_client/http_transport_base.rb +669 -100
  49. data/lib/mcp_client/input_round_trips.rb +128 -0
  50. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  51. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  52. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  53. data/lib/mcp_client/json_rpc_common.rb +967 -11
  54. data/lib/mcp_client/oauth_client.rb +14 -5
  55. data/lib/mcp_client/prompt.rb +4 -0
  56. data/lib/mcp_client/request_authorization.rb +128 -0
  57. data/lib/mcp_client/request_meta_scope.rb +77 -0
  58. data/lib/mcp_client/request_metadata.rb +287 -0
  59. data/lib/mcp_client/resource.rb +4 -2
  60. data/lib/mcp_client/resource_content.rb +20 -0
  61. data/lib/mcp_client/resource_link.rb +0 -2
  62. data/lib/mcp_client/resource_template.rb +4 -2
  63. data/lib/mcp_client/result_caching.rb +999 -0
  64. data/lib/mcp_client/result_completeness.rb +34 -0
  65. data/lib/mcp_client/root.rb +6 -0
  66. data/lib/mcp_client/round_trip_marker.rb +28 -0
  67. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  68. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  69. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  70. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  71. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  72. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  73. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  74. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  75. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  76. data/lib/mcp_client/schema_validator/references.rb +610 -0
  77. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  78. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  79. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  80. data/lib/mcp_client/schema_validator.rb +893 -175
  81. data/lib/mcp_client/server_base.rb +233 -5
  82. data/lib/mcp_client/server_factory.rb +12 -3
  83. data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
  84. data/lib/mcp_client/server_http.rb +307 -90
  85. data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
  86. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  87. data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
  88. data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
  89. data/lib/mcp_client/server_sse.rb +310 -76
  90. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  91. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
  92. data/lib/mcp_client/server_stdio.rb +778 -186
  93. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
  94. data/lib/mcp_client/server_streamable_http.rb +547 -157
  95. data/lib/mcp_client/session_pin.rb +119 -0
  96. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  97. data/lib/mcp_client/subscription.rb +852 -0
  98. data/lib/mcp_client/subscription_support.rb +715 -0
  99. data/lib/mcp_client/task.rb +286 -14
  100. data/lib/mcp_client/tool.rb +31 -5
  101. data/lib/mcp_client/version.rb +21 -6
  102. data/lib/mcp_client.rb +115 -19
  103. metadata +71 -7
@@ -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)
@@ -27,25 +60,165 @@ module MCPClient
27
60
  # @raise [MCPClient::Errors::TransportError] if parsing fails
28
61
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
29
62
  def parse_response(response, request = nil)
63
+ # Host code a stream listener reached raised while the body was still
64
+ # arriving: that is this exchange's failure (already marked as a
65
+ # nested exchange's, so no recovery acts on it), raised in place of
66
+ # the response it was interleaved with.
67
+ failure = stream_listener_error(response)
68
+ raise failure if failure
69
+
30
70
  body = response.body
31
71
  content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
32
72
  content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
33
73
 
34
- body = Zlib::GzipReader.new(StringIO.new(body)).read if content_encoding.include?('gzip')
35
- body = body&.strip
74
+ body = decompress_gzip(body) if content_encoding.include?('gzip')
36
75
 
37
76
  # Determine response format based on Content-Type header per MCP 2025 spec
38
77
  data = if content_type.include?('text/event-stream')
39
- # Parse SSE-formatted response for streaming
40
- parse_sse_response(body, request && request['id'])
41
- else
78
+ # Parse SSE-formatted response for streaming. The body is
79
+ # not stripped: its final blank line is the last event's
80
+ # terminator, and without it that event was never dispatched.
81
+ parse_sse_response(body.to_s, request && request['id'], live_event_count(response))
82
+ elsif body.is_a?(String)
42
83
  # Parse regular JSON response (default for Streamable HTTP)
43
- JSON.parse(body)
84
+ JSON.parse(body.strip)
85
+ else
86
+ # A host's `conn.response :json` middleware decodes the body
87
+ # before it reaches here — the README offers that middleware
88
+ # for the error path, and it applies to every response — so an
89
+ # already-decoded object is taken as it is rather than parsed
90
+ # a second time. An event-stream body is not JSON and reaches
91
+ # the branch above as the text it was sent as.
92
+ body
44
93
  end
45
94
 
46
95
  process_jsonrpc_response(data)
47
96
  rescue JSON::ParserError => e
48
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
97
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
98
+ rescue Zlib::Error => e
99
+ # Streamable HTTP always offers gzip, so a stream that stops before the
100
+ # gzip footer arrives here rather than as a socket failure. No response
101
+ # was delivered, which on a modern server means the in-flight request
102
+ # is lost and MUST be re-issued with a new id. A body that is complete
103
+ # but corrupt (bad CRC, bad deflate data) was not cut short: the
104
+ # server answered, badly, and running the request again would not
105
+ # make it answer better.
106
+ unless modern? && truncated_gzip?(e)
107
+ raise MCPClient::Errors::TransportError, "Invalid gzip response from server: #{e.message}"
108
+ end
109
+
110
+ # A stream cut after the deflate data — footer only — still delivered
111
+ # its answer; a delivered answer settles the request, as on a socket
112
+ # that died after the final event (re-issuing would run it again).
113
+ delivered = delivered_before_truncation(response, request, e)
114
+ return delivered unless delivered.nil?
115
+
116
+ raise MCPClient::Errors::ResponseStreamClosedError,
117
+ "Response stream closed before delivering the response: #{e.message}"
118
+ end
119
+
120
+ # The answer a gzip body that lost its footer delivered, parsed the
121
+ # ordinary way; nil when the deflate data itself stopped short of it.
122
+ # @param response [Faraday::Response] the response whose body failed to decode
123
+ # @param request [Hash, nil] the originating JSON-RPC request
124
+ # @param error [Zlib::Error] the decode failure
125
+ # @return [Object, nil] the parsed result
126
+ def delivered_before_truncation(response, request, error)
127
+ return nil unless request.is_a?(Hash) && request.key?('id')
128
+
129
+ body = inflate_delivered_gzip(response.body.to_s)
130
+ return nil if body.nil? || body.empty?
131
+
132
+ sse = sse_framed_body?(body)
133
+ body = complete_sse_events(body) if sse
134
+ return nil if body.empty? || !body_carries_response?(body, sse, request['id'])
135
+
136
+ @logger.warn("Response stream ended after the response arrived (#{error.message}); " \
137
+ "keeping the delivered #{request['method']} response instead of re-issuing it")
138
+ data = sse ? parse_sse_response(body, request['id'], live_event_count(response)) : JSON.parse(body.strip)
139
+ process_jsonrpc_response(data)
140
+ end
141
+
142
+ # Every complete event of a response stream is handed over while the
143
+ # body is still arriving, so a progress notification reaches the host
144
+ # before the tool finishes — and before a timeout ends the stream —
145
+ # and a legacy server's request on the stream is answered before the
146
+ # server has to end the response. A notification has no response
147
+ # stream worth reading incrementally.
148
+ # @param request [Hash] the JSON-RPC message being sent
149
+ # @return [Proc, nil]
150
+ def response_stream_listener(request)
151
+ return nil unless request.is_a?(Hash) && request.key?('id')
152
+
153
+ ->(event) { dispatch_live_sse_event(event) }
154
+ end
155
+
156
+ # Act on one event as it arrives: requests and notifications are
157
+ # routed now, responses wait for the completed body. A failing
158
+ # callback is logged rather than allowed to abort the read of the
159
+ # response it was interleaved with.
160
+ # @param event [String] one complete, LF-normalized SSE event
161
+ # @return [void]
162
+ def dispatch_live_sse_event(event)
163
+ events, = extract_sse_events("#{event}\n\n")
164
+ events.each do |parsed|
165
+ track_sse_event_id(parsed)
166
+ message = sse_event_json_rpc_message(parsed)
167
+ dispatch_server_message(message) if message.is_a?(Hash) && message['method']
168
+ end
169
+ end
170
+
171
+ # How many of the events the completed body splits into were handed to
172
+ # the stream listener while it arrived: the scanner counts every
173
+ # terminated block, blank or comment-only ones included, so the same
174
+ # split maps its count onto the events the body parser keeps.
175
+ # @param sse_body [String] the text/event-stream body
176
+ # @param live [Integer] blocks the scanner dispatched
177
+ # @return [Integer] events among them the body parser would keep
178
+ def live_message_events(sse_body, live)
179
+ return 0 if live.zero?
180
+
181
+ blocks = normalize_sse_newlines(sse_body).split("\n\n", -1)
182
+ blocks.first(live).count { |block| block.lines.any? { |line| line.strip.start_with?('data:', 'id:') } }
183
+ end
184
+
185
+ # Whether a gzip failure means the body stopped before its end rather
186
+ # than carrying bad data.
187
+ # @param error [Zlib::Error] the decompression failure
188
+ # @return [Boolean]
189
+ def truncated_gzip?(error)
190
+ error.is_a?(Zlib::GzipFile::NoFooter) || error.is_a?(Zlib::BufError) ||
191
+ error.message.to_s.match?(/unexpected end|footer/i)
192
+ end
193
+
194
+ # Incrementally decompress a gzip response body, aborting once the
195
+ # expanded output exceeds the configured ceiling.
196
+ # @param body [String] the gzip-compressed response body
197
+ # @return [String] the decompressed body
198
+ # @raise [MCPClient::Errors::ResponseTooLargeError] if the expansion limit is exceeded
199
+ def decompress_gzip(body)
200
+ limit = max_decompressed_body_bytes
201
+ reader = Zlib::GzipReader.new(StringIO.new(body))
202
+ decompressed = +''
203
+ while (chunk = reader.read(DECOMPRESS_CHUNK_BYTES))
204
+ decompressed << chunk
205
+ next unless decompressed.bytesize > limit
206
+
207
+ # ResponseTooLargeError (not a plain TransportError) so with_retry
208
+ # does not re-POST a request the server has already executed.
209
+ raise MCPClient::Errors::ResponseTooLargeError,
210
+ "Gzip response expanded beyond #{limit} bytes"
211
+ end
212
+ decompressed
213
+ ensure
214
+ reader&.close
215
+ end
216
+
217
+ # Configured ceiling for decompressed response bodies.
218
+ # @return [Integer] positive byte limit
219
+ def max_decompressed_body_bytes
220
+ configured = defined?(@max_decompressed_body_bytes) ? @max_decompressed_body_bytes : nil
221
+ configured || MAX_DECOMPRESSED_BODY_BYTES
49
222
  end
50
223
 
51
224
  # Parse a Server-Sent Event formatted response body.
@@ -60,20 +233,35 @@ module MCPClient
60
233
  # @param request_id [Integer, String, nil] id of the originating request
61
234
  # @return [Hash] the parsed JSON-RPC response
62
235
  # @raise [MCPClient::Errors::TransportError] if no response is found
63
- def parse_sse_response(sse_body, request_id = nil)
236
+ def parse_sse_response(sse_body, request_id = nil, live = 0)
64
237
  events, retry_ms = extract_sse_events(sse_body)
65
238
 
66
- raise MCPClient::Errors::TransportError, 'No data found in SSE response' if events.empty?
239
+ if events.empty?
240
+ # An empty stream is a stream that closed before delivering the
241
+ # response; on a modern server that means re-issue, not resume.
242
+ if modern?
243
+ raise MCPClient::Errors::ResponseStreamClosedError, 'SSE stream closed before delivering the response'
244
+ end
245
+
246
+ raise MCPClient::Errors::TransportError, 'No data found in SSE response'
247
+ end
67
248
 
68
- responses, saw_invalid_json = route_sse_events(events)
249
+ responses, saw_invalid_json = route_sse_events(events, live_message_events(sse_body, live))
69
250
  matched = select_sse_response(responses, request_id)
70
251
  return matched if matched
71
252
 
253
+ # Every event that reaches here is terminated: an unterminated final
254
+ # event is dropped before parsing. So invalid JSON is not a break that
255
+ # landed inside an event — the server processed the request and
256
+ # answered, however badly, and re-issuing would run it a second time.
72
257
  if saw_invalid_json
73
258
  raise MCPClient::Errors::TransportError,
74
259
  'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
75
260
  end
76
261
 
262
+ # A stream that ended between events carries no answer at all: the
263
+ # in-flight request was lost and takes the re-issue path (2026-07-28
264
+ # changelog, major change 9).
77
265
  resume_or_fail(events, request_id, retry_ms)
78
266
  end
79
267
 
@@ -88,7 +276,16 @@ module MCPClient
88
276
  # @raise [MCPClient::Errors::ServerError] when resumption fails
89
277
  # @raise [MCPClient::Errors::TransportError] when no cursor was received
90
278
  def resume_or_fail(events, request_id, retry_ms = nil)
91
- cursor = events.reverse.find { |e| e[:id] && !e[:id].empty? }&.dig(:id)
279
+ # MCP 2026-07-28 removed SSE resumability: the in-flight request is
280
+ # lost and must be re-issued as a new request (see rpc_request).
281
+ if modern?
282
+ raise MCPClient::Errors::ResponseStreamClosedError,
283
+ 'SSE stream closed before delivering the response'
284
+ end
285
+
286
+ # Only a validated id may become a cursor: it is sent back as a
287
+ # Last-Event-ID header on the resumption GET.
288
+ cursor = events.reverse.find { |e| retainable_event_id?(e[:id]) }&.dig(:id)
92
289
  if request_id && cursor
93
290
  # Resume with THIS stream's cursor and retry directive (both are
94
291
  # per-stream), not the shared @last_event_id / @sse_retry_ms which a
@@ -116,7 +313,9 @@ module MCPClient
116
313
  retry_ms = nil
117
314
  current_event = { type: 'message', data_lines: [], id: nil }
118
315
 
119
- sse_body.lines.each do |line|
316
+ # SSE line terminators are CRLF, CR or LF; a server framing its events
317
+ # with bare CR still delimits them, so normalize before splitting.
318
+ normalize_sse_newlines(sse_body).lines.each do |line|
120
319
  line = line.strip
121
320
 
122
321
  if line.empty?
@@ -141,8 +340,18 @@ module MCPClient
141
340
  end
142
341
  end
143
342
 
144
- # Handle last event if no trailing empty line
145
- events << current_event if sse_event_present?(current_event)
343
+ # An event is dispatched at its terminating blank line, so a body that
344
+ # ends inside an event delivered nothing for it. On a modern server
345
+ # the event is dropped — and a missing response re-issued — exactly
346
+ # as when the socket cut it; a legacy server keeps the benefit of the
347
+ # doubt this transport always gave it.
348
+ if sse_event_present?(current_event)
349
+ if modern?
350
+ @logger.warn('Dropping an SSE event the response stream ended without terminating')
351
+ else
352
+ events << current_event
353
+ end
354
+ end
146
355
  [events, retry_ms]
147
356
  end
148
357
 
@@ -155,24 +364,20 @@ module MCPClient
155
364
  # Track event ids for resumability, dispatch interleaved server messages
156
365
  # (requests, notifications, pings) and collect response candidates.
157
366
  # @param events [Array<Hash>] parsed SSE events
367
+ # @param live [Integer] leading events already routed as they arrived
158
368
  # @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
159
- def route_sse_events(events)
369
+ def route_sse_events(events, live = 0)
160
370
  responses = []
161
371
  saw_invalid_json = false
162
372
 
163
- events.each do |event|
164
- if event[:id] && !event[:id].empty?
165
- @mutex.synchronize { @last_event_id = event[:id] }
166
- @logger.debug("Tracking event ID for resumability: #{event[:id]}")
167
- end
168
- next unless event[:type] == 'message'
169
-
170
- message = parse_sse_event_data(event[:data_lines].join("\n"))
373
+ events.each_with_index do |event, index|
374
+ track_sse_event_id(event) if index >= live
375
+ message = sse_event_json_rpc_message(event)
171
376
  saw_invalid_json = true if message == :invalid
172
377
  next unless message.is_a?(Hash)
173
378
 
174
379
  if message['method']
175
- dispatch_server_message(message)
380
+ dispatch_server_message(message) if index >= live
176
381
  else
177
382
  responses << message
178
383
  end
@@ -181,6 +386,27 @@ module MCPClient
181
386
  [responses, saw_invalid_json]
182
387
  end
183
388
 
389
+ # The POST SSE stream is peer-controlled like the GET one, so its ids
390
+ # get the same bound/charset check before being retained or echoed in
391
+ # a Last-Event-ID header.
392
+ # @param event [Hash] a parsed SSE event
393
+ # @return [void]
394
+ def track_sse_event_id(event)
395
+ return unless event[:id] && !event[:id].empty? && !modern?
396
+
397
+ @mutex.synchronize { @last_event_id = event[:id] } if retainable_event_id?(event[:id])
398
+ @logger.debug("Tracking event ID for resumability: #{event[:id]}")
399
+ end
400
+
401
+ # @param event [Hash] a parsed SSE event
402
+ # @return [Hash, Symbol, nil] its JSON-RPC message, :invalid, or nil for
403
+ # an event of another type or without an object payload
404
+ def sse_event_json_rpc_message(event)
405
+ return nil unless event[:type] == 'message'
406
+
407
+ parse_sse_event_data(event[:data_lines].join("\n"))
408
+ end
409
+
184
410
  # Parse the data payload of a single SSE event.
185
411
  # @param json_data [String] the joined data lines
186
412
  # @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
@@ -190,10 +416,12 @@ module MCPClient
190
416
  message = JSON.parse(json_data)
191
417
  return message if message.is_a?(Hash)
192
418
 
193
- @logger.warn("Skipping non-object JSON-RPC message in SSE event: #{message.inspect}")
419
+ # Type only: the value is peer-controlled payload and may carry tool
420
+ # arguments, results or elicitation content.
421
+ @logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
194
422
  nil
195
423
  rescue JSON::ParserError => e
196
- @logger.warn("Skipping invalid JSON in SSE event: #{e.message}")
424
+ @logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json_data)}")
197
425
  :invalid
198
426
  end
199
427
 
@@ -208,7 +436,13 @@ module MCPClient
208
436
  responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
209
437
  end
210
438
 
211
- if matched.nil? && responses.length == 1
439
+ # A legacy server that echoes ids loosely — a string where an integer
440
+ # went out, or an id an intermediary rewrote — gets the benefit of the
441
+ # doubt when its stream carried exactly one response. A modern one
442
+ # does not: no response to THIS request arrived, so the request was
443
+ # lost and MCP 2026-07-28 says to re-issue it rather than complete it
444
+ # with the answer to something else.
445
+ if matched.nil? && responses.length == 1 && !modern?
212
446
  matched = responses.first
213
447
  @logger.warn(
214
448
  "SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \