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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|