ruby-mcp-client 2.0.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +914 -49
  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 +1367 -311
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -36
  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 +851 -478
  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/elicitation_validator.rb +56 -13
  35. data/lib/mcp_client/errors.rb +481 -6
  36. data/lib/mcp_client/header_params.rb +320 -0
  37. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  38. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  39. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  40. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  41. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  42. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  43. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  44. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  45. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  46. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  47. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  48. data/lib/mcp_client/http_transport_base.rb +669 -100
  49. data/lib/mcp_client/input_round_trips.rb +128 -0
  50. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  51. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  52. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  53. data/lib/mcp_client/json_rpc_common.rb +967 -11
  54. data/lib/mcp_client/oauth_client.rb +14 -5
  55. data/lib/mcp_client/prompt.rb +4 -0
  56. data/lib/mcp_client/request_authorization.rb +128 -0
  57. data/lib/mcp_client/request_meta_scope.rb +77 -0
  58. data/lib/mcp_client/request_metadata.rb +287 -0
  59. data/lib/mcp_client/resource.rb +4 -2
  60. data/lib/mcp_client/resource_content.rb +20 -0
  61. data/lib/mcp_client/resource_link.rb +0 -2
  62. data/lib/mcp_client/resource_template.rb +4 -2
  63. data/lib/mcp_client/result_caching.rb +999 -0
  64. data/lib/mcp_client/result_completeness.rb +34 -0
  65. data/lib/mcp_client/root.rb +6 -0
  66. data/lib/mcp_client/round_trip_marker.rb +28 -0
  67. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  68. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  69. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  70. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  71. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  72. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  73. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  74. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  75. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  76. data/lib/mcp_client/schema_validator/references.rb +610 -0
  77. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  78. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  79. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  80. data/lib/mcp_client/schema_validator.rb +893 -175
  81. data/lib/mcp_client/server_base.rb +233 -5
  82. data/lib/mcp_client/server_factory.rb +12 -3
  83. data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
  84. data/lib/mcp_client/server_http.rb +307 -90
  85. data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
  86. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  87. data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
  88. data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
  89. data/lib/mcp_client/server_sse.rb +310 -76
  90. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  91. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
  92. data/lib/mcp_client/server_stdio.rb +778 -186
  93. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
  94. data/lib/mcp_client/server_streamable_http.rb +547 -157
  95. data/lib/mcp_client/session_pin.rb +119 -0
  96. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  97. data/lib/mcp_client/subscription.rb +852 -0
  98. data/lib/mcp_client/subscription_support.rb +715 -0
  99. data/lib/mcp_client/task.rb +286 -14
  100. data/lib/mcp_client/tool.rb +31 -5
  101. data/lib/mcp_client/version.rb +21 -6
  102. data/lib/mcp_client.rb +115 -19
  103. metadata +71 -7
@@ -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
@@ -40,6 +56,13 @@ module MCPClient
40
56
  MAX_RECONNECT_DELAY = 30
41
57
  JITTER_FACTOR = 0.25
42
58
 
59
+ # Maximum bytes the SSE parse buffer may hold while waiting for an event
60
+ # terminator. The stream is peer-controlled: without a cap, a hostile
61
+ # server could withhold the blank-line delimiter forever and grow the
62
+ # buffer until the host runs out of memory. Generous enough for any
63
+ # legitimate JSON-RPC response event.
64
+ MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
65
+
43
66
  # @!attribute [r] base_url
44
67
  # @return [String] The base URL of the MCP server
45
68
  # @!attribute [r] tools
@@ -91,8 +114,14 @@ module MCPClient
91
114
  @tools_data = nil
92
115
  @request_id = 0
93
116
  @sse_results = {}
117
+ # Ids of requests a caller is actively waiting on. Only responses for
118
+ # these ids are stored in @sse_results — everything else on the peer
119
+ # controlled stream is unsolicited and discarded.
120
+ @pending_request_ids = Set.new
94
121
  @mutex = Monitor.new
95
- @buffer = ''
122
+ @buffer = +''
123
+ # How much of @buffer has already been searched for an event terminator
124
+ @buffer_scanned = 0
96
125
  @sse_connected = false
97
126
  @connection_established = false
98
127
  @connection_cv = @mutex.new_cond
@@ -131,21 +160,23 @@ module MCPClient
131
160
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
132
161
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
133
162
  def list_prompts
134
- @mutex.synchronize do
135
- return @prompts if @prompts
136
- 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 }
137
169
 
138
170
  begin
139
171
  ensure_initialized
140
172
 
141
- prompts_data = request_prompts_list
173
+ prompts = request_prompts_list.map { |prompt_data| MCPClient::Prompt.from_json(prompt_data, server: self) }
142
174
  @mutex.synchronize do
143
- @prompts = prompts_data.map do |prompt_data|
144
- MCPClient::Prompt.from_json(prompt_data, server: self)
145
- end
175
+ @prompts = attach_list_value(:prompts, prompts) ? prompts : nil
146
176
  end
147
177
 
148
- @mutex.synchronize { @prompts }
178
+ # This request's own list, never a re-read of @prompts.
179
+ prompts
149
180
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
150
181
  # Re-raise these errors directly
151
182
  raise
@@ -167,6 +198,12 @@ module MCPClient
167
198
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
168
199
  # Re-raise connection/transport errors directly to match test expectations
169
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}"
170
207
  rescue StandardError => e
171
208
  # For all other errors, wrap in PromptGetError
172
209
  raise MCPClient::Errors::PromptGetError, "Error get prompt '#{prompt_name}': #{e.message}"
@@ -179,16 +216,21 @@ module MCPClient
179
216
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
180
217
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
181
218
  def list_resources(cursor: nil)
182
- @mutex.synchronize do
183
- return @resources_result if @resources_result && !cursor
184
- end
219
+ cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
220
+ return cached if cached
185
221
 
186
222
  begin
187
223
  ensure_initialized
188
224
 
189
225
  params = {}
190
226
  params['cursor'] = cursor if cursor
191
- 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
192
234
 
193
235
  resources = (result['resources'] || []).map do |resource_data|
194
236
  MCPClient::Resource.from_json(resource_data, server: self)
@@ -197,7 +239,9 @@ module MCPClient
197
239
  resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
198
240
 
199
241
  @mutex.synchronize do
200
- @resources_result = resources_result unless cursor
242
+ unless cursor
243
+ @resources_result = attach_list_value(:resources, resources_result) ? resources_result : nil
244
+ end
201
245
  end
202
246
 
203
247
  resources_result
@@ -217,9 +261,13 @@ module MCPClient
217
261
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
218
262
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
219
263
  def read_resource(uri)
220
- result = rpc_request('resources/read', { uri: uri })
221
- contents = result['contents'] || []
222
- 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}"
223
271
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
224
272
  # Re-raise connection/transport errors directly to match test expectations
225
273
  raise
@@ -234,16 +282,35 @@ module MCPClient
234
282
  # @raise [MCPClient::Errors::ServerError] if server returns an error
235
283
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
236
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
+
237
292
  ensure_initialized
238
293
  params = {}
239
294
  params['cursor'] = cursor if cursor
240
- 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
241
301
 
242
302
  templates = (result['resourceTemplates'] || []).map do |template_data|
243
303
  MCPClient::ResourceTemplate.from_json(template_data, server: self)
244
304
  end
305
+ templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
245
306
 
246
- { '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
247
314
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
248
315
  raise
249
316
  rescue StandardError => e
@@ -290,21 +357,23 @@ module MCPClient
290
357
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
291
358
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
292
359
  def list_tools
293
- @mutex.synchronize do
294
- return @tools if @tools
295
- 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 }
296
366
 
297
367
  begin
298
368
  ensure_initialized
299
369
 
300
- tools_data = request_tools_list
370
+ tools = request_tools_list.map { |tool_data| MCPClient::Tool.from_json(tool_data, server: self) }
301
371
  @mutex.synchronize do
302
- @tools = tools_data.map do |tool_data|
303
- MCPClient::Tool.from_json(tool_data, server: self)
304
- end
372
+ @tools = attach_list_value(:tools, tools) ? tools : nil
305
373
  end
306
374
 
307
- @mutex.synchronize { @tools }
375
+ # This request's own list, never a re-read of @tools.
376
+ tools
308
377
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
309
378
  # Re-raise these errors directly
310
379
  raise
@@ -326,6 +395,12 @@ module MCPClient
326
395
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
327
396
  # Re-raise connection/transport errors directly to match test expectations
328
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}"
329
404
  rescue StandardError => e
330
405
  # For all other errors, wrap in ToolCallError
331
406
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
@@ -342,11 +417,17 @@ module MCPClient
342
417
  require_capability!('completions', method: 'completion/complete')
343
418
  params = { ref: ref, argument: argument }
344
419
  params[:context] = context if context
345
- result = rpc_request('completion/complete', params)
420
+ result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
346
421
  result['completion'] || { 'values' => [] }
347
422
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
348
423
  MCPClient::Errors::CapabilityError
349
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}"
350
431
  rescue StandardError => e
351
432
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
352
433
  end
@@ -356,13 +437,24 @@ module MCPClient
356
437
  # 'critical', 'alert', 'emergency')
357
438
  # @return [Hash] empty result on success
358
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.
359
444
  def log_level=(level)
445
+ MCPClient::Deprecations.warn(:logging, @logger)
360
446
  ensure_initialized
361
447
  require_capability!('logging', method: 'logging/setLevel')
362
448
  rpc_request('logging/setLevel', { level: level })
363
449
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
364
450
  MCPClient::Errors::CapabilityError
365
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}"
366
458
  rescue StandardError => e
367
459
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
368
460
  end
@@ -371,7 +463,15 @@ module MCPClient
371
463
  # @return [Boolean] true if connection was successful
372
464
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
373
465
  def connect
374
- 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
375
475
 
376
476
  # Check for pre-existing auth error (needed for tests)
377
477
  pre_existing_auth_error = @mutex.synchronize { @auth_error }
@@ -386,6 +486,10 @@ module MCPClient
386
486
  effective_timeout = [@read_timeout || 30, 30].min
387
487
  wait_for_connection(timeout: effective_timeout)
388
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)
389
493
  true
390
494
  rescue MCPClient::Errors::ConnectionError => e
391
495
  cleanup
@@ -410,6 +514,12 @@ module MCPClient
410
514
  # multiple connection attempts. This is essential for proper reconnection
411
515
  # logic and exponential backoff.
412
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
413
523
  @mutex.synchronize do
414
524
  # Set flags first before killing threads to prevent race conditions
415
525
  # where threads might check flags after they're set but before they're killed
@@ -423,7 +533,18 @@ module MCPClient
423
533
 
424
534
  # Reset the SSE parse buffer so a reconnect never inherits a leftover
425
535
  # partial event from the previous connection.
426
- @buffer = ''
536
+ @buffer = +''
537
+ @buffer_scanned = 0
538
+
539
+ # Drop results nobody is waiting for, so peer-supplied state cannot
540
+ # accumulate across reconnects. Results for still-pending requests are
541
+ # KEPT: a response can arrive while its POST is still returning, and
542
+ # the waiter (which reconnects through ensure_sse_connection_active)
543
+ # is about to consume it. Discarding those reported a timeout for a
544
+ # tool call the server had already executed — inviting a duplicate
545
+ # manual retry. unregister_pending_request clears each entry when its
546
+ # request finishes, so nothing lingers.
547
+ @sse_results.select! { |id, _| @pending_request_ids.include?(id) }
427
548
 
428
549
  # Log cleanup for debugging
429
550
  @logger.debug('Cleaning up SSE connection')
@@ -469,9 +590,48 @@ module MCPClient
469
590
  @sse_conn = nil
470
591
 
471
592
  @tools = nil
593
+ @tools_data = nil
594
+ @prompts = nil
595
+ @prompts_data = nil
596
+ @resources_result = nil
597
+ @templates_result = nil
472
598
  # Don't clear auth error as we need it for reporting the correct error
473
599
  # Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
474
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))
475
635
  end
476
636
 
477
637
  # Register a callback for elicitation requests (MCP 2025-06-18)
@@ -482,6 +642,13 @@ module MCPClient
482
642
  end
483
643
 
484
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.
485
652
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
486
653
  # @return [void]
487
654
  def on_roots_list_request(&block)
@@ -489,6 +656,11 @@ module MCPClient
489
656
  end
490
657
 
491
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.
492
664
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
493
665
  # @return [void]
494
666
  def on_sampling_request(&block)
@@ -519,8 +691,11 @@ module MCPClient
519
691
  send_error_response(request_id, -32_601, "Method not found: #{method}")
520
692
  end
521
693
  rescue StandardError => e
694
+ # The exception message is host-internal (file paths, connection
695
+ # strings, library internals): log it locally, but answer the peer with
696
+ # a constant message so failures cannot be used to probe the host.
522
697
  @logger.error("Error handling server request: #{e.message}")
523
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
698
+ send_error_response(request_id, -32_603, 'Internal error')
524
699
  end
525
700
 
526
701
  # Handle a server-initiated ping request (MCP ping utility)
@@ -572,6 +747,10 @@ module MCPClient
572
747
 
573
748
  # Call the registered callback
574
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)
575
754
 
576
755
  # Send the response back to the server (echoing related-task _meta)
577
756
  send_roots_list_response(request_id, merge_related_task_meta(result, params))
@@ -604,10 +783,18 @@ module MCPClient
604
783
  # If no callback is registered, return error
605
784
  unless @sampling_request_callback
606
785
  @logger.warn('Received sampling request but no callback registered, returning error')
607
- 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')
608
790
  return
609
791
  end
610
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
+
611
798
  # Call the registered callback
612
799
  result = @sampling_request_callback.call(request_id, params)
613
800
 
@@ -714,10 +901,11 @@ module MCPClient
714
901
  # header on all HTTP requests after the initialize handshake.
715
902
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
716
903
  @headers.each { |k, v| req.headers[k] = v }
904
+ note_request_authorization(authorization_header_value(req.headers))
717
905
  req.body = json_body
718
906
  end
719
907
 
720
- @logger.debug("Sent response via HTTP POST: #{json_body}")
908
+ @logger.debug("Sent response via HTTP POST: #{describe_jsonrpc_message(response)}")
721
909
  rescue StandardError => e
722
910
  @logger.error("Failed to send response via HTTP POST: #{e.message}")
723
911
  end
@@ -777,6 +965,7 @@ module MCPClient
777
965
  def establish_sse_connection(conn, sse_path)
778
966
  conn.get(sse_path) do |req|
779
967
  @headers.each { |k, v| req.headers[k] = v }
968
+ note_request_authorization(authorization_header_value(req.headers))
780
969
 
781
970
  req.options.on_data = proc do |chunk, _bytes|
782
971
  process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
@@ -839,7 +1028,14 @@ module MCPClient
839
1028
  # Process an SSE chunk from the server
840
1029
  # @param chunk [String] the chunk to process
841
1030
  def process_sse_chunk(chunk)
842
- @logger.debug("Processing SSE chunk: #{chunk.inspect}")
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)
1036
+ # Size only: the chunk is raw wire data carrying sampling prompts,
1037
+ # elicitation content and tool results.
1038
+ @logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
843
1039
 
844
1040
  # Only record activity for real events
845
1041
  record_activity if chunk.include?('event:')
@@ -850,7 +1046,7 @@ module MCPClient
850
1046
  event_buffers = extract_complete_events(chunk)
851
1047
 
852
1048
  # Process extracted events outside the mutex to avoid deadlocks
853
- 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) }
854
1050
  end
855
1051
 
856
1052
  # Check if the error represents an authorization error
@@ -897,21 +1093,61 @@ module MCPClient
897
1093
  # @return [Array<String>, nil] array of complete events or nil if none
898
1094
  # @private
899
1095
  def extract_complete_events(chunk)
900
- event_buffers = nil
1096
+ event_buffers = []
901
1097
  @mutex.synchronize do
902
- @buffer += chunk
903
-
904
- # Extract all complete events from the buffer
905
- # Handle both Unix (\n\n) and Windows (\r\n\r\n) line endings
906
- event_buffers = []
907
- while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
908
- event_data = extract_single_event(event_end)
909
- event_buffers << event_data
1098
+ # Append in place. `@buffer += chunk` allocates and copies the whole
1099
+ # buffer on every callback, so an unterminated event delivered in N
1100
+ # chunks costs O(N^2) copying — memory stays capped but a peer can
1101
+ # still burn CPU and thrash the allocator on the way there.
1102
+ @buffer << chunk
1103
+
1104
+ # Rescan only the newly arrived bytes, backing up by the longest
1105
+ # delimiter minus one so one split across two chunks is still found.
1106
+ scan_from = [@buffer_scanned - 3, 0].max
1107
+ while (event_end = next_event_end(scan_from))
1108
+ event_buffers << extract_single_event(event_end)
1109
+ # The buffer shifted; what remains is short (one event at most).
1110
+ scan_from = 0
1111
+ @buffer_scanned = 0
910
1112
  end
1113
+ @buffer_scanned = @buffer.length
1114
+
1115
+ # Whatever is left is a partial event still awaiting its terminator.
1116
+ # The cap is applied here rather than before appending so a single
1117
+ # oversized chunk that DOES contain complete events is still parsed.
1118
+ fail_oversized_sse_buffer! if @buffer.bytesize > MAX_SSE_BUFFER_BYTES
911
1119
  end
912
1120
  event_buffers
913
1121
  end
914
1122
 
1123
+ # Index of the earliest event terminator at or after an offset.
1124
+ # @param offset [Integer] character offset to start searching from
1125
+ # @return [Integer, nil] index of the terminator, or nil if none yet
1126
+ def next_event_end(offset)
1127
+ lf = @buffer.index("\n\n", offset)
1128
+ crlf = @buffer.index("\r\n\r\n", offset)
1129
+ [lf, crlf].compact.min
1130
+ end
1131
+
1132
+ # Drop an oversized partial event and fail the connection.
1133
+ #
1134
+ # Recording the cause matters: this runs inside Faraday's on_data callback
1135
+ # on the SSE worker thread, whose generic rescue would otherwise leave
1136
+ # callers with a bare "connection lost" and no reason. Mirrors the
1137
+ # endpoint-URI failure path so wait_for_connection surfaces it promptly.
1138
+ # @raise [MCPClient::Errors::ConnectionError] always
1139
+ def fail_oversized_sse_buffer!
1140
+ message = "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
1141
+ 'without a terminator'
1142
+ @buffer = +''
1143
+ @buffer_scanned = 0
1144
+ @connection_error = message
1145
+ @connection_established = false
1146
+ @connection_cv.broadcast
1147
+ @logger.error(message)
1148
+ raise MCPClient::Errors::ConnectionError, message
1149
+ end
1150
+
915
1151
  # Extract a single event from the buffer
916
1152
  # @param event_end [Integer] the position where the event ends
917
1153
  # @return [String] the extracted event data
@@ -946,16 +1182,32 @@ module MCPClient
946
1182
  # @return [Array<Hash>] the prompts data
947
1183
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
948
1184
  # @private
949
- 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)
950
1190
  @mutex.synchronize do
951
- 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
952
1204
  end
1205
+ end
953
1206
 
954
- # Follow nextCursor across pages so the full prompt list is returned.
955
- prompts = request_paginated_list('prompts/list', 'prompts')
956
-
957
- @mutex.synchronize { @prompts_data = prompts }
958
- @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')
959
1211
  end
960
1212
 
961
1213
  # Request the resources list using JSON-RPC
@@ -963,23 +1215,11 @@ module MCPClient
963
1215
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
964
1216
  # @private
965
1217
  def request_resources_list
966
- @mutex.synchronize do
967
- return @resources_data if @resources_data
968
- end
969
-
1218
+ # The raw list is this fetch's own (see request_tools_list).
970
1219
  result = rpc_request('resources/list')
971
1220
 
972
- if result && result['resources']
973
- @mutex.synchronize do
974
- @resources_data = result['resources']
975
- end
976
- return @mutex.synchronize { @resources_data.dup }
977
- elsif result
978
- @mutex.synchronize do
979
- @resources_data = result
980
- end
981
- return @mutex.synchronize { @resources_data.dup }
982
- end
1221
+ return result['resources'].dup if result && result['resources']
1222
+ return result.dup if result
983
1223
 
984
1224
  raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
985
1225
  end
@@ -989,17 +1229,11 @@ module MCPClient
989
1229
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
990
1230
  # @private
991
1231
  def request_tools_list
992
- @mutex.synchronize do
993
- return @tools_data.dup if @tools_data
994
- end
995
-
996
1232
  # Follow nextCursor across pages so the full tool list is returned. The
997
- # SSE parser no longer writes @tools_data per page, so this method is the
998
- # sole writer and only ever caches the COMPLETE list.
999
- tools = request_paginated_list('tools/list', 'tools')
1000
-
1001
- @mutex.synchronize { @tools_data = tools }
1002
- @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')
1003
1237
  end
1004
1238
  end
1005
1239
  end