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
@@ -0,0 +1,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module MCPClient
6
+ class Client
7
+ # The tasks/update delivery path of the MCP 2026-07-28 tasks extension:
8
+ # which keys count as answered, the pending payload an unconfirmed
9
+ # delivery leaves behind, and the session guard that keeps the answers of
10
+ # an ended session out of the next one. Mixed into {TaskSupport}, which
11
+ # owns the polling loop these deliveries run in.
12
+ module TaskUpdates
13
+ private
14
+
15
+ # Record keys whose answers were handed to the transport by
16
+ # {#update_task}: they stay answered even if a handler that reserved
17
+ # them fails afterwards.
18
+ # @return [void]
19
+ def remember_answered_keys(srv, task_id, keys)
20
+ remember_answered_keys_in(task_state(srv, task_id), keys)
21
+ end
22
+
23
+ # @param state [Hash] the task state the update is bound to
24
+ # @return [void]
25
+ def remember_answered_keys_in(state, keys)
26
+ answered_keys_mutex.synchronize do
27
+ state[:answered].merge(keys)
28
+ state[:submitted].merge(keys)
29
+ end
30
+ end
31
+
32
+ # Whether a tasks/update failure means the server definitely did not
33
+ # take the answers: a JSON-RPC error with a code from the server
34
+ # itself. A 5xx, a closed response stream or any untyped server error
35
+ # is ambiguous (the update may have been applied).
36
+ # @param error [MCPClient::Errors::ServerError]
37
+ # @return [Boolean]
38
+ def definite_rejection?(error)
39
+ error.code.is_a?(Integer) && !error.is_a?(MCPClient::Errors::TransientServerError)
40
+ end
41
+
42
+ # Give back the keys a definite rejection did not carry away, and drop
43
+ # what they left pending: one step, so an answer another delivery
44
+ # queues meanwhile keeps both its marker and its payload — deciding
45
+ # ownership and acting on it apart would let a newer answer land in
46
+ # between and be unmarked by this one. Nothing is given back once the
47
+ # wait abandoned this send and a retry holds the task's update lock:
48
+ # what that retry left is not this send's to release.
49
+ # @param state [Hash] the task state the rejected update was bound to
50
+ # @param lock [Mutex] the update lock this send holds
51
+ # @param input_responses [Hash] what the rejected update carried
52
+ # @return [void]
53
+ def release_rejected_update(state, lock, input_responses)
54
+ answered_keys_mutex.synchronize do
55
+ next unless state[:update_mutex].equal?(lock)
56
+
57
+ keys = rejected_keys_of(state, input_responses)
58
+ state[:answered].subtract(keys)
59
+ state[:submitted].subtract(keys)
60
+ drop_pending_keys(state, keys)
61
+ end
62
+ end
63
+
64
+ # Which of a rejected delivery's keys it still owns: the ones whose
65
+ # pending value is still the very value it carried. A key another
66
+ # update answered while this one was on the wire belongs to that
67
+ # newer delivery — it holds the pending value, it is sending it, and
68
+ # unmarking the key here would leave it unrecorded, so a later poll
69
+ # would put the same input request to the host again.
70
+ # @param state [Hash] the task state the rejected update was bound to
71
+ # @param input_responses [Hash] what that update carried
72
+ # @return [Array<String>] the keys to give back (callers hold answered_keys_mutex)
73
+ def rejected_keys_of(state, input_responses)
74
+ pending = state[:pending_update] || {}
75
+ input_responses.reject { |key, value| pending.key?(key) && !pending[key].equal?(value) }
76
+ .keys.map(&:to_s)
77
+ end
78
+
79
+ # Give back keys the server definitely did not take, in the state the
80
+ # rejected update was built from: a rejection that lands after a
81
+ # restart must not unmark keys the new session has answered for what
82
+ # is a different request.
83
+ # @param state [Hash] the task state the update was bound to
84
+ # @return [void]
85
+ def release_answered_keys_in(state, keys)
86
+ answered_keys_mutex.synchronize do
87
+ state[:answered].subtract(keys)
88
+ state[:submitted].subtract(keys)
89
+ end
90
+ end
91
+
92
+ # Send the answers a handler produced (or, with pending_only, only what
93
+ # an earlier ambiguous delivery left pending), bounded by the caller's
94
+ # timeout and carrying the session the answers belong to. An ambiguous
95
+ # delivery (the server may or may not have applied it) is not the end
96
+ # of the wait: the payload stays pending and goes out again with the
97
+ # next poll, like a lost tasks/get. A definite rejection surfaces.
98
+ # @return [void]
99
+ def deliver_task_update(srv, task_id, responses, wait, pending_only: false, outstanding: nil, observed_at: nil)
100
+ # The bookkeeping this delivery is bound to is captured here, before
101
+ # anything is sent: a session that restarts meanwhile must not make
102
+ # the send record its keys, drop its pending payload or release them
103
+ # in another session's state (the epoch guard drops the payload
104
+ # instead), and an abandoned send finishes against this very state.
105
+ # It is the wait's own state — the one the answers were built in —
106
+ # not whatever the live session now keys under the same id: a
107
+ # delivery the pin drops must not give back keys a concurrent wait
108
+ # has reserved in the session that replaced it.
109
+ state = wait[:state] || task_state(srv, task_id)
110
+ bounded_by_wait(wait, deadline: wait[:deadline],
111
+ on_abandon: ->(_runner) { abandon_task_update(state) }) do
112
+ send_task_update(srv, task_id, responses, epoch: wait[:epoch], pending_only: pending_only, state: state,
113
+ outstanding: outstanding, observed_at: observed_at,
114
+ timeout: request_timeout(wait_deadline(wait), srv))
115
+ end
116
+ rescue MCPClient::Errors::TaskError => e
117
+ raise unless ambiguous_update_failure?(e)
118
+
119
+ logger.debug("tasks/update for task #{shown_task_id(task_id)} could not be confirmed; " \
120
+ 'it is sent again on the next poll')
121
+ end
122
+
123
+ # One tasks/update, owning the pending-payload lifecycle: the keys are
124
+ # answered as soon as the payload is handed to the transport; the
125
+ # request carries every response still pending from an earlier
126
+ # ambiguous delivery (the server ignores keys it already has, so a
127
+ # resend is safe and no unconfirmed answer is left behind); a
128
+ # confirmed delivery clears the pending payload (a later answer
129
+ # supersedes a lost one); a definite JSON-RPC rejection gives the keys
130
+ # back and drops the payload; an ambiguous outcome (timeout, transport
131
+ # or connection failure, 5xx, untyped server error) keeps it pending
132
+ # for retransmission.
133
+ # @param timeout [Numeric, nil] request timeout, bounded by the wait when there is one
134
+ # @param pending_only [Boolean] send whatever is pending under the lock and nothing else
135
+ # (a retransmission; nothing pending sends nothing)
136
+ # @param epoch [Integer, nil] the session epoch the answers were produced in; the update is
137
+ # dropped when the server's session moved on since (nil: no expectation, e.g. #update_task)
138
+ # @param state [Hash, nil] the bookkeeping the answers were built from; every mutation
139
+ # (answered keys, pending payload) lands there and nowhere else
140
+ # @param outstanding [Set<String>, nil] for a retransmission, the input request keys the task
141
+ # still lists: a pending answer to any other key was consumed and is dropped, not resent
142
+ # (nil: not known, everything pending is sent)
143
+ # @return [true]
144
+ # @raise [MCPClient::Errors::TaskError, MCPClient::Errors::ServerError]
145
+ def send_task_update(srv, task_id, input_responses, timeout: nil, pending_only: false, epoch: nil, state: nil,
146
+ strict_session: false, outstanding: nil, observed_at: nil)
147
+ shown = shown_task_id(task_id)
148
+ state ||= task_state(srv, task_id)
149
+ # The answers are pending — and their keys answered — from the moment
150
+ # this delivery is queued, before it waits for the task's update
151
+ # lock: a wait that gives up while queued behind a hanging update
152
+ # (its wall clock ran out) must leave answers the next wait can
153
+ # retransmit, not keys marked answered with nothing to send. An
154
+ # explicit answer is newer than a pending one for the same key and
155
+ # wins the merge.
156
+ queue_task_update(state, input_responses) unless pending_only
157
+ # One update at a time per task: a concurrent update that read an
158
+ # empty pending slot could otherwise confirm and wipe an answer
159
+ # another delivery had just left pending.
160
+ lock = state[:update_mutex]
161
+ lock.synchronize do
162
+ payload = answered_keys_mutex.synchronize { state[:pending_update] }
163
+ if pending_only && outstanding && payload
164
+ payload = still_outstanding(state, payload, outstanding, shown, observed_at)
165
+ end
166
+ # An explicit answer goes out even when it adds nothing to send
167
+ # (#update_task with no responses is the caller's request, not a
168
+ # retransmission).
169
+ payload = input_responses if payload.nil? && !pending_only
170
+ return true if payload.nil?
171
+
172
+ dispatch_task_update(srv, task_id, payload, shown: shown, state: state, lock: lock,
173
+ timeout: timeout, epoch: epoch,
174
+ strict_session: strict_session)
175
+ end
176
+ end
177
+
178
+ # What a retransmission may carry: the pending answers the task still
179
+ # asks for. The rest was consumed (its acknowledgement was lost, not
180
+ # the update) and is dropped from the payload for good — the keys stay
181
+ # answered — so the update names only outstanding requests, as the
182
+ # extension requires, and a server rejecting a stale key cannot fail
183
+ # a task that is progressing normally.
184
+ # @param observed_at [Integer, nil] the answer sequence this observation
185
+ # was issued at: an answer queued after the poll went out is newer than
186
+ # anything the poll can testify about (nil: not known, nothing is retired)
187
+ # @return [Hash, nil] the payload left to send (callers hold the update lock)
188
+ def still_outstanding(state, payload, outstanding, shown, observed_at = nil)
189
+ consumed = payload.keys.reject do |key|
190
+ outstanding.include?(key.to_s) || newer_than_observation?(state, key, observed_at)
191
+ end
192
+ return payload if consumed.empty?
193
+
194
+ logger.debug("Task #{shown}: the server consumed the answers to #{consumed.join(', ')} " \
195
+ 'before acknowledging them; they are not sent again')
196
+ answered_keys_mutex.synchronize { drop_pending_keys(state, consumed.map(&:to_s)) }
197
+ remaining = payload.except(*consumed)
198
+ remaining.empty? ? nil : remaining
199
+ end
200
+
201
+ # Whether an answer is newer than the observation in hand: it was queued
202
+ # after that poll was issued, so the poll's snapshot was taken before the
203
+ # answer existed and cannot say the server consumed it. Retiring it on
204
+ # that evidence would drop the answer for good while its key stays
205
+ # answered — the host is never asked again and the update is never
206
+ # resent, and an input request the task keeps asking for strands it for
207
+ # its whole TTL. Concurrent waits make this ordinary: one wait's poll can
208
+ # be in flight while another answers the request it is about.
209
+ # @param key [Object] the pending key
210
+ # @param observed_at [Integer, nil] the answer sequence the poll was issued at
211
+ # @return [Boolean]
212
+ def newer_than_observation?(state, key, observed_at)
213
+ return false if observed_at.nil?
214
+
215
+ queued_at = answered_keys_mutex.synchronize { (state[:pending_at] || {})[key.to_s] }
216
+ !queued_at.nil? && queued_at > observed_at
217
+ end
218
+
219
+ # Record a delivery's answers before it queues for the task's update
220
+ # lock: answered (so no handler is asked again) and pending (so any
221
+ # wait can deliver them).
222
+ # @return [void]
223
+ def queue_task_update(state, input_responses)
224
+ return if input_responses.nil? || input_responses.empty?
225
+
226
+ # Marked and kept in one step: a rejection of an older answer to one
227
+ # of these keys decides what it still owns by the pending payload
228
+ # (see #rejected_keys_of), and between a mark and a keep done apart
229
+ # it would still find the older payload there — and unmark a key
230
+ # this newer answer has just claimed, which nothing marks again once
231
+ # this answer is acknowledged.
232
+ keys = input_responses.keys.map(&:to_s)
233
+ answered_keys_mutex.synchronize do
234
+ state[:answered].merge(keys)
235
+ state[:submitted].merge(keys)
236
+ state[:pending_update] = (state[:pending_update] || {}).merge(input_responses)
237
+ # When each answer became pending, so an observation can say whether
238
+ # it is old enough to testify about it (see #still_outstanding).
239
+ seq = (state[:answer_seq] = state[:answer_seq].to_i + 1)
240
+ pending_at = (state[:pending_at] ||= {})
241
+ keys.each { |key| pending_at[key] = seq }
242
+ end
243
+ end
244
+
245
+ # Whether the answers may still go out: the session they were produced
246
+ # in must be the one the request is about to reach. The connection is
247
+ # established first, because the built-in rpc_request initializes (and
248
+ # so may reconnect, ending the session) inside the very call this
249
+ # guards; the request itself is then pinned to the session (see
250
+ # {MCPClient::JsonRpcCommon#pinned_to_session}), so a reconnect between
251
+ # this compare and the write drops the payload at the wire rather than
252
+ # sending it into the next session. Task ids and input keys are
253
+ # session-scoped and reusable, so an answer that arrives in the wrong
254
+ # session could answer an unrelated request.
255
+ # @return [Boolean]
256
+ def task_update_session_current?(srv, shown, epoch)
257
+ return true unless epoch
258
+
259
+ begin
260
+ establish_session(srv)
261
+ rescue StandardError
262
+ # Not swallowed, only overtaken by the session it ended: with the
263
+ # session still the answers', the caller surfaces the failure
264
+ # (nothing was sent, the answers stay pending for the next poll).
265
+ raise if answered_keys_mutex.synchronize { current_session_epoch(srv) } == epoch
266
+ end
267
+ return true if answered_keys_mutex.synchronize { current_session_epoch(srv) } == epoch
268
+
269
+ logger.warn("Task #{shown}: the session restarted before the answers were sent; they are discarded")
270
+ false
271
+ end
272
+
273
+ # Bring the transport's session up before the epoch is compared, so a
274
+ # reconnect happens on this side of the guard. A failure is not
275
+ # swallowed: nothing is sent, and the caller (which has already
276
+ # recorded the answers as pending) surfaces it as the ambiguous
277
+ # delivery failure it is, so the next poll delivers them again.
278
+ # @return [void]
279
+ # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError]
280
+ def establish_session(srv)
281
+ srv.ensure_session_ready if srv.respond_to?(:ensure_session_ready)
282
+ end
283
+
284
+ # The wire part of {#send_task_update}: the keys are answered and the
285
+ # payload is pending from the moment it is handed to the transport, so
286
+ # a wait that abandons this send on its wall clock leaves answers that
287
+ # are still deliverable (the next wait retransmits them) rather than
288
+ # keys marked answered with nothing to send. Only the thread that
289
+ # still holds the task's update lock clears or releases them: once the
290
+ # wait abandoned this send, the retry that took the lock over owns the
291
+ # bookkeeping.
292
+ # @return [true]
293
+ def dispatch_task_update(srv, task_id, input_responses, shown:, state:, lock:, timeout:, epoch: nil,
294
+ strict_session: false)
295
+ keys = input_responses.keys.map(&:to_s)
296
+ begin
297
+ # Checked before the session is established, since it needs nothing
298
+ # from the wire: a task id the server handed out again names a task
299
+ # of its own, and these answers were built for the previous one.
300
+ unless task_lifetime_current?(state)
301
+ logger.warn("Task #{shown}: the server created a new task with this id before the answers were " \
302
+ 'sent; they are discarded')
303
+ drop_ended_session_update(state, lock, keys)
304
+ return ended_session_update_result(shown, strict_session, replaced: true)
305
+ end
306
+ unless task_update_session_current?(srv, shown, epoch)
307
+ drop_ended_session_update(state, lock, keys)
308
+ return ended_session_update_result(shown, strict_session)
309
+ end
310
+
311
+ # And held to at the wire, where a creation that lands while the
312
+ # session is being established is already visible: the check above
313
+ # cannot see one, and these answers would then be written for the
314
+ # task that replaced their own.
315
+ task_rpc(srv, 'tasks/update', { taskId: task_id, inputResponses: input_responses },
316
+ timeout: timeout, epoch: epoch, lifetime: state_lifetime_pin(state, task_id))
317
+ clear_pending_update(state, lock, keys)
318
+ true
319
+ rescue MCPClient::Errors::TaskReplacedError
320
+ # Nothing went out: the transport refused to write answers of a task
321
+ # the server has replaced (in the new one the keys are a different
322
+ # request's).
323
+ logger.warn("Task #{shown}: the server created a new task with this id before the answers were " \
324
+ 'sent; they are discarded')
325
+ drop_ended_session_update(state, lock, keys)
326
+ ended_session_update_result(shown, strict_session, replaced: true)
327
+ rescue MCPClient::Errors::SessionChangedError
328
+ # The transport refused to write into the session that replaced the
329
+ # answers' own: nothing went out, and in the new session these keys
330
+ # would answer a different request.
331
+ logger.warn("Task #{shown}: the session restarted before the answers were sent; they are discarded")
332
+ drop_ended_session_update(state, lock, keys)
333
+ ended_session_update_result(shown, strict_session)
334
+ rescue MCPClient::Errors::ServerError => e
335
+ release_rejected_update(state, lock, input_responses) if definite_rejection?(e)
336
+ raise if e.protocol_error?
337
+
338
+ raise task_failure(e, srv, task_id, 'updating', method: 'tasks/update', state: state)
339
+ rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
340
+ # Ambiguous (a failure to establish the session included): the
341
+ # payload stays pending for the next poll.
342
+ raise MCPClient::Errors::TaskError,
343
+ "Error updating task '#{shown}': #{sanitize_peer_log_text(e.message)}"
344
+ end
345
+ end
346
+
347
+ # The lifetime an update is bound to: the one its answers were built in
348
+ # (see {#task_lifetime_current?}), named so that the transport holds the
349
+ # write to it as well.
350
+ # @param state [Hash] the bookkeeping the answers belong to
351
+ # @return [Hash] the lifetime pin
352
+ def state_lifetime_pin(state, task_id)
353
+ { lookup: state[:lookup], generation: state[:generation], named: true,
354
+ task_id: task_id, operation: 'updating' }
355
+ end
356
+
357
+ # What a delivery the session guard (or the pin at the wire) dropped
358
+ # reports. A wait polls again and ends on the session move it will see
359
+ # next, so the drop is not an error for it; a caller that asked for
360
+ # this very delivery is told that nothing was sent, rather than being
361
+ # left to believe the server has the answers.
362
+ # @param replaced [Boolean] the task id was handed out again rather than
363
+ # the session having ended
364
+ # @return [true]
365
+ # @raise [MCPClient::Errors::TaskError] for a direct #update_task
366
+ def ended_session_update_result(shown, strict_session, replaced: false)
367
+ return true unless strict_session
368
+
369
+ if replaced
370
+ raise MCPClient::Errors::TaskError,
371
+ "Error updating task '#{shown}': the server created a new task with this id before the answers " \
372
+ 'were sent, so they were discarded (they belong to the task it replaced)'
373
+ end
374
+
375
+ raise MCPClient::Errors::TaskError,
376
+ "Error updating task '#{shown}': the server session the answers belong to ended before they were " \
377
+ 'sent, so they were discarded (a restarted server may reuse the task id for an unrelated task)'
378
+ end
379
+
380
+ # Nothing was written: the keys go back and the payload is dropped, in
381
+ # the state the answers were built from (the ended session's, which
382
+ # dies with it).
383
+ # @return [void]
384
+ def drop_ended_session_update(state, lock, keys)
385
+ return unless update_lock_current?(state, lock)
386
+
387
+ release_answered_keys_in(state, keys)
388
+ clear_pending_update(state, lock, keys)
389
+ end
390
+
391
+ # @return [void]
392
+ def keep_pending_update(state, input_responses)
393
+ answered_keys_mutex.synchronize do
394
+ state[:pending_update] = (state[:pending_update] || {}).merge(input_responses)
395
+ end
396
+ end
397
+
398
+ # Drop from the pending payload what this delivery settled — its own
399
+ # keys and no others: an answer another delivery queued while this one
400
+ # was on the wire never went out and must stay deliverable. Nothing is
401
+ # dropped when the wait abandoned this send and a retry holds the
402
+ # task's update lock now: what that retry left pending is not this
403
+ # send's to clear.
404
+ # @param keys [Array<String>] the keys this delivery carried
405
+ # @return [void]
406
+ def clear_pending_update(state, lock, keys)
407
+ answered_keys_mutex.synchronize do
408
+ next unless state[:update_mutex].equal?(lock)
409
+
410
+ drop_pending_keys(state, keys)
411
+ end
412
+ end
413
+
414
+ # @param keys [Array<String>] the keys to drop from the pending payload
415
+ # @return [void] (callers hold answered_keys_mutex)
416
+ def drop_pending_keys(state, keys)
417
+ # The order stamps go with the answers they date: a key queued again
418
+ # later is stamped again, and nothing is ordered against an answer
419
+ # that is no longer pending.
420
+ stamps = state[:pending_at]
421
+ keys.each { |key| stamps.delete(key.to_s) } if stamps
422
+ pending = state[:pending_update]
423
+ return if pending.nil?
424
+
425
+ remaining = pending.reject { |key, _| keys.include?(key.to_s) }
426
+ state[:pending_update] = remaining.empty? ? nil : remaining
427
+ end
428
+
429
+ # @return [Boolean] whether this send still holds the task's update lock
430
+ def update_lock_current?(state, lock)
431
+ answered_keys_mutex.synchronize { state[:update_mutex].equal?(lock) }
432
+ end
433
+
434
+ # A wait that ran out of wall clock abandons its tasks/update: the
435
+ # thread keeps the task's update lock for as long as the request hangs
436
+ # (a transport implementing only rpc_request(method, params) may never
437
+ # come back), which would block the retransmission the next wait owes
438
+ # the server. The lock is replaced so that retry can deliver the
439
+ # answers this send left pending; the abandoned thread finishes
440
+ # against the state it captured and touches none of it.
441
+ # @return [void]
442
+ def abandon_task_update(state)
443
+ answered_keys_mutex.synchronize { state[:update_mutex] = Mutex.new }
444
+ end
445
+
446
+ # Whether a failed tasks/update may still have been applied.
447
+ # @param error [MCPClient::Errors::TaskError]
448
+ # @return [Boolean]
449
+ def ambiguous_update_failure?(error)
450
+ cause = error.cause
451
+ return true if cause.is_a?(MCPClient::Errors::TransportError) || cause.is_a?(MCPClient::Errors::ConnectionError)
452
+
453
+ cause.is_a?(MCPClient::Errors::ServerError) && !definite_rejection?(cause)
454
+ end
455
+ end
456
+ end
457
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module MCPClient
6
+ class Client
7
+ # The boundaries a wait on a task can cross, and what it may still act on
8
+ # once it has crossed one.
9
+ #
10
+ # A task lives in one server session and, within it, in one lifetime of
11
+ # its id: a restart ends it, and so does a CreateTaskResult that hands the
12
+ # id to another task. Either way what the wait was following is gone, and
13
+ # neither its TTL backstop, nor its pace, nor the answered keys of its
14
+ # bookkeeping say anything about what answers to that id now. This module
15
+ # is where the wait joins a session and a lifetime, notices that it has
16
+ # left one, and decides what a handle it was seeded with — or an answer
17
+ # that came back across the boundary — is still good for. Mixed into
18
+ # {TaskSupport}, which owns the polling loop.
19
+ module TaskWaitBoundaries
20
+ private
21
+
22
+ # What a wait whose session has just ended returns — or raises.
23
+ #
24
+ # A task lives in one server session: the session that replaced it may
25
+ # name an entirely different task with the same id, so the wait never
26
+ # polls it there. What the ended session already answered is another
27
+ # matter: a terminal payload that came back from a poll the transport
28
+ # pinned to that session is this very task's result or error (the pin
29
+ # holds a request back until the wire, so an answer in hand is the
30
+ # answer of the session the wait was in), and it is the outcome. Short
31
+ # of that — no observation, a non-terminal one, or a transport that
32
+ # cannot pin a request to its session and so cannot vouch for which
33
+ # session answered — the wait ends: the task did not survive.
34
+ # @param current [MCPClient::Task, nil] the observation in hand
35
+ # @param wait [Hash]
36
+ # @param polled_state [Hash] the bookkeeping the poll belonged to
37
+ # @param polled_epoch [Integer, nil] the session the poll was sent in
38
+ # @return [MCPClient::Task] the terminal task
39
+ # @raise [MCPClient::Errors::TaskError]
40
+ def outcome_of_ended_session(current, wait, polled_state, polled_epoch)
41
+ # An answer counts as this wait's outcome only when it is stamped
42
+ # with the very session the poll was pinned to: a handle carrying
43
+ # another session's epoch describes another lifetime of the id. A
44
+ # task id the server handed out again is not that case: the pin is
45
+ # about sessions, and nothing on the wire tells which of the two
46
+ # tasks under that id the answer describes — so the wait ends.
47
+ terminal = wait[:ended] == :session && current&.terminal? &&
48
+ observation_of_session?(current, polled_epoch)
49
+ # Only the bookkeeping of the session the poll asked is forgotten;
50
+ # the replacement session's is untouched.
51
+ forget_task_keys(wait[:srv], wait[:task_id], state: polled_state) if terminal
52
+ end_of_task!(wait) unless terminal && session_pinning?(wait[:srv])
53
+
54
+ # A terminal task that came back after the caller's deadline
55
+ # (transport retries) does not rescue a timed-out wait.
56
+ raise_if_past_caller_deadline!(wait)
57
+ current
58
+ end
59
+
60
+ # End a wait whose task is gone: its server session is over and the task
61
+ # went with it, or the server handed the task id out again — a task id
62
+ # is unique within a session, so a fresh CreateTaskResult under it ended
63
+ # the task this wait was following.
64
+ # @return [void]
65
+ # @raise [MCPClient::Errors::TaskError]
66
+ def end_of_task!(wait)
67
+ shown = shown_task_id(wait[:task_id])
68
+ if wait[:ended] == :replaced
69
+ raise MCPClient::Errors::TaskError,
70
+ "Error waiting for task '#{shown}': the server created a new task with this id, so the task " \
71
+ 'being waited for was replaced and is gone'
72
+ end
73
+
74
+ raise MCPClient::Errors::TaskError,
75
+ "Error waiting for task '#{shown}': the server session it belongs to ended " \
76
+ 'before the task did, so the task is gone (a restarted server may reuse its id for an unrelated task)'
77
+ end
78
+
79
+ # Whether an observation is about the session it was polled in: the
80
+ # handle carries the session its request was pinned to. A handle
81
+ # without one (a server that reports no session), or a poll that
82
+ # belonged to no session, is taken at its word, as before.
83
+ # @return [Boolean]
84
+ def observation_of_session?(task, epoch)
85
+ task.session_epoch.nil? || epoch.nil? || task.session_epoch == epoch
86
+ end
87
+
88
+ # Whether the transport holds a request back from the session that
89
+ # replaced the one it belongs to (see {MCPClient::SessionPin}), which
90
+ # is what makes an answer in hand provably the answer of the session
91
+ # the wait was in.
92
+ # @return [Boolean]
93
+ def session_pinning?(srv)
94
+ srv.respond_to?(:pinned_to_session)
95
+ end
96
+
97
+ # Point a wait at the task state of the server's current session and
98
+ # lifetime, and report whether that is one it has not been in before.
99
+ # What the previous session — or the previous task under this id — said
100
+ # goes with it: its TTL backstop (createdAt + ttlMs of a task that no
101
+ # longer exists) must not end the wait in place of the boundary being
102
+ # crossed, and its last observation must not pace anything. A wait that
103
+ # reserves keys afterwards reserves them in the new set, since the id
104
+ # and its keys now name a different request.
105
+ # @param wait [Hash]
106
+ # @return [Boolean] whether the wait crossed a boundary, so that
107
+ # nothing the previous lifetime said (an observation in hand, its TTL,
108
+ # its pace) may be acted on
109
+ def refresh_wait_session(wait)
110
+ # The epoch, the lifetime and the state keyed under them are read in
111
+ # one step, so a restart (or a creation) between them cannot leave the
112
+ # wait pointing at one task's set while recording another's stamp.
113
+ answered_keys_mutex.synchronize do
114
+ epoch = current_session_epoch(wait[:srv])
115
+ generation = task_lifetime([wait[:srv].object_id, epoch, wait[:task_id]])
116
+ next false if wait[:epoch] == epoch && wait[:generation] == generation && wait[:answered]
117
+
118
+ moved = wait_boundary_crossed(wait, epoch, generation)
119
+ if moved
120
+ wait[:ttl_deadline] = nil
121
+ wait[:last] = nil
122
+ end
123
+ wait[:ended] = moved
124
+ wait[:epoch] = epoch
125
+ wait[:generation] = generation
126
+ state = task_state_locked(wait[:srv], wait[:task_id], epoch)
127
+ wait[:state] = state
128
+ wait[:answered] = state[:answered]
129
+ !moved.nil?
130
+ end
131
+ end
132
+
133
+ # Which boundary the wait has just crossed: the session it was in ended
134
+ # (:session), or the server handed the task id it is polling out again,
135
+ # so the task the wait follows is over and another one answers to that
136
+ # id now (:replaced).
137
+ # @return [Symbol, nil] nil when the wait is still where it was
138
+ def wait_boundary_crossed(wait, epoch, generation)
139
+ return :session if !wait[:epoch].nil? && wait[:epoch] != epoch
140
+ # The key is absent until the wait first joins a session: a lifetime
141
+ # of nil is an id no creation is on the books for, and a creation
142
+ # that gives it one has replaced the task the wait was following.
143
+ return :replaced if wait.key?(:generation) && wait[:generation] != generation
144
+
145
+ nil
146
+ end
147
+
148
+ # A handle that is already final: a DetailedTask of this server whose
149
+ # terminal payload is authoritative (and which the server may purge any
150
+ # moment), so there is nothing to poll. Its own lifetime's bookkeeping
151
+ # dies with the task; a handle kept across a restart — or across a
152
+ # creation that handed its task id out again — says nothing about what
153
+ # answers to that id now, where the reused id may name a live task
154
+ # whose answered keys another wait is deduplicating against.
155
+ # @return [MCPClient::Task, nil] the task when the wait is already over
156
+ def final_task_handle(task, srv, wait)
157
+ return nil unless task.is_a?(MCPClient::Task) && task.detailed? && task.terminal? && task.server.equal?(srv)
158
+
159
+ validate_terminal_task!(task)
160
+ refresh_wait_session(wait)
161
+ forget_task_keys(srv, wait[:task_id], state: wait[:state]) if seed_of_lifetime?(task, wait)
162
+ task
163
+ end
164
+
165
+ # Whether a task handle describes the very task whose bookkeeping the
166
+ # wait is pointing at: the session the wait joined and, within it, the
167
+ # lifetime the id has now. A handle that names no lifetime (a bare id,
168
+ # a handle that never came from a creation) cannot claim the live
169
+ # occupancy of the id: what it describes may be the task that answered
170
+ # to the id before, and the keys under it now would be another task's.
171
+ # @return [Boolean]
172
+ def seed_of_lifetime?(task, wait)
173
+ seed_of_session?(task, wait) && task.task_generation == wait[:generation]
174
+ end
175
+
176
+ # Take the seed's hints (its TTL backstop and its pace) from a handle
177
+ # that describes the task this wait is about: the same server, and the
178
+ # session the wait has joined.
179
+ # @return [void]
180
+ def seed_wait_from_handle(task, srv, wait)
181
+ return unless task.is_a?(MCPClient::Task) && task.server.equal?(srv) && seed_of_session?(task, wait)
182
+
183
+ seed_ttl_deadline(task, wait)
184
+ wait[:last] = task
185
+ end
186
+
187
+ # Whether a task handle may seed the wait: it must come from the
188
+ # session the wait has joined (a handle a host kept across a restart
189
+ # describes a task the new session knows nothing about, even when the
190
+ # id was reused). A server that reports no session (a bare double) is
191
+ # taken at its word, as before.
192
+ # @return [Boolean]
193
+ def seed_of_session?(task, wait)
194
+ task.session_epoch.nil? || task.session_epoch == wait[:epoch]
195
+ end
196
+ end
197
+ end
198
+ end