ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'request_meta_scope'
3
4
  require 'open3'
5
+ require 'monitor'
4
6
  require 'json'
5
7
  require_relative 'version'
6
8
  require 'logger'
@@ -8,9 +10,14 @@ require 'logger'
8
10
  module MCPClient
9
11
  # JSON-RPC implementation of MCP server over stdio.
10
12
  class ServerStdio < ServerBase
13
+ require_relative 'server_stdio/child_session'
11
14
  require_relative 'server_stdio/json_rpc_transport'
12
15
 
13
16
  include JsonRpcTransport
17
+ # Every operation that may weigh a cache decision runs inside a scope
18
+ # that reserves the host request_meta evaluation for the request it
19
+ # leads to, and drops it when the operation ends.
20
+ prepend MCPClient::RequestMetaScope
14
21
 
15
22
  # @!attribute [r] command
16
23
  # @return [String, Array] the command used to launch the server
@@ -27,6 +34,12 @@ module MCPClient
27
34
  # for the server to exit, send SIGTERM, then SIGKILL if it still runs.
28
35
  SHUTDOWN_GRACE_PERIOD = 2
29
36
 
37
+ # Seconds a process must last after the open subscriptions were re-sent to
38
+ # it — counted from the moment it received them, not from the moment it
39
+ # was spawned — before another unexpected exit counts as a crash rather
40
+ # than a crash loop (see {JsonRpcTransport#reopen_subscriptions}).
41
+ SUBSCRIPTION_RESTART_MIN_INTERVAL = 5
42
+
30
43
  # Chunk size (bytes) used when draining the subprocess stderr pipe
31
44
  STDERR_READ_CHUNK_SIZE = 8192
32
45
 
@@ -35,6 +48,14 @@ module MCPClient
35
48
  # (e.g. progress output using carriage returns).
36
49
  STDERR_MAX_LINE_SIZE = 64 * 1024
37
50
 
51
+ # How the server's protocol era is established (MCP 2026-07-28
52
+ # basic/transports/stdio "Backward Compatibility"):
53
+ # - :auto probe with server/discover, fall back to initialize on any
54
+ # non-modern error or timeout (dual-era client, the default)
55
+ # - :modern probe with server/discover and fail if the server is legacy
56
+ # - :legacy skip the probe and run the initialize handshake
57
+ PROTOCOL_MODES = %i[auto modern legacy].freeze
58
+
38
59
  # Initialize a new ServerStdio instance
39
60
  # @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
40
61
  # For improved security, passing an Array is recommended to avoid shell injection issues
@@ -44,12 +65,28 @@ module MCPClient
44
65
  # @param name [String, nil] optional name for this server
45
66
  # @param logger [Logger, nil] optional logger
46
67
  # @param env [Hash] optional environment variables for the subprocess
47
- def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, name: nil, logger: nil, env: {})
68
+ # @param protocol [Symbol] :auto (probe, fall back to initialize), :modern
69
+ # (probe, no fallback) or :legacy (initialize handshake only)
70
+ # @param discover_timeout [Numeric, nil] seconds to wait for the
71
+ # server/discover probe (default: read_timeout — a modern server that is
72
+ # slow to start must not be misclassified as legacy)
73
+ def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, name: nil, logger: nil, env: {},
74
+ protocol: :auto, discover_timeout: nil)
48
75
  super(name: name)
76
+ unless PROTOCOL_MODES.include?(protocol)
77
+ raise ArgumentError, "protocol must be one of #{PROTOCOL_MODES.inspect}, got #{protocol.inspect}"
78
+ end
79
+
80
+ @protocol_mode = protocol
81
+ @discover_timeout = discover_timeout || read_timeout
49
82
  @command_array = command.is_a?(Array) ? command : nil
50
83
  @command = command.is_a?(Array) ? command.join(' ') : command
51
84
  @mutex = Mutex.new
52
85
  @cond = ConditionVariable.new
86
+ # Serializes process start and protocol negotiation: two threads must
87
+ # not each spawn the server or run the probe and the fallback on the
88
+ # same pipes. Reentrant because negotiation waits on @mutex inside.
89
+ @init_lock = Monitor.new
53
90
  @next_id = 1
54
91
  @pending = {}
55
92
  # Ids of requests awaiting a response; used to drop late/unsolicited
@@ -68,6 +105,29 @@ module MCPClient
68
105
  @sampling_request_callback = nil # MCP 2025-11-25
69
106
  @reader_thread = nil
70
107
  @stderr_thread = nil
108
+ # Bumped whenever a subprocess is spawned or torn down, so a reader
109
+ # thread only ever speaks for the transport it was started for.
110
+ @transport_generation = 0
111
+ # Guards the pair (subprocess handles, generation) so that judging
112
+ # whether a request's transport is still current and writing it are
113
+ # one step: a restart cannot slip in between (see
114
+ # JsonRpcTransport#send_request).
115
+ @transport_lock = Mutex.new
116
+ @negotiating = false
117
+ @transport_retired = false
118
+ @modern_answer_received = false
119
+ # The record of the live child process, and of the one the open
120
+ # subscriptions were last re-sent to — the crash-loop bound is read from
121
+ # the latter (see ChildSession and JsonRpcTransport#reopen_subscriptions).
122
+ @session = nil
123
+ @subscription_carrier = nil
124
+ # The subscriptions waiting for a process, and the lock the two paths
125
+ # that write them share: a `cleanup` moving the open subscriptions onto
126
+ # the queue overlaps a hand-over whose listen write failed putting one
127
+ # back (JsonRpcTransport#enqueue_reconnecting_subscriptions). Made here
128
+ # so no two threads ever race to make it.
129
+ @reconnecting_mutex = Mutex.new
130
+ @reconnecting_subscriptions = []
71
131
  end
72
132
 
73
133
  # Server info from the initialize response
@@ -78,20 +138,35 @@ module MCPClient
78
138
  # @return [Hash, nil] Server capabilities
79
139
  attr_reader :capabilities
80
140
 
141
+ # @return [Symbol] the configured protocol mode (:auto, :modern or :legacy)
142
+ attr_reader :protocol_mode
143
+
144
+ # @return [Numeric] seconds allowed for the server/discover probe
145
+ attr_reader :discover_timeout
146
+
81
147
  # Connect to the MCP server by launching the command process via stdin/stdout
82
148
  # @return [Boolean] true if connection was successful
83
149
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
84
150
  def connect
85
- if @command_array
86
- if @env.any?
87
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, *@command_array)
88
- else
89
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*@command_array)
90
- end
91
- elsif @env.any?
92
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command)
93
- else
94
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
151
+ handles = spawn_server_process
152
+ # The handles and the generation change together: a request judged
153
+ # current against the old generation must not find the new stdin.
154
+ @transport_lock.synchronize do
155
+ @stdin, @stdout, @stderr, @wait_thread = handles
156
+ @transport_generation += 1
157
+ # A fresh process is not the one that exited: a restart made for the
158
+ # open subscriptions must not be torn down again by the next request.
159
+ @transport_retired = false
160
+ # A fresh process has said nothing yet: what the previous one wrote
161
+ # identifies nothing about this one, and what was negotiated WITH it
162
+ # binds nothing here. The era is per process (stdio "Backward
163
+ # Compatibility"), and the replacement's reader starts before the
164
+ # probe proposes anything: a 2025-11-25 replacement that pings at
165
+ # startup would otherwise be judged by the dead process's era, its
166
+ # ping dropped, and both the probe and the handshake left waiting on
167
+ # a server that answers nothing until its pong arrives.
168
+ @modern_answer_received = false
169
+ @protocol_version = nil
95
170
  end
96
171
  pin_pipe_encodings
97
172
  true
@@ -99,6 +174,19 @@ module MCPClient
99
174
  raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
100
175
  end
101
176
 
177
+ # @return [Array] the stdin, stdout, stderr and wait thread of the spawned process
178
+ def spawn_server_process
179
+ if @command_array
180
+ return Open3.popen3(@env, *@command_array) if @env.any?
181
+
182
+ Open3.popen3(*@command_array)
183
+ elsif @env.any?
184
+ Open3.popen3(@env, @command)
185
+ else
186
+ Open3.popen3(@command)
187
+ end
188
+ end
189
+
102
190
  # Pin the subprocess pipe encodings to UTF-8 instead of inheriting the
103
191
  # process locale (Encoding.default_external). JSON-RPC messages MUST be
104
192
  # UTF-8 encoded (MCP 2025-11-25 basic/transports.mdx); under a non-UTF-8
@@ -115,13 +203,99 @@ module MCPClient
115
203
  # Spawn a reader thread to collect JSON-RPC responses
116
204
  # @return [Thread] the reader thread
117
205
  def start_reader
206
+ generation = @transport_generation
207
+ # The record of the process this reader belongs to, so an EOF can be
208
+ # told from the EOF of a process that has since been replaced.
209
+ session = @session
210
+ stdout = @stdout
118
211
  @reader_thread = Thread.new do
119
- @stdout.each_line do |line|
212
+ stdout.each_line do |line|
120
213
  handle_line(line)
121
214
  end
215
+ handle_reader_eof(session, generation)
122
216
  rescue StandardError
123
217
  # Reader thread aborted unexpectedly
218
+ ensure
219
+ retire_transport(generation)
220
+ end
221
+ end
222
+
223
+ # The subprocess closed its stdout: it has exited (or its pipes were
224
+ # dropped), so no response will ever arrive on this transport again. MCP
225
+ # 2026-07-28 basic/transports/stdio ("Unexpected Termination") says a
226
+ # client SHOULD restart a server that terminated unexpectedly, so retire
227
+ # the handshake rather than let it describe a process that no longer
228
+ # exists: the next request releases these handles and negotiates again
229
+ # against a fresh subprocess.
230
+ #
231
+ # Nothing is restarted or replayed from here. A request that was in
232
+ # flight may already have been executed server-side, so it fails as it
233
+ # would on any other broken transport; only the session is recoverable.
234
+ # A deliberate shutdown bumps the generation first, so its own reader
235
+ # reaching EOF is not mistaken for an unexpected exit.
236
+ # @param generation [Integer] the transport this reader was started for
237
+ # @return [void]
238
+ def retire_transport(generation)
239
+ return unless generation == @transport_generation
240
+
241
+ # Waiters are woken: a request in flight on this transport will never
242
+ # be answered, and should fail now rather than wait out its timeout.
243
+ @mutex.synchronize do
244
+ @transport_retired = true
245
+ @cond.broadcast
124
246
  end
247
+ @logger.debug('Server stdout closed; the transport will be re-established on the next request')
248
+ end
249
+
250
+ # EOF on the process's stdout: unless the client closed its stdin, the
251
+ # server exited on its own.
252
+ #
253
+ # Waiting out an initialization still in flight is what makes an exit
254
+ # *during* one recoverable. The handling used to be skipped outright while
255
+ # `@initialized` was false — which is exactly the state a process that
256
+ # answered the discovery probe and then exited leaves behind. Nothing else
257
+ # noticed: {JsonRpcTransport#ensure_initialized} went on to mark the dead
258
+ # connection initialized and re-send the open subscriptions to it, the
259
+ # failed writes were deferred back onto the queue for "the next process",
260
+ # and with this reader already gone there was no one left to establish one.
261
+ # The subscriptions stayed :reconnecting for ever, with the host neither
262
+ # served nor told.
263
+ #
264
+ # The lock is that initialization finishing, and taking it cannot deadlock:
265
+ # the reader is never the thread inside it, and by the time it is waiting
266
+ # here it can deliver no further responses, so anything that thread is
267
+ # still waiting for is already bounded by its own timeout.
268
+ # @param session [MCPClient::ServerStdio::ChildSession, nil] the record of
269
+ # the process this reader was started for
270
+ # @param generation [Integer] the transport generation this reader was
271
+ # started for; an EOF on a generation that has since been torn down —
272
+ # by the host's `cleanup`, or by another exit — is not an exit to handle
273
+ # @return [void]
274
+ def handle_reader_eof(session, generation = @transport_generation)
275
+ @init_lock.synchronize { nil } unless @initialized
276
+ return unless live_transport?(generation, session)
277
+
278
+ handle_server_exit(session, generation)
279
+ end
280
+
281
+ # Whether the process a reader was started for is still the live one: its
282
+ # transport generation has not been claimed for teardown, and its record
283
+ # is the current session. Read under the transport lock, since a teardown
284
+ # claims the generation under it.
285
+ # @param generation [Integer] the transport generation to check
286
+ # @param session [MCPClient::ServerStdio::ChildSession, nil]
287
+ # @return [Boolean]
288
+ def live_transport?(generation, session)
289
+ @transport_lock.synchronize { !@stdin.nil? && generation == @transport_generation } && current_session?(session)
290
+ end
291
+
292
+ # Whether the process a reader was started for is still the live one. A
293
+ # reader whose process has already been torn down and replaced must not
294
+ # tear down its successor.
295
+ # @param session [MCPClient::ServerStdio::ChildSession, nil]
296
+ # @return [Boolean]
297
+ def current_session?(session)
298
+ session.nil? || @session.equal?(session)
125
299
  end
126
300
 
127
301
  # Spawn a thread to continuously drain the subprocess stderr.
@@ -138,10 +312,11 @@ module MCPClient
138
312
  # STDERR_MAX_LINE_SIZE is flushed rather than retained.
139
313
  # @return [Thread] the stderr reader thread
140
314
  def start_stderr_reader
315
+ stderr = @stderr
141
316
  @stderr_thread = Thread.new do
142
317
  buffer = +''
143
318
  loop do
144
- buffer << @stderr.readpartial(STDERR_READ_CHUNK_SIZE)
319
+ buffer << stderr.readpartial(STDERR_READ_CHUNK_SIZE)
145
320
  flush_stderr_lines(buffer)
146
321
  flush_stderr_overflow(buffer)
147
322
  end
@@ -159,6 +334,9 @@ module MCPClient
159
334
  # @param line [String] line of output to parse
160
335
  # @return [void]
161
336
  def handle_line(line)
337
+ # The response is dated from the arrival of its line, before it is
338
+ # decoded: parsing time is not freshness.
339
+ arrived = respond_to?(:monotonic_now, true) ? monotonic_now : nil
162
340
  msg = JSON.parse(line)
163
341
  @logger.debug("Received line: #{describe_jsonrpc_message(msg)}")
164
342
 
@@ -171,34 +349,91 @@ module MCPClient
171
349
 
172
350
  # Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
173
351
  if msg['method'] && msg.key?('id')
174
- handle_server_request(msg)
352
+ if modern_peer?
353
+ # MCP 2026-07-28 stdio: "The server MUST NOT write JSON-RPC requests
354
+ # to stdout" and "The client MUST NOT write JSON-RPC responses" —
355
+ # server-to-client interactions travel in InputRequiredResult.
356
+ @logger.warn("Ignoring server-initiated request #{msg['method']}: " \
357
+ 'a modern MCP server MUST NOT write JSON-RPC requests to stdout')
358
+ else
359
+ handle_server_request(msg)
360
+ end
175
361
  return
176
362
  end
177
363
 
178
364
  # Dispatch JSON-RPC notifications (no id, has method)
179
365
  if msg['method'] && !msg.key?('id')
180
- @notification_callback&.call(msg['method'], msg['params'])
366
+ route_notification(msg['method'], msg['params'])
181
367
  return
182
368
  end
183
369
 
184
370
  # Handle standard JSON-RPC responses (has id, no method)
185
371
  id = msg['id']
186
372
  return unless id
373
+ # A response to a subscriptions/listen request ends that subscription
374
+ # (no caller is waiting on it).
375
+ return if handle_subscription_response(msg)
376
+
377
+ record_response(id, msg, arrived)
378
+ rescue JSON::ParserError, EncodingError
379
+ # Skip non-JSONRPC or undecodable lines in the output stream so a single
380
+ # bad line cannot kill the reader thread
381
+ end
187
382
 
383
+ # Queue a response for the caller waiting on it.
384
+ # @param id [Integer, String] the response's request id
385
+ # @param msg [Hash] the decoded response
386
+ # @param arrived [Float, nil] when its line arrived (monotonic seconds)
387
+ # @return [void]
388
+ def record_response(id, msg, arrived)
188
389
  @mutex.synchronize do
189
390
  # Only retain a response that corresponds to an outstanding request.
190
391
  # Late responses (arriving after the caller timed out) and unsolicited
191
392
  # responses are dropped so @pending cannot grow without bound.
192
393
  if @awaiting.key?(id)
394
+ # The answer is recorded as identifying the peer BEFORE it is
395
+ # queued and before the next line is read: the thread waiting for
396
+ # it may not run until after the server has written its next line,
397
+ # and if that line is a request a modern server MUST NOT have
398
+ # written, it must already be known as prohibited traffic — a
399
+ # legacy accommodation is only owed while the probe is unanswered.
400
+ # Only an OUTSTANDING request's answer says anything, though: the
401
+ # response to a probe that timed out (and was cancelled) SHOULD be
402
+ # ignored, and the session it fell back to is a 2025-11-25 one
403
+ # whose server requests are still owed their responses. And only
404
+ # while the era is being negotiated: an answer on an established
405
+ # 2025-11-25 session renegotiates nothing, however modern its
406
+ # shape, and that session's ping, roots, sampling and elicitation
407
+ # requests stay owed their responses.
408
+ @modern_answer_received = true if era_probe_in_flight? && identifies_modern_server?(msg)
193
409
  @pending[id] = msg
410
+ # Dated from arrival: the waiter may wake much later.
411
+ (@response_arrivals ||= {})[id] = arrived || monotonic_now if respond_to?(:monotonic_now, true)
194
412
  @cond.broadcast
195
413
  else
196
414
  @logger.debug("Discarding response for unknown or expired request id=#{id}")
197
415
  end
198
416
  end
199
- rescue JSON::ParserError, EncodingError
200
- # Skip non-JSONRPC or undecodable lines in the output stream so a single
201
- # bad line cannot kill the reader thread
417
+ end
418
+
419
+ # Whether a server-initiated request is prohibited traffic.
420
+ #
421
+ # Judged by the ESTABLISHED era, not by protocol_version: during the
422
+ # server/discover probe the latter is only the version this client
423
+ # proposed. A legacy server MAY ping while the probe is unanswered and
424
+ # then wait for the response before doing anything else, so treating its
425
+ # request as prohibited modern traffic deadlocks the negotiation.
426
+ #
427
+ # Two exceptions. A client configured protocol: :modern has already
428
+ # ruled out the legacy fallback that the accommodation exists for: it
429
+ # will never speak legacy, so it never runs a host callback for a server
430
+ # request nor writes the response back — not even while its own probe is
431
+ # still in flight. And once the reader has seen an answer only a modern
432
+ # server could have written, the server is modern whatever the
433
+ # negotiating thread has got round to applying.
434
+ # @return [Boolean]
435
+ def modern_peer?
436
+ protocol_era == :modern || @protocol_mode == :modern || @modern_answer_received
202
437
  end
203
438
 
204
439
  # List all prompts available from the MCP server
@@ -206,24 +441,50 @@ module MCPClient
206
441
  # @raise [MCPClient::Errors::ServerError] if server returns an error
207
442
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
208
443
  def list_prompts
444
+ cached = hinted_list_value(:prompts)
445
+ return cached if cached
446
+
209
447
  ensure_initialized
210
- collect_paginated('prompts') do |cursor|
448
+ # A cursor the server rejects restarts the list from its first page,
449
+ # exactly as it does on the HTTP transports (MCP pagination).
450
+ page = { cursor: nil }
451
+ prompts = restarting_rejected_cursor('prompts', page) { collect_prompt_pages(page) }
452
+ attach_list_value(:prompts, prompts)
453
+ prompts
454
+ rescue MCPClient::Errors::ServerError => e
455
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
456
+ # supported versions); keep them intact instead of wrapping.
457
+ raise if e.protocol_error?
458
+
459
+ raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
460
+ rescue StandardError => e
461
+ raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
462
+ end
463
+
464
+ # Collect every page of prompts/list, recording what each page was
465
+ # answered under so the cache can bind the combined list to it.
466
+ # @param page [Hash] holds the cursor of the request in flight
467
+ # @return [Array<MCPClient::Prompt>]
468
+ def collect_prompt_pages(page)
469
+ pages = []
470
+ received_ats = []
471
+ fingerprints = []
472
+ epoch = cache_epoch(:prompts)
473
+ prompts = collect_paginated('prompts') do |cursor|
211
474
  params = {}
212
475
  params['cursor'] = cursor if cursor
213
- req_id = next_id
214
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'prompts/list', 'params' => params }
215
- send_request(req)
216
- res = wait_response(req_id)
217
- if (err = res['error'])
218
- raise MCPClient::Errors::ServerError, err['message']
219
- end
220
-
221
- result = res['result'] || {}
476
+ started = monotonic_now
477
+ page[:cursor] = cursor
478
+ page_result = fetch_list_page(:prompts, cursor) { rpc_request('prompts/list', params) } || {}
479
+ result = require_complete_result!(page_result, 'prompts/list')
480
+ pages << result
481
+ received_ats << response_received_at(since: started)
482
+ fingerprints << request_params_fingerprint
222
483
  prompts = (result['prompts'] || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
223
484
  [prompts, result['nextCursor']]
224
485
  end
225
- rescue StandardError => e
226
- raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
486
+ record_list_cache_hint('prompts/list', pages, received_ats, params: fingerprints, epoch: epoch)
487
+ prompts
227
488
  end
228
489
 
229
490
  # Get a prompt with the given parameters
@@ -234,21 +495,13 @@ module MCPClient
234
495
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
235
496
  def get_prompt(prompt_name, parameters)
236
497
  ensure_initialized
237
- req_id = next_id
238
- # JSON-RPC method for getting a prompt
239
- req = {
240
- 'jsonrpc' => '2.0',
241
- 'id' => req_id,
242
- 'method' => 'prompts/get',
243
- 'params' => build_named_request_params(prompt_name, parameters)
244
- }
245
- send_request(req)
246
- res = wait_response(req_id)
247
- if (err = res['error'])
248
- raise MCPClient::Errors::ServerError, err['message']
249
- end
498
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
499
+ rescue MCPClient::Errors::ServerError => e
500
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
501
+ # supported versions); keep them intact instead of wrapping.
502
+ raise if e.protocol_error?
250
503
 
251
- res['result']
504
+ raise MCPClient::Errors::PromptGetError, "Error calling prompt '#{prompt_name}': #{e.message}"
252
505
  rescue StandardError => e
253
506
  raise MCPClient::Errors::PromptGetError, "Error calling prompt '#{prompt_name}': #{e.message}"
254
507
  end
@@ -259,20 +512,26 @@ module MCPClient
259
512
  # @raise [MCPClient::Errors::ServerError] if server returns an error
260
513
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
261
514
  def list_resources(cursor: nil)
515
+ cached = cursor ? nil : hinted_list_value(:resources)
516
+ return cached if cached
517
+
262
518
  ensure_initialized
263
- req_id = next_id
264
519
  params = {}
265
520
  params['cursor'] = cursor if cursor
266
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'resources/list', 'params' => params }
267
- send_request(req)
268
- res = wait_response(req_id)
269
- if (err = res['error'])
270
- raise MCPClient::Errors::ServerError, err['message']
271
- end
272
-
273
- result = res['result'] || {}
521
+ epoch = cache_epoch(:resources)
522
+ answer = fetching_list_page(:resources, cursor) { rpc_request('resources/list', params) }
523
+ result = require_complete_result!(answer || {}, 'resources/list')
524
+ record_cache_hint(:resources, result, epoch: epoch) unless cursor
274
525
  resources = (result['resources'] || []).map { |td| MCPClient::Resource.from_json(td, server: self) }
275
- { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
526
+ resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
527
+ attach_list_value(:resources, resources_result) unless cursor
528
+ resources_result
529
+ rescue MCPClient::Errors::ServerError => e
530
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
531
+ # supported versions); keep them intact instead of wrapping.
532
+ raise if e.protocol_error?
533
+
534
+ raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
276
535
  rescue StandardError => e
277
536
  raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
278
537
  end
@@ -284,23 +543,16 @@ module MCPClient
284
543
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
285
544
  def read_resource(uri)
286
545
  ensure_initialized
287
- req_id = next_id
288
- # JSON-RPC method for reading a resource
289
- req = {
290
- 'jsonrpc' => '2.0',
291
- 'id' => req_id,
292
- 'method' => 'resources/read',
293
- 'params' => { 'uri' => uri }
294
- }
295
- send_request(req)
296
- res = wait_response(req_id)
297
- if (err = res['error'])
298
- raise MCPClient::Errors::ServerError, err['message']
299
- end
546
+ # A null result reaches the shared guard as-is: it is a malformed
547
+ # response, not an empty resource.
548
+ read_resource_with_cache(uri) { |sent| rpc_request('resources/read', { 'uri' => sent }) }
549
+ rescue MCPClient::Errors::ServerError => e
550
+ raise if e.protocol_error?
551
+ raise resource_not_found_error(uri, e) if resource_not_found_response?(e)
300
552
 
301
- result = res['result'] || {}
302
- contents = result['contents'] || []
303
- contents.map { |content| MCPClient::ResourceContent.from_json(content) }
553
+ raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
554
+ rescue MCPClient::Errors::TransportError
555
+ raise
304
556
  rescue StandardError => e
305
557
  raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
306
558
  end
@@ -311,20 +563,30 @@ module MCPClient
311
563
  # @raise [MCPClient::Errors::ServerError] if server returns an error
312
564
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
313
565
  def list_resource_templates(cursor: nil)
566
+ # Only a list the server itself bounded is served from here: a
567
+ # positive ttlMs means no second request, while a list with no hint
568
+ # (a 2025-11-25 server) is asked for again, as it was before this
569
+ # transport cached anything (MCP 2026-07-28 caching).
570
+ cached = cursor ? nil : hinted_list_value(:templates)
571
+ return cached if cached
572
+
314
573
  ensure_initialized
315
- req_id = next_id
316
574
  params = {}
317
575
  params['cursor'] = cursor if cursor
318
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'resources/templates/list', 'params' => params }
319
- send_request(req)
320
- res = wait_response(req_id)
321
- if (err = res['error'])
322
- raise MCPClient::Errors::ServerError, err['message']
323
- end
324
-
325
- result = res['result'] || {}
576
+ epoch = cache_epoch(:templates)
577
+ answer = fetching_list_page(:templates, cursor) { rpc_request('resources/templates/list', params) }
578
+ result = require_complete_result!(answer || {}, 'resources/templates/list')
579
+ record_cache_hint(:templates, result, epoch: epoch) unless cursor
326
580
  templates = (result['resourceTemplates'] || []).map { |td| MCPClient::ResourceTemplate.from_json(td, server: self) }
327
- { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
581
+ templates_result = { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
582
+ attach_list_value(:templates, templates_result) unless cursor
583
+ templates_result
584
+ rescue MCPClient::Errors::ServerError => e
585
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
586
+ # supported versions); keep them intact instead of wrapping.
587
+ raise if e.protocol_error?
588
+
589
+ raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
328
590
  rescue StandardError => e
329
591
  raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
330
592
  end
@@ -337,22 +599,23 @@ module MCPClient
337
599
  def subscribe_resource(uri)
338
600
  ensure_initialized
339
601
  require_capability!('resources', 'subscribe', method: 'resources/subscribe')
340
- req_id = next_id
341
- req = {
342
- 'jsonrpc' => '2.0',
343
- 'id' => req_id,
344
- 'method' => 'resources/subscribe',
345
- 'params' => { 'uri' => uri }
346
- }
347
- send_request(req)
348
- res = wait_response(req_id)
349
- if (err = res['error'])
350
- raise MCPClient::Errors::ServerError, err['message']
602
+ # MCP 2026-07-28 replaced resources/subscribe with a subscriptions/listen
603
+ # stream carrying resourceSubscriptions.
604
+ if modern?
605
+ subscribe_resource_via_listen(uri)
606
+ return true
351
607
  end
352
608
 
609
+ rpc_request('resources/subscribe', { 'uri' => uri })
353
610
  true
354
611
  rescue MCPClient::Errors::CapabilityError
355
612
  raise
613
+ rescue MCPClient::Errors::ServerError => e
614
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
615
+ # supported versions); keep them intact instead of wrapping.
616
+ raise if e.protocol_error?
617
+
618
+ raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
356
619
  rescue StandardError => e
357
620
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
358
621
  end
@@ -365,22 +628,21 @@ module MCPClient
365
628
  def unsubscribe_resource(uri)
366
629
  ensure_initialized
367
630
  require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
368
- req_id = next_id
369
- req = {
370
- 'jsonrpc' => '2.0',
371
- 'id' => req_id,
372
- 'method' => 'resources/unsubscribe',
373
- 'params' => { 'uri' => uri }
374
- }
375
- send_request(req)
376
- res = wait_response(req_id)
377
- if (err = res['error'])
378
- raise MCPClient::Errors::ServerError, err['message']
631
+ if modern?
632
+ unsubscribe_resource_via_listen(uri)
633
+ return true
379
634
  end
380
635
 
636
+ rpc_request('resources/unsubscribe', { 'uri' => uri })
381
637
  true
382
638
  rescue MCPClient::Errors::CapabilityError
383
639
  raise
640
+ rescue MCPClient::Errors::ServerError => e
641
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
642
+ # supported versions); keep them intact instead of wrapping.
643
+ raise if e.protocol_error?
644
+
645
+ raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
384
646
  rescue StandardError => e
385
647
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
386
648
  end
@@ -390,25 +652,53 @@ module MCPClient
390
652
  # @raise [MCPClient::Errors::ServerError] if server returns an error
391
653
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
392
654
  def list_tools
655
+ # MCP 2026-07-28 caching: a list the server put a positive ttlMs on is
656
+ # served here while it is still fresh, so a host reaching for the
657
+ # transport directly does not re-list on every call.
658
+ cached = hinted_list_value(:tools)
659
+ return cached if cached
660
+
393
661
  ensure_initialized
394
- collect_paginated('tools') do |cursor|
662
+ # A cursor the server rejects restarts the list from its first page,
663
+ # exactly as it does on the HTTP transports (MCP pagination).
664
+ page = { cursor: nil }
665
+ tools = restarting_rejected_cursor('tools', page) { collect_tool_pages(page) }
666
+ attach_list_value(:tools, tools)
667
+ tools
668
+ rescue MCPClient::Errors::ServerError => e
669
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
670
+ # supported versions); keep them intact instead of wrapping.
671
+ raise if e.protocol_error?
672
+
673
+ raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
674
+ rescue StandardError => e
675
+ raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
676
+ end
677
+
678
+ # Collect every page of tools/list, recording what each page was answered
679
+ # under so the cache can bind the combined list to it.
680
+ # @param page [Hash] holds the cursor of the request in flight
681
+ # @return [Array<MCPClient::Tool>]
682
+ def collect_tool_pages(page)
683
+ pages = []
684
+ received_ats = []
685
+ fingerprints = []
686
+ epoch = cache_epoch(:tools)
687
+ tools = collect_paginated('tools') do |cursor|
395
688
  params = {}
396
689
  params['cursor'] = cursor if cursor
397
- req_id = next_id
398
- # JSON-RPC method for listing tools
399
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'tools/list', 'params' => params }
400
- send_request(req)
401
- res = wait_response(req_id)
402
- if (err = res['error'])
403
- raise MCPClient::Errors::ServerError, err['message']
404
- end
405
-
406
- result = res['result'] || {}
690
+ started = monotonic_now
691
+ page[:cursor] = cursor
692
+ page_result = fetch_list_page(:tools, cursor) { rpc_request('tools/list', params) } || {}
693
+ result = require_complete_result!(page_result, 'tools/list')
694
+ pages << result
695
+ received_ats << response_received_at(since: started)
696
+ fingerprints << request_params_fingerprint
407
697
  tools = (result['tools'] || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
408
698
  [tools, result['nextCursor']]
409
699
  end
410
- rescue StandardError => e
411
- raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
700
+ record_list_cache_hint('tools/list', pages, received_ats, params: fingerprints, epoch: epoch)
701
+ tools
412
702
  end
413
703
 
414
704
  # Call a tool with the given parameters
@@ -419,21 +709,13 @@ module MCPClient
419
709
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
420
710
  def call_tool(tool_name, parameters)
421
711
  ensure_initialized
422
- req_id = next_id
423
- # JSON-RPC method for calling a tool
424
- req = {
425
- 'jsonrpc' => '2.0',
426
- 'id' => req_id,
427
- 'method' => 'tools/call',
428
- 'params' => build_named_request_params(tool_name, parameters)
429
- }
430
- send_request(req)
431
- res = wait_response(req_id)
432
- if (err = res['error'])
433
- raise MCPClient::Errors::ServerError, err['message']
434
- end
712
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
713
+ rescue MCPClient::Errors::ServerError => e
714
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
715
+ # supported versions); keep them intact instead of wrapping.
716
+ raise if e.protocol_error?
435
717
 
436
- res['result']
718
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
437
719
  rescue StandardError => e
438
720
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
439
721
  end
@@ -447,24 +729,18 @@ module MCPClient
447
729
  def complete(ref:, argument:, context: nil)
448
730
  ensure_initialized
449
731
  require_capability!('completions', method: 'completion/complete')
450
- req_id = next_id
451
732
  params = { 'ref' => ref, 'argument' => argument }
452
733
  params['context'] = context if context
453
- req = {
454
- 'jsonrpc' => '2.0',
455
- 'id' => req_id,
456
- 'method' => 'completion/complete',
457
- 'params' => params
458
- }
459
- send_request(req)
460
- res = wait_response(req_id)
461
- if (err = res['error'])
462
- raise MCPClient::Errors::ServerError, err['message']
463
- end
464
-
465
- res.dig('result', 'completion') || { 'values' => [] }
734
+ result = require_complete_result!(rpc_request('completion/complete', params) || {}, 'completion/complete')
735
+ result['completion'] || { 'values' => [] }
466
736
  rescue MCPClient::Errors::CapabilityError
467
737
  raise
738
+ rescue MCPClient::Errors::ServerError => e
739
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
740
+ # supported versions); keep them intact instead of wrapping.
741
+ raise if e.protocol_error?
742
+
743
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
468
744
  rescue StandardError => e
469
745
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
470
746
  end
@@ -474,25 +750,31 @@ module MCPClient
474
750
  # 'critical', 'alert', 'emergency')
475
751
  # @return [Hash] empty result on success
476
752
  # @raise [MCPClient::Errors::ServerError] if server returns an error
753
+ # @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
754
+ # earliest removal is the first revision released on or after
755
+ # 2027-07-28. Have the server log to stderr (stdio) or use
756
+ # OpenTelemetry instead.
477
757
  def log_level=(level)
758
+ MCPClient::Deprecations.warn(:logging, @logger)
478
759
  ensure_initialized
479
- require_capability!('logging', method: 'logging/setLevel')
480
- req_id = next_id
481
- req = {
482
- 'jsonrpc' => '2.0',
483
- 'id' => req_id,
484
- 'method' => 'logging/setLevel',
485
- 'params' => { 'level' => level }
486
- }
487
- send_request(req)
488
- res = wait_response(req_id)
489
- if (err = res['error'])
490
- raise MCPClient::Errors::ServerError, err['message']
760
+ # MCP 2026-07-28 removed logging/setLevel: the level is declared per
761
+ # request in _meta["io.modelcontextprotocol/logLevel"], so store it
762
+ # and let every subsequent request carry it.
763
+ if modern?
764
+ @log_level = validate_log_level!(level)
765
+ return
491
766
  end
492
767
 
493
- res['result'] || {}
494
- rescue MCPClient::Errors::CapabilityError
768
+ require_capability!('logging', method: 'logging/setLevel')
769
+ rpc_request('logging/setLevel', { 'level' => level }) || {}
770
+ rescue MCPClient::Errors::CapabilityError, ArgumentError
495
771
  raise
772
+ rescue MCPClient::Errors::ServerError => e
773
+ # 2026-07-28 protocol errors carry actionable data (requiredCapabilities,
774
+ # supported versions); keep them intact instead of wrapping.
775
+ raise if e.protocol_error?
776
+
777
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
496
778
  rescue StandardError => e
497
779
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
498
780
  end
@@ -505,6 +787,13 @@ module MCPClient
505
787
  end
506
788
 
507
789
  # Register a callback for roots/list requests (MCP 2025-06-18)
790
+ #
791
+ # @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
792
+ # removal is the first revision released on or after 2027-07-28. Registering
793
+ # a handler is not itself use of Roots — a handler that answers with no root
794
+ # exposes nothing deprecated — but a handler that answers with a root adopts
795
+ # the deprecated feature and raises the notice. Pass directories or files
796
+ # through tool parameters, resource URIs or server configuration instead.
508
797
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
509
798
  # @return [void]
510
799
  def on_roots_list_request(&block)
@@ -512,6 +801,11 @@ module MCPClient
512
801
  end
513
802
 
514
803
  # Register a callback for sampling requests (MCP 2025-11-25)
804
+ #
805
+ # @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577); earliest
806
+ # removal is the first revision released on or after 2027-07-28. Integrate
807
+ # directly with the LLM provider API instead of serving
808
+ # sampling/createMessage.
515
809
  # @param block [Proc] callback that receives (request_id, params) and returns response hash
516
810
  # @return [void]
517
811
  def on_sampling_request(&block)
@@ -596,6 +890,10 @@ module MCPClient
596
890
 
597
891
  # Call the registered callback
598
892
  result = @roots_list_request_callback.call(request_id, params)
893
+ # Serving a roots/list answer that carries a root means this host
894
+ # declared, and is using, the deprecated Roots capability (SEP-2577) —
895
+ # with or without a Client. An empty answer is not use of it.
896
+ warn_roots_deprecated(result)
599
897
 
600
898
  # Send the response back to the server (echoing related-task _meta)
601
899
  send_roots_list_response(request_id, merge_related_task_meta(result, params))
@@ -609,10 +907,18 @@ module MCPClient
609
907
  # If no callback is registered, return error
610
908
  unless @sampling_request_callback
611
909
  @logger.warn('Received sampling request but no callback registered, returning error')
612
- send_error_response(request_id, -1, 'Sampling not supported')
910
+ # sampling.mdx § Error Handling reserves -1 for "User rejected sampling
911
+ # request"; a capability this client never declared is an unsupported
912
+ # method (-32601, Method not found), as Client#handle_sampling_request answers.
913
+ send_error_response(request_id, -32_601, 'Sampling not supported')
613
914
  return
614
915
  end
615
916
 
917
+ # Sampling, and the includeContext values it may carry, are deprecated
918
+ # (SEP-2577, SEP-2596) — with or without a Client.
919
+ warn_sampling_deprecated(params)
920
+ return if refused_undeclared_sampling_tools?(request_id, params)
921
+
616
922
  # Call the registered callback
617
923
  result = @sampling_request_callback.call(request_id, params)
618
924
 
@@ -723,30 +1029,252 @@ module MCPClient
723
1029
  buffer.clear
724
1030
  end
725
1031
 
1032
+ # Whether #cleanup ends a session: only a 2025-11-25 handshake opens
1033
+ # one, and only once it completed. A stateless 2026-07-28 peer, a
1034
+ # process that never got through its handshake and a probe still in
1035
+ # flight leave nothing session-scoped behind — task ids and their
1036
+ # bookkeeping stay what they are for the process that comes next.
1037
+ # @return [Boolean]
1038
+ def ending_session?
1039
+ @initialized && !modern_peer?
1040
+ end
1041
+
726
1042
  # Clean up the server connection
727
1043
  # Closes all stdio handles and terminates any running processes and threads
728
1044
  # following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
729
1045
  # close stdin, wait for the server to exit, send SIGTERM if it does not exit
730
1046
  # within a reasonable time, then SIGKILL if it still does not exit.
1047
+ #
1048
+ # Tears down the process the transport holds *now*: a `cleanup` that
1049
+ # finds the process already claimed by another teardown — the reader of a
1050
+ # process that exited, dismantling it while the host got here — has
1051
+ # nothing to do, and never touches a replacement the host established in
1052
+ # the meantime (see {#teardown_transport}).
731
1053
  # @return [void]
732
1054
  def cleanup
733
- return unless @stdin
734
-
735
- @stdin.close unless @stdin.closed?
736
- terminate_server_process
737
- @stdout.close unless @stdout.closed?
738
- @stderr.close unless @stderr.closed?
739
- @reader_thread&.kill
740
- @stderr_thread&.kill
741
- rescue StandardError
742
- # Clean up resources during unexpected termination
743
- ensure
744
- # Release any buffered responses / awaiting markers
745
- @mutex.synchronize do
746
- @pending.clear
747
- @awaiting.clear
1055
+ # Everything this transport left on this thread — the notes of the
1056
+ # entries it served and recorded, the credentials, parameters and
1057
+ # metadata of its requests — describes a slice that will never be
1058
+ # tagged and a request that will never be made.
1059
+ forget_transport_thread_state
1060
+ teardown_transport(@transport_lock.synchronize { @transport_generation })
1061
+ end
1062
+
1063
+ # What one teardown claimed: the handles of one process, and the
1064
+ # generation the transport moved on to when it claimed them. Torn down
1065
+ # from the record, not from the transport, so a teardown that finishes
1066
+ # late — after a host request has already established the next process
1067
+ # — dismantles only what it claimed.
1068
+ TornDownTransport = Struct.new(:generation, :stdin, :stdout, :stderr, :wait_thread, :reader_thread,
1069
+ :stderr_thread, :session, keyword_init: true)
1070
+
1071
+ # Tear down the process of one transport generation.
1072
+ #
1073
+ # The teardown claims the process under the transport lock — the handles
1074
+ # come off the transport and the generation moves on, so a request judged
1075
+ # current is written before this or not at all, and a second teardown of
1076
+ # the same process (the host's `cleanup` racing the reader's own, or the
1077
+ # reverse) finds nothing to claim. Everything after the claim works on the
1078
+ # claimed handles alone: a `cleanup` that ran ahead of it may already have
1079
+ # let the host establish a replacement, and a reader whose teardown
1080
+ # finishes only now used to clear that replacement's session, handles,
1081
+ # reader and handshake on its way out — leaving a live, re-sent
1082
+ # subscription registered on a transport that had just forgotten its
1083
+ # process.
1084
+ # @param generation [Integer] the transport generation to tear down; a
1085
+ # generation that has moved on is not this teardown's to touch
1086
+ # @return [void]
1087
+ def teardown_transport(generation)
1088
+ claimed = claim_transport(generation)
1089
+ return unless claimed
1090
+
1091
+ begin
1092
+ park_open_subscriptions(claimed)
1093
+ terminate_server_process(claimed.wait_thread)
1094
+ claimed.stdout.close unless claimed.stdout.nil? || claimed.stdout.closed?
1095
+ claimed.stderr.close unless claimed.stderr.nil? || claimed.stderr.closed?
1096
+ # The reader calls this itself when the process exits on its own, and a
1097
+ # thread that kills itself here would abandon the rest of the shutdown —
1098
+ # including the restart that a live subscription depends on. It is at
1099
+ # EOF by then and returns on its own.
1100
+ claimed.reader_thread&.kill unless claimed.reader_thread.equal?(Thread.current)
1101
+ claimed.stderr_thread&.kill
1102
+ rescue StandardError
1103
+ # Clean up resources during unexpected termination
1104
+ ensure
1105
+ forget_torn_down_transport(claimed)
1106
+ end
1107
+ end
1108
+
1109
+ # Take the process of a transport generation off the transport, for one
1110
+ # teardown to dismantle. Under the transport lock: the handles and the
1111
+ # generation change together, and closing stdin here is what makes a
1112
+ # request judged current either already written or never written.
1113
+ # @param generation [Integer] the transport generation to claim
1114
+ # @return [TornDownTransport, nil] the claim, or nil when that generation
1115
+ # is not the live one (already claimed, or replaced)
1116
+ def claim_transport(generation)
1117
+ @transport_lock.synchronize do
1118
+ return nil unless @stdin && generation == @transport_generation
1119
+
1120
+ # Past this point the reader threads speak for a transport that is
1121
+ # being dismantled on purpose: their EOF must not retire whatever
1122
+ # replaces it. A 2025-11-25 handshake opened a session that ends
1123
+ # with the process. A stateless 2026-07-28 peer holds none: a task it
1124
+ # created outlives the connection exactly as it does over sessionless
1125
+ # HTTP, so the process that replaces this one is asked about it
1126
+ # rather than the task being written off as gone with a session that
1127
+ # never existed.
1128
+ bump_session_epoch if ending_session?
1129
+ @transport_generation += 1
1130
+ claimed = TornDownTransport.new(generation: @transport_generation, stdin: @stdin, stdout: @stdout,
1131
+ stderr: @stderr, wait_thread: @wait_thread, reader_thread: @reader_thread,
1132
+ stderr_thread: @stderr_thread, session: @session)
1133
+ @stdin.close unless @stdin.closed?
1134
+ @stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
1135
+ # The ids still outstanding went out on the process just claimed and
1136
+ # will never be answered. They are recorded as dropped here, at the
1137
+ # claim, so their waiters fail on that record — promptly, woken here —
1138
+ # even when a replacement is established before this teardown
1139
+ # finishes and {#forget_torn_down_transport} therefore leaves the
1140
+ # replacement's bookkeeping alone.
1141
+ @mutex.synchronize do
1142
+ dropped_requests.merge(@awaiting.keys)
1143
+ @cond.broadcast
1144
+ end
1145
+ claimed
1146
+ end
1147
+ end
1148
+
1149
+ # Subscriptions do not survive the process: keep the ones the host still
1150
+ # wants so they are re-sent once the process is re-established
1151
+ # (basic/patterns/subscriptions "Graceful Closure"). They are moved to
1152
+ # the pending list outside the registry lock, since a subscription being
1153
+ # opened holds its own lock while taking that one.
1154
+ #
1155
+ # Only the claimed process's, the way the pipe teardown is. Draining the
1156
+ # registry took whatever it held at that moment, and a teardown that got
1157
+ # here after a host request had established the replacement parked the
1158
+ # streams that replacement was already serving: their outstanding listen
1159
+ # ids were discarded with the dead process's, so `close` had nothing left
1160
+ # to cancel and the server went on serving a stream this client could no
1161
+ # longer name. A subscription carries the generation its listen went out
1162
+ # on ({MCPClient::Subscription#with_open_id}), which is the same question
1163
+ # the pipe asks, asked of the registry.
1164
+ #
1165
+ # Enqueued through the one lock a deferred hand-over writes under too,
1166
+ # since a listen write failing on the process being torn down lands in
1167
+ # the window between the snapshot and the write
1168
+ # (JsonRpcTransport#queue_subscriptions_of_ended_process, which also
1169
+ # forgets the listen ids this process was holding).
1170
+ # @param claimed [TornDownTransport] what this teardown claimed
1171
+ # @return [void]
1172
+ def park_open_subscriptions(claimed)
1173
+ open_subscriptions = subscriptions_mutex.synchronize do
1174
+ mine, theirs = subscriptions.values.partition { |subscription| claimed_subscription?(subscription, claimed) }
1175
+ subscriptions.keep_if { |_, subscription| theirs.include?(subscription) }
1176
+ mine
1177
+ end
1178
+ open_subscriptions.each(&:mark_reconnecting)
1179
+ queue_subscriptions_of_ended_process(open_subscriptions.select(&:reconnectable?))
1180
+ end
1181
+
1182
+ # Whether a registered subscription belongs to the process a teardown
1183
+ # claimed. The claim's generation is the one the transport moved *to*, so
1184
+ # the process it took is everything below it; a subscription opened on the
1185
+ # replacement carries a higher one. One that never recorded a generation
1186
+ # is treated as the claim's, which is what a registry entry was before
1187
+ # the stamp existed.
1188
+ # @param subscription [MCPClient::Subscription]
1189
+ # @param claimed [TornDownTransport] what this teardown claimed
1190
+ # @return [Boolean]
1191
+ def claimed_subscription?(subscription, claimed)
1192
+ generation = subscription.open_generation
1193
+ generation.nil? || generation < claimed.generation
1194
+ end
1195
+
1196
+ # The last word of a teardown, whatever else went wrong: the record of the
1197
+ # process it claimed is ended, and — only if no process has been
1198
+ # established since the claim — the transport forgets its session and its
1199
+ # handshake, and wakes the requests that will never be answered.
1200
+ #
1201
+ # No further response can arrive on a transport that is being dismantled,
1202
+ # so nothing is outstanding any more. Responses that already arrived are
1203
+ # kept: they are answers this client received and has not handed to their
1204
+ # caller yet, and a restart happening in that window must not turn a
1205
+ # completed request into a timeout. Each one belongs to a caller that is
1206
+ # about to take it out of the map, so keeping them cannot accumulate.
1207
+ # Waiters are woken so a request that will never be answered re-checks its
1208
+ # deadline rather than blocking on a reader thread that has been killed.
1209
+ # A replacement established meanwhile owns whatever is outstanding now,
1210
+ # and its handshake and session are its own.
1211
+ # @param claimed [TornDownTransport] what this teardown claimed
1212
+ # @return [void]
1213
+ def forget_torn_down_transport(claimed)
1214
+ # The process this session was is gone, whatever else went wrong above.
1215
+ # Its record outlives it: it is what the next session's re-send of the
1216
+ # open subscriptions asks about (JsonRpcTransport#reopen_subscriptions).
1217
+ claimed.session&.ended
1218
+ # Asked and acted on in one step, under the lock a replacement is
1219
+ # established through. Asking first and writing afterwards let the
1220
+ # answer go stale in between: a host request that established the
1221
+ # replacement in that window did so *after* both checks passed, and
1222
+ # this teardown then marked its outstanding requests dropped and left
1223
+ # it uninitialized with no session — a live process the transport could
1224
+ # no longer name.
1225
+ @transport_lock.synchronize do
1226
+ # Either signal says a process has been established since the claim:
1227
+ # `connect` moves the generation on, and the handshake that follows
1228
+ # records a new session.
1229
+ next if @transport_generation != claimed.generation
1230
+ next unless @session.equal?(claimed.session)
1231
+
1232
+ @mutex.synchronize do
1233
+ # The ids still outstanding are recorded as dropped: their waiters
1234
+ # fail on that record, whenever they next run, rather than wait out
1235
+ # their timeouts because the restart cleared the retirement first.
1236
+ dropped_requests.merge(@awaiting.keys)
1237
+ @response_arrivals&.clear
1238
+ @awaiting.clear
1239
+ @cond.broadcast
1240
+ end
1241
+ @session = nil
1242
+ # Cached results belong to the process that just ended.
1243
+ clear_result_cache
1244
+ # The next request re-establishes the process and, on a modern
1245
+ # server, re-sends the subscriptions the host still holds.
1246
+ @initialized = false
748
1247
  end
749
- @stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
1248
+ end
1249
+
1250
+ # The server process ended on its own (its stdout reached EOF). MCP
1251
+ # 2026-07-28 stdio "Unexpected Termination": the client SHOULD restart
1252
+ # it; in-flight requests are lost and subscriptions must be
1253
+ # re-established. Marking the session uninitialized makes the next
1254
+ # request spawn a fresh process and re-open live subscriptions — and a
1255
+ # host that is only waiting for notifications makes no such request, so
1256
+ # an open subscription restarts the process here instead.
1257
+ # @param session [MCPClient::ServerStdio::ChildSession, nil] the record of
1258
+ # the process that exited; the crash-loop bound only counts an exit this
1259
+ # path recorded, never a teardown the host asked for
1260
+ # (see {JsonRpcTransport#crash_looping?})
1261
+ # @param generation [Integer] the transport generation of the process
1262
+ # that exited; a generation the host has already torn down or replaced
1263
+ # is not this exit's to handle
1264
+ # @return [void]
1265
+ def handle_server_exit(session = @session, generation = @transport_generation)
1266
+ return unless live_transport?(generation, session)
1267
+
1268
+ @logger.warn('MCP server process ended unexpectedly')
1269
+ # Stamped before the teardown, on the record the teardown retires: this
1270
+ # is the one path that knows the process was not asked to go.
1271
+ session&.exited_unexpectedly
1272
+ # Retired before the teardown bumps the generation past this reader's,
1273
+ # so the exit stays observable the way any other unexpected exit is:
1274
+ # the next request releases the dead handles and negotiates again.
1275
+ retire_transport(generation)
1276
+ teardown_transport(generation)
1277
+ restart_for_open_subscriptions
750
1278
  end
751
1279
 
752
1280
  # Terminate the spawned server process per the MCP 2025-11-25 stdio
@@ -754,23 +1282,84 @@ module MCPClient
754
1282
  # so wait for the process to exit on its own; if it does not exit within
755
1283
  # the grace period send SIGTERM, wait again, and finally send SIGKILL.
756
1284
  # @return [void]
757
- def terminate_server_process
758
- return unless @wait_thread
759
- return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
1285
+ # @param wait_thread [Process::Waiter, nil] the process to wait for; the
1286
+ # one a teardown claimed, since the transport may hold a replacement by now
1287
+ # @return [void]
1288
+ def terminate_server_process(wait_thread = @wait_thread)
1289
+ return unless wait_thread
1290
+ return if wait_thread.join(SHUTDOWN_GRACE_PERIOD)
1291
+
1292
+ signal_server_process('TERM', wait_thread)
1293
+ return if wait_thread.join(SHUTDOWN_GRACE_PERIOD)
760
1294
 
761
- signal_server_process('TERM')
762
- return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
1295
+ signal_server_process('KILL', wait_thread)
1296
+ wait_thread.join(SHUTDOWN_GRACE_PERIOD)
1297
+ end
763
1298
 
764
- signal_server_process('KILL')
765
- @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
1299
+ # Cancel a subscription: on stdio there is no per-request stream to
1300
+ # close, so the client sends notifications/cancelled referencing the
1301
+ # subscriptions/listen request id.
1302
+ # @param subscription [MCPClient::Subscription]
1303
+ # @return [void]
1304
+ def cancel_subscription(subscription)
1305
+ # Closed first: a re-open in flight holds the subscription's lock until
1306
+ # it has taken its new id, so the id unregistered and cancelled below is
1307
+ # the one the server was actually sent.
1308
+ subscription.finish(by_client: true)
1309
+ unregister_subscription(subscription)
1310
+ subscriptions_mutex.synchronize { resource_subscriptions.delete_if { |_uri, sub| sub.equal?(subscription) } }
1311
+ cancel_outstanding_listens(subscription)
1312
+ end
1313
+
1314
+ # Tell the server the client has stopped reading every listen request it
1315
+ # wrote for this subscription on the process this pipe belongs to.
1316
+ #
1317
+ # Not just the one the subscription is on: a second listen written for it
1318
+ # on one process — a hand-over the queue duplicated, say — leaves the
1319
+ # server serving the first stream, and naming only the newest id left that
1320
+ # one open with the client no longer able to refer to it.
1321
+ #
1322
+ # The subscription's *current* id is not named on top of those. It used to
1323
+ # be, so that a handle closed between taking an id and writing its request
1324
+ # was cancelled anyway — but that cancellation named a request the server
1325
+ # had not been sent, and reached the pipe ahead of it. Every id this
1326
+ # client wrote is recorded ({MCPClient::Subscription#record_outstanding_listen}),
1327
+ # so a written id is here already; an id that is only assigned is the
1328
+ # opener's to cancel, once it has actually written it.
1329
+ #
1330
+ # Ids written to a pipe other than this one are left where they are: the
1331
+ # process reading this one was never sent those requests, and naming them
1332
+ # on it would cancel requests it has never seen
1333
+ # ({MCPClient::Subscription#take_outstanding_listens}).
1334
+ # @param subscription [MCPClient::Subscription]
1335
+ # @param io [IO, nil] the pipe to cancel on; defaults to the live process's
1336
+ # stdin, and is pinned by a caller that is cancelling ids it wrote to one
1337
+ # particular process (see {JsonRpcTransport#open_subscription})
1338
+ # @return [void]
1339
+ def cancel_outstanding_listens(subscription, io: @stdin)
1340
+ subscription.take_outstanding_listens(io).each { |id| send_subscription_cancellation(id, io: io) }
1341
+ end
1342
+
1343
+ # Tell the server the client closed a subscriptions/listen request.
1344
+ # @param id [Integer, String, nil] the listen request id
1345
+ # @param io [IO, nil] the pipe to write the cancellation to
1346
+ # @return [void]
1347
+ def send_subscription_cancellation(id, io: @stdin)
1348
+ return unless io && id
1349
+
1350
+ notif = build_jsonrpc_notification('notifications/cancelled',
1351
+ { 'requestId' => id, 'reason' => 'Client closed subscription' })
1352
+ io.puts(notif.to_json)
1353
+ rescue StandardError => e
1354
+ @logger.debug("Failed to send subscription cancellation: #{e.message}")
766
1355
  end
767
1356
 
768
1357
  # Send a signal to the server process, tolerating a process that has
769
1358
  # already exited or cannot be signalled.
770
1359
  # @param signal [String] signal name, e.g. 'TERM' or 'KILL'
771
1360
  # @return [void]
772
- def signal_server_process(signal)
773
- Process.kill(signal, @wait_thread.pid)
1361
+ def signal_server_process(signal, wait_thread = @wait_thread)
1362
+ Process.kill(signal, wait_thread.pid)
774
1363
  rescue Errno::ESRCH, Errno::EPERM => e
775
1364
  @logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
776
1365
  end