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,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'request_meta_scope'
3
4
  require 'uri'
4
5
  require 'json'
5
6
  require 'monitor'
@@ -7,26 +8,41 @@ require 'logger'
7
8
  require 'faraday'
8
9
  require 'faraday/retry'
9
10
  require 'faraday/follow_redirects'
11
+ require_relative 'deprecations'
10
12
 
11
13
  module MCPClient
12
14
  # Implementation of MCP server that communicates via Server-Sent Events (SSE)
13
15
  # Useful for communicating with remote MCP servers over HTTP
14
16
  #
17
+ # @deprecated The HTTP+SSE transport has been deprecated since MCP
18
+ # 2025-03-26 and is listed in the 2026-07-28 deprecated features
19
+ # registry (SEP-2596); earliest removal is three months after SEP-2596
20
+ # reaches Final. Use {MCPClient::ServerStreamableHTTP}.
21
+ #
15
22
  # @note Elicitation Support (MCP 2025-06-18)
16
23
  # This transport FULLY supports server-initiated elicitation requests via bidirectional
17
24
  # JSON-RPC. The server sends elicitation/create requests via the SSE stream, and the
18
25
  # client responds via HTTP POST to the RPC endpoint. This provides full elicitation
19
26
  # capability for remote servers.
20
27
  class ServerSSE < ServerBase
28
+ require_relative 'request_authorization'
21
29
  require_relative 'server_sse/sse_parser'
22
30
  require_relative 'server_sse/json_rpc_transport'
23
31
 
24
32
  include SseParser
25
33
  include JsonRpcTransport
34
+ # The credentials of the request this thread last sent, kept in the same
35
+ # slot, with the same empty-slot semantics, as on the other transports:
36
+ # {#cleanup} drops it with the rest of what this transport left behind.
37
+ include MCPClient::RequestAuthorization
26
38
 
27
39
  require_relative 'server_sse/reconnect_monitor'
28
40
 
29
41
  include ReconnectMonitor
42
+ # Every operation that may weigh a cache decision runs inside a scope
43
+ # that reserves the host request_meta evaluation for the request it
44
+ # leads to, and drops it when the operation ends.
45
+ prepend MCPClient::RequestMetaScope
30
46
 
31
47
  # Ratio of close_after timeout to ping interval
32
48
  CLOSE_AFTER_PING_RATIO = 2.5
@@ -144,21 +160,23 @@ module MCPClient
144
160
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
145
161
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
146
162
  def list_prompts
147
- @mutex.synchronize do
148
- return @prompts if @prompts
149
- end
163
+ # MCP 2026-07-28 caching: a list is served only while its hint is
164
+ # fresh, and only from the entry that carries the hint.
165
+ cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
166
+ return cached if cached
167
+
168
+ @mutex.synchronize { @prompts_data = nil }
150
169
 
151
170
  begin
152
171
  ensure_initialized
153
172
 
154
- prompts_data = request_prompts_list
173
+ prompts = request_prompts_list.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
155
174
  @mutex.synchronize do
156
- @prompts = prompts_data.map do |prompt_data|
157
- MCPClient::Prompt.from_json(prompt_data, server: self)
158
- end
175
+ @prompts = attach_list_value(:prompts, prompts) ? prompts : nil
159
176
  end
160
177
 
161
- @mutex.synchronize { @prompts }
178
+ # This request's own list, never a re-read of @prompts.
179
+ prompts
162
180
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
163
181
  # Re-raise these errors directly
164
182
  raise
@@ -180,6 +198,12 @@ module MCPClient
180
198
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
181
199
  # Re-raise connection/transport errors directly to match test expectations
182
200
  raise
201
+ rescue MCPClient::Errors::ServerError => e
202
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
203
+ # actionable data such as requiredCapabilities; keep them intact.
204
+ raise if e.protocol_error?
205
+
206
+ raise MCPClient::Errors::PromptGetError, "Error get prompt '#{prompt_name}': #{e.message}"
183
207
  rescue StandardError => e
184
208
  # For all other errors, wrap in PromptGetError
185
209
  raise MCPClient::Errors::PromptGetError, "Error get prompt '#{prompt_name}': #{e.message}"
@@ -192,16 +216,21 @@ module MCPClient
192
216
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
193
217
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
194
218
  def list_resources(cursor: nil)
195
- @mutex.synchronize do
196
- return @resources_result if @resources_result && !cursor
197
- end
219
+ cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
220
+ return cached if cached
198
221
 
199
222
  begin
200
223
  ensure_initialized
201
224
 
202
225
  params = {}
203
226
  params['cursor'] = cursor if cursor
204
- result = rpc_request('resources/list', params)
227
+ epoch = cache_epoch(:resources)
228
+ answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
229
+ result = require_complete_result!(answer, 'resources/list')
230
+ # MCP 2026-07-28 caching: the first page's hint decides how long the
231
+ # cached list may be served, counted from receipt (the list is
232
+ # attached once converted).
233
+ record_cache_hint(:resources, result, epoch: epoch) unless cursor
205
234
 
206
235
  resources = (result['resources'] || []).map do |resource_data|
207
236
  MCPClient::Resource.from_json(resource_data, server: self)
@@ -210,7 +239,9 @@ module MCPClient
210
239
  resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
211
240
 
212
241
  @mutex.synchronize do
213
- @resources_result = resources_result unless cursor
242
+ unless cursor
243
+ @resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
244
+ end
214
245
  end
215
246
 
216
247
  resources_result
@@ -230,9 +261,13 @@ module MCPClient
230
261
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
231
262
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
232
263
  def read_resource(uri)
233
- result = rpc_request('resources/read', { uri: uri })
234
- contents = result['contents'] || []
235
- contents.map { |content| MCPClient::ResourceContent.from_json(content) }
264
+ ensure_initialized
265
+ read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { uri: sent }) }
266
+ rescue MCPClient::Errors::ServerError => e
267
+ raise if e.protocol_error?
268
+ raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
269
+
270
+ raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
236
271
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
237
272
  # Re-raise connection/transport errors directly to match test expectations
238
273
  raise
@@ -247,16 +282,35 @@ module MCPClient
247
282
  # @raise [MCPClient::Errors::ServerError] if server returns an error
248
283
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
249
284
  def list_resource_templates(cursor: nil)
285
+ # Only a list the server itself bounded is served from here: a
286
+ # positive ttlMs means no second request, while a list with no hint
287
+ # (a 2025-11-25 server) is asked for again, as it was before this
288
+ # transport cached anything (MCP 2026-07-28 caching).
289
+ cached = cursor ? nil : hinted_list_value(:templates)
290
+ return cached if cached
291
+
250
292
  ensure_initialized
251
293
  params = {}
252
294
  params['cursor'] = cursor if cursor
253
- result = rpc_request('resources/templates/list', params)
295
+ epoch = cache_epoch(:templates)
296
+ answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
297
+ result = require_complete_result!(answer, 'resources/templates/list')
298
+ # MCP 2026-07-28 caching: the first page's hint decides freshness,
299
+ # counted from receipt (the list is attached once converted).
300
+ record_cache_hint(:templates, result, epoch: epoch) unless cursor
254
301
 
255
302
  templates = (result['resourceTemplates'] || []).map do |template_data|
256
303
  MCPClient::ResourceTemplate.from_json(template_data, server: self)
257
304
  end
305
+ templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
258
306
 
259
- { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
307
+ @mutex.synchronize do
308
+ unless cursor
309
+ @templates_result = attach_list_value(:templates, templates_result) ? templates_result : nil
310
+ end
311
+ end
312
+
313
+ templates_result
260
314
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
261
315
  raise
262
316
  rescue StandardError => e
@@ -303,21 +357,23 @@ module MCPClient
303
357
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
304
358
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
305
359
  def list_tools
306
- @mutex.synchronize do
307
- return @tools if @tools
308
- end
360
+ # MCP 2026-07-28 caching: a list is served only while its hint is
361
+ # fresh, and only from the entry that carries the hint.
362
+ cached = fresh_list_value(:tools) { @mutex.synchronize { @tools } }
363
+ return cached if cached
364
+
365
+ @mutex.synchronize { @tools_data = nil }
309
366
 
310
367
  begin
311
368
  ensure_initialized
312
369
 
313
- tools_data = request_tools_list
370
+ tools = request_tools_list.map { |tool_data| MCPClient::Tool.from_json(tool_data, server: self) }
314
371
  @mutex.synchronize do
315
- @tools = tools_data.map do |tool_data|
316
- MCPClient::Tool.from_json(tool_data, server: self)
317
- end
372
+ @tools = attach_list_value(:tools, tools) ? tools : nil
318
373
  end
319
374
 
320
- @mutex.synchronize { @tools }
375
+ # This request's own list, never a re-read of @tools.
376
+ tools
321
377
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
322
378
  # Re-raise these errors directly
323
379
  raise
@@ -339,6 +395,12 @@ module MCPClient
339
395
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
340
396
  # Re-raise connection/transport errors directly to match test expectations
341
397
  raise
398
+ rescue MCPClient::Errors::ServerError => e
399
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
400
+ # actionable data such as requiredCapabilities; keep them intact.
401
+ raise if e.protocol_error?
402
+
403
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
342
404
  rescue StandardError => e
343
405
  # For all other errors, wrap in ToolCallError
344
406
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
@@ -355,11 +417,17 @@ module MCPClient
355
417
  require_capability!('completions', method: 'completion/complete')
356
418
  params = { ref: ref, argument: argument }
357
419
  params[:context] = context if context
358
- result = rpc_request('completion/complete', params)
420
+ result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
359
421
  result['completion'] || { 'values' => [] }
360
422
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
361
423
  MCPClient::Errors::CapabilityError
362
424
  raise
425
+ rescue MCPClient::Errors::ServerError => e
426
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
427
+ # actionable data such as requiredCapabilities; keep them intact.
428
+ raise if e.protocol_error?
429
+
430
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
363
431
  rescue StandardError => e
364
432
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
365
433
  end
@@ -369,13 +437,24 @@ module MCPClient
369
437
  # 'critical', 'alert', 'emergency')
370
438
  # @return [Hash] empty result on success
371
439
  # @raise [MCPClient::Errors::ServerError] if server returns an error
440
+ # @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
441
+ # earliest removal is the first revision released on or after
442
+ # 2027-07-28. Have the server log to stderr (stdio) or use
443
+ # OpenTelemetry instead.
372
444
  def log_level=(level)
445
+ MCPClient::Deprecations.warn(:logging, @logger)
373
446
  ensure_initialized
374
447
  require_capability!('logging', method: 'logging/setLevel')
375
448
  rpc_request('logging/setLevel', { level: level })
376
449
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
377
450
  MCPClient::Errors::CapabilityError
378
451
  raise
452
+ rescue MCPClient::Errors::ServerError => e
453
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
454
+ # actionable data such as requiredCapabilities; keep them intact.
455
+ raise if e.protocol_error?
456
+
457
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
379
458
  rescue StandardError => e
380
459
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
381
460
  end
@@ -384,7 +463,15 @@ module MCPClient
384
463
  # @return [Boolean] true if connection was successful
385
464
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
386
465
  def connect
387
- return true if @mutex.synchronize { @connection_established }
466
+ # An already-established connection is another use of the transport,
467
+ # and another chance at the notice: the first connect may have found
468
+ # notices disabled, a logger above WARN or a logger that raised, all of
469
+ # which leave the slot unspent. A long-lived connection would otherwise
470
+ # never retry it.
471
+ if @mutex.synchronize { @connection_established }
472
+ MCPClient::Deprecations.warn(:http_sse_transport, @logger)
473
+ return true
474
+ end
388
475
 
389
476
  # Check for pre-existing auth error (needed for tests)
390
477
  pre_existing_auth_error = @mutex.synchronize { @auth_error }
@@ -399,6 +486,10 @@ module MCPClient
399
486
  effective_timeout = [@read_timeout || 30, 30].min
400
487
  wait_for_connection(timeout: effective_timeout)
401
488
  start_activity_monitor
489
+ # The notice is about using the transport: MCPClient.connect builds
490
+ # (and tries) an SSE server while probing a URL that may end up on
491
+ # another transport, so only an established connection counts.
492
+ MCPClient::Deprecations.warn(:http_sse_transport, @logger)
402
493
  true
403
494
  rescue MCPClient::Errors::ConnectionError => e
404
495
  cleanup
@@ -423,6 +514,12 @@ module MCPClient
423
514
  # multiple connection attempts. This is essential for proper reconnection
424
515
  # logic and exponential backoff.
425
516
  def cleanup
517
+ # Everything this transport left on this thread — the notes of the
518
+ # entries it served and recorded, the credentials, parameters and
519
+ # metadata of its requests — describes a slice that will never be
520
+ # tagged and a request that will never be made.
521
+ forget_transport_thread_state
522
+ bump_session_epoch
426
523
  @mutex.synchronize do
427
524
  # Set flags first before killing threads to prevent race conditions
428
525
  # where threads might check flags after they're set but before they're killed
@@ -493,9 +590,48 @@ module MCPClient
493
590
  @sse_conn = nil
494
591
 
495
592
  @tools = nil
593
+ @tools_data = nil
594
+ @prompts = nil
595
+ @prompts_data = nil
596
+ @resources_result = nil
597
+ @templates_result = nil
496
598
  # Don't clear auth error as we need it for reporting the correct error
497
599
  # Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
498
600
  end
601
+ # Cached results and their hints belong to the connection that was just
602
+ # torn down (MCP 2026-07-28 caching); outside @mutex, as the cache has
603
+ # its own lock.
604
+ clear_result_cache
605
+ end
606
+
607
+ # The authorization context of this transport (MCP 2026-07-28 caching,
608
+ # cacheScope "private"): HTTP+SSE sends the static Authorization header
609
+ # it was configured with, so that header is both what the next request
610
+ # would carry and what the last one carried.
611
+ # @param _kind [Symbol, String, nil] the cache kind (every request carries the same header)
612
+ # @return [String, nil]
613
+ def current_authorization_context(_kind = nil)
614
+ # Through Faraday's case-insensitive table, so several spellings of
615
+ # the header in the configured hash resolve to the one value a
616
+ # request would actually carry.
617
+ authorization_fingerprint(authorization_header_value(faraday_headers(@headers)))
618
+ end
619
+
620
+ # Remember the Authorization a request actually carried once it was sent,
621
+ # for a request that recorded none of its own. What binds the result is
622
+ # what the request was built with: `response.env` is mutable and the
623
+ # response phase may rewrite it (a redaction, a redirect that strips the
624
+ # header), which would file an authenticated result under the anonymous
625
+ # context.
626
+ # @param response [Faraday::Response, nil]
627
+ # @return [void]
628
+ def note_sent_authorization(response)
629
+ return if request_authorization_recorded?
630
+
631
+ env = response.respond_to?(:env) ? response.env : nil
632
+ return unless env.respond_to?(:request_headers) && env.request_headers
633
+
634
+ note_request_authorization(authorization_header_value(env.request_headers))
499
635
  end
500
636
 
501
637
  # Register a callback for elicitation requests (MCP 2025-06-18)
@@ -506,6 +642,13 @@ module MCPClient
506
642
  end
507
643
 
508
644
  # Register a callback for roots/list requests (MCP 2025-06-18)
645
+ #
646
+ # @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
647
+ # removal is the first revision released on or after 2027-07-28. Registering
648
+ # a handler is not itself use of Roots — a handler that answers with no root
649
+ # exposes nothing deprecated — but a handler that answers with a root adopts
650
+ # the deprecated feature and raises the notice. Pass directories or files
651
+ # through tool parameters, resource URIs or server configuration instead.
509
652
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
510
653
  # @return [void]
511
654
  def on_roots_list_request(&block)
@@ -513,6 +656,11 @@ module MCPClient
513
656
  end
514
657
 
515
658
  # Register a callback for sampling requests (MCP 2025-11-25)
659
+ #
660
+ # @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
661
+ # removal is the first revision released on or after 2027-07-28. Integrate
662
+ # directly with the LLM provider API instead of serving
663
+ # sampling/createMessage.
516
664
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
517
665
  # @return [void]
518
666
  def on_sampling_request(&block)
@@ -599,6 +747,10 @@ module MCPClient
599
747
 
600
748
  # Call the registered callback
601
749
  result = @roots_list_request_callback.call(request_id, params)
750
+ # Serving a roots/list answer that carries a root means this host
751
+ # declared, and is using, the deprecated Roots capability (SEP-2577) —
752
+ # with or without a Client. An empty answer is not use of it.
753
+ warn_roots_deprecated(result)
602
754
 
603
755
  # Send the response back to the server (echoing related-task _meta)
604
756
  send_roots_list_response(request_id, merge_related_task_meta(result, params))
@@ -631,10 +783,18 @@ module MCPClient
631
783
  # If no callback is registered, return error
632
784
  unless @sampling_request_callback
633
785
  @logger.warn('Received sampling request but no callback registered, returning error')
634
- send_error_response(request_id, -1, 'Sampling not supported')
786
+ # sampling.mdx § Error Handling reserves -1 for "User rejected sampling
787
+ # request"; a capability this client never declared is an unsupported
788
+ # method (-32601, Method not found), as Client#handle_sampling_request answers.
789
+ send_error_response(request_id, -32_601, 'Sampling not supported')
635
790
  return
636
791
  end
637
792
 
793
+ # Sampling, and the includeContext values it may carry, are deprecated
794
+ # (SEP-2577, SEP-2596) — with or without a Client.
795
+ warn_sampling_deprecated(params)
796
+ return if refused_undeclared_sampling_tools?(request_id, params)
797
+
638
798
  # Call the registered callback
639
799
  result = @sampling_request_callback.call(request_id, params)
640
800
 
@@ -741,6 +901,7 @@ module MCPClient
741
901
  # header on all HTTP requests after the initialize handshake.
742
902
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
743
903
  @headers.each { |k, v| req.headers[k] = v }
904
+ note_request_authorization(authorization_header_value(req.headers))
744
905
  req.body = json_body
745
906
  end
746
907
 
@@ -804,6 +965,7 @@ module MCPClient
804
965
  def establish_sse_connection(conn, sse_path)
805
966
  conn.get(sse_path) do |req|
806
967
  @headers.each { |k, v| req.headers[k] = v }
968
+ note_request_authorization(authorization_header_value(req.headers))
807
969
 
808
970
  req.options.on_data = proc do |chunk, _bytes|
809
971
  process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
@@ -866,6 +1028,11 @@ module MCPClient
866
1028
  # Process an SSE chunk from the server
867
1029
  # @param chunk [String] the chunk to process
868
1030
  def process_sse_chunk(chunk)
1031
+ # Every event in this chunk arrived together, before any of them was
1032
+ # decoded or dispatched: a response is dated from that moment, so a
1033
+ # slow callback on an earlier event in the same chunk cannot make it
1034
+ # look younger than the ttlMs its server sent (MCP 2026-07-28 caching).
1035
+ arrived = monotonic_now if respond_to?(:monotonic_now, true)
869
1036
  # Size only: the chunk is raw wire data carrying sampling prompts,
870
1037
  # elicitation content and tool results.
871
1038
  @logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
@@ -879,7 +1046,7 @@ module MCPClient
879
1046
  event_buffers = extract_complete_events(chunk)
880
1047
 
881
1048
  # Process extracted events outside the mutex to avoid deadlocks
882
- event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
1049
+ event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data, arrived) }
883
1050
  end
884
1051
 
885
1052
  # Check if the error represents an authorization error
@@ -1015,16 +1182,32 @@ module MCPClient
1015
1182
  # @return [Array<Hash>] the prompts data
1016
1183
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
1017
1184
  # @private
1018
- def request_prompts_list
1185
+ # Drop a cached list so the next access re-fetches it (change
1186
+ # notification, MCP 2026-07-28 caching).
1187
+ # @param kind [Symbol] :tools, :prompts or :resources
1188
+ # @return [void]
1189
+ def invalidate_list_cache(kind)
1019
1190
  @mutex.synchronize do
1020
- return @prompts_data.dup if @prompts_data
1191
+ case kind
1192
+ when :tools
1193
+ @tools = nil
1194
+ @tools_data = nil
1195
+ when :prompts
1196
+ @prompts = nil
1197
+ @prompts_data = nil
1198
+ when :resources
1199
+ @resources_result = nil
1200
+ when :templates
1201
+ # resources/list_changed covers resources/templates/list too.
1202
+ @templates_result = nil
1203
+ end
1021
1204
  end
1205
+ end
1022
1206
 
1023
- # Follow nextCursor across pages so the full prompt list is returned.
1024
- prompts = request_paginated_list('prompts/list', 'prompts')
1025
-
1026
- @mutex.synchronize { @prompts_data = prompts }
1027
- @mutex.synchronize { @prompts_data.dup }
1207
+ def request_prompts_list
1208
+ # Follow nextCursor across pages so the full prompt list is returned;
1209
+ # the raw pages are this fetch's own (see request_tools_list).
1210
+ request_paginated_list('prompts/list', 'prompts')
1028
1211
  end
1029
1212
 
1030
1213
  # Request the resources list using JSON-RPC
@@ -1032,23 +1215,11 @@ module MCPClient
1032
1215
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
1033
1216
  # @private
1034
1217
  def request_resources_list
1035
- @mutex.synchronize do
1036
- return @resources_data if @resources_data
1037
- end
1038
-
1218
+ # The raw list is this fetch's own (see request_tools_list).
1039
1219
  result = rpc_request('resources/list')
1040
1220
 
1041
- if result && result['resources']
1042
- @mutex.synchronize do
1043
- @resources_data = result['resources']
1044
- end
1045
- return @mutex.synchronize { @resources_data.dup }
1046
- elsif result
1047
- @mutex.synchronize do
1048
- @resources_data = result
1049
- end
1050
- return @mutex.synchronize { @resources_data.dup }
1051
- end
1221
+ return result['resources'].dup if result && result['resources']
1222
+ return result.dup if result
1052
1223
 
1053
1224
  raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
1054
1225
  end
@@ -1058,17 +1229,11 @@ module MCPClient
1058
1229
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
1059
1230
  # @private
1060
1231
  def request_tools_list
1061
- @mutex.synchronize do
1062
- return @tools_data.dup if @tools_data
1063
- end
1064
-
1065
1232
  # Follow nextCursor across pages so the full tool list is returned. The
1066
- # SSE parser no longer writes @tools_data per page, so this method is the
1067
- # sole writer and only ever caches the COMPLETE list.
1068
- tools = request_paginated_list('tools/list', 'tools')
1069
-
1070
- @mutex.synchronize { @tools_data = tools }
1071
- @mutex.synchronize { @tools_data.dup }
1233
+ # raw pages are this fetch's own: a shared copy could answer a
1234
+ # concurrent caller under other credentials with a privately scoped
1235
+ # list (MCP 2026-07-28 caching).
1236
+ request_paginated_list('tools/list', 'tools')
1072
1237
  end
1073
1238
  end
1074
1239
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ class ServerStdio
5
+ # The record of one server child process: when the open subscriptions were
6
+ # handed to it, and when its handles were torn down. One record per
7
+ # process, written only by that process's own lifecycle, so nothing
8
+ # another thread does to another process can change what this one says.
9
+ #
10
+ # It exists for the crash-loop bound. That bound used to live in flags and
11
+ # a timestamp on the transport, which two restarts (or a restart and a host
12
+ # request that re-established the process first) raced over: whichever
13
+ # finished last decided what the *other* process's uptime had been, and a
14
+ # server that exited on sight could be respawned for ever. The question a
15
+ # restart actually has to answer is about one particular process — "did the
16
+ # last process we gave these subscriptions to die straight after we gave
17
+ # them to it?" — and both facts it needs are recorded here, on that
18
+ # process, at the two moments they happen.
19
+ class ChildSession
20
+ # @return [Float, nil] monotonic time the open subscriptions were re-sent
21
+ # to this process, nil if it was never asked to carry any
22
+ attr_reader :carried_at
23
+ # @return [Float, nil] monotonic time this process's handles were torn
24
+ # down, nil while it is still the live session
25
+ attr_reader :ended_at
26
+
27
+ def initialize
28
+ @carried_at = nil
29
+ @ended_at = nil
30
+ @exited_unexpectedly = false
31
+ end
32
+
33
+ # @return [Boolean] whether this process went on its own rather than
34
+ # being torn down by the client
35
+ def exited_unexpectedly?
36
+ @exited_unexpectedly
37
+ end
38
+
39
+ # @return [Float] monotonic seconds
40
+ def self.now
41
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ end
43
+
44
+ # This process has been handed the subscriptions a previous process left
45
+ # open. Stamped before they go out, not after: the process can exit while
46
+ # they are still being written, and an exit under the re-send is the
47
+ # clearest crash loop there is.
48
+ # @return [void]
49
+ def carrying_subscriptions
50
+ @carried_at = ChildSession.now
51
+ end
52
+
53
+ # This process is gone (its stdio handles have been torn down). First
54
+ # stamp wins: a host `cleanup` racing the reader's own teardown must not
55
+ # move the moment the process ended.
56
+ # @return [void]
57
+ def ended
58
+ return if @ended_at
59
+
60
+ @ended_at = ChildSession.now
61
+ end
62
+
63
+ # This process exited on its own — the reader saw EOF on a stdin the
64
+ # client had not closed (see
65
+ # {MCPClient::ServerStdio#handle_server_exit}). Recorded separately from
66
+ # {#ended}, which every teardown stamps, because only an exit is a crash.
67
+ # @return [void]
68
+ def exited_unexpectedly
69
+ @exited_unexpectedly = true
70
+ end
71
+
72
+ # Whether this process died too soon after being given the subscriptions
73
+ # to be given them again — the crash loop the restart bound exists to
74
+ # stop. A process that was never given any is not part of that loop, and
75
+ # one that is still alive has not ended anything.
76
+ #
77
+ # Neither is a process the client itself shut down. A host that closes
78
+ # the transport and reconnects — as a `cleanup`/request cycle does, and
79
+ # as re-authenticating or re-configuring a server does — tears the
80
+ # process down whenever it likes, and reading that as the server crashing
81
+ # closed the very subscriptions the reconnect exists to carry across.
82
+ # Only an exit the reader actually watched happen counts.
83
+ #
84
+ # The interval is measured from the moment it received them, so a server
85
+ # that is slow to start is credited with none of its own handshake, and a
86
+ # process that was already gone when they were sent to it (its exit
87
+ # stamped before the re-send) counts as having survived no time at all.
88
+ # @param min_uptime [Numeric] seconds it had to last (see
89
+ # {MCPClient::ServerStdio::SUBSCRIPTION_RESTART_MIN_INTERVAL})
90
+ # @return [Boolean]
91
+ def died_carrying_subscriptions?(min_uptime)
92
+ return false unless @exited_unexpectedly && @carried_at && @ended_at
93
+
94
+ (@ended_at - @carried_at) < min_uptime
95
+ end
96
+ end
97
+ end
98
+ end