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,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
|