ruby-mcp-client 2.1.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  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 +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  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 +796 -518
  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/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -60,25 +60,135 @@ module MCPClient
60
60
  # @raise [MCPClient::Errors::TransportError] if parsing fails
61
61
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
62
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
+
63
70
  body = response.body
64
71
  content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
65
72
  content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
66
73
 
67
74
  body = decompress_gzip(body) if content_encoding.include?('gzip')
68
- body = body&.strip
69
75
 
70
76
  # Determine response format based on Content-Type header per MCP 2025 spec
71
77
  data = if content_type.include?('text/event-stream')
72
- # Parse SSE-formatted response for streaming
73
- parse_sse_response(body, request && request['id'])
74
- 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)
75
83
  # Parse regular JSON response (default for Streamable HTTP)
76
- 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
77
93
  end
78
94
 
79
95
  process_jsonrpc_response(data)
80
96
  rescue JSON::ParserError => e
81
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)
82
192
  end
83
193
 
84
194
  # Incrementally decompress a gzip response body, aborting once the
@@ -123,20 +233,35 @@ module MCPClient
123
233
  # @param request_id [Integer, String, nil] id of the originating request
124
234
  # @return [Hash] the parsed JSON-RPC response
125
235
  # @raise [MCPClient::Errors::TransportError] if no response is found
126
- def parse_sse_response(sse_body, request_id = nil)
236
+ def parse_sse_response(sse_body, request_id = nil, live = 0)
127
237
  events, retry_ms = extract_sse_events(sse_body)
128
238
 
129
- 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
130
248
 
131
- responses, saw_invalid_json = route_sse_events(events)
249
+ responses, saw_invalid_json = route_sse_events(events, live_message_events(sse_body, live))
132
250
  matched = select_sse_response(responses, request_id)
133
251
  return matched if matched
134
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.
135
257
  if saw_invalid_json
136
258
  raise MCPClient::Errors::TransportError,
137
259
  'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
138
260
  end
139
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).
140
265
  resume_or_fail(events, request_id, retry_ms)
141
266
  end
142
267
 
@@ -151,6 +276,13 @@ module MCPClient
151
276
  # @raise [MCPClient::Errors::ServerError] when resumption fails
152
277
  # @raise [MCPClient::Errors::TransportError] when no cursor was received
153
278
  def resume_or_fail(events, request_id, retry_ms = nil)
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
+
154
286
  # Only a validated id may become a cursor: it is sent back as a
155
287
  # Last-Event-ID header on the resumption GET.
156
288
  cursor = events.reverse.find { |e| retainable_event_id?(e[:id]) }&.dig(:id)
@@ -181,7 +313,9 @@ module MCPClient
181
313
  retry_ms = nil
182
314
  current_event = { type: 'message', data_lines: [], id: nil }
183
315
 
184
- 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|
185
319
  line = line.strip
186
320
 
187
321
  if line.empty?
@@ -206,8 +340,18 @@ module MCPClient
206
340
  end
207
341
  end
208
342
 
209
- # Handle last event if no trailing empty line
210
- 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
211
355
  [events, retry_ms]
212
356
  end
213
357
 
@@ -220,27 +364,20 @@ module MCPClient
220
364
  # Track event ids for resumability, dispatch interleaved server messages
221
365
  # (requests, notifications, pings) and collect response candidates.
222
366
  # @param events [Array<Hash>] parsed SSE events
367
+ # @param live [Integer] leading events already routed as they arrived
223
368
  # @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
224
- def route_sse_events(events)
369
+ def route_sse_events(events, live = 0)
225
370
  responses = []
226
371
  saw_invalid_json = false
227
372
 
228
- events.each do |event|
229
- if event[:id] && !event[:id].empty?
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])
234
- @logger.debug("Tracking event ID for resumability: #{event[:id]}")
235
- end
236
- next unless event[:type] == 'message'
237
-
238
- 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)
239
376
  saw_invalid_json = true if message == :invalid
240
377
  next unless message.is_a?(Hash)
241
378
 
242
379
  if message['method']
243
- dispatch_server_message(message)
380
+ dispatch_server_message(message) if index >= live
244
381
  else
245
382
  responses << message
246
383
  end
@@ -249,6 +386,27 @@ module MCPClient
249
386
  [responses, saw_invalid_json]
250
387
  end
251
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
+
252
410
  # Parse the data payload of a single SSE event.
253
411
  # @param json_data [String] the joined data lines
254
412
  # @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
@@ -278,7 +436,13 @@ module MCPClient
278
436
  responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
279
437
  end
280
438
 
281
- 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?
282
446
  matched = responses.first
283
447
  @logger.warn(
284
448
  "SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \