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,852 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'subscription/notification_dispatcher'
4
+
5
+ module MCPClient
6
+ # A long-lived notification subscription opened with `subscriptions/listen`
7
+ # (MCP 2026-07-28 basic/patterns/subscriptions).
8
+ #
9
+ # The subscription is identified by the JSON-RPC id of its listen request;
10
+ # every notification delivered on it carries that id in
11
+ # `_meta["io.modelcontextprotocol/subscriptionId"]`. It starts :pending,
12
+ # becomes :active when the server acknowledges it (with the subset of
13
+ # notification types it agreed to honour), and ends :closed — gracefully
14
+ # when the server answers the listen request, otherwise on a transport
15
+ # drop, a server `notifications/cancelled`, an error, or {#close}.
16
+ class Subscription
17
+ # The published SubscriptionFilter's fields and their value types (MCP
18
+ # 2026-07-28 schema, SubscriptionFilter). Nothing beyond these four is a
19
+ # core filter field: an extension that defines one of its own — the tasks
20
+ # extension's `taskIds`, say — registers it with {.register_filter_field}.
21
+ FILTER_FIELDS = {
22
+ 'toolsListChanged' => :boolean,
23
+ 'promptsListChanged' => :boolean,
24
+ 'resourcesListChanged' => :boolean,
25
+ 'resourceSubscriptions' => :string_array
26
+ }.freeze
27
+
28
+ # snake_case spellings accepted for the published filter fields
29
+ FILTER_ALIASES = {
30
+ 'tools_list_changed' => 'toolsListChanged',
31
+ 'prompts_list_changed' => 'promptsListChanged',
32
+ 'resources_list_changed' => 'resourcesListChanged',
33
+ 'resource_subscriptions' => 'resourceSubscriptions'
34
+ }.freeze
35
+
36
+ # The value types a filter field may have
37
+ FILTER_VALUE_TYPES = %i[boolean string_array].freeze
38
+
39
+ class << self
40
+ # Register a filter field an extension defines beyond the published
41
+ # SubscriptionFilter, so {.normalize_filter} accepts it (and its
42
+ # snake_case spelling) with the value type the extension gives it. A
43
+ # published field cannot be redefined; registering the same extension
44
+ # field twice with the same type is a no-op.
45
+ # @param name [String] the camelCase wire name of the field
46
+ # @param type [Symbol] :boolean or :string_array
47
+ # @param alias_name [String, nil] a snake_case spelling to accept for it
48
+ # @return [void]
49
+ # @raise [ArgumentError] on a published field, an unknown type, or a
50
+ # name already registered with another type
51
+ def register_filter_field(name, type, alias_name: nil)
52
+ name = name.to_s
53
+ raise ArgumentError, "#{name} is a published SubscriptionFilter field" if FILTER_FIELDS.key?(name)
54
+ raise ArgumentError, 'a filter field is boolean or string_array' unless FILTER_VALUE_TYPES.include?(type)
55
+
56
+ extension_filter_fields_mutex.synchronize do
57
+ fields = @extension_filter_fields || { fields: {}, aliases: {} }
58
+ registered = fields[:fields][name]
59
+ raise ArgumentError, "#{name} is already registered as #{registered}" if registered && registered != type
60
+
61
+ fields[:fields][name] = type
62
+ fields[:aliases][alias_name.to_s] = name if alias_name
63
+ @extension_filter_fields = fields
64
+ end
65
+ nil
66
+ end
67
+
68
+ # @return [Hash{String => Symbol}] every accepted filter field — the
69
+ # published four and the registered extension fields — with its type
70
+ def filter_fields
71
+ FILTER_FIELDS.merge(extension_filter_fields[:fields]).freeze
72
+ end
73
+
74
+ # @return [Hash{String => String}] every accepted snake_case spelling
75
+ def filter_aliases
76
+ FILTER_ALIASES.merge(extension_filter_fields[:aliases]).freeze
77
+ end
78
+
79
+ private
80
+
81
+ # @return [Hash] the registered extension fields and aliases
82
+ def extension_filter_fields
83
+ extension_filter_fields_mutex.synchronize { @extension_filter_fields || { fields: {}, aliases: {} } }
84
+ end
85
+
86
+ # @return [Mutex] guards the registry across extensions loading concurrently
87
+ def extension_filter_fields_mutex
88
+ @extension_filter_fields_mutex ||= Mutex.new
89
+ end
90
+ end
91
+
92
+ STATES = %i[pending active reconnecting closed].freeze
93
+
94
+ # Ceiling on the notifications waiting for this subscription's listeners.
95
+ # The queue is filled by the peer and drained by the host, so a chatty
96
+ # server and a listener that does real work (re-reading the resource that
97
+ # changed, say) would otherwise grow it without bound.
98
+ #
99
+ # A full queue discards by identity rather than by arrival order, so
100
+ # overflow costs a listener a repeated notice of the same resource or task
101
+ # and never its only notice of one of them; see
102
+ # {MCPClient::Subscription::NotificationDispatcher} for the policy.
103
+ # Blocking the transport reader instead would reinstate the deadlock the
104
+ # dispatcher exists to prevent — the reader would wait for a listener that
105
+ # is waiting for a response only that reader can deliver.
106
+ MAX_PENDING_NOTIFICATIONS = 1024
107
+
108
+ # Ceiling on the bytes the queued notifications retain, because a count is
109
+ # not a memory bound: the method name and params of every queued
110
+ # notification are held until its listener has run, one Streamable HTTP
111
+ # listen event may approach
112
+ # {MCPClient::HttpTransportBase::ListenStream::LISTEN_MAX_BUFFER_BYTES} and
113
+ # a stdio line has no inbound limit at all — so a peer facing a slow
114
+ # listener could put tens of gigabytes behind a nominally bounded queue.
115
+ #
116
+ # This changes when overflow starts, not what it discards: whichever
117
+ # ceiling the arriving notification would breach, the entry that goes is
118
+ # still chosen by identity, and only ever an entry whose removal relieves
119
+ # the breach. A notification larger than the whole budget is not charged
120
+ # against it and is held in a slot of its own, of which there is only ever
121
+ # one — so the retained total is the budget plus at worst one peer-sized
122
+ # payload rather than {MAX_PENDING_NOTIFICATIONS} of them, and no signal
123
+ # is lost to its size or displaced by one.
124
+ MAX_PENDING_NOTIFICATION_BYTES = 8 * 1024 * 1024
125
+
126
+ # The states {#wait_until_settled} waits for: the server has answered the
127
+ # listen request one way or the other.
128
+ SETTLED_STATES = %i[active closed].freeze
129
+
130
+ # @return [Integer, String, nil] the JSON-RPC id of the listen request (nil before it is sent)
131
+ attr_reader :id
132
+ # @return [Hash] the requested SubscriptionFilter (camelCase keys)
133
+ attr_reader :requested
134
+ # @return [Hash, nil] the filter the server agreed to honour, once acknowledged
135
+ attr_reader :acknowledged
136
+ # @return [MCPClient::ServerBase] the transport that owns the subscription
137
+ attr_reader :server
138
+ # @return [Symbol] :pending, :active, :reconnecting or :closed
139
+ attr_reader :state
140
+ # @return [MCPClient::Errors::MCPError, nil] why the subscription failed, if it did
141
+ attr_reader :error
142
+ # @return [String, nil] the reason a server-side teardown gave, if any
143
+ attr_reader :close_reason
144
+ # @return [Numeric, false, nil] the acknowledgment deadline the host asked
145
+ # for; every listen request re-issued for this subscription is bounded by
146
+ # it, not only the first
147
+ attr_reader :ack_timeout
148
+
149
+ # Normalize and validate a SubscriptionFilter given with String or Symbol,
150
+ # camelCase or snake_case keys.
151
+ #
152
+ # The result is detached from the caller and frozen. The filter is not
153
+ # serialized once and forgotten: Streamable HTTP builds the listen request
154
+ # on the stream's own thread, after `listen` has returned, and every
155
+ # reconnect builds it again — so an array the caller kept a reference to
156
+ # would let a later `<<` or a mutated String change the request that goes
157
+ # out, or change what a re-opened stream asks for.
158
+ # @param filter [Hash] the notification filter
159
+ # @return [Hash] camelCase String keys, frozen
160
+ # @raise [ArgumentError] on an unknown key or a mistyped value
161
+ def self.normalize_filter(filter)
162
+ raise ArgumentError, 'notifications must be a Hash (SubscriptionFilter)' unless filter.is_a?(Hash)
163
+
164
+ fields = filter_fields
165
+ aliases = filter_aliases
166
+ filter.to_h do |key, value|
167
+ name = key.to_s
168
+ name = aliases.fetch(name, name)
169
+ type = fields[name]
170
+ raise ArgumentError, "Unknown subscription filter field #{key.inspect}" unless type
171
+
172
+ case type
173
+ when :boolean
174
+ raise ArgumentError, "#{name} must be true or false" unless [true, false].include?(value)
175
+ when :string_array
176
+ raise ArgumentError, "#{name} must be an array of strings" unless value.is_a?(Array) && value.all?(String)
177
+
178
+ value = value.map { |item| item.dup.freeze }.freeze
179
+ end
180
+ [name, value]
181
+ end.freeze
182
+ end
183
+
184
+ # @param server [MCPClient::ServerBase] owning transport
185
+ # @param requested [Hash] normalized filter
186
+ # @param ack_timeout [Numeric, false, nil] the acknowledgment deadline the
187
+ # host asked for, kept for the requests re-issued later (see
188
+ # {MCPClient::SubscriptionSupport#rearm_acknowledgment_deadline}). Set
189
+ # here rather than assigned afterwards: a transport may re-open the
190
+ # stream before `listen` has returned the handle.
191
+ # @yield [method, params] optional listener for notifications on this subscription
192
+ def initialize(server:, requested:, ack_timeout: nil, &listener)
193
+ @server = server
194
+ @requested = requested
195
+ @ack_timeout = ack_timeout
196
+ @listeners = []
197
+ @listeners << listener if listener
198
+ @state = :pending
199
+ @mutex = Mutex.new
200
+ @settled = ConditionVariable.new
201
+ @id = nil
202
+ @acknowledged = nil
203
+ @error = nil
204
+ @close_reason = nil
205
+ @closed_gracefully = false
206
+ @closed_by_client = false
207
+ @dispatcher = nil
208
+ # Whether the server has answered the listen request this subscription
209
+ # is on — or the last one it was on, while no replacement has gone out.
210
+ # Written where those two things happen ({#acknowledge} and the two
211
+ # methods that take a new listen id), never inferred from how far a
212
+ # transport has got through a reconnect.
213
+ @answered = false
214
+ # Whether a transport is handing this subscription to a new session:
215
+ # see {#reestablishing?}.
216
+ @reestablishing = false
217
+ # The listen ids the transport has written for this subscription, and
218
+ # not yet cancelled, each paired with the pipe it was written to: see
219
+ # {#record_outstanding_listen}.
220
+ @outstanding_listens = []
221
+ # Of those, the ones whose write has not finished yet. They are not
222
+ # cancellable: see {#take_outstanding_listens}.
223
+ @unwritten_listens = []
224
+ # The transport generation this subscription's listen went out on: see
225
+ # {#with_open_id}.
226
+ @open_generation = nil
227
+ end
228
+
229
+ # @return [Integer] notifications queued for this subscription's listeners
230
+ def pending_notifications
231
+ dispatcher = @mutex.synchronize { @dispatcher }
232
+ dispatcher ? dispatcher.pending : 0
233
+ end
234
+
235
+ # Bytes retained by the notifications queued for this subscription's
236
+ # listeners, measured as the JSON the peer sent for them — their method
237
+ # names as well as their params (see {MAX_PENDING_NOTIFICATION_BYTES}).
238
+ # @return [Integer]
239
+ def pending_notification_bytes
240
+ dispatcher = @mutex.synchronize { @dispatcher }
241
+ dispatcher ? dispatcher.pending_bytes : 0
242
+ end
243
+
244
+ # Notifications dropped because the listeners could not keep up with the
245
+ # server (see {MAX_PENDING_NOTIFICATIONS} and
246
+ # {MAX_PENDING_NOTIFICATION_BYTES}).
247
+ # @return [Integer]
248
+ def dropped_notifications
249
+ dispatcher = @mutex.synchronize { @dispatcher }
250
+ dispatcher ? dispatcher.dropped : 0
251
+ end
252
+
253
+ # Add a listener for notifications delivered on this subscription.
254
+ # @yield [method, params]
255
+ # @return [self]
256
+ def on_notification(&block)
257
+ @mutex.synchronize { @listeners << block }
258
+ self
259
+ end
260
+
261
+ # @return [Boolean] whether the server acknowledged it and it is still open
262
+ def active?
263
+ @mutex.synchronize { @state == :active }
264
+ end
265
+
266
+ # @return [Boolean]
267
+ def closed?
268
+ @mutex.synchronize { @state == :closed }
269
+ end
270
+
271
+ # @return [Boolean] whether it is waiting for a transport to re-establish
272
+ # it — the stream dropped, or the stdio process it was on exited, and
273
+ # the transport that noticed has queued it for the next session
274
+ def reconnecting?
275
+ @mutex.synchronize { @state == :reconnecting }
276
+ end
277
+
278
+ # @return [Boolean] whether the server ended it with a response to the listen request
279
+ def closed_gracefully?
280
+ @mutex.synchronize { @closed_gracefully }
281
+ end
282
+
283
+ # @return [Boolean] whether {#close} ended it
284
+ def closed_by_client?
285
+ @mutex.synchronize { @closed_by_client }
286
+ end
287
+
288
+ # Requested notification types the server did not agree to honour.
289
+ #
290
+ # Support is read from the value the server acknowledged, not from the
291
+ # mere presence of the field: an acknowledgment that names
292
+ # `resourceSubscriptions` with none of the URIs it was sent has accepted
293
+ # no resource subscription at all, and a flag acknowledged as `false` will
294
+ # not be honoured either. A list the server granted in part counts as
295
+ # supported; see {#unacknowledged_resource_uris} for the URIs it left out.
296
+ # @return [Array<String>] empty until acknowledged
297
+ def unsupported
298
+ ack = @mutex.synchronize { @acknowledged }
299
+ return [] unless ack
300
+
301
+ @requested.keys.reject { |field| granted?(@requested[field], ack[field]) }
302
+ end
303
+
304
+ # Requested resource URIs the server did not agree to watch.
305
+ # @return [Array<String>] empty until acknowledged
306
+ def unacknowledged_resource_uris
307
+ ack = @mutex.synchronize { @acknowledged }
308
+ return [] unless ack
309
+
310
+ wanted = Array(@requested['resourceSubscriptions'])
311
+ granted = ack['resourceSubscriptions'].is_a?(Array) ? ack['resourceSubscriptions'] : []
312
+ wanted - granted
313
+ end
314
+
315
+ # Whether this stream is, right now, an active acknowledged watch of a
316
+ # resource: the server granted that URI and the stream it granted it on is
317
+ # the one still running.
318
+ #
319
+ # Being open is not enough, which is what a `subscribe_resource` looking
320
+ # for a stream to reuse has to know: a stream between listen attempts is
321
+ # serving nothing, and the request that replaces it is a new one the
322
+ # server holds no state for — it may be rejected, or acknowledged more
323
+ # narrowly.
324
+ # @param uri [String] the resource URI
325
+ # @return [Boolean]
326
+ def watching_resource?(uri)
327
+ @mutex.synchronize { @state == :active && acknowledges_resource?(uri) }
328
+ end
329
+
330
+ # Block until the server has answered the listen request this subscription
331
+ # is waiting on, and report what it said about this URI.
332
+ #
333
+ # This is the question the subscriber that *opened* the stream asks: it is
334
+ # waiting for the answer to its own listen request, and it gets it. A
335
+ # connection that merely drops does not unask that question and does not
336
+ # unanswer it — the server did grant the filter, and making the caller
337
+ # wait out its whole acknowledgment timeout for an answer it had already
338
+ # been given would be the transport's problem told as the subscriber's. A
339
+ # replacement request that has actually gone out is another matter: the
340
+ # server holds no subscription state across one and has to grant the
341
+ # filter again, so the answer to it is waited for rather than assumed —
342
+ # which is why {#with_open_id} and {#assign_id} unanswer it explicitly,
343
+ # instead of that turning on whether the reconnect has got as far as
344
+ # taking an id.
345
+ #
346
+ # Waiting is also the right answer for a request in flight with nothing
347
+ # granted yet, which used to read as success: a subscription with no
348
+ # acknowledgment has no unacknowledged URIs either.
349
+ # @param uri [String] the resource URI
350
+ # @param timeout [Numeric] seconds to wait for an answer
351
+ # @return [Symbol] :watching, :not_watching (answered without the URI),
352
+ # :closed, or :timeout
353
+ def await_resource_watch(uri, timeout)
354
+ await_watch(uri, timeout) { @answered }
355
+ end
356
+
357
+ # Block until this subscription is a running watch of the URI, and report
358
+ # whether it is.
359
+ #
360
+ # This is the other question, and the one a `subscribe_resource` looking
361
+ # for a stream to *reuse* asks: not "what did the server say" but "is the
362
+ # server watching this, now". Only a running stream answers it. An
363
+ # acknowledgment left on record by a stream that has dropped is not a
364
+ # grant — no server-side subscription exists between listen attempts, the
365
+ # request that replaces it is a new one the server may reject or
366
+ # acknowledge more narrowly, and reading the old record as the current
367
+ # grant reported a watch for the whole of an HTTP backoff or a stdio
368
+ # handshake. So a stream between attempts is waited for instead.
369
+ # @param uri [String] the resource URI
370
+ # @param timeout [Numeric] seconds to wait for the stream to be granted
371
+ # @return [Symbol] :watching, :not_watching (granted without the URI),
372
+ # :closed, or :timeout
373
+ def await_live_resource_watch(uri, timeout)
374
+ await_watch(uri, timeout) { @state == :active }
375
+ end
376
+
377
+ # Block until the server has settled the subscription: acknowledged it,
378
+ # or ended it with a response, an error or a cancellation.
379
+ # @param timeout [Numeric] seconds to wait
380
+ # @return [Symbol, nil] :active or :closed, nil while it is still pending
381
+ def wait_until_settled(timeout)
382
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
383
+ @mutex.synchronize do
384
+ loop do
385
+ answer = settled_state
386
+ return answer if answer
387
+
388
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
389
+ return nil if remaining <= 0
390
+
391
+ @settled.wait(@mutex, remaining)
392
+ end
393
+ end
394
+ end
395
+
396
+ # Cancel the subscription: the transport closes the stream (HTTP) or
397
+ # sends notifications/cancelled (stdio).
398
+ # @return [MCPClient::Subscription, nil] self if it was open, nil if already closed
399
+ def close
400
+ return nil if closed?
401
+
402
+ @server.cancel_subscription(self)
403
+ self
404
+ end
405
+
406
+ # --- transport-facing state transitions -------------------------------
407
+
408
+ # Put the subscription on a listen id, without the registration and the
409
+ # write {#with_open_id} holds its lock across. No transport opens or
410
+ # re-opens a stream this way — {#with_open_id} is the step every one of
411
+ # them takes — so a guarantee about opening or reconnecting is one this
412
+ # method cannot stand in for.
413
+ # @param id [Integer, String] the listen request id
414
+ # @return [void]
415
+ # @api private
416
+ def assign_id(id)
417
+ @mutex.synchronize do
418
+ @id = id
419
+ @acknowledged = nil
420
+ # A request the server has not seen is a question it has not answered.
421
+ @answered = false
422
+ # A subscription the host closed stays closed, whatever a racing
423
+ # reconnect does.
424
+ @state = :pending unless @state == :closed
425
+ end
426
+ end
427
+
428
+ # Take a fresh listen id and register/send the request under this
429
+ # subscription's own lock, so a concurrent {#close} either wins outright
430
+ # (nothing is sent) or waits and then cancels the id that was sent. A
431
+ # closed subscription is never re-opened.
432
+ # @param id [Integer, String] the new listen request id
433
+ # @yield runs while the id cannot change underneath it
434
+ # @return [Boolean] false when the host had already closed it
435
+ # @api private
436
+ def with_open_id(id, generation = nil)
437
+ @mutex.synchronize do
438
+ return false if @state == :closed
439
+
440
+ @id = id
441
+ @acknowledged = nil
442
+ # The request that went out is a new one: whatever the server said
443
+ # about the last, it has not answered this.
444
+ @answered = false
445
+ @state = :pending
446
+ # Stamped in the same step as the registration, so every registered
447
+ # subscription names the process its listen went out on: a teardown
448
+ # parks the ones belonging to the process it claimed and leaves the
449
+ # replacement's alone (see {MCPClient::ServerStdio#park_open_subscriptions}).
450
+ # A transport with no such notion (the HTTP ones, whose streams are
451
+ # per-connection) passes none and is never asked.
452
+ @open_generation = generation
453
+ yield
454
+ true
455
+ end
456
+ end
457
+
458
+ # The transport generation the listen this subscription is on went out
459
+ # under, or nil on a transport that does not number its processes.
460
+ # @return [Integer, nil]
461
+ # @api private
462
+ def open_generation
463
+ @mutex.synchronize { @open_generation }
464
+ end
465
+
466
+ # Record a listen request the transport has written for this subscription
467
+ # on the session it is on.
468
+ #
469
+ # A cancellation has to name a request the server may be serving, and
470
+ # that is not always the id the subscription happens to be on: a second
471
+ # listen written for it on one session (a hand-over that queued it twice,
472
+ # say) leaves the server holding the first stream, and
473
+ # `notifications/cancelled` for the newest id alone would never close it —
474
+ # the stream stays open until the server's own timeout, with the client
475
+ # unable to name it again.
476
+ #
477
+ # An attempt whose write raised is recorded too: the client cannot know
478
+ # how much of it the peer saw, and cancelling a request the server never
479
+ # received is ignored, while failing to cancel one it did receive is not.
480
+ # Recorded *before* the write for that reason, and marked written by
481
+ # {#mark_listen_written} whichever way the write ends.
482
+ #
483
+ # The pipe it is written to is recorded with it, because forgetting the
484
+ # ids of a process that is gone ({#discard_outstanding_listens}) cannot
485
+ # reach an attempt that has not recorded its id yet. One paused here while
486
+ # its process was torn down recorded afterwards, with nothing left to
487
+ # forget it, and the `close` that followed named it on the process that
488
+ # replaced it — a request that one had never been sent, while
489
+ # "the cancelled request MUST have been previously issued"
490
+ # (basic/patterns/cancellation). Recording the pipe makes the id
491
+ # cancellable on that pipe alone, whenever it is recorded.
492
+ # @param id [Integer, String] the listen request id
493
+ # @param io [IO, nil] the pipe the request is being written to; nil leaves
494
+ # the id cancellable wherever the caller is cancelling
495
+ # @return [void]
496
+ # @api private
497
+ def record_outstanding_listen(id, io = nil)
498
+ @mutex.synchronize do
499
+ @outstanding_listens << [id, io] unless @outstanding_listens.any? { |(known, _)| known == id }
500
+ @unwritten_listens << id unless @unwritten_listens.include?(id)
501
+ end
502
+ end
503
+
504
+ # The write of a listen request has finished — sent, or raised having sent
505
+ # who knows how much. Either way the id may now be cancelled.
506
+ #
507
+ # An id the session that carried it has since discarded
508
+ # ({#discard_outstanding_listens}) stays discarded: nothing written to a
509
+ # process that is gone is outstanding, and a late write that lands on its
510
+ # closed pipe must not put the id back.
511
+ # @param id [Integer, String] the listen request id
512
+ # @return [void]
513
+ # @api private
514
+ def mark_listen_written(id)
515
+ @mutex.synchronize { @unwritten_listens.delete(id) }
516
+ end
517
+
518
+ # The listen ids the server may still be serving for this subscription,
519
+ # leaving none behind: the caller is cancelling them.
520
+ #
521
+ # An id whose write has not finished is not among them, however impatient
522
+ # the caller: "the cancelled request MUST have been previously issued"
523
+ # (basic/patterns/cancellation), and cancelling an id the pipe has not
524
+ # carried yet put `cancelled(n)` on the wire ahead of `listen(n)`. The
525
+ # transport that is writing it cancels it itself once the write is done
526
+ # and it finds the subscription closed — the one moment at which the
527
+ # cancellation can name a request the server has actually been sent.
528
+ #
529
+ # Nor is an id written to a *different* pipe among them: the process on
530
+ # this one was never sent that request (see {#record_outstanding_listen}).
531
+ # Those are left recorded rather than dropped, since the caller that pins
532
+ # a pipe is not always the one that will cancel on the pipe they went to.
533
+ # @param io [IO, nil] cancel only what was written to this pipe; nil takes
534
+ # every written id, and an id recorded against no pipe is taken by any
535
+ # caller
536
+ # @return [Array] the recorded ids that have been written, oldest first
537
+ # @api private
538
+ def take_outstanding_listens(io = nil)
539
+ @mutex.synchronize do
540
+ taken, kept = @outstanding_listens.partition do |(id, recorded_io)|
541
+ !@unwritten_listens.include?(id) && cancellable_on?(recorded_io, io)
542
+ end
543
+ @outstanding_listens = kept
544
+ taken.map(&:first)
545
+ end
546
+ end
547
+
548
+ # Forget the recorded listen ids without cancelling them: the session they
549
+ # were written to is gone, so nothing is outstanding and none of them must
550
+ # be cancelled on the session that replaces it.
551
+ # @return [void]
552
+ # @api private
553
+ def discard_outstanding_listens
554
+ @mutex.synchronize do
555
+ @outstanding_listens = []
556
+ @unwritten_listens = []
557
+ end
558
+ end
559
+
560
+ # Whether this subscription is still the stream a given listen id opened.
561
+ # A transport that fails an attempt asks before undoing it: a restart
562
+ # racing a blocked write may already have re-opened the subscription under
563
+ # a newer id, and that stream is not the older attempt's to tear down.
564
+ # @param id [Integer, String] a listen request id
565
+ # @return [Boolean]
566
+ # @api private
567
+ def open_as?(id)
568
+ @mutex.synchronize { @id == id }
569
+ end
570
+
571
+ # Undo the listen attempt that failed — or find that it is no longer this
572
+ # attempt's to undo — in one step.
573
+ #
574
+ # {#open_as?} used to answer the first half of that question on its own,
575
+ # and a restart could re-open the subscription under a newer id *and*
576
+ # have it acknowledged between the answer and the transition it guarded:
577
+ # the older attempt then finished the very stream the fresh process was
578
+ # serving, with nothing left to cancel it. Asking and acting under one
579
+ # hold of the lock is what makes the answer good for the transition it
580
+ # decides.
581
+ #
582
+ # The three answers are the three things a failed attempt can be:
583
+ # superseded by a newer attempt, which owns the subscription now; a
584
+ # hand-over to a new session that could not be written
585
+ # ({#reestablishing?}), which goes back to waiting for the next one; or
586
+ # the caller's own request, which ends with the error.
587
+ # A hand-over is deferred only when it is the *process* that could not be
588
+ # written to: that process is on its way out, and the next one drains the
589
+ # queue. An attempt that failed before it took an id at all (the request
590
+ # could not be built) says nothing about the process, which stays up and
591
+ # healthy — nothing would ever drain the queue on it, and the previous
592
+ # acknowledgment would keep the watchdog from expiring it: the
593
+ # subscription stayed :reconnecting for ever with the host never told. So
594
+ # that failure is the subscription's own, and it ends with it.
595
+ # @param id [Integer, String, nil] the listen id the attempt sent under;
596
+ # nil for an attempt that failed before it took one (the request could
597
+ # not be built), which no newer attempt can have superseded
598
+ # @param error [MCPClient::Errors::MCPError] why it failed
599
+ # @return [Symbol] :superseded, :deferred (it is :reconnecting again) or
600
+ # :failed (ended with the error — or already closed)
601
+ # @api private
602
+ def fail_attempt(id, error)
603
+ @mutex.synchronize do
604
+ return :superseded if id && @id != id
605
+ return :failed if @state == :closed
606
+
607
+ if @reestablishing && id
608
+ @state = :reconnecting
609
+ return :deferred
610
+ end
611
+
612
+ close_locked(error: error)
613
+ :failed
614
+ end
615
+ end
616
+
617
+ # End the request a deadline was set on, if the subscription is still on
618
+ # that request and the server has still not answered it — in one step.
619
+ #
620
+ # The watchdog that waits out the deadline cannot decide this for itself:
621
+ # by the time its wait returns, a restart may have replaced the request
622
+ # with a newer one (a new id, with a deadline of its own, which the older
623
+ # request's timer must not spend), and an acknowledgment may have landed
624
+ # in the instant between the wait and the verdict, which is the server's
625
+ # answer and is kept.
626
+ # Anyone waiting for the subscription to settle is woken only once the
627
+ # block — the cancellation the transport sends for the expired request —
628
+ # has run: a host that sees the handle settle on a timeout sees a server
629
+ # that has already been told, rather than one the watchdog is still
630
+ # writing to. The block runs outside the lock, since telling the server
631
+ # takes the ids recorded on this subscription.
632
+ # @param id [Integer, String] the listen id the deadline was set on
633
+ # @param error [MCPClient::Errors::RequestTimeoutError] the deadline missed
634
+ # @yield after the subscription has been ended, before the waiters wake
635
+ # @return [Symbol, nil] :expired when the subscription was ended here;
636
+ # nil when the request was no longer this one's to expire
637
+ # @api private
638
+ def expire_unanswered(id, error)
639
+ @mutex.synchronize do
640
+ return nil if @id != id || @answered || SETTLED_STATES.include?(@state)
641
+ # A deadline bounds the request that is out, not the subscription: a
642
+ # transport waiting to send the next listen (an HTTP reconnect inside
643
+ # its backoff, a stdio restart still spawning) has nothing in flight
644
+ # for this deadline to expire, and ending the handle here would also
645
+ # mark it closed by the client — unreconnectable, so the re-send that
646
+ # basic/patterns/subscriptions requires after a reconnect never
647
+ # happens. The next attempt arms a deadline of its own with its id.
648
+ return nil if @state == :reconnecting
649
+
650
+ close_locked(by_client: true, error: error, announce: false)
651
+ end
652
+ begin
653
+ yield if block_given?
654
+ ensure
655
+ @mutex.synchronize { @settled.broadcast }
656
+ end
657
+ :expired
658
+ end
659
+
660
+ # Record what the server agreed to honour.
661
+ #
662
+ # The filter is copied and frozen through and through, arrays and strings
663
+ # included. The hash it arrives in is the peer's, parsed from the
664
+ # acknowledgment notification, and that same hash is handed to the host's
665
+ # `on_notification` callback and to this subscription's own listeners — so
666
+ # host code that edits it in place would otherwise be rewriting this
667
+ # subscription's record of what the server granted. Adding a URI the
668
+ # acknowledgment left out is enough to make a waiting `subscribe_resource`
669
+ # report a watch that does not exist.
670
+ # @param filter [Hash, nil] the acknowledged SubscriptionFilter
671
+ # @return [void]
672
+ # @api private
673
+ def acknowledge(filter)
674
+ detached = Subscription.deep_frozen_copy(filter.is_a?(Hash) ? filter : {})
675
+ @mutex.synchronize do
676
+ return if @state == :closed
677
+
678
+ @acknowledged = detached
679
+ @state = :active
680
+ @answered = true
681
+ # A stream the server has granted is no longer one being handed over.
682
+ @reestablishing = false
683
+ @settled.broadcast
684
+ end
685
+ end
686
+
687
+ # A detached, deeply frozen copy of a parsed JSON value.
688
+ # @param value [Object]
689
+ # @return [Object] frozen, sharing nothing mutable with the original
690
+ def self.deep_frozen_copy(value)
691
+ case value
692
+ when Hash then value.to_h { |key, item| [deep_frozen_copy(key), deep_frozen_copy(item)] }.freeze
693
+ when Array then value.map { |item| deep_frozen_copy(item) }.freeze
694
+ when String then value.dup.freeze
695
+ else value
696
+ end
697
+ end
698
+
699
+ # @api private
700
+ def deliver(method, params)
701
+ @mutex.synchronize do
702
+ return if @state == :closed || @listeners.empty?
703
+
704
+ # Queued while still open, so a closure that follows cannot swallow a
705
+ # notification that had already arrived, and the stop that ends the
706
+ # dispatcher can never overtake this one. Enqueuing never waits for
707
+ # the listeners: the transport reader must stay free to deliver the
708
+ # responses a listener's own requests are waiting for.
709
+ (@dispatcher ||= NotificationDispatcher.new(self)).deliver(@listeners.dup, method, params)
710
+ end
711
+ end
712
+
713
+ # @api private
714
+ def mark_reconnecting
715
+ @mutex.synchronize do
716
+ next if @state == :closed
717
+
718
+ @state = :reconnecting
719
+ @reestablishing = true
720
+ end
721
+ end
722
+
723
+ # Whether a transport is handing this subscription to a new session: it
724
+ # was {#mark_reconnecting}ed and no server has acknowledged it since.
725
+ #
726
+ # Unlike {#reconnecting?} this survives the :pending that taking the new
727
+ # listen id moves it to, which is the whole point: a transport whose
728
+ # re-send fails on the write has to tell a stream it is handing over —
729
+ # which MUST be re-sent, and belongs to the next session — from one it is
730
+ # opening for a caller, which is the caller's to hear about. The state
731
+ # alone cannot: by the time the write raises, the re-send has already
732
+ # moved it off :reconnecting.
733
+ # @return [Boolean]
734
+ # @api private
735
+ def reestablishing?
736
+ @mutex.synchronize { @reestablishing && @state != :closed }
737
+ end
738
+
739
+ # @api private
740
+ def finish(gracefully: false, by_client: false, error: nil, reason: nil)
741
+ @mutex.synchronize { close_locked(gracefully: gracefully, by_client: by_client, error: error, reason: reason) }
742
+ end
743
+
744
+ # @return [Boolean] whether the subscription should be re-established after a reconnect
745
+ # @api private
746
+ def reconnectable?
747
+ @mutex.synchronize { @state != :closed && !@closed_by_client }
748
+ end
749
+
750
+ def inspect
751
+ "#<MCPClient::Subscription id=#{@id.inspect} state=#{@state} requested=#{@requested.keys.join(',')}>"
752
+ end
753
+
754
+ private
755
+
756
+ # The closing transition, for the callers that decide it under the lock
757
+ # they already hold. A closed subscription stays as it was closed.
758
+ # @param announce [Boolean] whether to wake the waiters now; a caller
759
+ # that passes false owes them a broadcast of its own
760
+ # @return [void]
761
+ def close_locked(gracefully: false, by_client: false, error: nil, reason: nil, announce: true)
762
+ return if @state == :closed
763
+
764
+ @state = :closed
765
+ @closed_gracefully = gracefully
766
+ @closed_by_client = by_client
767
+ @error = error
768
+ @close_reason = reason
769
+ @settled.broadcast if announce
770
+ # Deliveries already queued still run; the dispatcher ends after them.
771
+ @dispatcher&.stop
772
+ end
773
+
774
+ # Wait for the condition the caller is asking about to hold, then read the
775
+ # acknowledgment on record for this URI. The block is evaluated with the
776
+ # lock held and decides *when* the record may be read; what it then says
777
+ # about the URI is the same question either way.
778
+ # @param uri [String] the resource URI
779
+ # @param timeout [Numeric] seconds to wait
780
+ # @yieldreturn [Boolean] whether the record may be read yet
781
+ # @return [Symbol] :watching, :not_watching, :closed or :timeout
782
+ def await_watch(uri, timeout)
783
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
784
+ @mutex.synchronize do
785
+ loop do
786
+ return :closed if @state == :closed
787
+ return acknowledges_resource?(uri) ? :watching : :not_watching if yield
788
+
789
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
790
+ return :timeout if remaining <= 0
791
+
792
+ @settled.wait(@mutex, remaining)
793
+ end
794
+ end
795
+ end
796
+
797
+ # Whether the value the server acknowledged for a requested field grants
798
+ # anything: a flag has to come back true, and a list of URIs or task ids
799
+ # has to name at least one of those asked for. Anything else is the server
800
+ # declining the field while echoing its name. A field this client did not
801
+ # really ask for (a `false` flag, an empty list) is trivially granted —
802
+ # there was nothing there for the server to decline.
803
+ # @param wanted [Object] the value this client asked for
804
+ # @param granted [Object] the value the server acknowledged
805
+ # @return [Boolean]
806
+ def granted?(wanted, granted)
807
+ if wanted.is_a?(Array)
808
+ return true if wanted.empty?
809
+
810
+ return granted.is_a?(Array) && wanted.intersect?(granted)
811
+ end
812
+
813
+ wanted == false || granted == true
814
+ end
815
+
816
+ # Whether the acknowledgment that stands names this URI. Called with the
817
+ # lock held.
818
+ # @param uri [String] the resource URI
819
+ # @return [Boolean]
820
+ def acknowledges_resource?(uri)
821
+ granted = @acknowledged.is_a?(Hash) ? @acknowledged['resourceSubscriptions'] : nil
822
+ granted.is_a?(Array) && granted.include?(uri)
823
+ end
824
+
825
+ # Whether a recorded listen id may be cancelled by a caller writing to a
826
+ # given pipe. An id recorded against no pipe belongs to a transport that
827
+ # does not pin one, and a caller that names none is cancelling wherever
828
+ # the ids went. Called with the lock held.
829
+ # @param recorded [IO, nil] the pipe the request was written to
830
+ # @param io [IO, nil] the pipe the caller is cancelling on
831
+ # @return [Boolean]
832
+ def cancellable_on?(recorded, io)
833
+ recorded.nil? || io.nil? || recorded.equal?(io)
834
+ end
835
+
836
+ # The answer {#wait_until_settled} reports. A drop does not unask the
837
+ # question the waiter asked: the server acknowledged the listen request,
838
+ # and putting the stream back to :reconnecting until it is acknowledged
839
+ # again does not unanswer it. A replacement request that has gone out
840
+ # does — that one is unanswered until the server answers it, which is a
841
+ # different thing from a connection that merely dropped and is recorded
842
+ # as such rather than left to whichever the reconnect reached first.
843
+ # Called with the lock held.
844
+ # @return [Symbol, nil] :closed, :active, or nil while it is still pending
845
+ def settled_state
846
+ return @state if SETTLED_STATES.include?(@state)
847
+ return :active if @answered
848
+
849
+ nil
850
+ end
851
+ end
852
+ end