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,1166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'task_workers'
4
+ require_relative '../task'
5
+ require_relative '../errors'
6
+ require_relative '../json_rpc_common'
7
+ require_relative 'task_registry'
8
+ require_relative 'task_wait_boundaries'
9
+ require_relative 'task_shape'
10
+ require_relative 'task_updates'
11
+
12
+ module MCPClient
13
+ class Client
14
+ # MCP 2026-07-28 tasks extension (io.modelcontextprotocol/tasks) support
15
+ # for {MCPClient::Client}: declared through `extensions:`, a server may
16
+ # answer tools/call with a task that the client then polls (tasks/get),
17
+ # feeds (tasks/update) and cancels (tasks/cancel).
18
+ module TaskSupport
19
+ include TaskWorkers
20
+
21
+ # The per-task bookkeeping (answered keys, in-flight keys, the session
22
+ # epochs everything is keyed by) lives in its own module.
23
+ include TaskRegistry
24
+ # Which session and which task a wait is following, and what it may
25
+ # still act on once either has moved.
26
+ include TaskWaitBoundaries
27
+ include TaskShape
28
+ # The tasks/update delivery path (answered keys, pending payloads, the
29
+ # session guard) lives in its own module; the wait loop below drives it.
30
+ include TaskUpdates
31
+
32
+ # Seconds to wait before the next tasks/get when the server gave no
33
+ # pollIntervalMs ("Clients SHOULD respect the pollIntervalMs provided
34
+ # in responses"), and the floor that keeps a pollIntervalMs of 0 from
35
+ # turning the wait into a busy loop.
36
+ DEFAULT_TASK_POLL_INTERVAL = 1.0
37
+ MIN_TASK_POLL_INTERVAL = 0.05
38
+ # Longest pause between two polls, whatever pollIntervalMs says: an
39
+ # interval the clock cannot represent (Infinity, an integer too large
40
+ # for a Float, NaN) is bounded rather than handed to sleep, which
41
+ # refuses it. It is that backstop and nothing more — every pace a
42
+ # server could mean is finite and far below it, and is kept, because
43
+ # polling faster than the server asked for is what the spec's polling
44
+ # SHOULD is there to prevent. What bounds a wait is the caller's
45
+ # timeout and the task's TTL, never a pace of this client's own.
46
+ MAX_TASK_POLL_INTERVAL = 1.0e18
47
+ MIN_TASK_REQUEST_TIMEOUT = 0.001
48
+ # The longest a single poll request may wait, whatever the TTL: a hung
49
+ # tasks/get must not block the wait for the task's whole lifetime.
50
+ MAX_TASK_REQUEST_TIMEOUT = 30.0
51
+
52
+ # How many input_required rounds one wait answers before giving up:
53
+ # a task is not a higher-trust channel than a multi round-trip request.
54
+ MAX_TASK_INPUT_ROUNDS = 10
55
+
56
+ # Answer the outstanding input requests of a task (tasks/update, MCP
57
+ # 2026-07-28 tasks extension). The acknowledgement is eventually
58
+ # consistent: keep observing the task (#get_task / #wait_for_task) until
59
+ # it is terminal. {#wait_for_task} does this automatically through the
60
+ # registered elicitation / sampling / roots handlers.
61
+ # @param task [String, MCPClient::Task] the task or its id
62
+ # @param input_responses [Hash{String => Hash}] responses keyed like the task's inputRequests
63
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
64
+ # @return [true]
65
+ # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
66
+ # @raise [MCPClient::Errors::TaskError] if the update fails, the server predates tasks/update, or the
67
+ # task handle belongs to a server session that has ended (its task id is another task's now)
68
+ def update_task(task, input_responses, server: nil)
69
+ srv = select_task_server(task, server, 'update_task')
70
+ task_id = task_identifier(task)
71
+ ensure_task_capability!(srv, 'update', strict: true)
72
+ unless modern_server?(srv)
73
+ raise MCPClient::Errors::TaskError, 'tasks/update requires an MCP 2026-07-28 server (tasks extension)'
74
+ end
75
+ unless input_responses.is_a?(Hash)
76
+ raise ArgumentError, 'input_responses must be a Hash keyed by input request key'
77
+ end
78
+
79
+ # The answers are for the task the handle names, in the session it
80
+ # was seen in: they never reach the session that replaced it, where
81
+ # the reused id and keys would answer an unrelated request. A bare id
82
+ # answers the task of the session live at this call.
83
+ epoch = handle_session_epoch(task, srv, 'updating') || invocation_session_epoch(srv)
84
+ # The answers are bound to the lifetime the handle names, and the
85
+ # bookkeeping they are recorded in is resolved in the same step: a
86
+ # creation that lands between the two would otherwise have the
87
+ # delivery queue them in the replacement's state and send them for it.
88
+ pin = task_lifetime_pin(task, task_id, srv, epoch, 'updating')
89
+ state = task_state_of_lifetime(srv, task_id, epoch, pin)
90
+ # A caller that asked for this delivery is told when it did not
91
+ # happen: a wait would poll again, but nothing else would notice.
92
+ send_task_update(srv, task_id, input_responses, epoch: epoch, state: state, strict_session: true)
93
+ end
94
+
95
+ # Wait for a task to reach a terminal status (MCP 2026-07-28 tasks
96
+ # extension): poll tasks/get at the server's pollIntervalMs, answer
97
+ # input_required states through tasks/update using the registered
98
+ # handlers (each inputRequests key is answered once), and give up when
99
+ # the task's TTL backstop or the caller's timeout elapses.
100
+ #
101
+ # Input requests are answered here (and by {MCPClient::Client#call_tool}) and
102
+ # nowhere else: a notifications/tasks that carries inputRequests is
103
+ # delivered to the notification listeners as it arrived, and a host
104
+ # that follows a task through notifications hands it to this method
105
+ # (or answers with {#update_task}) when it wants them answered.
106
+ #
107
+ # Giving up ends the wait and nothing else: the task keeps running,
108
+ # since only the host knows whether its result is still wanted. The
109
+ # handle stays usable — wait again, read it with {TaskApi#get_task}, or
110
+ # end the task with {TaskApi#cancel_task} (tasks/cancel; a task is never
111
+ # cancelled with notifications/cancelled).
112
+ # @param task [String, MCPClient::Task] the task or its id
113
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
114
+ # @param timeout [Numeric, nil] seconds to wait before giving up (nil = until the TTL, if any)
115
+ # @return [MCPClient::Task] the terminal task (completed, failed or cancelled), with its result or error
116
+ # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
117
+ # @raise [MCPClient::Errors::InputRequiredError] if an input request cannot be fulfilled
118
+ # @raise [MCPClient::Errors::TaskError] on timeout, TTL expiry, a failed request, or a server session
119
+ # that ended before the task was terminal (the task went with it; its id may be another task's now)
120
+ def wait_for_task(task, server: nil, timeout: nil)
121
+ return task if task.is_a?(MCPClient::Task) && !task.remote?
122
+
123
+ srv = select_task_server(task, server, 'wait_for_task')
124
+ task_id = task_identifier(task)
125
+ # The caller's deadline and the task's TTL backstop are kept apart:
126
+ # the TTL may change with every observation (the server MAY extend
127
+ # it) while the caller's timeout never moves. The deadline exists
128
+ # before anything is sent, so a capability probe (initialization,
129
+ # discovery) counts against it too.
130
+ # The wait carries the definition the creating call went out under, so
131
+ # every handle it hands back names the tool the task is running (see
132
+ # #validated_task_result) — and the lifetime that call started, so
133
+ # none of them can later reach a task the server names with the same
134
+ # id (see #handle_task_generation). A bare task id names no request,
135
+ # and so no tool and no lifetime; neither does a handle of another
136
+ # server, whose task and tool are its own.
137
+ wait = { task_id: task_id, srv: srv, deadline: timeout && (monotonic_time + timeout), ttl_deadline: nil,
138
+ answered: nil, state: nil, epoch: nil, last: nil, called_tool: called_tool_for(task, srv),
139
+ generation: handle_task_generation(task, srv) }
140
+ probe_task_capability!(wait)
141
+ unless modern_server?(srv)
142
+ raise MCPClient::Errors::TaskError, 'wait_for_task requires an MCP 2026-07-28 server (tasks extension)'
143
+ end
144
+
145
+ final = final_task_handle(task, srv, wait)
146
+ return final if final
147
+
148
+ # A handle whose session has ended names a task that is gone: it is
149
+ # never polled in the session that replaced it, where the id may
150
+ # belong to something else (the same rule #update_task and
151
+ # #cancel_task apply to a handle).
152
+ handle_session_epoch(task, srv, 'waiting for')
153
+ refresh_wait_session(wait)
154
+ # And a handle of a task a later CreateTaskResult under the same id
155
+ # replaced is never polled either: what the wait would follow is the
156
+ # task that answers to the id now, not the one the caller named.
157
+ task_lifetime_pin(task, task_id, srv, wait[:epoch], 'waiting for')
158
+ # The CreateTaskResult seed is not an observation: the first
159
+ # tasks/get goes out at once, whatever the seed claims. Its TTL
160
+ # still bounds a wait whose polls never come back, and its
161
+ # pollIntervalMs paces them — but only when the handle came from
162
+ # the server being polled: task ids and state are per server, so a
163
+ # handle from another server says nothing about this one's task —
164
+ # and neither does a handle from a session of this server that has
165
+ # ended, whose task id the new session may have reused.
166
+ seed_wait_from_handle(task, srv, wait)
167
+ loop do
168
+ # The server may have restarted since the last poll (during the
169
+ # sleep between two of them, say): the task belonged to the session
170
+ # that ended, so the wait ends here rather than polling an id the
171
+ # new session may have reused for something else.
172
+ end_of_task!(wait) if refresh_wait_session(wait)
173
+ current = observe_task(wait)
174
+ # A poll that came back with nothing is no observation: try again at
175
+ # the pace the server last asked for, unless the wait is over. It
176
+ # may have come back with nothing because the session ended under
177
+ # it (a poll that timed out, or one the transport held back from
178
+ # the session that replaced its own): the task then ended with its
179
+ # session, and the wait ends here rather than asking the new
180
+ # session about an id it may have reused — or waiting out the pace
181
+ # of a task that no longer exists.
182
+ unless current
183
+ end_of_task!(wait) if refresh_wait_session(wait)
184
+ raise_if_past_deadline!(wait)
185
+ next sleep(wait[:last] ? task_poll_delay(wait[:last], wait_deadline(wait)) : default_poll_delay(wait))
186
+ end
187
+
188
+ # The session the poll belongs to may have ended while it was in
189
+ # flight or just after it came back. The task went with it: its id
190
+ # in the replacement session names whatever that session made of
191
+ # it, so the wait never polls it there. What the ended session
192
+ # already answered still stands — see #outcome_of_ended_session.
193
+ polled_state = wait[:state]
194
+ polled_epoch = wait[:epoch]
195
+ return outcome_of_ended_session(current, wait, polled_state, polled_epoch) if refresh_wait_session(wait)
196
+
197
+ if current.terminal?
198
+ # A terminal task that came back after the caller's deadline
199
+ # (transport retries) does not rescue a timed-out wait; the TTL
200
+ # backstop is moot once the task is terminal.
201
+ raise_if_past_caller_deadline!(wait)
202
+ forget_task_keys(srv, task_id, state: wait[:state])
203
+ return current
204
+ end
205
+ wait[:last] = current
206
+ # The TTL backstop comes before any handler runs for the task, and
207
+ # from now on bounds every poll, even ones that time out. A poll
208
+ # that came back late (transport retries) ends the wait here.
209
+ bound_wait_by_ttl(current, wait)
210
+ raise_if_past_deadline!(wait)
211
+ retransmit_pending_update(current, wait)
212
+ # No new handler round once the wait is over, whatever the
213
+ # retransmission took.
214
+ raise_if_past_deadline!(wait)
215
+ # The retransmission is a full tasks/update round trip, and the
216
+ # session may have ended under it: the observation in hand says
217
+ # nothing about the live session, its input requests must never be
218
+ # put to the host, and the task itself did not survive.
219
+ end_of_task!(wait) if refresh_wait_session(wait)
220
+ answer_task_round(current, wait)
221
+
222
+ sleep(task_poll_delay(current, wait_deadline(wait)))
223
+ end
224
+ end
225
+
226
+ # Whether this client declared the MCP 2026-07-28 tasks extension
227
+ # (`extensions: ['io.modelcontextprotocol/tasks']`).
228
+ # @return [Boolean]
229
+ def tasks_extension?
230
+ @extensions.key?(MCPClient::JsonRpcCommon::TASKS_EXTENSION)
231
+ end
232
+
233
+ private
234
+
235
+ # The task state to act on for this iteration: the seed when it is
236
+ # still usable, else a fresh tasks/get; a seed that claims a terminal
237
+ # or input_required status without its payload is confirmed by
238
+ # tasks/get, and a terminal DetailedTask must carry its payload.
239
+ # @param wait [Hash] task_id, srv, deadline
240
+ # @return [MCPClient::Task]
241
+ # @raise [MCPClient::Errors::TaskError] once the deadline has passed
242
+ def observe_task(wait)
243
+ raise_if_past_deadline!(wait)
244
+ # What was already answered when this poll went out. Whatever it comes
245
+ # back with was decided by the server no earlier than this, so an
246
+ # answer queued after it is newer than the observation and must not be
247
+ # retired by it (see #still_outstanding).
248
+ wait[:observed_at] = answers_queued_so_far(wait)
249
+ current = poll_task(wait)
250
+ return nil unless current
251
+
252
+ validate_terminal_task!(current) if current.terminal?
253
+ # A poll asks by task id, which names no tool and no lifetime: the
254
+ # observation is the same task the wait was handed, so it keeps that
255
+ # handle's definition and names that handle's lifetime — a handle the
256
+ # wait hands back must refuse to reach a task that reused the id
257
+ # exactly as the handle the wait was given does.
258
+ current.with_called_tool(wait[:called_tool]).with_task_generation(wait[:generation])
259
+ end
260
+
261
+ # Answer this poll's outstanding input requests, bounding how many
262
+ # rounds a task may demand (the limit is per task, not per wait, and
263
+ # is applied with the key reservation, see #answer_task_input_requests).
264
+ # @return [void]
265
+ def answer_task_round(current, wait)
266
+ return unless pending_task_input?(current, wait[:answered])
267
+
268
+ answer_task_input_requests(current, wait[:answered], wait[:srv], wait)
269
+ end
270
+
271
+ # Deliver again an update whose acknowledgement was lost, before any
272
+ # new input is answered (the server ignores keys it already has). Only
273
+ # what is still pending once the task's update lock is held is sent: a
274
+ # snapshot taken before the lock could resend an answer a concurrent,
275
+ # confirmed update has just superseded. And only what the task still
276
+ # asks for: the keys of a tasks/update MUST name outstanding input
277
+ # requests, so an answer the observation in hand no longer lists was
278
+ # consumed (the acknowledgement, not the update, was lost) and is not
279
+ # sent again — the key stays answered, so the host is not asked twice.
280
+ # @param current [MCPClient::Task] the observation this poll made
281
+ # @return [void]
282
+ def retransmit_pending_update(current, wait)
283
+ deliver_task_update(wait[:srv], wait[:task_id], nil, wait, pending_only: true,
284
+ outstanding: outstanding_task_keys(current),
285
+ observed_at: wait[:observed_at])
286
+ end
287
+
288
+ # The task's answer sequence as it stands now: how many answers had been
289
+ # queued for it when the caller read it. Stamped on a poll before it goes
290
+ # out so what comes back can be ordered against the answers themselves.
291
+ # @return [Integer]
292
+ def answers_queued_so_far(wait)
293
+ state = wait[:state] || task_state(wait[:srv], wait[:task_id])
294
+ answered_keys_mutex.synchronize { state[:answer_seq].to_i }
295
+ end
296
+
297
+ # The input request keys a task observation still lists: none for a
298
+ # task that is not asking, nil when it is asking but the observation
299
+ # does not say for what (a summary without inputRequests).
300
+ # @return [Set<String>, nil]
301
+ def outstanding_task_keys(task)
302
+ return Set.new unless task.input_required?
303
+
304
+ requests = task.input_requests
305
+ requests.is_a?(Hash) ? Set.new(requests.keys.map(&:to_s)) : nil
306
+ end
307
+
308
+ # Whether the task lists an input request that has not been answered.
309
+ # @return [Boolean]
310
+ def pending_task_input?(task, answered)
311
+ return false unless task.input_required?
312
+
313
+ requests = task.input_requests
314
+ return false if requests.nil? && !task.detailed?
315
+ unless requests.is_a?(Hash)
316
+ raise MCPClient::Errors::InputRequiredError.new(
317
+ 'Malformed input_required task: inputRequests is not an object', data: task.to_h
318
+ )
319
+ end
320
+
321
+ requests.keys.any? { |key| !answered.include?(key) }
322
+ end
323
+
324
+ # The inputRequests keys already answered for a task, kept across
325
+ # waits ("Clients SHOULD deduplicate inputRequests keys across
326
+ # consecutive polls") until the task is terminal or cancelled.
327
+ # @return [Set<String>]
328
+ def answered_task_keys(srv, task_id)
329
+ task_state(srv, task_id)[:answered]
330
+ end
331
+
332
+ # The pace before the server ever said one (a bare task id, a handle
333
+ # from another server): the default interval, never the busy-loop
334
+ # floor, clamped to what is left of the wait.
335
+ # @return [Float] seconds
336
+ def default_poll_delay(wait)
337
+ deadline = wait_deadline(wait)
338
+ remaining = deadline && [deadline - monotonic_time, 0.0].max
339
+ remaining ? DEFAULT_TASK_POLL_INTERVAL.clamp(0.0, remaining) : DEFAULT_TASK_POLL_INTERVAL
340
+ end
341
+
342
+ # The wait's effective deadline: the earlier of the caller's timeout
343
+ # and the latest TTL backstop.
344
+ # @return [Float, nil]
345
+ def wait_deadline(wait)
346
+ [wait[:deadline], wait[:ttl_deadline]].compact.min
347
+ end
348
+
349
+ # Send a task request through a transport that may implement only the
350
+ # documented rpc_request(method, params) interface: the timeout keyword
351
+ # goes out when the transport accepts it, and a transport that does not
352
+ # is bounded on the wall clock instead (see {#capped_task_rpc}) — the
353
+ # computed bound must hold whoever enforces it. It is a per-attempt
354
+ # transport timeout only; the wall-clock bound of a wait comes from
355
+ # {#bounded_by_wait} around the whole call.
356
+ # @return [Object] the JSON-RPC result
357
+ def task_rpc(srv, method, params, timeout: nil, epoch: nil, lifetime: nil)
358
+ answer = if timeout && !accepts_timeout?(srv)
359
+ capped_task_rpc(srv, method, params, timeout, epoch, lifetime)
360
+ else
361
+ pinned_to_lifetime(srv, lifetime) do
362
+ pinned_to_session(srv, epoch) do
363
+ if timeout.nil?
364
+ srv.rpc_request(method,
365
+ params)
366
+ else
367
+ srv.rpc_request(method, params, timeout: timeout)
368
+ end
369
+ end
370
+ end
371
+ end
372
+ wire_keyed(answer)
373
+ end
374
+
375
+ # One task request through a transport that takes no timeout, bounded
376
+ # on the wall clock: without it the computed bound (MAX_TASK_REQUEST_TIMEOUT,
377
+ # or what is left of the task's TTL) would simply be dropped and a hung
378
+ # tasks/get would block a wait that has no caller deadline for good.
379
+ # The request runs on its own thread — a transport that never comes
380
+ # back cannot be interrupted — and when the bound runs out the caller
381
+ # is told it timed out, exactly as a transport enforcing the timeout
382
+ # itself would report it. The worker is not abandoned: the next poll
383
+ # for the same request joins it again instead of starting another one
384
+ # beside it (and takes its answer if it came back meanwhile), and the
385
+ # number of distinct requests left hanging on a transport is capped,
386
+ # so a transport that never answers cannot pile up live threads for as
387
+ # long as a wait keeps polling. The session pin is applied inside the
388
+ # worker: pins are thread-local, so the request would otherwise lose
389
+ # the guard that keeps it out of the session which replaced its own.
390
+ # @return [Object] the JSON-RPC result
391
+ # @raise [MCPClient::Errors::RequestTimeoutError] when the bound ran out
392
+ # @raise [MCPClient::Errors::TransportError] when the transport already
393
+ # has MAX_PENDING_TASK_REQUESTS requests hanging
394
+ def capped_task_rpc(srv, method, params, timeout, epoch, lifetime = nil)
395
+ key = [method, params, epoch, lifetime]
396
+ runner = pending_task_request(srv, key) do
397
+ Thread.new do
398
+ Thread.current.report_on_exception = false
399
+ pinned_to_lifetime(srv, lifetime) { pinned_to_session(srv, epoch) { srv.rpc_request(method, params) } }
400
+ end
401
+ end
402
+ if runner.join(timeout)
403
+ pending_task_requests_mutex.synchronize { pending_task_requests(srv).delete(key) }
404
+ return runner.value
405
+ end
406
+
407
+ raise MCPClient::Errors::RequestTimeoutError,
408
+ "Request #{method} timed out after #{timeout} seconds"
409
+ end
410
+
411
+ # Run the request with the transport refusing to write it once the
412
+ # session it belongs to has ended: the built-in transports establish
413
+ # (and so may re-establish) their session inside rpc_request itself, so
414
+ # a guard that compares before the call cannot cover the request that
415
+ # actually goes out. A transport that knows no session pin sends as
416
+ # before.
417
+ # @param epoch [Integer, nil] the session the request belongs to
418
+ # @return [Object] the block's value
419
+ def pinned_to_session(srv, epoch, &block)
420
+ return block.call if epoch.nil? || !srv.respond_to?(:pinned_to_session)
421
+
422
+ srv.pinned_to_session(epoch, &block)
423
+ end
424
+
425
+ # The bookkeeping of exactly the lifetime a request is about, resolved
426
+ # in one step with the check that the lifetime is still what the request
427
+ # names: a creation that landed between the two would hand the caller
428
+ # the replacement's state, which the delivery path then takes for its
429
+ # own — its keys answered, its answers sent, and the request already
430
+ # past the guard that should have refused it.
431
+ # @param pin [Hash, nil] the request's lifetime pin
432
+ # @return [Hash] the task state
433
+ # @raise [MCPClient::Errors::TaskReplacedError] if the lifetime moved
434
+ def task_state_of_lifetime(srv, task_id, epoch, pin)
435
+ answered_keys_mutex.synchronize do
436
+ check_task_lifetime_locked!(pin) if pin
437
+ task_state_locked(srv, task_id, registry_epoch(srv, epoch))
438
+ end
439
+ end
440
+
441
+ # @return [Boolean] whether the transport's rpc_request takes timeout:
442
+ def accepts_timeout?(srv)
443
+ srv.method(:rpc_request).parameters.any? { |type, name| type == :keyrest || (type == :key && name == :timeout) }
444
+ rescue NameError
445
+ true
446
+ end
447
+
448
+ # The timeout for a request that must not outlive the wait: what is
449
+ # left of it (a tiny positive floor keeps the transport from reading 0
450
+ # as "no timeout"), capped so a hung request never blocks the wait
451
+ # for the task's whole lifetime.
452
+ # @param deadline [Float, nil] monotonic deadline of the wait
453
+ # @return [Float]
454
+ def request_timeout(deadline, srv = nil)
455
+ remaining = deadline && [deadline - monotonic_time, MIN_TASK_REQUEST_TIMEOUT].max
456
+ # The cap bounds a request whose wait has no near deadline; it never
457
+ # enlarges a shorter timeout the transport was configured with.
458
+ configured = srv.respond_to?(:read_timeout) ? srv.read_timeout : nil
459
+ [remaining, MAX_TASK_REQUEST_TIMEOUT, configured.is_a?(Numeric) ? configured : nil].compact.min
460
+ end
461
+
462
+ # @param task_id [Object] a peer-controlled task id
463
+ # @return [String] safe for a log line or exception message
464
+ def shown_task_id(task_id)
465
+ sanitize_peer_log_text(task_id.to_s)
466
+ end
467
+
468
+ # The TTL backstop counts only once a poll has been tried: a seed that
469
+ # already looks expired is still confirmed by tasks/get.
470
+ # @return [void]
471
+ # @raise [MCPClient::Errors::TaskError]
472
+ def raise_if_past_deadline!(wait)
473
+ raise_ttl_elapsed!(wait) if wait[:polled] && ttl_backstop_elapsed?(wait)
474
+ raise_if_past_caller_deadline!(wait)
475
+ end
476
+
477
+ # Whether the TTL backstop the wait holds has run out — and is still
478
+ # the backstop of the session that is live. A restart (during the
479
+ # poll, during the sleep between two of them) ends the task the
480
+ # backstop was observed on: what the replacement session calls this
481
+ # task has a TTL of its own, so the stale one is dropped here rather
482
+ # than ending the wait before the new session has been polled. The
483
+ # wait's own epoch is left alone: the answer path compares it to
484
+ # decide whether an answer still belongs to the session it was
485
+ # produced in.
486
+ # @return [Boolean]
487
+ def ttl_backstop_elapsed?(wait)
488
+ return false unless wait[:ttl_deadline] && monotonic_time >= wait[:ttl_deadline]
489
+ return true unless wait[:epoch] &&
490
+ answered_keys_mutex.synchronize { current_session_epoch(wait[:srv]) } != wait[:epoch]
491
+
492
+ wait[:ttl_deadline] = nil
493
+ wait[:last] = nil
494
+ false
495
+ end
496
+
497
+ # The capability probe (initialization, discovery) counts against the
498
+ # caller's budget: a spent budget sends nothing, and a probe that
499
+ # outlives the remaining budget ends the wait — the transports take no
500
+ # per-call budget for their handshake, so the probe runs on its own
501
+ # thread and is abandoned (it finishes or fails on its own) once the
502
+ # wait is over.
503
+ # @param wait [Hash]
504
+ # @return [void]
505
+ # @raise [MCPClient::Errors::TaskError] when the budget ran out
506
+ def probe_task_capability!(wait)
507
+ raise_if_past_caller_deadline!(wait)
508
+ return ensure_task_capability!(wait[:srv], 'get', strict: true) unless wait[:deadline]
509
+
510
+ probe = shared_task_probe(wait[:srv])
511
+ remaining = [wait[:deadline] - monotonic_time, 0].max
512
+ unless probe.join(remaining)
513
+ raise MCPClient::Errors::TaskError, "Timed out waiting for task '#{shown_task_id(wait[:task_id])}'"
514
+ end
515
+
516
+ error = probe[:error]
517
+ raise error if error
518
+
519
+ raise_if_past_caller_deadline!(wait)
520
+ end
521
+
522
+ # One capability probe per server at a time: a wait that timed out on
523
+ # the handshake leaves it running, and the next wait joins that same
524
+ # probe instead of starting another (HTTP transports would tear down
525
+ # the session the first handshake just established).
526
+ # @param srv [MCPClient::ServerBase]
527
+ # @return [Thread] the live probe
528
+ def shared_task_probe(srv)
529
+ answered_keys_mutex.synchronize do
530
+ @task_probes ||= {}.compare_by_identity
531
+ live = @task_probes[srv]
532
+ return live if live&.alive?
533
+
534
+ @task_probes[srv] = Thread.new do
535
+ Thread.current.report_on_exception = false
536
+ begin
537
+ ensure_task_capability!(srv, 'get', strict: true)
538
+ rescue Exception => e # rubocop:disable Lint/RescueException -- handed back to the waiting thread
539
+ Thread.current[:error] = e
540
+ end
541
+ end
542
+ end
543
+ end
544
+
545
+ # The caller's timeout never moves and is enforced whatever the task
546
+ # did in the meantime.
547
+ # @return [void]
548
+ # @raise [MCPClient::Errors::TaskError]
549
+ def raise_if_past_caller_deadline!(wait)
550
+ return unless wait[:deadline] && monotonic_time >= wait[:deadline]
551
+
552
+ raise MCPClient::Errors::TaskError, "Timed out waiting for task '#{shown_task_id(wait[:task_id])}'"
553
+ end
554
+
555
+ # @raise [MCPClient::Errors::TaskError]
556
+ def raise_ttl_elapsed!(wait)
557
+ # The server may purge the task any moment; its bookkeeping is done
558
+ # — the bookkeeping of the session this wait was following, not
559
+ # whatever a restart has recorded under the id since.
560
+ forget_task_keys(wait[:srv], wait[:task_id], state: wait[:state])
561
+ raise MCPClient::Errors::TaskError,
562
+ "Task '#{shown_task_id(wait[:task_id])}' did not reach a terminal status within its TTL " \
563
+ '(createdAt + ttlMs)'
564
+ end
565
+
566
+ # The seed's TTL backstop (see #raise_if_past_deadline! for why an
567
+ # expired-looking seed does not end the wait before the first poll).
568
+ # @return [void]
569
+ def seed_ttl_deadline(task, wait)
570
+ remaining = task.ttl_remaining
571
+ wait[:ttl_deadline] = monotonic_time + [remaining, 0.0].max if remaining
572
+ end
573
+
574
+ # Record the latest TTL backstop (createdAt + ttlMs) of the task; every
575
+ # observation replaces it ("ttlMs MAY change over the lifetime of a
576
+ # task"), so an extended TTL extends the wait and a task made
577
+ # unlimited (ttlMs null) has no backstop any more.
578
+ # @return [void]
579
+ # @raise [MCPClient::Errors::TaskError] when the TTL already elapsed
580
+ def bound_wait_by_ttl(current, wait)
581
+ remaining = current.ttl_remaining
582
+ raise_ttl_elapsed!(wait) if remaining && remaining <= 0
583
+ if remaining
584
+ wait[:ttl_deadline] = monotonic_time + remaining
585
+ elsif current.ttl_reported?
586
+ # The observation reported a ttlMs the wait cannot turn into a
587
+ # deadline: an explicit null, or a value the clock cannot
588
+ # represent (Task#ttl_remaining answers nil for both). Either way
589
+ # the task has no backstop any more, and keeping the previous one
590
+ # would end the wait before the TTL the server just extended.
591
+ # Only an observation carrying no ttlMs at all keeps the last one.
592
+ wait[:ttl_deadline] = nil
593
+ end
594
+ end
595
+
596
+ # One tasks/get, bounded by what is left of the wait and pinned to the
597
+ # session the wait joined: the transports establish (and so may
598
+ # re-establish) their session inside rpc_request itself, and a poll
599
+ # answered by the session that replaced this one would describe another
600
+ # lifetime of the same, reusable task id. A request that merely timed
601
+ # out — or that the pin refused to write — is not the end of the task
602
+ # ("Clients SHOULD continue polling until the task reaches a terminal
603
+ # status"): nil is returned and the caller polls again.
604
+ # @return [MCPClient::Task, nil]
605
+ def poll_task(wait)
606
+ wait[:polled] = true
607
+ bounded_by_wait(wait, deadline: wait[:deadline]) do
608
+ get_task(wait[:task_id], server: wait[:srv], state: wait[:state], epoch: wait[:epoch], polling: true,
609
+ timeout: request_timeout(wait_deadline(wait), wait[:srv]))
610
+ end
611
+ rescue MCPClient::Errors::TaskNotFound
612
+ # Gone for good: nothing of it may colour a later task with this id.
613
+ forget_task_keys(wait[:srv], wait[:task_id], state: wait[:state])
614
+ raise
615
+ rescue MCPClient::Errors::SessionChangedError
616
+ # Nothing was asked: the transport held the poll back from the
617
+ # session that replaced its own. The wait sees the move on its next
618
+ # refresh and ends there (the task belonged to the ended session).
619
+ logger.debug("tasks/get for task #{shown_task_id(wait[:task_id])} was not sent into the session that " \
620
+ 'replaced its own')
621
+ nil
622
+ rescue MCPClient::Errors::TaskError => e
623
+ raise unless e.cause.is_a?(MCPClient::Errors::RequestTimeoutError)
624
+
625
+ logger.debug("tasks/get for task #{shown_task_id(wait[:task_id])} timed out; polling again")
626
+ nil
627
+ end
628
+
629
+ # A DetailedTask MUST carry the payload its terminal status implies
630
+ # (the result of a completed task, the JSON-RPC error of a failed one).
631
+ # @param task [MCPClient::Task] a detailed terminal task
632
+ # @return [void]
633
+ # @raise [MCPClient::Errors::InvalidResultError]
634
+ def validate_terminal_task!(task)
635
+ return if task.payload_present?
636
+
637
+ field = task.failed? ? 'error' : 'result'
638
+ present = task.failed? ? !task.error.nil? : !task.result.nil?
639
+ shape = if task.failed?
640
+ 'a JSON-RPC error object (integer code, string message)'
641
+ else
642
+ 'an object whose resultType, if any, is "complete"'
643
+ end
644
+ problem = present ? "with #{field} that is not #{shape}" : "without the #{field} field"
645
+ raise MCPClient::Errors::InvalidResultError, "Invalid task: status #{task.status} #{problem}"
646
+ end
647
+
648
+ # Enforce the MCP 2026-07-28 tasks extension gate: this client must have
649
+ # declared it and the server must have negotiated it.
650
+ # @param srv [MCPClient::ServerBase]
651
+ # @return [void]
652
+ # @raise [MCPClient::Errors::CapabilityError]
653
+ def ensure_tasks_extension!(srv)
654
+ extension = MCPClient::JsonRpcCommon::TASKS_EXTENSION
655
+ unless tasks_extension?
656
+ raise MCPClient::Errors::CapabilityError,
657
+ "Tasks on an MCP 2026-07-28 server require the #{extension} extension: pass " \
658
+ "extensions: ['#{extension}'] to MCPClient::Client.new"
659
+ end
660
+ return if srv.capability?('extensions', extension)
661
+
662
+ raise MCPClient::Errors::CapabilityError,
663
+ "Server #{srv.name || srv.class.name} did not negotiate the #{extension} tasks extension"
664
+ end
665
+
666
+ # tools/call on a 2026-07-28 server: the server alone decides whether to
667
+ # answer with a task, so send a plain call and wrap the outcome.
668
+ # @return [MCPClient::Task] the server's task, or a locally completed one
669
+ def call_tool_as_modern_task(tool_name, parameters, srv, tool: nil)
670
+ ensure_tasks_extension!(srv)
671
+ # The session the call reaches: a task it creates belongs to that
672
+ # session, whatever session is live once the answer is parsed. The
673
+ # call is written into that very session and no other — a transport
674
+ # that reconnects inside the request would otherwise run the tool in
675
+ # the replacement session while the task is stamped with the sampled
676
+ # one, and the wait would then refuse a task whose (possibly
677
+ # non-idempotent) tool has already run.
678
+ epoch = invocation_session_epoch(srv)
679
+ # The call and the re-resolve that follows it share one slot for the
680
+ # definition the request went out under, exactly as #call_tool does:
681
+ # without it the transport's record dies with its own call and the
682
+ # re-resolve would list again, validating the answer against a
683
+ # definition newer than the one the call carried.
684
+ with_called_tool_definition(srv) do
685
+ result = modern_task_tool_call(tool_name, parameters, srv, epoch)
686
+ # Read once, because reading it spends it: whichever branch follows
687
+ # validates against this definition, and a handle carries it so the
688
+ # result the task delivers later is checked against it too.
689
+ called = tool && (called_tool_definition(srv, tool.name) || tool)
690
+ next created_task(result, srv, epoch).with_called_tool(called) if task_result?(result)
691
+
692
+ MCPClient::Task.completed_locally(validated_sync_result(result, called), server: srv)
693
+ end
694
+ end
695
+
696
+ # The tools/call itself, with every failure to create a task reported as
697
+ # one.
698
+ # @param epoch [Integer, nil] the session the call belongs to
699
+ # @return [Object] the JSON-RPC result
700
+ # @raise [MCPClient::Errors::TaskError]
701
+ def modern_task_tool_call(tool_name, parameters, srv, epoch)
702
+ pinned_to_session(srv, epoch) { srv.call_tool(tool_name, parameters) }
703
+ rescue MCPClient::Errors::ServerError => e
704
+ # Protocol errors (HeaderMismatch, missing capability, ...) keep
705
+ # their type; anything else is a failed creation.
706
+ raise if e.protocol_error?
707
+
708
+ raise MCPClient::Errors::TaskError, 'Error creating task for tool ' \
709
+ "'#{sanitize_peer_log_text(tool_name.to_s)}': " \
710
+ "#{sanitize_peer_log_text(e.message)}"
711
+ rescue MCPClient::Errors::ToolCallError, MCPClient::Errors::TransportError,
712
+ MCPClient::Errors::ConnectionError => e
713
+ raise MCPClient::Errors::TaskError, 'Error creating task for tool ' \
714
+ "'#{sanitize_peer_log_text(tool_name.to_s)}': " \
715
+ "#{sanitize_peer_log_text(e.message)}"
716
+ end
717
+
718
+ # A call the server answered synchronously: the result is validated
719
+ # against the tool's outputSchema exactly as #call_tool would — against
720
+ # the definition the request went out under, which a mid-call
721
+ # HeaderMismatch refresh may since have replaced.
722
+ # @param tool [MCPClient::Tool, nil] the definition the call went out under
723
+ # @return [Object] the validated result
724
+ def validated_sync_result(result, tool)
725
+ return result unless tool
726
+
727
+ validate_called_result!(tool, result)
728
+ end
729
+
730
+ # The result a task delivered, validated against the definition the
731
+ # request that created the task went out under — the same check a
732
+ # synchronous answer to that request gets. Only a handle names a tool:
733
+ # a task id identifies no request, so a caller that kept only the id
734
+ # gets the result unvalidated, as before.
735
+ # @param task [Object] what the caller named the task with
736
+ # @param result [Object] the result the task delivered
737
+ # @return [Object] the result
738
+ def validated_task_result(task, result, srv = nil)
739
+ tool = srv ? called_tool_for(task, srv) : called_tool_of(task)
740
+ return result unless tool
741
+
742
+ validate_called_result!(tool, result)
743
+ end
744
+
745
+ # The definition a handle carries, if the caller named the task with a
746
+ # handle at all: the one its creating call went out under. Every handle
747
+ # of that task keeps it — a refreshed one (see {TaskApi#get_task}) and
748
+ # the one a wait hands back name the same task, and so the same tool —
749
+ # while a bare task id identifies no request and no tool.
750
+ # @param task [Object] what the caller named the task with
751
+ # @return [MCPClient::Tool, nil]
752
+ def called_tool_of(task)
753
+ task.is_a?(MCPClient::Task) ? task.called_tool : nil
754
+ end
755
+
756
+ # The definition a request addressed to `srv` validates against: the
757
+ # handle's, when the handle is this server's. A `server:` override that
758
+ # names another server asks that server about its own task, which the
759
+ # handle's tool says nothing about — its output schema belongs to the
760
+ # tool the handle's server ran, not to whatever the named server
761
+ # delivers under the same id.
762
+ # @param task [Object] what the caller named the task with
763
+ # @return [MCPClient::Tool, nil]
764
+ def called_tool_for(task, srv)
765
+ return nil unless task.is_a?(MCPClient::Task) && task.server.equal?(srv)
766
+
767
+ task.called_tool
768
+ end
769
+
770
+ # The handle for a CreateTaskResult, which MUST carry a taskId.
771
+ # @param epoch [Integer, nil] the session the creating call was sent in;
772
+ # without one the session live at this call is taken
773
+ # @return [MCPClient::Task]
774
+ # @raise [MCPClient::Errors::InvalidResultError]
775
+ def created_task(result, srv, epoch = nil)
776
+ result = wire_keyed(result)
777
+ # A CreateTaskResult is a Task: a defaulted status or a missing TTL
778
+ # would drive the wait on made-up state (and lose the backstop).
779
+ unless result.is_a?(Hash) && result['taskId'].is_a?(String)
780
+ raise MCPClient::Errors::InvalidResultError, 'Invalid CreateTaskResult: no taskId'
781
+ end
782
+
783
+ problem = task_shape_problem(result)
784
+ raise MCPClient::Errors::InvalidResultError, "Invalid CreateTaskResult: #{problem}" if problem
785
+
786
+ epoch ||= invocation_session_epoch(srv)
787
+ # A creation is a new task lifetime: whatever an earlier task with
788
+ # this id left behind (answered keys, an ambiguous update, a handler
789
+ # still presenting its input requests) is not its, and neither a wait
790
+ # nor a hold nor an answer of that task may reach this one.
791
+ generation = start_task_lifetime(srv, result['taskId'], epoch)
792
+ # The handle is the object just validated: a 2026-07-28
793
+ # CreateTaskResult is the flat Task itself, and an extra `task`
794
+ # property (the legacy 2025 wrapper) must not replace it. It names
795
+ # the session the call was answered in, not one that replaced it
796
+ # between the answer and this handle — and, within that session, the
797
+ # task this creation started rather than whichever one answers to the
798
+ # id later.
799
+ MCPClient::Task.from_json(result, server: srv, session_epoch: epoch, task_generation: generation)
800
+ end
801
+
802
+ # Count the lifetime a handle just built from a CreateTaskResult starts,
803
+ # and stamp the handle with it. The 2026-07-28 path builds the handle
804
+ # from the validated result itself (see {#created_task}); the legacy
805
+ # 2025-11-25 one has to unwrap the `task` member first, so the handle
806
+ # exists before the lifetime can be counted.
807
+ # @param task [MCPClient::Task] the handle of the creation
808
+ # @param epoch [Integer, nil] the session the creating call was sent in
809
+ # @return [MCPClient::Task] the handle, naming the lifetime it started
810
+ def started_task_lifetime(task, srv, epoch)
811
+ return task unless task.task_id.is_a?(String)
812
+
813
+ task.with_task_generation(start_task_lifetime(srv, task.task_id, epoch))
814
+ end
815
+
816
+ # @param result [Object] a JSON-RPC result
817
+ # @return [Boolean] whether it is a CreateTaskResult
818
+ def task_result?(result)
819
+ MCPClient::JsonRpcCommon.result_type(result) == 'task'
820
+ end
821
+
822
+ # Turn a CreateTaskResult answer to tools/call into the call's final
823
+ # result by waiting for the task.
824
+ # @param epoch [Integer, nil] the session the tools/call was sent in
825
+ # @return [Object] the final CallToolResult
826
+ def complete_task_result(tool_name, server, result, epoch = nil)
827
+ return result unless task_result?(result)
828
+
829
+ task = created_task(result, server, epoch)
830
+ logger.info("tools/call '#{sanitize_peer_log_text(tool_name.to_s)}' was accepted as task " \
831
+ "#{shown_task_id(task.task_id)}; waiting for it to finish")
832
+ task_outcome(wait_for_task(task))
833
+ end
834
+
835
+ # The request outcome carried by a terminal task.
836
+ # @param task [MCPClient::Task] a terminal task
837
+ # @return [Object] the result of a completed task
838
+ # @raise [MCPClient::Errors::ServerError] the JSON-RPC error of a failed task
839
+ # @raise [MCPClient::Errors::TaskError] when the task was cancelled (or is not terminal)
840
+ def task_outcome(task)
841
+ case task.status
842
+ when 'completed' then task.result
843
+ when 'failed'
844
+ error = task.error.is_a?(Hash) ? task.error : {}
845
+ error = error.merge('message' => sanitize_peer_log_text((error['message'] || task.status_message ||
846
+ 'Task failed').to_s))
847
+ raise MCPClient::Errors::ServerError.from_jsonrpc(error)
848
+ else
849
+ raise MCPClient::Errors::TaskError, "Task '#{shown_task_id(task.task_id)}' ended #{task.status}"
850
+ end
851
+ end
852
+
853
+ # Answer the input requests of an input_required task that have not
854
+ # been answered yet (keys are unique over a task's lifetime, so a key
855
+ # seen again on a later poll is the same request).
856
+ # @return [Array<String>] the keys answered now
857
+ def answer_task_input_requests(task, answered, srv, wait = {})
858
+ return [] unless task.input_required?
859
+
860
+ requests = task.input_requests
861
+ # A creation seed says input_required without listing the requests;
862
+ # the next tasks/get carries them.
863
+ return [] if requests.nil? && !task.detailed?
864
+
865
+ unless requests.is_a?(Hash)
866
+ raise MCPClient::Errors::InputRequiredError.new(
867
+ 'Malformed input_required task: inputRequests is not an object', data: task.to_h
868
+ )
869
+ end
870
+
871
+ # Reserve the keys before the handlers run, in one step with the
872
+ # per-task round limit, so two waits on the same task cannot both
873
+ # answer them nor spend the budget twice on one snapshot; a handler
874
+ # that fails gives the keys back, except those answered through
875
+ # #update_task in the meantime.
876
+ # The bookkeeping of the session the wait joined, not of whatever a
877
+ # restart has recorded since: the answered set the caller reserves
878
+ # in belongs to that session, and a round charged to the replacement
879
+ # session would block a task this observation says nothing about.
880
+ state = wait[:state] || task_state(srv, task.task_id)
881
+ # The keys a handler presents are in flight from the moment it
882
+ # starts, in the set of the session it starts in — fixed here,
883
+ # before anything runs — so no forget of the task's bookkeeping lets
884
+ # a retry present them again while the host is still answering.
885
+ pending, held, held_key = reserve_input_requests(task, requests, answered, srv, state)
886
+ return [] if pending.empty?
887
+
888
+ abandoned = false
889
+ begin
890
+ responses = bounded_by_wait(wait, on_abandon: lambda { |runner|
891
+ abandoned = true
892
+ hold_in_flight_keys(runner, state, answered, pending.keys, held, held_key)
893
+ }) { srv.fulfil_input_requests(pending, task.to_h) }
894
+ # The whole deadline (caller timeout and task TTL) is enforced
895
+ # before anything is delivered.
896
+ raise_if_past_deadline!(wait)
897
+ rescue StandardError => e
898
+ # Nothing was delivered, but a request the host answered before the
899
+ # failure has been put to a person: those answers are kept — queued
900
+ # for the next poll to deliver, and their keys left answered — so a
901
+ # retry never asks for them again. Everything else goes back
902
+ # (except what #update_task answered meanwhile), and so does the
903
+ # round.
904
+ unless abandoned
905
+ keep_partial_input_answers(state, pending, e)
906
+ answered_keys_mutex.synchronize do
907
+ answered.subtract(pending.keys - state[:submitted].to_a)
908
+ state[:rounds] -= 1 if state[:rounds].positive?
909
+ end_in_flight(pending.keys, held, held_key)
910
+ end
911
+ end
912
+ raise
913
+ end
914
+ # The handler answered: its keys are no longer in flight (the
915
+ # answered set keeps them deduplicated for this session).
916
+ answered_keys_mutex.synchronize { end_in_flight(pending.keys, held, held_key) }
917
+ # A session that restarted while the host was answering may have
918
+ # reused the task id and the keys, and so may a fresh CreateTaskResult
919
+ # under that id in this very session: the answers belong to the task
920
+ # that is over and are not delivered; the next poll asks again.
921
+ unless answers_still_this_task?(srv, task.task_id, wait)
922
+ logger.warn("Task #{shown_task_id(task.task_id)}: the task the input was requested for ended while it " \
923
+ 'was being answered (the session restarted, or the id was handed out again); the answers ' \
924
+ 'are discarded')
925
+ return []
926
+ end
927
+ deliver_task_update(srv, task.task_id, responses, wait)
928
+ pending.keys
929
+ end
930
+
931
+ # Hold on to the answers a round trip produced before it failed. The
932
+ # host answered those requests, so they are recorded (their keys stay
933
+ # answered) and left pending, which is what a later poll retransmits —
934
+ # the failure ends this wait, and nothing of what a person already
935
+ # typed is thrown away with it. Only keys this round reserved count: an
936
+ # answer for anything else was never this handler's to give.
937
+ # @param state [Hash] the task bookkeeping the round was reserved in
938
+ # @param pending [Hash] the requests this round put to the host
939
+ # @param error [StandardError] what ended the round
940
+ # @return [void]
941
+ def keep_partial_input_answers(state, pending, error)
942
+ return unless error.is_a?(MCPClient::Errors::InputRequiredError)
943
+
944
+ answers = error.answered_so_far.select { |key, _| pending.key?(key) }
945
+ return if answers.empty?
946
+
947
+ logger.debug("Task #{shown_task_id(state[:lookup].last)}: keeping #{answers.size} answer(s) the host gave " \
948
+ 'before the input round failed; they go out with the next update')
949
+ queue_task_update(state, answers)
950
+ end
951
+
952
+ # Whether the answers a handler just produced still belong to the task
953
+ # they were asked for: the session must not have restarted under the
954
+ # host, the bookkeeping they were built in must still be what the id
955
+ # names, and the task id must not have been handed out again in the
956
+ # session. In any of those cases the task the request came from is over
957
+ # and the keys would answer something else — the state check is what
958
+ # catches a task another waiter saw terminal (or gone) while the host
959
+ # was answering, which drops the bookkeeping without moving the
960
+ # lifetime counter the generation compare below reads.
961
+ # @return [Boolean]
962
+ def answers_still_this_task?(srv, task_id, wait)
963
+ answered_keys_mutex.synchronize do
964
+ epoch = current_session_epoch(srv)
965
+ next false if wait[:epoch] && wait[:epoch] != epoch
966
+
967
+ state = wait[:state]
968
+ next true unless state
969
+ next false unless @task_states && @task_states[state[:lookup]].equal?(state)
970
+
971
+ task_lifetime([srv.object_id, epoch, task_id]) == state[:generation]
972
+ end
973
+ end
974
+
975
+ # Whether a task request's error means the task is gone.
976
+ #
977
+ # On a 2026-07-28 server that answered with a JSON-RPC code, the code
978
+ # decides: the revision specifies -32602 for a task id that names
979
+ # nothing, and every other code is a failure of the request rather than
980
+ # a report about the task. An internal error (-32603) whose message
981
+ # happens to say "expired" is the server telling us something went
982
+ # wrong, not that the task is missing — reading it as a missing task
983
+ # would delete the task's bookkeeping, and with it the answers an
984
+ # unconfirmed tasks/update still owes the server.
985
+ #
986
+ # Anything else (a legacy server, or an error carrying no code at all)
987
+ # is left to the message, as before.
988
+ # @param modern [Boolean] whether the server negotiated MCP 2026-07-28
989
+ # @return [Boolean]
990
+ def task_not_found_error?(error, method, modern)
991
+ code = error.respond_to?(:code) ? error.code : nil
992
+ return modern_task_not_found?(error, method, code) if modern && code.is_a?(Integer)
993
+
994
+ legacy_task_not_found?(error, method)
995
+ end
996
+
997
+ # tasks/get answers -32602 for an unknown task; tasks/update and
998
+ # tasks/cancel use it for bad params too, so there the message still has
999
+ # to say the task is the problem — and a rejection of the supplied
1000
+ # inputResponses never is.
1001
+ # @param code [Integer] the JSON-RPC error code the server sent
1002
+ # @return [Boolean]
1003
+ def modern_task_not_found?(error, method, code)
1004
+ return false unless code == MCPClient::Errors::Codes::INVALID_PARAMS
1005
+ return true if method == 'tasks/get'
1006
+ return false if error.message.match?(/inputResponses/i)
1007
+ # An expired task is a task that is gone: the server dropped it, its
1008
+ # id names nothing, and the legacy matcher below has always read it
1009
+ # that way. The code is what separates this from an internal error
1010
+ # whose message merely mentions an expiry.
1011
+ return true if error.message.match?(/not found|unknown task|no such task|invalid taskId|expired/i)
1012
+
1013
+ # A caller that named no request keeps the reading it had: the params
1014
+ # are the request's problem, anything else the task's.
1015
+ method.nil? && !error.message.match?(/params/i)
1016
+ end
1017
+
1018
+ # The pre-2026 heuristic: those servers reported a missing task in the
1019
+ # message, with no code that says it.
1020
+ # @return [Boolean]
1021
+ def legacy_task_not_found?(error, method)
1022
+ return false if method != 'tasks/get' && error.message.match?(/inputResponses/i)
1023
+
1024
+ error.message.match?(/not found|unknown task|no such task|invalid taskId|expired/i)
1025
+ end
1026
+
1027
+ # Run a host handler or a task RPC within what is left of the wait:
1028
+ # with a deadline the work runs on its own thread and the wait ends
1029
+ # with the timed-out TaskError when it outlives the budget (the thread
1030
+ # is abandoned — a blocked elicitation cannot be interrupted, and a
1031
+ # transport implementing only the documented two-argument
1032
+ # rpc_request(method, params) takes no timeout at all, while the
1033
+ # built-in ones enforce theirs per attempt and may exceed it through
1034
+ # retry backoff — and its eventual answer is dropped); without a
1035
+ # deadline it runs inline.
1036
+ #
1037
+ # A handler is bounded by the whole wait (the caller's timeout and the
1038
+ # task's TTL); a task RPC only by the caller's timeout, which is what
1039
+ # makes wait_for_task(timeout:) a wall-clock bound on the complete
1040
+ # poll/update operation. The TTL is not applied to a request in
1041
+ # flight: an observation that comes back late MAY carry the extended
1042
+ # ttlMs that lifts the very backstop it outlived, so the TTL is
1043
+ # weighed against each observation (see #bound_wait_by_ttl) rather
1044
+ # than against the request fetching it.
1045
+ # @param on_abandon [Proc, nil] called with the abandoned thread before the wait ends
1046
+ # @param deadline [Float, nil] the bound to apply (default: the whole wait's)
1047
+ # @return [Object] the handler's result
1048
+ def bounded_by_wait(wait, on_abandon: nil, deadline: wait_deadline(wait))
1049
+ return yield unless deadline
1050
+
1051
+ remaining = [deadline - monotonic_time, 0].max
1052
+ runner = Thread.new do
1053
+ Thread.current.report_on_exception = false
1054
+ yield
1055
+ end
1056
+ unless runner.join(remaining)
1057
+ on_abandon&.call(runner)
1058
+ # Whichever bound ran out ends the wait: the task's TTL or the
1059
+ # caller's timeout.
1060
+ raise_if_past_deadline!(wait)
1061
+ raise MCPClient::Errors::TaskError, "Timed out waiting for task '#{shown_task_id(wait[:task_id])}'"
1062
+ end
1063
+
1064
+ runner.value
1065
+ end
1066
+
1067
+ # Reserve, in one step under the registry lock, the requests nobody is
1068
+ # answering yet: they join the answered set, the in-flight set of this
1069
+ # session, and cost one input round.
1070
+ # @return [Array(Hash, Set, Array)] the requests to answer, the in-flight set and its key
1071
+ # @raise [MCPClient::Errors::InputRequiredError] past MAX_TASK_INPUT_ROUNDS
1072
+ def reserve_input_requests(task, requests, answered, srv, state)
1073
+ answered_keys_mutex.synchronize do
1074
+ keys = requests.except(*answered, *in_flight_task_keys(srv, task.task_id, key: state[:key]).to_a)
1075
+ return [keys, nil, nil] if keys.empty?
1076
+
1077
+ if state[:rounds] >= MAX_TASK_INPUT_ROUNDS
1078
+ raise MCPClient::Errors::InputRequiredError.new(
1079
+ "Task '#{shown_task_id(task.task_id)}' kept requesting input after #{MAX_TASK_INPUT_ROUNDS} rounds",
1080
+ data: task.to_h
1081
+ )
1082
+ end
1083
+
1084
+ held, held_key = in_flight_task_keys(srv, task.task_id, create: true, key: state[:key])
1085
+ held.merge(keys.keys)
1086
+ state[:rounds] += 1
1087
+ answered.merge(keys.keys)
1088
+ [keys, held, held_key]
1089
+ end
1090
+ end
1091
+
1092
+ # An abandoned handler is still presenting its requests: the keys stay
1093
+ # reserved until it finishes (a later wait polls instead of asking the
1094
+ # host again, and the late answer is dropped), and the round it never
1095
+ # completed is given back so retries of a timed-out wait cannot spend
1096
+ # the per-task budget on one outstanding request.
1097
+ # @return [void]
1098
+ def hold_in_flight_keys(runner, state, answered, keys, held, held_key = nil)
1099
+ # The hold is the set of the session the handler was started in; the
1100
+ # watcher releases that very set — and drops only its own registry
1101
+ # entry — never one a later session's retry or another task filled.
1102
+ answered_keys_mutex.synchronize do
1103
+ state[:rounds] -= 1 if state[:rounds].positive?
1104
+ held.merge(keys)
1105
+ end
1106
+ Thread.new do
1107
+ Thread.current.report_on_exception = false
1108
+ begin
1109
+ runner.join
1110
+ rescue StandardError
1111
+ nil
1112
+ end
1113
+ answered_keys_mutex.synchronize do
1114
+ answered.subtract(keys - state[:submitted].to_a)
1115
+ end_in_flight(keys, held, held_key)
1116
+ end
1117
+ end
1118
+ end
1119
+
1120
+ # The keys a handler presented are no longer in flight.
1121
+ # @return [void] (callers hold answered_keys_mutex)
1122
+ def end_in_flight(keys, held, held_key)
1123
+ return unless held
1124
+
1125
+ held.subtract(keys)
1126
+ release_in_flight_entry(held, held_key) if held_key
1127
+ end
1128
+
1129
+ # The wait before the next tasks/get: the server's pollIntervalMs
1130
+ # (kept whatever its size, as long as the clock can represent it, and
1131
+ # never below MIN_TASK_POLL_INTERVAL), clamped to what is left of the
1132
+ # caller's timeout and of the task's TTL so neither can be overshot by
1133
+ # a whole polling interval.
1134
+ # @param task [MCPClient::Task]
1135
+ # @param deadline [Float, nil] monotonic deadline of the wait
1136
+ # @return [Float] seconds
1137
+ def task_poll_delay(task, deadline)
1138
+ interval = task.poll_interval_ms
1139
+ delay = interval.is_a?(Numeric) && interval >= 0 ? interval / 1000.0 : DEFAULT_TASK_POLL_INTERVAL
1140
+ # Infinity (an integer too large for a Float) and NaN land on the
1141
+ # bound, and so does a finite interval beyond it, which sleep would
1142
+ # refuse just the same.
1143
+ delay = MAX_TASK_POLL_INTERVAL unless delay.finite?
1144
+ delay = delay.clamp(MIN_TASK_POLL_INTERVAL, MAX_TASK_POLL_INTERVAL)
1145
+ remaining = [deadline && (deadline - monotonic_time), task.ttl_remaining].compact.min
1146
+ remaining ? delay.clamp(0.0, [remaining, 0.0].max) : delay
1147
+ end
1148
+
1149
+ # @return [Float] a monotonic clock reading in seconds
1150
+ def monotonic_time
1151
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1152
+ end
1153
+
1154
+ # The handle returned by a modern tasks/cancel: an acknowledgement only
1155
+ # (cancellation is eventually consistent), so the last known state of
1156
+ # this server's task. A handle from another server (or a bare id)
1157
+ # knows nothing about it: the task counts as still active.
1158
+ # @return [MCPClient::Task]
1159
+ def cancelled_task_handle(task, task_id, srv, epoch = nil)
1160
+ return task if task.is_a?(MCPClient::Task) && task.server.equal?(srv)
1161
+
1162
+ MCPClient::Task.new(task_id: task_id, status: 'working', server: srv, modern: true, session_epoch: epoch)
1163
+ end
1164
+ end
1165
+ end
1166
+ end