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
@@ -12,14 +12,736 @@ module MCPClient
12
12
  # @return [void]
13
13
  # @raise [MCPClient::Errors::ConnectionError] if initialization fails
14
14
  def ensure_initialized
15
- return if @initialized
15
+ # Read the retirement flag FIRST. A restart clears @initialized and
16
+ # then the flag; a thread reading them the other way round could see
17
+ # a stale handshake next to a cleared flag and skip the lock, then
18
+ # register a request against a transport that is being replaced.
19
+ return if !transport_retired? && @initialized
20
+
21
+ @init_lock.synchronize do
22
+ # The subprocess behind a completed handshake exited under it:
23
+ # release its pipes and reader threads before connect overwrites
24
+ # the handles, then negotiate again against the fresh process.
25
+ release_retired_transport if transport_retired?
26
+ return if @initialized
27
+
28
+ begin
29
+ # Ordinary requests are refused while the replacement is being
30
+ # negotiated (see #send_request): the restart clears the
31
+ # retirement before it negotiates, so the generation alone would
32
+ # judge a half-restarted transport current.
33
+ @transport_lock.synchronize { @negotiating = true }
34
+ # A process the host connected explicitly is negotiated, not
35
+ # replaced: spawning again would orphan it.
36
+ spawned = !live_process?
37
+ connect if spawned
38
+ # The record of the process that is now the session. Everything the
39
+ # crash-loop bound needs is written on it, by its own lifecycle: see
40
+ # {MCPClient::ServerStdio::ChildSession}. A process spawned here gets
41
+ # a fresh record; one the host connected explicitly keeps the record
42
+ # it already has, or gets its first.
43
+ session = spawned || @session.nil? ? (@session = MCPClient::ServerStdio::ChildSession.new) : @session
44
+ start_reader unless @reader_thread&.alive?
45
+ start_stderr_reader unless @stderr_thread&.alive?
46
+ negotiate_protocol
47
+ rescue StandardError
48
+ # A failed negotiation must not leave the subprocess, its pipes
49
+ # and its reader threads behind. @initialized stays false, so the
50
+ # next request runs connect again and overwrites @stdin/@stdout/
51
+ # @wait_thread — putting the first process permanently out of
52
+ # cleanup's reach.
53
+ release_transport
54
+ raise
55
+ ensure
56
+ @transport_lock.synchronize { @negotiating = false }
57
+ end
58
+
59
+ @initialized = true
60
+ # The record is passed rather than read back: a cleanup on another
61
+ # thread can retire @session while this one is still handing the
62
+ # subscriptions to the process it established, and the crash-loop
63
+ # bound must be stamped on the process that actually received them.
64
+ reopen_subscriptions(session)
65
+ end
66
+ end
67
+
68
+ # @return [void]
69
+ def ensure_session_ready
70
+ ensure_initialized
71
+ end
72
+
73
+ # Send the subscriptions/listen request for a subscription (a fresh id
74
+ # each time it is opened or re-opened).
75
+ # @param subscription [MCPClient::Subscription]
76
+ # @return [void]
77
+ def open_subscription(subscription)
78
+ # The pipe this attempt writes to, taken before anything is recorded
79
+ # about it and written to whatever happens next.
80
+ #
81
+ # Reading the transport's *current* stdin at the write instead let a
82
+ # listen that was still pending when the process exited be written to
83
+ # the process that replaced it: the teardown had already forgotten that
84
+ # id (nothing written to a dead process is outstanding, and none of its
85
+ # ids may be cancelled on its successor), so the replacement was
86
+ # serving a second stream this client could no longer name — and the
87
+ # restart's own listen was the only one `close` cancelled. Pinning the
88
+ # pipe makes the bookkeeping follow the process actually written to: a
89
+ # write that lands late goes to the pipe it was recorded against, and
90
+ # once the teardown has closed that pipe it fails into the error paths
91
+ # below instead.
92
+ # Read with the generation it belongs to, so the two agree: the pipe
93
+ # is what this attempt writes to, and the generation is what a
94
+ # teardown compares its claim against when it decides whose
95
+ # subscriptions to park.
96
+ stdin, generation = @transport_lock.synchronize { [@stdin, @transport_generation] }
97
+ # Whether the subscription has taken this attempt's id yet. A failure
98
+ # before that — the request could not be built — is nobody's to have
99
+ # superseded, and was filed as exactly that while the id it never
100
+ # took was compared with the one it had (see {#fail_open_attempt}).
101
+ taken = false
102
+ id = next_id
103
+ # No caller waits on this id: the response, if any, is the server's
104
+ # graceful closure and is routed to the subscription itself.
105
+ @mutex.synchronize { @awaiting.delete(id) }
106
+ request = build_jsonrpc_request('subscriptions/listen', { 'notifications' => subscription.requested }, id)
107
+ # A {Subscription#close} racing with a re-open must not leave the
108
+ # server holding a subscription this client can no longer cancel:
109
+ # taking the id and registering it happen under the subscription's own
110
+ # lock (so a close that wins stops the re-open outright), and a close
111
+ # that cancelled this id while the request was still going out is
112
+ # named again below, once the server has seen the listen.
113
+ taken = true
114
+ return unless subscription.with_open_id(id, generation) { register_subscription(subscription) }
115
+
116
+ # Recorded before the write, and whatever the write does: from here on
117
+ # the server may be serving this listen, and {#cancel_subscription}
118
+ # has to be able to name it even after a later request has taken the
119
+ # subscription's own id (see {Subscription#record_outstanding_listen}).
120
+ # It is not cancellable until the write has finished, though — a close
121
+ # that named it while the pipe still held nothing would put
122
+ # `cancelled(n)` on the wire ahead of `listen(n)`. So this attempt
123
+ # marks it written and then cancels it itself, if that close has
124
+ # happened by then.
125
+ #
126
+ # Both of those happen however the write ends. A write that raised may
127
+ # still have put the request on the pipe, and the id is recorded for
128
+ # exactly that reason; whether that request is then cancelled is
129
+ # decided with the failure's verdict (see {#fail_open_attempt}). The
130
+ # pipe is named on both, so the cancellation goes to the process the
131
+ # request went to and to no other.
132
+ subscription.record_outstanding_listen(id, stdin)
133
+ begin
134
+ send_request(request, io: stdin)
135
+ ensure
136
+ subscription.mark_listen_written(id)
137
+ end
138
+ cancel_outstanding_listens(subscription, io: stdin) if subscription.closed_by_client?
139
+ rescue StandardError => e
140
+ fail_open_attempt(subscription, taken ? id : nil, e, io: stdin)
141
+ end
142
+
143
+ # Undo the listen attempt that just failed — but only when the
144
+ # subscription is this attempt's to undo.
145
+ #
146
+ # A write can block long enough for the child to exit and for the
147
+ # restart that follows to take the subscription over. Two things can
148
+ # have happened by then, and neither is this attempt's to tear down:
149
+ #
150
+ # * the restart already re-opened it under a *newer id*. The registry is
151
+ # keyed by listen id, so unregistering "the subscription" would delete
152
+ # the new registration and finishing it would close a stream the fresh
153
+ # process is serving. Naming the id the write went out with keeps this
154
+ # attempt to its own. It only says so in the log — unless that newer
155
+ # stream has itself already failed, in which case the caller must be
156
+ # told rather than handed a closed handle with no explanation.
157
+ # * the subscription is one a session is being handed
158
+ # ({MCPClient::Subscription#reestablishing?}): it is a stream the spec
159
+ # requires to be re-sent, not one this caller asked for, so a write
160
+ # that failed because the process was gone (stdin closed under it, an
161
+ # EPIPE to a child that exited on sight, or a nested restart holding
162
+ # the init lock) leaves it for the next session instead of ending it.
163
+ # The question is asked of the subscription rather than of its state:
164
+ # taking the new listen id has already moved it from :reconnecting to
165
+ # :pending by the time the write raises, so the state says "being
166
+ # opened" for the very hand-over that is failing.
167
+ #
168
+ # Which of those it is, and the transition that follows, are decided in
169
+ # one step on the subscription ({MCPClient::Subscription#fail_attempt}).
170
+ # Asked first and acted on afterwards, the answer went stale in
171
+ # between: a restart re-opened the subscription under a newer id and
172
+ # had it acknowledged after the ownership check had passed, and this
173
+ # attempt then finished the healthy replacement. Only the registration
174
+ # comes first — it is scoped to this attempt's id, so a newer one is
175
+ # never touched by it.
176
+ #
177
+ # An attempt that failed before the subscription took its id at all
178
+ # (the request could not be built) arrives with no id: nothing was
179
+ # registered for it, and no newer attempt can have superseded it, so
180
+ # the failure is the caller's — or the next session's — to hear about.
181
+ # A failure that is the caller's own abandons the request the write may
182
+ # have put on the pipe, and abandoning a request on stdio is a
183
+ # cancellation naming its id (basic/transports/stdio "Cancellation"):
184
+ # left unnamed, the server was serving `listen(n)` for a subscription
185
+ # that had ended with the error, and nothing this client held could
186
+ # cancel it any more. It is sent best effort, to the pipe the request
187
+ # went to. A superseded attempt's request went to a process the restart
188
+ # has replaced, and a deferred hand-over's to one on its way out —
189
+ # neither is cancelled on a pipe that is gone.
190
+ # @param subscription [MCPClient::Subscription]
191
+ # @param id [Integer, String, nil] the listen id this attempt sent
192
+ # under; nil when it failed before taking one
193
+ # @param error [StandardError] why it failed
194
+ # @param io [IO, nil] the pipe the request was written to
195
+ # @return [void]
196
+ # @raise [StandardError] the failure, when it was still this attempt's
197
+ def fail_open_attempt(subscription, id, error, io: nil)
198
+ unregister_subscription_id(subscription, id) if id
199
+ failure = subscription_failure(error)
200
+ case subscription.fail_attempt(id, failure)
201
+ when :superseded then fail_superseded_attempt(subscription, id, error)
202
+ when :deferred then defer_reestablished_attempt(subscription, id, error)
203
+ else
204
+ cancel_outstanding_listens(subscription, io: io) if io && id
205
+ raise failure
206
+ end
207
+ end
208
+
209
+ # @param error [StandardError] a failure
210
+ # @return [MCPClient::Errors::MCPError] the error a subscription ends with
211
+ def subscription_failure(error)
212
+ error.is_a?(MCPClient::Errors::MCPError) ? error : MCPClient::Errors::TransportError.new(error.message)
213
+ end
214
+
215
+ # Put a subscription whose hand-over could not be written back on the
216
+ # queue the next session drains, and say so.
217
+ #
218
+ # It has just been taken off that queue by {#reopen_subscriptions} and
219
+ # out of the registry above, so leaving it alone would strand it: no
220
+ # session would re-send it and no `cleanup` would find it again. The
221
+ # queue is where a subscription waiting for a process belongs, and the
222
+ # process that could not be written to is on its way out — its reader
223
+ # reaches EOF and restarts, and the crash-loop bound then decides
224
+ # whether another one is worth spawning.
225
+ # The subscription is :reconnecting again already — that transition
226
+ # was decided with the verdict, under the one lock
227
+ # ({MCPClient::Subscription#fail_attempt}).
228
+ # @param subscription [MCPClient::Subscription]
229
+ # @param id [Integer, String, nil] the listen id this attempt sent
230
+ # under; nil when it failed before taking one
231
+ # @param error [StandardError] why it failed
232
+ # @return [void]
233
+ def defer_reestablished_attempt(subscription, id, error)
234
+ enqueue_reconnecting_subscriptions([subscription])
235
+ @logger.debug("#{id ? "subscriptions/listen #{id}" : 'a subscriptions/listen request'} failed while the " \
236
+ "subscription was being handed to a new process (#{error.message}); it will be re-sent to " \
237
+ 'the next one')
238
+ end
239
+
240
+ # A failure the subscription has already moved on from: harmless while
241
+ # the stream that replaced it stands, and the caller's answer when it
242
+ # does not.
243
+ # @param subscription [MCPClient::Subscription]
244
+ # @param id [Integer, String] the listen id this attempt sent under
245
+ # @param error [StandardError] why it failed
246
+ # @return [void]
247
+ # @raise [MCPClient::Errors::MCPError] the replacement's own failure
248
+ def fail_superseded_attempt(subscription, id, error)
249
+ replacement_error = subscription.closed? ? subscription.error : nil
250
+ if replacement_error
251
+ @logger.warn("subscriptions/listen #{id} failed (#{error.message}) and the stream that replaced it " \
252
+ "failed too: #{sanitize_log_text(replacement_error.message)}")
253
+ raise replacement_error
254
+ end
255
+
256
+ @logger.debug("subscriptions/listen #{id} failed after the subscription was re-opened " \
257
+ "(#{error.message}); the newer stream stands")
258
+ end
259
+
260
+ # After the process was re-established, re-send subscriptions/listen
261
+ # for every subscription the host still holds open ("the server holds
262
+ # no subscription state across reconnections").
263
+ #
264
+ # This is the one place a session is handed the subscriptions, so it is
265
+ # the one place that decides whether to hand them over at all:
266
+ #
267
+ # * a re-established session that turns out to be legacy cannot carry
268
+ # them. On `protocol: :auto` the restarted process may negotiate an
269
+ # older revision than the one that died, and {#cleanup} has already
270
+ # moved the open subscriptions out of the registry — returning would
271
+ # leave them :reconnecting for ever with the host never told.
272
+ # * neither can a session that would only continue a crash loop: if the
273
+ # last process these subscriptions were given died less than
274
+ # {MCPClient::ServerStdio::SUBSCRIPTION_RESTART_MIN_INTERVAL} after
275
+ # receiving them, handing them over again would spawn the same corpse
276
+ # for ever. Deciding here rather than at the restart is what makes the
277
+ # bound hold: a process is re-established by whichever thread gets
278
+ # there first — the reader's restart or a host request — and only the
279
+ # re-send is common to both.
280
+ #
281
+ # Either way the subscriptions end with the error, so the host learns
282
+ # from `closed?`/`error` rather than waiting on a stream that is not
283
+ # coming back.
284
+ # @param session [MCPClient::ServerStdio::ChildSession, nil] the record of
285
+ # the process being handed the subscriptions
286
+ # @return [void]
287
+ def reopen_subscriptions(session = @session)
288
+ pending = take_reconnecting_subscriptions
289
+ # A session handed nothing asks nothing, and must not spend the record
290
+ # either: the subscriptions are still open on the session this one
291
+ # replaced (a nested restart re-establishes the process between a
292
+ # hand-over and the next exit), and the process that carried them is
293
+ # still what the next hand-over has to be judged against.
294
+ return if pending.empty?
295
+
296
+ # The record of the process that last carried subscriptions answers
297
+ # exactly one question — whether handing them over again would only
298
+ # respawn the same corpse — and this is the moment it is asked. Asking
299
+ # spends it, whatever the answer: the loop it recorded is either
300
+ # broken here (these subscriptions are closed and never handed on) or
301
+ # replaced below by the record of the process that takes them. Left
302
+ # standing it outlived the loop it described, and the next hand-over —
303
+ # of a subscription opened directly on the replacement, which then ran
304
+ # healthily for hours — was refused for a crash it had no part in.
305
+ carrier = @subscription_carrier
306
+ @subscription_carrier = nil
307
+
308
+ refusal = reopen_refusal(carrier)
309
+ return fail_subscriptions(pending, refusal) if refusal
310
+
311
+ # Stamped on the process before the writes go out: one that exits
312
+ # while they are still going to it survived receiving them by no time
313
+ # at all, which is what the next hand-over needs to know.
314
+ session&.carrying_subscriptions
315
+ @subscription_carrier = session
316
+ pending.each do |subscription|
317
+ open_subscription(subscription)
318
+ # A re-sent listen is a new request the replacement has to
319
+ # acknowledge, so it carries the deadline the first one did: a
320
+ # process that takes it and then says nothing is otherwise bounded
321
+ # by nothing at all on stdio.
322
+ rearm_acknowledgment_deadline(subscription)
323
+ rescue StandardError => e
324
+ @logger.warn("Could not re-establish subscription: #{e.message}")
325
+ end
326
+ end
327
+
328
+ # @return [Boolean] whether a subprocess is connected and still running
329
+ def live_process?
330
+ return false unless @stdin.respond_to?(:closed?) && !@stdin.closed?
331
+
332
+ @wait_thread.respond_to?(:alive?) && @wait_thread.alive?
333
+ end
334
+
335
+ # Ids of requests a teardown found outstanding: recorded under @mutex
336
+ # by cleanup, consumed by their waiters (see #wait_response).
337
+ # @return [Set<Integer>]
338
+ def dropped_requests
339
+ @dropped_requests ||= Set.new
340
+ end
341
+
342
+ # @return [Boolean] whether the subprocess behind the handshake exited
343
+ def transport_retired?
344
+ @transport_retired
345
+ end
346
+
347
+ # Discard a transport whose subprocess exited after a successful
348
+ # handshake, so the next negotiation starts from a clean slate rather
349
+ # than on top of the dead process's handles.
350
+ # @return [void]
351
+ def release_retired_transport
352
+ @logger.info('The MCP server subprocess exited; restarting it for this request')
353
+ @initialized = false
354
+ @transport_retired = false
355
+ release_transport
356
+ end
357
+
358
+ # Tear down a transport that is not going to be used again — a
359
+ # handshake that never completed, or a subprocess that exited under a
360
+ # completed one. Failures are swallowed: the transport being unusable
361
+ # is often the reason it is being released, and the original error is
362
+ # the one worth raising.
363
+ # @return [void]
364
+ def release_transport
365
+ cleanup
366
+ rescue StandardError => e
367
+ @logger.debug("Releasing the stdio transport did not complete cleanly: #{e.message}")
368
+ end
369
+
370
+ # Why this session must not be given the open subscriptions, if it must
371
+ # not (see {#reopen_subscriptions}).
372
+ # @param carrier [MCPClient::ServerStdio::ChildSession, nil] the record
373
+ # of the process that last carried them
374
+ # @return [StandardError, nil]
375
+ def reopen_refusal(carrier)
376
+ unless modern?
377
+ return MCPClient::Errors::CapabilityError.new(
378
+ 'the re-established server process negotiated ' \
379
+ "#{protocol_version || 'no version'}, which cannot carry a subscriptions/listen stream"
380
+ )
381
+ end
382
+ return nil unless crash_looping?(carrier)
383
+
384
+ @logger.error('MCP server process exited again right after it was given its subscriptions; closing them')
385
+ MCPClient::Errors::TransportError.new('MCP server process exited again right after a restart')
386
+ end
387
+
388
+ # Whether the process these subscriptions were last given to died too
389
+ # soon after receiving them for another process to be worth spawning.
390
+ # The two moments are stamped on that process's own record, by its own
391
+ # lifecycle, so this answer cannot be spoiled by whatever another thread
392
+ # is doing to another process.
393
+ # @param carrier [MCPClient::ServerStdio::ChildSession, nil] the record
394
+ # of the process that last carried them
395
+ # @return [Boolean]
396
+ def crash_looping?(carrier)
397
+ carrier&.died_carrying_subscriptions?(
398
+ MCPClient::ServerStdio::SUBSCRIPTION_RESTART_MIN_INTERVAL
399
+ ) || false
400
+ end
401
+
402
+ # Put subscriptions on the queue the next session drains, at most once
403
+ # each.
404
+ #
405
+ # Two paths write to this queue and they overlap: {#cleanup} moves the
406
+ # open subscriptions onto it, and {#defer_reestablished_attempt} puts
407
+ # back a hand-over whose write failed — and the second happens inside
408
+ # the window the first leaves between taking the registry snapshot and
409
+ # writing it here. An unguarded Array `concat`ed by one and `<<`ed by
410
+ # the other is undefined in MRI: the same window can lose the entry, and
411
+ # a subscription no session re-sends and no `cleanup` finds again is a
412
+ # stream the spec says MUST be re-established, stranded with the host
413
+ # never told. Scanning that Array with `equal?` while another thread
414
+ # grows it does not make the append safe either — it only decided,
415
+ # unreliably, whether to make a second one. So both paths come through
416
+ # here, under the one lock that also guards the take, and membership is
417
+ # by identity: a handle appears on the queue once, and one hand-over
418
+ # goes out for it.
419
+ # @param subscriptions [Array<MCPClient::Subscription>] to enqueue
420
+ # @return [Array<MCPClient::Subscription>] the whole queue afterwards
421
+ def enqueue_reconnecting_subscriptions(subscriptions)
422
+ reconnecting_mutex.synchronize { enqueue_reconnecting_locked(subscriptions) }
423
+ end
424
+
425
+ # Hand the subscriptions of a process that is being torn down to the
426
+ # next one, forgetting the listen ids that process was holding: nothing
427
+ # written to it is outstanding any more, and none of those ids may be
428
+ # cancelled on the process that replaces it (see
429
+ # {MCPClient::Subscription#record_outstanding_listen}).
430
+ #
431
+ # Both steps happen under the lock a hand-over takes them off the queue
432
+ # under, so a session that is already re-sending them cannot have the
433
+ # ids it has just written forgotten by this teardown: it cannot reach
434
+ # its own writes until this has finished.
435
+ # @param subscriptions [Array<MCPClient::Subscription>] the ones still open
436
+ # @return [void]
437
+ def queue_subscriptions_of_ended_process(subscriptions)
438
+ reconnecting_mutex.synchronize do
439
+ enqueue_reconnecting_locked(subscriptions).each(&:discard_outstanding_listens)
440
+ end
441
+ end
442
+
443
+ # @param subscriptions [Array<MCPClient::Subscription>] to enqueue
444
+ # @return [Array<MCPClient::Subscription>] the whole queue afterwards
445
+ def enqueue_reconnecting_locked(subscriptions)
446
+ queue = (@reconnecting_subscriptions ||= [])
447
+ subscriptions.each do |subscription|
448
+ queue << subscription unless queue.any? { |queued| queued.equal?(subscription) }
449
+ end
450
+ queue.dup
451
+ end
452
+
453
+ # Take the subscriptions waiting for a process, leaving the queue empty.
454
+ # @return [Array<MCPClient::Subscription>]
455
+ def take_reconnecting_subscriptions
456
+ reconnecting_mutex.synchronize do
457
+ pending = (@reconnecting_subscriptions || []).select(&:reconnectable?)
458
+ @reconnecting_subscriptions = []
459
+ pending
460
+ end
461
+ end
462
+
463
+ # @return [Array<MCPClient::Subscription>] the subscriptions on the queue
464
+ # that a process could still be re-sent to
465
+ def reconnecting_subscriptions
466
+ reconnecting_mutex.synchronize { (@reconnecting_subscriptions || []).select(&:reconnectable?) }
467
+ end
468
+
469
+ # @return [Mutex] guards the queue of subscriptions waiting for a process
470
+ # (created by {MCPClient::ServerStdio#initialize}, so no two threads
471
+ # ever race to make it)
472
+ def reconnecting_mutex
473
+ @reconnecting_mutex ||= Mutex.new
474
+ end
475
+
476
+ # Restart the process the reader just watched exit, for the
477
+ # subscriptions the host still holds.
478
+ #
479
+ # A subscription is a standing request the host does not repeat: while
480
+ # it only waits for notifications there is no RPC for
481
+ # {#ensure_initialized} to re-establish the process on, so leaving the
482
+ # restart to "the next request" leaves every subscription
483
+ # :reconnecting for ever, with the host neither notified nor served.
484
+ # Restarting is also what MCP 2026-07-28 stdio "Unexpected Termination"
485
+ # asks of a client, and re-sending the subscriptions afterwards is what
486
+ # this transport already promises. With no subscription open there is
487
+ # nothing standing, and the process stays lazily re-established on the
488
+ # next request.
489
+ #
490
+ # A server that keeps exiting must not be respawned in a loop; that
491
+ # bound is enforced where the subscriptions are handed over rather than
492
+ # here (see {#reopen_subscriptions}), because the process is
493
+ # re-established by whichever thread gets there first — this restart or
494
+ # a host request that raced it — and only the hand-over is common to
495
+ # both. A restart that fails outright ends them here instead. Either way
496
+ # the host learns from `closed?`/`error` rather than waiting on a stream
497
+ # that is never coming back.
498
+ # @return [void]
499
+ def restart_for_open_subscriptions
500
+ pending = reconnecting_subscriptions
501
+ return if pending.empty?
502
+
503
+ @logger.info("Re-establishing the server process for #{pending.size} open subscription(s)")
504
+ ensure_initialized
505
+ hand_over_to_established_process
506
+ rescue StandardError => e
507
+ @logger.warn("Could not re-establish the server process: #{e.message}")
508
+ fail_reconnecting_subscriptions(e)
509
+ end
510
+
511
+ # Hand the queue to a process that is already established.
512
+ #
513
+ # {#ensure_initialized} re-sends the queue itself, but only when it
514
+ # negotiated the process: a host request that observed the exit while
515
+ # this teardown was still parking its subscriptions established the
516
+ # replacement, found the queue empty and returned, and the restart
517
+ # above then took the initialized fast path — leaving the subscriptions
518
+ # parked on a queue nothing was going to read. Whichever of the two
519
+ # finishes last drains what it finds here, so the re-send MCP
520
+ # 2026-07-28 basic/patterns/subscriptions requires after a stdio
521
+ # reconnect happens however the two threads interleave.
522
+ #
523
+ # Under the initialization lock, like every other hand-over: the
524
+ # process must not be replaced underneath the writes, and a queue taken
525
+ # while a negotiation is in flight would be re-sent to the process that
526
+ # negotiation is replacing.
527
+ # @return [void]
528
+ def hand_over_to_established_process
529
+ @init_lock.synchronize do
530
+ next unless @initialized
531
+
532
+ reopen_subscriptions
533
+ end
534
+ end
535
+
536
+ # End the subscriptions waiting for a process that is not coming back.
537
+ # @param error [StandardError] why it is not
538
+ # @return [void]
539
+ def fail_reconnecting_subscriptions(error)
540
+ fail_subscriptions(take_reconnecting_subscriptions, error)
541
+ end
542
+
543
+ # @param pending [Array<MCPClient::Subscription>] the subscriptions to end
544
+ # @param error [StandardError] why they ended
545
+ # @return [void]
546
+ def fail_subscriptions(pending, error)
547
+ failure = subscription_failure(error)
548
+ pending.each { |subscription| subscription.finish(gracefully: false, error: failure) }
549
+ end
550
+
551
+ # Establish the server's protocol era (MCP 2026-07-28
552
+ # basic/transports/stdio "Backward Compatibility"): probe with
553
+ # server/discover unless configured legacy-only, and fall back to the
554
+ # initialize handshake when the probe shows a legacy server.
555
+ # @return [void]
556
+ # @raise [MCPClient::Errors::ConnectionError] if no era can be established
557
+ def negotiate_protocol
558
+ return perform_initialize if @protocol_mode == :legacy
559
+ return if probe_modern_server
16
560
 
17
- connect
18
- start_reader
19
- start_stderr_reader
20
561
  perform_initialize
562
+ rescue StandardError
563
+ # Nothing was negotiated. The probe only PROPOSES its version, and a
564
+ # failure that never reached one of the classified outcomes — the
565
+ # host's request_meta provider raising, say, so no probe was even
566
+ # sent — would otherwise leave that proposal behind as a settled
567
+ # modern era. The next attempt would then take a legacy server's
568
+ # startup request for prohibited modern traffic and drop it, and a
569
+ # server waiting for that response answers nothing: the recovery
570
+ # deadlocks until it times out.
571
+ @protocol_version = nil
572
+ settle_era_probe
573
+ raise
574
+ end
575
+
576
+ # Send the server/discover probe with this client's preferred modern
577
+ # version. Three outcomes, per the stdio backward-compatibility rules:
578
+ # a DiscoverResult (modern: select a version from supportedVersions), a
579
+ # recognized modern error such as UnsupportedProtocolVersionError
580
+ # (modern: retry with an advertised version, never fall back), or any
581
+ # other error / a timeout (legacy: fall back to initialize). The
582
+ # fallback is deliberately not keyed to one error code — legacy servers
583
+ # answer pre-initialize requests with implementation-defined errors.
584
+ # @return [Boolean] true when the server is modern and a version was selected
585
+ # @raise [MCPClient::Errors::ConnectionError] if the server is modern but no
586
+ # version is mutually supported, or legacy while protocol: :modern is configured
587
+ def probe_modern_server
588
+ # The probe DECLARES this version; it does not establish it. Until the
589
+ # answer arrives the era stays unknown, so an incoming server request
590
+ # is still handled — a legacy server MAY ping during initialization and
591
+ # the receiver MUST respond promptly, and a server waiting for that
592
+ # response answers nothing until it arrives.
593
+ @protocol_version = MCPClient::LATEST_PROTOCOL_VERSION
594
+ begin_era_probe
595
+ modern_confirmed = false
596
+ begin
597
+ perform_discover
598
+ rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
599
+ raise unless e.modern_protocol_error?
600
+
601
+ # A well-formed rejection settles the era: whatever the retried
602
+ # probe does next, this server is modern and never gets initialize.
603
+ modern_confirmed = true
604
+ settle_era_probe
605
+ retry_discover_with_advertised_version(e)
606
+ end
607
+ true
608
+ rescue MCPClient::Errors::ConnectionError
609
+ # A DiscoverResult (or advertised list) with no mutual version: the
610
+ # server is modern but incompatible. Nothing was negotiated.
611
+ @protocol_version = nil
612
+ raise
613
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError => e
614
+ # A recognized modern error (-32020/-32021, or -32022 with no usable
615
+ # version) identifies a modern server: surface it, never fall back.
616
+ # Anything else — including a 2xx-style result that is not a
617
+ # DiscoverResult — is a legacy server, unless the era was already
618
+ # settled by a well-formed rejection.
619
+ if modern_confirmed || e.modern_protocol_error_for_probe?
620
+ @protocol_version = nil
621
+ raise MCPClient::Errors::ConnectionError, "Server is modern but incompatible: #{e.message}"
622
+ end
623
+
624
+ legacy_after_probe(e)
625
+ false
626
+ ensure
627
+ # However the probe ended, it is no longer proposing anything.
628
+ settle_era_probe
629
+ end
630
+
631
+ # Not a recognized modern error: a legacy server (or one that never
632
+ # answered).
633
+ # @param error [StandardError] the probe failure
634
+ # @return [void]
635
+ # @raise [MCPClient::Errors::ConnectionError] when protocol: :modern is configured
636
+ def legacy_after_probe(error)
637
+ e = error
638
+ @protocol_version = nil
639
+ if @protocol_mode == :modern
640
+ raise MCPClient::Errors::ConnectionError,
641
+ "Server did not answer server/discover as a modern MCP server (#{e.message}); it is most likely " \
642
+ 'a legacy server expecting the initialize handshake. Use protocol: :auto or :legacy to allow that.'
643
+ end
644
+
645
+ @logger.debug("server/discover probe failed (#{e.class}); treating the server as legacy")
646
+ end
647
+
648
+ # After UnsupportedProtocolVersionError, pick a mutually supported
649
+ # version from the error's advertised list and re-issue the probe.
650
+ # @param error [MCPClient::Errors::UnsupportedProtocolVersionError]
651
+ # @return [void]
652
+ def retry_discover_with_advertised_version(error)
653
+ version = select_protocol_version(error.supported)
654
+ unless version
655
+ raise MCPClient::Errors::ConnectionError,
656
+ "Server rejected protocol version #{@protocol_version} and supports only " \
657
+ "#{error.supported.join(', ')}, none of which this client speaks " \
658
+ "(modern versions supported: #{MCPClient::MODERN_PROTOCOL_VERSIONS.join(', ')})"
659
+ end
660
+
661
+ @logger.info("Server does not support #{@protocol_version}; retrying server/discover with #{version}")
662
+ @protocol_version = version
663
+ perform_discover
664
+ end
21
665
 
22
- @initialized = true
666
+ # Send server/discover and apply the DiscoverResult. Bounded by
667
+ # discover_timeout rather than the general read timeout so a silent
668
+ # legacy server delays the fallback only briefly.
669
+ # @return [Hash] the DiscoverResult
670
+ def perform_discover
671
+ req_id = next_id
672
+ req = build_registered_request('server/discover', {}, req_id)
673
+ send_request(req)
674
+ begin
675
+ res = wait_response(req_id, timeout: @discover_timeout)
676
+ rescue MCPClient::Errors::RequestTimeoutError
677
+ send_cancellation_notification(req_id)
678
+ raise
679
+ end
680
+ interpret_discover_answer(res)
681
+ end
682
+
683
+ # Turn the probe's answer into a DiscoverResult, or into the failure
684
+ # that says what kind of server sent it.
685
+ #
686
+ # The stdio fallback rule is keyed to the probe being answered with an
687
+ # error, or not answered at all — never to a result. `resultType` does
688
+ # not exist before 2026-07-28, so a result carrying it came from a
689
+ # modern server even when the rest of it is unusable: treating that as
690
+ # a legacy answer would pin a dual-era server to the 2025-11-25
691
+ # handshake for the life of the process, and would make a modern-only
692
+ # server fail to connect after it had already answered the probe. Such
693
+ # an answer therefore fails the negotiation instead of falling back.
694
+ # A result with no 2026-07-28 marker at all is still a legacy answer: a
695
+ # permissive server answering an unknown method with some object.
696
+ # @param res [Hash] the JSON-RPC response to the probe
697
+ # @return [Hash] the applied DiscoverResult
698
+ # @raise [MCPClient::Errors::ConnectionError] when a modern server answered unusably
699
+ # @raise [MCPClient::Errors::ServerError] when a legacy server answered
700
+ def interpret_discover_answer(res)
701
+ modern_answer = modern_discover_answer?(res)
702
+ # Named so the identity of an answer that fails to validate below is
703
+ # not recorded: apply_discover_result records it once the whole
704
+ # result has validated.
705
+ result = process_jsonrpc_response(res, method: 'server/discover')
706
+ reject_input_required_discover!(result)
707
+ reject_task_result_discover!(result)
708
+ unless discover_result?(result)
709
+ raise invalid_discover_answer(modern_answer, 'answered without a DiscoverResult')
710
+ end
711
+
712
+ apply_discover_result(result)
713
+ rescue MCPClient::Errors::InvalidResultError, MCPClient::Errors::InputRequiredError => e
714
+ raise invalid_discover_answer(modern_answer, "answered without a DiscoverResult (#{e.message})")
715
+ end
716
+
717
+ # @param res [Hash] the JSON-RPC response to the probe
718
+ # @return [Boolean] whether its result could only have come from a 2026-07-28 server
719
+ def modern_discover_answer?(res)
720
+ result = res.is_a?(Hash) ? res['result'] : nil
721
+ return false unless result.is_a?(Hash)
722
+
723
+ result.key?('resultType') || result.key?(:resultType) || discover_result?(result)
724
+ end
725
+
726
+ # Whether a response, as it comes off the wire, could only have been
727
+ # written by a modern server: a result carrying a 2026-07-28 marker, or
728
+ # one of the spec-defined modern errors in its mandated shape.
729
+ # @param msg [Hash] a JSON-RPC response
730
+ # @return [Boolean]
731
+ def identifies_modern_server?(msg)
732
+ return true if modern_discover_answer?(msg)
733
+ return false unless msg.key?('error')
734
+
735
+ MCPClient::Errors::ServerError.from_jsonrpc(msg['error']).modern_protocol_error?
736
+ end
737
+
738
+ # @param modern_answer [Boolean] whether the answer identified a modern server
739
+ # @param message [String] what was wrong with it
740
+ # @return [StandardError] the failure the probe should propagate
741
+ def invalid_discover_answer(modern_answer, message)
742
+ return MCPClient::Errors::ServerError.new("server/discover was #{message}") unless modern_answer
743
+
744
+ MCPClient::Errors::ConnectionError.new("Server is modern but incompatible: server/discover was #{message}")
23
745
  end
24
746
 
25
747
  # Handshake: send initialize request and initialized notification
@@ -28,16 +750,28 @@ module MCPClient
28
750
  def perform_initialize
29
751
  # Initialize request
30
752
  init_id = next_id
31
- init_req = build_jsonrpc_request('initialize', initialization_params, init_id)
753
+ init_req = build_registered_request('initialize', initialization_params, init_id)
32
754
  send_request(init_req)
33
755
  res = wait_response(init_id)
34
- if (err = res['error'])
35
- raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
756
+ begin
757
+ result = process_jsonrpc_response(res) || {}
758
+ rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
759
+ # A modern-only server SHOULD name the versions it supports when
760
+ # rejecting initialize (basic/versioning). When one of them is
761
+ # mutual the era is settled after all — the fallback ran only
762
+ # because the probe was too slow — so go back to server/discover
763
+ # instead of ending the session. A legacy-only configuration has
764
+ # opted out of the modern era and gets the error.
765
+ return fall_forward_to_modern(e) if fall_forward_to_modern?(e)
766
+
767
+ raise MCPClient::Errors::ConnectionError,
768
+ "Initialize failed: #{e.message} (server supports: #{e.supported.join(', ')})"
769
+ rescue MCPClient::Errors::ServerError => e
770
+ raise MCPClient::Errors::ConnectionError, "Initialize failed: #{e.message}"
36
771
  end
37
772
 
38
773
  # Store negotiated protocol version, server info and capabilities.
39
774
  # Disconnects if the server negotiated a version we cannot speak.
40
- result = res['result'] || {}
41
775
  @protocol_version = validate_protocol_version!(result)
42
776
  @server_info = result['serverInfo']
43
777
  @capabilities = result['capabilities']
@@ -48,6 +782,34 @@ module MCPClient
48
782
  @stdin.puts(notif.to_json)
49
783
  end
50
784
 
785
+ # Whether a rejected initialize handshake should send this connection
786
+ # back to the modern path. Only a well-formed rejection counts — a bare
787
+ # -32022 from a legacy endpoint identifies nothing — and only one that
788
+ # names a version this client speaks, since the retry has to declare
789
+ # one. A host that configured protocol: :legacy asked for the 2025-11-25
790
+ # handshake and gets the error instead.
791
+ # @param error [MCPClient::Errors::UnsupportedProtocolVersionError]
792
+ # @return [Boolean]
793
+ def fall_forward_to_modern?(error)
794
+ return false if @protocol_mode == :legacy
795
+
796
+ error.modern_protocol_error? && !select_protocol_version(error.supported).nil?
797
+ end
798
+
799
+ # Resume the modern path after a fallback handshake was refused by a
800
+ # modern server: the rejection settles the era, so server/discover is
801
+ # re-issued with a version the server named and initialize is never
802
+ # sent again.
803
+ # @param error [MCPClient::Errors::UnsupportedProtocolVersionError]
804
+ # @return [Hash] the DiscoverResult
805
+ def fall_forward_to_modern(error)
806
+ version = select_protocol_version(error.supported)
807
+ @logger.info('The server refused the initialize handshake and supports ' \
808
+ "#{error.supported.join(', ')}; it is a modern server — retrying server/discover with #{version}")
809
+ @protocol_version = version
810
+ perform_discover
811
+ end
812
+
51
813
  # Generate a new unique request ID and mark it as awaiting a response.
52
814
  # Registering the id before the request is sent lets the reader thread
53
815
  # distinguish expected responses from late/unsolicited ones.
@@ -61,13 +823,88 @@ module MCPClient
61
823
  end
62
824
  end
63
825
 
64
- # Send a JSON-RPC request and return nothing
826
+ # Build a JSON-RPC request under an id {#next_id} has already registered
827
+ # as outstanding. Building can fail — the host's request_meta provider
828
+ # is evaluated here and may raise — and a request that was never built
829
+ # is never sent and never answered, so its marker has to go with it;
830
+ # otherwise every such failure leaks an entry into @awaiting.
831
+ # @param method [String] JSON-RPC method
832
+ # @param params [Hash, nil] parameters for the request
833
+ # @param req_id [Integer] the registered request id
834
+ # @return [Hash] the JSON-RPC request
835
+ def build_registered_request(method, params, req_id)
836
+ build_jsonrpc_request(method, params, req_id)
837
+ rescue StandardError
838
+ @mutex.synchronize { @awaiting.delete(req_id) }
839
+ raise
840
+ end
841
+
842
+ # Write a registered request, unless the transport it was registered on
843
+ # has been replaced since. Replacement is judged by the transport
844
+ # generation, which every teardown and every spawn bumps: a restart in
845
+ # between has dropped the id from @awaiting, and the process the
846
+ # request was built for is gone.
847
+ # @param req [Hash] the JSON-RPC request
848
+ # @param generation [Integer] the transport generation the id was registered on
849
+ # @return [Hash, nil] the request once written, or nil when the
850
+ # transport was replaced under it before it was written (it was not sent)
851
+ # @raise [MCPClient::Errors::TransportError] on write errors
852
+ def send_if_current(req, generation)
853
+ return req unless send_request(req, generation) == :replaced
854
+
855
+ @logger.debug("The transport was replaced before #{req['method']} was sent; re-issuing it")
856
+ # Nothing was written, so nobody will ever wait on this id: drop it
857
+ # from the teardown's record as well as from the awaiting table. The
858
+ # waiter is what normally consumes that record, and an id no request
859
+ # carries has no waiter — it would accumulate for the process's life.
860
+ @mutex.synchronize do
861
+ @awaiting.delete(req['id'])
862
+ dropped_requests.delete(req['id'])
863
+ end
864
+ nil
865
+ end
866
+
867
+ # Send a JSON-RPC request. With a generation, the request is written
868
+ # only if that is still the transport's generation, and the check and
869
+ # the write are one step under the transport lock: a restart replaces
870
+ # the handles and bumps the generation under the same lock, so a
871
+ # request judged current cannot be written to the replacement process
872
+ # — where it would be executed unregistered and its answer discarded.
65
873
  # @param req [Hash] the JSON-RPC request
66
- # @return [void]
874
+ # @param generation [Integer, nil] the transport generation the request was registered on
875
+ # @param io [IO, nil] the pipe to write to; defaults to the live process's
876
+ # stdin, but a caller whose bookkeeping is tied to one particular
877
+ # process pins that process's pipe instead (see {#open_subscription})
878
+ # @return [Symbol] :sent, or :replaced when the transport was replaced and nothing was written
67
879
  # @raise [MCPClient::Errors::TransportError] on write errors
68
- def send_request(req)
880
+ def send_request(req, generation = nil, io: @stdin)
69
881
  @logger.debug("Sending JSONRPC request: #{describe_jsonrpc_message(req)}")
70
- @stdin.puts(req.to_json)
882
+ # A request pinned to a session that has since ended is not written at
883
+ # all: its payload names something else in the replacement session.
884
+ # The pin is read outside the transport lock (the two locks never
885
+ # nest); a restart completing between this check and the write moves
886
+ # the transport generation, which the locked check below catches.
887
+ @mutex.synchronize { check_session_pin! }
888
+ @transport_lock.synchronize do
889
+ # A replacement whose negotiation has not completed is not current
890
+ # either, whatever its generation says: an ordinary request written
891
+ # to it would reach the process before its handshake.
892
+ return :replaced if generation && (generation != @transport_generation || @negotiating)
893
+ raise IOError, 'the server process is gone' unless io
894
+
895
+ # The write goes to the pipe this request was recorded against,
896
+ # never to whichever pipe the transport holds by now.
897
+ io.puts(req.to_json)
898
+ end
899
+ :sent
900
+ rescue MCPClient::Errors::SessionChangedError, MCPClient::Errors::TaskReplacedError
901
+ # A refusal, not a failure: the pin (or the caller's own pre-write
902
+ # guard, see {MCPClient::SessionPin#guarded_writes}) turned the write
903
+ # down. Nothing was written, nothing will answer this id, and the
904
+ # refusal keeps its type — a definite "this request is not to be
905
+ # sent" must not reach the caller as an ambiguous transport failure.
906
+ @mutex.synchronize { @awaiting.delete(req['id']) } if req.is_a?(Hash) && req['id']
907
+ raise
71
908
  rescue StandardError => e
72
909
  # A request that failed to send will never receive a response, so drop
73
910
  # its awaiting marker; otherwise a broken transport (e.g. the server
@@ -84,6 +921,13 @@ module MCPClient
84
921
  deadline = Time.now + (timeout || @read_timeout)
85
922
  @mutex.synchronize do
86
923
  until @pending.key?(id)
924
+ # The subprocess exited: no answer is coming, however long the
925
+ # timeout. (An answer that arrived before it exited is above.)
926
+ # A restart another caller completed meanwhile has cleared the
927
+ # retirement again, but it recorded this request as dropped —
928
+ # the durable sign that the transport it went out on is gone.
929
+ break if @transport_retired || dropped_requests.include?(id)
930
+
87
931
  remaining = deadline - Time.now
88
932
  break if remaining <= 0
89
933
 
@@ -92,10 +936,21 @@ module MCPClient
92
936
  # Remove the response and the awaiting marker on both success and
93
937
  # timeout so neither @pending nor @awaiting accumulates entries.
94
938
  msg = @pending.delete(id)
939
+ transport_gone = @transport_retired || !dropped_requests.delete?(id).nil?
940
+ arrival = (@response_arrivals ||= {}).delete(id)
95
941
  @awaiting.delete(id)
96
- raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
942
+ if msg
943
+ # The response's receipt time is the reader's, not this wake-up.
944
+ note_response_received_at(arrival || monotonic_now) if respond_to?(:note_response_received_at, true)
945
+ return msg
946
+ end
97
947
 
98
- msg
948
+ if transport_gone
949
+ raise MCPClient::Errors::TransportError,
950
+ "The MCP server subprocess exited before answering JSONRPC request id=#{id}"
951
+ end
952
+
953
+ raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for JSONRPC response id=#{id}"
99
954
  end
100
955
  end
101
956
 
@@ -117,23 +972,130 @@ module MCPClient
117
972
  # @raise [MCPClient::Errors::TransportError] on transport errors
118
973
  # @raise [MCPClient::Errors::ToolCallError] on tool call errors
119
974
  def rpc_request(method, params = {}, timeout: nil)
975
+ freshly_probed = !@initialized || transport_retired?
120
976
  ensure_initialized
121
- with_retry(method) do
122
- req_id = next_id
123
- req = build_jsonrpc_request(method, params, req_id)
124
- send_request(req)
125
- begin
126
- res = wait_response(req_id, timeout: timeout)
127
- rescue MCPClient::Errors::RequestTimeoutError
128
- # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
129
- # notification for the abandoned request and stop waiting.
130
- send_cancellation_notification(req_id) if cancellable_request?(method, params)
131
- raise
977
+ if method == 'ping' && modern?
978
+ # `ping` was removed in MCP 2026-07-28; the mandatory server/discover
979
+ # request is the modern heartbeat. The probe that just established
980
+ # the connection IS such a round trip, so answer from it rather than
981
+ # paying for a second one.
982
+ return @last_discover_result if freshly_probed && @last_discover_result
983
+
984
+ method = 'server/discover'
985
+ end
986
+
987
+ # The multi round-trip resolver sits outside the per-attempt
988
+ # recovery, so a retry that carries inputResponses/requestState keeps
989
+ # them through transport retries, version renegotiation and the like.
990
+ result = resolve_input_round_trips(method, params, timeout) do |attempt_params|
991
+ # Every round is its own request, and the subprocess may have exited
992
+ # between two of them — the host handler that gathers the input
993
+ # waits for a person. MCP 2026-07-28 basic/transports/stdio
994
+ # ("Unexpected Termination"): the client SHOULD restart a server
995
+ # that terminated unexpectedly. The continuation is then re-issued
996
+ # against the fresh process with the answers and the state it was
997
+ # gathered for, rather than written to a dead pipe.
998
+ ensure_initialized
999
+ with_retry(method) do
1000
+ sent_version = nil
1001
+ begin
1002
+ send_request_and_wait(method, attempt_params, timeout) { |version| sent_version = version }
1003
+ rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
1004
+ # MCP 2026-07-28 basic/versioning: "The client SHOULD select a
1005
+ # mutually supported version from the supported list and retry
1006
+ # the request". The server rejected the request before
1007
+ # processing it, so re-sending cannot duplicate a side effect.
1008
+ # Compared against the version THIS request declared, read back
1009
+ # from the request itself: a concurrent request may have moved
1010
+ # the transport on while this one was being built.
1011
+ version = select_protocol_version(e.supported)
1012
+ raise unless modern? && version && version != sent_version
1013
+
1014
+ @logger.info("Server does not support protocol version #{sent_version}; " \
1015
+ "retrying #{method} with #{version}")
1016
+ @protocol_version = version
1017
+ send_request_and_wait(method, attempt_params, timeout)
1018
+ end
132
1019
  end
133
- process_jsonrpc_response(res)
1020
+ end
1021
+ # Every server/discover answer is validated and applied: a later
1022
+ # heartbeat may advertise new versions or capabilities.
1023
+ result = apply_discover_result(result) if method == 'server/discover'
1024
+ result
1025
+ end
1026
+
1027
+ # @param result [Object] a JSON-RPC result
1028
+ # @return [Boolean] whether it has the DiscoverResult shape
1029
+ def discover_result?(result)
1030
+ result.is_a?(Hash) && result['supportedVersions'].is_a?(Array)
1031
+ end
1032
+
1033
+ # One request/response exchange with its own JSON-RPC id.
1034
+ # @param method [String] JSON-RPC method
1035
+ # @param params [Hash] parameters for the request
1036
+ # @param timeout [Numeric, nil] per-request timeout override
1037
+ # @yieldparam version [String, nil] the protocol version the request declares
1038
+ # @return [Object] result from the JSON-RPC response
1039
+ def send_request_and_wait(method, params, timeout)
1040
+ # As late as a request pinned to a session can be held back: every
1041
+ # reconnect on the way here (ensure_initialized, a retry after the
1042
+ # child exited) has happened by now.
1043
+ check_session_pin!
1044
+ req_id, = send_on_current_transport(method, params) do |built|
1045
+ clear_response_received_at if respond_to?(:clear_response_received_at, true)
1046
+ yield declared_protocol_version(built) if block_given?
1047
+ end
1048
+ begin
1049
+ res = wait_response(req_id, timeout: timeout)
1050
+ rescue MCPClient::Errors::RequestTimeoutError
1051
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
1052
+ # notification for the abandoned request and stop waiting.
1053
+ send_cancellation_notification(req_id) if cancellable_request?(method, params)
1054
+ raise
1055
+ end
1056
+ process_jsonrpc_response(res, method: method)
1057
+ end
1058
+
1059
+ # Register, build and write a request on the transport that is current
1060
+ # when it is written. Between registering the id and writing, the
1061
+ # host's request_meta provider runs, and in that window the subprocess
1062
+ # may exit and another thread restart it: the restart drops every
1063
+ # outstanding id, so a request written afterwards would go out
1064
+ # unregistered and its answer be discarded as unsolicited. Nothing has
1065
+ # been sent when that is detected, so the request is simply rebuilt —
1066
+ # after waiting for the restart to complete — and sent registered.
1067
+ # @param method [String] JSON-RPC method
1068
+ # @param params [Hash] parameters for the request
1069
+ # @yieldparam req [Hash] the request as built, before it is written
1070
+ # @return [Array(Integer, Hash)] the registered id and the request
1071
+ def send_on_current_transport(method, params)
1072
+ loop do
1073
+ # A subprocess that exited under the handshake is restarted here
1074
+ # (MCP 2026-07-28 stdio "Unexpected Termination": the client
1075
+ # SHOULD restart it) rather than written to.
1076
+ ensure_initialized if transport_retired?
1077
+ generation = @transport_generation
1078
+ req_id = next_id
1079
+ req = build_registered_request(method, params, req_id)
1080
+ yield req if block_given?
1081
+ return [req_id, req] if send_if_current(req, generation)
1082
+
1083
+ ensure_initialized
134
1084
  end
135
1085
  end
136
1086
 
1087
+ # The protocol version a built request declares in its `_meta`. Read
1088
+ # back from the request rather than from the transport: building it
1089
+ # evaluates the host's metadata provider, during which a concurrent
1090
+ # request may settle the transport on a different version.
1091
+ # @param req [Hash] a JSON-RPC request
1092
+ # @return [String, nil] the declared version, nil for a legacy request
1093
+ def declared_protocol_version(req)
1094
+ params = req['params']
1095
+ meta = params.is_a?(Hash) ? params['_meta'] : nil
1096
+ meta.is_a?(Hash) ? meta[JsonRpcCommon::META_PROTOCOL_VERSION] : nil
1097
+ end
1098
+
137
1099
  # Best-effort notifications/cancelled for a request the client stopped
138
1100
  # waiting on. Failures are swallowed: the transport may be the reason
139
1101
  # the request timed out in the first place.
@@ -153,8 +1115,21 @@ module MCPClient
153
1115
  # @return [void]
154
1116
  def rpc_notify(method, params = {})
155
1117
  ensure_initialized
1118
+ if suppressed_modern_notification?(method)
1119
+ @logger.debug("Not sending #{method}: removed in MCP #{protocol_version}")
1120
+ return
1121
+ end
1122
+
156
1123
  notif = build_jsonrpc_notification(method, params)
157
- @stdin.puts(notif.to_json)
1124
+ begin
1125
+ @stdin.puts(notif.to_json)
1126
+ rescue StandardError => e
1127
+ # The same failure a request write reports, reported the same way:
1128
+ # a notification writes on its own, outside the request path's
1129
+ # check-and-write, and a dead pipe there reached the host as a raw
1130
+ # IOError.
1131
+ raise MCPClient::Errors::TransportError, "Failed to send JSONRPC notification: #{e.message}"
1132
+ end
158
1133
  end
159
1134
  end
160
1135
  end