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