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'
@@ -16,14 +17,23 @@ module MCPClient
16
17
  # @note Elicitation Support (MCP 2025-06-18)
17
18
  # This transport does NOT support server-initiated elicitation requests.
18
19
  # The HTTP transport uses a pure request-response architecture where only the client
19
- # can initiate requests. For elicitation support, use one of these transports instead:
20
+ # can initiate requests. A legacy server that sends one on an SSE response
21
+ # stream is answered with JSON-RPC -32601 rather than left waiting (a
22
+ # `ping` gets the empty result it requires). For elicitation support, use
23
+ # one of these transports instead:
20
24
  # - ServerStdio: Full bidirectional JSON-RPC over stdin/stdout
21
- # - ServerSSE: Server requests via SSE stream, client responses via HTTP POST
22
25
  # - ServerStreamableHTTP: Server requests via SSE-formatted responses, client responses via HTTP POST
26
+ # - ServerSSE: Server requests via SSE stream, client responses via HTTP POST — but the
27
+ # HTTP+SSE transport is deprecated (SEP-2596; earliest removal three months after
28
+ # SEP-2596 reaches Final), so prefer ServerStreamableHTTP for a new integration
23
29
  class ServerHTTP < ServerBase
24
30
  require_relative 'server_http/json_rpc_transport'
25
31
 
26
32
  include JsonRpcTransport
33
+ # Every operation that may weigh a cache decision runs inside a scope
34
+ # that reserves the host request_meta evaluation for the request it
35
+ # leads to, and drops it when the operation ends.
36
+ prepend MCPClient::RequestMetaScope
27
37
 
28
38
  # Default values for connection settings
29
39
  DEFAULT_READ_TIMEOUT = 30
@@ -93,13 +103,16 @@ module MCPClient
93
103
  end
94
104
 
95
105
  # Set up headers for HTTP requests
106
+ # MCP 2026-07-28 Streamable HTTP: the client MUST list both content
107
+ # types and support either framing of the response.
96
108
  @headers = opts[:headers].merge({
97
109
  'Content-Type' => 'application/json',
98
- 'Accept' => 'application/json',
110
+ 'Accept' => 'application/json, text/event-stream',
99
111
  'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}"
100
112
  })
101
113
 
102
114
  @read_timeout = opts[:read_timeout]
115
+ configure_protocol_mode(opts[:protocol], opts[:discover_timeout])
103
116
  @faraday_config = opts[:faraday_config]
104
117
  @tools = nil
105
118
  @tools_data = nil
@@ -110,38 +123,43 @@ module MCPClient
110
123
  @http_conn = nil
111
124
  @session_id = nil
112
125
  @oauth_provider = opts[:oauth_provider]
126
+ @elicitation_request_callback = nil # MCP 2026-07-28 multi round-trip requests
127
+ @roots_list_request_callback = nil
128
+ @sampling_request_callback = nil
113
129
  end
114
130
 
115
131
  # Connect to the MCP server over HTTP
116
132
  # @return [Boolean] true if connection was successful
117
133
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
118
134
  def connect
119
- return true if @mutex.synchronize { @connection_established }
135
+ # Serialized: concurrent first requests must not each run the probe
136
+ # and possibly settle on different eras (the monitor is reentrant, so
137
+ # the request plumbing inside may take @mutex again).
138
+ @mutex.synchronize do
139
+ return true if @connection_established
120
140
 
121
- begin
122
- @mutex.synchronize do
141
+ begin
123
142
  @connection_established = false
124
143
  @initialized = false
125
- end
126
144
 
127
- # Test connectivity with a simple HTTP request
128
- test_connection
145
+ # Test connectivity with a simple HTTP request
146
+ test_connection
129
147
 
130
- # Perform MCP initialization handshake
131
- perform_initialize
148
+ # Establish the protocol era: server/discover for a modern server,
149
+ # the initialize handshake for a legacy one.
150
+ negotiate_protocol
132
151
 
133
- @mutex.synchronize do
134
152
  @connection_established = true
135
153
  @initialized = true
136
- end
137
154
 
138
- true
139
- rescue MCPClient::Errors::ConnectionError => e
140
- cleanup
141
- raise e
142
- rescue StandardError => e
143
- cleanup
144
- raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
155
+ true
156
+ rescue MCPClient::Errors::ConnectionError => e
157
+ cleanup
158
+ raise e
159
+ rescue StandardError => e
160
+ cleanup
161
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
162
+ end
145
163
  end
146
164
  end
147
165
 
@@ -151,21 +169,18 @@ module MCPClient
151
169
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
152
170
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
153
171
  def list_tools
154
- @mutex.synchronize do
155
- return @tools if @tools
156
- end
157
-
172
+ # MCP 2026-07-28 caching: a cached list is served only while fresh, and
173
+ # only from the entry that carries its hint.
174
+ cached = fresh_list_value(:tools) { @mutex.synchronize { @tools } }
175
+ return cached if cached
176
+
177
+ # Stale: the raw page cache must go too, or the re-fetch would be
178
+ # answered from memory.
179
+ @mutex.synchronize { @tools_data = nil }
158
180
  begin
159
181
  ensure_connected
160
182
 
161
- tools_data = request_tools_list
162
- @mutex.synchronize do
163
- @tools = tools_data.map do |tool_data|
164
- MCPClient::Tool.from_json(tool_data, server: self)
165
- end
166
- end
167
-
168
- @mutex.synchronize { @tools }
183
+ refetch_or_serve_stale(:tools, stale_list_entry(:tools)) { fetch_tools_list }
169
184
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
170
185
  # Re-raise these errors directly
171
186
  raise
@@ -184,9 +199,15 @@ module MCPClient
184
199
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
185
200
  def call_tool(tool_name, parameters)
186
201
  rpc_request('tools/call', build_named_request_params(tool_name, parameters))
187
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
202
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ValidationError
188
203
  # Re-raise connection/transport errors directly to match test expectations
189
204
  raise
205
+ rescue MCPClient::Errors::ServerError => e
206
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
207
+ # actionable data such as requiredCapabilities; keep them intact.
208
+ raise if e.protocol_error?
209
+
210
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
190
211
  rescue StandardError => e
191
212
  # For all other errors, wrap in ToolCallError
192
213
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
@@ -196,6 +217,10 @@ module MCPClient
196
217
  def apply_request_headers(req, request)
197
218
  super
198
219
 
220
+ # Modern servers have no session; the base class added the 2026-07-28
221
+ # request metadata headers.
222
+ return if modern?
223
+
199
224
  # Add session and protocol version headers for non-initialize requests
200
225
  return unless request['method'] != 'initialize'
201
226
 
@@ -220,7 +245,7 @@ module MCPClient
220
245
  session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
221
246
  if session_id
222
247
  if valid_session_id?(session_id)
223
- @session_id = session_id
248
+ capture_session_id(session_id)
224
249
  @logger.debug("Captured session ID: #{@session_id}")
225
250
  else
226
251
  @logger.warn("Invalid session ID format received: #{session_id.inspect}")
@@ -236,23 +261,13 @@ module MCPClient
236
261
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
237
262
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
238
263
  def list_prompts
239
- @mutex.synchronize do
240
- return @prompts if @prompts
241
- end
264
+ cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
265
+ return cached if cached
242
266
 
267
+ @mutex.synchronize { @prompts_data = nil }
243
268
  begin
244
269
  ensure_connected
245
-
246
- # Follow nextCursor across pages so the full prompt list is returned.
247
- prompts = request_paginated_list('prompts/list', 'prompts')
248
-
249
- @mutex.synchronize do
250
- @prompts = prompts.map do |prompt_data|
251
- MCPClient::Prompt.from_json(prompt_data, server: self)
252
- end
253
- end
254
-
255
- @mutex.synchronize { @prompts }
270
+ refetch_or_serve_stale(:prompts, stale_list_entry(:prompts)) { fetch_prompts_list }
256
271
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
257
272
  raise
258
273
  rescue StandardError => e
@@ -260,6 +275,28 @@ module MCPClient
260
275
  end
261
276
  end
262
277
 
278
+ # Fetch and cache the prompt list.
279
+ # @return [Array<MCPClient::Prompt>]
280
+ def fetch_prompts_list
281
+ ensure_connected
282
+
283
+ # Follow nextCursor across pages so the full prompt list is returned.
284
+ prompts = request_paginated_list('prompts/list', 'prompts')
285
+
286
+ prompts = prompts.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
287
+ @mutex.synchronize do
288
+ @prompts = attach_list_value(:prompts, prompts) ? prompts : nil
289
+ end
290
+
291
+ # This request's own list, never a re-read of @prompts (another
292
+ # request may have stored its list in between).
293
+ prompts
294
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
295
+ raise
296
+ rescue StandardError => e
297
+ raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
298
+ end
299
+
263
300
  # Get a prompt with the given parameters
264
301
  # @param prompt_name [String] the name of the prompt to get
265
302
  # @param parameters [Hash] the parameters to pass to the prompt
@@ -271,6 +308,12 @@ module MCPClient
271
308
  rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
272
309
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
273
310
  raise
311
+ rescue MCPClient::Errors::ServerError => e
312
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
313
+ # actionable data such as requiredCapabilities; keep them intact.
314
+ raise if e.protocol_error?
315
+
316
+ raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
274
317
  rescue StandardError => e
275
318
  raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
276
319
  end
@@ -280,28 +323,18 @@ module MCPClient
280
323
  # @return [Hash] result containing resources array and optional nextCursor
281
324
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
282
325
  def list_resources(cursor: nil)
283
- @mutex.synchronize do
284
- return @resources_result if @resources_result && !cursor
285
- end
326
+ cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
327
+ return cached if cached
286
328
 
287
329
  begin
288
330
  ensure_connected
289
-
290
- params = {}
291
- params['cursor'] = cursor if cursor
292
- result = rpc_request('resources/list', params)
293
-
294
- resources = (result['resources'] || []).map do |resource_data|
295
- MCPClient::Resource.from_json(resource_data, server: self)
296
- end
297
-
298
- resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
299
-
300
- @mutex.synchronize do
301
- @resources_result = resources_result unless cursor
331
+ unless cursor
332
+ return refetch_or_serve_stale(:resources, stale_list_entry(:resources)) do
333
+ fetch_resources_list(nil)
334
+ end
302
335
  end
303
336
 
304
- resources_result
337
+ fetch_resources_list(cursor)
305
338
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
306
339
  raise
307
340
  rescue StandardError => e
@@ -309,14 +342,52 @@ module MCPClient
309
342
  end
310
343
  end
311
344
 
345
+ # Fetch one page of resources/list, caching the first page.
346
+ # @param cursor [String, nil]
347
+ # @return [Hash]
348
+ def fetch_resources_list(cursor)
349
+ params = {}
350
+ params['cursor'] = cursor if cursor
351
+ epoch = cache_epoch(:resources)
352
+ answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
353
+ result = require_complete_result!(answer, 'resources/list')
354
+ record_cache_hint(:resources, result, epoch: epoch) unless cursor
355
+
356
+ resources = (result['resources'] || []).map do |resource_data|
357
+ MCPClient::Resource.from_json(resource_data, server: self)
358
+ end
359
+
360
+ resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
361
+
362
+ # A list invalidated while in flight is returned but not cached.
363
+ @mutex.synchronize do
364
+ unless cursor
365
+ @resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
366
+ end
367
+ end
368
+
369
+ resources_result
370
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
371
+ raise
372
+ rescue StandardError => e
373
+ raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
374
+ end
375
+
312
376
  # Read a resource by its URI
313
377
  # @param uri [String] the URI of the resource to read
314
378
  # @return [Array<MCPClient::ResourceContent>] array of resource contents
315
379
  # @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
316
380
  def read_resource(uri)
317
- result = rpc_request('resources/read', { uri: uri })
318
- contents = result['contents'] || []
319
- contents.map { |content| MCPClient::ResourceContent.from_json(content) }
381
+ # The session exists before the cache epoch is snapshotted: a
382
+ # reconnect inside the request would otherwise advance it and drop
383
+ # the first read's entry.
384
+ ensure_connected
385
+ read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { uri: sent }) }
386
+ rescue MCPClient::Errors::ServerError => e
387
+ raise if e.protocol_error?
388
+ raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
389
+
390
+ raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
320
391
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
321
392
  raise
322
393
  rescue StandardError => e
@@ -334,11 +405,17 @@ module MCPClient
334
405
  require_capability!('completions', method: 'completion/complete')
335
406
  params = { ref: ref, argument: argument }
336
407
  params[:context] = context if context
337
- result = rpc_request('completion/complete', params)
408
+ result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
338
409
  result['completion'] || { 'values' => [] }
339
410
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
340
411
  MCPClient::Errors::CapabilityError
341
412
  raise
413
+ rescue MCPClient::Errors::ServerError => e
414
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
415
+ # actionable data such as requiredCapabilities; keep them intact.
416
+ raise if e.protocol_error?
417
+
418
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
342
419
  rescue StandardError => e
343
420
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
344
421
  end
@@ -348,13 +425,31 @@ module MCPClient
348
425
  # 'critical', 'alert', 'emergency')
349
426
  # @return [Hash] empty result on success
350
427
  # @raise [MCPClient::Errors::ServerError] if server returns an error
428
+ # @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
429
+ # earliest removal is the first revision released on or after
430
+ # 2027-07-28. Have the server log to stderr (stdio) or use
431
+ # OpenTelemetry instead.
351
432
  def log_level=(level)
433
+ MCPClient::Deprecations.warn(:logging, @logger)
352
434
  ensure_connected
435
+ # MCP 2026-07-28 removed logging/setLevel: the level travels per request
436
+ # in _meta["io.modelcontextprotocol/logLevel"].
437
+ if modern?
438
+ @log_level = validate_log_level!(level)
439
+ return
440
+ end
441
+
353
442
  require_capability!('logging', method: 'logging/setLevel')
354
443
  rpc_request('logging/setLevel', { level: level })
355
444
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
356
- MCPClient::Errors::CapabilityError
445
+ MCPClient::Errors::CapabilityError, ArgumentError
357
446
  raise
447
+ rescue MCPClient::Errors::ServerError => e
448
+ # 2026-07-28 protocol errors (typed -3202x, invalid result) carry
449
+ # actionable data such as requiredCapabilities; keep them intact.
450
+ raise if e.protocol_error?
451
+
452
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
358
453
  rescue StandardError => e
359
454
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
360
455
  end
@@ -364,19 +459,52 @@ module MCPClient
364
459
  # @return [Hash] result containing resourceTemplates array and optional nextCursor
365
460
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
366
461
  def list_resource_templates(cursor: nil)
462
+ # Only a list the server itself bounded is served from here: a
463
+ # positive ttlMs means no second request, while a list with no hint
464
+ # (a 2025-11-25 server) is asked for again, as it was before this
465
+ # transport cached anything (MCP 2026-07-28 caching).
466
+ cached = cursor ? nil : hinted_list_value(:templates)
467
+ return cached if cached
468
+
469
+ begin
470
+ ensure_connected
471
+ unless cursor
472
+ return refetch_or_serve_stale(:templates, stale_list_entry(:templates)) do
473
+ fetch_templates_list(nil)
474
+ end
475
+ end
476
+
477
+ fetch_templates_list(cursor)
478
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
479
+ raise
480
+ rescue StandardError => e
481
+ raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
482
+ end
483
+ end
484
+
485
+ # Fetch one page of resources/templates/list, caching the first page.
486
+ # @param cursor [String, nil]
487
+ # @return [Hash]
488
+ def fetch_templates_list(cursor)
367
489
  params = {}
368
490
  params['cursor'] = cursor if cursor
369
- result = rpc_request('resources/templates/list', params)
491
+ epoch = cache_epoch(:templates)
492
+ answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
493
+ result = require_complete_result!(answer, 'resources/templates/list')
494
+ record_cache_hint(:templates, result, epoch: epoch) unless cursor
370
495
 
371
496
  templates = (result['resourceTemplates'] || []).map do |template_data|
372
497
  MCPClient::ResourceTemplate.from_json(template_data, server: self)
373
498
  end
499
+ templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
374
500
 
375
- { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
376
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
377
- raise
378
- rescue StandardError => e
379
- raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
501
+ @mutex.synchronize do
502
+ unless cursor
503
+ @templates_result = attach_list_value(:templates, templates_result) ? templates_result : nil
504
+ end
505
+ end
506
+
507
+ templates_result
380
508
  end
381
509
 
382
510
  # Subscribe to resource updates
@@ -386,6 +514,13 @@ module MCPClient
386
514
  def subscribe_resource(uri)
387
515
  ensure_connected
388
516
  require_capability!('resources', 'subscribe', method: 'resources/subscribe')
517
+ # MCP 2026-07-28 replaced resources/subscribe with a subscriptions/listen
518
+ # stream carrying resourceSubscriptions.
519
+ if modern?
520
+ subscribe_resource_via_listen(uri)
521
+ return true
522
+ end
523
+
389
524
  rpc_request('resources/subscribe', { uri: uri })
390
525
  true
391
526
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
@@ -402,6 +537,11 @@ module MCPClient
402
537
  def unsubscribe_resource(uri)
403
538
  ensure_connected
404
539
  require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
540
+ if modern?
541
+ unsubscribe_resource_via_listen(uri)
542
+ return true
543
+ end
544
+
405
545
  rpc_request('resources/unsubscribe', { uri: uri })
406
546
  true
407
547
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
@@ -421,6 +561,53 @@ module MCPClient
421
561
  end
422
562
  end
423
563
 
564
+ # Register a handler for elicitation input requests (MCP 2026-07-28 multi
565
+ # round-trip requests; there is no server-initiated request channel on
566
+ # this transport, so these only serve InputRequiredResult round trips).
567
+ # @param block [Proc] callback that receives (key, params) and returns an ElicitResult
568
+ # @return [void]
569
+ def on_elicitation_request(&block)
570
+ @elicitation_request_callback = block
571
+ end
572
+
573
+ # Register a handler for roots/list input requests (MCP 2026-07-28).
574
+ #
575
+ # @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
576
+ # removal is the first revision released on or after 2027-07-28. Registering
577
+ # a handler is not itself use of Roots — a handler that answers with no root
578
+ # exposes nothing deprecated — but a handler that answers with a root adopts
579
+ # the deprecated feature and raises the notice. Pass directories or files
580
+ # through tool parameters, resource URIs or server configuration instead.
581
+ # @param block [Proc] callback that receives (key, params) and returns a ListRootsResult
582
+ # @return [void]
583
+ def on_roots_list_request(&block)
584
+ @roots_list_request_callback = block
585
+ end
586
+
587
+ # Register a handler for sampling input requests (MCP 2026-07-28).
588
+ #
589
+ # @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
590
+ # removal is the first revision released on or after 2027-07-28. Integrate
591
+ # directly with the LLM provider API instead of serving
592
+ # sampling/createMessage.
593
+ # @param block [Proc] callback that receives (key, params) and returns a CreateMessageResult
594
+ # @return [void]
595
+ def on_sampling_request(&block)
596
+ @sampling_request_callback = block
597
+ end
598
+
599
+ # This transport has no legacy server-request channel (server requests on
600
+ # a response stream are dropped), so on a legacy session it must not
601
+ # advertise roots, elicitation or sampling: the handlers above only serve
602
+ # the modern multi round-trip pattern.
603
+ # @return [Hash]
604
+ def client_capabilities
605
+ capabilities = super
606
+ return capabilities if modern?
607
+
608
+ capabilities.except('roots', 'elicitation', 'sampling')
609
+ end
610
+
424
611
  # Terminate the current session (if any)
425
612
  # @return [Boolean] true if termination was successful or no session exists
426
613
  def terminate_session
@@ -434,12 +621,18 @@ module MCPClient
434
621
  # Clean up the server connection
435
622
  # Properly closes HTTP connections and clears cached state
436
623
  def cleanup
624
+ # Only an established session ends a task's namespace; a sessionless
625
+ # (MCP 2026-07-28) connection is merely closed, and a connection that
626
+ # never came up has nothing to end — see #ending_session?.
627
+ bump_session_epoch if ending_session?
437
628
  @mutex.synchronize do
438
629
  # Attempt to terminate session before cleanup
439
630
  terminate_session if @session_id
440
631
 
441
632
  @connection_established = false
442
633
  @initialized = false
634
+ # Subscription streams (MCP 2026-07-28) end with the connection
635
+ close_listen_streams
443
636
 
444
637
  @logger.debug('Cleaning up HTTP connection')
445
638
 
@@ -449,7 +642,26 @@ module MCPClient
449
642
 
450
643
  @tools = nil
451
644
  @tools_data = nil
645
+ @prompts = nil
646
+ @prompts_data = nil
647
+ @resources_result = nil
648
+ @resources_data = nil
649
+ @templates_result = nil
452
650
  end
651
+ # Cached results and their hints belong to the connection (and its
652
+ # authorization context) that was just torn down; outside @mutex, as
653
+ # the cache has its own lock.
654
+ clear_result_cache
655
+ ensure
656
+ # Everything this transport left on this thread — the notes of the
657
+ # entries it served and recorded, the credentials, parameters and
658
+ # metadata of its requests — describes a slice that will never be
659
+ # tagged and a request that will never be made. Dropped after the
660
+ # session was terminated, never before: the DELETE that terminates it
661
+ # is a request of this transport's own, and the recorder on its
662
+ # connection would put its Authorization fingerprint straight back on
663
+ # this thread.
664
+ forget_transport_thread_state
453
665
  end
454
666
 
455
667
  private
@@ -485,7 +697,9 @@ module MCPClient
485
697
  name: nil,
486
698
  logger: nil,
487
699
  oauth_provider: nil,
488
- faraday_config: nil
700
+ faraday_config: nil,
701
+ protocol: :auto,
702
+ discover_timeout: nil
489
703
  }
490
704
  end
491
705
 
@@ -510,27 +724,30 @@ module MCPClient
510
724
  # @return [void]
511
725
  # @raise [MCPClient::Errors::ConnectionError] if connection is not established
512
726
  def ensure_connected
513
- return if @mutex.synchronize { @connection_established && @initialized }
727
+ # Serialized on the transport monitor (reentrant, so the nested
728
+ # cleanup/connect may take it again): checking the flags and acting on
729
+ # them must be one step. Otherwise a caller that observed "disconnected"
730
+ # can be overtaken by one that reconnects, and then tear that fresh
731
+ # connection down — terminating its session and re-running the era
732
+ # probe.
733
+ @mutex.synchronize do
734
+ return if @connection_established && @initialized
514
735
 
515
- @logger.debug('Connection not active, attempting to reconnect before request')
516
- cleanup
517
- connect
736
+ @logger.debug('Connection not active, attempting to reconnect before request')
737
+ cleanup
738
+ connect
739
+ end
518
740
  end
519
741
 
520
742
  # Request the tools list using JSON-RPC
521
743
  # @return [Array<Hash>] the tools data
522
744
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
523
745
  def request_tools_list
524
- @mutex.synchronize do
525
- return @tools_data.dup if @tools_data
526
- end
527
-
528
746
  # Follow nextCursor across pages so the full tool list is returned even
529
- # when the server paginates.
530
- tools = request_paginated_list('tools/list', 'tools')
531
-
532
- @mutex.synchronize { @tools_data = tools }
533
- @mutex.synchronize { @tools_data.dup }
747
+ # when the server paginates. The raw pages are this fetch's own: a
748
+ # shared copy could answer a concurrent caller under other
749
+ # credentials with a privately scoped list (MCP 2026-07-28 caching).
750
+ request_paginated_list('tools/list', 'tools')
534
751
  end
535
752
  end
536
753
  end