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,715 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'subscription'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
# subscriptions/listen support shared by every transport (MCP 2026-07-28
|
|
7
|
+
# basic/patterns/subscriptions): opening subscriptions, the registry keyed
|
|
8
|
+
# by listen request id, routing of tagged notifications, acknowledgment,
|
|
9
|
+
# graceful and abrupt closure, and the resources/subscribe mapping.
|
|
10
|
+
# Transports provide ensure_session_ready, open_subscription and
|
|
11
|
+
# cancel_subscription.
|
|
12
|
+
module SubscriptionSupport
|
|
13
|
+
# Seconds to wait for a resource subscription acknowledgment on a
|
|
14
|
+
# transport without its own read timeout.
|
|
15
|
+
DEFAULT_ACK_TIMEOUT = 30
|
|
16
|
+
|
|
17
|
+
# Open a long-lived notification stream. Modern servers only: the legacy
|
|
18
|
+
# transports keep resources/subscribe and the HTTP GET stream.
|
|
19
|
+
#
|
|
20
|
+
# The request itself is meant to outlive every other one this client
|
|
21
|
+
# sends — its response is the server's *closing* of the stream — so the
|
|
22
|
+
# deadline the lifecycle asks for ("implementations SHOULD establish
|
|
23
|
+
# timeouts for all sent requests", basic/patterns/cancellation "Timeouts")
|
|
24
|
+
# is on the acknowledgment rather than on the response: a server MUST
|
|
25
|
+
# acknowledge a listen before it sends anything on it, so a listen that
|
|
26
|
+
# has not been acknowledged is a request nothing is happening on. One that
|
|
27
|
+
# expires is cancelled the way that section requires, and the handle is
|
|
28
|
+
# closed carrying the timeout, instead of staying `:pending` for the life
|
|
29
|
+
# of the process with nothing to tell the host why.
|
|
30
|
+
# @param notifications [Hash] the SubscriptionFilter
|
|
31
|
+
# @param ack_timeout [Numeric, false, nil] seconds to wait for the
|
|
32
|
+
# acknowledgment; nil takes the transport's own ({#subscription_ack_timeout},
|
|
33
|
+
# i.e. its read timeout), false waits for ever
|
|
34
|
+
# @yield [method, params] notifications delivered on the subscription
|
|
35
|
+
# @return [MCPClient::Subscription]
|
|
36
|
+
# @raise [MCPClient::Errors::CapabilityError] on a legacy session
|
|
37
|
+
def listen(notifications:, ack_timeout: nil, &listener)
|
|
38
|
+
filter = MCPClient::Subscription.normalize_filter(notifications)
|
|
39
|
+
ensure_modern_listen!
|
|
40
|
+
open_listen(filter, ack_timeout: ack_timeout, &listener)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @raise [MCPClient::Errors::CapabilityError] on a legacy session
|
|
45
|
+
def ensure_modern_listen!
|
|
46
|
+
# A transport with no listen stream of its own (the deprecated SSE
|
|
47
|
+
# transport, a host adapter written against the older interface) cannot
|
|
48
|
+
# serve one however the session was negotiated.
|
|
49
|
+
unless respond_to?(:open_subscription, true)
|
|
50
|
+
raise MCPClient::Errors::CapabilityError,
|
|
51
|
+
"#{self.class.name} does not support subscriptions/listen (an MCP 2026-07-28 stdio or " \
|
|
52
|
+
'Streamable HTTP transport is required)'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
ensure_session_ready
|
|
56
|
+
return if modern?
|
|
57
|
+
|
|
58
|
+
raise MCPClient::Errors::CapabilityError,
|
|
59
|
+
'subscriptions/listen requires an MCP 2026-07-28 server; this server negotiated ' \
|
|
60
|
+
"#{protocol_version || 'no version'} (use resources/subscribe and server notifications instead)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Open a listen stream for a normalized filter.
|
|
64
|
+
#
|
|
65
|
+
# The deadline on the *first* request and the deadline on the requests a
|
|
66
|
+
# reconnect or a restart re-issues are two settings, because one caller
|
|
67
|
+
# wants them apart: `subscribe_resource` waits for the first
|
|
68
|
+
# acknowledgment itself and reports its absence as its own failure, so
|
|
69
|
+
# it wants no watchdog racing that wait — but the re-issued requests are
|
|
70
|
+
# ones nobody is waiting on, and those it wants bounded like any other
|
|
71
|
+
# (see {#open_resource_subscription}).
|
|
72
|
+
# @param filter [Hash] the normalized SubscriptionFilter
|
|
73
|
+
# @param ack_timeout [Numeric, false, nil] see {#listen}; kept on the
|
|
74
|
+
# handle for every re-issued request
|
|
75
|
+
# @param initial_deadline [Boolean] whether to arm the deadline on the
|
|
76
|
+
# first request too
|
|
77
|
+
# @yield [method, params] notifications delivered on the subscription
|
|
78
|
+
# @return [MCPClient::Subscription]
|
|
79
|
+
def open_listen(filter, ack_timeout:, initial_deadline: true, &listener)
|
|
80
|
+
subscription = MCPClient::Subscription.new(server: self, requested: filter, ack_timeout: ack_timeout, &listener)
|
|
81
|
+
open_subscription(subscription)
|
|
82
|
+
await_acknowledgment_deadline(subscription, ack_timeout) if initial_deadline
|
|
83
|
+
subscription
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Put the same deadline on a listen request a transport has just
|
|
87
|
+
# re-issued: an HTTP stream re-opened after a drop, or a stdio
|
|
88
|
+
# subscription re-sent to the process that replaced the one it was on.
|
|
89
|
+
#
|
|
90
|
+
# Each of those is a new JSON-RPC request, for which the server holds no
|
|
91
|
+
# subscription state and which it has to acknowledge afresh, so the
|
|
92
|
+
# "implementations SHOULD establish timeouts for all sent requests"
|
|
93
|
+
# (basic/patterns/cancellation "Timeouts") that bounded the first bounds it
|
|
94
|
+
# too. It used to bound only the first: the watchdog `listen` started
|
|
95
|
+
# retires at the first acknowledgment, and a replacement the server
|
|
96
|
+
# accepted and then never acknowledged left the handle `:pending` with
|
|
97
|
+
# nothing to tell the host why — indefinitely on stdio, and for as long as
|
|
98
|
+
# the peer kept sending SSE comments on Streamable HTTP.
|
|
99
|
+
#
|
|
100
|
+
# Watchdogs do not pile up behind a stream that keeps dropping: each one
|
|
101
|
+
# is bound to the request it was armed for, and retires as soon as the
|
|
102
|
+
# subscription has moved on to a newer one.
|
|
103
|
+
# @param subscription [MCPClient::Subscription]
|
|
104
|
+
# @return [Thread, nil] the watchdog, for tests; nil when there is none
|
|
105
|
+
def rearm_acknowledgment_deadline(subscription)
|
|
106
|
+
await_acknowledgment_deadline(subscription, subscription.ack_timeout)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Arrange for an unacknowledged listen to be given up on.
|
|
110
|
+
#
|
|
111
|
+
# Started only once the request is on its way, so nothing is cancelled
|
|
112
|
+
# before it exists; it waits on the subscription's own settling signal, so
|
|
113
|
+
# an acknowledgment (or any other end) retires it at once rather than
|
|
114
|
+
# leaving a thread asleep for the whole deadline. Every re-issued request
|
|
115
|
+
# gets one too ({#rearm_acknowledgment_deadline}).
|
|
116
|
+
#
|
|
117
|
+
# The deadline is the *request's*, not the handle's: the id it is set on
|
|
118
|
+
# is taken here, and only that request is expired by it
|
|
119
|
+
# ({MCPClient::Subscription#expire_unanswered}). Waiting on the mutable
|
|
120
|
+
# handle instead expired whatever request it was on by the time the wait
|
|
121
|
+
# returned — a first request's timer closed the replacement a restart had
|
|
122
|
+
# issued since, naming the replacement and a deadline it had not missed.
|
|
123
|
+
# @param subscription [MCPClient::Subscription]
|
|
124
|
+
# @param ack_timeout [Numeric, false, nil] see {#listen}
|
|
125
|
+
# @return [Thread, nil] the watchdog, for tests; nil when there is none
|
|
126
|
+
def await_acknowledgment_deadline(subscription, ack_timeout)
|
|
127
|
+
timeout = ack_timeout.nil? ? subscription_ack_timeout : ack_timeout
|
|
128
|
+
return nil unless timeout.is_a?(Numeric) && timeout.positive?
|
|
129
|
+
|
|
130
|
+
request_id = subscription.id
|
|
131
|
+
Thread.new do
|
|
132
|
+
Thread.current.name = 'MCP-listen-ack'
|
|
133
|
+
Thread.current.report_on_exception = false
|
|
134
|
+
next if subscription.wait_until_settled(timeout)
|
|
135
|
+
|
|
136
|
+
expire_unacknowledged_subscription(subscription, request_id, timeout)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# End a listen the server never acknowledged, and tell the server so —
|
|
141
|
+
# unless the subscription has moved on from that request, or the server
|
|
142
|
+
# answered it in the instant between the wait and this: the verdict and
|
|
143
|
+
# the closure are one step on the subscription, and whoever is waiting
|
|
144
|
+
# for the handle to settle is woken once the server has been told.
|
|
145
|
+
# @param subscription [MCPClient::Subscription]
|
|
146
|
+
# @param request_id [Integer, String] the listen id the deadline was set on
|
|
147
|
+
# @param timeout [Numeric] the deadline it missed
|
|
148
|
+
# @return [void]
|
|
149
|
+
def expire_unacknowledged_subscription(subscription, request_id, timeout)
|
|
150
|
+
error = MCPClient::Errors::RequestTimeoutError.new(
|
|
151
|
+
"subscriptions/listen #{request_id} was not acknowledged within #{timeout}s"
|
|
152
|
+
)
|
|
153
|
+
subscription.expire_unanswered(request_id, error) do
|
|
154
|
+
@logger.warn("subscriptions/listen #{request_id} was not acknowledged within #{timeout}s; cancelling it")
|
|
155
|
+
# The handle is already closed, so this is the cancellation alone: the
|
|
156
|
+
# notifications/cancelled on stdio, the closed response stream on HTTP.
|
|
157
|
+
cancel_subscription(subscription)
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
@logger.debug("Cancelling an unacknowledged subscription raised #{e.class}: #{e.message}")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The subscriptions this transport has opened, keyed by the String form
|
|
164
|
+
# of their listen request id.
|
|
165
|
+
# Keyed by the JSON-RPC id the listen went out with, exactly as it was
|
|
166
|
+
# sent. A JSON-RPC id of another type is another request's id — the
|
|
167
|
+
# cancellation path has always compared them exactly, and the
|
|
168
|
+
# acknowledgment and delivery paths do too — so a peer that tags a
|
|
169
|
+
# message with "9" does not reach the subscription listening on 9.
|
|
170
|
+
# @return [Hash{Integer, String => MCPClient::Subscription}]
|
|
171
|
+
def subscriptions
|
|
172
|
+
@subscriptions ||= {}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @return [Mutex] guards the subscription registry
|
|
176
|
+
def subscriptions_mutex
|
|
177
|
+
@subscriptions_mutex ||= Mutex.new
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param id [Integer, String, nil] a JSON-RPC id
|
|
181
|
+
# @return [MCPClient::Subscription, nil]
|
|
182
|
+
def subscription_by_id(id)
|
|
183
|
+
return nil if id.nil?
|
|
184
|
+
|
|
185
|
+
subscriptions_mutex.synchronize { subscriptions[id] }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @param subscription [MCPClient::Subscription]
|
|
189
|
+
# @return [void]
|
|
190
|
+
def register_subscription(subscription)
|
|
191
|
+
subscriptions_mutex.synchronize { subscriptions[subscription.id] = subscription }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @param subscription [MCPClient::Subscription]
|
|
195
|
+
# @return [void]
|
|
196
|
+
def unregister_subscription(subscription)
|
|
197
|
+
subscriptions_mutex.synchronize { subscriptions.delete(subscription.id) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Drop the registration a particular listen id made, and only that one: a
|
|
201
|
+
# subscription re-opened under a newer id (by a reconnect, or by a stdio
|
|
202
|
+
# restart racing a blocked write) is registered under that newer id, and
|
|
203
|
+
# the older attempt must not delete it.
|
|
204
|
+
# @param subscription [MCPClient::Subscription]
|
|
205
|
+
# @param id [Integer, String] the listen id it was registered under
|
|
206
|
+
# @return [void]
|
|
207
|
+
def unregister_subscription_id(subscription, id)
|
|
208
|
+
subscriptions_mutex.synchronize do
|
|
209
|
+
subscriptions.delete(id) if subscriptions[id].equal?(subscription)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The subscription a notification belongs to, from its
|
|
214
|
+
# io.modelcontextprotocol/subscriptionId.
|
|
215
|
+
# @param params [Hash, nil] notification params
|
|
216
|
+
# @return [MCPClient::Subscription, nil]
|
|
217
|
+
def subscription_for_notification(params)
|
|
218
|
+
meta = params.is_a?(Hash) ? params['_meta'] : nil
|
|
219
|
+
return nil unless meta.is_a?(Hash) && meta.key?(MCPClient::JsonRpcCommon::META_SUBSCRIPTION_ID)
|
|
220
|
+
|
|
221
|
+
subscription_by_id(meta[MCPClient::JsonRpcCommon::META_SUBSCRIPTION_ID])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Notifications that are subscription bookkeeping rather than something a
|
|
225
|
+
# subscription's listeners are watching for.
|
|
226
|
+
CONTROL_NOTIFICATIONS = %w[notifications/subscriptions/acknowledged notifications/cancelled].freeze
|
|
227
|
+
|
|
228
|
+
# Route an incoming notification, in this order:
|
|
229
|
+
# subscription bookkeeping (acknowledgment, server-side teardown), then
|
|
230
|
+
# transport and host cache invalidation, then the delivery to the owning
|
|
231
|
+
# subscription's listeners, and last the host's `on_notification` callback
|
|
232
|
+
# (so hosts still see subscription-delivered notifications exactly like
|
|
233
|
+
# request-scoped ones).
|
|
234
|
+
#
|
|
235
|
+
# The invalidations come first on purpose, the transport's and the host's
|
|
236
|
+
# alike. A listener runs on the subscription's own dispatcher thread, so
|
|
237
|
+
# queuing its delivery makes the notification visible at once — and a
|
|
238
|
+
# listener that reacts to a list_changed notification by calling a cached
|
|
239
|
+
# list method (`client.list_tools`, say) would then read the very entry the
|
|
240
|
+
# notification says is stale. Dropping the caches before the delivery is
|
|
241
|
+
# queued makes "the caches are already invalid when a listener sees the
|
|
242
|
+
# notification" a guarantee instead of a race the scheduler usually wins.
|
|
243
|
+
# That is why the host's invalidation has a hook of its own
|
|
244
|
+
# ({MCPClient::ServerBase#on_cache_invalidation}) rather than riding on the
|
|
245
|
+
# host callback below: while it did, the guarantee held only for the
|
|
246
|
+
# transport's own caches and the host's were dropped after the delivery.
|
|
247
|
+
#
|
|
248
|
+
# The host callback comes last, because it is the only step that can
|
|
249
|
+
# block. It is host code driven by the peer and it runs on whatever thread
|
|
250
|
+
# is routing — on stdio the process's sole stdout reader — so a callback
|
|
251
|
+
# that issues a synchronous request of its own waits there for a response
|
|
252
|
+
# only that reader can deliver. Round 3 moved the subscription's listeners
|
|
253
|
+
# off that thread for exactly this reason; running the callback ahead of
|
|
254
|
+
# them put the queueing back behind it, and a host handler that blocked
|
|
255
|
+
# stalled a delivery the dispatcher would otherwise have made at once.
|
|
256
|
+
# Queueing costs nothing to move: {#deliver_subscription_notification}
|
|
257
|
+
# hands the notification to the dispatcher rather than to the listeners,
|
|
258
|
+
# so nothing host-supplied runs before the callback either way.
|
|
259
|
+
#
|
|
260
|
+
# Being last, the callback can prevent nothing. An exception escaping it
|
|
261
|
+
# used to take the notification down with it — the subscription's
|
|
262
|
+
# listeners never saw something the host's own handler had already been
|
|
263
|
+
# told about — and, on stdio, the transport's reader thread with it; it is
|
|
264
|
+
# now logged and routing is over anyway. Nor can it drop or redirect a
|
|
265
|
+
# delivery by editing the payload it is handed: that is the very hash the
|
|
266
|
+
# delivery was routed by, and by the time the callback can touch it the
|
|
267
|
+
# subscription has already been resolved *and* the entry queued, so
|
|
268
|
+
# deleting or rewriting `_meta` changes nothing about where it went.
|
|
269
|
+
# @param method [String] notification method
|
|
270
|
+
# @param params [Hash, nil] notification params
|
|
271
|
+
# @return [void]
|
|
272
|
+
def route_notification(method, params)
|
|
273
|
+
handle_subscription_control(method, params)
|
|
274
|
+
# notifications/message is the Logging utility, Deprecated as a whole in
|
|
275
|
+
# 2026-07-28 (SEP-2577). The notice belongs here rather than in
|
|
276
|
+
# MCPClient::Client: a host that registered on_notification on the
|
|
277
|
+
# transport itself receives log messages without a Client ever existing.
|
|
278
|
+
warn_logging_deprecated if method == 'notifications/message'
|
|
279
|
+
invalidate_cache_for_notification(method, params)
|
|
280
|
+
# The host's caches go with the transport's, on their own hook rather
|
|
281
|
+
# than on the host callback below: that callback is deliberately last —
|
|
282
|
+
# it is the step that may block — and a client whose invalidation rode on
|
|
283
|
+
# it dropped its entries only after the delivery had been queued, so a
|
|
284
|
+
# listener could read the very list the notification says is stale. Only
|
|
285
|
+
# the invalidation is moved ahead; everything else the host does with a
|
|
286
|
+
# notification is still behind the delivery.
|
|
287
|
+
notify_cache_invalidation(method, params)
|
|
288
|
+
deliver_subscription_notification(subscription_delivery_target(method, params), method, params)
|
|
289
|
+
notify_host(method, params)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Hand a notification to the host's callback, surviving whatever it does
|
|
293
|
+
# with it (see {#route_notification}).
|
|
294
|
+
# @param method [String] notification method
|
|
295
|
+
# @param params [Hash, nil] notification params
|
|
296
|
+
# @return [void]
|
|
297
|
+
def notify_host(method, params)
|
|
298
|
+
@notification_callback&.call(method, params)
|
|
299
|
+
rescue StandardError => e
|
|
300
|
+
@logger.warn("Notification callback error for #{sanitize_log_text(method)}: #{sanitize_log_text(e.message)}")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Subscription bookkeeping carried by a notification: the server's
|
|
304
|
+
# acknowledgment of a listen request, and its teardown of one.
|
|
305
|
+
# @param method [String] notification method
|
|
306
|
+
# @param params [Hash, nil] notification params
|
|
307
|
+
# @return [void]
|
|
308
|
+
def handle_subscription_control(method, params)
|
|
309
|
+
case method
|
|
310
|
+
when 'notifications/subscriptions/acknowledged'
|
|
311
|
+
handle_subscription_acknowledgment(params)
|
|
312
|
+
when 'notifications/cancelled'
|
|
313
|
+
# Servers MUST send notifications/cancelled only to tear down a
|
|
314
|
+
# subscriptions/listen stream (basic/patterns/cancellation).
|
|
315
|
+
handle_server_cancellation(params)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Record what the server agreed to honour, and recheck the resource
|
|
320
|
+
# subscriptions this stream carries against it.
|
|
321
|
+
# @param params [Hash, nil] notification params
|
|
322
|
+
# @return [void]
|
|
323
|
+
def handle_subscription_acknowledgment(params)
|
|
324
|
+
subscription = subscription_for_notification(params)
|
|
325
|
+
return @logger.debug('Acknowledgment for an unknown subscription ignored') unless subscription
|
|
326
|
+
|
|
327
|
+
subscription.acknowledge(params['notifications'])
|
|
328
|
+
report_declined_subscription_types(subscription)
|
|
329
|
+
drop_unacknowledged_resource_subscriptions(subscription)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# "The client SHOULD check the acknowledged filter against what it
|
|
333
|
+
# requested and handle any unsupported types gracefully"
|
|
334
|
+
# (basic/patterns/subscriptions). Gracefully, for the notification types
|
|
335
|
+
# of a plain `listen`, is: the stream stays up for what was granted,
|
|
336
|
+
# {MCPClient::Subscription#unsupported} names the rest, and the host is
|
|
337
|
+
# told — a host that opted in to prompt-list changes and was granted
|
|
338
|
+
# tool-list changes alone would otherwise keep waiting for notifications
|
|
339
|
+
# that are never coming, with the handle `:active` and nothing said. The
|
|
340
|
+
# resource URIs are held to more than a log line
|
|
341
|
+
# ({#drop_unacknowledged_resource_subscriptions}): a watch the server
|
|
342
|
+
# declined is one nothing is watching.
|
|
343
|
+
# @param subscription [MCPClient::Subscription] the acknowledged stream
|
|
344
|
+
# @return [void]
|
|
345
|
+
def report_declined_subscription_types(subscription)
|
|
346
|
+
declined = subscription.unsupported
|
|
347
|
+
return if declined.empty?
|
|
348
|
+
|
|
349
|
+
@logger.warn("Server acknowledged subscription #{subscription.id} without #{declined.join(', ')}; " \
|
|
350
|
+
'no notifications of those types will arrive on it')
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# @param params [Hash, nil] notification params
|
|
354
|
+
# @return [void]
|
|
355
|
+
def handle_server_cancellation(params)
|
|
356
|
+
return unless params.is_a?(Hash)
|
|
357
|
+
|
|
358
|
+
# "Malformed notifications MAY be ignored": a reason that is not a
|
|
359
|
+
# string is one, and so is a request id of another type than the one
|
|
360
|
+
# this client issued — the registry is keyed by the id's text, so the
|
|
361
|
+
# type is checked on the subscription found (basic/patterns/cancellation
|
|
362
|
+
# "Error handling"). Neither may end a stream the server still serves.
|
|
363
|
+
reason = params['reason']
|
|
364
|
+
return unless reason.nil? || reason.is_a?(String)
|
|
365
|
+
|
|
366
|
+
subscription = subscription_by_id(params['requestId'])
|
|
367
|
+
return unless subscription && subscription.id == params['requestId']
|
|
368
|
+
|
|
369
|
+
@logger.info("Server cancelled subscription #{subscription.id}: " \
|
|
370
|
+
"#{sanitize_log_text(reason || 'no reason given')}")
|
|
371
|
+
unregister_subscription(subscription)
|
|
372
|
+
subscription.finish(gracefully: false, reason: reason)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# The subscription a notification is delivered to, resolved from the
|
|
376
|
+
# payload before the host's callback is given the chance to edit it (see
|
|
377
|
+
# {#route_notification}).
|
|
378
|
+
# @param method [String] notification method
|
|
379
|
+
# @param params [Hash, nil] notification params
|
|
380
|
+
# @return [MCPClient::Subscription, nil]
|
|
381
|
+
def subscription_delivery_target(method, params)
|
|
382
|
+
return nil if CONTROL_NOTIFICATIONS.include?(method)
|
|
383
|
+
|
|
384
|
+
meta = params.is_a?(Hash) ? params['_meta'] : nil
|
|
385
|
+
return nil unless meta.is_a?(Hash) && meta.key?(MCPClient::JsonRpcCommon::META_SUBSCRIPTION_ID)
|
|
386
|
+
|
|
387
|
+
subscription = subscription_by_id(meta[MCPClient::JsonRpcCommon::META_SUBSCRIPTION_ID])
|
|
388
|
+
@logger.debug("Notification #{sanitize_log_text(method)} for an unknown subscription ignored") unless subscription
|
|
389
|
+
subscription
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# @param subscription [MCPClient::Subscription, nil] the stream it belongs
|
|
393
|
+
# to, resolved from the payload the peer sent
|
|
394
|
+
# @param method [String] notification method
|
|
395
|
+
# @param params [Hash, nil] notification params
|
|
396
|
+
# @return [void]
|
|
397
|
+
def deliver_subscription_notification(subscription, method, params)
|
|
398
|
+
subscription&.deliver(method, params)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Handle a JSON-RPC response addressed to a listen request: a result is
|
|
402
|
+
# the server's graceful closure, an error a failed subscription.
|
|
403
|
+
# @param message [Hash] a JSON-RPC response
|
|
404
|
+
# @return [MCPClient::Subscription, nil] the subscription it ended, if any
|
|
405
|
+
def handle_subscription_response(message)
|
|
406
|
+
subscription = subscription_by_id(message['id'])
|
|
407
|
+
return nil unless subscription
|
|
408
|
+
|
|
409
|
+
unregister_subscription(subscription)
|
|
410
|
+
if message['error']
|
|
411
|
+
error = MCPClient::Errors::ServerError.from_jsonrpc(message['error'])
|
|
412
|
+
@logger.warn("subscriptions/listen #{subscription.id} failed: #{sanitize_log_text(error.message)}")
|
|
413
|
+
subscription.finish(gracefully: false, error: error)
|
|
414
|
+
else
|
|
415
|
+
close_subscription_gracefully(subscription, message['result'])
|
|
416
|
+
end
|
|
417
|
+
subscription
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# End a subscription on the server's closing response — but only when the
|
|
421
|
+
# result is one the client recognizes, and only when it is a *completion*.
|
|
422
|
+
# Every other response goes through
|
|
423
|
+
# {MCPClient::JsonRpcCommon#validate_result_type!}; skipping it here would
|
|
424
|
+
# make a missing, scalar or unknown-resultType result indistinguishable
|
|
425
|
+
# from a clean close.
|
|
426
|
+
#
|
|
427
|
+
# Recognized is not enough on its own. `input_required` is a resultType
|
|
428
|
+
# this client accepts — on tools/call, resources/read and prompts/get,
|
|
429
|
+
# the three requests a server may answer with one
|
|
430
|
+
# (basic/patterns/mrtr "Supported Requests"). subscriptions/listen is not
|
|
431
|
+
# among them, and the whole meaning of `input_required` is that the
|
|
432
|
+
# request has *not* completed, so reporting one as a graceful closure told
|
|
433
|
+
# the host the server had finished with a stream it had not.
|
|
434
|
+
# @param subscription [MCPClient::Subscription]
|
|
435
|
+
# @param result [Object] the response's result member
|
|
436
|
+
# @return [void]
|
|
437
|
+
def close_subscription_gracefully(subscription, result)
|
|
438
|
+
validate_result_type!(result)
|
|
439
|
+
type = MCPClient::JsonRpcCommon.result_type(result)
|
|
440
|
+
unless type == 'complete'
|
|
441
|
+
raise MCPClient::Errors::InvalidResultError,
|
|
442
|
+
"Invalid result: resultType #{type.inspect} does not close a subscription; " \
|
|
443
|
+
"input_required is only valid for #{MCPClient::JsonRpcCommon::MRTR_METHODS.join(', ')}, " \
|
|
444
|
+
'not subscriptions/listen'
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
@logger.debug("Server closed subscription #{subscription.id} gracefully")
|
|
448
|
+
subscription.finish(gracefully: true)
|
|
449
|
+
rescue MCPClient::Errors::InvalidResultError => e
|
|
450
|
+
@logger.warn("subscriptions/listen #{subscription.id} closed with an invalid result: #{e.message}")
|
|
451
|
+
subscription.finish(gracefully: false, error: e)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Open resource-update subscriptions the modern way: one listen stream
|
|
455
|
+
# per URI (resources/subscribe was replaced by
|
|
456
|
+
# subscriptions/listen.resourceSubscriptions). Blocks until the server
|
|
457
|
+
# acknowledges the stream, so the caller learns about a rejection the way
|
|
458
|
+
# it did from resources/subscribe.
|
|
459
|
+
# @param uri [String] the resource URI
|
|
460
|
+
# @return [MCPClient::Subscription] the acknowledged subscription
|
|
461
|
+
# @raise [MCPClient::Errors::MCPError] if the server refused the stream,
|
|
462
|
+
# ended it before acknowledging, or acknowledged it without the URI
|
|
463
|
+
def subscribe_resource_via_listen(uri)
|
|
464
|
+
existing = live_resource_subscription(uri)
|
|
465
|
+
return existing if existing
|
|
466
|
+
|
|
467
|
+
# One stream per URI: two threads subscribing to the same resource must
|
|
468
|
+
# not open two, or the second registration would hide the first and
|
|
469
|
+
# unsubscribe_resource would close only one of them.
|
|
470
|
+
resource_subscription_mutex(uri).synchronize do
|
|
471
|
+
existing = settled_resource_subscription(uri)
|
|
472
|
+
next existing if existing
|
|
473
|
+
|
|
474
|
+
open_resource_subscription(uri)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# The stream already mapped to this URI, once the server's word on it
|
|
479
|
+
# stands — or nil when there is none to reuse.
|
|
480
|
+
#
|
|
481
|
+
# A mapped stream is not a watch merely for being open, and it is not one
|
|
482
|
+
# merely for having been granted the URI once. After an HTTP connection
|
|
483
|
+
# drops or a stdio process restarts, the request that replaces it is a new
|
|
484
|
+
# listen the server holds no state for: it may be rejected, or
|
|
485
|
+
# acknowledged without this URI, and until it is answered nothing has been
|
|
486
|
+
# granted. Reporting success from that state — which used to happen for
|
|
487
|
+
# every handle that was not closed, and then for every handle whose old
|
|
488
|
+
# acknowledgment was still on record, so for the whole of an HTTP backoff
|
|
489
|
+
# or a stdio handshake — tells the subscriber about a watch nobody has
|
|
490
|
+
# made yet. So this asks whether the server is watching the URI *now*
|
|
491
|
+
# ({MCPClient::Subscription#await_live_resource_watch}), waiting out a
|
|
492
|
+
# stream that is between listen attempts rather than reading what the last
|
|
493
|
+
# one was granted, and a stream that comes back without the URI — or does
|
|
494
|
+
# not come back at all — stops being this URI's stream and is closed with
|
|
495
|
+
# it (see {#discard_mapped_resource_subscription}).
|
|
496
|
+
# @param uri [String] the resource URI
|
|
497
|
+
# @return [MCPClient::Subscription, nil]
|
|
498
|
+
def settled_resource_subscription(uri)
|
|
499
|
+
mapped = subscriptions_mutex.synchronize { resource_subscriptions[uri] }
|
|
500
|
+
return nil unless mapped
|
|
501
|
+
return mapped if mapped.watching_resource?(uri)
|
|
502
|
+
return mapped if mapped.await_live_resource_watch(uri, subscription_ack_timeout) == :watching
|
|
503
|
+
|
|
504
|
+
discard_mapped_resource_subscription(mapped, uri)
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Give up on a stream that is mapped to a URI the server is not honouring
|
|
509
|
+
# on it — because it answered without the URI, ended, or never answered at
|
|
510
|
+
# all within the acknowledgment timeout.
|
|
511
|
+
#
|
|
512
|
+
# Dropping the mapping is not enough. A stream that is merely
|
|
513
|
+
# :reconnecting is still reconnectable, so {#subscribe_resource_via_listen}
|
|
514
|
+
# would open a replacement beside it and the discarded one could come back
|
|
515
|
+
# and deliver the same updates a second time — while
|
|
516
|
+
# {#unsubscribe_resource_via_listen}, which looks for the stream through
|
|
517
|
+
# the very mapping that was just dropped, could no longer find or cancel
|
|
518
|
+
# it. Closing it is also what the caller is entitled to: on stdio it sends
|
|
519
|
+
# the `notifications/cancelled` the spec requires of a client that stops
|
|
520
|
+
# reading a stream, and on Streamable HTTP it closes the response stream.
|
|
521
|
+
#
|
|
522
|
+
# The mapping is dropped first, and the stream is closed only once no
|
|
523
|
+
# other URI still names it: a stream that is a live watch for a second
|
|
524
|
+
# resource is not this URI's to end.
|
|
525
|
+
# @param subscription [MCPClient::Subscription] the discarded stream
|
|
526
|
+
# @param uri [String] the resource URI it was mapped to
|
|
527
|
+
# @return [void]
|
|
528
|
+
def discard_mapped_resource_subscription(subscription, uri)
|
|
529
|
+
unmap_resource_subscription(subscription, uri)
|
|
530
|
+
still_mapped = subscriptions_mutex.synchronize do
|
|
531
|
+
resource_subscriptions.any? { |_mapped_uri, sub| sub.equal?(subscription) }
|
|
532
|
+
end
|
|
533
|
+
return if still_mapped
|
|
534
|
+
|
|
535
|
+
subscription.close
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Close the streams whose mapped URI the server's latest acknowledgment
|
|
539
|
+
# left out.
|
|
540
|
+
#
|
|
541
|
+
# Every acknowledgment is checked, not just the first: a stream re-opened
|
|
542
|
+
# after an HTTP drop or a stdio restart is a new listen request, the
|
|
543
|
+
# server holds no subscription state across it, and it MAY acknowledge a
|
|
544
|
+
# smaller subset the second time. {#confirm_resource_subscription} only
|
|
545
|
+
# guards the acknowledgment the subscriber waited for, so without this a
|
|
546
|
+
# narrowed re-acknowledgment left `resource_subscriptions` mapping a URI
|
|
547
|
+
# to a stream that no longer carried it, and
|
|
548
|
+
# {#live_resource_subscription} kept reporting success for a resource
|
|
549
|
+
# nothing was watching.
|
|
550
|
+
# @param subscription [MCPClient::Subscription] the acknowledged stream
|
|
551
|
+
# @return [void]
|
|
552
|
+
def drop_unacknowledged_resource_subscriptions(subscription)
|
|
553
|
+
missing = subscription.unacknowledged_resource_uris
|
|
554
|
+
return if missing.empty?
|
|
555
|
+
|
|
556
|
+
mapped = subscriptions_mutex.synchronize do
|
|
557
|
+
resource_subscriptions.select { |uri, sub| sub.equal?(subscription) && missing.include?(uri) }.keys
|
|
558
|
+
end
|
|
559
|
+
return if mapped.empty?
|
|
560
|
+
|
|
561
|
+
@logger.warn("Server re-acknowledged subscription #{subscription.id} without " \
|
|
562
|
+
"#{mapped.map { |uri| sanitize_log_text(uri) }.join(', ')}; closing it so the resource " \
|
|
563
|
+
'subscription no longer reports a watch the server is not honouring')
|
|
564
|
+
# Closing drops the mapping itself (the transport's cancel_subscription
|
|
565
|
+
# clears every URI pointing at this stream), so a later subscribe_resource
|
|
566
|
+
# opens a fresh stream and raises if that one is refused too.
|
|
567
|
+
subscription.close
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# @param uri [String] the resource URI
|
|
571
|
+
# @return [MCPClient::Subscription, nil] its stream, while the server is
|
|
572
|
+
# currently honouring it for that URI — see
|
|
573
|
+
# {MCPClient::Subscription#watching_resource?}
|
|
574
|
+
def live_resource_subscription(uri)
|
|
575
|
+
existing = subscriptions_mutex.synchronize { resource_subscriptions[uri] }
|
|
576
|
+
existing if existing&.watching_resource?(uri)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# @param uri [String] the resource URI
|
|
580
|
+
# @return [MCPClient::Subscription] the acknowledged subscription
|
|
581
|
+
def open_resource_subscription(uri)
|
|
582
|
+
# No watchdog on the first request: this caller waits for the
|
|
583
|
+
# acknowledgment itself, on the same timeout, and reports a stream that
|
|
584
|
+
# never arrives as its own failure rather than through a handle
|
|
585
|
+
# something else closed.
|
|
586
|
+
#
|
|
587
|
+
# The requests a restart or a reconnect re-issues are another matter.
|
|
588
|
+
# Nobody waits on those — the host is waiting for updates — so a
|
|
589
|
+
# replacement the server accepted and then never acknowledged was, with
|
|
590
|
+
# `ack_timeout: false`, pending for ever: the mapped stream was only
|
|
591
|
+
# discarded the next time the URI was asked about, and a host that never
|
|
592
|
+
# asked again was never told. Those requests are bounded by the
|
|
593
|
+
# transport's own timeout, like any other listen's.
|
|
594
|
+
ensure_modern_listen!
|
|
595
|
+
subscription = open_listen(MCPClient::Subscription.normalize_filter('resourceSubscriptions' => [uri]),
|
|
596
|
+
ack_timeout: subscription_ack_timeout, initial_deadline: false)
|
|
597
|
+
begin
|
|
598
|
+
confirm_resource_subscription(subscription, uri)
|
|
599
|
+
subscriptions_mutex.synchronize { resource_subscriptions[uri] = subscription }
|
|
600
|
+
recheck_mapped_resource_subscription(subscription, uri)
|
|
601
|
+
rescue StandardError
|
|
602
|
+
# Nothing watches a stream the caller could not use.
|
|
603
|
+
unmap_resource_subscription(subscription, uri)
|
|
604
|
+
subscription.close
|
|
605
|
+
raise
|
|
606
|
+
end
|
|
607
|
+
subscription
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Check the acknowledgment that stands *now that the URI is mapped*.
|
|
611
|
+
#
|
|
612
|
+
# {#drop_unacknowledged_resource_subscriptions} can only see a stream
|
|
613
|
+
# through the mapping, and the mapping is written after the acknowledgment
|
|
614
|
+
# the subscriber waited for. A stream that dropped and was re-opened in
|
|
615
|
+
# between is acknowledged afresh and MAY be granted more narrowly, so
|
|
616
|
+
# without this second look a re-acknowledgment that landed in that window
|
|
617
|
+
# was stored as a live watch nothing was honouring.
|
|
618
|
+
# @param subscription [MCPClient::Subscription]
|
|
619
|
+
# @param uri [String] the resource URI
|
|
620
|
+
# @return [void]
|
|
621
|
+
# @raise [MCPClient::Errors::MCPError] if the URI is no longer being watched
|
|
622
|
+
def recheck_mapped_resource_subscription(subscription, uri)
|
|
623
|
+
require_resource_watch(subscription, uri)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Drop a URI's mapping, but only while it still names this stream.
|
|
627
|
+
# @param subscription [MCPClient::Subscription]
|
|
628
|
+
# @param uri [String] the resource URI
|
|
629
|
+
# @return [void]
|
|
630
|
+
def unmap_resource_subscription(subscription, uri)
|
|
631
|
+
subscriptions_mutex.synchronize do
|
|
632
|
+
resource_subscriptions.delete(uri) if resource_subscriptions[uri].equal?(subscription)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Wait for the acknowledgment and check that it really covers the URI: a
|
|
637
|
+
# server MAY acknowledge a subset of the filter it was sent.
|
|
638
|
+
# @param subscription [MCPClient::Subscription]
|
|
639
|
+
# @param uri [String] the resource URI
|
|
640
|
+
# @return [void]
|
|
641
|
+
# @raise [MCPClient::Errors::MCPError] if the URI is not being watched
|
|
642
|
+
def confirm_resource_subscription(subscription, uri)
|
|
643
|
+
require_resource_watch(subscription, uri)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Wait for the server's word on this URI to stand, and demand that it
|
|
647
|
+
# watch it.
|
|
648
|
+
#
|
|
649
|
+
# The word that stands is the acknowledgment of the listen request the
|
|
650
|
+
# stream is currently on. A re-open clears it — the server holds no
|
|
651
|
+
# subscription state across one and has to grant the filter again — so a
|
|
652
|
+
# replacement in flight is waited for rather than read as an answer.
|
|
653
|
+
# Reading it as one is what used to report a watch nobody had made: a
|
|
654
|
+
# subscription with no acknowledgment has no unacknowledged URIs either,
|
|
655
|
+
# so "the URI is not missing from the acknowledgment" passed for
|
|
656
|
+
# "the server is watching it".
|
|
657
|
+
# @param subscription [MCPClient::Subscription]
|
|
658
|
+
# @param uri [String] the resource URI
|
|
659
|
+
# @return [void]
|
|
660
|
+
# @raise [MCPClient::Errors::MCPError] if the URI is not being watched
|
|
661
|
+
def require_resource_watch(subscription, uri)
|
|
662
|
+
case subscription.await_resource_watch(uri, subscription_ack_timeout)
|
|
663
|
+
when :watching
|
|
664
|
+
nil
|
|
665
|
+
when :not_watching
|
|
666
|
+
raise MCPClient::Errors::ResourceReadError,
|
|
667
|
+
"the server acknowledged the subscription without '#{uri}'"
|
|
668
|
+
when :closed
|
|
669
|
+
raise subscription.error if subscription.error
|
|
670
|
+
|
|
671
|
+
raise MCPClient::Errors::ResourceReadError, "the server closed the subscription for '#{uri}'"
|
|
672
|
+
else
|
|
673
|
+
raise MCPClient::Errors::ResourceReadError,
|
|
674
|
+
"timed out after #{subscription_ack_timeout}s waiting for the server to acknowledge '#{uri}'"
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# @return [Numeric] seconds allowed for a resource subscription acknowledgment
|
|
679
|
+
def subscription_ack_timeout
|
|
680
|
+
timeout = defined?(@read_timeout) ? @read_timeout : nil
|
|
681
|
+
timeout.is_a?(Numeric) && timeout.positive? ? timeout : DEFAULT_ACK_TIMEOUT
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# The lock that serializes opening the stream for one URI. Kept for the
|
|
685
|
+
# life of the transport: it is one Mutex per URI the host subscribed to.
|
|
686
|
+
# @param uri [String] the resource URI
|
|
687
|
+
# @return [Mutex]
|
|
688
|
+
def resource_subscription_mutex(uri)
|
|
689
|
+
subscriptions_mutex.synchronize { resource_subscription_mutexes[uri] ||= Mutex.new }
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# @return [Hash{String => Mutex}]
|
|
693
|
+
def resource_subscription_mutexes
|
|
694
|
+
@resource_subscription_mutexes ||= {}
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# @param uri [String] the resource URI
|
|
698
|
+
# @return [MCPClient::Subscription, nil] the subscription that was closed, if any
|
|
699
|
+
def unsubscribe_resource_via_listen(uri)
|
|
700
|
+
# Behind the same per-URI lock as subscribing, so an unsubscribe cannot
|
|
701
|
+
# slip between a subscribe's acknowledgment and its registration and
|
|
702
|
+
# leave the stream running.
|
|
703
|
+
resource_subscription_mutex(uri).synchronize do
|
|
704
|
+
subscription = subscriptions_mutex.synchronize { resource_subscriptions.delete(uri) }
|
|
705
|
+
subscription&.close
|
|
706
|
+
subscription
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# @return [Hash{String => MCPClient::Subscription}] listen streams opened by subscribe_resource
|
|
711
|
+
def resource_subscriptions
|
|
712
|
+
@resource_subscriptions ||= {}
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|