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'
@@ -22,6 +23,10 @@ module MCPClient
22
23
  require_relative 'server_streamable_http/json_rpc_transport'
23
24
 
24
25
  include JsonRpcTransport
26
+ # Every operation that may weigh a cache decision runs inside a scope
27
+ # that reserves the host request_meta evaluation for the request it
28
+ # leads to, and drops it when the operation ends.
29
+ prepend MCPClient::RequestMetaScope
25
30
 
26
31
  # Default values for connection settings
27
32
  DEFAULT_READ_TIMEOUT = 30
@@ -143,6 +148,7 @@ module MCPClient
143
148
  })
144
149
 
145
150
  @read_timeout = opts[:read_timeout]
151
+ configure_protocol_mode(opts[:protocol], opts[:discover_timeout])
146
152
  @faraday_config = opts[:faraday_config]
147
153
  @max_decompressed_body_bytes = validate_decompression_limit(opts[:max_decompressed_body_bytes])
148
154
  @tools = nil
@@ -181,35 +187,39 @@ module MCPClient
181
187
  # @return [Boolean] true if connection was successful
182
188
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
183
189
  def connect
184
- return true if @mutex.synchronize { @connection_established }
190
+ # Serialized: concurrent first requests must not each run the probe
191
+ # and possibly settle on different eras (the monitor is reentrant, so
192
+ # the request plumbing inside may take @mutex again).
193
+ @mutex.synchronize do
194
+ return true if @connection_established
185
195
 
186
- begin
187
- @mutex.synchronize do
196
+ begin
188
197
  @connection_established = false
189
198
  @initialized = false
190
- end
191
199
 
192
- # Test connectivity with a simple HTTP request
193
- test_connection
200
+ # Test connectivity with a simple HTTP request
201
+ test_connection
194
202
 
195
- # Perform MCP initialization handshake
196
- perform_initialize
203
+ # Establish the protocol era: server/discover for a modern server,
204
+ # the initialize handshake for a legacy one.
205
+ negotiate_protocol
197
206
 
198
- # Start long-lived GET connection for server events
199
- start_events_connection
207
+ # Long-lived GET stream for server events: legacy only. MCP
208
+ # 2026-07-28 removed the GET endpoint; change notifications arrive
209
+ # on subscriptions/listen streams instead.
210
+ start_events_connection unless modern?
200
211
 
201
- @mutex.synchronize do
202
212
  @connection_established = true
203
213
  @initialized = true
204
- end
205
214
 
206
- true
207
- rescue MCPClient::Errors::ConnectionError => e
208
- cleanup
209
- raise e
210
- rescue StandardError => e
211
- cleanup
212
- raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
215
+ true
216
+ rescue MCPClient::Errors::ConnectionError => e
217
+ cleanup
218
+ raise e
219
+ rescue StandardError => e
220
+ cleanup
221
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
222
+ end
213
223
  end
214
224
  end
215
225
 
@@ -219,21 +229,18 @@ module MCPClient
219
229
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
220
230
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
221
231
  def list_tools
222
- @mutex.synchronize do
223
- return @tools if @tools
224
- end
225
-
232
+ # MCP 2026-07-28 caching: a cached list is served only while fresh, and
233
+ # only from the entry that carries its hint.
234
+ cached = fresh_list_value(:tools) { @mutex.synchronize { @tools } }
235
+ return cached if cached
236
+
237
+ # Stale: the raw page cache must go too, or the re-fetch would be
238
+ # answered from memory.
239
+ @mutex.synchronize { @tools_data = nil }
226
240
  begin
227
241
  ensure_connected
228
242
 
229
- tools_data = request_tools_list
230
- @mutex.synchronize do
231
- @tools = tools_data.map do |tool_data|
232
- MCPClient::Tool.from_json(tool_data, server: self)
233
- end
234
- end
235
-
236
- @mutex.synchronize { @tools }
243
+ refetch_or_serve_stale(:tools, stale_list_entry(:tools)) { fetch_tools_list }
237
244
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
238
245
  # Re-raise these errors directly
239
246
  raise
@@ -252,9 +259,15 @@ module MCPClient
252
259
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
253
260
  def call_tool(tool_name, parameters)
254
261
  rpc_request('tools/call', build_named_request_params(tool_name, parameters))
255
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
262
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ValidationError
256
263
  # Re-raise connection/transport errors directly to match test expectations
257
264
  raise
265
+ rescue MCPClient::Errors::ServerError => e
266
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
267
+ # actionable data such as requiredCapabilities; keep them intact.
268
+ raise if e.protocol_error?
269
+
270
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
258
271
  rescue StandardError => e
259
272
  # For all other errors, wrap in ToolCallError
260
273
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
@@ -281,11 +294,17 @@ module MCPClient
281
294
  require_capability!('completions', method: 'completion/complete')
282
295
  params = { ref: ref, argument: argument }
283
296
  params[:context] = context if context
284
- result = rpc_request('completion/complete', params)
297
+ result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
285
298
  result['completion'] || { 'values' => [] }
286
299
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
287
300
  MCPClient::Errors::CapabilityError
288
301
  raise
302
+ rescue MCPClient::Errors::ServerError => e
303
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
304
+ # actionable data such as requiredCapabilities; keep them intact.
305
+ raise if e.protocol_error?
306
+
307
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
289
308
  rescue StandardError => e
290
309
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
291
310
  end
@@ -295,13 +314,31 @@ module MCPClient
295
314
  # 'critical', 'alert', 'emergency')
296
315
  # @return [Hash] empty result on success
297
316
  # @raise [MCPClient::Errors::ServerError] if server returns an error
317
+ # @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
318
+ # earliest removal is the first revision released on or after
319
+ # 2027-07-28. Have the server log to stderr (stdio) or use
320
+ # OpenTelemetry instead.
298
321
  def log_level=(level)
322
+ MCPClient::Deprecations.warn(:logging, @logger)
299
323
  ensure_connected
324
+ # MCP 2026-07-28 removed logging/setLevel: the level travels per request
325
+ # in _meta["io.modelcontextprotocol/logLevel"].
326
+ if modern?
327
+ @log_level = validate_log_level!(level)
328
+ return
329
+ end
330
+
300
331
  require_capability!('logging', method: 'logging/setLevel')
301
332
  rpc_request('logging/setLevel', { level: level })
302
333
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
303
- MCPClient::Errors::CapabilityError
334
+ MCPClient::Errors::CapabilityError, ArgumentError
304
335
  raise
336
+ rescue MCPClient::Errors::ServerError => e
337
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
338
+ # actionable data such as requiredCapabilities; keep them intact.
339
+ raise if e.protocol_error?
340
+
341
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
305
342
  rescue StandardError => e
306
343
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
307
344
  end
@@ -310,29 +347,40 @@ module MCPClient
310
347
  # @return [Array<MCPClient::Prompt>] list of available prompts
311
348
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
312
349
  def list_prompts
313
- @mutex.synchronize do
314
- return @prompts if @prompts
315
- end
350
+ cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
351
+ return cached if cached
316
352
 
353
+ @mutex.synchronize { @prompts_data = nil }
317
354
  begin
318
355
  ensure_connected
319
-
320
- prompts_data = request_prompts_list
321
- @mutex.synchronize do
322
- @prompts = prompts_data.map do |prompt_data|
323
- MCPClient::Prompt.from_json(prompt_data, server: self)
324
- end
325
- end
326
-
327
- @mutex.synchronize { @prompts }
356
+ refetch_or_serve_stale(:prompts, stale_list_entry(:prompts)) { fetch_prompts_list }
328
357
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
329
- # Re-raise these errors directly
330
358
  raise
331
359
  rescue StandardError => e
332
360
  raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
333
361
  end
334
362
  end
335
363
 
364
+ # Fetch and cache the prompt list.
365
+ # @return [Array<MCPClient::Prompt>]
366
+ def fetch_prompts_list
367
+ ensure_connected
368
+
369
+ prompts = request_prompts_list.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
370
+ @mutex.synchronize do
371
+ @prompts = attach_list_value(:prompts, prompts) ? prompts : nil
372
+ end
373
+
374
+ # This request's own list, never a re-read of @prompts (another
375
+ # request may have stored its list in between).
376
+ prompts
377
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
378
+ # Re-raise these errors directly
379
+ raise
380
+ rescue StandardError => e
381
+ raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
382
+ end
383
+
336
384
  # Get a prompt with the given parameters
337
385
  # @param prompt_name [String] the name of the prompt to get
338
386
  # @param parameters [Hash] the parameters to pass to the prompt
@@ -343,6 +391,12 @@ module MCPClient
343
391
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
344
392
  # Re-raise connection/transport errors directly
345
393
  raise
394
+ rescue MCPClient::Errors::ServerError => e
395
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
396
+ # actionable data such as requiredCapabilities; keep them intact.
397
+ raise if e.protocol_error?
398
+
399
+ raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
346
400
  rescue StandardError => e
347
401
  # For all other errors, wrap in PromptGetError
348
402
  raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
@@ -353,44 +407,69 @@ module MCPClient
353
407
  # @return [Hash] result containing resources array and optional nextCursor
354
408
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
355
409
  def list_resources(cursor: nil)
356
- @mutex.synchronize do
357
- return @resources_result if @resources_result && !cursor
358
- end
410
+ cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
411
+ return cached if cached
359
412
 
360
413
  begin
361
414
  ensure_connected
362
-
363
- params = {}
364
- params['cursor'] = cursor if cursor
365
- result = rpc_request('resources/list', params)
366
-
367
- resources = (result['resources'] || []).map do |resource_data|
368
- MCPClient::Resource.from_json(resource_data, server: self)
369
- end
370
-
371
- resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
372
-
373
- @mutex.synchronize do
374
- @resources_result = resources_result unless cursor
415
+ unless cursor
416
+ return refetch_or_serve_stale(:resources, stale_list_entry(:resources)) do
417
+ fetch_resources_list(nil)
418
+ end
375
419
  end
376
420
 
377
- resources_result
421
+ fetch_resources_list(cursor)
378
422
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
379
- # Re-raise these errors directly
380
423
  raise
381
424
  rescue StandardError => e
382
425
  raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
383
426
  end
384
427
  end
385
428
 
429
+ # Fetch one page of resources/list, caching the first page.
430
+ # @param cursor [String, nil]
431
+ # @return [Hash]
432
+ def fetch_resources_list(cursor)
433
+ params = {}
434
+ params['cursor'] = cursor if cursor
435
+ epoch = cache_epoch(:resources)
436
+ answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
437
+ result = require_complete_result!(answer, 'resources/list')
438
+ record_cache_hint(:resources, result, epoch: epoch) unless cursor
439
+
440
+ resources = (result['resources'] || []).map do |resource_data|
441
+ MCPClient::Resource.from_json(resource_data, server: self)
442
+ end
443
+
444
+ resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
445
+
446
+ # A list invalidated while in flight is returned but not cached.
447
+ @mutex.synchronize do
448
+ unless cursor
449
+ @resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
450
+ end
451
+ end
452
+
453
+ resources_result
454
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
455
+ # Re-raise these errors directly
456
+ raise
457
+ rescue StandardError => e
458
+ raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
459
+ end
460
+
386
461
  # Read a resource by its URI
387
462
  # @param uri [String] the URI of the resource to read
388
463
  # @return [Array<MCPClient::ResourceContent>] array of resource contents
389
464
  # @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
390
465
  def read_resource(uri)
391
- result = rpc_request('resources/read', { uri: uri })
392
- contents = result['contents'] || []
393
- contents.map { |content| MCPClient::ResourceContent.from_json(content) }
466
+ ensure_connected
467
+ read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { uri: sent }) }
468
+ rescue MCPClient::Errors::ServerError => e
469
+ raise if e.protocol_error?
470
+ raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
471
+
472
+ raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
394
473
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
395
474
  # Re-raise connection/transport errors directly
396
475
  raise
@@ -404,19 +483,52 @@ module MCPClient
404
483
  # @return [Hash] result containing resourceTemplates array and optional nextCursor
405
484
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
406
485
  def list_resource_templates(cursor: nil)
486
+ # Only a list the server itself bounded is served from here: a
487
+ # positive ttlMs means no second request, while a list with no hint
488
+ # (a 2025-11-25 server) is asked for again, as it was before this
489
+ # transport cached anything (MCP 2026-07-28 caching).
490
+ cached = cursor ? nil : hinted_list_value(:templates)
491
+ return cached if cached
492
+
493
+ begin
494
+ ensure_connected
495
+ unless cursor
496
+ return refetch_or_serve_stale(:templates, stale_list_entry(:templates)) do
497
+ fetch_templates_list(nil)
498
+ end
499
+ end
500
+
501
+ fetch_templates_list(cursor)
502
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
503
+ raise
504
+ rescue StandardError => e
505
+ raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
506
+ end
507
+ end
508
+
509
+ # Fetch one page of resources/templates/list, caching the first page.
510
+ # @param cursor [String, nil]
511
+ # @return [Hash]
512
+ def fetch_templates_list(cursor)
407
513
  params = {}
408
514
  params['cursor'] = cursor if cursor
409
- result = rpc_request('resources/templates/list', params)
515
+ epoch = cache_epoch(:templates)
516
+ answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
517
+ result = require_complete_result!(answer, 'resources/templates/list')
518
+ record_cache_hint(:templates, result, epoch: epoch) unless cursor
410
519
 
411
520
  templates = (result['resourceTemplates'] || []).map do |template_data|
412
521
  MCPClient::ResourceTemplate.from_json(template_data, server: self)
413
522
  end
523
+ templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
414
524
 
415
- { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
416
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
417
- raise
418
- rescue StandardError => e
419
- raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
525
+ @mutex.synchronize do
526
+ unless cursor
527
+ @templates_result = attach_list_value(:templates, templates_result) ? templates_result : nil
528
+ end
529
+ end
530
+
531
+ templates_result
420
532
  end
421
533
 
422
534
  # Subscribe to resource updates
@@ -426,6 +538,13 @@ module MCPClient
426
538
  def subscribe_resource(uri)
427
539
  ensure_connected
428
540
  require_capability!('resources', 'subscribe', method: 'resources/subscribe')
541
+ # MCP 2026-07-28 replaced resources/subscribe with a subscriptions/listen
542
+ # stream carrying resourceSubscriptions.
543
+ if modern?
544
+ subscribe_resource_via_listen(uri)
545
+ return true
546
+ end
547
+
429
548
  rpc_request('resources/subscribe', { uri: uri })
430
549
  true
431
550
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
@@ -442,6 +561,11 @@ module MCPClient
442
561
  def unsubscribe_resource(uri)
443
562
  ensure_connected
444
563
  require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
564
+ if modern?
565
+ unsubscribe_resource_via_listen(uri)
566
+ return true
567
+ end
568
+
445
569
  rpc_request('resources/unsubscribe', { uri: uri })
446
570
  true
447
571
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
@@ -455,6 +579,10 @@ module MCPClient
455
579
  def apply_request_headers(req, request)
456
580
  super
457
581
 
582
+ # Modern servers have no session; the base class added the 2026-07-28
583
+ # request metadata headers.
584
+ return if modern?
585
+
458
586
  # Add session and protocol version headers for non-initialize requests
459
587
  return unless request['method'] != 'initialize'
460
588
 
@@ -482,7 +610,7 @@ module MCPClient
482
610
  session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
483
611
  if session_id
484
612
  if valid_session_id?(session_id)
485
- @session_id = session_id
613
+ capture_session_id(session_id)
486
614
  @logger.debug("Captured session ID: #{@session_id}")
487
615
  else
488
616
  @logger.warn("Invalid session ID format received: #{session_id.inspect}")
@@ -505,6 +633,10 @@ module MCPClient
505
633
  # Clean up the server connection
506
634
  # Properly closes HTTP connections, stops threads, and clears cached state
507
635
  def cleanup
636
+ # Only an established session ends a task's namespace; a sessionless
637
+ # (MCP 2026-07-28) connection is merely closed, and a connection that
638
+ # never came up has nothing to end — see #ending_session?.
639
+ bump_session_epoch if ending_session?
508
640
  @mutex.synchronize do
509
641
  return unless @connection_established || @initialized
510
642
 
@@ -514,6 +646,9 @@ module MCPClient
514
646
  @connection_established = false
515
647
  @initialized = false
516
648
 
649
+ # Subscription streams (MCP 2026-07-28) end with the connection
650
+ close_listen_streams
651
+
517
652
  # Attempt to terminate session before cleanup
518
653
  begin
519
654
  terminate_session if @session_id
@@ -545,11 +680,27 @@ module MCPClient
545
680
  @prompts_data = nil
546
681
  @resources = nil
547
682
  @resources_data = nil
683
+ @resources_result = nil
684
+ @templates_result = nil
548
685
  @buffer = +''
549
686
  @buffer_scanned = 0
550
687
 
551
688
  @logger.info('Cleanup completed')
552
689
  end
690
+ # Cached results and their hints belong to the connection (and its
691
+ # authorization context) that was just torn down; outside @mutex, as
692
+ # the cache has its own lock.
693
+ clear_result_cache
694
+ ensure
695
+ # Everything this transport left on this thread — the notes of the
696
+ # entries it served and recorded, the credentials, parameters and
697
+ # metadata of its requests — describes a slice that will never be
698
+ # tagged and a request that will never be made. Dropped after the
699
+ # session was terminated, never before: the DELETE that terminates it
700
+ # is a request of this transport's own, and the recorder on its
701
+ # connection would put its Authorization fingerprint straight back on
702
+ # this thread.
703
+ forget_transport_thread_state
553
704
  end
554
705
 
555
706
  # Register a callback for elicitation requests (MCP 2025-06-18)
@@ -560,6 +711,13 @@ module MCPClient
560
711
  end
561
712
 
562
713
  # Register a callback for roots/list requests (MCP 2025-06-18)
714
+ #
715
+ # @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
716
+ # removal is the first revision released on or after 2027-07-28. Registering
717
+ # a handler is not itself use of Roots — a handler that answers with no root
718
+ # exposes nothing deprecated — but a handler that answers with a root adopts
719
+ # the deprecated feature and raises the notice. Pass directories or files
720
+ # through tool parameters, resource URIs or server configuration instead.
563
721
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
564
722
  # @return [void]
565
723
  def on_roots_list_request(&block)
@@ -567,6 +725,11 @@ module MCPClient
567
725
  end
568
726
 
569
727
  # Register a callback for sampling requests (MCP 2025-11-25)
728
+ #
729
+ # @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
730
+ # removal is the first revision released on or after 2027-07-28. Integrate
731
+ # directly with the LLM provider API instead of serving
732
+ # sampling/createMessage.
570
733
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
571
734
  # @return [void]
572
735
  def on_sampling_request(&block)
@@ -610,7 +773,9 @@ module MCPClient
610
773
  logger: nil,
611
774
  oauth_provider: nil,
612
775
  faraday_config: nil,
613
- max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
776
+ max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
777
+ protocol: :auto,
778
+ discover_timeout: nil
614
779
  }
615
780
  end
616
781
 
@@ -635,65 +800,50 @@ module MCPClient
635
800
  # @return [void]
636
801
  # @raise [MCPClient::Errors::ConnectionError] if connection is not established
637
802
  def ensure_connected
638
- return if @mutex.synchronize { @connection_established && @initialized }
803
+ # Serialized on the transport monitor (reentrant, so the nested
804
+ # cleanup/connect may take it again): checking the flags and acting on
805
+ # them must be one step. Otherwise a caller that observed "disconnected"
806
+ # can be overtaken by one that reconnects, and then tear that fresh
807
+ # connection down — terminating its session and re-running the era
808
+ # probe.
809
+ @mutex.synchronize do
810
+ return if @connection_established && @initialized
639
811
 
640
- @logger.debug('Connection not active, attempting to reconnect before request')
641
- cleanup
642
- connect
812
+ @logger.debug('Connection not active, attempting to reconnect before request')
813
+ cleanup
814
+ connect
815
+ end
643
816
  end
644
817
 
645
818
  # Request the tools list using JSON-RPC
646
819
  # @return [Array<Hash>] the tools data
647
820
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
648
821
  def request_tools_list
649
- @mutex.synchronize do
650
- return @tools_data.dup if @tools_data
651
- end
652
-
653
822
  # Follow nextCursor across pages so the full tool list is returned even
654
- # when the server paginates.
655
- tools = request_paginated_list('tools/list', 'tools')
656
-
657
- @mutex.synchronize { @tools_data = tools }
658
- @mutex.synchronize { @tools_data.dup }
823
+ # when the server paginates. The raw pages are this fetch's own: a
824
+ # shared copy could answer a concurrent caller under other
825
+ # credentials with a privately scoped list (MCP 2026-07-28 caching).
826
+ request_paginated_list('tools/list', 'tools')
659
827
  end
660
828
 
661
829
  # Request the prompts list using JSON-RPC
662
830
  # @return [Array<Hash>] the prompts data
663
831
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
664
832
  def request_prompts_list
665
- @mutex.synchronize do
666
- return @prompts_data.dup if @prompts_data
667
- end
668
-
669
- # Follow nextCursor across pages so the full prompt list is returned.
670
- prompts = request_paginated_list('prompts/list', 'prompts')
671
-
672
- @mutex.synchronize { @prompts_data = prompts }
673
- @mutex.synchronize { @prompts_data.dup }
833
+ # Follow nextCursor across pages so the full prompt list is returned;
834
+ # the raw pages are this fetch's own (see request_tools_list).
835
+ request_paginated_list('prompts/list', 'prompts')
674
836
  end
675
837
 
676
838
  # Request the resources list using JSON-RPC
677
839
  # @return [Array<Hash>] the resources data
678
840
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
679
841
  def request_resources_list
680
- @mutex.synchronize do
681
- return @resources_data if @resources_data
682
- end
683
-
842
+ # The raw list is this fetch's own (see request_tools_list).
684
843
  result = rpc_request('resources/list')
685
844
 
686
- if result.is_a?(Hash) && result['resources']
687
- @mutex.synchronize do
688
- @resources_data = result['resources']
689
- end
690
- return @mutex.synchronize { @resources_data.dup }
691
- elsif result.is_a?(Array) || result
692
- @mutex.synchronize do
693
- @resources_data = result
694
- end
695
- return @mutex.synchronize { @resources_data.dup }
696
- end
845
+ return result['resources'].dup if result.is_a?(Hash) && result['resources']
846
+ return result.dup if result.is_a?(Array) || result
697
847
 
698
848
  raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
699
849
  end
@@ -962,6 +1112,7 @@ module MCPClient
962
1112
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
963
1113
  # MCP: authorization MUST be included in every HTTP request
964
1114
  @oauth_provider&.apply_authorization(req)
1115
+ note_request_authorization(req.headers['Authorization'])
965
1116
  # SEP-1699: resumption is via GET with the Last-Event-ID cursor, so the
966
1117
  # server can replay messages missed since the last received event.
967
1118
  last_event_id = @mutex.synchronize { @last_event_id }
@@ -1110,6 +1261,29 @@ module MCPClient
1110
1261
  # interleaved on a POST SSE response stream.
1111
1262
  # @param message [Hash] the parsed JSON-RPC message
1112
1263
  def dispatch_server_message(message)
1264
+ # Host code reached from here -- a notification listener, a handler for
1265
+ # a server-initiated request -- may issue a request of its own while the
1266
+ # response that carried this message is still being parsed. That request
1267
+ # is an exchange of its own, and the call still waiting for this response
1268
+ # must keep both its recorded definition and its own failures
1269
+ # (HttpTransportBase::RequestRecovery#dispatching_to_host).
1270
+ dispatching_to_host { dispatch_server_message_now(message) }
1271
+ end
1272
+
1273
+ # @param message [Hash] the parsed JSON-RPC message
1274
+ def dispatch_server_message_now(message)
1275
+ if protocol_era == :modern && message['method'] && message.key?('id')
1276
+ # MCP 2026-07-28: "The server MUST NOT send independent JSON-RPC
1277
+ # requests on this stream" — server-to-client interactions are
1278
+ # embedded in InputRequiredResult. There is no response channel
1279
+ # either (clients MUST NOT POST responses), so the request is dropped.
1280
+ # Judged by the ESTABLISHED era: while the probe is in flight a legacy
1281
+ # server may be waiting for its ping on the probe's own stream.
1282
+ @logger.warn("Ignoring server-initiated request #{message['method']} on a response stream: " \
1283
+ 'not permitted by MCP 2026-07-28')
1284
+ return
1285
+ end
1286
+
1113
1287
  # Handle ping requests from server (keepalive mechanism)
1114
1288
  if message['method'] == 'ping' && message.key?('id')
1115
1289
  handle_ping_request(message['id'])
@@ -1118,7 +1292,7 @@ module MCPClient
1118
1292
  handle_server_request(message)
1119
1293
  elsif message['method'] && !message.key?('id')
1120
1294
  # Handle server notifications (messages without id)
1121
- @notification_callback&.call(message['method'], message['params'])
1295
+ route_notification(message['method'], message['params'])
1122
1296
  elsif message.key?('id')
1123
1297
  # A response replayed on the events stream after its POST stream was
1124
1298
  # closed before delivery (SEP-1699 resumption)
@@ -1204,6 +1378,10 @@ module MCPClient
1204
1378
 
1205
1379
  # Call the registered callback
1206
1380
  result = @roots_list_request_callback.call(request_id, params)
1381
+ # Serving a roots/list answer that carries a root means this host
1382
+ # declared, and is using, the deprecated Roots capability (SEP-2577) —
1383
+ # with or without a Client. An empty answer is not use of it.
1384
+ warn_roots_deprecated(result)
1207
1385
 
1208
1386
  # Send the response back to the server (echoing related-task _meta)
1209
1387
  send_roots_list_response(request_id, merge_related_task_meta(result, params))
@@ -1217,10 +1395,18 @@ module MCPClient
1217
1395
  # If no callback is registered, return error
1218
1396
  unless @sampling_request_callback
1219
1397
  @logger.warn('Received sampling request but no callback registered, returning error')
1220
- send_error_response(request_id, -1, 'Sampling not supported')
1398
+ # sampling.mdx § Error Handling reserves -1 for "User rejected sampling
1399
+ # request"; a capability this client never declared is an unsupported
1400
+ # method (-32601, Method not found), as Client#handle_sampling_request answers.
1401
+ send_error_response(request_id, -32_601, 'Sampling not supported')
1221
1402
  return
1222
1403
  end
1223
1404
 
1405
+ # Sampling, and the includeContext values it may carry, are deprecated
1406
+ # (SEP-2577, SEP-2596) — with or without a Client.
1407
+ warn_sampling_deprecated(params)
1408
+ return if refused_undeclared_sampling_tools?(request_id, params)
1409
+
1224
1410
  # Call the registered callback
1225
1411
  result = @sampling_request_callback.call(request_id, params)
1226
1412
 
@@ -1356,6 +1542,7 @@ module MCPClient
1356
1542
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
1357
1543
  # MCP: authorization MUST be included in every HTTP request
1358
1544
  @oauth_provider&.apply_authorization(req)
1545
+ note_request_authorization(req.headers['Authorization'])
1359
1546
  req.body = json_body
1360
1547
  end
1361
1548