ruby-mcp-client 2.1.0 → 3.0.0

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