ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,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