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,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative '../session_pin'
|
|
5
|
+
|
|
6
|
+
module MCPClient
|
|
7
|
+
class Client
|
|
8
|
+
# Which task a task id names, over the life of one server session.
|
|
9
|
+
#
|
|
10
|
+
# A task id is unique within a session, so a server that answers with an
|
|
11
|
+
# id it has already handed out has ended that task and started another:
|
|
12
|
+
# every CreateTaskResult begins a lifetime of its own, and a wait, a hold,
|
|
13
|
+
# an answer or a cancel of one lifetime never reaches another. A lifetime
|
|
14
|
+
# is a number drawn from the session's own counter, never reused and
|
|
15
|
+
# never restarted, so two lifetimes stay distinguishable even after the
|
|
16
|
+
# counters of the ids created longest ago are pruned.
|
|
17
|
+
#
|
|
18
|
+
# A request is bound to the lifetime it is about (see {#task_lifetime_pin})
|
|
19
|
+
# rather than cleared by a preflight: the binding is checked at the wire,
|
|
20
|
+
# where a CreateTaskResult a concurrent call records is already visible,
|
|
21
|
+
# and again before the answer is acted on. Mixed into {TaskRegistry},
|
|
22
|
+
# whose task states and in-flight key registry it reads to decide what a
|
|
23
|
+
# prune may forget.
|
|
24
|
+
module TaskLifetimes
|
|
25
|
+
# How many task ids one client keeps a lifetime counter for. The counter
|
|
26
|
+
# has to outlive the task's own bookkeeping — that is the point: a
|
|
27
|
+
# creation under an id this client has already seen is a different task,
|
|
28
|
+
# whether or not anything of the previous one is still around — so on a
|
|
29
|
+
# sessionless connection, where the epoch never moves and nothing else
|
|
30
|
+
# prunes it, the map would otherwise grow with every task ever created.
|
|
31
|
+
# It bounds the ids of tasks that are over: a task still running keeps
|
|
32
|
+
# its lifetime however many ids follow it (see {#prune_task_lifetimes}).
|
|
33
|
+
MAX_TRACKED_TASK_LIFETIMES = 4096
|
|
34
|
+
# How far a prune goes once the cap is passed: dropping a batch keeps the
|
|
35
|
+
# scan amortized instead of walking the map on every creation.
|
|
36
|
+
TRACKED_TASK_LIFETIMES_LOW_WATER = (MAX_TRACKED_TASK_LIFETIMES * 3) / 4
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Which task the id names now, as a number of its session's counter.
|
|
41
|
+
# @param lookup [Array] server, session epoch and task id
|
|
42
|
+
# @return [Integer, nil] nil for an id no creation is on the books for
|
|
43
|
+
# (never seen, or crowded out of the counter map)
|
|
44
|
+
def task_lifetime(lookup)
|
|
45
|
+
@task_lifetimes&.fetch(lookup, nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Begin the lifetime a CreateTaskResult just created, and answer with
|
|
49
|
+
# it. A task id is unique within a session, so a server that answers
|
|
50
|
+
# with an id it has already handed out in this session has ended that
|
|
51
|
+
# task and started another: the bookkeeping of the previous lifetime is
|
|
52
|
+
# dropped and the id's lifetime moves, which gives the new task an
|
|
53
|
+
# answered set, an in-flight registry entry and a pending update
|
|
54
|
+
# entirely of its own. Without the move, an input key a handler of the
|
|
55
|
+
# previous task is still presenting would be suppressed as already in
|
|
56
|
+
# flight on the new one, and that handler's answer would be delivered to
|
|
57
|
+
# the new task.
|
|
58
|
+
#
|
|
59
|
+
# Every creation this client observed counts, whether or not anything of
|
|
60
|
+
# the previous one is still around: a second CreateTaskResult that
|
|
61
|
+
# arrives before any wait allocated state, or after a terminal poll (or
|
|
62
|
+
# a TTL expiry) forgot it, is just as much a different task, and two
|
|
63
|
+
# handles that named the same lifetime would let the older one update or
|
|
64
|
+
# cancel the task that replaced it.
|
|
65
|
+
#
|
|
66
|
+
# The lifetime is returned rather than read back afterwards: two
|
|
67
|
+
# concurrent creations of one id take this lock one after the other, and
|
|
68
|
+
# a second reading would stamp both handles with the later of the two.
|
|
69
|
+
# @return [Integer] the lifetime this creation started
|
|
70
|
+
def start_task_lifetime(srv, task_id, epoch)
|
|
71
|
+
answered_keys_mutex.synchronize do
|
|
72
|
+
lookup = [srv.object_id, registry_epoch(srv, epoch), task_id]
|
|
73
|
+
@task_states ||= {}
|
|
74
|
+
drop_ended_session_state(lookup)
|
|
75
|
+
lifetimes = (@task_lifetimes ||= {})
|
|
76
|
+
generation = next_task_lifetime(lookup)
|
|
77
|
+
# Re-inserted, so the ids created most recently are the last a prune
|
|
78
|
+
# reaches (a Hash keeps a rewritten key where it was).
|
|
79
|
+
lifetimes.delete(lookup)
|
|
80
|
+
lifetimes[lookup] = generation
|
|
81
|
+
# The task exists from here until something says it is over, and its
|
|
82
|
+
# own bookkeeping — never the replaced task's — says so: a live
|
|
83
|
+
# entry is what keeps the prune below off the lifetime the handle
|
|
84
|
+
# this creation produces names.
|
|
85
|
+
@task_states[lookup] = new_task_state(lookup)
|
|
86
|
+
prune_task_lifetimes(lifetimes)
|
|
87
|
+
generation
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The next lifetime number of a server session: one counter for the
|
|
92
|
+
# whole session rather than one per id, so a number is never handed out
|
|
93
|
+
# twice in it and a task id crowded out of the map (see
|
|
94
|
+
# {#prune_task_lifetimes}) cannot be created again at a number some
|
|
95
|
+
# handle of the pruned lifetime still names.
|
|
96
|
+
# @param lookup [Array] server, session epoch and task id
|
|
97
|
+
# @return [Integer] (callers hold answered_keys_mutex)
|
|
98
|
+
def next_task_lifetime(lookup)
|
|
99
|
+
counters = (@task_lifetime_counters ||= {})
|
|
100
|
+
session = lookup.first(2)
|
|
101
|
+
counter = counters[session] || 0
|
|
102
|
+
counters[session] = counter + 1
|
|
103
|
+
counter
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A previous session of this server is over: the lifetimes counted in it
|
|
107
|
+
# go too, the session's counter with them — the epoch alone already
|
|
108
|
+
# separates them from the next session's.
|
|
109
|
+
# @return [void] (callers hold answered_keys_mutex)
|
|
110
|
+
def drop_ended_lifetimes(lookup)
|
|
111
|
+
@task_lifetimes&.delete_if { |other, _| other[0] == lookup[0] && other[1] < lookup[1] }
|
|
112
|
+
@task_lifetime_counters&.delete_if { |other, _| other[0] == lookup[0] && other[1] < lookup[1] }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Forget the lifetimes of the task ids created longest ago, once more of
|
|
116
|
+
# them are kept than {MAX_TRACKED_TASK_LIFETIMES}. A handle of a
|
|
117
|
+
# forgotten lifetime is refused rather than let through (see
|
|
118
|
+
# {#check_task_lifetime_locked!}): the session's counter never restarts,
|
|
119
|
+
# so a later creation under a pruned id is a number of its own and can
|
|
120
|
+
# never read as the lifetime such a handle names.
|
|
121
|
+
#
|
|
122
|
+
# Only the lifetimes of tasks this client no longer tracks are
|
|
123
|
+
# forgotten. A creation records the task's bookkeeping (see
|
|
124
|
+
# {#start_task_lifetime}) and a terminal poll, a cancellation, a TTL
|
|
125
|
+
# expiry or a `TaskNotFound` drops it again, so what the cap bounds is
|
|
126
|
+
# the ids of tasks that have ended — never a handle whose task is still
|
|
127
|
+
# running, whose absence from the registry the client has no business
|
|
128
|
+
# reading as the task being over. An id whose keys a handler is still
|
|
129
|
+
# presenting stays too: an abandoned handler outlives the bookkeeping,
|
|
130
|
+
# and its holds name the lifetime they belong to.
|
|
131
|
+
# @return [void] (callers hold answered_keys_mutex)
|
|
132
|
+
def prune_task_lifetimes(lifetimes)
|
|
133
|
+
return if lifetimes.size <= MAX_TRACKED_TASK_LIFETIMES
|
|
134
|
+
|
|
135
|
+
# A snapshot: the entries are deleted as the scan walks them.
|
|
136
|
+
tracked = lifetimes.keys
|
|
137
|
+
tracked.each do |lookup|
|
|
138
|
+
break if lifetimes.size <= TRACKED_TASK_LIFETIMES_LOW_WATER
|
|
139
|
+
next if @task_states&.key?(lookup) || presenting_earlier_lifetime?(lookup)
|
|
140
|
+
|
|
141
|
+
lifetimes.delete(lookup)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Whether a handler is still presenting the input keys of some lifetime
|
|
146
|
+
# of this task id (an abandoned handler outlives its state).
|
|
147
|
+
# @return [Boolean] (callers hold answered_keys_mutex)
|
|
148
|
+
def presenting_earlier_lifetime?(lookup)
|
|
149
|
+
return false unless @in_flight_keys
|
|
150
|
+
|
|
151
|
+
@in_flight_keys.any? { |key, held| key.first(3) == lookup && !held.empty? }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Whether the bookkeeping a request was built from is still what its
|
|
155
|
+
# task id names: a CreateTaskResult that handed the id out again since
|
|
156
|
+
# started a different task, and the request belongs to the previous one.
|
|
157
|
+
# @param state [Hash] the bookkeeping the request captured
|
|
158
|
+
# @return [Boolean]
|
|
159
|
+
def task_lifetime_current?(state)
|
|
160
|
+
answered_keys_mutex.synchronize { task_lifetime(state[:lookup]) == state[:generation] }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The lifetime a handle refreshed from another one keeps: the one the
|
|
164
|
+
# source handle named, when that handle is this server's. A request
|
|
165
|
+
# that named its task with a bare id (or with another server's handle)
|
|
166
|
+
# asks about whatever the id means now and produces a handle that says
|
|
167
|
+
# the same, exactly as before — but a handle built from one that named
|
|
168
|
+
# a definite task must keep naming it, or a later creation under the id
|
|
169
|
+
# would silently move it to the task that replaced it.
|
|
170
|
+
# @param task [Object] what the caller named the task with
|
|
171
|
+
# @return [Integer, nil]
|
|
172
|
+
def handle_task_generation(task, srv)
|
|
173
|
+
return nil unless task.is_a?(MCPClient::Task) && task.server.equal?(srv)
|
|
174
|
+
|
|
175
|
+
task.task_generation
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Bind a request to the task it is about, and refuse it here and now if
|
|
179
|
+
# that task is already gone.
|
|
180
|
+
#
|
|
181
|
+
# A handle from a creation names a definite task: the request is for
|
|
182
|
+
# that lifetime and no other, and the pin holds the transport to it at
|
|
183
|
+
# the wire (see {#pinned_to_lifetime}) as well as the answer to it
|
|
184
|
+
# afterwards (see {#verify_task_lifetime!}). A bare id, a handle from
|
|
185
|
+
# another server and a handle that never came from a creation name
|
|
186
|
+
# whatever the id means now and are let through, exactly as before —
|
|
187
|
+
# their pin only records which lifetime that was, so what the request
|
|
188
|
+
# forgets on the way out is that lifetime's and never a replacement's.
|
|
189
|
+
# @param task [Object] what the caller named the task with
|
|
190
|
+
# @param task_id [String] the id the request carries
|
|
191
|
+
# @param epoch [Integer, nil] the session the request is pinned to
|
|
192
|
+
# @param operation [String] for the error message ('updating', 'waiting for')
|
|
193
|
+
# @return [Hash] the pin
|
|
194
|
+
# @raise [MCPClient::Errors::TaskReplacedError] if the handle's task was replaced
|
|
195
|
+
def task_lifetime_pin(task, task_id, srv, epoch, operation)
|
|
196
|
+
named = handle_task_generation(task, srv)
|
|
197
|
+
pin = { lookup: [srv.object_id, registry_epoch(srv, epoch), task_id], generation: named, named: !named.nil?,
|
|
198
|
+
task_id: task_id, operation: operation }
|
|
199
|
+
answered_keys_mutex.synchronize do
|
|
200
|
+
pin[:generation] = task_lifetime(pin[:lookup]) unless pin[:named]
|
|
201
|
+
check_task_lifetime_locked!(pin)
|
|
202
|
+
end
|
|
203
|
+
pin
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Run the block with the transport refusing to write once the task id
|
|
207
|
+
# names another task than the request is about. The check happens where
|
|
208
|
+
# the session pin's does — immediately before the wire, so a
|
|
209
|
+
# CreateTaskResult a concurrent call records while this request is being
|
|
210
|
+
# built (or while its session is being established) is seen, and the
|
|
211
|
+
# request is not sent for a task that is already gone. A transport that
|
|
212
|
+
# knows no write guard sends as before.
|
|
213
|
+
# @param pin [Hash, nil] the request's lifetime pin
|
|
214
|
+
# @return [Object] the block's value
|
|
215
|
+
def pinned_to_lifetime(srv, pin, &block)
|
|
216
|
+
return block.call if pin.nil? || !pin[:named] || !srv.respond_to?(:guarded_writes)
|
|
217
|
+
|
|
218
|
+
srv.guarded_writes(-> { check_task_lifetime!(pin) }, &block)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The answer of a request is acted on only while the task it named is
|
|
222
|
+
# still what its id names: a creation that landed while the answer was
|
|
223
|
+
# in flight makes the answer the previous task's, and the handle,
|
|
224
|
+
# the delivered result and the bookkeeping cleanup it drives would all
|
|
225
|
+
# be about a task that is gone.
|
|
226
|
+
# @param pin [Hash, nil] the request's lifetime pin
|
|
227
|
+
# @return [void]
|
|
228
|
+
# @raise [MCPClient::Errors::TaskReplacedError]
|
|
229
|
+
def verify_task_lifetime!(pin)
|
|
230
|
+
check_task_lifetime!(pin) if pin
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @return [void]
|
|
234
|
+
# @raise [MCPClient::Errors::TaskReplacedError]
|
|
235
|
+
def check_task_lifetime!(pin)
|
|
236
|
+
answered_keys_mutex.synchronize { check_task_lifetime_locked!(pin) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# {#check_task_lifetime!} for a caller already holding the registry lock.
|
|
240
|
+
# @return [void] (callers hold answered_keys_mutex)
|
|
241
|
+
# @raise [MCPClient::Errors::TaskReplacedError]
|
|
242
|
+
def check_task_lifetime_locked!(pin)
|
|
243
|
+
return unless pin[:named]
|
|
244
|
+
|
|
245
|
+
current = task_lifetime(pin[:lookup])
|
|
246
|
+
return if current == pin[:generation]
|
|
247
|
+
|
|
248
|
+
raise MCPClient::Errors::TaskReplacedError, replaced_task_message(pin, current)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @param current [Integer, nil] the lifetime the id names now
|
|
252
|
+
# @return [String]
|
|
253
|
+
def replaced_task_message(pin, current)
|
|
254
|
+
shown = shown_task_id(pin[:task_id])
|
|
255
|
+
if current.nil?
|
|
256
|
+
# Crowded out of the counter map (or forgotten with the client's
|
|
257
|
+
# state): what the id names now cannot be told from what the handle
|
|
258
|
+
# names, so the request is refused rather than sent on a guess.
|
|
259
|
+
return "Error #{pin[:operation]} task '#{shown}': this client no longer tracks the task this handle " \
|
|
260
|
+
'names (the session created task ids enough to crowd it out), so the request is refused rather ' \
|
|
261
|
+
'than sent for whatever answers to the id now'
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
"Error #{pin[:operation]} task '#{shown}': the server has created a new task with this id since, so the " \
|
|
265
|
+
'task this handle names was replaced and is gone'
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'task_lifetimes'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
class Client
|
|
7
|
+
# The per-task bookkeeping of the tasks extension: the answered and
|
|
8
|
+
# in-flight input keys, the rounds spent and the pending update of each
|
|
9
|
+
# task, all keyed by the server session they belong to. Task ids are per
|
|
10
|
+
# session and reusable, so every entry dies with its session — and every
|
|
11
|
+
# request that names a task carries the session it is about. Inside one
|
|
12
|
+
# session an id is reusable too: every CreateTaskResult starts a fresh
|
|
13
|
+
# lifetime under it, and a wait, a hold or an answer of one lifetime never
|
|
14
|
+
# reaches another — which lifetime a task id names, and which one a
|
|
15
|
+
# request is bound to, lives in {TaskLifetimes}. Mixed into
|
|
16
|
+
# {MCPClient::Client} through {TaskSupport}.
|
|
17
|
+
module TaskRegistry
|
|
18
|
+
# Which task a reusable id names, and how a request is bound to it.
|
|
19
|
+
include TaskLifetimes
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Per-task bookkeeping shared by every wait on the task: the answered
|
|
24
|
+
# keys, the input rounds spent, an update still to be delivered and
|
|
25
|
+
# the lock that serializes its updates. Task ids are per server
|
|
26
|
+
# session (a restarted stdio process may reuse them), so the state is
|
|
27
|
+
# keyed by the transport's session epoch too and dies with it. The
|
|
28
|
+
# epoch is read under the lock and never runs backwards: a caller
|
|
29
|
+
# that read it before a restart gets the current session's state and
|
|
30
|
+
# cannot delete it or bring an older session back.
|
|
31
|
+
# @return [Hash]
|
|
32
|
+
def task_state(srv, task_id)
|
|
33
|
+
answered_keys_mutex.synchronize { task_state_locked(srv, task_id, current_session_epoch(srv)) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# {#task_state} for a caller already holding the registry lock and the
|
|
37
|
+
# epoch it read under it.
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def task_state_locked(srv, task_id, epoch)
|
|
40
|
+
@task_states ||= {}
|
|
41
|
+
lookup = [srv.object_id, epoch, task_id]
|
|
42
|
+
drop_ended_session_state(lookup)
|
|
43
|
+
@task_states[lookup] ||= new_task_state(lookup)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A task's bookkeeping, stamped with the lifetime of its id (see
|
|
47
|
+
# {#start_task_lifetime}). The state carries both keys: `lookup`, under
|
|
48
|
+
# which the live task of an id is found, so a request that captured the
|
|
49
|
+
# state can drop exactly what it was working on (see #forget_task_keys)
|
|
50
|
+
# and never a later lifetime of the same id; and `key`, which names the
|
|
51
|
+
# lifetime itself and under which the in-flight holds of a running
|
|
52
|
+
# handler live, so two lifetimes of one id never share them.
|
|
53
|
+
# @param lookup [Array] server, session epoch and task id
|
|
54
|
+
# @return [Hash] (callers hold answered_keys_mutex)
|
|
55
|
+
def new_task_state(lookup)
|
|
56
|
+
generation = task_lifetime(lookup)
|
|
57
|
+
{ key: [*lookup, generation], lookup: lookup, generation: generation, answered: Set.new,
|
|
58
|
+
submitted: Set.new, rounds: 0, pending_update: nil, update_mutex: Mutex.new,
|
|
59
|
+
answer_seq: 0, pending_at: {} }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A previous session of this server is over: its state (answered keys,
|
|
63
|
+
# pending answers) and the id lifetimes counted in it are dropped, not
|
|
64
|
+
# left behind — the epoch alone already separates them from the next
|
|
65
|
+
# session's.
|
|
66
|
+
# @return [void] (callers hold answered_keys_mutex)
|
|
67
|
+
def drop_ended_session_state(lookup)
|
|
68
|
+
@task_states.delete_if { |other, _| other[0] == lookup[0] && other[1] < lookup[1] }
|
|
69
|
+
drop_ended_lifetimes(lookup)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Forget the bookkeeping of the sessions a cleanup ended.
|
|
73
|
+
#
|
|
74
|
+
# Ending a connection is not ending a session — a 2026-07-28 HTTP
|
|
75
|
+
# transport is sessionless, and its tasks outlive a cleanup — and the
|
|
76
|
+
# bookkeeping of a task that survives survives with it: the keys it has
|
|
77
|
+
# already answered are still answered (a wait resumed after the cleanup
|
|
78
|
+
# would otherwise put the same input request to the host a second time)
|
|
79
|
+
# and an update the server never acknowledged is still owed to it. What
|
|
80
|
+
# a session that did end left behind goes now rather than on the next
|
|
81
|
+
# lookup, so a cleanup still releases what nothing can reach.
|
|
82
|
+
#
|
|
83
|
+
# What an id names is not bookkeeping and does not go with either. The
|
|
84
|
+
# lifetimes and, above all, the session counters that number them stay:
|
|
85
|
+
# restarting a counter would hand a task created after the cleanup the
|
|
86
|
+
# number a handle from before it still names, and that handle would then
|
|
87
|
+
# update or cancel the task that replaced its own. A session that does
|
|
88
|
+
# end takes its lifetimes with it as before (see
|
|
89
|
+
# {#drop_ended_session_state}), and the ids of tasks nothing tracks any
|
|
90
|
+
# more are pruned (see {#prune_task_lifetimes}), so what is kept stays
|
|
91
|
+
# bounded.
|
|
92
|
+
# @return [void]
|
|
93
|
+
def clear_task_states
|
|
94
|
+
answered_keys_mutex.synchronize do
|
|
95
|
+
# Read after the transports cleaned up: a session the cleanup ended
|
|
96
|
+
# has moved the epoch by now, and its state is what goes.
|
|
97
|
+
live = servers.to_h { |srv| [srv.object_id, current_session_epoch(srv)] }
|
|
98
|
+
@task_states&.delete_if { |lookup, _| live[lookup[0]] != lookup[1] }
|
|
99
|
+
# The keys a handler is still presenting belong to a lifetime of a
|
|
100
|
+
# session, and are kept exactly as long as that session is.
|
|
101
|
+
@in_flight_keys&.delete_if { |key, _| live[key[0]] != key[1] }
|
|
102
|
+
@task_states = nil if @task_states.is_a?(Hash) && @task_states.empty?
|
|
103
|
+
@in_flight_keys = nil if @in_flight_keys.is_a?(Hash) && @in_flight_keys.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The server's session epoch as this client knows it, monotonic: the
|
|
108
|
+
# highest value ever read wins, so a stale reading never reopens an
|
|
109
|
+
# ended session. Callers hold answered_keys_mutex or do not care about
|
|
110
|
+
# a concurrent bump (the next task_state resolves it).
|
|
111
|
+
# @return [Integer]
|
|
112
|
+
def current_session_epoch(srv)
|
|
113
|
+
read = srv.respond_to?(:session_epoch) ? srv.session_epoch : 0
|
|
114
|
+
@task_session_epochs ||= {}.compare_by_identity
|
|
115
|
+
seen = @task_session_epochs[srv] || 0
|
|
116
|
+
@task_session_epochs[srv] = [read, seen].max
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The session an explicit request for a task handle belongs to. Task
|
|
120
|
+
# ids are per session and reusable, so a handle a host kept across a
|
|
121
|
+
# restart names a task that no longer exists: the request is refused
|
|
122
|
+
# rather than sent into the session that replaced it, where the same id
|
|
123
|
+
# may name something else. A bare task id, a handle from another
|
|
124
|
+
# server, or a server that reports no session names whatever the live
|
|
125
|
+
# session knows and carries no pin.
|
|
126
|
+
# @param task [Object] what the caller named the task with
|
|
127
|
+
# @param operation [String] for the error message ('updating', 'cancelling')
|
|
128
|
+
# @return [Integer, nil] the epoch to pin the request to
|
|
129
|
+
# @raise [MCPClient::Errors::TaskError] if the handle's session has ended
|
|
130
|
+
def handle_session_epoch(task, srv, operation)
|
|
131
|
+
return nil unless task.is_a?(MCPClient::Task) && task.server.equal?(srv) && task.session_epoch
|
|
132
|
+
|
|
133
|
+
epoch = task.session_epoch
|
|
134
|
+
return epoch if answered_keys_mutex.synchronize { current_session_epoch(srv) } == epoch
|
|
135
|
+
|
|
136
|
+
raise MCPClient::Errors::TaskError,
|
|
137
|
+
"Error #{operation} task '#{shown_task_id(task.task_id)}': the server session it belongs to has " \
|
|
138
|
+
'ended, so the task is gone (a restarted server may reuse its id for an unrelated task)'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The session a task request that named its task with a bare id belongs
|
|
142
|
+
# to: the one live when the caller asked. Task ids are per session and
|
|
143
|
+
# reusable, so a request that is only written (or replayed, after an
|
|
144
|
+
# HTTP 404 restarted the session) once the session has moved would
|
|
145
|
+
# read, cancel or answer whatever the replacement session named with
|
|
146
|
+
# the same id. A server that reports no session carries no pin.
|
|
147
|
+
# @return [Integer, nil] the epoch to pin the request to
|
|
148
|
+
def invocation_session_epoch(srv)
|
|
149
|
+
return nil unless srv.respond_to?(:session_epoch)
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
# The session comes up before it is sampled: the transports connect
|
|
153
|
+
# lazily inside the request itself, and that first connection ends
|
|
154
|
+
# the session the epoch was read in — a pin taken before it would
|
|
155
|
+
# refuse the very request that establishes the session.
|
|
156
|
+
establish_session(srv)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
# Not this method's failure to report: the request that follows
|
|
159
|
+
# sends (and fails) exactly as it would have.
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
answered_keys_mutex.synchronize { current_session_epoch(srv) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The epoch a request's bookkeeping is keyed by: the session it was
|
|
166
|
+
# pinned to, or — for a request that carries no pin, e.g. on a
|
|
167
|
+
# transport that reports no session — the one live now. A transport
|
|
168
|
+
# without a session epoch reports 0 from every lookup path, so a nil
|
|
169
|
+
# pin must resolve to that same key and never to a key of its own.
|
|
170
|
+
# @param epoch [Integer, nil] the session the request is pinned to
|
|
171
|
+
# @return [Integer]
|
|
172
|
+
def registry_epoch(srv, epoch)
|
|
173
|
+
epoch.nil? ? current_session_epoch(srv) : epoch
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @return [Array] where a task id's live bookkeeping is found in the
|
|
177
|
+
# session live at this call (callers hold answered_keys_mutex)
|
|
178
|
+
def task_state_lookup(srv, task_id)
|
|
179
|
+
[srv.object_id, current_session_epoch(srv), task_id]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [Array] the registry key of the current lifetime of a task id,
|
|
183
|
+
# under which its in-flight holds live (callers hold answered_keys_mutex)
|
|
184
|
+
def task_state_key(srv, task_id)
|
|
185
|
+
lookup = task_state_lookup(srv, task_id)
|
|
186
|
+
[*lookup, task_lifetime(lookup)]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [Mutex] guards the answered-key registry (request threads share the client)
|
|
190
|
+
def answered_keys_mutex
|
|
191
|
+
@answered_keys_mutex ||= Mutex.new
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Drop a task's bookkeeping: it is terminal, cancelled, gone or past
|
|
195
|
+
# its TTL, so nothing of it may colour a later task with the same id.
|
|
196
|
+
# @param state [Hash, nil] the bookkeeping the caller was working on;
|
|
197
|
+
# only that very state is dropped, so a request abandoned on the
|
|
198
|
+
# wait's wall clock cannot, on its late completion, wipe what a new
|
|
199
|
+
# session — or a new lifetime of a reused task id — has recorded
|
|
200
|
+
# since. Without one, the state of the session the request was about
|
|
201
|
+
# is dropped (the current one when that session is not known either).
|
|
202
|
+
# @param epoch [Integer, nil] the session the request that reported the
|
|
203
|
+
# task gone (or terminal) was pinned to
|
|
204
|
+
# @param pin [Hash, nil] the lifetime the request was bound to, when no
|
|
205
|
+
# state was captured: a creation that landed while the answer was in
|
|
206
|
+
# flight made the task the answer describes a past one, and what the
|
|
207
|
+
# id keys now belongs to the task that replaced it — a live occupancy
|
|
208
|
+
# a terminal (or missing) answer about its predecessor must not wipe
|
|
209
|
+
# @return [void]
|
|
210
|
+
def forget_task_keys(srv, task_id, state: nil, epoch: nil, pin: nil)
|
|
211
|
+
answered_keys_mutex.synchronize do
|
|
212
|
+
unless state
|
|
213
|
+
lookup = epoch.nil? ? task_state_lookup(srv, task_id) : [srv.object_id, epoch, task_id]
|
|
214
|
+
next if pin && task_lifetime(lookup) != pin[:generation]
|
|
215
|
+
|
|
216
|
+
@task_states&.delete(lookup)
|
|
217
|
+
next
|
|
218
|
+
end
|
|
219
|
+
next unless @task_states && @task_states[state[:lookup]].equal?(state)
|
|
220
|
+
|
|
221
|
+
@task_states.delete(state[:lookup])
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
NO_KEYS = Set.new.freeze
|
|
226
|
+
private_constant :NO_KEYS
|
|
227
|
+
|
|
228
|
+
# Keys a running handler presents, kept apart from the task's
|
|
229
|
+
# bookkeeping so no forget lets a retry present them again meanwhile.
|
|
230
|
+
# A read allocates nothing; the reservation that holds keys asks for
|
|
231
|
+
# the set to be created and receives its registry key with it.
|
|
232
|
+
# @param key [Array, nil] the registry key to use; without one the
|
|
233
|
+
# current lifetime's is resolved (a caller working on the state a wait
|
|
234
|
+
# captured passes that state's key, so neither a restart nor a task id
|
|
235
|
+
# handed out again can move its reservation to what replaced it)
|
|
236
|
+
# @return [Set<String>, Array(Set<String>, Array)] (callers hold answered_keys_mutex)
|
|
237
|
+
def in_flight_task_keys(srv, task_id, create: false, key: nil)
|
|
238
|
+
key ||= task_state_key(srv, task_id)
|
|
239
|
+
return [(@in_flight_keys ||= {})[key] ||= Set.new, key] if create
|
|
240
|
+
|
|
241
|
+
@in_flight_keys&.fetch(key, nil) || NO_KEYS
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Drop a registry entry once its own set is empty; another task's or
|
|
245
|
+
# session's entry is never touched.
|
|
246
|
+
# @return [void] (callers hold answered_keys_mutex)
|
|
247
|
+
def release_in_flight_entry(held, held_key)
|
|
248
|
+
return unless held.empty? && @in_flight_keys && @in_flight_keys[held_key].equal?(held)
|
|
249
|
+
|
|
250
|
+
@in_flight_keys.delete(held_key)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../task'
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
|
|
6
|
+
module MCPClient
|
|
7
|
+
class Client
|
|
8
|
+
# The shape checks of MCP 2026-07-28 task payloads (Task, DetailedTask
|
|
9
|
+
# and the payload a status implies), shared by the task support.
|
|
10
|
+
module TaskShape
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# A modern tasks/get result is a DetailedTask: every field the Task
|
|
14
|
+
# shape requires must be there (ttlMs may be null but must be
|
|
15
|
+
# present), since a defaulted status or a missing TTL would drive the
|
|
16
|
+
# wait on made-up state.
|
|
17
|
+
# @param result [Object] the raw tasks/get result
|
|
18
|
+
# @return [void]
|
|
19
|
+
# @raise [MCPClient::Errors::InvalidResultError]
|
|
20
|
+
def validate_detailed_task_shape!(result)
|
|
21
|
+
problem = detailed_task_shape_problem(result)
|
|
22
|
+
raise MCPClient::Errors::InvalidResultError, "Invalid tasks/get result: #{problem}" if problem
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] what is wrong with a DetailedTask's shape
|
|
26
|
+
def detailed_task_shape_problem(result)
|
|
27
|
+
task_shape_problem(result) || task_status_payload_problem(result)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The fields every Task carries (CreateTaskResult and DetailedTask
|
|
31
|
+
# alike): a status, parseable timestamps, a ttlMs key and, when the
|
|
32
|
+
# server offers one, a usable pollIntervalMs.
|
|
33
|
+
# @param result [Object]
|
|
34
|
+
# @return [String, nil]
|
|
35
|
+
def task_shape_problem(result)
|
|
36
|
+
return 'not an object' unless result.is_a?(Hash)
|
|
37
|
+
return 'taskId is not a string' unless result['taskId'].is_a?(String)
|
|
38
|
+
return 'status is not a task status' unless MCPClient::Task::VALID_STATUSES.include?(result['status'])
|
|
39
|
+
|
|
40
|
+
problem = task_timestamp_problem(result)
|
|
41
|
+
return problem if problem
|
|
42
|
+
return 'ttlMs is missing' unless result.key?('ttlMs')
|
|
43
|
+
return 'ttlMs is not an integer or null' unless result['ttlMs'].nil? || result['ttlMs'].is_a?(Integer)
|
|
44
|
+
|
|
45
|
+
task_poll_interval_problem(result)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# pollIntervalMs is the pace the client is asked to keep ("Clients
|
|
49
|
+
# SHOULD respect the pollIntervalMs provided in responses"), so a
|
|
50
|
+
# malformed one is a malformed task rather than a hint to ignore:
|
|
51
|
+
# silently falling back to the default would poll at a rate the server
|
|
52
|
+
# never asked for. An absent field (and an explicit null) asks for
|
|
53
|
+
# nothing and is not a problem.
|
|
54
|
+
# @return [String, nil]
|
|
55
|
+
def task_poll_interval_problem(result)
|
|
56
|
+
interval = result['pollIntervalMs']
|
|
57
|
+
return nil if interval.nil?
|
|
58
|
+
return nil if interval.is_a?(Integer) && !interval.negative?
|
|
59
|
+
|
|
60
|
+
'pollIntervalMs is not a non-negative integer'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The timestamps of a Task are ISO 8601; one that does not parse could
|
|
64
|
+
# never bound a wait, so it is not a task at all.
|
|
65
|
+
# @return [String, nil]
|
|
66
|
+
def task_timestamp_problem(result)
|
|
67
|
+
%w[createdAt lastUpdatedAt].each do |field|
|
|
68
|
+
return "#{field} is not a string" unless result[field].is_a?(String)
|
|
69
|
+
return "#{field} is not an ISO 8601 timestamp" unless iso8601?(result[field])
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def iso8601?(text)
|
|
76
|
+
Time.iso8601(text)
|
|
77
|
+
true
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The payload a DetailedTask's status implies ("If status is completed,
|
|
83
|
+
# result MUST be included; if failed, error; if input_required,
|
|
84
|
+
# inputRequests").
|
|
85
|
+
# @return [String, nil]
|
|
86
|
+
def task_status_payload_problem(result)
|
|
87
|
+
case result['status']
|
|
88
|
+
when 'completed'
|
|
89
|
+
unless MCPClient::Task.complete_result_object?(result['result'])
|
|
90
|
+
'a completed task needs an object result whose resultType, if any, is "complete"'
|
|
91
|
+
end
|
|
92
|
+
when 'failed'
|
|
93
|
+
unless MCPClient::Task.jsonrpc_error_object?(result['error'])
|
|
94
|
+
'a failed task needs a JSON-RPC error object (integer code, string message)'
|
|
95
|
+
end
|
|
96
|
+
when 'input_required'
|
|
97
|
+
'an input_required task needs an inputRequests object' unless result['inputRequests'].is_a?(Hash)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|