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'
@@ -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
@@ -33,6 +38,52 @@ module MCPClient
33
38
  SSE_MAX_RECONNECT_DELAY = 30 # Maximum reconnect delay in seconds
34
39
  THREAD_JOIN_TIMEOUT = 5 # Timeout for thread cleanup
35
40
 
41
+ # Floor for the delay between resumption GETs. SEP-1699's polling pattern
42
+ # wants fast reconnects, so this is far smaller than the events-stream
43
+ # floor — it only prevents a peer-supplied "retry: 0" from turning the
44
+ # deadline window into a back-to-back request loop.
45
+ MIN_RESUMPTION_RECONNECT_DELAY = 0.01
46
+
47
+ # Maximum length of a server-supplied SSE event id retained as the
48
+ # resumption cursor. The id is echoed in the Last-Event-ID header of
49
+ # subsequent requests, so an unbounded value means unbounded retained
50
+ # memory and oversized outbound headers.
51
+ MAX_EVENT_ID_LENGTH = 1024
52
+
53
+ # Characters allowed in a retained event id: printable ASCII, since the
54
+ # value becomes an HTTP header value. Notably excludes CR/LF. The range
55
+ # starts at 0x20 because a space is legal inside a field value, and
56
+ # rejecting ids like "cursor 42" would silently strand resumption on a
57
+ # stale cursor.
58
+ EVENT_ID_PATTERN = /\A[\x20-\x7E]+\z/
59
+
60
+ # Ceiling on concurrent threads POSTing server-initiated responses
61
+ # (pongs, roots/sampling/elicitation replies, error responses). Each
62
+ # server request on the events stream costs one blocking HTTP POST in
63
+ # its own thread; without a bound, a peer flooding requests could
64
+ # accumulate threads and connections until the host is exhausted.
65
+ # Responses beyond the budget are dropped, with saturation logged at most
66
+ # once per SATURATION_LOG_INTERVAL seconds.
67
+ MAX_CONCURRENT_RESPONSE_POSTS = 8
68
+
69
+ # Minimum gap between "response budget saturated" warnings
70
+ SATURATION_LOG_INTERVAL = 5
71
+
72
+ # Floor for server-supplied retry directives on the long-lived events
73
+ # stream. The directive is peer-controlled: honoring "retry: 0" literally
74
+ # would let a hostile server that closes every stream drive a tight
75
+ # reconnect loop (sustained CPU/TLS/connection churn). Waiting longer
76
+ # than the directive stays SEP-1699 compliant — the retry field is a
77
+ # lower bound on the reconnect delay, not an exact schedule.
78
+ MIN_EVENTS_RECONNECT_DELAY = 0.1
79
+
80
+ # Maximum bytes an SSE parse buffer (events stream or resumption GET) may
81
+ # hold while waiting for an event terminator. The stream is
82
+ # peer-controlled: without a cap, a hostile server could withhold the
83
+ # blank-line delimiter forever and grow the buffer until the host runs
84
+ # out of memory. Generous enough for any legitimate JSON-RPC event.
85
+ MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
86
+
36
87
  # @!attribute [r] base_url
37
88
  # @return [String] The base URL of the MCP server
38
89
  # @!attribute [r] endpoint
@@ -97,7 +148,9 @@ module MCPClient
97
148
  })
98
149
 
99
150
  @read_timeout = opts[:read_timeout]
151
+ configure_protocol_mode(opts[:protocol], opts[:discover_timeout])
100
152
  @faraday_config = opts[:faraday_config]
153
+ @max_decompressed_body_bytes = validate_decompression_limit(opts[:max_decompressed_body_bytes])
101
154
  @tools = nil
102
155
  @tools_data = nil
103
156
  @prompts = nil
@@ -113,12 +166,18 @@ module MCPClient
113
166
  @last_event_id = nil
114
167
  @sse_retry_ms = nil
115
168
  @pending_stream_responses = {}
169
+ @response_post_count = 0
170
+ # Saturation bookkeeping for the response-POST budget
171
+ @dropped_response_posts = 0
172
+ @last_saturation_log_at = nil
116
173
  @oauth_provider = opts[:oauth_provider]
117
174
 
118
175
  # SSE events connection state
119
176
  @events_connection = nil
120
177
  @events_thread = nil
121
- @buffer = '' # Buffer for partial SSE event data
178
+ @buffer = +'' # Buffer for partial SSE event data
179
+ # How much of @buffer has already been searched for an event terminator
180
+ @buffer_scanned = 0
122
181
  @elicitation_request_callback = nil # MCP 2025-06-18
123
182
  @roots_list_request_callback = nil # MCP 2025-06-18
124
183
  @sampling_request_callback = nil # MCP 2025-11-25
@@ -128,35 +187,39 @@ module MCPClient
128
187
  # @return [Boolean] true if connection was successful
129
188
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
130
189
  def connect
131
- 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
132
195
 
133
- begin
134
- @mutex.synchronize do
196
+ begin
135
197
  @connection_established = false
136
198
  @initialized = false
137
- end
138
199
 
139
- # Test connectivity with a simple HTTP request
140
- test_connection
200
+ # Test connectivity with a simple HTTP request
201
+ test_connection
141
202
 
142
- # Perform MCP initialization handshake
143
- perform_initialize
203
+ # Establish the protocol era: server/discover for a modern server,
204
+ # the initialize handshake for a legacy one.
205
+ negotiate_protocol
144
206
 
145
- # Start long-lived GET connection for server events
146
- 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?
147
211
 
148
- @mutex.synchronize do
149
212
  @connection_established = true
150
213
  @initialized = true
151
- end
152
214
 
153
- true
154
- rescue MCPClient::Errors::ConnectionError => e
155
- cleanup
156
- raise e
157
- rescue StandardError => e
158
- cleanup
159
- 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
160
223
  end
161
224
  end
162
225
 
@@ -166,21 +229,18 @@ module MCPClient
166
229
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
167
230
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
168
231
  def list_tools
169
- @mutex.synchronize do
170
- return @tools if @tools
171
- end
172
-
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 }
173
240
  begin
174
241
  ensure_connected
175
242
 
176
- tools_data = request_tools_list
177
- @mutex.synchronize do
178
- @tools = tools_data.map do |tool_data|
179
- MCPClient::Tool.from_json(tool_data, server: self)
180
- end
181
- end
182
-
183
- @mutex.synchronize { @tools }
243
+ refetch_or_serve_stale(:tools, stale_list_entry(:tools)) { fetch_tools_list }
184
244
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
185
245
  # Re-raise these errors directly
186
246
  raise
@@ -199,9 +259,15 @@ module MCPClient
199
259
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
200
260
  def call_tool(tool_name, parameters)
201
261
  rpc_request('tools/call', build_named_request_params(tool_name, parameters))
202
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
262
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ValidationError
203
263
  # Re-raise connection/transport errors directly to match test expectations
204
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}"
205
271
  rescue StandardError => e
206
272
  # For all other errors, wrap in ToolCallError
207
273
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
@@ -228,11 +294,17 @@ module MCPClient
228
294
  require_capability!('completions', method: 'completion/complete')
229
295
  params = { ref: ref, argument: argument }
230
296
  params[:context] = context if context
231
- result = rpc_request('completion/complete', params)
297
+ result = require_complete_result!(rpc_request('completion/complete', params), 'completion/complete')
232
298
  result['completion'] || { 'values' => [] }
233
299
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
234
300
  MCPClient::Errors::CapabilityError
235
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}"
236
308
  rescue StandardError => e
237
309
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
238
310
  end
@@ -242,13 +314,31 @@ module MCPClient
242
314
  # 'critical', 'alert', 'emergency')
243
315
  # @return [Hash] empty result on success
244
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.
245
321
  def log_level=(level)
322
+ MCPClient::Deprecations.warn(:logging, @logger)
246
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
+
247
331
  require_capability!('logging', method: 'logging/setLevel')
248
332
  rpc_request('logging/setLevel', { level: level })
249
333
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
250
- MCPClient::Errors::CapabilityError
334
+ MCPClient::Errors::CapabilityError, ArgumentError
251
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}"
252
342
  rescue StandardError => e
253
343
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
254
344
  end
@@ -257,29 +347,40 @@ module MCPClient
257
347
  # @return [Array<MCPClient::Prompt>] list of available prompts
258
348
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
259
349
  def list_prompts
260
- @mutex.synchronize do
261
- return @prompts if @prompts
262
- end
350
+ cached = fresh_list_value(:prompts) { @mutex.synchronize { @prompts } }
351
+ return cached if cached
263
352
 
353
+ @mutex.synchronize { @prompts_data = nil }
264
354
  begin
265
355
  ensure_connected
266
-
267
- prompts_data = request_prompts_list
268
- @mutex.synchronize do
269
- @prompts = prompts_data.map do |prompt_data|
270
- MCPClient::Prompt.from_json(prompt_data, server: self)
271
- end
272
- end
273
-
274
- @mutex.synchronize { @prompts }
356
+ refetch_or_serve_stale(:prompts, stale_list_entry(:prompts)) { fetch_prompts_list }
275
357
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
276
- # Re-raise these errors directly
277
358
  raise
278
359
  rescue StandardError => e
279
360
  raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
280
361
  end
281
362
  end
282
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
+
283
384
  # Get a prompt with the given parameters
284
385
  # @param prompt_name [String] the name of the prompt to get
285
386
  # @param parameters [Hash] the parameters to pass to the prompt
@@ -290,6 +391,12 @@ module MCPClient
290
391
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
291
392
  # Re-raise connection/transport errors directly
292
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}"
293
400
  rescue StandardError => e
294
401
  # For all other errors, wrap in PromptGetError
295
402
  raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
@@ -300,44 +407,69 @@ module MCPClient
300
407
  # @return [Hash] result containing resources array and optional nextCursor
301
408
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
302
409
  def list_resources(cursor: nil)
303
- @mutex.synchronize do
304
- return @resources_result if @resources_result && !cursor
305
- end
410
+ cached = cursor ? nil : fresh_list_value(:resources) { @mutex.synchronize { @resources_result } }
411
+ return cached if cached
306
412
 
307
413
  begin
308
414
  ensure_connected
309
-
310
- params = {}
311
- params['cursor'] = cursor if cursor
312
- result = rpc_request('resources/list', params)
313
-
314
- resources = (result['resources'] || []).map do |resource_data|
315
- MCPClient::Resource.from_json(resource_data, server: self)
316
- end
317
-
318
- resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
319
-
320
- @mutex.synchronize do
321
- @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
322
419
  end
323
420
 
324
- resources_result
421
+ fetch_resources_list(cursor)
325
422
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
326
- # Re-raise these errors directly
327
423
  raise
328
424
  rescue StandardError => e
329
425
  raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
330
426
  end
331
427
  end
332
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
+
333
461
  # Read a resource by its URI
334
462
  # @param uri [String] the URI of the resource to read
335
463
  # @return [Array<MCPClient::ResourceContent>] array of resource contents
336
464
  # @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
337
465
  def read_resource(uri)
338
- result = rpc_request('resources/read', { uri: uri })
339
- contents = result['contents'] || []
340
- 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}"
341
473
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
342
474
  # Re-raise connection/transport errors directly
343
475
  raise
@@ -351,19 +483,52 @@ module MCPClient
351
483
  # @return [Hash] result containing resourceTemplates array and optional nextCursor
352
484
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
353
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)
354
513
  params = {}
355
514
  params['cursor'] = cursor if cursor
356
- 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
357
519
 
358
520
  templates = (result['resourceTemplates'] || []).map do |template_data|
359
521
  MCPClient::ResourceTemplate.from_json(template_data, server: self)
360
522
  end
523
+ templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
361
524
 
362
- { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
363
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
364
- raise
365
- rescue StandardError => e
366
- 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
367
532
  end
368
533
 
369
534
  # Subscribe to resource updates
@@ -373,6 +538,13 @@ module MCPClient
373
538
  def subscribe_resource(uri)
374
539
  ensure_connected
375
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
+
376
548
  rpc_request('resources/subscribe', { uri: uri })
377
549
  true
378
550
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
@@ -389,6 +561,11 @@ module MCPClient
389
561
  def unsubscribe_resource(uri)
390
562
  ensure_connected
391
563
  require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
564
+ if modern?
565
+ unsubscribe_resource_via_listen(uri)
566
+ return true
567
+ end
568
+
392
569
  rpc_request('resources/unsubscribe', { uri: uri })
393
570
  true
394
571
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
@@ -402,6 +579,10 @@ module MCPClient
402
579
  def apply_request_headers(req, request)
403
580
  super
404
581
 
582
+ # Modern servers have no session; the base class added the 2026-07-28
583
+ # request metadata headers.
584
+ return if modern?
585
+
405
586
  # Add session and protocol version headers for non-initialize requests
406
587
  return unless request['method'] != 'initialize'
407
588
 
@@ -429,7 +610,7 @@ module MCPClient
429
610
  session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
430
611
  if session_id
431
612
  if valid_session_id?(session_id)
432
- @session_id = session_id
613
+ capture_session_id(session_id)
433
614
  @logger.debug("Captured session ID: #{@session_id}")
434
615
  else
435
616
  @logger.warn("Invalid session ID format received: #{session_id.inspect}")
@@ -452,6 +633,10 @@ module MCPClient
452
633
  # Clean up the server connection
453
634
  # Properly closes HTTP connections, stops threads, and clears cached state
454
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?
455
640
  @mutex.synchronize do
456
641
  return unless @connection_established || @initialized
457
642
 
@@ -461,6 +646,9 @@ module MCPClient
461
646
  @connection_established = false
462
647
  @initialized = false
463
648
 
649
+ # Subscription streams (MCP 2026-07-28) end with the connection
650
+ close_listen_streams
651
+
464
652
  # Attempt to terminate session before cleanup
465
653
  begin
466
654
  terminate_session if @session_id
@@ -492,10 +680,27 @@ module MCPClient
492
680
  @prompts_data = nil
493
681
  @resources = nil
494
682
  @resources_data = nil
495
- @buffer = ''
683
+ @resources_result = nil
684
+ @templates_result = nil
685
+ @buffer = +''
686
+ @buffer_scanned = 0
496
687
 
497
688
  @logger.info('Cleanup completed')
498
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
499
704
  end
500
705
 
501
706
  # Register a callback for elicitation requests (MCP 2025-06-18)
@@ -506,6 +711,13 @@ module MCPClient
506
711
  end
507
712
 
508
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.
509
721
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
510
722
  # @return [void]
511
723
  def on_roots_list_request(&block)
@@ -513,6 +725,11 @@ module MCPClient
513
725
  end
514
726
 
515
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.
516
733
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
517
734
  # @return [void]
518
735
  def on_sampling_request(&block)
@@ -534,6 +751,17 @@ module MCPClient
534
751
 
535
752
  # Default options for server initialization
536
753
  # @return [Hash] Default options
754
+ # Validate the configured decompression ceiling.
755
+ # @param value [Object] the max_decompressed_body_bytes option
756
+ # @return [Integer] the validated positive byte limit
757
+ # @raise [ArgumentError] if the value is not a positive Integer
758
+ def validate_decompression_limit(value)
759
+ return value if value.is_a?(Integer) && value.positive?
760
+
761
+ raise ArgumentError,
762
+ "max_decompressed_body_bytes must be a positive Integer, got #{value.inspect}"
763
+ end
764
+
537
765
  def default_options
538
766
  {
539
767
  endpoint: '/rpc',
@@ -544,7 +772,10 @@ module MCPClient
544
772
  name: nil,
545
773
  logger: nil,
546
774
  oauth_provider: nil,
547
- faraday_config: nil
775
+ faraday_config: nil,
776
+ max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
777
+ protocol: :auto,
778
+ discover_timeout: nil
548
779
  }
549
780
  end
550
781
 
@@ -569,65 +800,50 @@ module MCPClient
569
800
  # @return [void]
570
801
  # @raise [MCPClient::Errors::ConnectionError] if connection is not established
571
802
  def ensure_connected
572
- 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
573
811
 
574
- @logger.debug('Connection not active, attempting to reconnect before request')
575
- cleanup
576
- connect
812
+ @logger.debug('Connection not active, attempting to reconnect before request')
813
+ cleanup
814
+ connect
815
+ end
577
816
  end
578
817
 
579
818
  # Request the tools list using JSON-RPC
580
819
  # @return [Array<Hash>] the tools data
581
820
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
582
821
  def request_tools_list
583
- @mutex.synchronize do
584
- return @tools_data.dup if @tools_data
585
- end
586
-
587
822
  # Follow nextCursor across pages so the full tool list is returned even
588
- # when the server paginates.
589
- tools = request_paginated_list('tools/list', 'tools')
590
-
591
- @mutex.synchronize { @tools_data = tools }
592
- @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')
593
827
  end
594
828
 
595
829
  # Request the prompts list using JSON-RPC
596
830
  # @return [Array<Hash>] the prompts data
597
831
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
598
832
  def request_prompts_list
599
- @mutex.synchronize do
600
- return @prompts_data.dup if @prompts_data
601
- end
602
-
603
- # Follow nextCursor across pages so the full prompt list is returned.
604
- prompts = request_paginated_list('prompts/list', 'prompts')
605
-
606
- @mutex.synchronize { @prompts_data = prompts }
607
- @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')
608
836
  end
609
837
 
610
838
  # Request the resources list using JSON-RPC
611
839
  # @return [Array<Hash>] the resources data
612
840
  # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
613
841
  def request_resources_list
614
- @mutex.synchronize do
615
- return @resources_data if @resources_data
616
- end
617
-
842
+ # The raw list is this fetch's own (see request_tools_list).
618
843
  result = rpc_request('resources/list')
619
844
 
620
- if result.is_a?(Hash) && result['resources']
621
- @mutex.synchronize do
622
- @resources_data = result['resources']
623
- end
624
- return @mutex.synchronize { @resources_data.dup }
625
- elsif result.is_a?(Array) || result
626
- @mutex.synchronize do
627
- @resources_data = result
628
- end
629
- return @mutex.synchronize { @resources_data.dup }
630
- end
845
+ return result['resources'].dup if result.is_a?(Hash) && result['resources']
846
+ return result.dup if result.is_a?(Array) || result
631
847
 
632
848
  raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
633
849
  end
@@ -723,12 +939,17 @@ module MCPClient
723
939
  end
724
940
 
725
941
  # Reconnect delay for the events stream: the server's SSE retry directive
726
- # (in ms) when present, otherwise the caller's backoff value.
942
+ # (in ms) when present, otherwise the caller's backoff value. The
943
+ # peer-controlled directive is floored at MIN_EVENTS_RECONNECT_DELAY so a
944
+ # zero/near-zero value cannot drive a tight reconnect loop; the
945
+ # deadline-bounded resumption loop intentionally keeps honoring zero.
727
946
  # @param fallback_seconds [Numeric] exponential-backoff fallback
728
947
  # @return [Numeric] delay in seconds
729
948
  def events_reconnect_delay(fallback_seconds)
730
949
  retry_ms = @sse_retry_ms
731
- retry_ms ? retry_ms / 1000.0 : fallback_seconds
950
+ return fallback_seconds unless retry_ms
951
+
952
+ [retry_ms / 1000.0, MIN_EVENTS_RECONNECT_DELAY].max
732
953
  end
733
954
 
734
955
  # Wait for a response replayed after the POST stream was closed before
@@ -770,8 +991,11 @@ module MCPClient
770
991
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
771
992
  state = { cursor: cursor, retry_ms: retry_ms }
772
993
  # SEP-1699: the client MUST respect the server's retry directive before
773
- # attempting to reconnect.
774
- delay = retry_ms ? retry_ms / 1000.0 : 0
994
+ # attempting to reconnect. With no directive the FIRST GET goes out
995
+ # immediately, as before this change: delaying it adds latency to every
996
+ # resumption and, on a short read_timeout, can burn the whole budget
997
+ # before any I/O happens.
998
+ delay = retry_ms ? resumption_delay(retry_ms) : 0
775
999
 
776
1000
  loop do
777
1001
  sleep(delay) if delay.positive?
@@ -779,10 +1003,21 @@ module MCPClient
779
1003
  break unless @mutex.synchronize { @pending_stream_responses.key?(request_id) }
780
1004
  break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
781
1005
 
782
- delay = state[:retry_ms] ? state[:retry_ms] / 1000.0 : SSE_RECONNECT_DELAY
1006
+ delay = resumption_delay(state[:retry_ms])
783
1007
  end
784
1008
  end
785
1009
 
1010
+ # Delay before the next resumption GET: the peer's retry directive when
1011
+ # present, floored so "retry: 0" cannot turn the deadline window into a
1012
+ # back-to-back request loop, otherwise the standard reconnect delay.
1013
+ # @param retry_ms [Integer, nil] retry directive in milliseconds
1014
+ # @return [Numeric] delay in seconds
1015
+ def resumption_delay(retry_ms)
1016
+ return SSE_RECONNECT_DELAY unless retry_ms
1017
+
1018
+ [retry_ms / 1000.0, MIN_RESUMPTION_RECONNECT_DELAY].max
1019
+ end
1020
+
786
1021
  # One GET with the current cursor; complete SSE events are dispatched
787
1022
  # through the standard server-message path, which routes replayed
788
1023
  # responses to their registered waiters. `id:` and `retry:` fields
@@ -803,6 +1038,10 @@ module MCPClient
803
1038
  req.options.on_data = proc do |chunk, _bytes|
804
1039
  buffer << chunk
805
1040
  process_resumption_buffer(buffer, state)
1041
+ # A peer replaying delimiter-free data must not grow this buffer
1042
+ # without bound: abort the GET (rescued below); the deadline-bounded
1043
+ # resumption loop decides whether to retry with a fresh buffer.
1044
+ enforce_sse_buffer_cap!(buffer)
806
1045
  end
807
1046
  end
808
1047
  rescue StandardError => e
@@ -815,10 +1054,16 @@ module MCPClient
815
1054
  # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
816
1055
  # @return [void]
817
1056
  def process_resumption_buffer(buffer, state)
818
- while (separator = buffer.match(/\r\n\r\n|\n\n/))
1057
+ # Same incremental scan as the events stream: without a cursor every
1058
+ # chunk re-matched the whole accumulated buffer from byte zero.
1059
+ scan_from = [state[:scanned].to_i - 3, 0].max
1060
+ while (separator = buffer.match(/\r\n\r\n|\n\n/, scan_from))
819
1061
  event_text = buffer.slice!(0, separator.end(0))
820
1062
  handle_resumption_event(event_text, state)
1063
+ scan_from = 0
1064
+ state[:scanned] = 0
821
1065
  end
1066
+ state[:scanned] = buffer.length
822
1067
  end
823
1068
 
824
1069
  # Parse one SSE event received on a resumption stream: track `id:` lines
@@ -835,7 +1080,9 @@ module MCPClient
835
1080
  data_lines << line.sub(/\Adata:\s*/, '')
836
1081
  elsif line.start_with?('id:')
837
1082
  id = line.sub(/\Aid:\s*/, '').strip
838
- state[:cursor] = id unless id.empty?
1083
+ # Same validation as the events stream: this cursor is echoed in
1084
+ # the Last-Event-ID header of the next resumption GET.
1085
+ state[:cursor] = id if retainable_event_id?(id)
839
1086
  elsif line.start_with?('retry:')
840
1087
  raw = line.sub(/\Aretry:\s*/, '').strip
841
1088
  state[:retry_ms] = raw.to_i if raw.match?(/\A\d+\z/)
@@ -865,6 +1112,7 @@ module MCPClient
865
1112
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
866
1113
  # MCP: authorization MUST be included in every HTTP request
867
1114
  @oauth_provider&.apply_authorization(req)
1115
+ note_request_authorization(req.headers['Authorization'])
868
1116
  # SEP-1699: resumption is via GET with the Last-Event-ID cursor, so the
869
1117
  # server can replay messages missed since the last received event.
870
1118
  last_event_id = @mutex.synchronize { @last_event_id }
@@ -875,22 +1123,64 @@ module MCPClient
875
1123
  # Buffers partial chunks and processes complete SSE events
876
1124
  # @param chunk [String] the chunk to process
877
1125
  def process_event_chunk(chunk)
878
- @logger.debug("Processing event chunk: #{chunk.inspect}") if @logger.level <= Logger::DEBUG
1126
+ # Size only: the chunk is raw wire data carrying sampling prompts,
1127
+ # elicitation content and tool results.
1128
+ @logger.debug("Processing event chunk (#{describe_body_size(chunk)})") if @logger.level <= Logger::DEBUG
879
1129
 
880
1130
  @mutex.synchronize do
881
- @buffer += chunk
882
-
883
- # Extract complete events (SSE format: events end with double newline)
884
- while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
1131
+ # Append in place and scan only the newly arrived bytes. `+= chunk`
1132
+ # reallocated and copied the whole buffer per callback, and each
1133
+ # search restarted at index 0, so an unterminated event delivered in
1134
+ # N chunks cost O(N^2) work: capped memory, uncapped CPU.
1135
+ @buffer << chunk
1136
+
1137
+ scan_from = [@buffer_scanned - 3, 0].max
1138
+ while (event_end = next_buffer_event_end(scan_from))
885
1139
  event_data = extract_event(event_end)
886
1140
  parse_and_handle_event(event_data)
1141
+ scan_from = 0
1142
+ @buffer_scanned = 0
1143
+ end
1144
+ @buffer_scanned = @buffer.length
1145
+
1146
+ # Everything left is a partial event awaiting its terminator; cap how
1147
+ # much a peer may accumulate. The raise propagates (unlike processing
1148
+ # errors below) so the events loop drops this connection and its
1149
+ # backoff takes over — memory stays bounded either way.
1150
+ begin
1151
+ enforce_sse_buffer_cap!(@buffer)
1152
+ rescue MCPClient::Errors::ConnectionError
1153
+ @buffer = +''
1154
+ @buffer_scanned = 0
1155
+ raise
887
1156
  end
888
1157
  end
1158
+ rescue MCPClient::Errors::ConnectionError
1159
+ raise
889
1160
  rescue StandardError => e
890
1161
  @logger.error("Error processing event chunk: #{e.message}")
891
1162
  @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
892
1163
  end
893
1164
 
1165
+ # Index of the earliest event terminator at or after an offset.
1166
+ # @param offset [Integer] character offset to start searching from
1167
+ # @return [Integer, nil] index of the terminator, or nil if none yet
1168
+ def next_buffer_event_end(offset)
1169
+ lf = @buffer.index("\n\n", offset)
1170
+ crlf = @buffer.index("\r\n\r\n", offset)
1171
+ [lf, crlf].compact.min
1172
+ end
1173
+
1174
+ # @param buffer [String] a partial-event SSE buffer
1175
+ # @raise [MCPClient::Errors::ConnectionError] when the buffer exceeds MAX_SSE_BUFFER_BYTES
1176
+ def enforce_sse_buffer_cap!(buffer)
1177
+ return if buffer.bytesize <= MAX_SSE_BUFFER_BYTES
1178
+
1179
+ raise MCPClient::Errors::ConnectionError,
1180
+ "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
1181
+ 'without a terminator'
1182
+ end
1183
+
894
1184
  # Extract a single event from the buffer
895
1185
  # @param event_end [Integer] the position where the event ends
896
1186
  # @return [String] the extracted event data
@@ -922,9 +1212,12 @@ module MCPClient
922
1212
  # SSE allows multiple data lines that should be joined with newlines
923
1213
  data_lines << line[5..].strip
924
1214
  elsif line.start_with?('id:')
925
- # Track event ID for resumability (MCP future enhancement)
1215
+ # Track event ID for resumability (MCP future enhancement). The id
1216
+ # is peer-controlled and gets echoed in the Last-Event-ID header, so
1217
+ # only a bounded, header-safe value is retained; the previous valid
1218
+ # cursor is kept when one is rejected.
926
1219
  event[:id] = line[3..].strip
927
- @last_event_id = event[:id]
1220
+ @last_event_id = event[:id] if retainable_event_id?(event[:id])
928
1221
  elsif line.start_with?('retry:')
929
1222
  # SEP-1699: the client MUST respect the server's retry directive
930
1223
  # (milliseconds) when reconnecting; zero is a valid directive.
@@ -947,10 +1240,19 @@ module MCPClient
947
1240
  return if data.empty?
948
1241
 
949
1242
  begin
950
- dispatch_server_message(JSON.parse(data))
1243
+ message = JSON.parse(data)
1244
+ unless message.is_a?(Hash)
1245
+ # A JSON-parseable scalar/array is not a JSON-RPC message; dispatch
1246
+ # would raise on it. Log the type, never the value.
1247
+ @logger.warn("Skipping non-object JSON-RPC message on the events stream (#{message.class})")
1248
+ return
1249
+ end
1250
+
1251
+ dispatch_server_message(message)
951
1252
  rescue JSON::ParserError => e
952
- @logger.error("Invalid JSON in server message: #{e.message}")
953
- @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
1253
+ # The parser message names the failure position, not the payload; the
1254
+ # payload itself stays out of the log.
1255
+ @logger.error("Invalid JSON in server message: #{describe_parse_error(e, data)}")
954
1256
  end
955
1257
  end
956
1258
 
@@ -959,6 +1261,29 @@ module MCPClient
959
1261
  # interleaved on a POST SSE response stream.
960
1262
  # @param message [Hash] the parsed JSON-RPC message
961
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
+
962
1287
  # Handle ping requests from server (keepalive mechanism)
963
1288
  if message['method'] == 'ping' && message.key?('id')
964
1289
  handle_ping_request(message['id'])
@@ -967,7 +1292,7 @@ module MCPClient
967
1292
  handle_server_request(message)
968
1293
  elsif message['method'] && !message.key?('id')
969
1294
  # Handle server notifications (messages without id)
970
- @notification_callback&.call(message['method'], message['params'])
1295
+ route_notification(message['method'], message['params'])
971
1296
  elsif message.key?('id')
972
1297
  # A response replayed on the events stream after its POST stream was
973
1298
  # closed before delivery (SEP-1699 resumption)
@@ -985,26 +1310,9 @@ module MCPClient
985
1310
  result: {}
986
1311
  }
987
1312
 
988
- # Send pong response in a separate thread to avoid blocking event processing
989
- Thread.new do
990
- conn = http_connection
991
- response = conn.post(@endpoint) do |req|
992
- @headers.each { |k, v| req.headers[k] = v }
993
- req.headers['Mcp-Session-Id'] = @session_id if @session_id
994
- req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
995
- # MCP: authorization MUST be included in every HTTP request
996
- @oauth_provider&.apply_authorization(req)
997
- req.body = pong_response.to_json
998
- end
999
-
1000
- if response.success?
1001
- @logger.debug("Sent pong response for ping ID: #{ping_id}") if @logger.level <= Logger::DEBUG
1002
- else
1003
- @logger.warn("Failed to send pong response: HTTP #{response.status}")
1004
- end
1005
- rescue StandardError => e
1006
- @logger.error("Failed to send pong response: #{e.message}")
1007
- end
1313
+ # Pongs go through the same bounded response path as every other
1314
+ # server-initiated reply, so a ping flood cannot fan out threads.
1315
+ post_jsonrpc_response(pong_response)
1008
1316
  end
1009
1317
 
1010
1318
  # Handle incoming JSON-RPC request from server (MCP 2025-06-18)
@@ -1029,8 +1337,11 @@ module MCPClient
1029
1337
  send_error_response(request_id, -32_601, "Method not found: #{method}")
1030
1338
  end
1031
1339
  rescue StandardError => e
1340
+ # The exception message is host-internal (file paths, connection
1341
+ # strings, library internals): log it locally, but answer the peer with
1342
+ # a constant message so failures cannot be used to probe the host.
1032
1343
  @logger.error("Error handling server request: #{e.message}")
1033
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
1344
+ send_error_response(request_id, -32_603, 'Internal error')
1034
1345
  end
1035
1346
 
1036
1347
  # Handle elicitation/create request from server (MCP 2025-11-25)
@@ -1067,6 +1378,10 @@ module MCPClient
1067
1378
 
1068
1379
  # Call the registered callback
1069
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)
1070
1385
 
1071
1386
  # Send the response back to the server (echoing related-task _meta)
1072
1387
  send_roots_list_response(request_id, merge_related_task_meta(result, params))
@@ -1080,10 +1395,18 @@ module MCPClient
1080
1395
  # If no callback is registered, return error
1081
1396
  unless @sampling_request_callback
1082
1397
  @logger.warn('Received sampling request but no callback registered, returning error')
1083
- 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')
1084
1402
  return
1085
1403
  end
1086
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
+
1087
1410
  # Call the registered callback
1088
1411
  result = @sampling_request_callback.call(request_id, params)
1089
1412
 
@@ -1184,7 +1507,31 @@ module MCPClient
1184
1507
  # @return [void]
1185
1508
  # @private
1186
1509
  def post_jsonrpc_response(response)
1187
- # Send response in a separate thread to avoid blocking event processing
1510
+ # Send the response in a separate thread to avoid blocking event
1511
+ # processing, but never beyond the concurrency budget: server requests
1512
+ # arrive at the peer's rate, and each unanswered POST would otherwise
1513
+ # pin one thread and one connection.
1514
+ unless acquire_response_post_slot
1515
+ log_response_post_saturation
1516
+ return
1517
+ end
1518
+
1519
+ begin
1520
+ start_response_post_thread(response)
1521
+ rescue ThreadError => e
1522
+ # Thread.new failed, so the ensure inside the block never runs and the
1523
+ # reservation would leak. Eight such failures would silently mute this
1524
+ # instance's replies for the rest of its life, including after
1525
+ # reconnect (cleanup does not reset the counter).
1526
+ release_response_post_slot
1527
+ @logger.error("Failed to start response POST thread: #{e.message}")
1528
+ end
1529
+ end
1530
+
1531
+ # Spawn the worker that POSTs one server-initiated response.
1532
+ # @param response [Hash] the JSON-RPC response
1533
+ # @return [Thread]
1534
+ def start_response_post_thread(response)
1188
1535
  Thread.new do
1189
1536
  conn = http_connection
1190
1537
  json_body = JSON.generate(response)
@@ -1195,17 +1542,60 @@ module MCPClient
1195
1542
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
1196
1543
  # MCP: authorization MUST be included in every HTTP request
1197
1544
  @oauth_provider&.apply_authorization(req)
1545
+ note_request_authorization(req.headers['Authorization'])
1198
1546
  req.body = json_body
1199
1547
  end
1200
1548
 
1201
1549
  if resp.success?
1202
- @logger.debug("Sent JSON-RPC response: #{json_body}")
1550
+ @logger.debug("Sent JSON-RPC response: #{describe_jsonrpc_message(response)}")
1203
1551
  else
1204
1552
  @logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
1205
1553
  end
1206
1554
  rescue StandardError => e
1207
1555
  @logger.error("Failed to send JSON-RPC response: #{e.message}")
1556
+ ensure
1557
+ release_response_post_slot
1558
+ end
1559
+ end
1560
+
1561
+ # Warn that the response-POST budget is saturated, at most once per
1562
+ # SATURATION_LOG_INTERVAL.
1563
+ #
1564
+ # The peer controls how often this path is reached, so logging every
1565
+ # rejection (at WARN, which the default logger emits) would just trade a
1566
+ # thread-exhaustion vector for a log-volume one. The peer-supplied request
1567
+ # id is deliberately omitted for the same reason.
1568
+ # @return [void]
1569
+ def log_response_post_saturation
1570
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1571
+ dropped = @mutex.synchronize do
1572
+ @dropped_response_posts += 1
1573
+ next nil if @last_saturation_log_at && now - @last_saturation_log_at < SATURATION_LOG_INTERVAL
1574
+
1575
+ @last_saturation_log_at = now
1576
+ @dropped_response_posts
1208
1577
  end
1578
+ return unless dropped
1579
+
1580
+ @logger.warn("Dropping server-initiated responses: #{MAX_CONCURRENT_RESPONSE_POSTS} " \
1581
+ "response POSTs already in flight (#{dropped} dropped so far)")
1582
+ end
1583
+
1584
+ # Reserve one slot of the response-POST concurrency budget.
1585
+ # @return [Boolean] whether a slot was available
1586
+ def acquire_response_post_slot
1587
+ @mutex.synchronize do
1588
+ return false if @response_post_count >= MAX_CONCURRENT_RESPONSE_POSTS
1589
+
1590
+ @response_post_count += 1
1591
+ true
1592
+ end
1593
+ end
1594
+
1595
+ # Release a slot reserved by acquire_response_post_slot.
1596
+ # @return [void]
1597
+ def release_response_post_slot
1598
+ @mutex.synchronize { @response_post_count -= 1 }
1209
1599
  end
1210
1600
  end
1211
1601
  end