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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'deprecations'
4
+
3
5
  module MCPClient
4
6
  # Base class for MCP servers - serves as the interface for different server implementations
5
7
  class ServerBase
@@ -7,8 +9,10 @@ module MCPClient
7
9
  # @return [String] the name of the server
8
10
  attr_reader :name
9
11
 
10
- # Initialize the server with a name
11
- # @param name [String, nil] server name
12
+ # @!attribute [r] read_timeout
13
+ # @return [Numeric, nil] the configured per-request timeout in seconds, when the transport has one
14
+ attr_reader :read_timeout
15
+
12
16
  # Server-declared instructions from the initialize result, if any
13
17
  # @return [String, nil]
14
18
  attr_reader :instructions
@@ -25,6 +29,8 @@ module MCPClient
25
29
  @client_info = info.transform_keys(&:to_s)
26
30
  end
27
31
 
32
+ # Initialize the server with a name
33
+ # @param name [String, nil] server name
28
34
  def initialize(name: nil)
29
35
  @name = name
30
36
  end
@@ -147,6 +153,15 @@ module MCPClient
147
153
  # @param method [String] the JSON-RPC method the caller wants to send
148
154
  # @raise [MCPClient::Errors::CapabilityError]
149
155
  def require_capability!(*path, method:)
156
+ # A DiscoverResult that may no longer be reused is re-fetched on the
157
+ # next use of what it declared (MCP 2026-07-28 caching: a stale result
158
+ # is re-fetched on access) BEFORE the capability is judged at all: the
159
+ # server may have enabled a capability the old result lacked, or
160
+ # withdrawn one it still lists.
161
+ if respond_to?(:modern?, true) && modern? && discovery_refresh_needed?
162
+ @logger&.debug("The server/discover result may not be reused; refreshing it before #{method}")
163
+ rpc_request('server/discover')
164
+ end
150
165
  return if capability?(*path)
151
166
 
152
167
  raise MCPClient::Errors::CapabilityError,
@@ -154,11 +169,57 @@ module MCPClient
154
169
  "required for #{method}"
155
170
  end
156
171
 
172
+ # Whether the DiscoverResult behind the negotiated capabilities may still
173
+ # answer for the request about to go out.
174
+ #
175
+ # It is a cacheable result like any other, so it is bound by both of the
176
+ # caching rules the lists and reads obey: its ttlMs, counted from receipt
177
+ # (a result with no hint, or a zero, negative or malformed one, is stale
178
+ # at once), and — for a privately scoped result, which is what a server
179
+ # that declares no scope gets — the authorization context and effective
180
+ # parameters of the request that produced it. "Private responses MUST NOT
181
+ # be shared across authorization contexts (e.g. a different access token
182
+ # requires a different cache)", and the capabilities a server declares
183
+ # are exactly the kind of answer that differs between two tokens.
184
+ # @return [Boolean]
185
+ def discovery_refresh_needed?
186
+ return false unless respond_to?(:discovery_fresh?, true)
187
+ return true unless discovery_fresh?
188
+ return false unless respond_to?(:cache_fresh?, true)
189
+
190
+ reusable = cache_fresh?(:discover)
191
+ # The lookup holds the evaluation of the host's request_meta for the
192
+ # request it expected to follow. Nothing is sent when the result stands,
193
+ # so it is dropped rather than left on this thread for whichever request
194
+ # goes out next (see ResultCaching#release_serving_request_meta).
195
+ release_serving_request_meta if reusable && respond_to?(:release_serving_request_meta, true)
196
+ !reusable
197
+ end
198
+
157
199
  # Clean up the server connection
158
200
  def cleanup
159
201
  raise NotImplementedError, 'Subclasses must implement cleanup'
160
202
  end
161
203
 
204
+ # How many times this transport's session ended (cleanup, a restarted
205
+ # stdio process, a reconnect). Anything scoped to a session — task ids
206
+ # and their bookkeeping — is keyed by it, so state from a previous
207
+ # session never colours the next one.
208
+ # @return [Integer]
209
+ def session_epoch
210
+ @session_epoch || 0
211
+ end
212
+
213
+ protected
214
+
215
+ # Mark the current session as ended.
216
+ # @return [void]
217
+ def bump_session_epoch
218
+ @session_epoch = session_epoch + 1
219
+ end
220
+
221
+ public
222
+
162
223
  # Send a JSON-RPC request and return the result
163
224
  # @param method [String] JSON-RPC method name
164
225
  # @param params [Hash] parameters for the request
@@ -186,6 +247,31 @@ module MCPClient
186
247
  end
187
248
  end
188
249
 
250
+ # Open a long-lived notification stream (MCP 2026-07-28 subscriptions/listen).
251
+ # @param notifications [Hash] the SubscriptionFilter (tools_list_changed,
252
+ # prompts_list_changed, resources_list_changed, resource_subscriptions,
253
+ # snake_case or camelCase; an extension's own field, such as the tasks
254
+ # extension's task_ids, once it has registered it — see
255
+ # {MCPClient::Subscription.register_filter_field})
256
+ # @param ack_timeout [Numeric, false, nil] seconds to wait for the
257
+ # server's acknowledgment before giving the listen up; nil takes the
258
+ # transport's own read timeout, false waits for ever
259
+ # @yield [method, params] notifications delivered on the subscription
260
+ # @return [MCPClient::Subscription]
261
+ def listen(notifications:, ack_timeout: nil, &listener)
262
+ raise NotImplementedError, 'Subclasses must implement listen'
263
+ end
264
+
265
+ # Cancel a subscription opened with {#listen}.
266
+ # @param subscription [MCPClient::Subscription]
267
+ # @return [void]
268
+ def cancel_subscription(subscription)
269
+ raise NotImplementedError, 'Subclasses must implement cancel_subscription'
270
+ end
271
+
272
+ # @return [Logger] the transport logger
273
+ attr_reader :logger
274
+
189
275
  # Ping the MCP server to check connectivity (zero-parameter heartbeat call)
190
276
  # @return [Object] result from the ping request
191
277
  def ping
@@ -199,6 +285,43 @@ module MCPClient
199
285
  @notification_callback = block
200
286
  end
201
287
 
288
+ # Register a callback for the caches a notification invalidates, run
289
+ # *before* the notification is delivered to a subscription's listeners.
290
+ #
291
+ # A host layered above the transport (MCPClient::Client) keeps caches of
292
+ # its own, and they have to be gone by the time a listener reacting to a
293
+ # `list_changed` notification calls the cached list method. `on_notification`
294
+ # cannot serve for that: it is the last routing step, deliberately after
295
+ # the delivery, because it is host code that may block on the very reader
296
+ # the delivery came from. So the invalidation gets a hook of its own, ahead
297
+ # of the delivery, and only the invalidation goes on it.
298
+ # @yield [method, params] invoked before the notification is delivered
299
+ # @return [void]
300
+ # @see MCPClient::JsonRpcCommon#notify_cache_invalidation for where it runs
301
+ def on_cache_invalidation(&block)
302
+ @cache_invalidation_callback = block
303
+ end
304
+
305
+ # Map a resources/read error response to ResourceNotFound. MCP 2026-07-28
306
+ # (server/resources.mdx "Error Handling"): a missing resource is reported
307
+ # with -32602 (Invalid params); "for backwards compatibility, clients
308
+ # SHOULD also accept -32002 as a resource not found error".
309
+ # @param uri [String] the requested resource URI
310
+ # @param error [MCPClient::Errors::ServerError] the server's error response
311
+ # @return [MCPClient::Errors::ResourceNotFound]
312
+ def resource_not_found_error(uri, error)
313
+ MCPClient::Errors::ResourceNotFound.new("Resource '#{uri}' not found: #{error.message}")
314
+ end
315
+
316
+ # Whether a resources/read error response means the resource does not
317
+ # exist, given this session's protocol era.
318
+ # @param error [MCPClient::Errors::ServerError] the server's error response
319
+ # @return [Boolean]
320
+ def resource_not_found_response?(error)
321
+ modern = respond_to?(:modern?) && modern?
322
+ MCPClient::Errors::Codes.resource_not_found_code?(error.code, modern: modern)
323
+ end
324
+
202
325
  # Safety bound on the number of pages followed when auto-paginating a
203
326
  # cursor-based list operation, to protect against a server that returns
204
327
  # a nextCursor indefinitely.
@@ -228,7 +351,10 @@ module MCPClient
228
351
  items.concat(Array(page_items))
229
352
  pages += 1
230
353
 
231
- break if next_cursor.nil? || next_cursor.to_s.empty?
354
+ # Only a missing (null) nextCursor ends the list: a cursor is opaque,
355
+ # and the empty string is one a server may hand out. A cursor handed
356
+ # out twice stops the walk below.
357
+ break if next_cursor.nil?
232
358
 
233
359
  if seen_cursors[next_cursor]
234
360
  @logger.warn("Pagination for #{kind} stopped: server returned a repeated cursor #{next_cursor.inspect}")
@@ -259,9 +385,95 @@ module MCPClient
259
385
  # @return [Array<Hash>] all raw item hashes collected across pages
260
386
  # @raise [MCPClient::Errors::TransportError] if a page result is not a Hash or Array
261
387
  def request_paginated_list(method, key)
262
- collect_paginated(key) do |cursor|
388
+ # The cursor the page request now in flight carries, so a rejection can
389
+ # be told apart from an -32602 the first page's request earned.
390
+ page = { cursor: nil }
391
+ restarting_rejected_cursor(key, page) { collect_list_pages(method, key, page) }
392
+ end
393
+
394
+ # Collect a paginated list once more from its first page when the server
395
+ # rejects a cursor it had issued.
396
+ #
397
+ # MCP pagination: a cursor the server no longer accepts (-32602) ends the
398
+ # sequence the pages collected so far belong to, so the list is collected
399
+ # again from the beginning rather than failing a caller who only asked for
400
+ # a list. A second rejection is the server's answer and is raised, as is a
401
+ # rejection of the first page's own request — it carries no cursor.
402
+ # @param key [String] the result array key (for the log line)
403
+ # @param page [Hash] holds the cursor of the request in flight
404
+ # @yield collects every page of the list
405
+ # @return [Object] the block's value
406
+ def restarting_rejected_cursor(key, page)
407
+ restarted = false
408
+ begin
409
+ page[:cursor] = nil
410
+ yield
411
+ rescue MCPClient::Errors::ServerError => e
412
+ raise if restarted || !page[:cursor] || !cursor_rejected?(e)
413
+
414
+ @logger.warn("Pagination for #{key} restarted: the server rejected a cursor it had issued")
415
+ restarted = true
416
+ retry
417
+ end
418
+ end
419
+
420
+ # @param error [MCPClient::Errors::ServerError] a page request's failure
421
+ # @return [Boolean] whether it rejects the cursor the request carried
422
+ def cursor_rejected?(error)
423
+ respond_to?(:invalid_cursor_error?, true) && invalid_cursor_error?(error)
424
+ end
425
+
426
+ # Send one page request of an auto-paginated list, dropping the entry the
427
+ # list is cached under when the server rejects the cursor it carried.
428
+ #
429
+ # A cursor names a position in one sequence of pages: once the server has
430
+ # forgotten it, the pages cached from that sequence are gone with it, and
431
+ # a restart that then fails transiently must not serve them back. Only the
432
+ # entry goes — the fetch already collecting this list replaces the
433
+ # transport's own copy, and an invalidation of a fetch's own making is not
434
+ # a change the host has to hear about (which would restart that fetch).
435
+ # A rejection of the first page's request carries no cursor and says
436
+ # nothing about the cache, so it leaves it alone.
437
+ # @param kind [Symbol, nil] the list kind
438
+ # @param cursor [String, nil] the cursor this page request carries
439
+ # @yield sends the page request
440
+ # @return [Object] the block's value
441
+ def fetch_list_page(kind, cursor)
442
+ yield
443
+ rescue MCPClient::Errors::ServerError => e
444
+ raise unless kind && cursor && cursor_rejected?(e) && respond_to?(:invalidate_cache, true)
445
+
446
+ invalidate_cache(kind)
447
+ raise
448
+ end
449
+
450
+ # Collect every page of a list, recording what each page was answered
451
+ # under so the cache can bind the combined list to it.
452
+ # @param method [String] the list method
453
+ # @param key [String] the result array key
454
+ # @param page [Hash] holds the cursor of the request in flight
455
+ # @return [Array<Hash>] all raw item hashes collected across pages
456
+ def collect_list_pages(method, key, page)
457
+ pages = []
458
+ received_ats = []
459
+ contexts = []
460
+ fingerprints = []
461
+ page[:cursor] = nil
462
+ epoch = list_cache_epoch(method) if respond_to?(:list_cache_epoch, true)
463
+ kind = respond_to?(:list_kind_for, true) ? list_kind_for(method) : nil
464
+ items = collect_paginated(key) do |cursor|
263
465
  params = cursor ? { cursor: cursor } : {}
264
- result = rpc_request(method, params)
466
+ started = respond_to?(:monotonic_now, true) ? monotonic_now : nil
467
+ page[:cursor] = cursor
468
+ # A cursor the server no longer accepts ends the sequence its pages
469
+ # belong to: what was cached under that sequence goes with it, so a
470
+ # restart that then fails cannot serve it back (MCP pagination).
471
+ answer = fetch_list_page(kind, cursor) { rpc_request(method, params) }
472
+ result = require_complete_result!(answer, method)
473
+ pages << result
474
+ received_ats << response_received_at(since: started) if respond_to?(:response_received_at, true)
475
+ contexts << (respond_to?(:request_authorization_context, true) ? request_authorization_context : nil)
476
+ fingerprints << (respond_to?(:request_params_fingerprint, true) ? request_params_fingerprint : nil)
265
477
  case result
266
478
  when Hash
267
479
  [result[key] || [], result['nextCursor']]
@@ -272,6 +484,22 @@ module MCPClient
272
484
  "Invalid #{method} response: expected an object or array, got #{result.class}"
273
485
  end
274
486
  end
487
+ # MCP 2026-07-28 caching: every page carries its own ttlMs; the list is
488
+ # fresh only as long as its shortest-lived page, and pages fetched
489
+ # under differing credentials or parameters are never served combined.
490
+ if respond_to?(:record_list_cache_hint, true)
491
+ record_list_cache_hint(method, pages, received_ats, contexts: contexts, params: fingerprints, epoch: epoch)
492
+ end
493
+ items
494
+ end
495
+
496
+ # Whether a cached list of the given kind may still be served (MCP
497
+ # 2026-07-28 caching); transports without freshness hints keep caching
498
+ # until a change notification.
499
+ # @param _kind [Symbol]
500
+ # @return [Boolean]
501
+ def cache_fresh?(_kind)
502
+ true
275
503
  end
276
504
 
277
505
  # Initialize logger with proper formatter handling
@@ -38,7 +38,9 @@ module MCPClient
38
38
  read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
39
39
  name: config[:name],
40
40
  logger: logger,
41
- env: config[:env] || {}
41
+ env: config[:env] || {},
42
+ protocol: config[:protocol] || :auto,
43
+ discover_timeout: config[:discover_timeout]
42
44
  )
43
45
  end
44
46
 
@@ -78,7 +80,9 @@ module MCPClient
78
80
  name: config[:name],
79
81
  logger: logger,
80
82
  oauth_provider: config[:oauth_provider],
81
- faraday_config: config[:faraday_config]
83
+ faraday_config: config[:faraday_config],
84
+ protocol: config[:protocol] || :auto,
85
+ discover_timeout: config[:discover_timeout]
82
86
  )
83
87
  end
84
88
 
@@ -102,7 +106,9 @@ module MCPClient
102
106
  faraday_config: config[:faraday_config],
103
107
  max_decompressed_body_bytes:
104
108
  config[:max_decompressed_body_bytes] ||
105
- MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
109
+ MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
110
+ protocol: config[:protocol] || :auto,
111
+ discover_timeout: config[:discover_timeout]
106
112
  )
107
113
  end
108
114
 
@@ -12,17 +12,232 @@ module MCPClient
12
12
 
13
13
  # Parse an HTTP JSON-RPC response
14
14
  # @param response [Faraday::Response] the HTTP response
15
- # @param _request [Hash, nil] the originating JSON-RPC request (unused)
15
+ # @param request [Hash, nil] the originating JSON-RPC request
16
16
  # @return [Hash] the parsed result
17
17
  # @raise [MCPClient::Errors::TransportError] if parsing fails
18
18
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
19
- def parse_response(response, _request = nil)
20
- body = response.body.strip
21
- data = JSON.parse(body)
19
+ # A host's `conn.response :json` middleware decodes the body before it
20
+ # reaches here — the README offers that middleware for the error path,
21
+ # and it applies to every response — so an already-decoded object is
22
+ # taken as it is rather than parsed a second time. An event-stream body
23
+ # is never decoded by that middleware, so it is still the raw text.
24
+ def parse_response(response, request = nil)
25
+ # Host code a stream listener reached raised while the body was still
26
+ # arriving: that is this exchange's failure (already marked as a
27
+ # nested exchange's, so no recovery acts on it), raised in place of
28
+ # the response it was interleaved with — as it is when the completed
29
+ # body is parsed.
30
+ failure = stream_listener_error(response)
31
+ raise failure if failure
32
+
33
+ body = response.body
34
+ headers = response.respond_to?(:headers) ? response.headers || {} : {}
35
+ content_type = headers['content-type'] || headers['Content-Type'] || ''
36
+ # MCP 2026-07-28 Streamable HTTP: the server answers with either a
37
+ # single JSON object or an SSE stream scoped to the request; the
38
+ # client MUST support both.
39
+ data = if content_type.include?('text/event-stream')
40
+ # Not stripped: the blank line that terminates the final
41
+ # event is what makes it a delivered event at all.
42
+ response_from_sse(body.to_s, request && request['id'], live_event_count(response))
43
+ elsif body.is_a?(String)
44
+ JSON.parse(body.strip)
45
+ else
46
+ body
47
+ end
22
48
  process_jsonrpc_response(data)
23
49
  rescue JSON::ParserError => e
24
50
  raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
25
51
  end
52
+
53
+ # Every complete event of a response stream is handed over while the
54
+ # body is still arriving, so a legacy server's request on the stream
55
+ # is answered — and a progress notification delivered — before the
56
+ # server has to end the response. A notification has no response
57
+ # stream worth reading incrementally.
58
+ # @param request [Hash] the JSON-RPC message being sent
59
+ # @return [Proc, nil]
60
+ def response_stream_listener(request)
61
+ return nil unless request.is_a?(Hash) && request.key?('id')
62
+
63
+ ->(event) { dispatch_live_sse_event(event) }
64
+ end
65
+
66
+ # Act on one event as it arrives: requests and notifications are
67
+ # routed now, responses wait for the completed body. A failing
68
+ # callback is not allowed to abort the read of the response it was
69
+ # interleaved with: the capture middleware holds it and parse_response
70
+ # raises it once the body is in.
71
+ # @param event [String] one complete, LF-normalized SSE event
72
+ # @return [void]
73
+ def dispatch_live_sse_event(event)
74
+ message = sse_event_message(event)
75
+ dispatch_sse_message(message) if message && message['method']
76
+ end
77
+
78
+ # Pick the JSON-RPC response to the request out of an SSE-framed body,
79
+ # forwarding request-scoped notifications (progress, log messages) to
80
+ # the notification callback — except the `live` leading events, which
81
+ # were already routed as they arrived. Server-initiated requests are
82
+ # not permitted on a 2026-07-28 response stream and are dropped.
83
+ # @param sse_body [String] the text/event-stream body
84
+ # @param request_id [Integer, String, nil] id of the originating request
85
+ # @param live [Integer] events already dispatched while the body arrived
86
+ # @return [Hash] the JSON-RPC response
87
+ # @raise [MCPClient::Errors::TransportError] when the stream carries no response
88
+ def response_from_sse(sse_body, request_id, live = 0)
89
+ responses = []
90
+ saw_invalid_json = false
91
+ sse_events(sse_body).each_with_index do |event, index|
92
+ message = sse_event_message(event)
93
+ saw_invalid_json = true if message.nil? && event.lines.any? { |l| l.start_with?('data:') }
94
+ next unless message
95
+
96
+ if message['method']
97
+ dispatch_sse_message(message) if index >= live
98
+ else
99
+ responses << message
100
+ end
101
+ end
102
+ matched = responses.find { |m| request_id.nil? || m['id'] == request_id || m['id'].to_s == request_id.to_s }
103
+ matched ||= tolerated_id_mismatch(responses, request_id)
104
+ return matched if matched
105
+
106
+ # Every event that reaches here is terminated, so a data line that did
107
+ # not parse is a delivered (malformed) answer rather than a break
108
+ # inside one: the server ran the request, and re-issuing would run it
109
+ # again.
110
+ if saw_invalid_json
111
+ raise MCPClient::Errors::TransportError,
112
+ 'Invalid JSON response from server: SSE event carried no valid JSON-RPC message'
113
+ end
114
+
115
+ # The stream closed without the response: on a modern server the
116
+ # request is lost and must be re-issued (see rpc_request).
117
+ if modern?
118
+ raise MCPClient::Errors::ResponseStreamClosedError, 'SSE stream closed before delivering the response'
119
+ end
120
+
121
+ raise MCPClient::Errors::TransportError, 'No JSON-RPC response found in SSE response'
122
+ end
123
+
124
+ # Split a response stream into events, in the order and count the
125
+ # stream listener saw them.
126
+ #
127
+ # SSE line terminators are CRLF, CR or LF; a server framing its events
128
+ # with bare CR still delimits them, so normalize before splitting. An
129
+ # event is dispatched at its terminating blank line, so a body that
130
+ # ends inside an event delivered nothing for it: on a modern server
131
+ # that event is dropped (and a missing response re-issued). A legacy
132
+ # server keeps the benefit of the doubt this transport always gave it.
133
+ # @param sse_body [String] the text/event-stream body
134
+ # @return [Array<String>] the events, without their terminators
135
+ def sse_events(sse_body)
136
+ normalized = modern? ? complete_sse_events(sse_body) : normalize_sse_newlines(sse_body)
137
+ events = normalized.split("\n\n", -1)
138
+ events.pop if events.last.to_s.empty?
139
+ events
140
+ end
141
+
142
+ # @param event [String] one LF-normalized SSE event
143
+ # @return [Hash, nil] the JSON-RPC message its data lines carry, if any
144
+ def sse_event_message(event)
145
+ data_lines = event.lines.map(&:chomp).select { |l| l.start_with?('data:') }
146
+ return nil if data_lines.empty?
147
+
148
+ parse_sse_message(data_lines.map { |l| l.sub(/\Adata:\s*/, '') }.join("\n"))
149
+ end
150
+
151
+ # The only response on a stream, when its id is not the one asked for.
152
+ #
153
+ # A legacy server that echoes ids loosely — a string where an integer
154
+ # went out, or an id an intermediary rewrote — still gets the benefit of
155
+ # the doubt. A modern one does not: no response to THIS request arrived,
156
+ # so the request was lost and MCP 2026-07-28 says to re-issue it rather
157
+ # than complete it with the answer to something else.
158
+ # @param responses [Array<Hash>] the responses the stream carried
159
+ # @param request_id [Integer, String, nil] id of the originating request
160
+ # @return [Hash, nil] the response to accept, or nil to treat as lost
161
+ def tolerated_id_mismatch(responses, request_id)
162
+ return nil if modern? || responses.size != 1
163
+
164
+ @logger.warn("SSE response id #{responses.first['id'].inspect} does not match request id " \
165
+ "#{request_id.inspect}; accepting the only response on the stream")
166
+ responses.first
167
+ end
168
+
169
+ # Route a non-response message: notifications go to the callback, and a
170
+ # server-initiated request is answered on a legacy stream and dropped on
171
+ # a modern one.
172
+ # @param message [Hash] a JSON-RPC request or notification
173
+ # @return [void]
174
+ def dispatch_sse_message(message)
175
+ # Host code reached from here -- a notification listener -- may issue a
176
+ # request of its own while the response that carried this message is
177
+ # still being parsed. That request is an exchange of its own, and the
178
+ # call still waiting for this response must keep both its recorded
179
+ # definition and its own failures (HttpTransportBase::RequestRecovery#dispatching_to_host).
180
+ dispatching_to_host { dispatch_sse_message_now(message) }
181
+ end
182
+
183
+ # @param message [Hash] a JSON-RPC request or notification
184
+ # @return [void]
185
+ def dispatch_sse_message_now(message)
186
+ unless message.key?('id')
187
+ route_notification(message['method'], message['params'])
188
+ return
189
+ end
190
+
191
+ # MCP 2026-07-28: "The server MUST NOT send independent JSON-RPC
192
+ # requests on this stream" and clients MUST NOT POST responses to it,
193
+ # so there is nothing to answer with. Judged by the ESTABLISHED era:
194
+ # while the probe is in flight the version is only a proposal, and a
195
+ # legacy server may be waiting for its ping on the probe's stream.
196
+ if protocol_era == :modern
197
+ @logger.warn("Ignoring server-initiated request #{message['method']} on a response stream")
198
+ return
199
+ end
200
+
201
+ answer_server_request(message)
202
+ end
203
+
204
+ # Answer a server-initiated request on a legacy (2025-11-25 and earlier)
205
+ # response stream, where the server may send one and a receiver "MUST
206
+ # respond promptly" to ping. This transport serves no other
207
+ # server-initiated method — it has no elicitation, roots or sampling
208
+ # callbacks — so those get the JSON-RPC method-not-found answer rather
209
+ # than silence, which would leave the server waiting.
210
+ # @param message [Hash] the server's JSON-RPC request
211
+ # @return [void]
212
+ def answer_server_request(message)
213
+ send_http_request(server_request_answer(message))
214
+ rescue StandardError => e
215
+ @logger.error("Failed to answer server request #{message['method']}: #{e.message}")
216
+ end
217
+
218
+ # @param message [Hash] the server's JSON-RPC request
219
+ # @return [Hash] the JSON-RPC response to POST back
220
+ def server_request_answer(message)
221
+ answer = { 'jsonrpc' => '2.0', 'id' => message['id'] }
222
+ return answer.merge('result' => {}) if message['method'] == 'ping'
223
+
224
+ @logger.warn("Answering unsupported server request #{message['method']} with method not found")
225
+ answer.merge('error' => { 'code' => MCPClient::Errors::Codes::METHOD_NOT_FOUND,
226
+ 'message' => "Method not found: #{message['method']}" })
227
+ end
228
+
229
+ # @param json [String] one SSE event's data
230
+ # @return [Hash, nil] the parsed JSON-RPC message, nil when unusable
231
+ def parse_sse_message(json)
232
+ message = JSON.parse(json)
233
+ return message if message.is_a?(Hash)
234
+
235
+ @logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
236
+ nil
237
+ rescue JSON::ParserError => e
238
+ @logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json)}")
239
+ nil
240
+ end
26
241
  end
27
242
  end
28
243
  end